diff --git a/.github/workflows/admin.yml b/.github/workflows/admin.yml index ae18109ca02..49ab50ee632 100644 --- a/.github/workflows/admin.yml +++ b/.github/workflows/admin.yml @@ -46,7 +46,7 @@ jobs: cache: false - name: Enable Go build cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-${{ github.ref }}-${{ hashFiles('**') }} @@ -55,7 +55,7 @@ jobs: ${{ runner.os }}-go-build- - name: Enable Go modules cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/agent.yml b/.github/workflows/agent.yml index deddc9f1565..5b5f9fd300b 100644 --- a/.github/workflows/agent.yml +++ b/.github/workflows/agent.yml @@ -80,7 +80,7 @@ jobs: cache: false - name: Enable Go build cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-${{ github.ref }}-${{ hashFiles('**') }} @@ -89,7 +89,7 @@ jobs: ${{ runner.os }}-go-build- - name: Enable Go modules cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml index 2bc4c09ec96..eb5882ae91f 100644 --- a/.github/workflows/clean.yml +++ b/.github/workflows/clean.yml @@ -38,14 +38,14 @@ jobs: lfs: true - name: Enable Go modules cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }} restore-keys: ${{ runner.os }}-go-modules- - name: Enable Go build cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-${{ github.ref }}-${{ hashFiles('**') }} diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml index 780361e829e..d9347fcacdd 100644 --- a/.github/workflows/dependabot.yml +++ b/.github/workflows/dependabot.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # v2.4.0 + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index be9b1fb1db4..2bc60a3ecc7 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -37,7 +37,7 @@ jobs: ref: ${{ github.event.inputs.branch }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to ghcr.io registry uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2fd1cfd7fd6..3f80d3b15f1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: cache: false - name: Enable Go build cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-${{ github.ref }}-${{ hashFiles('**') }} @@ -39,7 +39,7 @@ jobs: ${{ runner.os }}-go-build- - name: Enable Go modules cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }} @@ -116,7 +116,7 @@ jobs: env: REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.ROBOT_TOKEN || secrets.GITHUB_TOKEN }} run: | - if out=$(bin/go-consistent -pedantic -exclude "tests" ./...); exit_code=$?; [ $exit_code -eq 0 ]; then + if out=$(bin/go-consistent -pedantic -exclude "tests|api/agent" ./...); exit_code=$?; [ $exit_code -eq 0 ]; then echo "$out" exit 0 fi @@ -126,7 +126,7 @@ jobs: exit $exit_code fi - echo "$out" | bin/reviewdog -f=go-consistent -reporter=github-pr-review -fail-on-error + echo "$out" | bin/reviewdog -f=go-consistent -reporter=github-pr-review -fail-level=error - name: Test common API run: make test-common @@ -147,7 +147,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check spelling of md files - uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0 + uses: crate-ci/typos@5c19779cb52ea50e151f5a10333ccd269227b5ae # v1.41.0 with: files: "**/*.md ./documentation/**/*.md" diff --git a/.github/workflows/managed.yml b/.github/workflows/managed.yml index 4962457e6a9..564653909d0 100644 --- a/.github/workflows/managed.yml +++ b/.github/workflows/managed.yml @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Enable Go build and module cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: | ~/go-build-cache diff --git a/.github/workflows/qan-api2.yml b/.github/workflows/qan-api2.yml index 21bc288090c..1bba4929f6d 100644 --- a/.github/workflows/qan-api2.yml +++ b/.github/workflows/qan-api2.yml @@ -46,7 +46,7 @@ jobs: cache: false - name: Enable Go build cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-${{ github.ref }}-${{ hashFiles('**') }} @@ -55,7 +55,7 @@ jobs: ${{ runner.os }}-go-build- - name: Enable Go modules cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 69d138f184b..f7e8cdd6747 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -13,13 +13,13 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Create SBOM for PMM - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + uses: anchore/sbom-action@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 with: file: go.mod artifact-name: pmm.spdx.json - name: Publish SBOM for PMM - uses: anchore/sbom-action/publish-sbom@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + uses: anchore/sbom-action/publish-sbom@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 with: sbom-artifact-match: ".*\\.spdx\\.json$" @@ -30,12 +30,12 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Create SBOM for vmproxy - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + uses: anchore/sbom-action@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 with: path: ./vmproxy artifact-name: vmproxy.spdx.json - name: Publish SBOM for vmproxy - uses: anchore/sbom-action/publish-sbom@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + uses: anchore/sbom-action/publish-sbom@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 with: sbom-artifact-match: ".*\\.spdx\\.json$" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 6e583f067bb..d34a42b367c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -43,6 +43,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 + uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: sarif_file: results.sarif diff --git a/.github/workflows/vmproxy.yml b/.github/workflows/vmproxy.yml index ec282d0bd35..14d00845211 100644 --- a/.github/workflows/vmproxy.yml +++ b/.github/workflows/vmproxy.yml @@ -46,7 +46,7 @@ jobs: cache: false - name: Enable Go build cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-${{ github.ref }}-${{ hashFiles('**') }} @@ -55,7 +55,7 @@ jobs: ${{ runner.os }}-go-build- - name: Enable Go modules cache - uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }} diff --git a/.golangci.yml b/.golangci.yml index 29c8586a2df..bb8f420936f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -105,6 +105,7 @@ linters: - nonamedreturns # not critical for tests, albeit desirable - testpackage # senseless - unused # very annoying false positive: https://github.com/golangci/golangci-lint/issues/791 + - unusedwrite # not critical for tests path: _test\.go - linters: - recvcheck diff --git a/.mockery.yaml b/.mockery.yaml index 57900b2822a..e8b4ae96c9e 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -1,6 +1,7 @@ # This is the top-level configuration file for mockery. disable-version-string: True with-expecter: False +resolve-type-alias: False inpackage: True dir: "{{.InterfaceDir}}" filename: "mock_{{ .InterfaceName | snakecase }}_test.go" @@ -21,9 +22,6 @@ packages: github.com/percona/pmm/managed/services/checks: interfaces: agentsRegistry: - github.com/percona/pmm/managed/services/grafana: - interfaces: - awsInstanceChecker: github.com/percona/pmm/managed/services/inventory: interfaces: agentService: @@ -69,10 +67,8 @@ packages: interfaces: agentsStateUpdater: checksService: - emailer: grafanaClient: prometheusService: - rulesService: supervisordService: telemetryService: templatesService: @@ -83,6 +79,9 @@ packages: Versioner: config: mockname: "Mock{{ .InterfaceName | camelcase | firstUpper }}" + github.com/percona/pmm/managed/services/victoriametrics: + interfaces: + haService: github.com/percona/pmm/managed/services/telemetry: interfaces: DataSource: @@ -90,11 +89,6 @@ packages: distributionUtilService: sender: # admin - github.com/percona/pmm/admin/commands/pmm/server/docker: - interfaces: - Functions: - config: - mockname: "Mock{{ .InterfaceName | camelcase | firstUpper }}" # agent github.com/percona/pmm/agent/agentlocal: interfaces: diff --git a/code-of-conduct.md b/CODE_OF_CONDUCT.md similarity index 100% rename from code-of-conduct.md rename to CODE_OF_CONDUCT.md diff --git a/Makefile.include b/Makefile.include index dd39096e746..93c644a356a 100644 --- a/Makefile.include +++ b/Makefile.include @@ -36,7 +36,7 @@ gen: clean ## Generate files make format ## TODO: One formatting run is not enough, figure out why. go install -v ./... -clean: ## Remove generated files +clean: ## Remove generated files make -C api clean gen-mocks: @@ -53,9 +53,9 @@ check: ## Run required checkers and linters bin/buf lint -v api LOG_LEVEL=error bin/golangci-lint run bin/go-sumtype ./... - bin/go-consistent -pedantic ./... + bin/go-consistent -pedantic -exclude "tests|api/agent" ./... -check-license: ## Run license header checks against source files +check-license: ## Run license header checks against source files bin/license-eye -c .licenserc.yaml header check check-all: check-license check ## Run golangci linter to check for changes against main diff --git a/README.md b/README.md index 20e94112e7e..8f4e0451c7a 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ ## Percona Monitoring and Management -A **single pane of glass** to easily view and monitor the performance of your MySQL, MongoDB, PostgreSQL, and MariaDB databases. +A **single pane of glass** to easily view and monitor the performance of your MySQL, MongoDB, PostgreSQL, Valkey, and Redis databases. -[Percona Monitoring and Management (PMM)](https://www.percona.com/software/database-tools/percona-monitoring-and-management) is a best-of-breed open source database monitoring solution. It helps you reduce complexity, optimize performance, and improve the security of your business-critical database environments, no matter where they are located or deployed. +[Percona Monitoring and Management (PMM)](https://www.percona.com/software/database-tools/percona-monitoring-and-management) is the best-of-breed open source database monitoring solution. It helps you reduce complexity, optimize performance, and improve the security of your business-critical database environments, no matter where they are located or deployed. PMM helps users to: * Reduce Complexity * Optimize Database Performance diff --git a/admin/commands/summary.go b/admin/commands/summary.go index 37dfe624ebd..83bb3225c2f 100644 --- a/admin/commands/summary.go +++ b/admin/commands/summary.go @@ -411,6 +411,6 @@ func (cmd *SummaryCommand) RunCmdWithContext(ctx context.Context, globals *flags // register command. var ( hostname, _ = os.Hostname() - filename = fmt.Sprintf("summary_%s_%s.zip", + filename = fmt.Sprintf("/tmp/summary_%s_%s.zip", strings.ReplaceAll(hostname, ".", "_"), time.Now().Format("2006_01_02_15_04_05")) ) diff --git a/admin/commands/summary_test.go b/admin/commands/summary_test.go index 8476bd6ed26..26b5ad0775c 100644 --- a/admin/commands/summary_test.go +++ b/admin/commands/summary_test.go @@ -16,7 +16,6 @@ package commands import ( "archive/zip" - "context" "os" "path/filepath" "testing" @@ -29,12 +28,12 @@ import ( ) func TestSummary(t *testing.T) { - agentlocal.SetTransport(context.TODO(), true, agentlocal.DefaultPMMAgentListenPort) + agentlocal.SetTransport(t.Context(), true, agentlocal.DefaultPMMAgentListenPort) - f, err := os.CreateTemp("", "pmm-admin-test-summary") + f, err := os.CreateTemp("", "pmm-admin-test-summary-*.zip") //nolint:usetesting require.NoError(t, err) filename := f.Name() - t.Log(filename) + t.Logf("Using temp file: %s", filename) defer os.Remove(filename) //nolint:errcheck assert.NoError(t, f.Close()) @@ -43,7 +42,7 @@ func TestSummary(t *testing.T) { cmd := &SummaryCommand{ Filename: filename, } - res, err := cmd.RunCmdWithContext(context.TODO(), &flags.GlobalFlags{}) + res, err := cmd.RunCmdWithContext(t.Context(), &flags.GlobalFlags{}) require.NoError(t, err) expected := &summaryResult{ Filename: filename, @@ -56,7 +55,7 @@ func TestSummary(t *testing.T) { Filename: filename, SkipServer: true, } - res, err := cmd.RunCmdWithContext(context.TODO(), &flags.GlobalFlags{}) + res, err := cmd.RunCmdWithContext(t.Context(), &flags.GlobalFlags{}) require.NoError(t, err) expected := &summaryResult{ Filename: filename, @@ -74,7 +73,7 @@ func TestSummary(t *testing.T) { SkipServer: true, Pprof: true, } - res, err := cmd.RunCmdWithContext(context.TODO(), &flags.GlobalFlags{}) + res, err := cmd.RunCmdWithContext(t.Context(), &flags.GlobalFlags{}) require.NoError(t, err) expected := &summaryResult{ Filename: filename, diff --git a/agent/config/config.go b/agent/config/config.go index b87d278133f..aefef1c98b9 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -262,7 +262,7 @@ func get(args []string, cfg *Config, l *logrus.Entry) (string, error) { //nolint if cfg.Paths.TempDir == "" { cfg.Paths.TempDir = filepath.Join(cfg.Paths.PathsBase, agentTmpPath) - l.Infof("Temporary directory is not configured and will be set to %s", cfg.Paths.TempDir) + l.Infof("Temporary directory will default to %s", cfg.Paths.TempDir) } if cfg.Paths.NomadDataDir == "" { diff --git a/agent/connectionuptime/service.go b/agent/connectionuptime/service.go index 9ae64b2f148..b831bed8130 100644 --- a/agent/connectionuptime/service.go +++ b/agent/connectionuptime/service.go @@ -102,6 +102,7 @@ func (c *Service) removeFirstElementsUntilIndex(i int) { func (c *Service) RunCleanupGoroutine(ctx context.Context) { go func() { ticker := time.NewTicker(periodForRunningDeletingOldEvents) + defer ticker.Stop() for { select { case <-ticker.C: diff --git a/api-tests/inventory/nodes_test.go b/api-tests/inventory/nodes_test.go index 1d330b808c6..133eb19e444 100644 --- a/api-tests/inventory/nodes_test.go +++ b/api-tests/inventory/nodes_test.go @@ -185,7 +185,7 @@ func TestGenericNode(t *testing.T) { // Check for duplicates. res, err = client.Default.NodesService.AddNode(params) - pmmapitests.AssertAPIErrorf(t, err, 409, codes.AlreadyExists, "Node with name %q already exists.", nodeName) + pmmapitests.AssertAPIErrorf(t, err, 409, codes.AlreadyExists, "Node with name %s already exists.", nodeName) if !assert.Nil(t, res) { pmmapitests.RemoveNodes(t, res.Payload.Generic.NodeID) } @@ -250,7 +250,7 @@ func TestContainerNode(t *testing.T) { // Check for duplicates. res, err = client.Default.NodesService.AddNode(params) - pmmapitests.AssertAPIErrorf(t, err, 409, codes.AlreadyExists, "Node with name %q already exists.", nodeName) + pmmapitests.AssertAPIErrorf(t, err, 409, codes.AlreadyExists, "Node with name %s already exists.", nodeName) if !assert.Nil(t, res) { pmmapitests.RemoveNodes(t, res.Payload.Container.NodeID) } @@ -314,7 +314,7 @@ func TestRemoteNode(t *testing.T) { // Check duplicates. res, err = client.Default.NodesService.AddNode(params) - pmmapitests.AssertAPIErrorf(t, err, 409, codes.AlreadyExists, "Node with name %q already exists.", nodeName) + pmmapitests.AssertAPIErrorf(t, err, 409, codes.AlreadyExists, "Node with name %s already exists.", nodeName) if !assert.Nil(t, res) { pmmapitests.RemoveNodes(t, res.Payload.Remote.NodeID) } diff --git a/api-tests/management/nodes_test.go b/api-tests/management/nodes_test.go index f8cf1e48a58..403eaeaaf77 100644 --- a/api-tests/management/nodes_test.go +++ b/api-tests/management/nodes_test.go @@ -80,7 +80,7 @@ func TestNodeRegister(t *testing.T) { Body: body, } _, err := client.Default.ManagementService.RegisterNode(¶ms) - wantErr := fmt.Sprintf("Node with name %q already exists.", nodeName) + wantErr := fmt.Sprintf("Node with name %s already exists.", nodeName) pmmapitests.AssertAPIErrorf(t, err, 409, codes.AlreadyExists, wantErr) }) diff --git a/api-tests/tools/go.mod b/api-tests/tools/go.mod index 9d1077686d0..62d51a3b79a 100644 --- a/api-tests/tools/go.mod +++ b/api-tests/tools/go.mod @@ -1,5 +1,5 @@ module tools -go 1.25.4 +go 1.25.5 require github.com/jstemmer/go-junit-report v1.0.0 diff --git a/api/Makefile b/api/Makefile index 1a43e1fe5dd..b953c4690f1 100644 --- a/api/Makefile +++ b/api/Makefile @@ -21,7 +21,8 @@ gen: ## Generate PMM API dump/v1beta1 \ accesscontrol/v1beta1 \ qan/v1 \ - platform/v1"; \ + platform/v1 \ + ha/v1beta1"; \ for API in $$SPECS; do \ set -x ; \ ../bin/swagger mixin $$API/json/header.json $$API/*.swagger.json --output=$$API/json/$$(basename $$API).json --keep-spec-order; \ @@ -54,7 +55,8 @@ gen: ## Generate PMM API advisors/v1/json/v1.json \ backup/v1/json/v1.json \ qan/v1/json/v1.json \ - platform/v1/json/v1.json + platform/v1/json/v1.json \ + ha/v1beta1/json/v1beta1.json ../bin/swagger validate swagger/swagger.json # It looks like the order is already correct, so no need to run this @@ -75,7 +77,8 @@ gen: ## Generate PMM API dump/v1beta1/json/v1beta1.json \ accesscontrol/v1beta1/json/v1beta1.json \ qan/v1/json/v1.json \ - platform/v1/json/v1.json + platform/v1/json/v1.json \ + ha/v1beta1/json/v1beta1.json ../bin/swagger validate swagger/swagger-dev.json @@ -120,7 +123,8 @@ clean: clean-swagger ## Remove generated files dump/v1beta1 \ accesscontrol/v1beta1 \ qan/v1 \ - platform/v1"; \ + platform/v1 \ + ha/v1beta1"; \ for API in $$SPECS; do \ rm -fr $$API/json/client $$API/json/models $$API/json/$$(basename $$API).json ; \ done diff --git a/api/agent/v1/query_test.go b/api/agent/v1/query_test.go index 1cedbe65014..03c0105a9dd 100644 --- a/api/agent/v1/query_test.go +++ b/api/agent/v1/query_test.go @@ -46,8 +46,8 @@ func TestQuerySQLResultsSerialization(t *testing.T) { int64(-1), uint64(1), float64(7.42), - "\x00\x01\xfe\xff", - []byte{0x00, 0x01, 0xfe, 0xff}, + "\x00\x01\xFE\xFF", + []byte{0x00, 0x01, 0xFE, 0xFF}, now, []interface{}{int64(1), int64(2), int64(3)}, map[string]interface{}{"k": int64(42)}, @@ -92,8 +92,8 @@ func TestQuerySQLResultsSerialization(t *testing.T) { "int64": int64(-1), "uint64": uint64(1), "double": float64(7.42), - "string": "\x00\x01\xfe\xff", - "bytes": "\x00\x01\xfe\xff", + "string": "\x00\x01\xFE\xFF", + "bytes": "\x00\x01\xFE\xFF", "time": now, "slice": []interface{}{int64(1), int64(2), int64(3)}, "map": map[string]interface{}{"k": int64(42)}, @@ -159,7 +159,7 @@ func TestQueryDocsResultsSerialization(t *testing.T) { "int64": int64(-1), "uint64": uint64(1), "double": float64(7.42), - "string": "\x00\x01\xfe\xff", + "string": "\x00\x01\xFE\xFF", "time": now, "slice": []interface{}{int64(1), int64(2), int64(3)}, "map": map[string]interface{}{"k": int64(42)}, @@ -196,7 +196,7 @@ func TestQueryDocsResultsSerialization(t *testing.T) { "int": int(-1), "int8": int8(-1), "int16": int16(-1), "int32": int32(-1), "uint": uint(1), "uint8": uint8(1), "uint16": uint16(1), "uint32": uint32(1), "double": float32(7.42), - "bytes1": []byte("funyarinpa"), "bytes2": []byte{0x00, 0x01, 0xfe, 0xff}, + "bytes1": []byte("funyarinpa"), "bytes2": []byte{0x00, 0x01, 0xFE, 0xFF}, "mongoTimestamp": primitive.Timestamp{T: uint32(now.Unix()), I: 42}, "mongoDateTime": primitive.NewDateTimeFromTime(now), "slice": []int{1, 2, 3}, @@ -226,7 +226,7 @@ func TestQueryDocsResultsSerialization(t *testing.T) { "int": int64(-1), "int8": int64(-1), "int16": int64(-1), "int32": int64(-1), "uint": uint64(1), "uint8": uint64(1), "uint16": uint64(1), "uint32": uint64(1), "double": float64(7.420000076293945), - "bytes1": "funyarinpa", "bytes2": "\x00\x01\xfe\xff", + "bytes1": "funyarinpa", "bytes2": "\x00\x01\xFE\xFF", "mongoTimestamp": now.Truncate(time.Second).Add(42 * time.Nanosecond), // resolution is up to a second; cram I (ordinal) into nanoseconds "mongoDateTime": now.Truncate(time.Millisecond), // resolution is up to a millisecond "slice": []interface{}{int64(1), int64(2), int64(3)}, diff --git a/api/ha/v1beta1/ha.pb.go b/api/ha/v1beta1/ha.pb.go new file mode 100644 index 00000000000..ca2806a08ae --- /dev/null +++ b/api/ha/v1beta1/ha.pb.go @@ -0,0 +1,392 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: ha/v1beta1/ha.proto + +package hav1beta1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// NodeRole represents the role of a node in the HA cluster. +type NodeRole int32 + +const ( + NodeRole_NODE_ROLE_UNSPECIFIED NodeRole = 0 + NodeRole_NODE_ROLE_LEADER NodeRole = 1 + NodeRole_NODE_ROLE_FOLLOWER NodeRole = 2 +) + +// Enum value maps for NodeRole. +var ( + NodeRole_name = map[int32]string{ + 0: "NODE_ROLE_UNSPECIFIED", + 1: "NODE_ROLE_LEADER", + 2: "NODE_ROLE_FOLLOWER", + } + NodeRole_value = map[string]int32{ + "NODE_ROLE_UNSPECIFIED": 0, + "NODE_ROLE_LEADER": 1, + "NODE_ROLE_FOLLOWER": 2, + } +) + +func (x NodeRole) Enum() *NodeRole { + p := new(NodeRole) + *p = x + return p +} + +func (x NodeRole) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NodeRole) Descriptor() protoreflect.EnumDescriptor { + return file_ha_v1beta1_ha_proto_enumTypes[0].Descriptor() +} + +func (NodeRole) Type() protoreflect.EnumType { + return &file_ha_v1beta1_ha_proto_enumTypes[0] +} + +func (x NodeRole) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NodeRole.Descriptor instead. +func (NodeRole) EnumDescriptor() ([]byte, []int) { + return file_ha_v1beta1_ha_proto_rawDescGZIP(), []int{0} +} + +type ListNodesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNodesRequest) Reset() { + *x = ListNodesRequest{} + mi := &file_ha_v1beta1_ha_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNodesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNodesRequest) ProtoMessage() {} + +func (x *ListNodesRequest) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1beta1_ha_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNodesRequest.ProtoReflect.Descriptor instead. +func (*ListNodesRequest) Descriptor() ([]byte, []int) { + return file_ha_v1beta1_ha_proto_rawDescGZIP(), []int{0} +} + +// HANode represents a single node in the HA cluster. +type HANode struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Human-readable name of the node. + NodeName string `protobuf:"bytes,1,opt,name=node_name,json=nodeName,proto3" json:"node_name,omitempty"` + // Role of the node in the cluster. + Role NodeRole `protobuf:"varint,2,opt,name=role,proto3,enum=ha.v1beta1.NodeRole" json:"role,omitempty"` + // Current status of the node from MemberList. + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HANode) Reset() { + *x = HANode{} + mi := &file_ha_v1beta1_ha_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HANode) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HANode) ProtoMessage() {} + +func (x *HANode) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1beta1_ha_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HANode.ProtoReflect.Descriptor instead. +func (*HANode) Descriptor() ([]byte, []int) { + return file_ha_v1beta1_ha_proto_rawDescGZIP(), []int{1} +} + +func (x *HANode) GetNodeName() string { + if x != nil { + return x.NodeName + } + return "" +} + +func (x *HANode) GetRole() NodeRole { + if x != nil { + return x.Role + } + return NodeRole_NODE_ROLE_UNSPECIFIED +} + +func (x *HANode) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +type ListNodesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // List of nodes in the HA cluster. + Nodes []*HANode `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNodesResponse) Reset() { + *x = ListNodesResponse{} + mi := &file_ha_v1beta1_ha_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNodesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNodesResponse) ProtoMessage() {} + +func (x *ListNodesResponse) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1beta1_ha_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNodesResponse.ProtoReflect.Descriptor instead. +func (*ListNodesResponse) Descriptor() ([]byte, []int) { + return file_ha_v1beta1_ha_proto_rawDescGZIP(), []int{2} +} + +func (x *ListNodesResponse) GetNodes() []*HANode { + if x != nil { + return x.Nodes + } + return nil +} + +type StatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusRequest) Reset() { + *x = StatusRequest{} + mi := &file_ha_v1beta1_ha_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusRequest) ProtoMessage() {} + +func (x *StatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1beta1_ha_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. +func (*StatusRequest) Descriptor() ([]byte, []int) { + return file_ha_v1beta1_ha_proto_rawDescGZIP(), []int{3} +} + +type StatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Status of HA mode: "Enabled" or "Disabled". + Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusResponse) Reset() { + *x = StatusResponse{} + mi := &file_ha_v1beta1_ha_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusResponse) ProtoMessage() {} + +func (x *StatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1beta1_ha_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. +func (*StatusResponse) Descriptor() ([]byte, []int) { + return file_ha_v1beta1_ha_proto_rawDescGZIP(), []int{4} +} + +func (x *StatusResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +var File_ha_v1beta1_ha_proto protoreflect.FileDescriptor + +const file_ha_v1beta1_ha_proto_rawDesc = "" + + "\n" + + "\x13ha/v1beta1/ha.proto\x12\n" + + "ha.v1beta1\x1a\x1cgoogle/api/annotations.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\x12\n" + + "\x10ListNodesRequest\"g\n" + + "\x06HANode\x12\x1b\n" + + "\tnode_name\x18\x01 \x01(\tR\bnodeName\x12(\n" + + "\x04role\x18\x02 \x01(\x0e2\x14.ha.v1beta1.NodeRoleR\x04role\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\"=\n" + + "\x11ListNodesResponse\x12(\n" + + "\x05nodes\x18\x01 \x03(\v2\x12.ha.v1beta1.HANodeR\x05nodes\"\x0f\n" + + "\rStatusRequest\"(\n" + + "\x0eStatusResponse\x12\x16\n" + + "\x06status\x18\x01 \x01(\tR\x06status*S\n" + + "\bNodeRole\x12\x19\n" + + "\x15NODE_ROLE_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10NODE_ROLE_LEADER\x10\x01\x12\x16\n" + + "\x12NODE_ROLE_FOLLOWER\x10\x022\x89\x03\n" + + "\tHAService\x12\xa4\x01\n" + + "\x06Status\x12\x19.ha.v1beta1.StatusRequest\x1a\x1a.ha.v1beta1.StatusResponse\"c\x92AK\x12\tHA Status\x1a>Returns whether High Availability mode is enabled or disabled.\x82\xd3\xe4\x93\x02\x0f\x12\r/v1/ha/status\x12\xd4\x01\n" + + "\tListNodes\x12\x1c.ha.v1beta1.ListNodesRequest\x1a\x1d.ha.v1beta1.ListNodesResponse\"\x89\x01\x92Ar\x12\rList HA Nodes\x1aaReturns a list of all nodes in the High Availability cluster with their current status and roles.\x82\xd3\xe4\x93\x02\x0e\x12\f/v1/ha/nodesB\x93\x01\n" + + "\x0ecom.ha.v1beta1B\aHaProtoP\x01Z/github.com/percona/pmm/api/ha/v1beta1;hav1beta1\xa2\x02\x03HXX\xaa\x02\n" + + "Ha.V1beta1\xca\x02\n" + + "Ha\\V1beta1\xe2\x02\x16Ha\\V1beta1\\GPBMetadata\xea\x02\vHa::V1beta1b\x06proto3" + +var ( + file_ha_v1beta1_ha_proto_rawDescOnce sync.Once + file_ha_v1beta1_ha_proto_rawDescData []byte +) + +func file_ha_v1beta1_ha_proto_rawDescGZIP() []byte { + file_ha_v1beta1_ha_proto_rawDescOnce.Do(func() { + file_ha_v1beta1_ha_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ha_v1beta1_ha_proto_rawDesc), len(file_ha_v1beta1_ha_proto_rawDesc))) + }) + return file_ha_v1beta1_ha_proto_rawDescData +} + +var ( + file_ha_v1beta1_ha_proto_enumTypes = make([]protoimpl.EnumInfo, 1) + file_ha_v1beta1_ha_proto_msgTypes = make([]protoimpl.MessageInfo, 5) + file_ha_v1beta1_ha_proto_goTypes = []any{ + (NodeRole)(0), // 0: ha.v1beta1.NodeRole + (*ListNodesRequest)(nil), // 1: ha.v1beta1.ListNodesRequest + (*HANode)(nil), // 2: ha.v1beta1.HANode + (*ListNodesResponse)(nil), // 3: ha.v1beta1.ListNodesResponse + (*StatusRequest)(nil), // 4: ha.v1beta1.StatusRequest + (*StatusResponse)(nil), // 5: ha.v1beta1.StatusResponse + } +) + +var file_ha_v1beta1_ha_proto_depIdxs = []int32{ + 0, // 0: ha.v1beta1.HANode.role:type_name -> ha.v1beta1.NodeRole + 2, // 1: ha.v1beta1.ListNodesResponse.nodes:type_name -> ha.v1beta1.HANode + 4, // 2: ha.v1beta1.HAService.Status:input_type -> ha.v1beta1.StatusRequest + 1, // 3: ha.v1beta1.HAService.ListNodes:input_type -> ha.v1beta1.ListNodesRequest + 5, // 4: ha.v1beta1.HAService.Status:output_type -> ha.v1beta1.StatusResponse + 3, // 5: ha.v1beta1.HAService.ListNodes:output_type -> ha.v1beta1.ListNodesResponse + 4, // [4:6] is the sub-list for method output_type + 2, // [2:4] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_ha_v1beta1_ha_proto_init() } +func file_ha_v1beta1_ha_proto_init() { + if File_ha_v1beta1_ha_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ha_v1beta1_ha_proto_rawDesc), len(file_ha_v1beta1_ha_proto_rawDesc)), + NumEnums: 1, + NumMessages: 5, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ha_v1beta1_ha_proto_goTypes, + DependencyIndexes: file_ha_v1beta1_ha_proto_depIdxs, + EnumInfos: file_ha_v1beta1_ha_proto_enumTypes, + MessageInfos: file_ha_v1beta1_ha_proto_msgTypes, + }.Build() + File_ha_v1beta1_ha_proto = out.File + file_ha_v1beta1_ha_proto_goTypes = nil + file_ha_v1beta1_ha_proto_depIdxs = nil +} diff --git a/api/ha/v1beta1/ha.pb.gw.go b/api/ha/v1beta1/ha.pb.gw.go new file mode 100644 index 00000000000..e3fa3477164 --- /dev/null +++ b/api/ha/v1beta1/ha.pb.gw.go @@ -0,0 +1,211 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: ha/v1beta1/ha.proto + +/* +Package hav1beta1 is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package hav1beta1 + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +func request_HAService_Status_0(ctx context.Context, marshaler runtime.Marshaler, client HAServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.Status(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_HAService_Status_0(ctx context.Context, marshaler runtime.Marshaler, server HAServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + msg, err := server.Status(ctx, &protoReq) + return msg, metadata, err +} + +func request_HAService_ListNodes_0(ctx context.Context, marshaler runtime.Marshaler, client HAServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNodesRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.ListNodes(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_HAService_ListNodes_0(ctx context.Context, marshaler runtime.Marshaler, server HAServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNodesRequest + metadata runtime.ServerMetadata + ) + msg, err := server.ListNodes(ctx, &protoReq) + return msg, metadata, err +} + +// RegisterHAServiceHandlerServer registers the http handlers for service HAService to "mux". +// UnaryRPC :call HAServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterHAServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterHAServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server HAServiceServer) error { + mux.Handle(http.MethodGet, pattern_HAService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/ha.v1beta1.HAService/Status", runtime.WithHTTPPathPattern("/v1/ha/status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_HAService_Status_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_HAService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_HAService_ListNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/ha.v1beta1.HAService/ListNodes", runtime.WithHTTPPathPattern("/v1/ha/nodes")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_HAService_ListNodes_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_HAService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterHAServiceHandlerFromEndpoint is same as RegisterHAServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterHAServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterHAServiceHandler(ctx, mux, conn) +} + +// RegisterHAServiceHandler registers the http handlers for service HAService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterHAServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterHAServiceHandlerClient(ctx, mux, NewHAServiceClient(conn)) +} + +// RegisterHAServiceHandlerClient registers the http handlers for service HAService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "HAServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "HAServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "HAServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterHAServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client HAServiceClient) error { + mux.Handle(http.MethodGet, pattern_HAService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/ha.v1beta1.HAService/Status", runtime.WithHTTPPathPattern("/v1/ha/status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_HAService_Status_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_HAService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_HAService_ListNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/ha.v1beta1.HAService/ListNodes", runtime.WithHTTPPathPattern("/v1/ha/nodes")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_HAService_ListNodes_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_HAService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_HAService_Status_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "ha", "status"}, "")) + pattern_HAService_ListNodes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "ha", "nodes"}, "")) +) + +var ( + forward_HAService_Status_0 = runtime.ForwardResponseMessage + forward_HAService_ListNodes_0 = runtime.ForwardResponseMessage +) diff --git a/api/ha/v1beta1/ha.pb.validate.go b/api/ha/v1beta1/ha.pb.validate.go new file mode 100644 index 00000000000..5a55fe5cfe1 --- /dev/null +++ b/api/ha/v1beta1/ha.pb.validate.go @@ -0,0 +1,578 @@ +// Code generated by protoc-gen-validate. DO NOT EDIT. +// source: ha/v1beta1/ha.proto + +package hav1beta1 + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/mail" + "net/url" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "google.golang.org/protobuf/types/known/anypb" +) + +// ensure the imports are used +var ( + _ = bytes.MinRead + _ = errors.New("") + _ = fmt.Print + _ = utf8.UTFMax + _ = (*regexp.Regexp)(nil) + _ = (*strings.Reader)(nil) + _ = net.IPv4len + _ = time.Duration(0) + _ = (*url.URL)(nil) + _ = (*mail.Address)(nil) + _ = anypb.Any{} + _ = sort.Sort +) + +// Validate checks the field values on ListNodesRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ListNodesRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListNodesRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListNodesRequestMultiError, or nil if none found. +func (m *ListNodesRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ListNodesRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return ListNodesRequestMultiError(errors) + } + + return nil +} + +// ListNodesRequestMultiError is an error wrapping multiple validation errors +// returned by ListNodesRequest.ValidateAll() if the designated constraints +// aren't met. +type ListNodesRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListNodesRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListNodesRequestMultiError) AllErrors() []error { return m } + +// ListNodesRequestValidationError is the validation error returned by +// ListNodesRequest.Validate if the designated constraints aren't met. +type ListNodesRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListNodesRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListNodesRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListNodesRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListNodesRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListNodesRequestValidationError) ErrorName() string { return "ListNodesRequestValidationError" } + +// Error satisfies the builtin error interface +func (e ListNodesRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListNodesRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListNodesRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListNodesRequestValidationError{} + +// Validate checks the field values on HANode with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *HANode) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on HANode with the rules defined in the +// proto definition for this message. If any rules are violated, the result is +// a list of violation errors wrapped in HANodeMultiError, or nil if none found. +func (m *HANode) ValidateAll() error { + return m.validate(true) +} + +func (m *HANode) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for NodeName + + // no validation rules for Role + + // no validation rules for Status + + if len(errors) > 0 { + return HANodeMultiError(errors) + } + + return nil +} + +// HANodeMultiError is an error wrapping multiple validation errors returned by +// HANode.ValidateAll() if the designated constraints aren't met. +type HANodeMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m HANodeMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m HANodeMultiError) AllErrors() []error { return m } + +// HANodeValidationError is the validation error returned by HANode.Validate if +// the designated constraints aren't met. +type HANodeValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e HANodeValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e HANodeValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e HANodeValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e HANodeValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e HANodeValidationError) ErrorName() string { return "HANodeValidationError" } + +// Error satisfies the builtin error interface +func (e HANodeValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sHANode.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = HANodeValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = HANodeValidationError{} + +// Validate checks the field values on ListNodesResponse with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ListNodesResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListNodesResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListNodesResponseMultiError, or nil if none found. +func (m *ListNodesResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *ListNodesResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + for idx, item := range m.GetNodes() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ListNodesResponseValidationError{ + field: fmt.Sprintf("Nodes[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ListNodesResponseValidationError{ + field: fmt.Sprintf("Nodes[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ListNodesResponseValidationError{ + field: fmt.Sprintf("Nodes[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + if len(errors) > 0 { + return ListNodesResponseMultiError(errors) + } + + return nil +} + +// ListNodesResponseMultiError is an error wrapping multiple validation errors +// returned by ListNodesResponse.ValidateAll() if the designated constraints +// aren't met. +type ListNodesResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListNodesResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListNodesResponseMultiError) AllErrors() []error { return m } + +// ListNodesResponseValidationError is the validation error returned by +// ListNodesResponse.Validate if the designated constraints aren't met. +type ListNodesResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListNodesResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListNodesResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListNodesResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListNodesResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListNodesResponseValidationError) ErrorName() string { + return "ListNodesResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e ListNodesResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListNodesResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListNodesResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListNodesResponseValidationError{} + +// Validate checks the field values on StatusRequest with the rules defined in +// the proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *StatusRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on StatusRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in StatusRequestMultiError, or +// nil if none found. +func (m *StatusRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *StatusRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return StatusRequestMultiError(errors) + } + + return nil +} + +// StatusRequestMultiError is an error wrapping multiple validation errors +// returned by StatusRequest.ValidateAll() if the designated constraints +// aren't met. +type StatusRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m StatusRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m StatusRequestMultiError) AllErrors() []error { return m } + +// StatusRequestValidationError is the validation error returned by +// StatusRequest.Validate if the designated constraints aren't met. +type StatusRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e StatusRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e StatusRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e StatusRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e StatusRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e StatusRequestValidationError) ErrorName() string { return "StatusRequestValidationError" } + +// Error satisfies the builtin error interface +func (e StatusRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sStatusRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = StatusRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = StatusRequestValidationError{} + +// Validate checks the field values on StatusResponse with the rules defined in +// the proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *StatusResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on StatusResponse with the rules defined +// in the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in StatusResponseMultiError, +// or nil if none found. +func (m *StatusResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *StatusResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Status + + if len(errors) > 0 { + return StatusResponseMultiError(errors) + } + + return nil +} + +// StatusResponseMultiError is an error wrapping multiple validation errors +// returned by StatusResponse.ValidateAll() if the designated constraints +// aren't met. +type StatusResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m StatusResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m StatusResponseMultiError) AllErrors() []error { return m } + +// StatusResponseValidationError is the validation error returned by +// StatusResponse.Validate if the designated constraints aren't met. +type StatusResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e StatusResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e StatusResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e StatusResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e StatusResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e StatusResponseValidationError) ErrorName() string { return "StatusResponseValidationError" } + +// Error satisfies the builtin error interface +func (e StatusResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sStatusResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = StatusResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = StatusResponseValidationError{} diff --git a/api/ha/v1beta1/ha.proto b/api/ha/v1beta1/ha.proto new file mode 100644 index 00000000000..66578818d3d --- /dev/null +++ b/api/ha/v1beta1/ha.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package ha.v1beta1; + +import "google/api/annotations.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +// NodeRole represents the role of a node in the HA cluster. +enum NodeRole { + NODE_ROLE_UNSPECIFIED = 0; + NODE_ROLE_LEADER = 1; + NODE_ROLE_FOLLOWER = 2; +} + +message ListNodesRequest {} + +// HANode represents a single node in the HA cluster. +message HANode { + // Human-readable name of the node. + string node_name = 1; + // Role of the node in the cluster. + NodeRole role = 2; + // Current status of the node from MemberList. + string status = 3; +} + +message ListNodesResponse { + // List of nodes in the HA cluster. + repeated HANode nodes = 1; +} + +message StatusRequest {} + +message StatusResponse { + // Status of HA mode: "Enabled" or "Disabled". + string status = 1; +} + +// HAService provides High Availability cluster status information. +service HAService { + // Status returns the current HA mode status. + rpc Status(StatusRequest) returns (StatusResponse) { + option (google.api.http) = {get: "/v1/ha/status"}; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "HA Status" + description: "Returns whether High Availability mode is enabled or disabled." + }; + } + // ListNodes returns a list of all nodes in the High Availability cluster. + rpc ListNodes(ListNodesRequest) returns (ListNodesResponse) { + option (google.api.http) = {get: "/v1/ha/nodes"}; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List HA Nodes" + description: "Returns a list of all nodes in the High Availability cluster with their current status and roles." + }; + } +} diff --git a/api/ha/v1beta1/ha_grpc.pb.go b/api/ha/v1beta1/ha_grpc.pb.go new file mode 100644 index 00000000000..75e664a29e5 --- /dev/null +++ b/api/ha/v1beta1/ha_grpc.pb.go @@ -0,0 +1,169 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc (unknown) +// source: ha/v1beta1/ha.proto + +package hav1beta1 + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + HAService_Status_FullMethodName = "/ha.v1beta1.HAService/Status" + HAService_ListNodes_FullMethodName = "/ha.v1beta1.HAService/ListNodes" +) + +// HAServiceClient is the client API for HAService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// HAService provides High Availability cluster status information. +type HAServiceClient interface { + // Status returns the current HA mode status. + Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) + // ListNodes returns a list of all nodes in the High Availability cluster. + ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) +} + +type hAServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewHAServiceClient(cc grpc.ClientConnInterface) HAServiceClient { + return &hAServiceClient{cc} +} + +func (c *hAServiceClient) Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StatusResponse) + err := c.cc.Invoke(ctx, HAService_Status_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hAServiceClient) ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListNodesResponse) + err := c.cc.Invoke(ctx, HAService_ListNodes_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// HAServiceServer is the server API for HAService service. +// All implementations must embed UnimplementedHAServiceServer +// for forward compatibility. +// +// HAService provides High Availability cluster status information. +type HAServiceServer interface { + // Status returns the current HA mode status. + Status(context.Context, *StatusRequest) (*StatusResponse, error) + // ListNodes returns a list of all nodes in the High Availability cluster. + ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) + mustEmbedUnimplementedHAServiceServer() +} + +// UnimplementedHAServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedHAServiceServer struct{} + +func (UnimplementedHAServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Status not implemented") +} + +func (UnimplementedHAServiceServer) ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListNodes not implemented") +} +func (UnimplementedHAServiceServer) mustEmbedUnimplementedHAServiceServer() {} +func (UnimplementedHAServiceServer) testEmbeddedByValue() {} + +// UnsafeHAServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to HAServiceServer will +// result in compilation errors. +type UnsafeHAServiceServer interface { + mustEmbedUnimplementedHAServiceServer() +} + +func RegisterHAServiceServer(s grpc.ServiceRegistrar, srv HAServiceServer) { + // If the following call panics, it indicates UnimplementedHAServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&HAService_ServiceDesc, srv) +} + +func _HAService_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HAServiceServer).Status(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HAService_Status_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HAServiceServer).Status(ctx, req.(*StatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HAService_ListNodes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListNodesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HAServiceServer).ListNodes(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HAService_ListNodes_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HAServiceServer).ListNodes(ctx, req.(*ListNodesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// HAService_ServiceDesc is the grpc.ServiceDesc for HAService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var HAService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ha.v1beta1.HAService", + HandlerType: (*HAServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Status", + Handler: _HAService_Status_Handler, + }, + { + MethodName: "ListNodes", + Handler: _HAService_ListNodes_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ha/v1beta1/ha.proto", +} diff --git a/api/ha/v1beta1/json/client/ha_service/ha_service_client.go b/api/ha/v1beta1/json/client/ha_service/ha_service_client.go new file mode 100644 index 00000000000..8f35942b801 --- /dev/null +++ b/api/ha/v1beta1/json/client/ha_service/ha_service_client.go @@ -0,0 +1,155 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ha_service + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/runtime" + httptransport "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// New creates a new ha service API client. +func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { + return &Client{transport: transport, formats: formats} +} + +// New creates a new ha service API client with basic auth credentials. +// It takes the following parameters: +// - host: http host (github.com). +// - basePath: any base path for the API client ("/v1", "/v3"). +// - scheme: http scheme ("http", "https"). +// - user: user for basic authentication header. +// - password: password for basic authentication header. +func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { + transport := httptransport.New(host, basePath, []string{scheme}) + transport.DefaultAuthentication = httptransport.BasicAuth(user, password) + return &Client{transport: transport, formats: strfmt.Default} +} + +// New creates a new ha service API client with a bearer token for authentication. +// It takes the following parameters: +// - host: http host (github.com). +// - basePath: any base path for the API client ("/v1", "/v3"). +// - scheme: http scheme ("http", "https"). +// - bearerToken: bearer token for Bearer authentication header. +func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { + transport := httptransport.New(host, basePath, []string{scheme}) + transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) + return &Client{transport: transport, formats: strfmt.Default} +} + +/* +Client for ha service API +*/ +type Client struct { + transport runtime.ClientTransport + formats strfmt.Registry +} + +// ClientOption may be used to customize the behavior of Client methods. +type ClientOption func(*runtime.ClientOperation) + +// ClientService is the interface for Client methods +type ClientService interface { + ListNodes(params *ListNodesParams, opts ...ClientOption) (*ListNodesOK, error) + + Status(params *StatusParams, opts ...ClientOption) (*StatusOK, error) + + SetTransport(transport runtime.ClientTransport) +} + +/* +ListNodes lists HA nodes + +Returns a list of all nodes in the High Availability cluster with their current status and roles. +*/ +func (a *Client) ListNodes(params *ListNodesParams, opts ...ClientOption) (*ListNodesOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewListNodesParams() + } + op := &runtime.ClientOperation{ + ID: "ListNodes", + Method: "GET", + PathPattern: "/v1/ha/nodes", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &ListNodesReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*ListNodesOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*ListNodesDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +/* +Status HAs status + +Returns whether High Availability mode is enabled or disabled. +*/ +func (a *Client) Status(params *StatusParams, opts ...ClientOption) (*StatusOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewStatusParams() + } + op := &runtime.ClientOperation{ + ID: "Status", + Method: "GET", + PathPattern: "/v1/ha/status", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &StatusReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*StatusOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*StatusDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +// SetTransport changes the transport on the client +func (a *Client) SetTransport(transport runtime.ClientTransport) { + a.transport = transport +} diff --git a/api/ha/v1beta1/json/client/ha_service/list_nodes_parameters.go b/api/ha/v1beta1/json/client/ha_service/list_nodes_parameters.go new file mode 100644 index 00000000000..c74ca68dd4e --- /dev/null +++ b/api/ha/v1beta1/json/client/ha_service/list_nodes_parameters.go @@ -0,0 +1,127 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ha_service + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewListNodesParams creates a new ListNodesParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewListNodesParams() *ListNodesParams { + return &ListNodesParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewListNodesParamsWithTimeout creates a new ListNodesParams object +// with the ability to set a timeout on a request. +func NewListNodesParamsWithTimeout(timeout time.Duration) *ListNodesParams { + return &ListNodesParams{ + timeout: timeout, + } +} + +// NewListNodesParamsWithContext creates a new ListNodesParams object +// with the ability to set a context for a request. +func NewListNodesParamsWithContext(ctx context.Context) *ListNodesParams { + return &ListNodesParams{ + Context: ctx, + } +} + +// NewListNodesParamsWithHTTPClient creates a new ListNodesParams object +// with the ability to set a custom HTTPClient for a request. +func NewListNodesParamsWithHTTPClient(client *http.Client) *ListNodesParams { + return &ListNodesParams{ + HTTPClient: client, + } +} + +/* +ListNodesParams contains all the parameters to send to the API endpoint + + for the list nodes operation. + + Typically these are written to a http.Request. +*/ +type ListNodesParams struct { + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the list nodes params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListNodesParams) WithDefaults() *ListNodesParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the list nodes params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListNodesParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the list nodes params +func (o *ListNodesParams) WithTimeout(timeout time.Duration) *ListNodesParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the list nodes params +func (o *ListNodesParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the list nodes params +func (o *ListNodesParams) WithContext(ctx context.Context) *ListNodesParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the list nodes params +func (o *ListNodesParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the list nodes params +func (o *ListNodesParams) WithHTTPClient(client *http.Client) *ListNodesParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the list nodes params +func (o *ListNodesParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WriteToRequest writes these params to a swagger request +func (o *ListNodesParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/api/ha/v1beta1/json/client/ha_service/list_nodes_responses.go b/api/ha/v1beta1/json/client/ha_service/list_nodes_responses.go new file mode 100644 index 00000000000..8661938e936 --- /dev/null +++ b/api/ha/v1beta1/json/client/ha_service/list_nodes_responses.go @@ -0,0 +1,626 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ha_service + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// ListNodesReader is a Reader for the ListNodes structure. +type ListNodesReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *ListNodesReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewListNodesOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewListNodesDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewListNodesOK creates a ListNodesOK with default headers values +func NewListNodesOK() *ListNodesOK { + return &ListNodesOK{} +} + +/* +ListNodesOK describes a response with status code 200, with default header values. + +A successful response. +*/ +type ListNodesOK struct { + Payload *ListNodesOKBody +} + +// IsSuccess returns true when this list nodes Ok response has a 2xx status code +func (o *ListNodesOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this list nodes Ok response has a 3xx status code +func (o *ListNodesOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this list nodes Ok response has a 4xx status code +func (o *ListNodesOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this list nodes Ok response has a 5xx status code +func (o *ListNodesOK) IsServerError() bool { + return false +} + +// IsCode returns true when this list nodes Ok response a status code equal to that given +func (o *ListNodesOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the list nodes Ok response +func (o *ListNodesOK) Code() int { + return 200 +} + +func (o *ListNodesOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/nodes][%d] listNodesOk %s", 200, payload) +} + +func (o *ListNodesOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/nodes][%d] listNodesOk %s", 200, payload) +} + +func (o *ListNodesOK) GetPayload() *ListNodesOKBody { + return o.Payload +} + +func (o *ListNodesOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(ListNodesOKBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewListNodesDefault creates a ListNodesDefault with default headers values +func NewListNodesDefault(code int) *ListNodesDefault { + return &ListNodesDefault{ + _statusCode: code, + } +} + +/* +ListNodesDefault describes a response with status code -1, with default header values. + +An unexpected error response. +*/ +type ListNodesDefault struct { + _statusCode int + + Payload *ListNodesDefaultBody +} + +// IsSuccess returns true when this list nodes default response has a 2xx status code +func (o *ListNodesDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this list nodes default response has a 3xx status code +func (o *ListNodesDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this list nodes default response has a 4xx status code +func (o *ListNodesDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this list nodes default response has a 5xx status code +func (o *ListNodesDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this list nodes default response a status code equal to that given +func (o *ListNodesDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the list nodes default response +func (o *ListNodesDefault) Code() int { + return o._statusCode +} + +func (o *ListNodesDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/nodes][%d] ListNodes default %s", o._statusCode, payload) +} + +func (o *ListNodesDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/nodes][%d] ListNodes default %s", o._statusCode, payload) +} + +func (o *ListNodesDefault) GetPayload() *ListNodesDefaultBody { + return o.Payload +} + +func (o *ListNodesDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(ListNodesDefaultBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +/* +ListNodesDefaultBody list nodes default body +swagger:model ListNodesDefaultBody +*/ +type ListNodesDefaultBody struct { + // code + Code int32 `json:"code,omitempty"` + + // message + Message string `json:"message,omitempty"` + + // details + Details []*ListNodesDefaultBodyDetailsItems0 `json:"details"` +} + +// Validate validates this list nodes default body +func (o *ListNodesDefaultBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateDetails(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListNodesDefaultBody) validateDetails(formats strfmt.Registry) error { + if swag.IsZero(o.Details) { // not required + return nil + } + + for i := 0; i < len(o.Details); i++ { + if swag.IsZero(o.Details[i]) { // not required + continue + } + + if o.Details[i] != nil { + if err := o.Details[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("ListNodes default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("ListNodes default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this list nodes default body based on the context it is used +func (o *ListNodesDefaultBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateDetails(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListNodesDefaultBody) contextValidateDetails(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Details); i++ { + if o.Details[i] != nil { + + if swag.IsZero(o.Details[i]) { // not required + return nil + } + + if err := o.Details[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("ListNodes default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("ListNodes default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *ListNodesDefaultBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListNodesDefaultBody) UnmarshalBinary(b []byte) error { + var res ListNodesDefaultBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +ListNodesDefaultBodyDetailsItems0 list nodes default body details items0 +swagger:model ListNodesDefaultBodyDetailsItems0 +*/ +type ListNodesDefaultBodyDetailsItems0 struct { + // at type + AtType string `json:"@type,omitempty"` + + // list nodes default body details items0 + ListNodesDefaultBodyDetailsItems0 map[string]any `json:"-"` +} + +// UnmarshalJSON unmarshals this object with additional properties from JSON +func (o *ListNodesDefaultBodyDetailsItems0) UnmarshalJSON(data []byte) error { + // stage 1, bind the properties + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + if err := json.Unmarshal(data, &stage1); err != nil { + return err + } + var rcv ListNodesDefaultBodyDetailsItems0 + + rcv.AtType = stage1.AtType + *o = rcv + + // stage 2, remove properties and add to map + stage2 := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &stage2); err != nil { + return err + } + + delete(stage2, "@type") + // stage 3, add additional properties values + if len(stage2) > 0 { + result := make(map[string]any) + for k, v := range stage2 { + var toadd any + if err := json.Unmarshal(v, &toadd); err != nil { + return err + } + result[k] = toadd + } + o.ListNodesDefaultBodyDetailsItems0 = result + } + + return nil +} + +// MarshalJSON marshals this object with additional properties into a JSON object +func (o ListNodesDefaultBodyDetailsItems0) MarshalJSON() ([]byte, error) { + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + + stage1.AtType = o.AtType + + // make JSON object for known properties + props, err := json.Marshal(stage1) + if err != nil { + return nil, err + } + + if len(o.ListNodesDefaultBodyDetailsItems0) == 0 { // no additional properties + return props, nil + } + + // make JSON object for the additional properties + additional, err := json.Marshal(o.ListNodesDefaultBodyDetailsItems0) + if err != nil { + return nil, err + } + + if len(props) < 3 { // "{}": only additional properties + return additional, nil + } + + // concatenate the 2 objects + return swag.ConcatJSON(props, additional), nil +} + +// Validate validates this list nodes default body details items0 +func (o *ListNodesDefaultBodyDetailsItems0) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this list nodes default body details items0 based on context it is used +func (o *ListNodesDefaultBodyDetailsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *ListNodesDefaultBodyDetailsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListNodesDefaultBodyDetailsItems0) UnmarshalBinary(b []byte) error { + var res ListNodesDefaultBodyDetailsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +ListNodesOKBody list nodes OK body +swagger:model ListNodesOKBody +*/ +type ListNodesOKBody struct { + // List of nodes in the HA cluster. + Nodes []*ListNodesOKBodyNodesItems0 `json:"nodes"` +} + +// Validate validates this list nodes OK body +func (o *ListNodesOKBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateNodes(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListNodesOKBody) validateNodes(formats strfmt.Registry) error { + if swag.IsZero(o.Nodes) { // not required + return nil + } + + for i := 0; i < len(o.Nodes); i++ { + if swag.IsZero(o.Nodes[i]) { // not required + continue + } + + if o.Nodes[i] != nil { + if err := o.Nodes[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("listNodesOk" + "." + "nodes" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("listNodesOk" + "." + "nodes" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this list nodes OK body based on the context it is used +func (o *ListNodesOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateNodes(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListNodesOKBody) contextValidateNodes(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Nodes); i++ { + if o.Nodes[i] != nil { + + if swag.IsZero(o.Nodes[i]) { // not required + return nil + } + + if err := o.Nodes[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("listNodesOk" + "." + "nodes" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("listNodesOk" + "." + "nodes" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *ListNodesOKBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListNodesOKBody) UnmarshalBinary(b []byte) error { + var res ListNodesOKBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +ListNodesOKBodyNodesItems0 HANode represents a single node in the HA cluster. +swagger:model ListNodesOKBodyNodesItems0 +*/ +type ListNodesOKBodyNodesItems0 struct { + // Human-readable name of the node. + NodeName string `json:"node_name,omitempty"` + + // NodeRole represents the role of a node in the HA cluster. + // Enum: ["NODE_ROLE_UNSPECIFIED","NODE_ROLE_LEADER","NODE_ROLE_FOLLOWER"] + Role *string `json:"role,omitempty"` + + // Current status of the node from MemberList. + Status string `json:"status,omitempty"` +} + +// Validate validates this list nodes OK body nodes items0 +func (o *ListNodesOKBodyNodesItems0) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateRole(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +var listNodesOkBodyNodesItems0TypeRolePropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["NODE_ROLE_UNSPECIFIED","NODE_ROLE_LEADER","NODE_ROLE_FOLLOWER"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + listNodesOkBodyNodesItems0TypeRolePropEnum = append(listNodesOkBodyNodesItems0TypeRolePropEnum, v) + } +} + +const ( + + // ListNodesOKBodyNodesItems0RoleNODEROLEUNSPECIFIED captures enum value "NODE_ROLE_UNSPECIFIED" + ListNodesOKBodyNodesItems0RoleNODEROLEUNSPECIFIED string = "NODE_ROLE_UNSPECIFIED" + + // ListNodesOKBodyNodesItems0RoleNODEROLELEADER captures enum value "NODE_ROLE_LEADER" + ListNodesOKBodyNodesItems0RoleNODEROLELEADER string = "NODE_ROLE_LEADER" + + // ListNodesOKBodyNodesItems0RoleNODEROLEFOLLOWER captures enum value "NODE_ROLE_FOLLOWER" + ListNodesOKBodyNodesItems0RoleNODEROLEFOLLOWER string = "NODE_ROLE_FOLLOWER" +) + +// prop value enum +func (o *ListNodesOKBodyNodesItems0) validateRoleEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, listNodesOkBodyNodesItems0TypeRolePropEnum, true); err != nil { + return err + } + return nil +} + +func (o *ListNodesOKBodyNodesItems0) validateRole(formats strfmt.Registry) error { + if swag.IsZero(o.Role) { // not required + return nil + } + + // value enum + if err := o.validateRoleEnum("role", "body", *o.Role); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this list nodes OK body nodes items0 based on context it is used +func (o *ListNodesOKBodyNodesItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *ListNodesOKBodyNodesItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListNodesOKBodyNodesItems0) UnmarshalBinary(b []byte) error { + var res ListNodesOKBodyNodesItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} diff --git a/api/ha/v1beta1/json/client/ha_service/status_parameters.go b/api/ha/v1beta1/json/client/ha_service/status_parameters.go new file mode 100644 index 00000000000..161ac87d1e0 --- /dev/null +++ b/api/ha/v1beta1/json/client/ha_service/status_parameters.go @@ -0,0 +1,127 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ha_service + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewStatusParams creates a new StatusParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewStatusParams() *StatusParams { + return &StatusParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewStatusParamsWithTimeout creates a new StatusParams object +// with the ability to set a timeout on a request. +func NewStatusParamsWithTimeout(timeout time.Duration) *StatusParams { + return &StatusParams{ + timeout: timeout, + } +} + +// NewStatusParamsWithContext creates a new StatusParams object +// with the ability to set a context for a request. +func NewStatusParamsWithContext(ctx context.Context) *StatusParams { + return &StatusParams{ + Context: ctx, + } +} + +// NewStatusParamsWithHTTPClient creates a new StatusParams object +// with the ability to set a custom HTTPClient for a request. +func NewStatusParamsWithHTTPClient(client *http.Client) *StatusParams { + return &StatusParams{ + HTTPClient: client, + } +} + +/* +StatusParams contains all the parameters to send to the API endpoint + + for the status operation. + + Typically these are written to a http.Request. +*/ +type StatusParams struct { + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the status params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *StatusParams) WithDefaults() *StatusParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the status params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *StatusParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the status params +func (o *StatusParams) WithTimeout(timeout time.Duration) *StatusParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the status params +func (o *StatusParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the status params +func (o *StatusParams) WithContext(ctx context.Context) *StatusParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the status params +func (o *StatusParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the status params +func (o *StatusParams) WithHTTPClient(client *http.Client) *StatusParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the status params +func (o *StatusParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WriteToRequest writes these params to a swagger request +func (o *StatusParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/api/ha/v1beta1/json/client/ha_service/status_responses.go b/api/ha/v1beta1/json/client/ha_service/status_responses.go new file mode 100644 index 00000000000..4530572e2ba --- /dev/null +++ b/api/ha/v1beta1/json/client/ha_service/status_responses.go @@ -0,0 +1,453 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ha_service + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// StatusReader is a Reader for the Status structure. +type StatusReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *StatusReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewStatusOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewStatusDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewStatusOK creates a StatusOK with default headers values +func NewStatusOK() *StatusOK { + return &StatusOK{} +} + +/* +StatusOK describes a response with status code 200, with default header values. + +A successful response. +*/ +type StatusOK struct { + Payload *StatusOKBody +} + +// IsSuccess returns true when this status Ok response has a 2xx status code +func (o *StatusOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this status Ok response has a 3xx status code +func (o *StatusOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this status Ok response has a 4xx status code +func (o *StatusOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this status Ok response has a 5xx status code +func (o *StatusOK) IsServerError() bool { + return false +} + +// IsCode returns true when this status Ok response a status code equal to that given +func (o *StatusOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the status Ok response +func (o *StatusOK) Code() int { + return 200 +} + +func (o *StatusOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/status][%d] statusOk %s", 200, payload) +} + +func (o *StatusOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/status][%d] statusOk %s", 200, payload) +} + +func (o *StatusOK) GetPayload() *StatusOKBody { + return o.Payload +} + +func (o *StatusOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(StatusOKBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewStatusDefault creates a StatusDefault with default headers values +func NewStatusDefault(code int) *StatusDefault { + return &StatusDefault{ + _statusCode: code, + } +} + +/* +StatusDefault describes a response with status code -1, with default header values. + +An unexpected error response. +*/ +type StatusDefault struct { + _statusCode int + + Payload *StatusDefaultBody +} + +// IsSuccess returns true when this status default response has a 2xx status code +func (o *StatusDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this status default response has a 3xx status code +func (o *StatusDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this status default response has a 4xx status code +func (o *StatusDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this status default response has a 5xx status code +func (o *StatusDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this status default response a status code equal to that given +func (o *StatusDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the status default response +func (o *StatusDefault) Code() int { + return o._statusCode +} + +func (o *StatusDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/status][%d] Status default %s", o._statusCode, payload) +} + +func (o *StatusDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/ha/status][%d] Status default %s", o._statusCode, payload) +} + +func (o *StatusDefault) GetPayload() *StatusDefaultBody { + return o.Payload +} + +func (o *StatusDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(StatusDefaultBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +/* +StatusDefaultBody status default body +swagger:model StatusDefaultBody +*/ +type StatusDefaultBody struct { + // code + Code int32 `json:"code,omitempty"` + + // message + Message string `json:"message,omitempty"` + + // details + Details []*StatusDefaultBodyDetailsItems0 `json:"details"` +} + +// Validate validates this status default body +func (o *StatusDefaultBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateDetails(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *StatusDefaultBody) validateDetails(formats strfmt.Registry) error { + if swag.IsZero(o.Details) { // not required + return nil + } + + for i := 0; i < len(o.Details); i++ { + if swag.IsZero(o.Details[i]) { // not required + continue + } + + if o.Details[i] != nil { + if err := o.Details[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("Status default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("Status default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this status default body based on the context it is used +func (o *StatusDefaultBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateDetails(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *StatusDefaultBody) contextValidateDetails(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Details); i++ { + if o.Details[i] != nil { + + if swag.IsZero(o.Details[i]) { // not required + return nil + } + + if err := o.Details[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("Status default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("Status default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *StatusDefaultBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *StatusDefaultBody) UnmarshalBinary(b []byte) error { + var res StatusDefaultBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +StatusDefaultBodyDetailsItems0 status default body details items0 +swagger:model StatusDefaultBodyDetailsItems0 +*/ +type StatusDefaultBodyDetailsItems0 struct { + // at type + AtType string `json:"@type,omitempty"` + + // status default body details items0 + StatusDefaultBodyDetailsItems0 map[string]any `json:"-"` +} + +// UnmarshalJSON unmarshals this object with additional properties from JSON +func (o *StatusDefaultBodyDetailsItems0) UnmarshalJSON(data []byte) error { + // stage 1, bind the properties + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + if err := json.Unmarshal(data, &stage1); err != nil { + return err + } + var rcv StatusDefaultBodyDetailsItems0 + + rcv.AtType = stage1.AtType + *o = rcv + + // stage 2, remove properties and add to map + stage2 := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &stage2); err != nil { + return err + } + + delete(stage2, "@type") + // stage 3, add additional properties values + if len(stage2) > 0 { + result := make(map[string]any) + for k, v := range stage2 { + var toadd any + if err := json.Unmarshal(v, &toadd); err != nil { + return err + } + result[k] = toadd + } + o.StatusDefaultBodyDetailsItems0 = result + } + + return nil +} + +// MarshalJSON marshals this object with additional properties into a JSON object +func (o StatusDefaultBodyDetailsItems0) MarshalJSON() ([]byte, error) { + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + + stage1.AtType = o.AtType + + // make JSON object for known properties + props, err := json.Marshal(stage1) + if err != nil { + return nil, err + } + + if len(o.StatusDefaultBodyDetailsItems0) == 0 { // no additional properties + return props, nil + } + + // make JSON object for the additional properties + additional, err := json.Marshal(o.StatusDefaultBodyDetailsItems0) + if err != nil { + return nil, err + } + + if len(props) < 3 { // "{}": only additional properties + return additional, nil + } + + // concatenate the 2 objects + return swag.ConcatJSON(props, additional), nil +} + +// Validate validates this status default body details items0 +func (o *StatusDefaultBodyDetailsItems0) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this status default body details items0 based on context it is used +func (o *StatusDefaultBodyDetailsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *StatusDefaultBodyDetailsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *StatusDefaultBodyDetailsItems0) UnmarshalBinary(b []byte) error { + var res StatusDefaultBodyDetailsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +StatusOKBody status OK body +swagger:model StatusOKBody +*/ +type StatusOKBody struct { + // Status of HA mode: "Enabled" or "Disabled". + Status string `json:"status,omitempty"` +} + +// Validate validates this status OK body +func (o *StatusOKBody) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this status OK body based on context it is used +func (o *StatusOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *StatusOKBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *StatusOKBody) UnmarshalBinary(b []byte) error { + var res StatusOKBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} diff --git a/api/ha/v1beta1/json/client/pmm_ha_api_client.go b/api/ha/v1beta1/json/client/pmm_ha_api_client.go new file mode 100644 index 00000000000..e90005e3554 --- /dev/null +++ b/api/ha/v1beta1/json/client/pmm_ha_api_client.go @@ -0,0 +1,112 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package client + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/runtime" + httptransport "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + + "github.com/percona/pmm/api/ha/v1beta1/json/client/ha_service" +) + +// Default PMM HA API HTTP client. +var Default = NewHTTPClient(nil) + +const ( + // DefaultHost is the default Host + // found in Meta (info) section of spec file + DefaultHost string = "localhost" + // DefaultBasePath is the default BasePath + // found in Meta (info) section of spec file + DefaultBasePath string = "/" +) + +// DefaultSchemes are the default schemes found in Meta (info) section of spec file +var DefaultSchemes = []string{"http", "https"} + +// NewHTTPClient creates a new PMM HA API HTTP client. +func NewHTTPClient(formats strfmt.Registry) *PMMHAAPI { + return NewHTTPClientWithConfig(formats, nil) +} + +// NewHTTPClientWithConfig creates a new PMM HA API HTTP client, +// using a customizable transport config. +func NewHTTPClientWithConfig(formats strfmt.Registry, cfg *TransportConfig) *PMMHAAPI { + // ensure nullable parameters have default + if cfg == nil { + cfg = DefaultTransportConfig() + } + + // create transport and client + transport := httptransport.New(cfg.Host, cfg.BasePath, cfg.Schemes) + return New(transport, formats) +} + +// New creates a new PMM HA API client +func New(transport runtime.ClientTransport, formats strfmt.Registry) *PMMHAAPI { + // ensure nullable parameters have default + if formats == nil { + formats = strfmt.Default + } + + cli := new(PMMHAAPI) + cli.Transport = transport + cli.HAService = ha_service.New(transport, formats) + return cli +} + +// DefaultTransportConfig creates a TransportConfig with the +// default settings taken from the meta section of the spec file. +func DefaultTransportConfig() *TransportConfig { + return &TransportConfig{ + Host: DefaultHost, + BasePath: DefaultBasePath, + Schemes: DefaultSchemes, + } +} + +// TransportConfig contains the transport related info, +// found in the meta section of the spec file. +type TransportConfig struct { + Host string + BasePath string + Schemes []string +} + +// WithHost overrides the default host, +// provided by the meta section of the spec file. +func (cfg *TransportConfig) WithHost(host string) *TransportConfig { + cfg.Host = host + return cfg +} + +// WithBasePath overrides the default basePath, +// provided by the meta section of the spec file. +func (cfg *TransportConfig) WithBasePath(basePath string) *TransportConfig { + cfg.BasePath = basePath + return cfg +} + +// WithSchemes overrides the default schemes, +// provided by the meta section of the spec file. +func (cfg *TransportConfig) WithSchemes(schemes []string) *TransportConfig { + cfg.Schemes = schemes + return cfg +} + +// PMMHAAPI is a client for PMM HA API +type PMMHAAPI struct { + HAService ha_service.ClientService + + Transport runtime.ClientTransport +} + +// SetTransport changes the transport on the client and all its subresources +func (c *PMMHAAPI) SetTransport(transport runtime.ClientTransport) { + c.Transport = transport + c.HAService.SetTransport(transport) +} diff --git a/api/ha/v1beta1/json/header.json b/api/ha/v1beta1/json/header.json new file mode 100644 index 00000000000..2e36fdd8bb0 --- /dev/null +++ b/api/ha/v1beta1/json/header.json @@ -0,0 +1,11 @@ +{ + "swagger": "2.0", + "info": { + "title": "PMM HA API", + "version": "v1beta1" + }, + "schemes": [ + "https", + "http" + ] +} diff --git a/api/ha/v1beta1/json/v1beta1.json b/api/ha/v1beta1/json/v1beta1.json new file mode 100644 index 00000000000..df5b60e712e --- /dev/null +++ b/api/ha/v1beta1/json/v1beta1.json @@ -0,0 +1,163 @@ +{ + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "schemes": [ + "https", + "http" + ], + "swagger": "2.0", + "info": { + "title": "PMM HA API", + "version": "v1beta1" + }, + "paths": { + "/v1/ha/nodes": { + "get": { + "description": "Returns a list of all nodes in the High Availability cluster with their current status and roles.", + "tags": [ + "HAService" + ], + "summary": "List HA Nodes", + "operationId": "ListNodes", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "nodes": { + "description": "List of nodes in the HA cluster.", + "type": "array", + "items": { + "description": "HANode represents a single node in the HA cluster.", + "type": "object", + "properties": { + "node_name": { + "description": "Human-readable name of the node.", + "type": "string", + "x-order": 0 + }, + "role": { + "description": "NodeRole represents the role of a node in the HA cluster.", + "type": "string", + "default": "NODE_ROLE_UNSPECIFIED", + "enum": [ + "NODE_ROLE_UNSPECIFIED", + "NODE_ROLE_LEADER", + "NODE_ROLE_FOLLOWER" + ], + "x-order": 1 + }, + "status": { + "description": "Current status of the node from MemberList.", + "type": "string", + "x-order": 2 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, + "/v1/ha/status": { + "get": { + "description": "Returns whether High Availability mode is enabled or disabled.", + "tags": [ + "HAService" + ], + "summary": "HA Status", + "operationId": "Status", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "status": { + "description": "Status of HA mode: \"Enabled\" or \"Disabled\".", + "type": "string", + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + } + }, + "tags": [ + { + "name": "HAService" + } + ] +} \ No newline at end of file diff --git a/api/inventory/v1/json/client/nodes_service/add_node_responses.go b/api/inventory/v1/json/client/nodes_service/add_node_responses.go index 026b8d616a0..6d671e6a8b3 100644 --- a/api/inventory/v1/json/client/nodes_service/add_node_responses.go +++ b/api/inventory/v1/json/client/nodes_service/add_node_responses.go @@ -1117,6 +1117,9 @@ type AddNodeOKBodyContainer struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this add node OK body container @@ -1178,6 +1181,9 @@ type AddNodeOKBodyGeneric struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this add node OK body generic diff --git a/api/inventory/v1/json/client/nodes_service/get_node_responses.go b/api/inventory/v1/json/client/nodes_service/get_node_responses.go index 4970213d76f..40f3bb381f9 100644 --- a/api/inventory/v1/json/client/nodes_service/get_node_responses.go +++ b/api/inventory/v1/json/client/nodes_service/get_node_responses.go @@ -783,6 +783,9 @@ type GetNodeOKBodyContainer struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this get node OK body container @@ -844,6 +847,9 @@ type GetNodeOKBodyGeneric struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this get node OK body generic diff --git a/api/inventory/v1/json/client/nodes_service/list_nodes_responses.go b/api/inventory/v1/json/client/nodes_service/list_nodes_responses.go index 2af35dd6bc9..15fa4eb9397 100644 --- a/api/inventory/v1/json/client/nodes_service/list_nodes_responses.go +++ b/api/inventory/v1/json/client/nodes_service/list_nodes_responses.go @@ -828,6 +828,9 @@ type ListNodesOKBodyContainerItems0 struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this list nodes OK body container items0 @@ -889,6 +892,9 @@ type ListNodesOKBodyGenericItems0 struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this list nodes OK body generic items0 diff --git a/api/inventory/v1/json/v1.json b/api/inventory/v1/json/v1.json index bc11b8a83d6..16f6ad3ee79 100644 --- a/api/inventory/v1/json/v1.json +++ b/api/inventory/v1/json/v1.json @@ -10386,6 +10386,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } } }, @@ -10449,6 +10454,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } } }, @@ -10935,6 +10945,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -10995,6 +11010,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -11250,6 +11270,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -11310,6 +11335,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 diff --git a/api/inventory/v1/nodes.pb.go b/api/inventory/v1/nodes.pb.go index 790d71eef51..3bee6661180 100644 --- a/api/inventory/v1/nodes.pb.go +++ b/api/inventory/v1/nodes.pb.go @@ -104,9 +104,11 @@ type GenericNode struct { // Node availability zone. Az string `protobuf:"bytes,8,opt,name=az,proto3" json:"az,omitempty"` // Custom user-assigned labels. - CustomLabels map[string]string `protobuf:"bytes,9,rep,name=custom_labels,json=customLabels,proto3" json:"custom_labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CustomLabels map[string]string `protobuf:"bytes,9,rep,name=custom_labels,json=customLabels,proto3" json:"custom_labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // True if this node is a PMM Server node (HA mode). + IsPmmServerNode bool `protobuf:"varint,10,opt,name=is_pmm_server_node,json=isPmmServerNode,proto3" json:"is_pmm_server_node,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GenericNode) Reset() { @@ -202,6 +204,13 @@ func (x *GenericNode) GetCustomLabels() map[string]string { return nil } +func (x *GenericNode) GetIsPmmServerNode() bool { + if x != nil { + return x.IsPmmServerNode + } + return false +} + // ContainerNode represents a Docker container. type ContainerNode struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -224,9 +233,11 @@ type ContainerNode struct { // Node availability zone. Az string `protobuf:"bytes,9,opt,name=az,proto3" json:"az,omitempty"` // Custom user-assigned labels. - CustomLabels map[string]string `protobuf:"bytes,10,rep,name=custom_labels,json=customLabels,proto3" json:"custom_labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CustomLabels map[string]string `protobuf:"bytes,10,rep,name=custom_labels,json=customLabels,proto3" json:"custom_labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // True if this node is a PMM Server node (HA mode). + IsPmmServerNode bool `protobuf:"varint,11,opt,name=is_pmm_server_node,json=isPmmServerNode,proto3" json:"is_pmm_server_node,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ContainerNode) Reset() { @@ -329,6 +340,13 @@ func (x *ContainerNode) GetCustomLabels() map[string]string { return nil } +func (x *ContainerNode) GetIsPmmServerNode() bool { + if x != nil { + return x.IsPmmServerNode + } + return false +} + // RemoteNode represents generic remote Node. It's a node where we don't run pmm-agents. Only external exporters can run on Remote Nodes. type RemoteNode struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1783,7 +1801,7 @@ var File_inventory_v1_nodes_proto protoreflect.FileDescriptor const file_inventory_v1_nodes_proto_rawDesc = "" + "\n" + - "\x18inventory/v1/nodes.proto\x12\finventory.v1\x1a\x1cgoogle/api/annotations.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\x1a\x17validate/validate.proto\"\xee\x02\n" + + "\x18inventory/v1/nodes.proto\x12\finventory.v1\x1a\x1cgoogle/api/annotations.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\x1a\x17validate/validate.proto\"\x9b\x03\n" + "\vGenericNode\x12\x17\n" + "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x1b\n" + "\tnode_name\x18\x02 \x01(\tR\bnodeName\x12\x18\n" + @@ -1795,10 +1813,12 @@ const file_inventory_v1_nodes_proto_rawDesc = "" + "node_model\x18\x06 \x01(\tR\tnodeModel\x12\x16\n" + "\x06region\x18\a \x01(\tR\x06region\x12\x0e\n" + "\x02az\x18\b \x01(\tR\x02az\x12P\n" + - "\rcustom_labels\x18\t \x03(\v2+.inventory.v1.GenericNode.CustomLabelsEntryR\fcustomLabels\x1a?\n" + + "\rcustom_labels\x18\t \x03(\v2+.inventory.v1.GenericNode.CustomLabelsEntryR\fcustomLabels\x12+\n" + + "\x12is_pmm_server_node\x18\n" + + " \x01(\bR\x0fisPmmServerNode\x1a?\n" + "\x11CustomLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa4\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd1\x03\n" + "\rContainerNode\x12\x17\n" + "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x1b\n" + "\tnode_name\x18\x02 \x01(\tR\bnodeName\x12\x18\n" + @@ -1812,7 +1832,8 @@ const file_inventory_v1_nodes_proto_rawDesc = "" + "\x06region\x18\b \x01(\tR\x06region\x12\x0e\n" + "\x02az\x18\t \x01(\tR\x02az\x12R\n" + "\rcustom_labels\x18\n" + - " \x03(\v2-.inventory.v1.ContainerNode.CustomLabelsEntryR\fcustomLabels\x1a?\n" + + " \x03(\v2-.inventory.v1.ContainerNode.CustomLabelsEntryR\fcustomLabels\x12+\n" + + "\x12is_pmm_server_node\x18\v \x01(\bR\x0fisPmmServerNode\x1a?\n" + "\x11CustomLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb5\x02\n" + diff --git a/api/inventory/v1/nodes.pb.validate.go b/api/inventory/v1/nodes.pb.validate.go index f80ee529abe..bf279aac8f2 100644 --- a/api/inventory/v1/nodes.pb.validate.go +++ b/api/inventory/v1/nodes.pb.validate.go @@ -75,6 +75,8 @@ func (m *GenericNode) validate(all bool) error { // no validation rules for CustomLabels + // no validation rules for IsPmmServerNode + if len(errors) > 0 { return GenericNodeMultiError(errors) } @@ -194,6 +196,8 @@ func (m *ContainerNode) validate(all bool) error { // no validation rules for CustomLabels + // no validation rules for IsPmmServerNode + if len(errors) > 0 { return ContainerNodeMultiError(errors) } diff --git a/api/inventory/v1/nodes.proto b/api/inventory/v1/nodes.proto index f79ae62efdd..3cead66d61b 100644 --- a/api/inventory/v1/nodes.proto +++ b/api/inventory/v1/nodes.proto @@ -36,6 +36,8 @@ message GenericNode { string az = 8; // Custom user-assigned labels. map custom_labels = 9; + // True if this node is a PMM Server node (HA mode). + bool is_pmm_server_node = 10; } // ContainerNode represents a Docker container. @@ -60,6 +62,8 @@ message ContainerNode { string az = 9; // Custom user-assigned labels. map custom_labels = 10; + // True if this node is a PMM Server node (HA mode). + bool is_pmm_server_node = 11; } // RemoteNode represents generic remote Node. It's a node where we don't run pmm-agents. Only external exporters can run on Remote Nodes. diff --git a/api/management/v1/json/client/management_service/get_node_responses.go b/api/management/v1/json/client/management_service/get_node_responses.go index 17e18f31b3d..241eb92bed5 100644 --- a/api/management/v1/json/client/management_service/get_node_responses.go +++ b/api/management/v1/json/client/management_service/get_node_responses.go @@ -584,6 +584,9 @@ type GetNodeOKBodyNode struct { // Instance ID for cloud providers (e.g. AWS RDS). InstanceID string `json:"instance_id,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this get node OK body node diff --git a/api/management/v1/json/client/management_service/list_nodes_responses.go b/api/management/v1/json/client/management_service/list_nodes_responses.go index 7e00c929b8a..d9f378b0034 100644 --- a/api/management/v1/json/client/management_service/list_nodes_responses.go +++ b/api/management/v1/json/client/management_service/list_nodes_responses.go @@ -593,6 +593,9 @@ type ListNodesOKBodyNodesItems0 struct { // Instance ID for cloud providers (e.g. AWS RDS). InstanceID string `json:"instance_id,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this list nodes OK body nodes items0 diff --git a/api/management/v1/json/client/management_service/register_node_responses.go b/api/management/v1/json/client/management_service/register_node_responses.go index ee28b422ff0..f4c682e05e9 100644 --- a/api/management/v1/json/client/management_service/register_node_responses.go +++ b/api/management/v1/json/client/management_service/register_node_responses.go @@ -876,6 +876,9 @@ type RegisterNodeOKBodyContainerNode struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this register node OK body container node @@ -937,6 +940,9 @@ type RegisterNodeOKBodyGenericNode struct { // Custom user-assigned labels. CustomLabels map[string]string `json:"custom_labels,omitempty"` + + // True if this node is a PMM Server node (HA mode). + IsPMMServerNode bool `json:"is_pmm_server_node,omitempty"` } // Validate validates this register node OK body generic node diff --git a/api/management/v1/json/v1.json b/api/management/v1/json/v1.json index 92c3bc78f85..25b3cb77244 100644 --- a/api/management/v1/json/v1.json +++ b/api/management/v1/json/v1.json @@ -776,6 +776,11 @@ "description": "Instance ID for cloud providers (e.g. AWS RDS).", "type": "string", "x-order": 17 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 18 } } }, @@ -999,6 +1004,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -1059,6 +1069,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -1318,6 +1333,11 @@ "description": "Instance ID for cloud providers (e.g. AWS RDS).", "type": "string", "x-order": 17 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 18 } }, "x-order": 0 diff --git a/api/management/v1/node.pb.go b/api/management/v1/node.pb.go index a30b8dc7f64..558a1a30eb5 100644 --- a/api/management/v1/node.pb.go +++ b/api/management/v1/node.pb.go @@ -615,9 +615,11 @@ type UniversalNode struct { // List of services running on this node. Services []*UniversalNode_Service `protobuf:"bytes,17,rep,name=services,proto3" json:"services,omitempty"` // Instance ID for cloud providers (e.g. AWS RDS). - InstanceId string `protobuf:"bytes,18,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + InstanceId string `protobuf:"bytes,18,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + // True if this node is a PMM Server node (HA mode). + IsPmmServerNode bool `protobuf:"varint,19,opt,name=is_pmm_server_node,json=isPmmServerNode,proto3" json:"is_pmm_server_node,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UniversalNode) Reset() { @@ -776,6 +778,13 @@ func (x *UniversalNode) GetInstanceId() string { return "" } +func (x *UniversalNode) GetIsPmmServerNode() bool { + if x != nil { + return x.IsPmmServerNode + } + return false +} + type ListNodesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Node type to be filtered out. @@ -1149,7 +1158,7 @@ const file_management_v1_node_proto_rawDesc = "" + "\anode_id\x18\x01 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x06nodeId\x12\x14\n" + "\x05force\x18\x02 \x01(\bR\x05force\"2\n" + "\x16UnregisterNodeResponse\x12\x18\n" + - "\awarning\x18\x01 \x01(\tR\awarning\"\xf0\b\n" + + "\awarning\x18\x01 \x01(\tR\awarning\"\x9d\t\n" + "\rUniversalNode\x12\x17\n" + "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x1b\n" + "\tnode_type\x18\x02 \x01(\tR\bnodeType\x12\x1b\n" + @@ -1174,7 +1183,8 @@ const file_management_v1_node_proto_rawDesc = "" + "\x06agents\x18\x10 \x03(\v2\".management.v1.UniversalNode.AgentR\x06agents\x12@\n" + "\bservices\x18\x11 \x03(\v2$.management.v1.UniversalNode.ServiceR\bservices\x12\x1f\n" + "\vinstance_id\x18\x12 \x01(\tR\n" + - "instanceId\x1an\n" + + "instanceId\x12+\n" + + "\x12is_pmm_server_node\x18\x13 \x01(\bR\x0fisPmmServerNode\x1an\n" + "\aService\x12\x1d\n" + "\n" + "service_id\x18\x01 \x01(\tR\tserviceId\x12!\n" + diff --git a/api/management/v1/node.pb.validate.go b/api/management/v1/node.pb.validate.go index 64eaf8133ec..62a389ade8a 100644 --- a/api/management/v1/node.pb.validate.go +++ b/api/management/v1/node.pb.validate.go @@ -899,6 +899,8 @@ func (m *UniversalNode) validate(all bool) error { // no validation rules for InstanceId + // no validation rules for IsPmmServerNode + if len(errors) > 0 { return UniversalNodeMultiError(errors) } diff --git a/api/management/v1/node.proto b/api/management/v1/node.proto index 4d31c83b506..74b16d47f3a 100644 --- a/api/management/v1/node.proto +++ b/api/management/v1/node.proto @@ -162,6 +162,8 @@ message UniversalNode { repeated Service services = 17; // Instance ID for cloud providers (e.g. AWS RDS). string instance_id = 18; + // True if this node is a PMM Server node (HA mode). + bool is_pmm_server_node = 19; } message ListNodesRequest { diff --git a/api/swagger/swagger-dev.json b/api/swagger/swagger-dev.json index f75b38cc10d..6361e0fd2a7 100644 --- a/api/swagger/swagger-dev.json +++ b/api/swagger/swagger-dev.json @@ -5149,6 +5149,145 @@ } } }, + "/v1/ha/nodes": { + "get": { + "description": "Returns a list of all nodes in the High Availability cluster with their current status and roles.", + "tags": [ + "HAService" + ], + "summary": "List HA Nodes", + "operationId": "ListNodesMixin12", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "nodes": { + "description": "List of nodes in the HA cluster.", + "type": "array", + "items": { + "description": "HANode represents a single node in the HA cluster.", + "type": "object", + "properties": { + "node_name": { + "description": "Human-readable name of the node.", + "type": "string", + "x-order": 0 + }, + "role": { + "description": "NodeRole represents the role of a node in the HA cluster.", + "type": "string", + "default": "NODE_ROLE_UNSPECIFIED", + "enum": [ + "NODE_ROLE_UNSPECIFIED", + "NODE_ROLE_LEADER", + "NODE_ROLE_FOLLOWER" + ], + "x-order": 1 + }, + "status": { + "description": "Current status of the node from MemberList.", + "type": "string", + "x-order": 2 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, + "/v1/ha/status": { + "get": { + "description": "Returns whether High Availability mode is enabled or disabled.", + "tags": [ + "HAService" + ], + "summary": "HA Status", + "operationId": "Status", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "status": { + "description": "Status of HA mode: \"Enabled\" or \"Disabled\".", + "type": "string", + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, "/v1/inventory/agents": { "get": { "description": "Returns a list of all Agents.", @@ -15520,6 +15659,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } } }, @@ -15583,6 +15727,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } } }, @@ -16069,6 +16218,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -16129,6 +16283,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -16384,6 +16543,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -16444,6 +16608,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -20064,6 +20233,11 @@ "description": "Instance ID for cloud providers (e.g. AWS RDS).", "type": "string", "x-order": 17 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 18 } } }, @@ -20287,6 +20461,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -20347,6 +20526,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -20606,6 +20790,11 @@ "description": "Instance ID for cloud providers (e.g. AWS RDS).", "type": "string", "x-order": 17 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 18 } }, "x-order": 0 @@ -31196,6 +31385,9 @@ }, { "name": "PlatformService" + }, + { + "name": "HAService" } ], "x-readme": { diff --git a/api/swagger/swagger.json b/api/swagger/swagger.json index 29742337ec0..be080bd9f40 100644 --- a/api/swagger/swagger.json +++ b/api/swagger/swagger.json @@ -4191,6 +4191,145 @@ } } }, + "/v1/ha/nodes": { + "get": { + "description": "Returns a list of all nodes in the High Availability cluster with their current status and roles.", + "tags": [ + "HAService" + ], + "summary": "List HA Nodes", + "operationId": "ListNodesMixin10", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "nodes": { + "description": "List of nodes in the HA cluster.", + "type": "array", + "items": { + "description": "HANode represents a single node in the HA cluster.", + "type": "object", + "properties": { + "node_name": { + "description": "Human-readable name of the node.", + "type": "string", + "x-order": 0 + }, + "role": { + "description": "NodeRole represents the role of a node in the HA cluster.", + "type": "string", + "default": "NODE_ROLE_UNSPECIFIED", + "enum": [ + "NODE_ROLE_UNSPECIFIED", + "NODE_ROLE_LEADER", + "NODE_ROLE_FOLLOWER" + ], + "x-order": 1 + }, + "status": { + "description": "Current status of the node from MemberList.", + "type": "string", + "x-order": 2 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, + "/v1/ha/status": { + "get": { + "description": "Returns whether High Availability mode is enabled or disabled.", + "tags": [ + "HAService" + ], + "summary": "HA Status", + "operationId": "Status", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "status": { + "description": "Status of HA mode: \"Enabled\" or \"Disabled\".", + "type": "string", + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, "/v1/inventory/agents": { "get": { "description": "Returns a list of all Agents.", @@ -14562,6 +14701,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } } }, @@ -14625,6 +14769,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } } }, @@ -15111,6 +15260,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -15171,6 +15325,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -15426,6 +15585,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -15486,6 +15650,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -19106,6 +19275,11 @@ "description": "Instance ID for cloud providers (e.g. AWS RDS).", "type": "string", "x-order": 17 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 18 } } }, @@ -19329,6 +19503,11 @@ "type": "string" }, "x-order": 8 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 9 } }, "x-order": 0 @@ -19389,6 +19568,11 @@ "type": "string" }, "x-order": 9 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 10 } }, "x-order": 1 @@ -19648,6 +19832,11 @@ "description": "Instance ID for cloud providers (e.g. AWS RDS).", "type": "string", "x-order": 17 + }, + "is_pmm_server_node": { + "description": "True if this node is a PMM Server node (HA mode).", + "type": "boolean", + "x-order": 18 } }, "x-order": 0 @@ -30232,6 +30421,9 @@ }, { "name": "PlatformService" + }, + { + "name": "HAService" } ], "x-readme": { diff --git a/build/ansible/pmm-docker/post-build.yml b/build/ansible/pmm-docker/post-build.yml index b473876e5cb..98cb5661c45 100644 --- a/build/ansible/pmm-docker/post-build.yml +++ b/build/ansible/pmm-docker/post-build.yml @@ -18,15 +18,6 @@ owner: pmm group: root - - name: Set up pmm-agent - command: > - pmm-agent setup - --config-file=/usr/local/percona/pmm/config/pmm-agent.yaml - --skip-registration - --id=pmm-server - --server-address=127.0.0.1:8443 - --server-insecure-tls - - name: Remove pmm-managed database from PostgreSQL postgresql_db: login_user: postgres @@ -48,7 +39,7 @@ - name: Cleanup dnf cache command: dnf clean all - - name: Cleanup build logs, data and package cache + - name: Cleanup build logs, data, config files and package cache file: path: "{{ item }}" state: absent @@ -58,12 +49,15 @@ - /var/log/secure - /var/log/wtmp - /var/log/clickhouse-server + - /var/log/clickhouse-keeper - /var/log/nginx - /var/lib/pgsql - /var/cache/dnf - /var/cache/yum - /srv/pmm-encryption.key - /srv/pmm-agent/tmp + - /srv/pmm-agent/config + - /usr/local/percona/pmm/config/pmm-agent.yaml - name: Remove users created by installers user: @@ -97,6 +91,18 @@ - absent - directory + - name: Remove auto-generated config files + file: + path: "/etc/supervisord.d/{{ item }}" + state: absent + loop: + - victoriametrics.ini + - vmalert.ini + - vmproxy.ini + - qan-api2.ini + - grafana.ini + - nomad-server.ini + - name: Create '/srv/logs' directory file: path: /srv/logs diff --git a/build/ansible/roles/initialization/tasks/main.yml b/build/ansible/roles/initialization/tasks/main.yml index 5821b64f179..a8e4e782825 100644 --- a/build/ansible/roles/initialization/tasks/main.yml +++ b/build/ansible/roles/initialization/tasks/main.yml @@ -54,7 +54,10 @@ host: 127.0.0.1 port: 5432 timeout: 150 - when: lookup('env','GF_DATABASE_URL') == '' and lookup('env','GF_DATABASE_HOST') == '' and lookup('env','PMM_DISABLE_BUILTIN_POSTGRES') == '' + when: + - lookup('env','GF_DATABASE_URL') == '' + - lookup('env','GF_DATABASE_HOST') == '' + - lookup('env','PMM_DISABLE_BUILTIN_POSTGRES') == '' - name: Create Grafana DB block: @@ -74,7 +77,10 @@ login_user: postgres state: present when: not ansible_check_mode - when: lookup('env','GF_DATABASE_URL') == '' and lookup('env','GF_DATABASE_HOST') == '' and need_initialization + when: + - lookup('env','GF_DATABASE_URL') == '' + - lookup('env','GF_DATABASE_HOST') == '' + - need_initialization - name: Upgrade/Install dashboards include_role: @@ -105,7 +111,10 @@ - name: Initialize admin password for AMI if needed include_role: name: init-admin-password-ami - when: need_initialization and is_ami + when: + - need_initialization + - is_ami + when: need_initialization or need_upgrade - name: Disable maintenance mode diff --git a/build/ansible/roles/supervisord/files/pmm.ini b/build/ansible/roles/supervisord/files/pmm.ini index f44f4a5b8c2..171891286ba 100644 --- a/build/ansible/roles/supervisord/files/pmm.ini +++ b/build/ansible/roles/supervisord/files/pmm.ini @@ -103,7 +103,7 @@ redirect_stderr = true priority = 15 command = /usr/sbin/pmm-agent --config-file=/usr/local/percona/pmm/config/pmm-agent.yaml --paths-tempdir=/srv/pmm-agent/tmp autorestart = true -autostart = true +autostart = false startretries = 1000 startsecs = 1 stopsignal = TERM diff --git a/build/docker/rpmbuild/Dockerfile.el8 b/build/docker/rpmbuild/Dockerfile.el8 index 900a9165959..a514bfdbe6b 100644 --- a/build/docker/rpmbuild/Dockerfile.el8 +++ b/build/docker/rpmbuild/Dockerfile.el8 @@ -25,8 +25,8 @@ RUN dnf update -y && \ dnf clean all && rm -rf /var/cache/dnf /var/cache/yum # keep that format for easier search -ENV GO_VERSION=1.25.4 -ENV GO_RELEASER_VERSION=2.12.7 +ENV GO_VERSION=1.25.5 +ENV GO_RELEASER_VERSION=2.13.1 RUN if [ `uname -m` == "x86_64" ]; then ARCH=amd64; else ARCH=arm64; fi && \ curl -fSsL -o /tmp/golang.tar.gz https://dl.google.com/go/go${GO_VERSION}.linux-${ARCH}.tar.gz && \ diff --git a/build/docker/rpmbuild/Dockerfile.el9 b/build/docker/rpmbuild/Dockerfile.el9 index 54afd35c32c..27fee0faaba 100644 --- a/build/docker/rpmbuild/Dockerfile.el9 +++ b/build/docker/rpmbuild/Dockerfile.el9 @@ -26,8 +26,8 @@ RUN dnf update -y && \ dnf clean all && rm -rf /var/cache/dnf /var/cache/yum # keep that format for easier search -ENV GO_VERSION=1.25.4 -ENV GO_RELEASER_VERSION=2.12.7 +ENV GO_VERSION=1.25.5 +ENV GO_RELEASER_VERSION=2.13.1 RUN if [ `uname -m` == "x86_64" ]; then ARCH=amd64; else ARCH=arm64; fi && \ curl -fSsL -o /tmp/golang.tar.gz https://dl.google.com/go/go${GO_VERSION}.linux-${ARCH}.tar.gz && \ diff --git a/build/docker/rpmbuild/Dockerfile.hetzner-el9 b/build/docker/rpmbuild/Dockerfile.hetzner-el9 index d3596d5c142..158a68142fa 100644 --- a/build/docker/rpmbuild/Dockerfile.hetzner-el9 +++ b/build/docker/rpmbuild/Dockerfile.hetzner-el9 @@ -43,8 +43,8 @@ RUN sed -i 's|metalink=|#metalink=|g' /etc/yum.repos.d/epel*.repo && \ sed -i 's|#baseurl=.*|baseurl=https://ftp.fau.de/epel/9/Everything/$basearch/|g' /etc/yum.repos.d/epel.repo || true # keep that format for easier search -ENV GO_VERSION=1.25.4 -ENV GO_RELEASER_VERSION=2.12.7 +ENV GO_VERSION=1.25.5 +ENV GO_RELEASER_VERSION=2.13.1 RUN if [ "$(uname -m)" == "x86_64" ]; then ARCH=amd64; else ARCH=arm64; fi && \ curl -fSsL -o /tmp/golang.tar.gz https://dl.google.com/go/go${GO_VERSION}.linux-${ARCH}.tar.gz && \ diff --git a/build/docker/server/entrypoint.sh b/build/docker/server/entrypoint.sh index 3c86a6c4db1..524329a1829 100755 --- a/build/docker/server/entrypoint.sh +++ b/build/docker/server/entrypoint.sh @@ -73,7 +73,7 @@ fi # Check /usr/share/pmm-server directory on every start echo "Checking /usr/share/pmm-server directory structure..." # Still ensure critical directories exist, but don't create empty ones -if [ ! -d "/usr/share/pmm-server/nginx" ]; then +if [ ! -d "/usr/share/pmm-server/nginx" ]; then echo "Creating nginx temp directories..." mkdir -p /usr/share/pmm-server/nginx/{client_temp,proxy_temp,fastcgi_temp,uwsgi_temp,scgi_temp} fi @@ -110,7 +110,7 @@ bash /var/lib/cloud/scripts/per-boot/generate-ssl-certificate chmod 750 /srv/postgres14 || true echo "Checking nginx configuration..." -if ! nginx -t; then +if ! nginx -t -e /dev/stdout; then echo "Nginx configuration test failed, exiting..." exit 1 fi @@ -118,5 +118,37 @@ fi # pmm-managed-init validates environment variables. pmm-managed-init +declare AGENT_CONFIG_DIR="/usr/local/percona/pmm/config" +declare AGENT_ID=pmm-server + +if [ "$PMM_HA_ENABLE" = "1" ] || [ "$PMM_HA_ENABLE" = "true" ]; then + echo "High Availability mode is enabled." + if [ -f "$AGENT_CONFIG_DIR/pmm-agent.yaml" ]; then + rm -f "$AGENT_CONFIG_DIR/pmm-agent.yaml" + fi + + AGENT_CONFIG_DIR="/srv/pmm-agent/config" + if [ ! -d "$AGENT_CONFIG_DIR" ]; then + echo "Creating pmm-agent config directory..." + install -d -m 770 "$AGENT_CONFIG_DIR" + fi + + AGENT_ID="$(uuidgen)" +fi + +if [ ! -f "$AGENT_CONFIG_DIR/pmm-agent.yaml" ]; then + echo "Creating pmm-agent configuration..." + pmm-agent setup \ + --config-file="$AGENT_CONFIG_DIR/pmm-agent.yaml" \ + --skip-registration \ + --id="$AGENT_ID" \ + --paths-tempdir=/srv/pmm-agent/tmp \ + --paths-nomad-data-dir=/srv/nomad/data \ + --server-address=127.0.0.1:8443 \ + --server-insecure-tls +fi + +unset AGENT_CONFIG_DIR AGENT_ID + # Start supervisor in foreground exec supervisord -n -c /etc/supervisord.conf diff --git a/build/scripts/build-client-docker b/build/scripts/build-client-docker index 71243d481c4..99a2467512b 100755 --- a/build/scripts/build-client-docker +++ b/build/scripts/build-client-docker @@ -20,8 +20,6 @@ fi docker build --build-arg BUILD_DATE="`date --rfc-3339=seconds`" \ --build-arg VERSION=$(cat VERSION) \ --build-arg RELEASE=$(date +%s) \ - --squash \ - --no-cache \ -f ${DOCKER_FILE_LOCATION}/${docker_file} \ -t ${DOCKER_CLIENT_TAG} \ ${DOCKER_FILE_LOCATION} diff --git a/build/scripts/build-server-docker b/build/scripts/build-server-docker index 2fa5b4f043a..74ce206a886 100755 --- a/build/scripts/build-server-docker +++ b/build/scripts/build-server-docker @@ -12,9 +12,6 @@ fi docker_root=$(realpath ${rpms_dir}/..) cp -r ${root_dir}/tmp/source/pmm/build/ansible ${docker_root}/ansible -ls ${docker_root}/ansible -ls ${docker_root}/ansible/roles/pmm-images -ls ${docker_root}/ansible/roles/pmm-images/tasks cp ${root_dir}/tmp/source/pmm/build/docker/server/* ${docker_root}/ cp "${root_dir}/results/tarball/pmm-client-${pmm_version}.tar.gz" "${docker_root}/pmm-client.tar.gz" @@ -23,7 +20,7 @@ ls -la ${rpms_dir} docker run --rm -v ${rpms_dir}:/home/builder/rpm/RPMS ${rpmbuild_docker_image} sh -c " sudo chown -R builder /home/builder/rpm/RPMS until /usr/bin/createrepo_c --update /home/builder/rpm/RPMS; do - echo "waiting" + echo waiting... sleep 1 done " @@ -32,12 +29,10 @@ if [ -z "${DOCKER_TAG}" ]; then DOCKER_TAG=perconalab/pmm-server-fb:${full_pmm_version} fi -IMAGE_VERSION=`echo $DOCKER_TAG | cut -d ':' -f2` +IMAGE_VERSION=$(echo "$DOCKER_TAG" | cut -d ':' -f2) docker build --build-arg BUILD_DATE="`date --rfc-3339=seconds`" \ --build-arg VERSION="$IMAGE_VERSION" \ - --squash \ - --no-cache \ -f ${docker_root}/${docker_file} \ -t ${DOCKER_TAG} \ ${docker_root}/ diff --git a/docker-compose.ha.yml b/docker-compose.ha.yml deleted file mode 100644 index 6254835ecfb..00000000000 --- a/docker-compose.ha.yml +++ /dev/null @@ -1,365 +0,0 @@ ---- -services: - # PMM with external DBs - ch: - image: ${CH_IMAGE:-clickhouse/clickhouse-server:25.3.6.56-alpine} - platform: linux/amd64 - hostname: ${CH_HOSTNAME:-ch} - ports: - - ${CH_PORT:-9000}:9000 - environment: - - CLICKHOUSE_PASSWORD=clickhouse - networks: - ha: - ipv4_address: 172.20.0.7 - volumes: - - chdata:/var/lib/clickhouse # Volume for ClickHouse data - - - victoriametrics: - hostname: ${VM_HOSTNAME:-victoriametrics} - image: victoriametrics/victoria-metrics:v1.93.4 - ports: - - 8428:8428 - - 8089:8089 - - 8089:8089/udp - - 2003:2003 - - 2003:2003/udp - - 4242:4242 - volumes: - - vmdata:/storage - command: - - "--storageDataPath=/storage" - - "--graphiteListenAddr=:2003" - - "--opentsdbListenAddr=:4242" - - "--httpListenAddr=:8428" - - "--influxListenAddr=:8089" - networks: - ha: - ipv4_address: 172.20.0.4 - - # PMM with external Postgres DB - pg: -# build: -# context: ./managed/testdata/pg -# args: -# POSTGRES_IMAGE: ${POSTGRES_IMAGE:-postgres:14} -# dockerfile: Dockerfile - image: postgres:14 - container_name: pg - environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-pmm-password} - ports: - - ${POSTGRES_PORT:-5432}:5432 - command: | - postgres - -c shared_preload_libraries=pg_stat_statements - -c pg_stat_statements.max=10000 - -c pg_stat_statements.track=all - -c pg_stat_statements.save=off - -c fsync=off - -c hba_file=/conf/pg_hba.conf - -c log_statement=all - # -c ssl=on - # -c ssl_ca_file=/certs/root.crt - # -c ssl_cert_file=/certs/server.crt - # -c ssl_key_file=/certs/server.key - networks: - ha: - ipv4_address: 172.20.0.3 - volumes: - - pgdata:/var/lib/postgresql/data # Volume for PostgreSQL data - - ./managed/testdata/pg/conf/:/conf/ - - ./managed/testdata/pg/queries/:/docker-entrypoint-initdb.d/ - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s - retries: 5 - start_period: 10s - - - haproxy: - image: haproxy:latest - container_name: haproxy - hostname: haproxy - networks: - ha: - ipv4_address: 172.20.0.10 - volumes: - - ./managed/testdata/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg - - ./managed/testdata/haproxy/localhost.pem:/usr/local/etc/ssl/private/localhost.pem:ro - ports: - - 7080:80 - - 7443:443 - depends_on: - - pmm-server-active - - pmm-server-passive - - pmm-server-passive-2 - - pmm-server-active: - depends_on: - ch: - condition: service_started - pg: - condition: service_healthy - victoriametrics: - condition: service_started - image: ${PMM_CONTAINER:-perconalab/pmm-server:3-dev-container} - platform: linux/amd64 - container_name: pmm-server-active - hostname: pmm-server-active - networks: - ha: - ipv4_address: 172.20.0.5 - environment: - - PMM_RELEASE_PATH=/root/go/src/github.com/percona/pmm/bin - - REVIEWDOG_GITHUB_API_TOKEN=${REVIEWDOG_GITHUB_API_TOKEN:-} - - AWS_ACCESS_KEY=${AWS_ACCESS_KEY:-} - - AWS_SECRET_KEY=${AWS_SECRET_KEY:-} - - PMM_CLICKHOUSE_ADDR=${CH_HOSTNAME:-ch}:9000 - - PMM_CLICKHOUSE_DATABASE=pmm - - PMM_DISABLE_BUILTIN_CLICKHOUSE=1 - - PMM_POSTGRES_ADDR=pg:5432 - - PMM_POSTGRES_USERNAME=pmm-managed - - PMM_POSTGRES_DBPASSWORD=pmm-managed - - PMM_DISABLE_BUILTIN_POSTGRES=1 - - GF_DATABASE_URL=postgres://grafana:grafana@pg:5432/grafana - - GF_DATABASE_NAME=grafana - - GF_DATABASE_USER=grafana - - GF_DATABASE_PASSWORD=grafana - - GF_DATABASE_HOST=pg - - GF_DATABASE_PORT=5432 - - GO_VERSION=1.22 - - PMM_VM_URL=${PMM_VM_URL:-http://victoriametrics:8428/} - - PMM_TEST_HA_ENABLE=1 - - PMM_TEST_HA_BOOTSTRAP=1 - - PMM_TEST_HA_NODE_ID=pmm-server-active - - PMM_TEST_HA_ADVERTISE_ADDRESS=172.20.0.5 - - PMM_TEST_HA_PEERS=pmm-server-active,pmm-server-passive,pmm-server-passive-2 - - PMM_TEST_HA_GOSSIP_PORT=9096 - - PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 - - extra_hosts: - - host.docker.internal:host-gateway - # - portal.localhost:${PORTAL_HOST:-host-gateway} - # - check.localhost:${PORTAL_CHECK_HOST:-host-gateway} - # - pmm.localhost:${PORTAL_PMM_HOST:-host-gateway} - # - check-dev.percona.com:${PORTAL_PMM_HOST:-host-gateway} - - # for delve - cap_add: - - SYS_PTRACE - security_opt: - - seccomp:unconfined - - # see https://github.com/golang/go/wiki/LinuxKernelSignalVectorBug#what-to-do - ulimits: - memlock: 67108864 - - # ports: - # - ${PMM_PORT_HTTP:-8081}:8080 - # - ${PMM_PORT_HTTPS:-8441}:8443 - # For headless delve - # - ${PMM_PORT_DELVE:-2345}:2345 - volumes: - - ./:/root/go/src/github.com/percona/pmm - # - "../grafana/public:/usr/share/grafana/public" - - ./Makefile.devcontainer:/root/go/src/github.com/percona/pmm/Makefile:ro # change Makefile in devcontainer - # caching - - go-modules:/root/go/pkg/mod - - root-cache:/root/.cache - - ./managed/testdata/pg/certs/:/certs/ - healthcheck: - test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/v1/server/readyz"] - interval: 10s - timeout: 5s - start_period: 30s - retries: 10 - - pmm-server-passive: - depends_on: - ch: - condition: service_started - pg: - condition: service_healthy - victoriametrics: - condition: service_started - pmm-server-active: - condition: service_healthy - image: ${PMM_CONTAINER:-perconalab/pmm-server:3-dev-container} - platform: linux/amd64 - container_name: pmm-server-passive - hostname: pmm-server-passive - networks: - ha: - ipv4_address: 172.20.0.6 - environment: - - PMM_RELEASE_PATH=/root/go/src/github.com/percona/pmm/bin - - REVIEWDOG_GITHUB_API_TOKEN=${REVIEWDOG_GITHUB_API_TOKEN:-} - - AWS_ACCESS_KEY=${AWS_ACCESS_KEY:-} - - AWS_SECRET_KEY=${AWS_SECRET_KEY:-} - # - PMM_DEV_PERCONA_PLATFORM_ADDRESS=https://check.localhost - # - PMM_DEV_PERCONA_PLATFORM_INSECURE=1 - # - PMM_DEV_PERCONA_PLATFORM_PUBLIC_KEY= - # - PMM_DEV_TELEMETRY_INTERVAL=10s - # - PMM_DEV_TELEMETRY_RETRY_BACKOFF=10s - # - PMM_DEV_TELEMETRY_DISABLE_START_DELAY=1 - - PMM_CLICKHOUSE_ADDR=${CH_HOSTNAME:-ch}:9000 - - PMM_CLICKHOUSE_DATABASE=pmm - - PMM_DISABLE_BUILTIN_CLICKHOUSE=1 - - PMM_POSTGRES_ADDR=pg:5432 - - PMM_POSTGRES_USERNAME=pmm-managed - - PMM_POSTGRES_DBPASSWORD=pmm-managed - - PMM_DISABLE_BUILTIN_POSTGRES=1 - # - PMM_POSTGRES_SSL_MODE=require - # - PMM_POSTGRES_SSL_CA_PATH=/certs/root.crt - # - PMM_POSTGRES_SSL_KEY_PATH=/certs/pmm-managed.key - # - PMM_POSTGRES_SSL_CERT_PATH=/certs/pmm-managed.crt - - GF_DATABASE_URL=postgres://grafana:grafana@pg:5432/grafana - - GF_DATABASE_NAME=grafana - - GF_DATABASE_USER=grafana - - GF_DATABASE_PASSWORD=grafana - - GF_DATABASE_HOST=pg - - GF_DATABASE_PORT=5432 - # - GF_DATABASE_SSL_MODE=require - # - PMM_DEBUG=1 - - GO_VERSION=1.20 - - PMM_VM_URL=${PMM_VM_URL:-http://victoriametrics:8428/} - - PMM_TEST_HA_ENABLE=1 - - PMM_TEST_HA_NODE_ID=pmm-server-passive - - PMM_TEST_HA_ADVERTISE_ADDRESS=172.20.0.6 - - PMM_TEST_HA_PEERS=pmm-server-active,pmm-server-passive,pmm-server-passive-2 - - PMM_TEST_HA_GOSSIP_PORT=9096 - - PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 - - extra_hosts: - - host.docker.internal:host-gateway - # - portal.localhost:${PORTAL_HOST:-host-gateway} - # - check.localhost:${PORTAL_CHECK_HOST:-host-gateway} - # - pmm.localhost:${PORTAL_PMM_HOST:-host-gateway} - # - check-dev.percona.com:${PORTAL_PMM_HOST:-host-gateway} - - # for delve - cap_add: - - SYS_PTRACE - security_opt: - - seccomp:unconfined - - # see https://github.com/golang/go/wiki/LinuxKernelSignalVectorBug#what-to-do - ulimits: - memlock: 67108864 - - # ports: - # - ${PMM_PORT_HTTP:-8082}:8080 - # - ${PMM_PORT_HTTPS:-8432}:8443 - # For headless delve - # - ${PMM_PORT_DELVE:-12345}:2345 - volumes: - - ./:/root/go/src/github.com/percona/pmm - # - "../grafana/public:/usr/share/grafana/public" - - ./Makefile.devcontainer:/root/go/src/github.com/percona/pmm/Makefile:ro # change Makefile in devcontainer - # caching - - go-modules:/root/go/pkg/mod - - root-cache:/root/.cache - - ./managed/testdata/pg/certs/:/certs/ - - pmm-server-passive-2: - depends_on: - ch: - condition: service_started - pg: - condition: service_healthy - victoriametrics: - condition: service_started - pmm-server-active: - condition: service_healthy - image: ${PMM_CONTAINER:-perconalab/pmm-server:3-dev-container} - platform: linux/amd64 - container_name: pmm-server-passive-2 - hostname: pmm-server-passive-2 - networks: - ha: - ipv4_address: 172.20.0.11 - environment: - - PMM_RELEASE_PATH=/root/go/src/github.com/percona/pmm/bin - - REVIEWDOG_GITHUB_API_TOKEN=${REVIEWDOG_GITHUB_API_TOKEN:-} - - AWS_ACCESS_KEY=${AWS_ACCESS_KEY:-} - - AWS_SECRET_KEY=${AWS_SECRET_KEY:-} - # - PMM_DEV_PERCONA_PLATFORM_ADDRESS=https://check.localhost - # - PMM_DEV_PERCONA_PLATFORM_INSECURE=1 - # - PMM_DEV_PERCONA_PLATFORM_PUBLIC_KEY= - # - PMM_DEV_TELEMETRY_INTERVAL=10s - # - PMM_DEV_TELEMETRY_RETRY_BACKOFF=10s - # - PMM_DEV_TELEMETRY_DISABLE_START_DELAY=1 - - PMM_CLICKHOUSE_ADDR=${CH_HOSTNAME:-ch}:9000 - - PMM_CLICKHOUSE_DATABASE=pmm - - PMM_DISABLE_BUILTIN_CLICKHOUSE=1 - - PMM_POSTGRES_ADDR=pg:5432 - - PMM_POSTGRES_USERNAME=pmm-managed - - PMM_POSTGRES_DBPASSWORD=pmm-managed - - PMM_DISABLE_BUILTIN_POSTGRES=1 - # - PMM_POSTGRES_SSL_MODE=require - # - PMM_POSTGRES_SSL_CA_PATH=/certs/root.crt - # - PMM_POSTGRES_SSL_KEY_PATH=/certs/pmm-managed.key - # - PMM_POSTGRES_SSL_CERT_PATH=/certs/pmm-managed.crt - - GF_DATABASE_URL=postgres://grafana:grafana@pg:5432/grafana - - GF_DATABASE_NAME=grafana - - GF_DATABASE_USER=grafana - - GF_DATABASE_PASSWORD=grafana - - GF_DATABASE_HOST=pg - - GF_DATABASE_PORT=5432 - # - GF_DATABASE_SSL_MODE=require - # - PMM_DEBUG=1 - - GO_VERSION=1.20 - - PMM_VM_URL=${PMM_VM_URL:-http://victoriametrics:8428/} - - PMM_TEST_HA_ENABLE=1 - - PMM_TEST_HA_NODE_ID=pmm-server-passive-2 - - PMM_TEST_HA_ADVERTISE_ADDRESS=172.20.0.11 - - PMM_TEST_HA_PEERS=pmm-server-active,pmm-server-passive,pmm-server-passive-2 - - PMM_TEST_HA_GOSSIP_PORT=9096 - - PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 - - extra_hosts: - - host.docker.internal:host-gateway - # - portal.localhost:${PORTAL_HOST:-host-gateway} - # - check.localhost:${PORTAL_CHECK_HOST:-host-gateway} - # - pmm.localhost:${PORTAL_PMM_HOST:-host-gateway} - # - check-dev.percona.com:${PORTAL_PMM_HOST:-host-gateway} - - # for delve - cap_add: - - SYS_PTRACE - security_opt: - - seccomp:unconfined - - # see https://github.com/golang/go/wiki/LinuxKernelSignalVectorBug#what-to-do - ulimits: - memlock: 67108864 - - # ports: - # - ${PMM_PORT_HTTP:-8083}:8080 - # - ${PMM_PORT_HTTPS:-8433}:8443 - # For headless delve - # - ${PMM_PORT_DELVE:-12345}:2345 - volumes: - - ./:/root/go/src/github.com/percona/pmm - # - "../grafana/public:/usr/share/grafana/public" - - ./Makefile.devcontainer:/root/go/src/github.com/percona/pmm/Makefile:ro # change Makefile in devcontainer - # caching - - go-modules:/root/go/pkg/mod - - root-cache:/root/.cache - - ./managed/testdata/pg/certs/:/certs/ - -volumes: - chdata: # Volume for ClickHouse data - vmdata: # Volume for VictoriaMetrics data - pgdata: # Volume for PostgreSQL data - go-modules: - root-cache: - -networks: - ha: - ipam: - config: - - subnet: 172.20.0.0/24 diff --git a/docker-compose.yml b/docker-compose.yml index ee43a923c65..c522cf1ab2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: - PMM_ENABLE_TELEMETRY=${PMM_ENABLE_TELEMETRY:-0} - PMM_PUBLIC_ADDRESS=${PMM_PUBLIC_ADDRESS:-localhost} - PMM_RELEASE_VERSION=3.0.0 - - GO_VERSION=1.24.x + - GO_VERSION=1.25.x # - PMM_ENABLE_INTERNAL_PG_QAN=${PMM_ENABLE_INTERNAL_PG_QAN:-1} # - PMM_DISTRIBUTION_METHOD=${PMM_DISTRIBUTION_METHOD:-docker} # - PMM_DEV_UPDATE_DOCKER_IMAGE=${PMM_DEV_UPDATE_DOCKER_IMAGE:-} @@ -40,6 +40,8 @@ services: # - PMM_CLICKHOUSE_DATABASE=pmm # - PMM_CLICKHOUSE_USER=default # - PMM_CLICKHOUSE_PASSWORD= + # - PMM_CLICKHOUSE_IS_CLUSTER=1 + # - PMM_CLICKHOUSE_CLUSTER_NAME=pmmclickhouse # - PMM_DEBUG=1 # - PMM_DEV_ADVISOR_CHECKS_FILE=/srv/checks/local-checks.yml # - PMM_POSTGRES_ADDR=pg @@ -95,23 +97,20 @@ services: - go-modules:/root/go/pkg/mod - go-cache:/root/.cache - srv:/srv - # temporarily mount advisor files until we package them. + # mount advisor files for easy development - ./managed/data/advisors/:/usr/local/percona/advisors/ - ./managed/data/checks/:/usr/local/percona/checks/ - ./managed/data/alerting-templates/:/usr/local/percona/alerting-templates/ # grafana - # - "../grafana:/workspace" - # - "../grafana/public:/usr/share/grafana/public" - # - "../grafana/conf/grafana.local-dev.ini:/usr/share/grafana/conf/defaults.ini" - # command: > - # bash -c " - # rm -rf /tmp/certs - # mkdir /tmp/certs - # cp -R /root/go/src/github.com/percona/pmm/managed/testdata/pg/certs/* /tmp/certs - # chown grafana:grafana /tmp/certs/* - # chmod 600 /tmp/certs/* - # /opt/entrypoint.sh - # " + # - "../grafana:/workspace" + # - "../grafana/public:/usr/share/grafana/public" + # - "../grafana/conf/grafana.local-dev.ini:/usr/share/grafana/conf/defaults.ini" + # command: > + # bash -c " + # # script you want to run before entrypoint, e.g. + # bash /tmp/wait-for-it.sh -t 30 some-service:some-port -- + # /opt/entrypoint.sh + # " renderer: image: grafana/grafana-image-renderer:latest diff --git a/docs/process/best_practices.md b/docs/process/best_practices.md index 62ab1ad1a0b..afa11f7cdb9 100644 --- a/docs/process/best_practices.md +++ b/docs/process/best_practices.md @@ -32,7 +32,7 @@ For consistency, environment variables should keep to the following suggestions: variables that are not meant for end-users in any circumstance e.g., `PMM_DEV_PERCONA_PLATFORM_ADDRESS` - Use the `PMM_TEST_` prefix for any variable that is not part of PMM GA functionality. - Use the `PMM_` prefix for variables that is part of PMM GA functionality. -- Use a sub-prefix if a number of env vars relate to one component, e.g., `PMM_TEST_HA_` +- Use a sub-prefix if a number of env vars relate to one component, e.g., `PMM_HA_` - The use of PERCONA_ prefix is prohibited (exception: PMM_PERCONA_PLATFORM_URL, since it's part of a proper name, not a prefix) ## Code style diff --git a/docs/process/v2_to_v3_environment_variables.md b/docs/process/v2_to_v3_environment_variables.md index fb73aa37187..f95ad9ff4e4 100644 --- a/docs/process/v2_to_v3_environment_variables.md +++ b/docs/process/v2_to_v3_environment_variables.md @@ -63,3 +63,5 @@ Below is a list of affected variables and their new names. |--------------------------------------------|--------------------------------------------------------------| | `PMM_CLICKHOUSE_USER` | Added in v3.2.0 | | `PMM_CLICKHOUSE_PASSWORD` | Added in v3.2.0 | +| `PMM_CLICKHOUSE_IS_CLUSTER` | Added in v3.6.0 | +| `PMM_CLICKHOUSE_CLUSTER_NAME` | Added in v3.6.0 | diff --git a/documentation/docs/install-pmm/HA.md b/documentation/docs/install-pmm/HA.md index 6f6b96a3475..74fefe8ffdb 100644 --- a/documentation/docs/install-pmm/HA.md +++ b/documentation/docs/install-pmm/HA.md @@ -158,6 +158,17 @@ Choose the option that best fits your infrastructure and requirements: | `PG_USERNAME` | The username for your PostgreSQL server.

Example: `pmmuser` | | `PG_PASSWORD` | The password for your PostgreSQL server.

Example: `pgpassword` | | `GF_USERNAME` | The username for your Grafana database user.

Example: `gfuser` | + | `PMM_CLICKHOUSE_IS_CLUSTER` | Set to `1` to indicate that ClickHouse is running in cluster mode. This enables PMM to use distributed tables and cluster-aware queries for Query Analytics (QAN) metrics.

Example: `1` | + | `PMM_CLICKHOUSE_CLUSTER_NAME` | Optional. If set, specifies the ClickHouse cluster name to use for distributed table operations. Used together with PMM_CLICKHOUSE_IS_CLUSTER.

Example: `my_cluster` | + | | + + > [!WARNING] + > **Cluster Name Character Limitations:** + > Use only letters, digits, and underscores (`a-z`, `A-Z`, `0-9`, `_`) in ClickHouse cluster names. Avoid dashes (`-`), spaces, and other special characters. + + > [!NOTE] + > **QAN Initialization Delay:** + > When using ClickHouse in cluster mode (`PMM_CLICKHOUSE_IS_CLUSTER=1`), it can take up to 5 minutes for Query Analytics (QAN) to become fully operational after startup or configuration changes. During this period, you may receive error messages in the QAN interface. This is expected and will resolve automatically once the cluster is ready. | `GF_PASSWORD` | The password for your Grafana database user.

Example: `gfpassword` | | `PMM_ACTIVE_IP` | The IP address of the instance where the active PMM server is running or the desired IP address for your active PMM server container within the Docker network, depending on your setup.

Example: `17.10.1.5` | | `PMM_ACTIVE_NODE_ID` | The unique ID for your active PMM server node.

Example: `pmm-server-active` | @@ -447,14 +458,13 @@ Choose the option that best fits your infrastructure and requirements: -e GF_DATABASE_PORT=5432 \ -e GF_DATABASE_NAME=grafana \ -e PMM_VM_URL=http://${VM_HOST_IP}:8428 \ - -e PMM_TEST_HA_ENABLE=1 \ - -e PMM_TEST_HA_BOOTSTRAP=1 \ - -e PMM_TEST_HA_NODE_ID=${PMM_ACTIVE_NODE_ID} \ - -e PMM_TEST_HA_ADVERTISE_ADDRESS=${PMM_ACTIVE_IP} \ - -e PMM_TEST_HA_GOSSIP_PORT=9096 \ - -e PMM_TEST_HA_RAFT_PORT=9097 \ - -e PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 \ - -e PMM_TEST_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ + -e PMM_HA_ENABLE=1 \ + -e PMM_HA_NODE_ID=${PMM_ACTIVE_NODE_ID} \ + -e PMM_HA_ADVERTISE_ADDRESS=${PMM_ACTIVE_IP} \ + -e PMM_HA_GOSSIP_PORT=9096 \ + -e PMM_HA_RAFT_PORT=9097 \ + -e PMM_HA_GRAFANA_GOSSIP_PORT=9094 \ + -e PMM_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ -v pmm-server-active_data:/srv \ ${PMM_DOCKER_IMAGE} ``` @@ -486,14 +496,13 @@ Choose the option that best fits your infrastructure and requirements: -e GF_DATABASE_PORT=5432 \ -e GF_DATABASE_NAME=grafana \ -e PMM_VM_URL=http://${VM_HOST_IP}:8428 \ - -e PMM_TEST_HA_ENABLE=1 \ - -e PMM_TEST_HA_BOOTSTRAP=1 \ - -e PMM_TEST_HA_NODE_ID=${PMM_ACTIVE_NODE_ID} \ - -e PMM_TEST_HA_ADVERTISE_ADDRESS=${PMM_ACTIVE_IP} \ - -e PMM_TEST_HA_GOSSIP_PORT=9096 \ - -e PMM_TEST_HA_RAFT_PORT=9097 \ - -e PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 \ - -e PMM_TEST_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ + -e PMM_HA_ENABLE=1 \ + -e PMM_HA_NODE_ID=${PMM_ACTIVE_NODE_ID} \ + -e PMM_HA_ADVERTISE_ADDRESS=${PMM_ACTIVE_IP} \ + -e PMM_HA_GOSSIP_PORT=9096 \ + -e PMM_HA_RAFT_PORT=9097 \ + -e PMM_HA_GRAFANA_GOSSIP_PORT=9094 \ + -e PMM_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ -v pmm-server-active_data:/srv \ ${PMM_DOCKER_IMAGE} ``` @@ -523,14 +532,13 @@ Choose the option that best fits your infrastructure and requirements: -e GF_DATABASE_PORT=5432 \ -e GF_DATABASE_NAME=grafana \ -e PMM_VM_URL=http://${VM_HOST_IP}:8428 \ - -e PMM_TEST_HA_ENABLE=1 \ - -e PMM_TEST_HA_BOOTSTRAP=0 \ - -e PMM_TEST_HA_NODE_ID=${PMM_PASSIVE_NODE_ID} \ - -e PMM_TEST_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE_IP} \ - -e PMM_TEST_HA_GOSSIP_PORT=9096 \ - -e PMM_TEST_HA_RAFT_PORT=9097 \ - -e PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 \ - -e PMM_TEST_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ + -e PMM_HA_ENABLE=1 \ + -e PMM_HA_NODE_ID=${PMM_PASSIVE_NODE_ID} \ + -e PMM_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE_IP} \ + -e PMM_HA_GOSSIP_PORT=9096 \ + -e PMM_HA_RAFT_PORT=9097 \ + -e PMM_HA_GRAFANA_GOSSIP_PORT=9094 \ + -e PMM_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ -v pmm-server-passive_data:/srv \ ${PMM_DOCKER_IMAGE} ``` @@ -562,14 +570,13 @@ Choose the option that best fits your infrastructure and requirements: -e GF_DATABASE_PORT=5432 \ -e GF_DATABASE_NAME=grafana \ -e PMM_VM_URL=http://${VM_HOST_IP}:8428 \ - -e PMM_TEST_HA_ENABLE=1 \ - -e PMM_TEST_HA_BOOTSTRAP=0 \ - -e PMM_TEST_HA_NODE_ID=${PMM_PASSIVE_NODE_ID} \ - -e PMM_TEST_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE_IP} \ - -e PMM_TEST_HA_GOSSIP_PORT=9096 \ - -e PMM_TEST_HA_RAFT_PORT=9097 \ - -e PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 \ - -e PMM_TEST_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ + -e PMM_HA_ENABLE=1 \ + -e PMM_HA_NODE_ID=${PMM_PASSIVE_NODE_ID} \ + -e PMM_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE_IP} \ + -e PMM_HA_GOSSIP_PORT=9096 \ + -e PMM_HA_RAFT_PORT=9097 \ + -e PMM_HA_GRAFANA_GOSSIP_PORT=9094 \ + -e PMM_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ -v pmm-server-passive_data:/srv \ ${PMM_DOCKER_IMAGE} ``` @@ -599,14 +606,13 @@ Choose the option that best fits your infrastructure and requirements: -e GF_DATABASE_PORT=5432 \ -e GF_DATABASE_NAME=grafana \ -e PMM_VM_URL=http://${VM_HOST_IP}:8428 \ - -e PMM_TEST_HA_ENABLE=1 \ - -e PMM_TEST_HA_BOOTSTRAP=0 \ - -e PMM_TEST_HA_NODE_ID=${PMM_PASSIVE2_NODE_ID} \ - -e PMM_TEST_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE2_IP} \ - -e PMM_TEST_HA_GOSSIP_PORT=9096 \ - -e PMM_TEST_HA_RAFT_PORT=9097 \ - -e PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 \ - -e PMM_TEST_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ + -e PMM_HA_ENABLE=1 \ + -e PMM_HA_NODE_ID=${PMM_PASSIVE2_NODE_ID} \ + -e PMM_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE2_IP} \ + -e PMM_HA_GOSSIP_PORT=9096 \ + -e PMM_HA_RAFT_PORT=9097 \ + -e PMM_HA_GRAFANA_GOSSIP_PORT=9094 \ + -e PMM_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ -v pmm-server-passive-2_data:/srv \ ${PMM_DOCKER_IMAGE} ``` @@ -638,14 +644,13 @@ Choose the option that best fits your infrastructure and requirements: -e GF_DATABASE_PORT=5432 \ -e GF_DATABASE_NAME=grafana \ -e PMM_VM_URL=http://${VM_HOST_IP}:8428 \ - -e PMM_TEST_HA_ENABLE=1 \ - -e PMM_TEST_HA_BOOTSTRAP=0 \ - -e PMM_TEST_HA_NODE_ID=${PMM_PASSIVE2_NODE_ID} \ - -e PMM_TEST_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE2_IP} \ - -e PMM_TEST_HA_GOSSIP_PORT=9096 \ - -e PMM_TEST_HA_RAFT_PORT=9097 \ - -e PMM_TEST_HA_GRAFANA_GOSSIP_PORT=9094 \ - -e PMM_TEST_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ + -e PMM_HA_ENABLE=1 \ + -e PMM_HA_NODE_ID=${PMM_PASSIVE2_NODE_ID} \ + -e PMM_HA_ADVERTISE_ADDRESS=${PMM_PASSIVE2_IP} \ + -e PMM_HA_GOSSIP_PORT=9096 \ + -e PMM_HA_RAFT_PORT=9097 \ + -e PMM_HA_GRAFANA_GOSSIP_PORT=9094 \ + -e PMM_HA_PEERS=${PMM_ACTIVE_IP},${PMM_PASSIVE_IP},${PMM_PASSIVE2_IP} \ -v pmm-server-passive-2_data:/srv \ ${PMM_DOCKER_IMAGE} ``` @@ -654,7 +659,7 @@ Choose the option that best fits your infrastructure and requirements: ### Step 7: Set up HAProxy - HAProxy provides high availability for your PMM setup by directing traffic to the current leader server via the `/v1/leaderHealthCheck` endpoint: + HAProxy provides high availability for your PMM setup by directing traffic to the current leader server via the `/v1/server/leaderHealthCheck` endpoint: {.power-number} 1. Pull the HAProxy Docker image: @@ -703,7 +708,7 @@ Choose the option that best fits your infrastructure and requirements: Replace `/path/to/haproxy-config` with the path where you want to store your HAProxy configuration. - 7. Create an HAProxy configuration file named `haproxy.cfg.template` in that directory. This configuration tells HAProxy to use the `/v1/leaderHealthCheck` endpoint of each PMM server to identify the leader: + 7. Create an HAProxy configuration file named `haproxy.cfg.template` in that directory. This configuration tells HAProxy to use the `/v1/server/leaderHealthCheck` endpoint of each PMM server to identify the leader: ``` global @@ -731,7 +736,7 @@ Choose the option that best fits your infrastructure and requirements: backend http_back option httpchk - http-check send meth POST uri /v1/leaderHealthCheck ver HTTP/1.1 hdr Host www + http-check send meth GET uri /v1/server/leaderHealthCheck ver HTTP/1.1 hdr Host www http-check expect status 200 server pmm-server-active-http PMM_ACTIVE_IP:8080 check server pmm-server-passive-http PMM_PASSIVE_IP:8080 check backup @@ -739,7 +744,7 @@ Choose the option that best fits your infrastructure and requirements: backend https_back option httpchk - http-check send meth POST uri /v1/leaderHealthCheck ver HTTP/1.1 hdr Host www + http-check send meth GET uri /v1/server/leaderHealthCheck ver HTTP/1.1 hdr Host www http-check expect status 200 server pmm-server-active-https PMM_ACTIVE_IP:8443 check ssl verify none server pmm-server-passive-https PMM_PASSIVE_IP:8443 check ssl verify none diff --git a/documentation/docs/install-pmm/install-pmm-server/deployment-options/docker/preview_env_var.md b/documentation/docs/install-pmm/install-pmm-server/deployment-options/docker/preview_env_var.md index 211b653b06f..74f2bae76e4 100644 --- a/documentation/docs/install-pmm/install-pmm-server/deployment-options/docker/preview_env_var.md +++ b/documentation/docs/install-pmm/install-pmm-server/deployment-options/docker/preview_env_var.md @@ -6,14 +6,13 @@ For stable, production-ready configuration options, see the main [Environment variables for PMM Server](../docker/env_var.md) documentation. | Variable | Description | -------------------------------------- | -------------------------------------------------------------------------------------------------------- -| `PMM_TEST_HA_ENABLE` | Enable PMM to run in High Availability (HA) mode. -| `PMM_TEST_HA_BOOTSTRAP` | Bootstrap HA cluster. -| `PMM_TEST_HA_NODE_ID` | HA Node ID. -| `PMM_TEST_HA_ADVERTISE_ADDRESS` | HA Advertise address. -| `PMM_TEST_HA_GOSSIP_PORT` | HA gossip port. -| `PMM_TEST_HA_RAFT_PORT` | HA raft port. -| `PMM_TEST_HA_GRAFANA_GOSSIP_PORT` | HA Grafana gossip port. -| `PMM_TEST_HA_PEERS` | HA Peers. +| `PMM_HA_ENABLE` | Enable PMM to run in High Availability (HA) mode. +| `PMM_HA_NODE_ID` | HA Node ID. +| `PMM_HA_ADVERTISE_ADDRESS` | HA Advertise address. +| `PMM_HA_GOSSIP_PORT` | HA gossip port. +| `PMM_HA_RAFT_PORT` | HA raft port. +| `PMM_HA_GRAFANA_GOSSIP_PORT` | HA Grafana gossip port. +| `PMM_HA_PEERS` | HA Peers. ## Available preview variables diff --git a/documentation/docs/install-pmm/install-pmm-server/deployment-options/helm/index.md b/documentation/docs/install-pmm/install-pmm-server/deployment-options/helm/index.md index bd3f49d4fd0..7927f6372ef 100644 --- a/documentation/docs/install-pmm/install-pmm-server/deployment-options/helm/index.md +++ b/documentation/docs/install-pmm/install-pmm-server/deployment-options/helm/index.md @@ -147,10 +147,10 @@ Create the required Kubernetes secret and deploy PMM Server using Helm: ```bash # If using ClusterIP (default) - kubectl port-forward svc/pmm-service 443:443 + kubectl port-forward svc/monitoring-service 443:443 # If using NodePort - kubectl get svc pmm-service -o jsonpath='{.spec.ports[0].nodePort}' + kubectl get svc monitoring-service -o jsonpath='{.spec.ports[0].nodePort}' ``` === "On OpenShift" @@ -158,13 +158,13 @@ Create the required Kubernetes secret and deploy PMM Server using Helm: ```bash # Create a Route to expose PMM - oc expose svc/pmm-service --port=443 + oc expose svc/monitoring-service --port=443 # Get the Route URL - oc get route pmm-service -o jsonpath='{.spec.host}' + oc get route monitoring-service -o jsonpath='{.spec.host}' # Or use port-forwarding for testing - oc port-forward svc/pmm-service 443:443 + oc port-forward svc/monitoring-service 443:443 ``` ### Configure PMM Server diff --git a/documentation/docs/release-notes/3.6.0.md b/documentation/docs/release-notes/3.6.0.md new file mode 100644 index 00000000000..edc256d19a4 --- /dev/null +++ b/documentation/docs/release-notes/3.6.0.md @@ -0,0 +1,55 @@ +# Percona Monitoring and Management 3.6.0 + +**Release date**: January 2026 + +Percona Monitoring and Management (PMM) is an open source database monitoring, management, and observability solution for MySQL, PostgreSQL, MongoDB, Valkey and Redis. PMM empowers you to: + + +- monitor the health and performance of your database systems +- identify patterns and trends in database behavior +- diagnose and resolve issues faster with actionable insights +- manage databases across on-premises, cloud, and hybrid environments + +## 📋 Release summary + +## ✨ Release highlights + +### + +## 🔒 Security updates + +### + +## 📈 Improvements + +- [PMM-14528](https://perconadev.atlassian.net/browse/PMM-14528): Updated Watchtower and Docker API libraries with full Docker v29.0.0 support. The `DOCKER_API_VERSION` workaround is no longer required. If you're using an older watchtower version and see `client version is too old` errors, see [Troubleshoot upgrade issues](../troubleshoot/upgrade_issues.md#watchtower-fails-with-client-version-is-too-old-error). + + + +## ✅ Fixed issues + +- [PMM-14378](https://perconadev.atlassian.net/browse/PMM-14378): Fixed `waitid: no child processes` error that could occasionally occur when registering PMM Client (Docker distribution) with PMM Server. + +- [PMM-14321](https://perconadev.atlassian.net/browse/PMM-14321): Fixed a PMM Agent crash triggered when parsing slow query log entries containing queries that use `Value` as a column alias. + +- [PMM-14440](https://perconadev.atlassian.net/browse/PMM-14440): Fixed `excessive was collected before with the same name and label values` errors in `mysqld_exporter` logs that caused rapid log file growth. + +- [PMM-14568](https://perconadev.atlassian.net/browse/PMM-14568): Fixed `container is not a PMM server` error when upgrading PMM Server via the UI. This occurred when the image name was different from `pmm-server`. + +- [PMM-10308](https://perconadev.atlassian.net/browse/PMM-10308): Fixed missing metric queries in the **MySQL Instance Summary** dashboard that caused several panels to show `N/A` instead of actual values. + +- [PMM-14573](https://perconadev.atlassian.net/browse/PMM-14573): Fixed a connection leak in MongoDB exporter that could exhaust connections and crash MongoDB nodes when replica set members were unreachable. + + + + + + + + + +## 🚀 Ready to upgrade to PMM 3.5.0? + +- **New installation:** [Install PMM with our quickstart guide](../quickstart/quickstart.md) +- **Upgrading PMM 3:** [Upgrade your existing PMM 3 installation](../pmm-upgrade/index.md) +- **Upgrading from PMM 2:** [Migrate from PMM 2 to PMM 3](../pmm-upgrade/migrating_from_pmm_2.md) diff --git a/documentation/docs/troubleshoot/upgrade_issues.md b/documentation/docs/troubleshoot/upgrade_issues.md index 58062d03568..2e434f62521 100644 --- a/documentation/docs/troubleshoot/upgrade_issues.md +++ b/documentation/docs/troubleshoot/upgrade_issues.md @@ -3,13 +3,32 @@ ## PMM Server not updating correctly If the automatic update process isn't working, you can force an update using the API: - +{.power-number} 1. Open your terminal. -2. Run the update command, replacing : with your credentials and with your PMM server addressЖ - +2. Run the update command, replacing : with your credentials and with your PMM server addressЖ ```curl -X POST \ --user : \ 'http:///v1/server/updates:start' \ -H 'Content-Type: application/json' ``` -3. Wait 2-5 minutes and refresh the PMM Home page to verify the update. \ No newline at end of file +3. Wait 2-5 minutes and refresh the PMM Home page to verify the update. + +## Watchtower fails with "client version is too old" error + +When upgrading PMM Server via the UI, Watchtower may fail with the following error: +``` +client version X.XX is too old. Minimum supported API version is X.XX, please upgrade your client to a newer version +``` +This occurs when your Docker installation requires a newer API version than Watchtower supports. + +**Solution** +To resolve this issue: +{.power-number} +1. Update to the latest Watchtower image: +``` + docker pull percona/watchtower:latest +``` +2. If the error persists, add the `DOCKER_API_VERSION` environment variable matching your Docker's minimum API version: +``` + -e DOCKER_API_VERSION=1.45 +``` \ No newline at end of file diff --git a/go.mod b/go.mod index b3580bc54ae..8d3a62b19c1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/percona/pmm -go 1.25.4 +go 1.25.5 // Update saas with // go get -v github.com/percona/saas@latest @@ -15,10 +15,10 @@ replace golang.org/x/crypto => github.com/percona-lab/crypto v0.0.0-202311081441 require ( github.com/AlekSi/pointer v1.2.0 - github.com/ClickHouse/clickhouse-go/v2 v2.41.0 + github.com/ClickHouse/clickhouse-go/v2 v2.42.0 github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/alecthomas/kingpin/v2 v2.4.0 - github.com/alecthomas/kong v1.12.0 + github.com/alecthomas/kong v1.13.0 github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2/config v1.32.2 @@ -28,7 +28,7 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/envoyproxy/protoc-gen-validate v1.2.1 + github.com/envoyproxy/protoc-gen-validate v1.3.0 github.com/go-co-op/gocron v1.37.0 github.com/go-openapi/errors v0.22.4 github.com/go-openapi/runtime v0.29.0 @@ -45,7 +45,8 @@ require ( github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 github.com/hashicorp/go-version v1.8.0 - github.com/hashicorp/raft v1.7.0 + github.com/hashicorp/raft v1.7.3 + github.com/hashicorp/raft-boltdb/v2 v2.3.1 github.com/jmoiron/sqlx v1.4.0 github.com/jotaen/kong-completion v0.0.5 github.com/lib/pq v1.10.9 @@ -70,14 +71,15 @@ require ( github.com/tink-crypto/tink-go v0.0.0-20230613075026-d6de17e3f164 go.mongodb.org/mongo-driver v1.17.6 go.starlark.net v0.0.0-20230717150657-8a3343210976 - golang.org/x/crypto v0.45.0 - golang.org/x/sync v0.18.0 - golang.org/x/sys v0.38.0 - golang.org/x/text v0.31.0 - golang.org/x/tools v0.39.0 - google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 - google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 - google.golang.org/grpc v1.77.0 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/crypto v0.46.0 + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.39.0 + golang.org/x/text v0.32.0 + golang.org/x/tools v0.40.0 + google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda + google.golang.org/grpc v1.78.0 google.golang.org/protobuf v1.36.10 gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 gopkg.in/reform.v1 v1.5.1 @@ -96,6 +98,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect + github.com/boltdb/bolt v1.3.1 // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -118,7 +121,8 @@ require ( github.com/google/btree v1.0.1 // indirect github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-msgpack/v2 v2.1.1 // indirect + github.com/hashicorp/go-metrics v0.5.4 // indirect + github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/miekg/dns v1.1.68 // indirect @@ -128,11 +132,11 @@ require ( github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + go.etcd.io/bbolt v1.3.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) @@ -189,8 +193,8 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 62994b63346..25b964533f2 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= @@ -32,8 +33,8 @@ github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/ github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg= github.com/ClickHouse/clickhouse-go v1.4.3 h1:iAFMa2UrQdR5bHJ2/yaSLffZkxpcOYQMCUuKeNXGdqc= github.com/ClickHouse/clickhouse-go v1.4.3/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= -github.com/ClickHouse/clickhouse-go/v2 v2.41.0 h1:JbLKMXLEkW0NMalMgI+GYb6FVZtpaMVEzQa/HC1ZMRE= -github.com/ClickHouse/clickhouse-go/v2 v2.41.0/go.mod h1:/RoTHh4aDA4FOCIQggwsiOwO7Zq1+HxQ0inef0Au/7k= +github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk= +github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= @@ -50,14 +51,15 @@ github.com/Percona-Lab/spec v0.21.0-percona/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.12.0 h1:oKd/0fHSdajj5PfGDd3ScvEvpVJf9mT2mb5r9xYadYM= -github.com/alecthomas/kong v1.12.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= +github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= @@ -105,6 +107,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/brianvoe/gofakeit v3.18.0+incompatible h1:wDOmHc9DLG4nRjUVVaxA+CEglKOW72Y5+4WNxUIkjM8= github.com/brianvoe/gofakeit v3.18.0+incompatible/go.mod h1:kfwdRA90vvNhPutZWfH7WPaDzUjz+CZFqG+rPkOjGOc= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= @@ -152,8 +156,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= @@ -258,6 +262,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -276,6 +282,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -308,11 +315,13 @@ github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVH github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= +github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-msgpack v1.1.5 h1:9byZdVjKTe5mce63pRVNP1L7UAmdHOTEMGehn6KvJWs= github.com/hashicorp/go-msgpack v1.1.5/go.mod h1:gWVc3sv/wbDmR3rQsj1CAktEZzoz1YNK9NfGLXJ69/4= -github.com/hashicorp/go-msgpack/v2 v2.1.1 h1:xQEY9yB2wnHitoSzk/B9UjXWRQ67QKu5AOm8aFp8N3I= -github.com/hashicorp/go-msgpack/v2 v2.1.1/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4= +github.com/hashicorp/go-msgpack/v2 v2.1.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0= +github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= @@ -329,8 +338,12 @@ github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= -github.com/hashicorp/raft v1.7.0 h1:4u24Qn6lQ6uwziM++UgsyiT64Q8GyRn43CV41qPiz1o= -github.com/hashicorp/raft v1.7.0/go.mod h1:N1sKh6Vn47mrWvEArQgILTyng8GoDRNYlgKyK7PMjs0= +github.com/hashicorp/raft v1.7.3 h1:DxpEqZJysHN0wK+fviai5mFcSYsCkNpFUl1xpAW8Rbo= +github.com/hashicorp/raft v1.7.3/go.mod h1:DfvCGFxpAUPE0L4Uc8JLlTPtc3GzSbdH0MTJCLgnmJQ= +github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702 h1:RLKEcCuKcZ+qp2VlaaZsYZfLOmIiuJNpEi48Rl8u9cQ= +github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702/go.mod h1:nTakvJ4XYq45UXtn0DbwR4aU9ZdjlnIenpbs6Cd+FM0= +github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyvj6eW9pwkc= +github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= @@ -344,9 +357,12 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= @@ -359,6 +375,7 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -469,6 +486,8 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang/exp v0.0.0-20250914183048-a974e0d45e0a h1:RF1vfKM34/3DbGNis22BGd6sDDY3XBi0eM7pYqmOEO0= @@ -481,6 +500,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= @@ -488,6 +509,8 @@ github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVR github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/prometheus v0.308.0 h1:kVh/5m1n6m4cSK9HYTDEbMxzuzCWyEdPdKSxFRxXj04= @@ -515,6 +538,7 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -558,6 +582,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= @@ -565,16 +591,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.starlark.net v0.0.0-20230717150657-8a3343210976 h1:7ljYNcZU84T2N0tZdDgvL7U3M4iFmglAUUU1gRFE/2Q= go.starlark.net v0.0.0-20230717150657-8a3343210976/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -601,12 +627,13 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -615,13 +642,15 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -630,22 +659,29 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -662,15 +698,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -680,8 +716,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -698,8 +734,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -715,24 +751,25 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= -google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= @@ -758,6 +795,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/managed/AGENT.md b/managed/AGENT.md index 4ab8c68d238..717f5f9b3ce 100644 --- a/managed/AGENT.md +++ b/managed/AGENT.md @@ -114,6 +114,7 @@ Multiple code generation tools are used: - Don't commit test binaries or test artifacts (add to `.gitignore` if needed) - Don't comment on every single line of code unnecessarily, only where clarity is needed - Don't inline comments (i.e. `code // comment`), always put comments on separate lines +- Don't use named return values in functions ### Error Handling - Use `status.Error()` for gRPC errors with proper codes diff --git a/managed/README.md b/managed/README.md deleted file mode 100644 index bab621bbf76..00000000000 --- a/managed/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Percona Monitoring and Management (PMM) management daemon - -**pmm-managed** manages the configuration of [PMM](https://docs.percona.com/percona-monitoring-and-management/index.html) -server components (VictoriaMetrics, Grafana, QAN, etc.) and exposes APIs for interacting with them. Those APIs are also used by -[pmm-admin tool](https://github.com/percona/pmm/tree/main/admin). diff --git a/managed/cmd/pmm-encryption-rotation/main.go b/managed/cmd/pmm-encryption-rotation/main.go index 23f267b735e..2769f14c6af 100644 --- a/managed/cmd/pmm-encryption-rotation/main.go +++ b/managed/cmd/pmm-encryption-rotation/main.go @@ -26,6 +26,7 @@ import ( "github.com/percona/pmm/managed/models" encryptionService "github.com/percona/pmm/managed/services/encryption" + "github.com/percona/pmm/managed/utils/encryption" "github.com/percona/pmm/utils/logger" "github.com/percona/pmm/version" ) @@ -37,9 +38,37 @@ func main() { logger.SetupGlobalLogger() - logrus.Infof("PMM Encryption Rotation Tools version: %s", version.Version) + var opts flags + kong.Parse( + &opts, + kong.Name("encryption-rotation"), + kong.Description(fmt.Sprintf("Version %s", version.Version)), //nolint:perfsprint + kong.UsageOnError(), + kong.ConfigureHelp(kong.HelpOptions{ + Compact: true, + NoExpandSubcommands: true, + }), + kong.Vars{ + "address": models.DefaultPostgreSQLAddr, + "disable_sslmode": models.DisableSSLMode, + "require_sslmode": models.RequireSSLMode, + "verify_sslmode": models.VerifyCaSSLMode, + "verify_full_sslmode": models.VerifyFullSSLMode, + }, + ) + + if opts.GenerateKey { + e := &encryption.Encryption{} + key, err := e.GenerateKey() + if err != nil { + logrus.Errorf("Failed to generate key: %v", err) + os.Exit(1) + } + fmt.Print(key) //nolint:forbidigo + os.Exit(0) + } - sqlDB, err := models.OpenDB(setupParams()) + sqlDB, err := models.OpenDB(setupParams(opts)) if err != nil { logrus.Error(err) os.Exit(codeDBConnectionFailed) @@ -62,28 +91,10 @@ type flags struct { SSLCAPath string `name:"postgres-ssl-ca-path" help:"PostgreSQL SSL CA root certificate path" type:"path"` SSLKeyPath string `name:"postgres-ssl-key-path" help:"PostgreSQL SSL key path" type:"path"` SSLCertPath string `name:"postgres-ssl-cert-path" help:"PostgreSQL SSL certificate path" type:"path"` + GenerateKey bool `name:"generate-key" help:"Only generate a new encryption key and print to stdout"` } -func setupParams() models.SetupDBParams { - var opts flags - kong.Parse( - &opts, - kong.Name("encryption-rotation"), - kong.Description(fmt.Sprintf("Version %s", version.Version)), //nolint:perfsprint - kong.UsageOnError(), - kong.ConfigureHelp(kong.HelpOptions{ - Compact: true, - NoExpandSubcommands: true, - }), - kong.Vars{ - "address": models.DefaultPostgreSQLAddr, - "disable_sslmode": models.DisableSSLMode, - "require_sslmode": models.RequireSSLMode, - "verify_sslmode": models.VerifyCaSSLMode, - "verify_full_sslmode": models.VerifyFullSSLMode, - }, - ) - +func setupParams(opts flags) models.SetupDBParams { return models.SetupDBParams{ Address: opts.Address, Name: opts.DBName, diff --git a/managed/cmd/pmm-managed-init/main.go b/managed/cmd/pmm-managed-init/main.go index 4934ef14013..b7c18fd59a0 100644 --- a/managed/cmd/pmm-managed-init/main.go +++ b/managed/cmd/pmm-managed-init/main.go @@ -55,6 +55,13 @@ func main() { pmmConfigParams := make(map[string]any) pmmConfigParams["DisableInternalDB"], _ = strconv.ParseBool(os.Getenv("PMM_DISABLE_BUILTIN_POSTGRES")) pmmConfigParams["DisableInternalClickhouse"], _ = strconv.ParseBool(os.Getenv("PMM_DISABLE_BUILTIN_CLICKHOUSE")) + pmmConfigParams["AgentConfigFilePath"] = models.AgentConfigFilePath + + isHAEnabled, _ := strconv.ParseBool(os.Getenv("PMM_HA_ENABLE")) + if isHAEnabled { + pmmConfigParams["AgentConfigFilePath"] = "/srv/pmm-agent/config/pmm-agent.yaml" + } + if err := supervisord.SavePMMConfig(pmmConfigParams); err != nil { logrus.Errorf("PMM Server configuration error: %s.", err) os.Exit(1) diff --git a/managed/cmd/pmm-managed/main.go b/managed/cmd/pmm-managed/main.go index 92930a24fc5..a3da2c35b48 100644 --- a/managed/cmd/pmm-managed/main.go +++ b/managed/cmd/pmm-managed/main.go @@ -67,6 +67,7 @@ import ( alertingv1 "github.com/percona/pmm/api/alerting/v1" backupv1 "github.com/percona/pmm/api/backup/v1" dumpv1beta1 "github.com/percona/pmm/api/dump/v1beta1" + hav1beta1 "github.com/percona/pmm/api/ha/v1beta1" inventoryv1 "github.com/percona/pmm/api/inventory/v1" managementv1 "github.com/percona/pmm/api/management/v1" platformv1 "github.com/percona/pmm/api/platform/v1" @@ -298,6 +299,8 @@ func runGRPCServer(ctx context.Context, deps *gRPCServerDeps) { uieventsv1.RegisterUIEventsServiceServer(gRPCServer, deps.uieventsService) + hav1beta1.RegisterHAServiceServer(gRPCServer, ha.NewHAServer(deps.ha)) + // run server until it is stopped gracefully or not listener, err := net.Listen("tcp", gRPCAddr) if err != nil { @@ -397,6 +400,8 @@ func runHTTP1Server(ctx context.Context, deps *http1ServerDeps) { uieventsv1.RegisterUIEventsServiceHandler, userv1.RegisterUserServiceHandler, + + hav1beta1.RegisterHAServiceHandler, } { if err := r(ctx, proxyMux, sharedConn); err != nil { l.Panic(err) @@ -536,7 +541,7 @@ func setup(ctx context.Context, deps *setupDeps) bool { deps.l.Infof("Checking VictoriaMetrics...") if err = deps.vmdb.IsReady(ctx); err != nil { - deps.l.Warnf("VictoriaMetrics problem: %+v.", err) + deps.l.Warnf("Failed to check VictoriaMetrics readiness: %+v.", err) return false } deps.vmdb.RequestConfigurationUpdate() @@ -596,6 +601,7 @@ func migrateDB(ctx context.Context, sqlDB *sql.DB, params models.SetupDBParams) l.Infof("Migrating database...") _, err := models.SetupDB(timeoutCtx, sqlDB, params) if err == nil { + l.Infof("Database migration completed.") return } @@ -671,30 +677,27 @@ func main() { //nolint:maintidx,cyclop String() haEnabled := kingpin.Flag("ha-enable", "Enable HA"). - Envar("PMM_TEST_HA_ENABLE"). - Bool() - haBootstrap := kingpin.Flag("ha-bootstrap", "Bootstrap HA cluster"). - Envar("PMM_TEST_HA_BOOTSTRAP"). + Envar("PMM_HA_ENABLE"). Bool() haNodeID := kingpin.Flag("ha-node-id", "HA Node ID"). - Envar("PMM_TEST_HA_NODE_ID"). + Envar("PMM_HA_NODE_ID"). String() haAdvertiseAddress := kingpin.Flag("ha-advertise-address", "HA Advertise address"). - Envar("PMM_TEST_HA_ADVERTISE_ADDRESS"). + Envar("PMM_HA_ADVERTISE_ADDRESS"). String() haPeers := kingpin.Flag("ha-peers", "HA Peers"). - Envar("PMM_TEST_HA_PEERS"). + Envar("PMM_HA_PEERS"). String() haRaftPort := kingpin.Flag("ha-raft-port", "HA raft port"). - Envar("PMM_TEST_HA_RAFT_PORT"). + Envar("PMM_HA_RAFT_PORT"). Default("9760"). Int() haGossipPort := kingpin.Flag("ha-gossip-port", "HA gossip port"). - Envar("PMM_TEST_HA_GOSSIP_PORT"). + Envar("PMM_HA_GOSSIP_PORT"). Default("9761"). Int() haGrafanaGossipPort := kingpin.Flag("ha-grafana-gossip-port", "HA Grafana gossip port"). - Envar("PMM_TEST_HA_GRAFANA_GOSSIP_PORT"). + Envar("PMM_HA_GRAFANA_GOSSIP_PORT"). Default("9762"). Int() @@ -763,7 +766,6 @@ func main() { //nolint:maintidx,cyclop } haParams := &models.HAParams{ Enabled: *haEnabled, - Bootstrap: *haBootstrap, NodeID: *haNodeID, AdvertiseAddress: *haAdvertiseAddress, Nodes: nodes, @@ -832,6 +834,8 @@ func main() { //nolint:maintidx,cyclop SSLCAPath: *postgresSSLCAPathF, SSLKeyPath: *postgresSSLKeyPathF, SSLCertPath: *postgresSSLCertPathF, + HANodeID: *haNodeID, + HAPeers: nodes, } sqlDB, err := models.OpenDB(setupParams) @@ -840,29 +844,30 @@ func main() { //nolint:maintidx,cyclop } defer sqlDB.Close() //nolint:errcheck - if haService.Bootstrap() { - migrateDB(ctx, sqlDB, setupParams) + if *haEnabled { + models.AgentConfigFilePath = "/srv/pmm-agent/config/pmm-agent.yaml" } + migrateDB(ctx, sqlDB, setupParams) + prom.MustRegister(sqlmetrics.NewCollector("postgres", *postgresDBNameF, sqlDB)) reformL := sqlmetrics.NewReform("postgres", *postgresDBNameF, logrus.WithField("component", "reform").Tracef) prom.MustRegister(reformL) db := reform.NewDB(sqlDB, postgresql.Dialect, reformL) - if haService.Bootstrap() { - // Generate unique PMM Server ID if it's not already set. - err = models.SetPMMServerID(db) - if err != nil { - l.Panicf("failed to set PMM Server ID") - } + // Generate unique PMM Server ID if it's not already. + err = models.SetPMMServerID(db) + if err != nil { + l.Panicf("failed to set PMM Server ID") } cleaner := clean.New(db) externalRules := vmalert.NewExternalRules() - vmdb, err := victoriametrics.NewVictoriaMetrics(*victoriaMetricsConfigF, db, vmParams) + vmdb, err := victoriametrics.NewVictoriaMetrics(*victoriaMetricsConfigF, db, vmParams, haService) if err != nil { l.Panicf("VictoriaMetrics service problem: %+v", err) } + vmalert, err := vmalert.NewVMAlert(externalRules, *victoriaMetricsVMAlertURLF) if err != nil { l.Panicf("VictoriaMetrics VMAlert service problem: %+v", err) @@ -873,12 +878,14 @@ func main() { //nolint:maintidx,cyclop qanClient := getQANClient(ctx, sqlDB, *postgresDBNameF, *qanAPIAddrF) - agentsRegistry := agents.NewRegistry(db, vmParams) + agentsRegistry := agents.NewRegistry(db, vmParams, haService) - // TODO remove once PMM cluster will be Active-Active - haService.AddLeaderService(ha.NewStandardService("agentsRegistry", func(_ context.Context) error { return nil }, func() { - agentsRegistry.KickAll(ctx) - })) + // TODO remove once PMM cluster is Active-Active + // TODO kick non-pmm-server agents only + // haService.AddLeaderService(ha.NewStandardService( + // "agentsRegistry", + // func(_ context.Context) error { return nil }, + // func() { agentsRegistry.KickAll(ctx) })) pbmPITRService := backup.NewPBMPITRService() backupRemovalService := backup.NewRemovalService(db, pbmPITRService) @@ -913,18 +920,11 @@ func main() { //nolint:maintidx,cyclop HAParams: haParams, }) - haService.AddLeaderService(ha.NewStandardService("pmm-agent-runner", func(_ context.Context) error { - err := supervisord.StartSupervisedService("pmm-agent") - if err != nil { - l.Warnf("couldn't start pmm-agent: %q", err) - } - return err - }, func() { - err := supervisord.StopSupervisedService("pmm-agent") - if err != nil { - l.Warnf("couldn't stop pmm-agent: %q", err) - } - })) + // Keep the agent always running, even on follower nodes. + err = supervisord.StartSupervisedService("pmm-agent") + if err != nil { + l.Warnf("Could not start pmm-agent: %s", err) + } platformAddress, err := envvars.GetPlatformAddress() if err != nil { diff --git a/managed/data/checks/mongodb_version.yml b/managed/data/checks/mongodb_version.yml index 0f60f00f6b6..bb00f352e0b 100644 --- a/managed/data/checks/mongodb_version.yml +++ b/managed/data/checks/mongodb_version.yml @@ -17,8 +17,8 @@ checks: "4.4": 40429, # https://www.percona.com/downloads/percona-server-mongodb-4.4/ "5.0": 50029, # https://www.percona.com/downloads/percona-server-mongodb-5.0/ "6.0": 60025, # https://www.percona.com/downloads/percona-server-mongodb-6.0/ - "7.0": 70024, # https://www.percona.com/downloads/percona-server-mongodb-7.0/ - "8.0": 80012, # https://www.percona.com/downloads/percona-server-mongodb-8.0/ + "7.0": 70026, # https://www.percona.com/downloads/percona-server-mongodb-7.0/ + "8.0": 80016, # https://www.percona.com/downloads/percona-server-mongodb-8.0/ }, "read_url": "https://docs.percona.com/percona-platform/advisors/checks/{}.html", } diff --git a/managed/models/agent_model.go b/managed/models/agent_model.go index 609d237bd6a..359ca192d92 100644 --- a/managed/models/agent_model.go +++ b/managed/models/agent_model.go @@ -83,7 +83,12 @@ const ( var v2_42 = version.MustParse("2.42.0-0") // PMMServerAgentID is a special Agent ID representing pmm-agent on PMM Server. -const PMMServerAgentID = string("pmm-server") // a special ID, reserved for PMM Server +// It takes the value of "pmm-server" in regular non-HA setups, while in Active/Active HA setups +// it is set to the actual pmm-agent's Agent ID, which is a UUID. +var PMMServerAgentID = string("pmm-server") + +// AgentConfigFilePath is the default path to pmm-agent config file; it changes to /srv in HA setups. +var AgentConfigFilePath = "/usr/local/percona/pmm/config/pmm-agent.yaml" // ExporterOptions represents structure for special Exporter options. type ExporterOptions struct { @@ -312,6 +317,7 @@ type Agent struct { ListenPort *uint16 `reform:"listen_port"` Version *string `reform:"version"` ProcessExecPath *string `reform:"process_exec_path"` + IsConnected bool `reform:"is_connected"` Username *string `reform:"username"` Password *string `reform:"password"` diff --git a/managed/models/agent_model_reform.go b/managed/models/agent_model_reform.go index 2f6137d7a6d..e0b98449525 100644 --- a/managed/models/agent_model_reform.go +++ b/managed/models/agent_model_reform.go @@ -43,6 +43,7 @@ func (v *agentTableType) Columns() []string { "listen_port", "version", "process_exec_path", + "is_connected", "username", "password", "agent_password", @@ -96,6 +97,7 @@ var AgentTable = &agentTableType{ {Name: "ListenPort", Type: "*uint16", Column: "listen_port"}, {Name: "Version", Type: "*string", Column: "version"}, {Name: "ProcessExecPath", Type: "*string", Column: "process_exec_path"}, + {Name: "IsConnected", Type: "bool", Column: "is_connected"}, {Name: "Username", Type: "*string", Column: "username"}, {Name: "Password", Type: "*string", Column: "password"}, {Name: "AgentPassword", Type: "*string", Column: "agent_password"}, @@ -118,7 +120,7 @@ var AgentTable = &agentTableType{ // String returns a string representation of this struct or record. func (s Agent) String() string { - res := make([]string, 29) + res := make([]string, 30) res[0] = "AgentID: " + reform.Inspect(s.AgentID, true) res[1] = "AgentType: " + reform.Inspect(s.AgentType, true) res[2] = "RunsOnNodeID: " + reform.Inspect(s.RunsOnNodeID, true) @@ -134,20 +136,21 @@ func (s Agent) String() string { res[12] = "ListenPort: " + reform.Inspect(s.ListenPort, true) res[13] = "Version: " + reform.Inspect(s.Version, true) res[14] = "ProcessExecPath: " + reform.Inspect(s.ProcessExecPath, true) - res[15] = "Username: " + reform.Inspect(s.Username, true) - res[16] = "Password: " + reform.Inspect(s.Password, true) - res[17] = "AgentPassword: " + reform.Inspect(s.AgentPassword, true) - res[18] = "TLS: " + reform.Inspect(s.TLS, true) - res[19] = "TLSSkipVerify: " + reform.Inspect(s.TLSSkipVerify, true) - res[20] = "LogLevel: " + reform.Inspect(s.LogLevel, true) - res[21] = "ExporterOptions: " + reform.Inspect(s.ExporterOptions, true) - res[22] = "QANOptions: " + reform.Inspect(s.QANOptions, true) - res[23] = "AWSOptions: " + reform.Inspect(s.AWSOptions, true) - res[24] = "AzureOptions: " + reform.Inspect(s.AzureOptions, true) - res[25] = "MongoDBOptions: " + reform.Inspect(s.MongoDBOptions, true) - res[26] = "MySQLOptions: " + reform.Inspect(s.MySQLOptions, true) - res[27] = "PostgreSQLOptions: " + reform.Inspect(s.PostgreSQLOptions, true) - res[28] = "ValkeyOptions: " + reform.Inspect(s.ValkeyOptions, true) + res[15] = "IsConnected: " + reform.Inspect(s.IsConnected, true) + res[16] = "Username: " + reform.Inspect(s.Username, true) + res[17] = "Password: " + reform.Inspect(s.Password, true) + res[18] = "AgentPassword: " + reform.Inspect(s.AgentPassword, true) + res[19] = "TLS: " + reform.Inspect(s.TLS, true) + res[20] = "TLSSkipVerify: " + reform.Inspect(s.TLSSkipVerify, true) + res[21] = "LogLevel: " + reform.Inspect(s.LogLevel, true) + res[22] = "ExporterOptions: " + reform.Inspect(s.ExporterOptions, true) + res[23] = "QANOptions: " + reform.Inspect(s.QANOptions, true) + res[24] = "AWSOptions: " + reform.Inspect(s.AWSOptions, true) + res[25] = "AzureOptions: " + reform.Inspect(s.AzureOptions, true) + res[26] = "MongoDBOptions: " + reform.Inspect(s.MongoDBOptions, true) + res[27] = "MySQLOptions: " + reform.Inspect(s.MySQLOptions, true) + res[28] = "PostgreSQLOptions: " + reform.Inspect(s.PostgreSQLOptions, true) + res[29] = "ValkeyOptions: " + reform.Inspect(s.ValkeyOptions, true) return strings.Join(res, ", ") } @@ -170,6 +173,7 @@ func (s *Agent) Values() []interface{} { s.ListenPort, s.Version, s.ProcessExecPath, + s.IsConnected, s.Username, s.Password, s.AgentPassword, @@ -206,6 +210,7 @@ func (s *Agent) Pointers() []interface{} { &s.ListenPort, &s.Version, &s.ProcessExecPath, + &s.IsConnected, &s.Username, &s.Password, &s.AgentPassword, diff --git a/managed/models/database.go b/managed/models/database.go index bc45e191946..8b496cba52a 100644 --- a/managed/models/database.go +++ b/managed/models/database.go @@ -27,12 +27,17 @@ import ( "net" "net/url" "os" + "os/exec" "strconv" "strings" "time" + "github.com/AlekSi/pointer" + "github.com/google/uuid" "github.com/lib/pq" "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "go.yaml.in/yaml/v3" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "gopkg.in/reform.v1" @@ -1168,6 +1173,10 @@ var databaseSchema = [][]string{ 114: { `ALTER TABLE agents ADD COLUMN environment_variables TEXT`, }, + 115: { + `ALTER TABLE agents ADD COLUMN is_connected BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE nodes ADD COLUMN is_pmm_server_node BOOLEAN NOT NULL DEFAULT false`, + }, } // ^^^ Avoid default values in schema definition. ^^^ @@ -1233,6 +1242,8 @@ type SetupDBParams struct { SSLCAPath string SSLKeyPath string SSLCertPath string + HANodeID string + HAPeers []string SetupFixtures SetupFixturesMode MigrationVersion *int } @@ -1449,7 +1460,11 @@ func migrateDB(db *reform.DB, params SetupDBParams) error { return err } - err = setupPMMServerAgents(tx.Querier, params) + if params.HANodeID != "" { + err = setupPMMServerHAAgents(tx.Querier, params) + } else { + err = setupPMMServerAgents(tx.Querier, params) + } if err != nil { return err } @@ -1458,11 +1473,101 @@ func migrateDB(db *reform.DB, params SetupDBParams) error { }) } +type agentConfig struct { + ID string `yaml:"id"` +} + +func setupPMMServerHAAgents(q *reform.Querier, params SetupDBParams) error { + agentID := uuid.New().String() + nodeID := uuid.New().String() + + // create PMM Server Node and associated Agents in HA mode + logrus.Infof("Setting up PMM Server agents in HA mode, Node ID: %s", params.HANodeID) + + file, err := os.Open(AgentConfigFilePath) + if err != nil { + return err + } + defer func() { + _ = file.Close() + }() + + var agentConfig agentConfig + err = yaml.NewDecoder(file).Decode(&agentConfig) + if err != nil { + return fmt.Errorf("could not parse agent config file %s: %w", AgentConfigFilePath, err) + } + + if agentConfig.ID == "" { + return fmt.Errorf("the agent ID is empty in config file %s", AgentConfigFilePath) + } + + if agentConfig.ID != PMMServerAgentID { + // check if the agent with such ID already exists + agent, err := FindAgentByID(q, agentConfig.ID) + if err == nil { + logrus.Infof("PMM Agent with ID %s already exists, skipping creation", agentConfig.ID) + PMMServerAgentID = agentConfig.ID + PMMServerNodeID = pointer.Get(agent.RunsOnNodeID) + return nil + } + } + + args := []string{ + "setup", + "--config-file", AgentConfigFilePath, + "--server-address", "127.0.0.1:8443", + "--id", agentID, + "--skip-registration", + "--server-insecure-tls", + } + cmd := exec.Command("pmm-agent", args...) //nolint:gosec + logrus.Debugf("Running: pmm-agent %s", strings.Join(cmd.Args, " ")) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error setting up pmm-agent: %w: %s", err, output) + } + + labels := map[string]string{ + "cluster": "pmm", + "environment": "pmm", + } + + node, err := createNodeWithID(q, nodeID, GenericNodeType, &CreateNodeParams{ + NodeName: params.HANodeID, + Address: "127.0.0.1", + CustomLabels: labels, + IsPMMServerNode: true, + }) + if err != nil { + logrus.Errorf("Failed to create a node with ID %s: %s", nodeID, err) + return err + } + + agent, err := createPMMAgentWithID(q, agentID, node.NodeID, labels) + if err != nil { + return err + } + + if _, err = CreateNodeExporter(q, agent.AgentID, labels, false, false, []string{}, nil, ""); err != nil { + return err + } + + // set PMMServerAgentID and PMMServerNodeID to generated values in HA setup + PMMServerAgentID = agent.AgentID + logrus.Infof("Set PMMServerAgentID to: %s", PMMServerAgentID) + PMMServerNodeID = node.NodeID + logrus.Infof("Set PMMServerNodeID to: %s", PMMServerNodeID) + + return nil +} + func setupPMMServerAgents(q *reform.Querier, params SetupDBParams) error { // create PMM Server Node and associated Agents node, err := createNodeWithID(q, PMMServerNodeID, GenericNodeType, &CreateNodeParams{ - NodeName: "pmm-server", - Address: "127.0.0.1", + NodeName: "pmm-server", + Address: "127.0.0.1", + IsPMMServerNode: true, }) if err != nil { if status.Code(err) == codes.AlreadyExists { @@ -1471,25 +1576,29 @@ func setupPMMServerAgents(q *reform.Querier, params SetupDBParams) error { } return err } + if _, err = createPMMAgentWithID(q, PMMServerAgentID, node.NodeID, nil); err != nil { return err } if _, err = CreateNodeExporter(q, PMMServerAgentID, nil, false, false, []string{}, nil, ""); err != nil { return err } + address, port, err := parsePGAddress(params.Address) if err != nil { return err } if params.Address != DefaultPostgreSQLAddr { - if node, err = CreateNode(q, RemoteNodeType, &CreateNodeParams{ + node, err = CreateNode(q, RemoteNodeType, &CreateNodeParams{ NodeName: PMMServerPostgreSQLNodeName, Address: address, - }); err != nil { + }) + if err != nil { return err } } else { - params.Name = "" // using postgres database in order to get metrics from entrypoint extension setup for QAN. + // Using postgres database in order to get metrics from entrypoint extension setup for QAN. + params.Name = "" } // create PostgreSQL Service and associated Agents @@ -1552,7 +1661,7 @@ func setupPMMServerAgents(q *reform.Querier, params SetupDBParams) error { // parsePGAddress parses PostgreSQL address into address:port; if no port specified returns default port number. func parsePGAddress(address string) (string, uint16, error) { if !strings.Contains(address, ":") { - return address, 5432, nil + return address, 5432, nil //nolint:mnd } address, portStr, err := net.SplitHostPort(address) if err != nil { diff --git a/managed/models/node_helpers.go b/managed/models/node_helpers.go index 31cac092ad9..c49b1db1ef5 100644 --- a/managed/models/node_helpers.go +++ b/managed/models/node_helpers.go @@ -57,7 +57,7 @@ func checkUniqueNodeName(q *reform.Querier, name string) error { return errors.WithStack(err) } - return status.Errorf(codes.AlreadyExists, "Node with name %q already exists.", name) + return status.Errorf(codes.AlreadyExists, "Node with name %s already exists.", name) } // CheckUniqueNodeAddressRegion checks for uniqueness of instance address and region. @@ -174,18 +174,19 @@ func FindNodeByName(q *reform.Querier, name string) (*Node, error) { // CreateNodeParams contains parameters for creating Nodes. type CreateNodeParams struct { - NodeName string - MachineID *string - Distro string - NodeModel string - AZ string - ContainerID *string - ContainerName *string - CustomLabels map[string]string - Address string - InstanceID string - Region *string - Password *string + NodeName string + MachineID *string + Distro string + NodeModel string + AZ string + ContainerID *string + ContainerName *string + CustomLabels map[string]string + Address string + InstanceID string + Region *string + Password *string + IsPMMServerNode bool } // createNodeWithID creates a Node with given ID. @@ -198,7 +199,7 @@ func createNodeWithID(q *reform.Querier, id string, nodeType NodeType, params *C return nil, err } - // do not check that machine-id is unique: https://jira.percona.com/browse/PMM-4196 + // do not check that machine-id is unique: https://perconadev.atlassian.net/browse/PMM-4196 if nodeType == RemoteRDSNodeType { if strings.Contains(params.InstanceID, ".") { @@ -211,22 +212,23 @@ func createNodeWithID(q *reform.Querier, id string, nodeType NodeType, params *C } // Trim trailing \n received from broken 2.0.0 clients. - // See https://jira.percona.com/browse/PMM-4720 + // See https://perconadev.atlassian.net/browse/PMM-4720 machineID := pointer.ToStringOrNil(strings.TrimSpace(pointer.GetString(params.MachineID))) node := &Node{ - NodeID: id, - NodeType: nodeType, - NodeName: params.NodeName, - MachineID: machineID, - Distro: params.Distro, - NodeModel: params.NodeModel, - AZ: params.AZ, - ContainerID: params.ContainerID, - ContainerName: params.ContainerName, - InstanceID: params.InstanceID, - Address: params.Address, - Region: params.Region, + NodeID: id, + NodeType: nodeType, + NodeName: params.NodeName, + MachineID: machineID, + Distro: params.Distro, + NodeModel: params.NodeModel, + AZ: params.AZ, + ContainerID: params.ContainerID, + ContainerName: params.ContainerName, + InstanceID: params.InstanceID, + Address: params.Address, + Region: params.Region, + IsPMMServerNode: params.IsPMMServerNode, } if err := node.SetCustomLabels(params.CustomLabels); err != nil { return nil, err diff --git a/managed/models/node_helpers_test.go b/managed/models/node_helpers_test.go index 5a76a48b409..06226238480 100644 --- a/managed/models/node_helpers_test.go +++ b/managed/models/node_helpers_test.go @@ -190,12 +190,13 @@ func TestNodeHelpers(t *testing.T) { CreatedAt: now, UpdatedAt: now, }, { - NodeID: models.PMMServerNodeID, - NodeType: models.GenericNodeType, - NodeName: "pmm-server", - Address: "127.0.0.1", - CreatedAt: now, - UpdatedAt: now, + NodeID: models.PMMServerNodeID, + NodeType: models.GenericNodeType, + NodeName: "pmm-server", + Address: "127.0.0.1", + CreatedAt: now, + UpdatedAt: now, + IsPMMServerNode: true, }} require.Equal(t, expected, nodes) }) diff --git a/managed/models/node_model.go b/managed/models/node_model.go index e932f933ec8..4d511cba1c0 100644 --- a/managed/models/node_model.go +++ b/managed/models/node_model.go @@ -39,7 +39,9 @@ const ( ) // PMMServerNodeID is a special Node ID representing PMM Server Node. -const PMMServerNodeID = string("pmm-server") // A special ID reserved for PMM Server Node. +// It takes the value of "pmm-server" in regular non-HA setups and in Active/Passive HA setups, +// while in Active/Active HA setups it is set to a dynamically generated UUID. +var PMMServerNodeID = string("pmm-server") // Node represents Node as stored in database. // @@ -67,6 +69,9 @@ type Node struct { ContainerName *string `reform:"container_name"` Region *string `reform:"region"` // non-nil value must be unique in combination with instance/address + + // IsPMMServerNode indicates if this node is a PMM Server node. + IsPMMServerNode bool `reform:"is_pmm_server_node"` } // BeforeInsert implements reform.BeforeInserter interface. diff --git a/managed/models/node_model_reform.go b/managed/models/node_model_reform.go index b81ed3626a9..c08d1e363b4 100644 --- a/managed/models/node_model_reform.go +++ b/managed/models/node_model_reform.go @@ -43,6 +43,7 @@ func (v *nodeTableType) Columns() []string { "container_id", "container_name", "region", + "is_pmm_server_node", } } @@ -82,6 +83,7 @@ var NodeTable = &nodeTableType{ {Name: "ContainerID", Type: "*string", Column: "container_id"}, {Name: "ContainerName", Type: "*string", Column: "container_name"}, {Name: "Region", Type: "*string", Column: "region"}, + {Name: "IsPMMServerNode", Type: "bool", Column: "is_pmm_server_node"}, }, PKFieldIndex: 0, }, @@ -90,7 +92,7 @@ var NodeTable = &nodeTableType{ // String returns a string representation of this struct or record. func (s Node) String() string { - res := make([]string, 15) + res := make([]string, 16) res[0] = "NodeID: " + reform.Inspect(s.NodeID, true) res[1] = "NodeType: " + reform.Inspect(s.NodeType, true) res[2] = "NodeName: " + reform.Inspect(s.NodeName, true) @@ -106,6 +108,7 @@ func (s Node) String() string { res[12] = "ContainerID: " + reform.Inspect(s.ContainerID, true) res[13] = "ContainerName: " + reform.Inspect(s.ContainerName, true) res[14] = "Region: " + reform.Inspect(s.Region, true) + res[15] = "IsPMMServerNode: " + reform.Inspect(s.IsPMMServerNode, true) return strings.Join(res, ", ") } @@ -128,6 +131,7 @@ func (s *Node) Values() []interface{} { s.ContainerID, s.ContainerName, s.Region, + s.IsPMMServerNode, } } @@ -150,6 +154,7 @@ func (s *Node) Pointers() []interface{} { &s.ContainerID, &s.ContainerName, &s.Region, + &s.IsPMMServerNode, } } diff --git a/managed/models/params.go b/managed/models/params.go index 8121686d708..97fad048831 100644 --- a/managed/models/params.go +++ b/managed/models/params.go @@ -18,13 +18,14 @@ package models // HAParams defines parameters related to High Availability. type HAParams struct { GrafanaGossipPort int - Enabled bool - Bootstrap bool - NodeID string - AdvertiseAddress string - Nodes []string - RaftPort int - GossipPort int + // Enabled indicates whether HA is enabled. + Enabled bool + NodeID string + AdvertiseAddress string + // Nodes is a list of initial cluster node addresses. + Nodes []string + RaftPort int + GossipPort int } // Params defines parameters for supervisor. diff --git a/managed/services/agents/deps.go b/managed/services/agents/deps.go index 15d9f63ba83..c76a250478b 100644 --- a/managed/services/agents/deps.go +++ b/managed/services/agents/deps.go @@ -32,7 +32,7 @@ import ( // FIXME Rename to victoriaMetrics.Service, update tests. type prometheusService interface { RequestConfigurationUpdate() - BuildScrapeConfigForVMAgent(pmmAgentID string) ([]byte, error) + BuildScrapeConfigForVMAgent(ctx context.Context, pmmAgentID string) ([]byte, error) } // qanClient is a subset of methods of qan.Client used by this package. diff --git a/managed/services/agents/handler.go b/managed/services/agents/handler.go index 4a41b9e90c6..244f2c20b69 100644 --- a/managed/services/agents/handler.go +++ b/managed/services/agents/handler.go @@ -35,6 +35,8 @@ import ( "github.com/percona/pmm/utils/logger" ) +const defaultAgentPingInterval = 10 * time.Second + // Handler handles agent requests. type Handler struct { db *reform.DB @@ -80,7 +82,7 @@ func (h *Handler) Run(stream agentv1.AgentService_ConnectServer) error { h.state.RequestStateUpdate(ctx, agent.id) - ticker := time.NewTicker(10 * time.Second) + ticker := time.NewTicker(defaultAgentPingInterval) defer ticker.Stop() for { select { @@ -102,7 +104,7 @@ func (h *Handler) Run(stream agentv1.AgentService_ConnectServer) error { if req == nil { disconnectReason = "done" err = agent.channel.Wait() - h.r.unregister(agent.id, disconnectReason) + h.r.unregister(ctx, agent.id, disconnectReason) if err != nil { l.Error(errors.WithStack(err)) } diff --git a/managed/services/agents/jobs.go b/managed/services/agents/jobs.go index 901435d872a..55e858ec204 100644 --- a/managed/services/agents/jobs.go +++ b/managed/services/agents/jobs.go @@ -13,7 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package agents provides jobs functionality. package agents import ( diff --git a/managed/services/agents/log_level.go b/managed/services/agents/log_level.go index 442c0da4041..6a2e25f3a66 100644 --- a/managed/services/agents/log_level.go +++ b/managed/services/agents/log_level.go @@ -13,7 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package agents provides jobs functionality. package agents import ( diff --git a/managed/services/agents/postgresql.go b/managed/services/agents/postgresql.go index 4ab5a9d013d..9b13c41c4ed 100644 --- a/managed/services/agents/postgresql.go +++ b/managed/services/agents/postgresql.go @@ -164,7 +164,7 @@ func postgresExporterConfig(node *models.Node, service *models.Service, exporter } // qanPostgreSQLPgStatementsAgentConfig returns desired configuration of qan-postgresql-pgstatements-agent built-in agent. -func qanPostgreSQLPgStatementsAgentConfig(service *models.Service, agent *models.Agent, pmmAgentVersion *version.Parsed) *agentv1.SetStateRequest_BuiltinAgent { +func qanPostgreSQLPgStatementsAgentConfig(service *models.Service, agent *models.Agent, pmmAgentVersion *version.Parsed) *agentv1.SetStateRequest_BuiltinAgent { //nolint:lll tdp := agent.TemplateDelimiters(service) dnsParams := models.DSNParams{ DialTimeout: 5 * time.Second, diff --git a/managed/services/agents/registry.go b/managed/services/agents/registry.go index a897836c6f1..3a5c74169ff 100644 --- a/managed/services/agents/registry.go +++ b/managed/services/agents/registry.go @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package agents contains business logic of working with pmm-agent. +// Package agents contains business logic for working with pmm-agent. package agents import ( @@ -39,6 +39,8 @@ import ( const ( prometheusNamespace = "pmm_managed" prometheusSubsystem = "agents" + // ConnectionCacheTTL is the duration for which agent connection status is cached in HA mode. + connectionCacheTTL = 10 * time.Second ) var ( @@ -71,6 +73,11 @@ type pmmAgentInfo struct { kickChan chan struct{} } +// haService is a subset of methods from ha.Service used by Registry. +type haService interface { + Params() *models.HAParams +} + // Registry keeps track of all connected pmm-agents. type Registry struct { db *reform.DB @@ -80,6 +87,13 @@ type Registry struct { roster *roster + haService haService + + // Cache for connection status in HA mode + connectionCache map[string]struct{} + connectionCacheTTL time.Time + cacheMu sync.RWMutex + mConnects prom.Counter mDisconnects *prom.CounterVec mRoundTrip prom.Summary @@ -90,7 +104,7 @@ type Registry struct { } // NewRegistry creates a new registry with given database connection. -func NewRegistry(db *reform.DB, externalVMChecker victoriaMetricsParams) *Registry { +func NewRegistry(db *reform.DB, vmParams victoriaMetricsParams, ha haService) *Registry { agents := make(map[string]*pmmAgentInfo) r := &Registry{ db: db, @@ -99,6 +113,10 @@ func NewRegistry(db *reform.DB, externalVMChecker victoriaMetricsParams) *Regist roster: newRoster(db), + haService: ha, + + connectionCache: make(map[string]struct{}), + mConnects: prom.NewCounter(prom.CounterOpts{ Namespace: prometheusNamespace, Subsystem: prometheusSubsystem, @@ -116,17 +134,17 @@ func NewRegistry(db *reform.DB, externalVMChecker victoriaMetricsParams) *Regist Subsystem: prometheusSubsystem, Name: "round_trip_seconds", Help: "Round-trip time.", - Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, //nolint:mnd }), mClockDrift: prom.NewSummary(prom.SummaryOpts{ Namespace: prometheusNamespace, Subsystem: prometheusSubsystem, Name: "clock_drift_seconds", Help: "Clock drift.", - Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, //nolint:mnd }), - isExternalVM: externalVMChecker.ExternalVM(), + isExternalVM: vmParams.ExternalVM(), } r.mAgents = prom.NewGaugeFunc(prom.GaugeOpts{ @@ -147,10 +165,61 @@ func NewRegistry(db *reform.DB, externalVMChecker victoriaMetricsParams) *Regist return r } -// IsConnected returns true if pmm-agent with given ID is currently connected, false otherwise. +// IsConnected returns true if pmm-agent is currently connected, false otherwise. +// In HA mode, this queries the database (with 10-second caching) to support distributed environments. +// In non-HA mode, this checks the in-memory registry for better performance. func (r *Registry) IsConnected(pmmAgentID string) bool { - _, err := r.get(pmmAgentID) - return err == nil + if !r.haService.Params().Enabled { + // Non-HA mode: check in-memory registry + _, err := r.get(pmmAgentID) + return err == nil + } + + // HA mode: check cache first, then database + if !time.Now().After(r.connectionCacheTTL) { + r.cacheMu.RLock() + _, exists := r.connectionCache[pmmAgentID] + r.cacheMu.RUnlock() + if exists { + return true + } + } + + r.rebuildConnectionCache() + + r.cacheMu.RLock() + _, exists := r.connectionCache[pmmAgentID] + r.cacheMu.RUnlock() + + return exists +} + +// rebuildConnectionCache fetches all agent connection statuses from the database +// and caches them for 10 seconds. +func (r *Registry) rebuildConnectionCache() { + newCache := make(map[string]struct{}) + + // Fetch pmm-agents from the database, reset cache to empty on error. + _ = r.db.InTransaction(func(tx *reform.TX) error { + agentType := models.PMMAgentType + agents, err := models.FindAgents(tx.Querier, models.AgentFilters{AgentType: &agentType}) + if err != nil { + return err + } + + for _, agent := range agents { + if agent.IsConnected { + newCache[agent.AgentID] = struct{}{} + } + } + + return nil + }) + + r.cacheMu.Lock() + r.connectionCache = newCache + r.connectionCacheTTL = time.Now().Add(connectionCacheTTL) + r.cacheMu.Unlock() } func (r *Registry) register(stream agentv1.AgentService_ConnectServer) (*pmmAgentInfo, error) { @@ -214,6 +283,30 @@ func (r *Registry) register(stream agentv1.AgentService_ConnectServer) (*pmmAgen kickChan: make(chan struct{}), } r.agents[agentMD.ID] = agent + + // Only persist is_connected to database when HA is enabled + if r.haService.Params().Enabled { + err = r.db.InTransactionContext(ctx, nil, func(tx *reform.TX) error { + a, err := models.FindAgentByID(tx.Querier, agentMD.ID) + if err != nil { + return fmt.Errorf("failed to find agent: %w", err) + } + a.IsConnected = true + if err := tx.Update(a); err != nil { + return fmt.Errorf("failed to update agent: %w", err) + } + return nil + }) + if err != nil { + delete(r.agents, agentMD.ID) + return nil, fmt.Errorf("failed to persist the connection status for agent %s: %w", agentMD.ID, err) + } + + r.cacheMu.Lock() + r.connectionCache[agentMD.ID] = struct{}{} + r.cacheMu.Unlock() + } + return agent, nil } @@ -268,7 +361,7 @@ func (r *Registry) authenticate(md *agentv1.AgentConnectMetadata, q *reform.Quer } // unregister removes pmm-agent with given ID from the registry. -func (r *Registry) unregister(pmmAgentID, disconnectReason string) *pmmAgentInfo { +func (r *Registry) unregister(ctx context.Context, pmmAgentID, disconnectReason string) *pmmAgentInfo { r.mDisconnects.WithLabelValues(disconnectReason).Inc() r.rw.Lock() @@ -284,6 +377,35 @@ func (r *Registry) unregister(pmmAgentID, disconnectReason string) *pmmAgentInfo delete(r.agents, pmmAgentID) r.roster.clear(pmmAgentID) + + // Only persist connection status when HA is enabled + if r.haService.Params().Enabled { + l := logger.Get(ctx) + err := r.db.InTransactionContext(ctx, nil, func(tx *reform.TX) error { + a, err := models.FindAgentByID(tx.Querier, pmmAgentID) + if err != nil { + // Agent might have been deleted, which is fine + if status.Code(err) == codes.NotFound { + return nil + } + return fmt.Errorf("failed to find agent: %w", err) + } + a.IsConnected = false + if err := tx.Update(a); err != nil { + return fmt.Errorf("failed to update agent: %w", err) + } + return nil + }) + if err != nil { + // Log but don't fail - agent is already disconnected from the registry + l.Errorf("Failed to update the connection status for agent %s: %v", pmmAgentID, err) + } + + r.cacheMu.Lock() + delete(r.connectionCache, pmmAgentID) + r.cacheMu.Unlock() + } + return agent } @@ -301,7 +423,7 @@ func (r *Registry) ping(ctx context.Context, agent *pmmAgentInfo) error { } roundtrip := time.Since(start) agentTime := resp.(*agentv1.Pong).CurrentTime.AsTime() //nolint:forcetypeassert - clockDrift := agentTime.Sub(start) - roundtrip/2 + clockDrift := agentTime.Sub(start) - roundtrip/2 //nolint:mnd if clockDrift < 0 { clockDrift = -clockDrift } @@ -311,15 +433,15 @@ func (r *Registry) ping(ctx context.Context, agent *pmmAgentInfo) error { return nil } -// addOrRemoveVMAgent - creates vmAgent agentType if pmm-agent's version supports it and agent not exists yet, -// otherwise ensures that vmAgent not exist for pmm-agent and pmm-agent's agents don't have push_metrics mode, +// addOrRemoveVMAgent - creates vmAgent agentType if pmm-agent's version supports it and agent does not exist yet, +// otherwise ensures that vmAgent does not start for pmm-agent when pmm-agent's agents don't have push_metrics mode, // removes it if needed. func (r *Registry) addOrRemoveVMAgent(q *reform.Querier, pmmAgentID, runsOnNodeID string) error { return r.addVMAgentToPMMAgent(q, pmmAgentID, runsOnNodeID) } func (r *Registry) addVMAgentToPMMAgent(q *reform.Querier, pmmAgentID, runsOnNodeID string) error { - if runsOnNodeID == "pmm-server" && !r.isExternalVM { + if runsOnNodeID == models.PMMServerNodeID && !r.isExternalVM { return nil } vmAgentType := models.VMAgentType @@ -362,7 +484,7 @@ func (r *Registry) addNomadAgentToPMMAgent(q *reform.Querier, pmmAgentID, runsOn // Kick unregisters and forcefully disconnects pmm-agent with given ID. func (r *Registry) Kick(ctx context.Context, pmmAgentID string) { - agent := r.unregister(pmmAgentID, "kick") + agent := r.unregister(ctx, pmmAgentID, "kick") if agent == nil { return } diff --git a/managed/services/agents/state.go b/managed/services/agents/state.go index 8ee7669b965..a033b98b48d 100644 --- a/managed/services/agents/state.go +++ b/managed/services/agents/state.go @@ -82,7 +82,7 @@ func (u *StateUpdater) UpdateAgentsState(ctx context.Context) error { return errors.Wrap(err, "cannot find pmmAgentsIDs for AgentsState update") } var wg sync.WaitGroup - limiter := make(chan struct{}, 10) + limiter := make(chan struct{}, 10) //nolint:mnd for _, pmmAgentID := range pmmAgents { wg.Add(1) limiter <- struct{}{} @@ -140,7 +140,7 @@ func (u *StateUpdater) runStateChangeHandler(ctx context.Context, agent *pmmAgen } // sendSetStateRequest sends SetStateRequest to given pmm-agent. -func (u *StateUpdater) sendSetStateRequest(ctx context.Context, agent *pmmAgentInfo) error { //nolint:cyclop +func (u *StateUpdater) sendSetStateRequest(ctx context.Context, agent *pmmAgentInfo) error { //nolint:cyclop,maintidx l := logger.Get(ctx) start := time.Now() defer func() { @@ -188,7 +188,7 @@ func (u *StateUpdater) sendSetStateRequest(ctx context.Context, agent *pmmAgentI case models.PMMAgentType: continue case models.VMAgentType: - scrapeCfg, err := u.vmdb.BuildScrapeConfigForVMAgent(agent.id) + scrapeCfg, err := u.vmdb.BuildScrapeConfigForVMAgent(ctx, agent.id) if err != nil { return errors.Wrapf(err, "cannot get agent scrape config for agent: %s", agent.id) } diff --git a/managed/services/agents/vmagent.go b/managed/services/agents/vmagent.go index 577bd74b1e0..5adb61afae5 100644 --- a/managed/services/agents/vmagent.go +++ b/managed/services/agents/vmagent.go @@ -90,7 +90,7 @@ func vmAgentConfig(scrapeCfg string, params victoriaMetricsParams) *agentv1.SetS // First, collect all VMAGENT_ environment variables from the system systemEnvs := make(map[string]string) for _, env := range os.Environ() { - if strings.HasPrefix(env, envvars.ENVvmAgentPrefix) { + if strings.HasPrefix(env, envvars.EnvVMAgentPrefix) { parts := strings.SplitN(env, "=", 2) if len(parts) == 2 { systemEnvs[parts[0]] = parts[1] diff --git a/managed/services/alerting/service.go b/managed/services/alerting/service.go index 181e84c86e3..9ceb6f64cd9 100644 --- a/managed/services/alerting/service.go +++ b/managed/services/alerting/service.go @@ -410,7 +410,7 @@ func (s *Service) CreateTemplate(ctx context.Context, req *alerting.CreateTempla templates, err := alert.Parse(strings.NewReader(req.Yaml), pParams) if err != nil { - s.l.Errorf("failed to parse rule template form request: +%v", err) + s.l.Errorf("failed to parse rule template form request: %+v", err) return nil, status.Errorf(codes.InvalidArgument, "Failed to parse rule template: %v.", err) } @@ -463,7 +463,7 @@ func (s *Service) UpdateTemplate(ctx context.Context, req *alerting.UpdateTempla templates, err := alert.Parse(strings.NewReader(req.Yaml), parseParams) if err != nil { - s.l.Errorf("failed to parse rule template form request: +%v", err) + s.l.Errorf("failed to parse rule template form request: %+v", err) return nil, status.Error(codes.InvalidArgument, "Failed to parse rule template.") } diff --git a/managed/services/converters.go b/managed/services/converters.go index cb1e66b7574..432561ec915 100644 --- a/managed/services/converters.go +++ b/managed/services/converters.go @@ -40,29 +40,31 @@ func ToAPINode(node *models.Node) (inventoryv1.Node, error) { //nolint:ireturn switch node.NodeType { case models.GenericNodeType: return &inventoryv1.GenericNode{ - NodeId: node.NodeID, - NodeName: node.NodeName, - MachineId: pointer.GetString(node.MachineID), - Distro: node.Distro, - NodeModel: node.NodeModel, - Region: pointer.GetString(node.Region), - Az: node.AZ, - CustomLabels: labels, - Address: node.Address, + NodeId: node.NodeID, + NodeName: node.NodeName, + MachineId: pointer.GetString(node.MachineID), + Distro: node.Distro, + NodeModel: node.NodeModel, + Region: pointer.GetString(node.Region), + Az: node.AZ, + CustomLabels: labels, + Address: node.Address, + IsPmmServerNode: node.IsPMMServerNode, }, nil case models.ContainerNodeType: return &inventoryv1.ContainerNode{ - NodeId: node.NodeID, - NodeName: node.NodeName, - MachineId: pointer.GetString(node.MachineID), - ContainerId: pointer.GetString(node.ContainerID), - ContainerName: pointer.GetString(node.ContainerName), - NodeModel: node.NodeModel, - Region: pointer.GetString(node.Region), - Az: node.AZ, - CustomLabels: labels, - Address: node.Address, + NodeId: node.NodeID, + NodeName: node.NodeName, + MachineId: pointer.GetString(node.MachineID), + ContainerId: pointer.GetString(node.ContainerID), + ContainerName: pointer.GetString(node.ContainerName), + NodeModel: node.NodeModel, + Region: pointer.GetString(node.Region), + Az: node.AZ, + CustomLabels: labels, + Address: node.Address, + IsPmmServerNode: node.IsPMMServerNode, }, nil case models.RemoteNodeType: diff --git a/managed/services/encryption/encryption_rotation.go b/managed/services/encryption/encryption_rotation.go index bf6014e8b5c..ed23c192e33 100644 --- a/managed/services/encryption/encryption_rotation.go +++ b/managed/services/encryption/encryption_rotation.go @@ -111,7 +111,7 @@ func pmmServerStatus(status string) bool { } func pmmServerStatusWithRetries(status string) bool { - for i := 0; i < retries; i++ { + for range retries { if !pmmServerStatus(status) { logrus.Infoln("Retry...") time.Sleep(interval) diff --git a/managed/services/encryption/encryption_rotation_test.go b/managed/services/encryption/encryption_rotation_test.go index f5058bf7b21..3d4a9e1ca95 100644 --- a/managed/services/encryption/encryption_rotation_test.go +++ b/managed/services/encryption/encryption_rotation_test.go @@ -31,18 +31,18 @@ import ( const ( encryptionKeyTestPath = "/srv/pmm-encryption-rotation-test.key" - originEncryptionKey = `CMatkOIIEmQKWAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQWVzR2NtS2V5EiIaIKDxOKZxwiJl5Hj6oPZ/unTzmAvfwHWzZ1Wli0vac15YGAEQARjGrZDiCCAB` - // pmm-managed-username encrypted with originEncryptionKey - originUsernameHash = `AYxEFsZsg7lp9+eSy6+wPFHlaNNy0ZpTbYN0NuCLPnQOZUYf2S6H9B+XJdF4+DscxC/pJwI=` - // pmm-managed-password encrypted with originEncryptionKey - originPasswordHash = `AYxEFsZuL5xZb5IxGGh8NI6GrjDxCzFGxIcHe94UXcg+dnZphu7GQSgmZm633XvZ8CBU2wo=` //nolint:gosec + originalEncryptionKey = `CMatkOIIEmQKWAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQWVzR2NtS2V5EiIaIKDxOKZxwiJl5Hj6oPZ/unTzmAvfwHWzZ1Wli0vac15YGAEQARjGrZDiCCAB` + // pmm-managed-username encrypted with originalEncryptionKey + originalUsernameHash = `AYxEFsZsg7lp9+eSy6+wPFHlaNNy0ZpTbYN0NuCLPnQOZUYf2S6H9B+XJdF4+DscxC/pJwI=` + // pmm-managed-password encrypted with originalEncryptionKey + originalPasswordHash = `AYxEFsZuL5xZb5IxGGh8NI6GrjDxCzFGxIcHe94UXcg+dnZphu7GQSgmZm633XvZ8CBU2wo=` //nolint:gosec ) func TestEncryptionRotation(t *testing.T) { db := testdb.Open(t, models.SkipFixtures, nil) defer db.Close() //nolint:errcheck - err := createOriginEncryptionKey(t) + err := createOriginalEncryptionKey(t) require.NoError(t, err) err = insertTestData(db) @@ -54,7 +54,7 @@ func TestEncryptionRotation(t *testing.T) { newEncryptionKey, err := os.ReadFile(encryptionKeyTestPath) require.NoError(t, err) - require.NotEqual(t, newEncryptionKey, []byte(originEncryptionKey)) + require.NotEqual(t, newEncryptionKey, []byte(originalEncryptionKey)) err = checkNewlyEncryptedData(db) require.NoError(t, err) @@ -63,14 +63,14 @@ func TestEncryptionRotation(t *testing.T) { require.NoError(t, err) } -func createOriginEncryptionKey(t *testing.T) error { +func createOriginalEncryptionKey(t *testing.T) error { t.Helper() t.Setenv(encryption.CustomEncryptionKeyPathEnvVar, encryptionKeyTestPath) - err := os.WriteFile(encryptionKeyTestPath, []byte(originEncryptionKey), 0o600) + err := os.WriteFile(encryptionKeyTestPath, []byte(originalEncryptionKey), 0o600) if err != nil { return err } - encryption.DefaultEncryption = encryption.New() + // Encryption will be lazily initialized when first used return nil } @@ -101,7 +101,7 @@ func insertTestData(db *sql.DB) error { _, err = db.Exec( `INSERT INTO agents (agent_id, agent_type, username, password, runs_on_node_id, pmm_agent_id, disabled, status, created_at, updated_at, tls, tls_skip_verify, qan_options, mysql_options, aws_options, exporter_options) `+ `VALUES ('1', 'pmm-agent', $1, $2, '1', NULL, false, '', $3, $4, false, false, '{"max_query_length": 0, "query_examples_disabled": false, "comments_parsing_disabled": true, "max_query_log_size": 0}', '{"table_count_tablestats_group_limit": 0}', '{"rds_basic_metrics_disabled": true, "rds_enhanced_metrics_disabled": true}', '{"push_metrics": false, "expose_exporter": false}')`, - originUsernameHash, originPasswordHash, now, now) + originalUsernameHash, originalPasswordHash, now, now) if err != nil { return err } @@ -116,10 +116,10 @@ func checkNewlyEncryptedData(db *sql.DB) error { if err != nil { return err } - if newlyEncryptedUsername == originUsernameHash { + if newlyEncryptedUsername == originalUsernameHash { return errors.New("username hash not rotated properly") } - if newlyEncryptedPassword == originPasswordHash { + if newlyEncryptedPassword == originalPasswordHash { return errors.New("password hash not rotated properly") } diff --git a/managed/services/grafana/auth_server.go b/managed/services/grafana/auth_server.go index 769dcee32f2..3a93f40fd72 100644 --- a/managed/services/grafana/auth_server.go +++ b/managed/services/grafana/auth_server.go @@ -39,14 +39,15 @@ import ( ) const ( - connectionEndpoint = "/agent.v1.AgentService/Connect" + connectionEndpointV2 = "/agent.Agent/Connect" + connectionEndpoint = "/agent.v1.AgentService/Connect" ) // rules maps original URL prefix to minimal required role. var rules = map[string]role{ // TODO https://jira.percona.com/browse/PMM-4420 - "/agent.Agent/Connect": admin, // compatibility for v2 agents - connectionEndpoint: admin, + connectionEndpointV2: admin, // compatibility for v2 agents + connectionEndpoint: admin, "/inventory.": admin, "/management.": admin, @@ -69,6 +70,7 @@ var rules = map[string]role{ "/v1/backups": admin, "/v1/dumps": admin, "/v1/accesscontrol": admin, + "/v1/ha": viewer, "/v1/inventory/": admin, "/v1/inventory/services:getTypes": viewer, "/v1/management/": admin, @@ -118,7 +120,8 @@ var lbacPrefixes = []string{ // "/graph/api/v1/labels", // Note: this path appears not to be used in Grafana "/prometheus/api/v1/", "/v1/qan/", - "/graph/api/datasources/proxy/1/api/v1/", // https://github.com/grafana/grafana/blob/146c3120a79e71e9a4836ddf1e1dc104854c7851/public/app/core/utils/query.ts#L35 + // https://github.com/grafana/grafana/blob/146c3120a79e71e9a4836ddf1e1dc104854c7851/public/app/core/utils/query.ts#L35 + "/graph/api/datasources/proxy/1/api/v1/", } const lbacHeaderName = "X-Proxy-Filter" @@ -129,8 +132,11 @@ const lbacHeaderName = "X-Proxy-Filter" // as this code is reserved for auth_request. const authenticationErrorCode = 401 -// cacheInvalidationPeriod is and period when cache for grafana response should be invalidated. -const cacheInvalidationPeriod = 3 * time.Second +const ( + // Note: cacheInvalidationInterval is used to invalidate cache for grafana responses. + cacheInvalidationInterval = 3 * time.Second + authenticationTimeout = 3 * time.Second +) // clientError contains authentication error response details. type authError struct { @@ -183,7 +189,8 @@ func NewAuthServer(c clientInterface, db *reform.DB) *AuthServer { // Run runs cache invalidator which removes expired cache items. func (s *AuthServer) Run(ctx context.Context) { - t := time.NewTicker(cacheInvalidationPeriod) + t := time.NewTicker(cacheInvalidationInterval) + defer t.Stop() for { select { @@ -194,7 +201,7 @@ func (s *AuthServer) Run(ctx context.Context) { now := time.Now() s.rw.Lock() for key, item := range s.cache { - if now.Add(-cacheInvalidationPeriod).After(item.created) { + if now.Add(-cacheInvalidationInterval).After(item.created) { delete(s.cache, key) } } @@ -223,8 +230,7 @@ func (s *AuthServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { l := s.l.WithField("req", fmt.Sprintf("%s %s", req.Method, req.URL.Path)) // TODO l := logger.Get(ctx) once we have it after https://jira.percona.com/browse/PMM-4326 - // fail-safe - ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second) + ctx, cancel := context.WithTimeout(req.Context(), authenticationTimeout) defer cancel() authUser, err := s.authenticate(ctx, req, l) @@ -424,9 +430,9 @@ func nextPrefix(path string) string { func isLocalAgentConnection(req *http.Request) bool { ip := strings.Split(req.RemoteAddr, ":")[0] - pmmAgent := req.Header.Get("Pmm-Agent-Id") + // pmmAgent := req.Header.Get("Pmm-Agent-Id") path := req.Header.Get("X-Original-Uri") - if ip == "127.0.0.1" && pmmAgent == "pmm-server" && path == connectionEndpoint { + if ip == "127.0.0.1" && path == connectionEndpoint { return true } diff --git a/managed/services/grafana/auth_server_test.go b/managed/services/grafana/auth_server_test.go index 10afd4ea4b7..0af2a5953a4 100644 --- a/managed/services/grafana/auth_server_test.go +++ b/managed/services/grafana/auth_server_test.go @@ -116,6 +116,8 @@ func TestAuthServerAuthenticate(t *testing.T) { "/v1/advisors": editor, "/v1/advisors/checks:start": editor, "/v1/advisors/failedServices": editor, + "/v1/ha/status": viewer, + "/v1/ha/nodes": viewer, "/v1/management/services": admin, "/v1/management/agents": admin, "/v1/server/updates": viewer, diff --git a/managed/services/grafana/client.go b/managed/services/grafana/client.go index c05c26bec95..0012a6481c2 100644 --- a/managed/services/grafana/client.go +++ b/managed/services/grafana/client.go @@ -13,7 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package grafana provides facilities for working with Grafana. package grafana import ( @@ -156,7 +155,7 @@ func (c *Client) do(ctx context.Context, method, path, rawQuery string, headers return errors.WithStack(cErr) } - if len(b) > 0 && target != nil { + if len(b) != 0 && target != nil { if err = json.Unmarshal(b, target); err != nil { return errors.WithStack(err) } diff --git a/managed/services/ha/ha.go b/managed/services/ha/ha.go new file mode 100644 index 00000000000..c33bc9b0515 --- /dev/null +++ b/managed/services/ha/ha.go @@ -0,0 +1,99 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ha + +import ( + "context" + + "github.com/hashicorp/memberlist" + + hav1beta1 "github.com/percona/pmm/api/ha/v1beta1" +) + +// HAServer implements the HAService gRPC API. +type HAServer struct { //nolint:revive + service *Service + hav1beta1.UnimplementedHAServiceServer +} + +// NewHAServer creates a new HAServer instance. +func NewHAServer(service *Service) *HAServer { + return &HAServer{ + service: service, + } +} + +// Status returns the current HA mode status. +func (s *HAServer) Status(_ context.Context, _ *hav1beta1.StatusRequest) (*hav1beta1.StatusResponse, error) { //nolint:unparam + status := "Disabled" + if s.service.params.Enabled { + status = "Enabled" + } + return &hav1beta1.StatusResponse{Status: status}, nil +} + +// ListNodes returns a list of all nodes in the High Availability cluster. +func (s *HAServer) ListNodes(_ context.Context, _ *hav1beta1.ListNodesRequest) (*hav1beta1.ListNodesResponse, error) { //nolint:unparam + if !s.service.params.Enabled { + return &hav1beta1.ListNodesResponse{Nodes: []*hav1beta1.HANode{}}, nil + } + + s.service.rw.RLock() + memberlist := s.service.memberlist + raftNode := s.service.raftNode + s.service.rw.RUnlock() + + if memberlist == nil { + return &hav1beta1.ListNodesResponse{Nodes: []*hav1beta1.HANode{}}, nil + } + + _, leaderID := raftNode.LeaderWithID() + members := memberlist.Members() + nodes := []*hav1beta1.HANode{} + + for _, member := range members { + role := hav1beta1.NodeRole_NODE_ROLE_FOLLOWER + if member.Name == string(leaderID) { + role = hav1beta1.NodeRole_NODE_ROLE_LEADER + } + + status := memberlistStateToString(member.State) + + nodes = append(nodes, &hav1beta1.HANode{ + NodeName: member.Name, + Role: role, + Status: status, + }) + } + + return &hav1beta1.ListNodesResponse{Nodes: nodes}, nil +} + +// memberlistStateToString converts memberlist state to a string representation. +func memberlistStateToString(state memberlist.NodeStateType) string { + switch state { + case memberlist.StateAlive: + return "alive" + case memberlist.StateSuspect: + return "suspect" + case memberlist.StateDead: + return "dead" + case memberlist.StateLeft: + return "left" + default: + return "unknown" + } +} diff --git a/managed/services/ha/ha_test.go b/managed/services/ha/ha_test.go new file mode 100644 index 00000000000..c7f0901a1ed --- /dev/null +++ b/managed/services/ha/ha_test.go @@ -0,0 +1,167 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ha + +import ( + "sync" + "testing" + + "github.com/hashicorp/memberlist" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + hav1beta1 "github.com/percona/pmm/api/ha/v1beta1" + "github.com/percona/pmm/managed/models" +) + +func TestHAServer_Status(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + haEnabled bool + expectedStatus string + }{ + { + name: "HA Enabled", + haEnabled: true, + expectedStatus: "Enabled", + }, + { + name: "HA Disabled", + haEnabled: false, + expectedStatus: "Disabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + service := &Service{ + params: &models.HAParams{ + Enabled: tt.haEnabled, + }, + } + + server := NewHAServer(service) + + resp, err := server.Status(t.Context(), &hav1beta1.StatusRequest{}) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, tt.expectedStatus, resp.Status) + }) + } +} + +func TestHAServer_ListNodes_HADisabled(t *testing.T) { + t.Parallel() + + service := &Service{ + params: &models.HAParams{ + Enabled: false, + }, + } + + server := NewHAServer(service) + + resp, err := server.ListNodes(t.Context(), &hav1beta1.ListNodesRequest{}) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Empty(t, resp.Nodes) +} + +func TestHAServer_ListNodes_NilMemberlist(t *testing.T) { + t.Parallel() + + service := &Service{ + params: &models.HAParams{ + Enabled: true, + }, + memberlist: nil, + rw: sync.RWMutex{}, + } + + server := NewHAServer(service) + + resp, err := server.ListNodes(t.Context(), &hav1beta1.ListNodesRequest{}) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Empty(t, resp.Nodes) +} + +func TestMemberlistStateToString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state memberlist.NodeStateType + expectedString string + }{ + { + name: "StateAlive", + state: memberlist.StateAlive, + expectedString: "alive", + }, + { + name: "StateSuspect", + state: memberlist.StateSuspect, + expectedString: "suspect", + }, + { + name: "StateDead", + state: memberlist.StateDead, + expectedString: "dead", + }, + { + name: "StateLeft", + state: memberlist.StateLeft, + expectedString: "left", + }, + { + name: "Unknown state", + state: memberlist.NodeStateType(99), + expectedString: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := memberlistStateToString(tt.state) + assert.Equal(t, tt.expectedString, result) + }) + } +} + +func TestNewHAServer(t *testing.T) { + t.Parallel() + + service := &Service{ + params: &models.HAParams{ + Enabled: true, + }, + } + + server := NewHAServer(service) + + require.NotNil(t, server) + assert.Equal(t, service, server.service) +} diff --git a/managed/services/ha/haservice.go b/managed/services/ha/haservice.go new file mode 100644 index 00000000000..d360ed0a460 --- /dev/null +++ b/managed/services/ha/haservice.go @@ -0,0 +1,549 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package ha contains everything related to high availability. +package ha + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/hashicorp/memberlist" + "github.com/hashicorp/raft" + raftboltdb "github.com/hashicorp/raft-boltdb/v2" + "github.com/sirupsen/logrus" + + "github.com/percona/pmm/managed/models" +) + +const ( + defaultNodeEventChanSize = 5 + defaultRaftRetries = 3 + defaultTransportTimeout = 10 * time.Second + defaultLeaveTimeout = 5 * time.Second + defaultTickerInterval = 5 * time.Second + defaultApplyTimeout = 3 * time.Second + defaultRaftDataDir = "/srv/ha" + defaultRaftDataDirPerm = 0o750 + defaultSnapshotRetention = 3 + defaultSnapshotThreshold = 8192 + defaultTrailingLogs = 10240 + defaultHeartbeatTimeout = 1000 * time.Millisecond + defaultElectionTimeout = 1000 * time.Millisecond + defaultCommitTimeout = 50 * time.Millisecond + defaultLeaderLeaseTimeout = 500 * time.Millisecond + defaultSnapshotInterval = 120 * time.Second + defaultServerOpTimeout = 10 * time.Second +) + +// Service represents the high-availability service. +type Service struct { + params *models.HAParams + bootstrapCluster bool + + services *services + + nodeCh chan memberlist.NodeEvent + leaderCh chan raft.Observation + + l *logrus.Entry + wg *sync.WaitGroup + + rw sync.RWMutex + raftNode *raft.Raft + memberlist *memberlist.Memberlist +} + +// Apply applies a log entry to the high-availability service. +// Currently only used for Raft consensus, not for state replication. +func (s *Service) Apply(logEntry *raft.Log) any { + s.l.Debugf("raft: applied log entry: index=%d, data=%s", logEntry.Index, string(logEntry.Data)) + return nil +} + +// Snapshot returns a snapshot of the high-availability service. +// Since PMM HA uses Raft for leader election only (not state replication), +// the FSM has no state to snapshot. Cluster configuration (voters) is +// automatically stored by Raft in the snapshot metadata. +func (s *Service) Snapshot() (raft.FSMSnapshot, error) { //nolint:ireturn + return &fsmSnapshot{}, nil +} + +// Restore restores the high availability service to a previous state. +// Since PMM HA is stateless (leader election only), there's nothing to restore. +// Cluster configuration (voters) is automatically restored by Raft from snapshot metadata. +func (s *Service) Restore(rc io.ReadCloser) error { + // FSM has no state, but we need to consume the reader + // Raft automatically restores cluster configuration from metadata + s.l.Debug("Restore called - FSM is stateless, cluster config restored by Raft") + return rc.Close() +} + +// fsmSnapshot implements raft.FSMSnapshot for stateless PMM HA. +type fsmSnapshot struct{} + +// Persist writes an empty snapshot since PMM HA FSM is stateless. +// Cluster configuration (voters, etc.) is automatically persisted by Raft in metadata. +func (f *fsmSnapshot) Persist(sink raft.SnapshotSink) error { + return sink.Close() +} + +// Release is called when we are finished with the snapshot. +func (f *fsmSnapshot) Release() { + // Nothing to release for stateless FSM +} + +// memberlistLogWriter is an io.Writer that converts memberlist's standard log format to structured output. +type memberlistLogWriter struct { + logger *logrus.Entry + logRegex *regexp.Regexp +} + +// newMemberlistLogWriter creates a new log writer for memberlist. +func newMemberlistLogWriter(logger *logrus.Entry) *memberlistLogWriter { + return &memberlistLogWriter{ + logger: logger, + logRegex: regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} \[(\w+)\] (?:memberlist: )?(.+)$`), + } +} + +// Write implements io.Writer interface and converts memberlist logs to logrus format. +func (w *memberlistLogWriter) Write(p []byte) (int, error) { + // Remove trailing newline for parsing + msg := string(bytes.TrimRight(p, "\n")) + + // Parse memberlist log format: "2025/12/22 21:43:27 [DEBUG|INFO|WARN|ERR] message" + matches := w.logRegex.FindStringSubmatch(msg) + if len(matches) == 3 { //nolint:mnd + level := strings.ToLower(matches[1]) + message := matches[2] + + // Log with appropriate level + switch level { + case "debug": + w.logger.Debug(message) + case "info": + w.logger.Info(message) + case "warn": + w.logger.Warn(message) + case "err": + w.logger.Error(message) + default: + w.logger.Info(message) + } + } else { + // Fallback for unparseable logs + w.logger.Info(msg) + } + + return len(p), nil +} + +var _ io.Writer = (*memberlistLogWriter)(nil) + +// setupRaftStorage sets up persistent storage for Raft. +func setupRaftStorage(nodeID string, l *logrus.Entry) (*raftboltdb.BoltStore, *raftboltdb.BoltStore, *raft.FileSnapshotStore, error) { + // Create the Raft data directory for this node + raftDir := filepath.Join(defaultRaftDataDir, nodeID) + if err := os.MkdirAll(raftDir, defaultRaftDataDirPerm); err != nil { + return nil, nil, nil, fmt.Errorf("failed to create Raft data directory: %w", err) + } + l.Infof("Using Raft data directory: %s", raftDir) + + // Create BoltDB-based log store + logStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-log.db")) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create BoltDB log store: %w", err) + } + + // Create BoltDB-based stable store + stableStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-stable.db")) + if err != nil { + if cerr := logStore.Close(); cerr != nil { + l.Errorf("failed to close logStore after stableStore error: %v", cerr) + } + return nil, nil, nil, fmt.Errorf("failed to create BoltDB stable store: %w", err) + } + + // Create file-based snapshot store + snapshotStore, err := raft.NewFileSnapshotStore(raftDir, defaultSnapshotRetention, os.Stderr) + if err != nil { + if cerr := logStore.Close(); cerr != nil { + l.Errorf("failed to close logStore after snapshotStore error: %v", cerr) + } + if cerr := stableStore.Close(); cerr != nil { + l.Errorf("failed to close stableStore after snapshotStore error: %v", cerr) + } + return nil, nil, nil, fmt.Errorf("failed to create file snapshot store: %w", err) + } + + return logStore, stableStore, snapshotStore, nil +} + +// New provides a new instance of the high availability service. +func New(params *models.HAParams) *Service { + return &Service{ + params: params, + bootstrapCluster: true, + services: newServices(), + nodeCh: make(chan memberlist.NodeEvent, defaultNodeEventChanSize), + leaderCh: make(chan raft.Observation), + l: logrus.WithField("component", "ha"), + wg: &sync.WaitGroup{}, + } +} + +// Run runs the high availability service. +func (s *Service) Run(ctx context.Context) error { + s.wg.Go(func() { + for { + select { + case <-s.services.Refresh(): + if s.IsLeader() { + s.services.StartAllServices(ctx) + } + case <-ctx.Done(): + s.services.StopAllServices() + return + } + } + }) + + if !s.params.Enabled { + s.l.Infoln("High availability is disabled") + s.wg.Wait() + s.services.Wait() + return nil + } + + s.l.Infoln("Starting...") + defer s.l.Infoln("Done.") + + // Create the Raft configuration + raftConfig := raft.DefaultConfig() + raftConfig.LocalID = raft.ServerID(s.params.NodeID) + raftConfig.LogOutput = s.l.Logger.Out + + // Set log level based on environment + if os.Getenv("PMM_DEBUG") == "1" { + raftConfig.LogLevel = "DEBUG" + } else { + raftConfig.LogLevel = "WARN" + } + + // Configure timeouts for better cluster stability + raftConfig.HeartbeatTimeout = defaultHeartbeatTimeout + raftConfig.ElectionTimeout = defaultElectionTimeout + raftConfig.CommitTimeout = defaultCommitTimeout + raftConfig.LeaderLeaseTimeout = defaultLeaderLeaseTimeout + + // Configure snapshots for log compaction + // Since PMM HA is stateless (leader election only), snapshots are minimal + raftConfig.SnapshotInterval = defaultSnapshotInterval + raftConfig.SnapshotThreshold = defaultSnapshotThreshold + raftConfig.TrailingLogs = defaultTrailingLogs + + tcpAddr, err := net.ResolveTCPAddr("tcp", net.JoinHostPort(s.params.AdvertiseAddress, strconv.Itoa(s.params.RaftPort))) + if err != nil { + return err + } + + raftTrans, err := raft.NewTCPTransport( + net.JoinHostPort("0.0.0.0", strconv.Itoa(s.params.RaftPort)), + tcpAddr, + defaultRaftRetries, + defaultTransportTimeout, + nil) + if err != nil { + return err + } + + // Set up persistent storage for Raft + logStore, stableStore, snapshotStore, err := setupRaftStorage(s.params.NodeID, s.l) + if err != nil { + return err + } + + defer func() { + if logStore != nil { + if closeErr := logStore.Close(); closeErr != nil { + s.l.Errorf("error closing log store: %v", closeErr) + } + } + if stableStore != nil { + if closeErr := stableStore.Close(); closeErr != nil { + s.l.Errorf("error closing stable store: %v", closeErr) + } + } + }() + + // Create a new Raft node with persistent storage + s.rw.Lock() + s.raftNode, err = raft.NewRaft(raftConfig, s, logStore, stableStore, snapshotStore, raftTrans) + s.rw.Unlock() + if err != nil { + return err + } + defer func() { + if s.IsLeader() { + s.raftNode.LeadershipTransfer() + } + err := s.raftNode.Shutdown().Error() + if err != nil { + s.l.Errorf("error during the shutdown of raft node: %q", err) + } + }() + + // Create the memberlist configuration + memberlistConfig := memberlist.DefaultWANConfig() + memberlistConfig.Name = s.params.NodeID + memberlistConfig.BindAddr = "0.0.0.0" + memberlistConfig.BindPort = s.params.GossipPort + memberlistConfig.AdvertiseAddr = s.params.AdvertiseAddress + memberlistConfig.AdvertisePort = s.params.GossipPort + memberlistConfig.Events = &memberlist.ChannelEventDelegate{Ch: s.nodeCh} + memberlistConfig.LogOutput = newMemberlistLogWriter(s.l.WithField("subsystem", "memberlist")) + + // Create the memberlist + s.memberlist, err = memberlist.Create(memberlistConfig) + if err != nil { + return fmt.Errorf("failed to create memberlist: %w", err) + } + defer func() { + err := s.memberlist.Leave(defaultLeaveTimeout) + if err != nil { + s.l.Errorf("couldn't leave memberlist cluster: %q", err) + } + err = s.memberlist.Shutdown() + if err != nil { + s.l.Errorf("couldn't shutdown memberlist listeners: %q", err) + } + }() + + if s.bootstrapCluster { + // Start the Raft node + cfg := raft.Configuration{ + Servers: []raft.Server{ + { + Suffrage: raft.Voter, + ID: raft.ServerID(s.params.NodeID), + Address: raft.ServerAddress(net.JoinHostPort(s.lookupFQDN(ctx, s.params.AdvertiseAddress), strconv.Itoa(s.params.RaftPort))), + }, + }, + } + if err := s.raftNode.BootstrapCluster(cfg).Error(); err != nil { + // Cluster might already be bootstrapped with persistent storage + if !errors.Is(err, raft.ErrCantBootstrap) { + return fmt.Errorf("failed to bootstrap Raft cluster: %w", err) + } + s.l.Info("Cluster already bootstrapped, skipping") + } + } + if len(s.params.Nodes) != 0 { + _, err := s.memberlist.Join(s.params.Nodes) + if err != nil { + return fmt.Errorf("failed to join memberlist cluster: %w", err) + } + } + s.wg.Go(func() { + s.runLeaderObserver(ctx) + }) + + s.wg.Go(func() { + s.runRaftNodesSynchronizer(ctx) + }) + + <-ctx.Done() + + s.wg.Wait() + s.services.Wait() + + return nil +} + +func (s *Service) runRaftNodesSynchronizer(ctx context.Context) { + t := time.NewTicker(defaultTickerInterval) + defer t.Stop() + + for { + select { + case event := <-s.nodeCh: + if !s.IsLeader() { + continue + } + node := event.Node + switch event.Event { + case memberlist.NodeJoin: + s.addMemberlistNodeToRaft(ctx, node) + case memberlist.NodeLeave: + s.removeMemberlistNodeFromRaft(node) + case memberlist.NodeUpdate: + continue + } + case <-t.C: + if !s.IsLeader() { + continue + } + + // Get Raft configuration with error handling + configFuture := s.raftNode.GetConfiguration() + if err := configFuture.Error(); err != nil { + s.l.Errorf("failed to get raft configuration: %v", err) + continue + } + + servers := configFuture.Configuration().Servers + raftServers := make(map[string]struct{}) + for _, server := range servers { + raftServers[string(server.ID)] = struct{}{} + } + members := s.memberlist.Members() + s.l.Debugf("HA memberlist: %v", members) + for _, node := range members { + if _, ok := raftServers[node.Name]; !ok { + s.addMemberlistNodeToRaft(ctx, node) + } + } + case <-ctx.Done(): + return + } + } +} + +func (s *Service) removeMemberlistNodeFromRaft(node *memberlist.Node) { + s.rw.RLock() + defer s.rw.RUnlock() + err := s.raftNode.RemoveServer(raft.ServerID(node.Name), 0, defaultServerOpTimeout).Error() + if err != nil { + s.l.Errorln(err) + } +} + +func (s *Service) addMemberlistNodeToRaft(ctx context.Context, node *memberlist.Node) { + s.rw.RLock() + defer s.rw.RUnlock() + + hostname := s.lookupFQDN(ctx, node.Addr.String()) + serverAddress := raft.ServerAddress(fmt.Sprintf("%s:%d", hostname, s.params.RaftPort)) + + err := s.raftNode.AddVoter(raft.ServerID(node.Name), serverAddress, 0, defaultServerOpTimeout).Error() + if err != nil { + s.l.Errorf("Couldn't add a server node %s (address: %s): %s", node.Name, serverAddress, err) + } else { + s.l.Infof("Added node %s to Raft cluster with address: %s", node.Name, serverAddress) + } +} + +// lookupFQDN performs reverse DNS lookup to get FQDN from IP address. +func (s *Service) lookupFQDN(ctx context.Context, address string) string { + if net.ParseIP(address) == nil { + return address + } + + names, err := net.DefaultResolver.LookupAddr(ctx, address) + if err != nil || len(names) == 0 { + s.l.Warnf("Failed to lookup FQDN for %s, using IP: %s", address, err) + return address + } + + fqdn := strings.TrimSuffix(names[0], ".") + s.l.Debugf("Resolved %s to FQDN: %s", address, fqdn) + return fqdn +} + +func (s *Service) runLeaderObserver(ctx context.Context) { + t := time.NewTicker(defaultTickerInterval) + defer t.Stop() + + for { + s.rw.RLock() + node := s.raftNode + s.rw.RUnlock() + select { + case isLeader := <-node.LeaderCh(): + if isLeader { + s.services.StartAllServices(ctx) + s.l.Info("I am the leader!") + peers := s.memberlist.Members() + for _, peer := range peers { + if peer.Name == s.params.NodeID { + continue + } + s.addMemberlistNodeToRaft(ctx, peer) + } + } else { + s.l.Info("I am not a leader!") + s.services.StopAllServices() + } + case <-t.C: + address, serverID := node.LeaderWithID() + if serverID != "" { + s.l.Debugf("Leader is %s on %s", serverID, address) + } + case <-ctx.Done(): + return + } + } +} + +// AddLeaderService adds a leader service to the high availability service. +func (s *Service) AddLeaderService(leaderService LeaderService) { + err := s.services.Add(leaderService) + if err != nil { + s.l.Errorf("couldn't add HA service: %+v", err) + } +} + +// BroadcastMessage broadcasts a message from the high availability service. +// Note: Currently unused. Reserved for future cluster-wide message distribution. +// This method should only be called by the leader node. +func (s *Service) BroadcastMessage(message []byte) error { + if !s.params.Enabled { + return fmt.Errorf("HA is disabled") + } + + s.rw.RLock() + defer s.rw.RUnlock() + + future := s.raftNode.Apply(message, defaultApplyTimeout) + + if err := future.Error(); err != nil { + return fmt.Errorf("failed to apply log to raft: %w", err) + } + return nil +} + +// IsLeader checks if the current instance of HA service is the leader. +func (s *Service) IsLeader() bool { + s.rw.RLock() + defer s.rw.RUnlock() + return !s.params.Enabled || (s.raftNode != nil && s.raftNode.State() == raft.Leader) +} + +// Params returns HA parameters. +func (s *Service) Params() *models.HAParams { + return s.params +} diff --git a/managed/services/ha/haservice_test.go b/managed/services/ha/haservice_test.go new file mode 100644 index 00000000000..c49db993a41 --- /dev/null +++ b/managed/services/ha/haservice_test.go @@ -0,0 +1,305 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ha + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/raft" + raftboltdb "github.com/hashicorp/raft-boltdb/v2" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/percona/pmm/managed/models" +) + +func TestService_Apply(t *testing.T) { + t.Parallel() + + s := &Service{ + l: logrus.WithField("component", "test"), + } + + logEntry := &raft.Log{ + Index: 42, + Data: []byte("test data"), + } + + result := s.Apply(logEntry) + assert.Nil(t, result) +} + +func TestService_Snapshot(t *testing.T) { + t.Parallel() + + s := &Service{ + l: logrus.WithField("component", "test"), + } + + snapshot, err := s.Snapshot() + + require.NoError(t, err) + require.NotNil(t, snapshot) + + _, ok := snapshot.(*fsmSnapshot) + assert.True(t, ok, "snapshot should be of type *fsmSnapshot") +} + +func TestService_Restore(t *testing.T) { + t.Parallel() + + t.Run("closes reader successfully", func(t *testing.T) { + t.Parallel() + + s := &Service{ + l: logrus.WithField("component", "test"), + } + + data := []byte("test restore data") + rc := io.NopCloser(bytes.NewReader(data)) + + err := s.Restore(rc) + + require.NoError(t, err) + }) + + t.Run("handles empty reader", func(t *testing.T) { + t.Parallel() + + s := &Service{ + l: logrus.WithField("component", "test"), + } + + rc := io.NopCloser(bytes.NewReader(nil)) + + err := s.Restore(rc) + + require.NoError(t, err) + }) +} + +func TestFSMSnapshot_Persist(t *testing.T) { + t.Parallel() + + snapshot := &fsmSnapshot{} + + mockSink := &mockSnapshotSink{ + closed: false, + } + + err := snapshot.Persist(mockSink) + + require.NoError(t, err) + assert.True(t, mockSink.closed) +} + +func TestFSMSnapshot_Release(t *testing.T) { + t.Parallel() + + snapshot := &fsmSnapshot{} + + assert.NotPanics(t, func() { + snapshot.Release() + }) +} + +func TestSetupRaftStorage(t *testing.T) { + t.Run("creates directory structure", func(t *testing.T) { + tmpDir := t.TempDir() + raftDir := filepath.Join(tmpDir, "test-node-1") + + require.NoError(t, os.MkdirAll(raftDir, defaultRaftDataDirPerm)) + + l := logrus.WithField("component", "test") + + logStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-log.db")) + require.NoError(t, err) + require.NotNil(t, logStore) + defer logStore.Close() + + stableStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-stable.db")) + require.NoError(t, err) + require.NotNil(t, stableStore) + defer stableStore.Close() + + snapshotStore, err := raft.NewFileSnapshotStore(raftDir, defaultSnapshotRetention, l.Logger.Out) + require.NoError(t, err) + require.NotNil(t, snapshotStore) + + assert.DirExists(t, raftDir) + assert.FileExists(t, filepath.Join(raftDir, "raft-log.db")) + assert.FileExists(t, filepath.Join(raftDir, "raft-stable.db")) + }) +} + +func TestNew(t *testing.T) { + t.Parallel() + + params := &models.HAParams{ + Enabled: true, + NodeID: "node-1", + AdvertiseAddress: "127.0.0.1", + RaftPort: 7300, + GossipPort: 7301, + Nodes: []string{"node-2"}, + } + + service := New(params) + + require.NotNil(t, service) + assert.Equal(t, params, service.params) + assert.True(t, service.bootstrapCluster) + assert.NotNil(t, service.services) + assert.NotNil(t, service.nodeCh) + assert.NotNil(t, service.leaderCh) + assert.NotNil(t, service.l) + assert.NotNil(t, service.wg) + + assert.Equal(t, defaultNodeEventChanSize, cap(service.nodeCh)) +} + +func TestService_IsLeader(t *testing.T) { + t.Parallel() + + t.Run("returns true when HA disabled", func(t *testing.T) { + t.Parallel() + + s := &Service{ + params: &models.HAParams{ + Enabled: false, + }, + } + + assert.True(t, s.IsLeader()) + }) + + t.Run("returns false when raftNode is nil", func(t *testing.T) { + t.Parallel() + + s := &Service{ + params: &models.HAParams{ + Enabled: true, + }, + raftNode: nil, + } + + assert.False(t, s.IsLeader()) + }) +} + +func TestService_Params(t *testing.T) { + t.Parallel() + + params := &models.HAParams{ + Enabled: true, + NodeID: "test-node", + AdvertiseAddress: "192.168.1.1", + RaftPort: 7300, + GossipPort: 7301, + } + + s := &Service{ + params: params, + } + + result := s.Params() + + assert.Equal(t, params, result) + assert.Equal(t, "test-node", result.NodeID) + assert.Equal(t, "192.168.1.1", result.AdvertiseAddress) + assert.Equal(t, 7300, result.RaftPort) + assert.Equal(t, 7301, result.GossipPort) +} + +func TestService_AddLeaderService(t *testing.T) { + t.Parallel() + + t.Run("successfully adds service", func(t *testing.T) { + t.Parallel() + + s := &Service{ + services: newServices(), + l: logrus.WithField("component", "test"), + } + + svc := &mockLeaderService{id: "test-service"} + s.AddLeaderService(svc) + + assert.Len(t, s.services.all, 1) + assert.Equal(t, svc, s.services.all["test-service"]) + }) + + t.Run("logs error when add fails", func(t *testing.T) { + t.Parallel() + + s := &Service{ + services: newServices(), + l: logrus.WithField("component", "test"), + } + + svc := &mockLeaderService{id: "duplicate"} + s.AddLeaderService(svc) + + assert.NotPanics(t, func() { + s.AddLeaderService(svc) + }) + }) +} + +func TestService_BroadcastMessage(t *testing.T) { + t.Parallel() + + t.Run("returns error when HA disabled", func(t *testing.T) { + t.Parallel() + + s := &Service{ + params: &models.HAParams{ + Enabled: false, + }, + } + + err := s.BroadcastMessage([]byte("test message")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "HA is disabled") + }) +} + +type mockSnapshotSink struct { + closed bool +} + +func (m *mockSnapshotSink) Write(p []byte) (n int, err error) { + return len(p), nil +} + +func (m *mockSnapshotSink) Close() error { + m.closed = true + return nil +} + +func (m *mockSnapshotSink) ID() string { + return "mock-snapshot" +} + +func (m *mockSnapshotSink) Cancel() error { + return nil +} diff --git a/managed/services/ha/highavailability.go b/managed/services/ha/highavailability.go deleted file mode 100644 index 32ac3cd084d..00000000000 --- a/managed/services/ha/highavailability.go +++ /dev/null @@ -1,334 +0,0 @@ -// Copyright (C) 2023 Percona LLC -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Package ha contains everything related to high availability. -package ha - -import ( - "context" - "fmt" - "io" - "net" - "strconv" - "sync" - "time" - - "github.com/hashicorp/memberlist" - "github.com/hashicorp/raft" - "github.com/sirupsen/logrus" - - "github.com/percona/pmm/managed/models" -) - -const ( - defaultNodeEventChanSize = 5 - defaultRaftRetries = 3 - defaultTransportTimeout = 10 * time.Second - defaultLeaveTimeout = 5 * time.Second - defaultTickerInterval = 5 * time.Second - defaultApplyTimeout = 3 * time.Second -) - -// Service represents the high-availability service. -type Service struct { - params *models.HAParams - bootstrapCluster bool - - services *services - - receivedMessages chan []byte - nodeCh chan memberlist.NodeEvent - leaderCh chan raft.Observation - - l *logrus.Entry - wg *sync.WaitGroup - - rw sync.RWMutex - raftNode *raft.Raft - memberlist *memberlist.Memberlist -} - -// Apply applies a log entry to the high-availability service. -func (s *Service) Apply(logEntry *raft.Log) interface{} { - s.l.Printf("raft: got a message: %s", string(logEntry.Data)) - s.receivedMessages <- logEntry.Data - return nil -} - -// Snapshot returns a snapshot of the high-availability service. -func (s *Service) Snapshot() (raft.FSMSnapshot, error) { //nolint:ireturn - return nil, nil //nolint:nilnil -} - -// Restore restores the high availability service to a previous state. -func (s *Service) Restore(_ io.ReadCloser) error { - return nil -} - -// New provides a new instance of the high availability service. -func New(params *models.HAParams) *Service { - return &Service{ - params: params, - bootstrapCluster: params.Bootstrap, - services: newServices(), - nodeCh: make(chan memberlist.NodeEvent, defaultNodeEventChanSize), - leaderCh: make(chan raft.Observation), - receivedMessages: make(chan []byte), - l: logrus.WithField("component", "ha"), - wg: &sync.WaitGroup{}, - } -} - -// Run runs the high availability service. -func (s *Service) Run(ctx context.Context) error { - s.wg.Go(func() { - for { - select { - case <-s.services.Refresh(): - if s.IsLeader() { - s.services.StartAllServices(ctx) - } - case <-ctx.Done(): - s.services.StopRunningServices() - return - } - } - }) - - if !s.params.Enabled { - s.l.Infoln("High availability is disabled") - s.wg.Wait() - s.services.Wait() - return nil - } - - s.l.Infoln("Starting...") - defer s.l.Infoln("Done.") - - // Create the Raft configuration - raftConfig := raft.DefaultConfig() - raftConfig.LocalID = raft.ServerID(s.params.NodeID) - raftConfig.LogLevel = "DEBUG" - - // Create a new Raft transport - raa, err := net.ResolveTCPAddr("", net.JoinHostPort(s.params.AdvertiseAddress, strconv.Itoa(s.params.RaftPort))) - if err != nil { - return err - } - raftTrans, err := raft.NewTCPTransport(net.JoinHostPort("0.0.0.0", strconv.Itoa(s.params.RaftPort)), raa, defaultRaftRetries, defaultTransportTimeout, nil) - if err != nil { - return err - } - - // Create a new Raft node - s.rw.Lock() - s.raftNode, err = raft.NewRaft(raftConfig, s, raft.NewInmemStore(), raft.NewInmemStore(), raft.NewInmemSnapshotStore(), raftTrans) - s.rw.Unlock() - if err != nil { - return err - } - defer func() { - if s.IsLeader() { - s.raftNode.LeadershipTransfer() - } - err := s.raftNode.Shutdown().Error() - if err != nil { - s.l.Errorf("error during the shutdown of raft node: %q", err) - } - }() - - // Create the memberlist configuration - memberlistConfig := memberlist.DefaultWANConfig() - memberlistConfig.Name = s.params.NodeID - memberlistConfig.BindAddr = "0.0.0.0" - memberlistConfig.BindPort = s.params.GossipPort - memberlistConfig.AdvertiseAddr = raa.IP.String() - memberlistConfig.AdvertisePort = s.params.GossipPort - memberlistConfig.Events = &memberlist.ChannelEventDelegate{Ch: s.nodeCh} - - // Create the memberlist - s.memberlist, err = memberlist.Create(memberlistConfig) - if err != nil { - return fmt.Errorf("failed to create memberlist: %w", err) - } - defer func() { - err := s.memberlist.Leave(defaultLeaveTimeout) - if err != nil { - s.l.Errorf("couldn't leave memberlist cluster: %q", err) - } - err = s.memberlist.Shutdown() - if err != nil { - s.l.Errorf("couldn't shutdown memberlist listeners: %q", err) - } - }() - - if s.bootstrapCluster { - // Start the Raft node - cfg := raft.Configuration{ - Servers: []raft.Server{ - { - Suffrage: raft.Voter, - ID: raft.ServerID(s.params.NodeID), - Address: raft.ServerAddress(raa.String()), - }, - }, - } - if err := s.raftNode.BootstrapCluster(cfg).Error(); err != nil { - return fmt.Errorf("failed to bootstrap Raft cluster: %w", err) - } - } - if len(s.params.Nodes) != 0 { - _, err := s.memberlist.Join(s.params.Nodes) - if err != nil { - return fmt.Errorf("failed to join memberlist cluster: %w", err) - } - } - s.wg.Go(func() { - s.runLeaderObserver(ctx) - }) - - s.wg.Go(func() { - s.runRaftNodesSynchronizer(ctx) - }) - - <-ctx.Done() - - s.wg.Wait() - s.services.Wait() - - return nil -} - -func (s *Service) runRaftNodesSynchronizer(ctx context.Context) { - t := time.NewTicker(defaultTickerInterval) - - for { - select { - case event := <-s.nodeCh: - if !s.IsLeader() { - continue - } - node := event.Node - switch event.Event { - case memberlist.NodeJoin: - s.addMemberlistNodeToRaft(node) - case memberlist.NodeLeave: - s.removeMemberlistNodeFromRaft(node) - case memberlist.NodeUpdate: - continue - } - case <-t.C: - if !s.IsLeader() { - continue - } - servers := s.raftNode.GetConfiguration().Configuration().Servers - raftServers := make(map[string]struct{}) - for _, server := range servers { - raftServers[string(server.ID)] = struct{}{} - } - members := s.memberlist.Members() - s.l.Infof("memberlist members: %v", members) - for _, node := range members { - if _, ok := raftServers[node.Name]; !ok { - s.addMemberlistNodeToRaft(node) - } - } - case <-ctx.Done(): - t.Stop() - return - } - } -} - -func (s *Service) removeMemberlistNodeFromRaft(node *memberlist.Node) { - s.rw.RLock() - defer s.rw.RUnlock() - err := s.raftNode.RemoveServer(raft.ServerID(node.Name), 0, 10*time.Second).Error() - if err != nil { - s.l.Errorln(err) - } -} - -func (s *Service) addMemberlistNodeToRaft(node *memberlist.Node) { - s.rw.RLock() - defer s.rw.RUnlock() - err := s.raftNode.AddVoter(raft.ServerID(node.Name), raft.ServerAddress(fmt.Sprintf("%s:%d", node.Addr.String(), s.params.RaftPort)), 0, 10*time.Second).Error() - if err != nil { - s.l.Errorf("couldn't add a server node %s: %q", node.Name, err) - } -} - -func (s *Service) runLeaderObserver(ctx context.Context) { - t := time.NewTicker(defaultTickerInterval) - for { - s.rw.RLock() - node := s.raftNode - s.rw.RUnlock() - select { - case isLeader := <-node.LeaderCh(): - if isLeader { - s.services.StartAllServices(ctx) - // This node is the leader - s.l.Printf("I am the leader!") - peers := s.memberlist.Members() - for _, peer := range peers { - if peer.Name == s.params.NodeID { - continue - } - s.addMemberlistNodeToRaft(peer) - } - } else { - s.l.Printf("I am not a leader!") - s.services.StopRunningServices() - } - case <-t.C: - address, serverID := s.raftNode.LeaderWithID() - s.l.Infof("Leader is %s on %s", serverID, address) - case <-ctx.Done(): - return - } - } -} - -// AddLeaderService adds a leader service to the high availability service. -func (s *Service) AddLeaderService(leaderService LeaderService) { - err := s.services.Add(leaderService) - if err != nil { - s.l.Errorf("couldn't add HA service: +%v", err) - } -} - -// BroadcastMessage broadcasts a message from the high availability service. -func (s *Service) BroadcastMessage(message []byte) { - if s.params.Enabled { - s.rw.RLock() - defer s.rw.RUnlock() - s.raftNode.Apply(message, defaultApplyTimeout) - } else { - s.receivedMessages <- message - } -} - -// IsLeader checks if the current instance of the high availability service is the leader. -func (s *Service) IsLeader() bool { - s.rw.RLock() - defer s.rw.RUnlock() - return (s.raftNode != nil && s.raftNode.State() == raft.Leader) || !s.params.Enabled -} - -// Bootstrap performs the necessary steps to initialize the high availability service. -func (s *Service) Bootstrap() bool { - return s.params.Bootstrap || !s.params.Enabled -} diff --git a/managed/services/ha/leaderservice_test.go b/managed/services/ha/leaderservice_test.go new file mode 100644 index 00000000000..8a036b7490e --- /dev/null +++ b/managed/services/ha/leaderservice_test.go @@ -0,0 +1,447 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ha + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStandardService_New(t *testing.T) { + t.Parallel() + + startFunc := func(_ context.Context) error { return nil } + stopFunc := func() {} + + svc := NewStandardService("test-id", startFunc, stopFunc) + + require.NotNil(t, svc) + assert.Equal(t, "test-id", svc.id) + assert.NotNil(t, svc.startFunc) + assert.NotNil(t, svc.stopFunc) +} + +func TestStandardService_ID(t *testing.T) { + t.Parallel() + + svc := NewStandardService("my-service", nil, nil) + + assert.Equal(t, "my-service", svc.ID()) +} + +func TestStandardService_Start(t *testing.T) { + t.Parallel() + + t.Run("calls startFunc successfully", func(t *testing.T) { + t.Parallel() + + called := false + startFunc := func(_ context.Context) error { + called = true + return nil + } + stopFunc := func() {} + + svc := NewStandardService("test", startFunc, stopFunc) + err := svc.Start(t.Context()) + + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("returns error from startFunc", func(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("start failed") + startFunc := func(_ context.Context) error { + return expectedErr + } + stopFunc := func() {} + + svc := NewStandardService("test", startFunc, stopFunc) + err := svc.Start(t.Context()) + + require.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + t.Run("passes context to startFunc", func(t *testing.T) { + t.Parallel() + + var receivedCtx context.Context + startFunc := func(ctx context.Context) error { + receivedCtx = ctx //nolint:fatcontext + return nil + } + stopFunc := func() {} + + svc := NewStandardService("test", startFunc, stopFunc) + ctx := context.WithValue(t.Context(), "key", "value") //nolint:revive,staticcheck + err := svc.Start(ctx) + + require.NoError(t, err) + assert.Equal(t, "value", receivedCtx.Value("key")) + }) + + t.Run("handles concurrent start calls safely", func(t *testing.T) { + t.Parallel() + + callCount := 0 + var mu sync.Mutex + startFunc := func(_ context.Context) error { + mu.Lock() + callCount++ + mu.Unlock() + time.Sleep(10 * time.Millisecond) + return nil + } + stopFunc := func() {} + + svc := NewStandardService("test", startFunc, stopFunc) + + var wg sync.WaitGroup + for range 5 { + wg.Go(func() { + _ = svc.Start(t.Context()) + }) + } + + wg.Wait() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 5, callCount) + }) +} + +func TestStandardService_Stop(t *testing.T) { + t.Parallel() + + t.Run("calls stopFunc", func(t *testing.T) { + t.Parallel() + + called := false + startFunc := func(_ context.Context) error { return nil } + stopFunc := func() { + called = true + } + + svc := NewStandardService("test", startFunc, stopFunc) + svc.Stop() + + assert.True(t, called) + }) + + t.Run("handles concurrent stop calls safely", func(t *testing.T) { + t.Parallel() + + callCount := 0 + var mu sync.Mutex + startFunc := func(_ context.Context) error { return nil } + stopFunc := func() { + mu.Lock() + callCount++ + mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + + svc := NewStandardService("test", startFunc, stopFunc) + + var wg sync.WaitGroup + for range 5 { + wg.Go(func() { + svc.Stop() + }) + } + + wg.Wait() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 5, callCount) + }) +} + +func TestStandardService_ConcurrentStartStop(t *testing.T) { + t.Parallel() + + var startCount, stopCount int + var mu sync.Mutex + + startFunc := func(_ context.Context) error { + mu.Lock() + startCount++ + mu.Unlock() + time.Sleep(5 * time.Millisecond) + return nil + } + stopFunc := func() { + mu.Lock() + stopCount++ + mu.Unlock() + time.Sleep(5 * time.Millisecond) + } + + svc := NewStandardService("test", startFunc, stopFunc) + + var wg sync.WaitGroup + for range 10 { + wg.Add(2) + go func() { + defer wg.Done() + _ = svc.Start(t.Context()) + }() + go func() { + defer wg.Done() + svc.Stop() + }() + } + + wg.Wait() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 10, startCount) + assert.Equal(t, 10, stopCount) +} + +func TestContextService_New(t *testing.T) { + t.Parallel() + + startFunc := func(_ context.Context) error { return nil } + + svc := NewContextService("test-id", startFunc) + + require.NotNil(t, svc) + assert.Equal(t, "test-id", svc.id) + assert.NotNil(t, svc.startFunc) +} + +func TestContextService_ID(t *testing.T) { + t.Parallel() + + svc := NewContextService("my-context-service", nil) + + assert.Equal(t, "my-context-service", svc.ID()) +} + +func TestContextService_Start(t *testing.T) { + t.Parallel() + + t.Run("creates and stores cancel func", func(t *testing.T) { + t.Parallel() + + startFunc := func(ctx context.Context) error { + <-ctx.Done() + return nil + } + + svc := NewContextService("test", startFunc) + + go func() { + _ = svc.Start(t.Context()) + }() + + time.Sleep(50 * time.Millisecond) + + svc.m.Lock() + cancel := svc.cancel + svc.m.Unlock() + + assert.NotNil(t, cancel) + }) + + t.Run("passes derived context to startFunc", func(t *testing.T) { + t.Parallel() + + receivedDone := make(chan struct{}) + startFunc := func(ctx context.Context) error { + select { + case <-ctx.Done(): + close(receivedDone) + case <-time.After(100 * time.Millisecond): + } + return nil + } + + svc := NewContextService("test", startFunc) + + go func() { + _ = svc.Start(t.Context()) + }() + + time.Sleep(20 * time.Millisecond) + svc.Stop() + + select { + case <-receivedDone: + case <-time.After(200 * time.Millisecond): + t.Fatal("context was not cancelled") + } + }) + + t.Run("handles concurrent start calls", func(t *testing.T) { + t.Parallel() + + callCount := 0 + var mu sync.Mutex + done := make(chan struct{}) + + startFunc := func(ctx context.Context) error { + mu.Lock() + callCount++ + mu.Unlock() + select { + case <-ctx.Done(): + case <-done: + } + return nil + } + + svc := NewContextService("test", startFunc) + + var wg sync.WaitGroup + for range 5 { + wg.Go(func() { + _ = svc.Start(t.Context()) + }) + } + + time.Sleep(50 * time.Millisecond) + + // Signal all goroutines to finish + close(done) + + wg.Wait() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 5, callCount) + }) +} + +func TestContextService_Stop(t *testing.T) { + t.Parallel() + + t.Run("cancels context", func(t *testing.T) { + t.Parallel() + + cancelled := make(chan struct{}) + startFunc := func(ctx context.Context) error { + <-ctx.Done() + close(cancelled) + return nil + } + + svc := NewContextService("test", startFunc) + + go func() { + _ = svc.Start(t.Context()) + }() + + time.Sleep(50 * time.Millisecond) + svc.Stop() + + select { + case <-cancelled: + case <-time.After(200 * time.Millisecond): + t.Fatal("context was not cancelled") + } + }) + + t.Run("handles stop before start", func(t *testing.T) { + t.Parallel() + + startFunc := func(_ context.Context) error { return nil } + svc := NewContextService("test", startFunc) + + svc.m.Lock() + svc.cancel = func() {} + svc.m.Unlock() + + assert.NotPanics(t, func() { + svc.Stop() + }) + }) + + t.Run("handles multiple stop calls safely", func(t *testing.T) { + t.Parallel() + + startFunc := func(ctx context.Context) error { + <-ctx.Done() + return nil + } + + svc := NewContextService("test", startFunc) + + go func() { + _ = svc.Start(t.Context()) + }() + + time.Sleep(50 * time.Millisecond) + + assert.NotPanics(t, func() { + var wg sync.WaitGroup + for range 5 { + wg.Go(func() { + svc.Stop() + }) + } + wg.Wait() + }) + }) +} + +func TestContextService_ConcurrentStartStop(t *testing.T) { + t.Parallel() + + var startCount int + var mu sync.Mutex + + startFunc := func(ctx context.Context) error { + mu.Lock() + startCount++ + mu.Unlock() + <-ctx.Done() + return nil + } + + svc := NewContextService("test", startFunc) + + var wg sync.WaitGroup + + // Start a single service instance + wg.Go(func() { + _ = svc.Start(t.Context()) + }) + + time.Sleep(50 * time.Millisecond) + + // Stop it + svc.Stop() + + wg.Wait() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 1, startCount) +} diff --git a/managed/services/ha/services.go b/managed/services/ha/services.go index f33efd787c9..e7dea7f0fd8 100644 --- a/managed/services/ha/services.go +++ b/managed/services/ha/services.go @@ -35,6 +35,7 @@ type services struct { l *logrus.Entry } +// newServices creates a new services manager. func newServices() *services { return &services{ all: make(map[string]LeaderService), @@ -44,6 +45,7 @@ func newServices() *services { } } +// Add registers a new leader service. func (s *services) Add(service LeaderService) error { s.rw.Lock() defer s.rw.Unlock() @@ -60,6 +62,7 @@ func (s *services) Add(service LeaderService) error { return nil } +// StartAllServices starts all registered services that are not currently running. func (s *services) StartAllServices(ctx context.Context) { type startItem struct { svc LeaderService @@ -89,7 +92,8 @@ func (s *services) StartAllServices(ctx context.Context) { } } -func (s *services) StopRunningServices() { +// StopAllServices stops all running services. +func (s *services) StopAllServices() { s.rw.Lock() toStop := make([]LeaderService, 0, len(s.running)) for id, service := range s.running { @@ -105,14 +109,17 @@ func (s *services) StopRunningServices() { } } +// Refresh returns a channel that signals when services should be refreshed. func (s *services) Refresh() chan struct{} { return s.refresh } +// Wait waits for all services to stop. func (s *services) Wait() { s.wg.Wait() } +// removeService removes a service from the registry of running services. func (s *services) removeService(id string) { s.rw.Lock() delete(s.running, id) diff --git a/managed/services/ha/services_test.go b/managed/services/ha/services_test.go new file mode 100644 index 00000000000..a921513d5c7 --- /dev/null +++ b/managed/services/ha/services_test.go @@ -0,0 +1,409 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ha + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewServices(t *testing.T) { + t.Parallel() + + s := newServices() + + require.NotNil(t, s) + assert.NotNil(t, s.all) + assert.NotNil(t, s.running) + assert.NotNil(t, s.refresh) + assert.NotNil(t, s.l) + assert.Empty(t, s.all) + assert.Empty(t, s.running) +} + +func TestServices_Add(t *testing.T) { + t.Parallel() + + t.Run("add single service succeeds", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc := &mockLeaderService{id: "test-service-1"} + + err := s.Add(svc) + + require.NoError(t, err) + assert.Len(t, s.all, 1) + assert.Equal(t, svc, s.all["test-service-1"]) + }) + + t.Run("add duplicate service returns error", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc1 := &mockLeaderService{id: "duplicate-id"} + svc2 := &mockLeaderService{id: "duplicate-id"} + + err := s.Add(svc1) + require.NoError(t, err) + + err = s.Add(svc2) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + assert.Len(t, s.all, 1) + }) + + t.Run("add triggers refresh signal", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc := &mockLeaderService{id: "test-service"} + + err := s.Add(svc) + require.NoError(t, err) + + select { + case <-s.refresh: + case <-time.After(100 * time.Millisecond): + t.Fatal("refresh signal not received") + } + }) + + t.Run("concurrent add operations", func(t *testing.T) { + t.Parallel() + + s := newServices() + const numServices = 10 + var wg sync.WaitGroup + + for i := 0; i < numServices; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + svc := &mockLeaderService{id: string(rune('a' + id))} + _ = s.Add(svc) + }(i) + } + + wg.Wait() + assert.Len(t, s.all, numServices) + }) +} + +func TestServices_StartAllServices(t *testing.T) { + t.Parallel() + + t.Run("starts only non-running services", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc1 := &mockLeaderService{id: "service-1"} + svc2 := &mockLeaderService{id: "service-2"} + + require.NoError(t, s.Add(svc1)) + require.NoError(t, s.Add(svc2)) + + ctx := context.Background() + s.StartAllServices(ctx) + + time.Sleep(50 * time.Millisecond) + + assert.True(t, svc1.isStarted()) + assert.True(t, svc2.isStarted()) + + s.rw.Lock() + runningCount := len(s.running) + s.rw.Unlock() + assert.Equal(t, 2, runningCount) + }) + + t.Run("marks services as running", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc := &mockLeaderService{id: "test-service"} + + require.NoError(t, s.Add(svc)) + + ctx := context.Background() + s.StartAllServices(ctx) + + time.Sleep(50 * time.Millisecond) + + s.rw.Lock() + runningCount := len(s.running) + _, exists := s.running["test-service"] + s.rw.Unlock() + + assert.Equal(t, 1, runningCount) + assert.True(t, exists) + }) + + t.Run("handles service start errors", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc := &mockLeaderService{ + id: "failing-service", + startErr: errors.New("start failed"), + startDone: make(chan struct{}), + } + + require.NoError(t, s.Add(svc)) + + ctx := context.Background() + s.StartAllServices(ctx) + + select { + case <-svc.startDone: + case <-time.After(200 * time.Millisecond): + t.Fatal("service start did not complete") + } + + // Wait for service to be removed from running map after error + time.Sleep(100 * time.Millisecond) + + assert.True(t, svc.isStarted()) + + // Check running map is empty + s.rw.Lock() + isEmpty := len(s.running) == 0 + s.rw.Unlock() + assert.True(t, isEmpty) + }) + + t.Run("does not restart already running services", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc := &mockLeaderService{id: "test-service"} + + require.NoError(t, s.Add(svc)) + + ctx := context.Background() + s.StartAllServices(ctx) + + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 1, svc.getStartCount()) + + s.StartAllServices(ctx) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 1, svc.getStartCount()) + }) +} + +func TestServices_StopAllServices(t *testing.T) { + t.Parallel() + + t.Run("stops all running services", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc1 := &mockLeaderService{id: "service-1"} + svc2 := &mockLeaderService{id: "service-2"} + + require.NoError(t, s.Add(svc1)) + require.NoError(t, s.Add(svc2)) + + ctx := context.Background() + s.StartAllServices(ctx) + + time.Sleep(50 * time.Millisecond) + + s.StopAllServices() + + assert.True(t, svc1.isStopped()) + assert.True(t, svc2.isStopped()) + }) + + t.Run("removes services from running map", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc := &mockLeaderService{id: "test-service"} + + require.NoError(t, s.Add(svc)) + + ctx := context.Background() + s.StartAllServices(ctx) + + time.Sleep(50 * time.Millisecond) + + s.rw.Lock() + runningCount := len(s.running) + s.rw.Unlock() + assert.Equal(t, 1, runningCount) + + s.StopAllServices() + + s.rw.Lock() + runningCount = len(s.running) + s.rw.Unlock() + assert.Equal(t, 0, runningCount) + }) + + t.Run("handles stopping with no running services", func(t *testing.T) { + t.Parallel() + + s := newServices() + + assert.NotPanics(t, func() { + s.StopAllServices() + }) + + s.rw.Lock() + isEmpty := len(s.running) == 0 + s.rw.Unlock() + assert.True(t, isEmpty) + }) +} + +func TestServices_Refresh(t *testing.T) { + t.Parallel() + + t.Run("returns valid channel", func(t *testing.T) { + t.Parallel() + + s := newServices() + ch := s.Refresh() + + require.NotNil(t, ch) + }) + + t.Run("channel receives signals on add", func(t *testing.T) { + t.Parallel() + + s := newServices() + ch := s.Refresh() + + svc := &mockLeaderService{id: "test-service"} + err := s.Add(svc) + require.NoError(t, err) + + select { + case <-ch: + case <-time.After(100 * time.Millisecond): + t.Fatal("refresh signal not received") + } + }) +} + +func TestServices_Wait(t *testing.T) { + t.Parallel() + + t.Run("waits for all services to complete", func(t *testing.T) { + t.Parallel() + + s := newServices() + svc := &mockLeaderService{ + id: "blocking-service", + blockStart: true, + startUnlock: make(chan struct{}), + } + + require.NoError(t, s.Add(svc)) + + ctx := context.Background() + s.StartAllServices(ctx) + + time.Sleep(50 * time.Millisecond) + + done := make(chan struct{}) + go func() { + s.Wait() + close(done) + }() + + select { + case <-done: + t.Fatal("Wait returned before service completed") + case <-time.After(50 * time.Millisecond): + } + + s.StopAllServices() + close(svc.startUnlock) + + select { + case <-done: + case <-time.After(200 * time.Millisecond): + t.Fatal("Wait did not return after service completed") + } + }) +} + +type mockLeaderService struct { + id string + started bool + stopped bool + startCount int + startErr error + blockStart bool + startUnlock chan struct{} + startDone chan struct{} + mu sync.Mutex +} + +func (m *mockLeaderService) ID() string { + return m.id +} + +func (m *mockLeaderService) Start(_ context.Context) error { + m.mu.Lock() + m.started = true + m.startCount++ + err := m.startErr + m.mu.Unlock() + + if m.startDone != nil { + close(m.startDone) + } + + if m.blockStart { + <-m.startUnlock + } + + return err +} + +func (m *mockLeaderService) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + m.stopped = true +} + +func (m *mockLeaderService) isStarted() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.started +} + +func (m *mockLeaderService) isStopped() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.stopped +} + +func (m *mockLeaderService) getStartCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.startCount +} diff --git a/managed/services/inventory/agents.go b/managed/services/inventory/agents.go index bad2990c622..bb5225cc88d 100644 --- a/managed/services/inventory/agents.go +++ b/managed/services/inventory/agents.go @@ -1102,7 +1102,7 @@ func (as *AgentsService) ChangeQANPostgreSQLPgStatementsAgent(ctx context.Contex if err != nil { return nil, status.Errorf(codes.NotFound, "agent with ID %q not found", agentID) } - if pointer.GetString(a.PMMAgentID) == "pmm-server" { + if pointer.GetString(a.PMMAgentID) == models.PMMServerAgentID { return nil, status.Errorf( codes.FailedPrecondition, "QAN for PMM's internal PostgreSQL server is set to %s via an environment variable.", diff --git a/managed/services/inventory/nodes_test.go b/managed/services/inventory/nodes_test.go index 1b35016f692..aa5d4a88423 100644 --- a/managed/services/inventory/nodes_test.go +++ b/managed/services/inventory/nodes_test.go @@ -110,7 +110,7 @@ func TestNodes(t *testing.T) { }, }, }) - tests.AssertGRPCError(t, status.New(codes.AlreadyExists, `Node with name "test" already exists.`), err) + tests.AssertGRPCError(t, status.New(codes.AlreadyExists, `Node with name test already exists.`), err) }) t.Run("AddHostnameNotUnique", func(t *testing.T) { @@ -402,6 +402,6 @@ func TestAddNode(t *testing.T) { Remote: &inventoryv1.AddRemoteNodeParams{NodeName: "test"}, }, }) - tests.AssertGRPCError(t, status.New(codes.AlreadyExists, `Node with name "test" already exists.`), err) + tests.AssertGRPCError(t, status.New(codes.AlreadyExists, `Node with name test already exists.`), err) }) } diff --git a/managed/services/management/node.go b/managed/services/management/node.go index 9cb9ff40cbb..b91b875b409 100644 --- a/managed/services/management/node.go +++ b/managed/services/management/node.go @@ -44,7 +44,7 @@ func (s *ManagementService) RegisterNode(ctx context.Context, req *managementv1. switch status.Code(err) { //nolint:exhaustive case codes.OK: if !req.Reregister { - return status.Errorf(codes.AlreadyExists, "Node with name %q already exists.", req.NodeName) + return status.Errorf(codes.AlreadyExists, "Node with name %s already exists.", req.NodeName) } err = models.RemoveNode(tx.Querier, node.NodeID, models.RemoveCascade) case codes.NotFound: @@ -310,21 +310,22 @@ func (s *ManagementService) ListNodes(ctx context.Context, req *managementv1.Lis } uNode := &managementv1.UniversalNode{ - Address: node.Address, - CustomLabels: labels, - NodeId: node.NodeID, - NodeName: node.NodeName, - NodeType: string(node.NodeType), - Az: node.AZ, - CreatedAt: timestamppb.New(node.CreatedAt), - ContainerId: pointer.GetString(node.ContainerID), - ContainerName: pointer.GetString(node.ContainerName), - Distro: node.Distro, - MachineId: pointer.GetString(node.MachineID), - NodeModel: node.NodeModel, - Region: pointer.GetString(node.Region), - UpdatedAt: timestamppb.New(node.UpdatedAt), - InstanceId: node.InstanceID, + Address: node.Address, + CustomLabels: labels, + NodeId: node.NodeID, + NodeName: node.NodeName, + NodeType: string(node.NodeType), + Az: node.AZ, + CreatedAt: timestamppb.New(node.CreatedAt), + ContainerId: pointer.GetString(node.ContainerID), + ContainerName: pointer.GetString(node.ContainerName), + Distro: node.Distro, + MachineId: pointer.GetString(node.MachineID), + NodeModel: node.NodeModel, + Region: pointer.GetString(node.Region), + UpdatedAt: timestamppb.New(node.UpdatedAt), + InstanceId: node.InstanceID, + IsPmmServerNode: node.IsPMMServerNode, } if metric, ok := metrics[node.NodeID]; ok { @@ -384,20 +385,21 @@ func (s *ManagementService) GetNode(ctx context.Context, req *managementv1.GetNo } uNode := &managementv1.UniversalNode{ - Address: node.Address, - Az: node.AZ, - CreatedAt: timestamppb.New(node.CreatedAt), - ContainerId: pointer.GetString(node.ContainerID), - ContainerName: pointer.GetString(node.ContainerName), - CustomLabels: labels, - Distro: node.Distro, - MachineId: pointer.GetString(node.MachineID), - NodeId: node.NodeID, - NodeName: node.NodeName, - NodeType: string(node.NodeType), - NodeModel: node.NodeModel, - Region: pointer.GetString(node.Region), - UpdatedAt: timestamppb.New(node.UpdatedAt), + Address: node.Address, + Az: node.AZ, + CreatedAt: timestamppb.New(node.CreatedAt), + ContainerId: pointer.GetString(node.ContainerID), + ContainerName: pointer.GetString(node.ContainerName), + CustomLabels: labels, + Distro: node.Distro, + MachineId: pointer.GetString(node.MachineID), + NodeId: node.NodeID, + NodeName: node.NodeName, + NodeType: string(node.NodeType), + NodeModel: node.NodeModel, + Region: pointer.GetString(node.Region), + UpdatedAt: timestamppb.New(node.UpdatedAt), + IsPmmServerNode: node.IsPMMServerNode, } if metric, ok := metrics[node.NodeID]; ok { diff --git a/managed/services/management/node_test.go b/managed/services/management/node_test.go index eff121a322d..6d4a0be4c9f 100644 --- a/managed/services/management/node_test.go +++ b/managed/services/management/node_test.go @@ -128,7 +128,7 @@ func TestNodeService(t *testing.T) { NodeName: getTestNodeName(), }) assert.Nil(t, res) - tests.AssertGRPCError(t, status.New(codes.AlreadyExists, `Node with name "test-node" already exists.`), err) + tests.AssertGRPCError(t, status.New(codes.AlreadyExists, `Node with name test-node already exists.`), err) }) t.Run("Reregister", func(t *testing.T) { @@ -321,20 +321,21 @@ func TestNodeService(t *testing.T) { expected := &managementv1.ListNodesResponse{ Nodes: []*managementv1.UniversalNode{ { - NodeId: "pmm-server", - NodeType: "generic", - NodeName: "pmm-server", - MachineId: "", - Distro: "", - NodeModel: "", - ContainerId: "", - ContainerName: "", - Address: "127.0.0.1", - Region: "", - Az: "", - CustomLabels: nil, - CreatedAt: timestamppb.New(now), - UpdatedAt: timestamppb.New(now), + NodeId: "pmm-server", + NodeType: "generic", + NodeName: "pmm-server", + MachineId: "", + Distro: "", + NodeModel: "", + ContainerId: "", + ContainerName: "", + Address: "127.0.0.1", + Region: "", + Az: "", + CustomLabels: nil, + CreatedAt: timestamppb.New(now), + UpdatedAt: timestamppb.New(now), + IsPmmServerNode: true, Agents: []*managementv1.UniversalNode_Agent{ { AgentId: nodeExporterID, @@ -406,20 +407,21 @@ func TestNodeService(t *testing.T) { expected := &managementv1.ListNodesResponse{ Nodes: []*managementv1.UniversalNode{ { - NodeId: "pmm-server", - NodeType: "generic", - NodeName: "pmm-server", - MachineId: "", - Distro: "", - NodeModel: "", - ContainerId: "", - ContainerName: "", - Address: "127.0.0.1", - Region: "", - Az: "", - CustomLabels: nil, - CreatedAt: timestamppb.New(now), - UpdatedAt: timestamppb.New(now), + NodeId: "pmm-server", + NodeType: "generic", + NodeName: "pmm-server", + MachineId: "", + Distro: "", + NodeModel: "", + ContainerId: "", + ContainerName: "", + Address: "127.0.0.1", + Region: "", + Az: "", + CustomLabels: nil, + CreatedAt: timestamppb.New(now), + UpdatedAt: timestamppb.New(now), + IsPmmServerNode: true, Agents: []*managementv1.UniversalNode_Agent{ { AgentId: nodeExporterID, @@ -530,21 +532,22 @@ func TestNodeService(t *testing.T) { expected := &managementv1.GetNodeResponse{ Node: &managementv1.UniversalNode{ - NodeId: "pmm-server", - NodeType: "generic", - NodeName: "pmm-server", - MachineId: "", - Distro: "", - NodeModel: "", - ContainerId: "", - ContainerName: "", - Address: "127.0.0.1", - Region: "", - Az: "", - CustomLabels: nil, - CreatedAt: timestamppb.New(now), - UpdatedAt: timestamppb.New(now), - Status: managementv1.UniversalNode_STATUS_UP, + NodeId: "pmm-server", + NodeType: "generic", + NodeName: "pmm-server", + MachineId: "", + Distro: "", + NodeModel: "", + ContainerId: "", + ContainerName: "", + Address: "127.0.0.1", + Region: "", + Az: "", + CustomLabels: nil, + CreatedAt: timestamppb.New(now), + UpdatedAt: timestamppb.New(now), + Status: managementv1.UniversalNode_STATUS_UP, + IsPmmServerNode: true, }, } diff --git a/managed/services/server/logs.go b/managed/services/server/logs.go index 735d0f44751..3d25f9b103d 100644 --- a/managed/services/server/logs.go +++ b/managed/services/server/logs.go @@ -35,6 +35,7 @@ import ( "github.com/pkg/errors" "golang.org/x/sys/unix" + "github.com/percona/pmm/managed/models" pprofUtils "github.com/percona/pmm/managed/utils/pprof" "github.com/percona/pmm/utils/logger" "github.com/percona/pmm/utils/pdeathsig" @@ -177,7 +178,7 @@ func (l *Logs) files(ctx context.Context, pprofConfig *PprofConfig, logReadLines "/etc/supervisord.d/vmalert.ini", "/etc/supervisord.d/vmproxy.ini", - "/usr/local/percona/pmm/config/pmm-agent.yaml", + models.AgentConfigFilePath, } { b, m, err := readFile(f) files = append(files, fileContent{ diff --git a/managed/services/supervisord/pmm_config.go b/managed/services/supervisord/pmm_config.go index 303626f0ac9..56b88d38cce 100644 --- a/managed/services/supervisord/pmm_config.go +++ b/managed/services/supervisord/pmm_config.go @@ -13,7 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package supervisord provides facilities for working with Supervisord. package supervisord import ( @@ -22,8 +21,11 @@ import ( "text/template" "github.com/pkg/errors" + "github.com/sirupsen/logrus" ) +const wwrPermissions = 0o664 + // SavePMMConfig renders and saves pmm config. func SavePMMConfig(params map[string]any) error { cfg, err := marshalConfig(params) @@ -33,6 +35,7 @@ func SavePMMConfig(params map[string]any) error { if err := saveConfig(pmmConfig, cfg); err != nil { return errors.Wrapf(err, "failed to save pmm config") } + logrus.Info("pmm.ini configuration has been updated.") return nil } @@ -66,12 +69,12 @@ func saveConfig(path string, cfg []byte) (err error) { if err == nil { return } - if resErr := os.WriteFile(path, oldCfg, 0o664); resErr != nil { //nolint:gosec + if resErr := os.WriteFile(path, oldCfg, wwrPermissions); resErr != nil { //nolint:gosec err = errors.Wrap(err, errors.Wrap(resErr, "failed to restore config").Error()) } }() - if err = os.WriteFile(path, cfg, 0o664); err != nil { //nolint:gosec + if err = os.WriteFile(path, cfg, wwrPermissions); err != nil { //nolint:gosec err = errors.Wrap(err, "failed to write new config") } return err @@ -179,7 +182,7 @@ redirect_stderr = true [program:pmm-agent] priority = 15 -command = /usr/sbin/pmm-agent --config-file=/usr/local/percona/pmm/config/pmm-agent.yaml --paths-tempdir=/srv/pmm-agent/tmp --paths-nomad-data-dir=/srv/nomad/data +command = /usr/sbin/pmm-agent --config-file={{ .AgentConfigFilePath }} --paths-tempdir=/srv/pmm-agent/tmp --paths-nomad-data-dir=/srv/nomad/data autorestart = true autostart = false startretries = 1000 diff --git a/managed/services/supervisord/pmm_config_test.go b/managed/services/supervisord/pmm_config_test.go index bb36783e3a5..652f946e4f3 100644 --- a/managed/services/supervisord/pmm_config_test.go +++ b/managed/services/supervisord/pmm_config_test.go @@ -34,12 +34,12 @@ func TestSavePMMConfig(t *testing.T) { }{ { description: "disable internal postgresql db", - params: map[string]any{"DisableInternalDB": true, "DisableSupervisor": false, "DisableInternalClickhouse": false}, + params: map[string]any{"DisableInternalDB": true, "DisableSupervisor": false, "DisableInternalClickhouse": false, "AgentConfigFilePath": "/usr/local/percona/pmm/config/pmm-agent.yaml"}, file: "pmm-db_disabled", }, { description: "enable internal postgresql db", - params: map[string]any{"DisableInternalDB": false, "DisableSupervisor": false, "DisableInternalClickhouse": false}, + params: map[string]any{"DisableInternalDB": false, "DisableSupervisor": false, "DisableInternalClickhouse": false, "AgentConfigFilePath": "/usr/local/percona/pmm/config/pmm-agent.yaml"}, file: "pmm-db_enabled", }, } diff --git a/managed/services/supervisord/supervisord.go b/managed/services/supervisord/supervisord.go index 64a96280b21..7e91db40c30 100644 --- a/managed/services/supervisord/supervisord.go +++ b/managed/services/supervisord/supervisord.go @@ -27,6 +27,7 @@ import ( "os/exec" "path/filepath" "reflect" + "slices" "strconv" "strings" "sync" @@ -146,14 +147,7 @@ func (s *Service) Run(ctx context.Context) { var toDelete []chan *event for ch, sub := range s.subs { if e.Program == sub.program { - var found bool - for _, t := range sub.eventTypes { - if e.Type == t { - found = true - break - } - } - if found { + if slices.Contains(sub.eventTypes, e.Type) { ch <- e close(ch) toDelete = append(toDelete, ch) @@ -214,6 +208,13 @@ func (s *Service) reload(name string) error { if _, err := s.supervisorctl("reread"); err != nil { s.l.Warn(err) } + + path := filepath.Join(s.configDir, name+".ini") + if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { + s.l.Warnf("Config file %s does not exist, skipping update", path) + return nil + } + _, err := s.supervisorctl("update", name) return err } @@ -222,7 +223,7 @@ func (s *Service) reload(name string) error { func (s *Service) marshalConfig(tmpl *template.Template, settings *models.Settings, ssoDetails *models.PerconaSSODetails) ([]byte, error) { clickhouseDatabase := envvars.GetEnv("PMM_CLICKHOUSE_DATABASE", defaultClickhouseDatabase) clickhouseAddr := envvars.GetEnv("PMM_CLICKHOUSE_ADDR", defaultClickhouseAddr) - clickhouseAddrPair := strings.SplitN(clickhouseAddr, ":", 2) + clickhouseAddrPair := strings.SplitN(clickhouseAddr, ":", 2) //nolint:mnd clickhouseUser := envvars.GetEnv("PMM_CLICKHOUSE_USER", defaultClickhouseUser) clickhousePassword := envvars.GetEnv("PMM_CLICKHOUSE_PASSWORD", defaultClickhousePassword) vmSearchDisableCache := envvars.GetEnv("VM_search_disableCache", strconv.FormatBool(!settings.IsVictoriaMetricsCacheEnabled())) @@ -237,7 +238,7 @@ func (s *Service) marshalConfig(tmpl *template.Template, settings *models.Settin templateParams := map[string]interface{}{ "DataRetentionHours": int(settings.DataRetention.Hours()), - "DataRetentionDays": int(settings.DataRetention.Hours() / 24), + "DataRetentionDays": int(settings.DataRetention.Hours() / 24), //nolint:mnd "VMAlertFlags": s.vmParams.VMAlertFlags, "VMSearchDisableCache": vmSearchDisableCache, "VMSearchMaxQueryLen": vmSearchMaxQueryLen, @@ -250,6 +251,7 @@ func (s *Service) marshalConfig(tmpl *template.Template, settings *models.Settin "VMPromscrapeStreamParse": vmPromscrapeStreamParse, "VMURL": s.vmParams.URL(), "ExternalVM": s.vmParams.ExternalVM(), + "NomadEnabled": settings.IsNomadEnabled(), "InterfaceToBind": envvars.GetInterfaceToBind(), "ClickhouseAddr": clickhouseAddr, "ClickhouseDatabase": clickhouseDatabase, @@ -257,12 +259,13 @@ func (s *Service) marshalConfig(tmpl *template.Template, settings *models.Settin "ClickhousePort": clickhouseAddrPair[1], "ClickhouseUser": clickhouseUser, "ClickhousePassword": clickhousePassword, + "PMMServerHost": "", + "PerconaSSODetails": nil, } s.addPostgresParams(templateParams) s.addClusterParams(templateParams) - templateParams["PMMServerHost"] = "" if settings.PMMPublicAddress != "" { pmmPublicAddress := settings.PMMPublicAddress if !strings.HasPrefix(pmmPublicAddress, "https://") && !strings.HasPrefix(pmmPublicAddress, "http://") { @@ -283,14 +286,6 @@ func (s *Service) marshalConfig(tmpl *template.Template, settings *models.Settin templateParams["PMMServerAddress"] = settings.PMMPublicAddress templateParams["PMMServerID"] = settings.PMMServerID templateParams["IssuerDomain"] = u.Host - } else { - templateParams["PerconaSSODetails"] = nil - } - - if settings.IsNomadEnabled() { - templateParams["NomadEnabled"] = "true" - } else { - templateParams["NomadEnabled"] = "false" } var buf bytes.Buffer @@ -352,10 +347,10 @@ func (s *Service) saveConfigAndReload(name string, cfg []byte) (bool, error) { } // restore old content and reload in case of error - restore := true + restore := oldCfg != nil defer func() { if restore { - if err = os.WriteFile(path, oldCfg, 0o664); err != nil { //nolint:gosec + if err = os.WriteFile(path, oldCfg, 0o664); err != nil { //nolint:gosec,mnd s.l.Errorf("Failed to restore: %s.", err) } if err = s.reload(name); err != nil { @@ -365,7 +360,7 @@ func (s *Service) saveConfigAndReload(name string, cfg []byte) (bool, error) { }() // write and reload - if err = os.WriteFile(path, cfg, 0o664); err != nil { //nolint:gosec + if err = os.WriteFile(path, cfg, 0o664); err != nil { //nolint:gosec,mnd return false, errors.WithStack(err) } if err = s.reload(name); err != nil { @@ -394,7 +389,23 @@ func (s *Service) UpdateConfiguration(settings *models.Settings, ssoDetails *mod } for _, tmpl := range templates.Templates() { - if tmpl.Name() == "" || (tmpl.Name() == "victoriametrics" && s.vmParams.ExternalVM()) { + if tmpl.Name() == "" { + continue + } + + if tmpl.Name() == "victoriametrics" && s.vmParams.ExternalVM() { + e := os.Remove(filepath.Join(s.configDir, tmpl.Name()+".ini")) + if e != nil && !errors.Is(e, fs.ErrNotExist) { + s.l.Warnf("Failed to remove %s config for external VM: %s.", tmpl.Name(), e) + } + continue + } + + if tmpl.Name() == "nomad-server" && !settings.IsNomadEnabled() { + e := os.Remove(filepath.Join(s.configDir, tmpl.Name()+".ini")) + if e != nil && !errors.Is(e, fs.ErrNotExist) { + s.l.Warnf("Failed to remove %s config when disabled: %s.", tmpl.Name(), e) + } continue } @@ -413,12 +424,6 @@ func (s *Service) UpdateConfiguration(settings *models.Settings, ssoDetails *mod return err } -// RestartSupervisedService restarts given service. -func (s *Service) RestartSupervisedService(serviceName string) error { - _, err := s.supervisorctl("restart", serviceName) - return err -} - // StartSupervisedService starts given service. func (s *Service) StartSupervisedService(serviceName string) error { _, err := s.supervisorctl("start", serviceName) @@ -435,7 +440,6 @@ func (s *Service) StopSupervisedService(serviceName string) error { var templates = template.Must(template.New("").Option("missingkey=error").Parse(` {{define "victoriametrics"}} -{{- if not .ExternalVM }} [program:victoriametrics] priority = 7 command = @@ -457,7 +461,7 @@ command = --envflag.enable --envflag.prefix=VM_ autorestart = true -autostart = true +autostart = {{ not .ExternalVM }} startretries = 10 startsecs = 1 stopsignal = INT @@ -466,7 +470,6 @@ stdout_logfile = /srv/logs/victoriametrics.log stdout_logfile_maxbytes = 10MB stdout_logfile_backups = 3 redirect_stderr = true -{{end -}} {{end}} {{define "vmalert"}} @@ -601,7 +604,7 @@ redirect_stderr = true [program:nomad-server] priority = 5 command = /usr/local/percona/pmm/tools/nomad agent -config /srv/nomad/nomad-server-{{ .PMMServerHost }}.hcl -autorestart = {{ .NomadEnabled }} +autorestart = true autostart = {{ .NomadEnabled }} startretries = 10 startsecs = 1 diff --git a/managed/services/supervisord/supervisord_test.go b/managed/services/supervisord/supervisord_test.go index e4a47e3647a..78dea345ea1 100644 --- a/managed/services/supervisord/supervisord_test.go +++ b/managed/services/supervisord/supervisord_test.go @@ -50,9 +50,7 @@ func TestConfig(t *testing.T) { PMMPublicAddress: "192.168.0.42:8443", } settings.VictoriaMetrics.CacheEnabled = pointer.ToBool(false) - - err = s.UpdateConfiguration(settings, nil) - require.NoError(t, err) + settings.Nomad.Enabled = pointer.ToBool(true) for _, tmpl := range templates.Templates() { n := tmpl.Name() diff --git a/managed/services/telemetry/config.default.yml b/managed/services/telemetry/config.default.yml index 2f11dfec594..28bff2fa9f1 100644 --- a/managed/services/telemetry/config.default.yml +++ b/managed/services/telemetry/config.default.yml @@ -955,7 +955,7 @@ telemetry: summary: "Use of HA feature" data: - metric_name: "pmm_server_ha_enable" - column: "PMM_TEST_HA_ENABLE" + column: "PMM_HA_ENABLE" - id: PMMServerBuiltinDatabaseDisabled source: ENV_VARS diff --git a/managed/services/versioncache/errors.go b/managed/services/versioncache/errors.go index 7a0eba21b55..36fd56460ca 100644 --- a/managed/services/versioncache/errors.go +++ b/managed/services/versioncache/errors.go @@ -16,7 +16,7 @@ // Package versioncache provides service software version cache functionality. package versioncache -import "github.com/pkg/errors" +import "errors" // ErrInvalidArgument is returned when provided argument is invalid. var ErrInvalidArgument = errors.New("invalid argument") diff --git a/managed/services/versioncache/versioncache.go b/managed/services/versioncache/versioncache.go index 34028665c07..f7283e404fb 100644 --- a/managed/services/versioncache/versioncache.go +++ b/managed/services/versioncache/versioncache.go @@ -206,13 +206,12 @@ func (s *Service) RequestSoftwareVersionsUpdate() { // Run runs software version cache service. func (s *Service) Run(ctx context.Context) { - time.Sleep(startupDelay) // sleep a while, so the server establishes the connections with agents. + // Sleep a while, so the server establishes the connections with agents. + time.Sleep(startupDelay) s.l.Info("Starting...") defer s.l.Info("Done.") - defer close(s.updateCh) - var checkAfter time.Duration for { select { diff --git a/managed/services/victoriametrics/deps.go b/managed/services/victoriametrics/deps.go new file mode 100644 index 00000000000..ede31af6404 --- /dev/null +++ b/managed/services/victoriametrics/deps.go @@ -0,0 +1,24 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package victoriametrics + +import "github.com/percona/pmm/managed/models" + +// haService is an interface for checking HA leadership status. +type haService interface { + IsLeader() bool + Params() *models.HAParams +} diff --git a/managed/services/victoriametrics/mock_ha_service_test.go b/managed/services/victoriametrics/mock_ha_service_test.go new file mode 100644 index 00000000000..42e7cb6d446 --- /dev/null +++ b/managed/services/victoriametrics/mock_ha_service_test.go @@ -0,0 +1,67 @@ +// Code generated by mockery. DO NOT EDIT. + +package victoriametrics + +import ( + mock "github.com/stretchr/testify/mock" + + models "github.com/percona/pmm/managed/models" +) + +// mockHaService is an autogenerated mock type for the haService type +type mockHaService struct { + mock.Mock +} + +// IsLeader provides a mock function with no fields +func (_m *mockHaService) IsLeader() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for IsLeader") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Params provides a mock function with no fields +func (_m *mockHaService) Params() *models.HAParams { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Params") + } + + var r0 *models.HAParams + if rf, ok := ret.Get(0).(func() *models.HAParams); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.HAParams) + } + } + + return r0 +} + +// newMockHaService creates a new instance of mockHaService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockHaService(t interface { + mock.TestingT + Cleanup(func()) +}, +) *mockHaService { + mock := &mockHaService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/managed/services/victoriametrics/prometheus.go b/managed/services/victoriametrics/prometheus.go index a61093a0c4f..53d37e355de 100644 --- a/managed/services/victoriametrics/prometheus.go +++ b/managed/services/victoriametrics/prometheus.go @@ -29,7 +29,7 @@ import ( // AddScrapeConfigs - adds agents scrape configuration to given scrape config, // pmm_agent_id and push_metrics used for filtering. func AddScrapeConfigs(l *logrus.Entry, cfg *config.Config, q *reform.Querier, //nolint:cyclop,maintidx - globalResolutions *models.MetricsResolutions, pmmAgentID *string, pushMetrics bool, + globalResolutions *models.MetricsResolutions, pmmAgentID *string, pushMetrics bool, skipExternalAgents bool, ) error { agents, err := models.FindAgentsForScrapeConfig(q, pmmAgentID, pushMetrics) if err != nil { @@ -73,8 +73,9 @@ func AddScrapeConfigs(l *logrus.Entry, cfg *config.Config, q *reform.Querier, // var paramsHost string var paramPMMAgentVersion *version.Parsed var pmmAgent *models.Agent + var pmmAgentNode *models.Node if agent.PMMAgentID != nil { - // extract node address through pmm-agent + // find a related pmm-agent to get the node address (runs_on_node_id) pmmAgent, err = models.FindAgentByID(q, *agent.PMMAgentID) if err != nil { return errors.WithStack(err) @@ -85,12 +86,10 @@ func AddScrapeConfigs(l *logrus.Entry, cfg *config.Config, q *reform.Querier, // } } switch { - // special case for push metrics mode, - // vmagent scrapes it from localhost. case pushMetrics: paramsHost = "127.0.0.1" case agent.PMMAgentID != nil: - pmmAgentNode := &models.Node{NodeID: pointer.GetString(pmmAgent.RunsOnNodeID)} + pmmAgentNode = &models.Node{NodeID: pointer.GetString(pmmAgent.RunsOnNodeID)} if err = q.Reload(pmmAgentNode); err != nil { return errors.WithStack(err) } @@ -107,6 +106,15 @@ func AddScrapeConfigs(l *logrus.Entry, cfg *config.Config, q *reform.Querier, // continue } + // In HA mode, skip generating scrape config for agents that run on other PMM Server nodes. + // These agents listen on 127.0.0.1 and are unreachable from this PMM instance. + // We check the node where the pmm-agent runs (not the service node). + if !pushMetrics && pmmAgentNode != nil && pmmAgentNode.NodeID != models.PMMServerNodeID && pmmAgentNode.IsPMMServerNode { + l.Debugf("Skip the scrape config for %s agent %s running on remote PMM Server node %s in HA mode", + agent.AgentType, agent.AgentID, pmmAgentNode.NodeName) + continue + } + mr := *globalResolutions // copy global resolutions if agent.ExporterOptions.MetricsResolutions != nil { if agent.ExporterOptions.MetricsResolutions.MR != 0 { @@ -187,6 +195,10 @@ func AddScrapeConfigs(l *logrus.Entry, cfg *config.Config, q *reform.Querier, // continue case models.RDSExporterType: + if skipExternalAgents && pointer.GetString(agent.RunsOnNodeID) == models.PMMServerNodeID { + l.Debugf("Skip the scrape config for RDSExporter %s running on PMM Server in HA non-leader mode", agent.AgentID) + continue + } rdsParams = append(rdsParams, &scrapeConfigParams{ host: paramsHost, node: paramsNode, @@ -197,6 +209,10 @@ func AddScrapeConfigs(l *logrus.Entry, cfg *config.Config, q *reform.Querier, // continue case models.ExternalExporterType: + if skipExternalAgents && pointer.GetString(agent.RunsOnNodeID) == models.PMMServerNodeID { + l.Debugf("Skip the scrape config for ExternalExporter %s running on PMM Server in HA non-leader mode", agent.AgentID) + continue + } scfgs, err = scrapeConfigsForExternalExporter(&mr, &scrapeConfigParams{ host: paramsHost, node: paramsNode, @@ -248,10 +264,16 @@ func AddScrapeConfigs(l *logrus.Entry, cfg *config.Config, q *reform.Querier, // } // AddInternalServicesToScrape adds internal services metrics to scrape targets. -func AddInternalServicesToScrape(cfg *config.Config, s models.MetricsResolutions) { - cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, - scrapeConfigForGrafana(s.MR), - scrapeConfigForPMMManaged(s.MR), - scrapeConfigForQANAPI2(s.MR), - scrapeConfigForClickhouse(s.MR)) +func addInternalServicesToScrape(s models.MetricsResolutions, svc *Service, pmmServerNodeName string) []*config.ScrapeConfig { + cfg := []*config.ScrapeConfig{ + scrapeConfigForGrafana(s.MR, pmmServerNodeName), + scrapeConfigForPMMManaged(s.MR, pmmServerNodeName), + scrapeConfigForQANAPI2(s.MR, pmmServerNodeName), + } + + if svc.params.ExternalVM() { + return cfg + } + + return append(cfg, scrapeConfigForClickhouse(s.MR, pmmServerNodeName)) } diff --git a/managed/services/victoriametrics/scrape_configs.go b/managed/services/victoriametrics/scrape_configs.go index fcb13d1d06b..f01eca1030f 100644 --- a/managed/services/victoriametrics/scrape_configs.go +++ b/managed/services/victoriametrics/scrape_configs.go @@ -43,11 +43,11 @@ func scrapeTimeout(interval time.Duration) config.Duration { case interval <= 2*time.Second: return config.Duration(time.Second) default: - return config.Duration(float64(interval) * 0.9) + return config.Duration(float64(interval) * 0.9) //nolint:mnd } } -func scrapeConfigForClickhouse(mr time.Duration) *config.ScrapeConfig { +func scrapeConfigForClickhouse(mr time.Duration, pmmServerNodeName string) *config.ScrapeConfig { return &config.ScrapeConfig{ JobName: "clickhouse", ScrapeInterval: config.Duration(mr), @@ -56,13 +56,13 @@ func scrapeConfigForClickhouse(mr time.Duration) *config.ScrapeConfig { ServiceDiscoveryConfig: config.ServiceDiscoveryConfig{ StaticConfigs: []*config.Group{{ Targets: []string{"127.0.0.1:9363"}, - Labels: map[string]string{"instance": "pmm-server"}, + Labels: map[string]string{"instance": pmmServerNodeName}, }}, }, } } -func scrapeConfigForGrafana(interval time.Duration) *config.ScrapeConfig { +func scrapeConfigForGrafana(interval time.Duration, pmmServerNodeName string) *config.ScrapeConfig { return &config.ScrapeConfig{ JobName: "grafana", ScrapeInterval: config.Duration(interval), @@ -71,13 +71,13 @@ func scrapeConfigForGrafana(interval time.Duration) *config.ScrapeConfig { ServiceDiscoveryConfig: config.ServiceDiscoveryConfig{ StaticConfigs: []*config.Group{{ Targets: []string{"127.0.0.1:3000"}, - Labels: map[string]string{"instance": "pmm-server"}, + Labels: map[string]string{"instance": pmmServerNodeName}, }}, }, } } -func scrapeConfigForPMMManaged(interval time.Duration) *config.ScrapeConfig { +func scrapeConfigForPMMManaged(interval time.Duration, pmmServerNodeName string) *config.ScrapeConfig { return &config.ScrapeConfig{ JobName: "pmm-managed", ScrapeInterval: config.Duration(interval), @@ -86,13 +86,13 @@ func scrapeConfigForPMMManaged(interval time.Duration) *config.ScrapeConfig { ServiceDiscoveryConfig: config.ServiceDiscoveryConfig{ StaticConfigs: []*config.Group{{ Targets: []string{"127.0.0.1:7773"}, - Labels: map[string]string{"instance": "pmm-server"}, + Labels: map[string]string{"instance": pmmServerNodeName}, }}, }, } } -func scrapeConfigForQANAPI2(interval time.Duration) *config.ScrapeConfig { +func scrapeConfigForQANAPI2(interval time.Duration, pmmServerNodeName string) *config.ScrapeConfig { return &config.ScrapeConfig{ JobName: "qan-api2", ScrapeInterval: config.Duration(interval), @@ -101,13 +101,13 @@ func scrapeConfigForQANAPI2(interval time.Duration) *config.ScrapeConfig { ServiceDiscoveryConfig: config.ServiceDiscoveryConfig{ StaticConfigs: []*config.Group{{ Targets: []string{"127.0.0.1:9933"}, - Labels: map[string]string{"instance": "pmm-server"}, + Labels: map[string]string{"instance": pmmServerNodeName}, }}, }, } } -func scrapeConfigForNomadServer(resolution time.Duration) *config.ScrapeConfig { +func scrapeConfigForNomadServer(resolution time.Duration, pmmServerNodeName string) *config.ScrapeConfig { return &config.ScrapeConfig{ JobName: "nomad", ScrapeInterval: config.Duration(resolution), @@ -117,7 +117,7 @@ func scrapeConfigForNomadServer(resolution time.Duration) *config.ScrapeConfig { ServiceDiscoveryConfig: config.ServiceDiscoveryConfig{ StaticConfigs: []*config.Group{{ Targets: []string{"127.0.0.1:4646"}, - Labels: map[string]string{"instance": "pmm-server"}, + Labels: map[string]string{"instance": pmmServerNodeName}, }}, }, HTTPClientConfig: config.HTTPClientConfig{ @@ -138,6 +138,7 @@ func scrapeConfigsForNomadAgent(m *models.MetricsResolutions, s *scrapeConfigPar JobName: jobName(s.agent, "mr"), ScrapeInterval: config.Duration(m.MR), ScrapeTimeout: scrapeTimeout(m.MR), + Scheme: "https", MetricsPath: "/v1/metrics", } @@ -548,7 +549,7 @@ func scrapeConfigsForRDSExporter(params []*scrapeConfigParams) []*config.ScrapeC } sort.Strings(hostports) - r := make([]*config.ScrapeConfig, 0, len(hostports)*2) + r := make([]*config.ScrapeConfig, 0, len(hostports)*2) //nolint:mnd for _, hostport := range hostports { metricsResolutions := hostportMap[hostport] mr := scrapeConfigForRDSExporter("mr", metricsResolutions.MR, hostport, "/enhanced") diff --git a/managed/services/victoriametrics/victoriametrics.go b/managed/services/victoriametrics/victoriametrics.go index 6128ad5a5d6..b3dd0ef2202 100644 --- a/managed/services/victoriametrics/victoriametrics.go +++ b/managed/services/victoriametrics/victoriametrics.go @@ -62,12 +62,13 @@ type Service struct { params *models.VictoriaMetricsParams - l *logrus.Entry - reloadCh chan struct{} + l *logrus.Entry + reloadCh chan struct{} + haService haService } // NewVictoriaMetrics creates new VictoriaMetrics service. -func NewVictoriaMetrics(scrapeConfigPath string, db *reform.DB, params *models.VictoriaMetricsParams) (*Service, error) { +func NewVictoriaMetrics(scrapeConfigPath string, db *reform.DB, params *models.VictoriaMetricsParams, haService haService) (*Service, error) { u, err := url.Parse(params.URL()) if err != nil { return nil, errors.WithStack(err) @@ -81,6 +82,7 @@ func NewVictoriaMetrics(scrapeConfigPath string, db *reform.DB, params *models.V params: params, l: logrus.WithField("component", "victoriametrics"), reloadCh: make(chan struct{}, 1), + haService: haService, }, nil } @@ -100,7 +102,7 @@ func (svc *Service) Run(ctx context.Context) { } // reloadCh, configuration update loop, and RequestConfigurationUpdate method ensure that configuration - // is reloaded when requested, but several requests are batched together to avoid too often reloads. + // is reloaded when requested, but several requests are batched together to avoid too frequent reloads. // That allows the caller to just call RequestConfigurationUpdate when it seems fit. if cap(svc.reloadCh) != 1 { panic("reloadCh should have capacity 1") @@ -139,9 +141,27 @@ func (svc *Service) RequestConfigurationUpdate() { } } +// Start is called when this node becomes the leader in HA mode. +func (svc *Service) Start(_ context.Context) error { //nolint:unparam + svc.l.Info("Became leader, triggering configuration update to include external agents") + svc.RequestConfigurationUpdate() + return nil +} + +// Stop is called when this node loses leadership in HA mode. +func (svc *Service) Stop() { + svc.l.Info("Lost leadership, triggering configuration update to exclude external agents") + svc.RequestConfigurationUpdate() +} + +// ID returns the service identifier. +func (svc *Service) ID() string { + return "victoriametrics" +} + // updateConfiguration updates VictoriaMetrics configuration. func (svc *Service) updateConfiguration(ctx context.Context) error { - if svc.params.ExternalVM() { + if svc.params.ExternalVM() && !svc.haService.Params().Enabled { return nil } start := time.Now() @@ -228,7 +248,7 @@ func (svc *Service) marshalConfig(base *config.Config) ([]byte, error) { return b, nil } -// validateConfig validates given configuration with `victoriametrics -dryRun`. +// validateConfig validates given configuration with `victoriametrics -promscrape.config.dryRun`. func (svc *Service) validateConfig(ctx context.Context, cfg []byte) error { f, err := os.CreateTemp("", "pmm-managed-config-victoriametrics-") if err != nil { @@ -250,7 +270,7 @@ func (svc *Service) validateConfig(ctx context.Context, cfg []byte) error { if err != nil { svc.l.Errorf("%s", b) s := string(b) - if m := checkFailedRE.FindStringSubmatch(s); len(m) == 2 { + if m := checkFailedRE.FindStringSubmatch(s); len(m) == 2 { //nolint:mnd return status.Error(codes.Aborted, m[1]) } @@ -265,7 +285,7 @@ func (svc *Service) validateConfig(ctx context.Context, cfg []byte) error { b, err = cmd.CombinedOutput() if err != nil { s := string(b) - if m := checkFailedRE.FindStringSubmatch(s); len(m) == 2 { + if m := checkFailedRE.FindStringSubmatch(s); len(m) == 2 { //nolint:mnd svc.l.Warnf("VictoriaMetrics scrape configuration contains unsupported params: %s", m[1]) } else { svc.l.Warnf("VictoriaMetrics scrape configuration contains unsupported params: %s", b) @@ -326,6 +346,12 @@ func (svc *Service) populateConfig(cfg *config.Config) error { if err != nil { return err } + + pmmServerNodeName := models.PMMServerNodeID + if svc.haService.Params().Enabled { + pmmServerNodeName = svc.haService.Params().NodeID + } + resolutions := settings.MetricsResolutions if cfg.GlobalConfig.ScrapeInterval == 0 { cfg.GlobalConfig.ScrapeInterval = config.Duration(resolutions.LR) @@ -333,17 +359,20 @@ func (svc *Service) populateConfig(cfg *config.Config) error { if cfg.GlobalConfig.ScrapeTimeout == 0 { cfg.GlobalConfig.ScrapeTimeout = ScrapeTimeout(resolutions.LR) } - cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, scrapeConfigForVictoriaMetrics(svc.l, resolutions.HR, svc.params)) - if svc.params.ExternalVM() { + if !svc.params.ExternalVM() && !svc.haService.Params().Enabled { + cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, scrapeConfigForVictoriaMetrics(svc.l, resolutions.HR, svc.params)) + } + if svc.params.ExternalVM() && !svc.haService.Params().Enabled { cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, scrapeConfigForInternalVMAgent(resolutions.HR, svc.baseURL.Host)) } - cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, scrapeConfigForVMAlert(resolutions.HR)) - AddInternalServicesToScrape(cfg, resolutions) + cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, scrapeConfigForVMAlert(resolutions.HR, pmmServerNodeName)) + cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, addInternalServicesToScrape(resolutions, svc, pmmServerNodeName)...) if pointer.GetBool(settings.Nomad.Enabled) { - cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, - scrapeConfigForNomadServer(resolutions.MR)) + cfg.ScrapeConfigs = append(cfg.ScrapeConfigs, scrapeConfigForNomadServer(resolutions.MR, pmmServerNodeName)) } - return AddScrapeConfigs(svc.l, cfg, tx.Querier, &resolutions, nil, false) + // In HA mode, skip external exporter agents if this node is not the leader + skipExternalAgents := !svc.haService.IsLeader() + return AddScrapeConfigs(svc.l, cfg, tx.Querier, &resolutions, nil, false, skipExternalAgents) }) } @@ -390,7 +419,7 @@ func scrapeConfigForInternalVMAgent(interval time.Duration, target string) *conf } // scrapeConfigForVMAlert returns scrape config for VMAlert in Prometheus format. -func scrapeConfigForVMAlert(interval time.Duration) *config.ScrapeConfig { +func scrapeConfigForVMAlert(interval time.Duration, pmmServerNodeName string) *config.ScrapeConfig { return &config.ScrapeConfig{ JobName: "vmalert", ScrapeInterval: config.Duration(interval), @@ -400,7 +429,7 @@ func scrapeConfigForVMAlert(interval time.Duration) *config.ScrapeConfig { StaticConfigs: []*config.Group{ { Targets: []string{"127.0.0.1:8880"}, - Labels: map[string]string{"instance": "pmm-server"}, + Labels: map[string]string{"instance": pmmServerNodeName}, }, }, }, @@ -408,18 +437,20 @@ func scrapeConfigForVMAlert(interval time.Duration) *config.ScrapeConfig { } // BuildScrapeConfigForVMAgent builds scrape configuration for given pmm-agent. -func (svc *Service) BuildScrapeConfigForVMAgent(pmmAgentID string) ([]byte, error) { +func (svc *Service) BuildScrapeConfigForVMAgent(ctx context.Context, pmmAgentID string) ([]byte, error) { if pmmAgentID == models.PMMServerAgentID { return svc.buildVMConfig() } var cfg config.Config - e := svc.db.InTransaction(func(tx *reform.TX) error { + e := svc.db.InTransactionContext(ctx, nil, func(tx *reform.TX) error { settings, err := models.GetSettings(tx) if err != nil { return err } s := settings.MetricsResolutions - return AddScrapeConfigs(svc.l, &cfg, tx.Querier, &s, pointer.ToString(pmmAgentID), true) + // In HA mode, skip ExternalExporter agents if this node is not the leader + skipExternalExporter := !svc.haService.IsLeader() + return AddScrapeConfigs(svc.l, &cfg, tx.Querier, &s, pointer.ToString(pmmAgentID), true, skipExternalExporter) }) if e != nil { return nil, e @@ -444,7 +475,7 @@ func (svc *Service) IsReady(ctx context.Context) error { if err != nil { return errors.WithStack(err) } - defer resp.Body.Close() //nolint:gosec,errcheck,nolintlint + defer resp.Body.Close() //nolint:errcheck b, err := io.ReadAll(resp.Body) svc.l.Debugf("VM health: %s", b) diff --git a/managed/services/victoriametrics/victoriametrics_test.go b/managed/services/victoriametrics/victoriametrics_test.go index 5adf7110104..0a97e675358 100644 --- a/managed/services/victoriametrics/victoriametrics_test.go +++ b/managed/services/victoriametrics/victoriametrics_test.go @@ -45,13 +45,17 @@ func setup(t *testing.T) (*reform.DB, *Service, []byte) { db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) vmParams, err := models.NewVictoriaMetricsParams(models.BasePrometheusConfigPath, models.VMBaseURL) check.NoError(err) - svc, err := NewVictoriaMetrics(configPath, db, vmParams) + + mockHaService := newMockHaService(t) + mockHaService.On("Params").Return(&models.HAParams{Enabled: false, NodeID: "pmm-ha-service-0"}).Maybe() + mockHaService.On("IsLeader").Return(true).Maybe() + svc, err := NewVictoriaMetrics(configPath, db, vmParams, mockHaService) check.NoError(err) original, err := os.ReadFile(configPath) check.NoError(err) - check.NoError(svc.IsReady(context.Background())) + check.NoError(svc.IsReady(t.Context())) return db, svc, original } @@ -61,7 +65,7 @@ func teardown(t *testing.T, db *reform.DB, svc *Service, original []byte) { check := assert.New(t) check.NoError(os.WriteFile(configPath, original, 0o600)) - check.NoError(svc.reload(context.Background())) + check.NoError(svc.reload(t.Context())) check.NoError(db.DBInterface().(*sql.DB).Close()) } @@ -72,7 +76,7 @@ func TestVictoriaMetrics(t *testing.T) { db, svc, original := setup(t) defer teardown(t, db, svc, original) - check.NoError(svc.updateConfiguration(context.Background())) + check.NoError(svc.updateConfiguration(t.Context())) actual, err := os.ReadFile(configPath) check.NoError(err) @@ -288,7 +292,7 @@ func TestVictoriaMetrics(t *testing.T) { check.NoError(err, "%+v", str) } - check.NoError(svc.updateConfiguration(context.Background())) + check.NoError(svc.updateConfiguration(t.Context())) expected := strings.TrimSpace(` # Managed by pmm-managed. DO NOT EDIT. diff --git a/managed/services/vmalert/vmalert.go b/managed/services/vmalert/vmalert.go index 4f6a921aa5a..58290571964 100644 --- a/managed/services/vmalert/vmalert.go +++ b/managed/services/vmalert/vmalert.go @@ -18,6 +18,7 @@ package vmalert import ( "context" + "fmt" "io" "net" "net/http" @@ -25,7 +26,6 @@ import ( "path" "time" - "github.com/pkg/errors" prom "github.com/prometheus/client_golang/prometheus" "github.com/sirupsen/logrus" @@ -57,7 +57,7 @@ type Service struct { func NewVMAlert(externalRules *ExternalRules, baseURL string) (*Service, error) { u, err := url.Parse(baseURL) if err != nil { - return nil, errors.WithStack(err) + return nil, err } var t http.RoundTripper = &http.Transport{ @@ -145,7 +145,7 @@ func (svc *Service) updateConfiguration(ctx context.Context) error { }() if err := svc.reload(ctx); err != nil { - return errors.WithStack(err) + return err } svc.l.Infof("Configuration reloaded.") @@ -158,22 +158,22 @@ func (svc *Service) reload(ctx context.Context) error { u.Path = path.Join(u.Path, "-", "reload") req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { - return errors.WithStack(err) + return err } resp, err := svc.client.Do(req) if err != nil { - return errors.WithStack(err) + return err } defer resp.Body.Close() //nolint:gosec,errcheck,nolintlint b, err := io.ReadAll(resp.Body) svc.l.Debugf("VMAlert reload: %s", b) if err != nil { - return errors.WithStack(err) + return err } if resp.StatusCode != http.StatusOK { - return errors.Errorf("expected 200, got %d", resp.StatusCode) + return fmt.Errorf("expected 200, got %d", resp.StatusCode) } return nil } @@ -184,21 +184,21 @@ func (svc *Service) IsReady(ctx context.Context) error { u.Path = path.Join(u.Path, "health") req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { - return errors.WithStack(err) + return err } resp, err := svc.client.Do(req) if err != nil { - return errors.WithStack(err) + return err } defer resp.Body.Close() //nolint:gosec,errcheck,nolintlint b, err := io.ReadAll(resp.Body) svc.l.Debugf("VMAlert health: %s", b) if err != nil { - return errors.WithStack(err) + return err } if resp.StatusCode != http.StatusOK { - return errors.Errorf("expected 200, got %d", resp.StatusCode) + return fmt.Errorf("expected 200, got %d", resp.StatusCode) } return nil diff --git a/managed/testdata/haproxy/haproxy.cfg b/managed/testdata/haproxy/haproxy.cfg deleted file mode 100644 index 8092fc92a51..00000000000 --- a/managed/testdata/haproxy/haproxy.cfg +++ /dev/null @@ -1,39 +0,0 @@ -global - log stdout local0 debug - log stdout local1 info - log stdout local2 info - daemon - -defaults - log global - mode http - option httplog - option dontlognull - timeout connect 5000 - timeout client 50000 - timeout server 50000 - -frontend http_front - bind *:80 - default_backend http_back - -frontend https_front - bind *:443 ssl crt /usr/local/etc/ssl/private/localhost.pem - default_backend https_back - -backend http_back - option httpchk - http-check send meth GET uri /v1/server/leaderHealthCheck ver HTTP/1.1 hdr Host www - http-check expect status 200 - server pmm-server-active-http pmm-server-active:8080 check - server pmm-server-passive-http pmm-server-passive:8080 check backup - server pmm-server-passive-2-http pmm-server-passive-2:8080 check backup - -backend https_back - option httpchk - http-check send meth GET uri /v1/server/leaderHealthCheck ver HTTP/1.1 hdr Host www - http-check expect status 200 - server pmm-server-active-https pmm-server-active:8443 check ssl verify none backup - server pmm-server-passive-https pmm-server-passive:8443 check ssl verify none backup - server pmm-server-passive-2-https pmm-server-passive-2:8443 check ssl verify none backup - diff --git a/managed/testdata/haproxy/localhost.crt b/managed/testdata/haproxy/localhost.crt deleted file mode 100644 index 1b601b09c17..00000000000 --- a/managed/testdata/haproxy/localhost.crt +++ /dev/null @@ -1,20 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDPjCCAiYCCQC8Y6/8ayWo6DANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQGEwJU -UjEQMA4GA1UECgwHUGVyY29uYTESMBAGA1UEAwwJbG9jYWxob3N0MSwwKgYJKoZI -hvcNAQkBFh1udXJsYW4ubW9sZG9tdXJvdkBwZXJjb25hLmNvbTAeFw0yMzA3MDYy -MDA2NDNaFw0yNDA3MDUyMDA2NDNaMGExCzAJBgNVBAYTAlRSMRAwDgYDVQQKDAdQ -ZXJjb25hMRIwEAYDVQQDDAlsb2NhbGhvc3QxLDAqBgkqhkiG9w0BCQEWHW51cmxh -bi5tb2xkb211cm92QHBlcmNvbmEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAmp+Xbi1C79S2l7IawE0dIBaKlx4vO/baQBDi+SXuhUonw8dPTqvD -DZy96/irc7SudvsJdcpcFn9tGfASDez56uZqt/39wsA+uUsym9yV39gcZPRzeoiV -nxDny1dcNJSqlGkcDp7BqXaj2/e6bK5RW3cpUnnRk4M7weDsdblBJLYPAIqTGMmZ -Chf/iKAz6i9E+FRuXi3rhFJKUEGv+5nh7Pjd/9BxmVyjl9f9SWhe+AkimOs+CYrh -lcGAHJ6XKq5KAMB43elZxSXgv6X5eTVhQsqf7X0e8n0OrXKqP+fqrQMPnkOsVvEj -q90mcG1mvvPrMoXN0XN1dfJEhyRB/hwi5QIDAQABMA0GCSqGSIb3DQEBCwUAA4IB -AQB9BKnOT2KiKTdnydorEpuMgzD1RZ9bfX8mGiucjPh5lcjO6L9haUbFN/6PupZP -x1WRKNwYm+R2vP/Q1tlkOwVDfBtycAhyx5NMRyHkmG90ap/hJUThF2D9Q+5A81Ma -tnpg5jbxPEBeMHujGZmDEiBNPHc9oP7HNuPXMzZWxAOjRAg2WtaqjyJi8ExnCP1t -4ELKdjojtSefhxQzZmdHNBKWa0kUJhDGfhvSo0//H9n8Q7VMmtVS94Klu/H+IG88 -EYmEzgkmty2eie+Jiv+S2WGDEUuopAReifGscFI3tYvNBbeU4GbtUCXKoNX8N6hO -1POaPPj84EK2ncLJXffk0XYq ------END CERTIFICATE----- diff --git a/managed/testdata/haproxy/localhost.csr b/managed/testdata/haproxy/localhost.csr deleted file mode 100644 index 84d654a2896..00000000000 --- a/managed/testdata/haproxy/localhost.csr +++ /dev/null @@ -1,17 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIICpjCCAY4CAQAwYTELMAkGA1UEBhMCVFIxEDAOBgNVBAoMB1BlcmNvbmExEjAQ -BgNVBAMMCWxvY2FsaG9zdDEsMCoGCSqGSIb3DQEJARYdbnVybGFuLm1vbGRvbXVy -b3ZAcGVyY29uYS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCa -n5duLULv1LaXshrATR0gFoqXHi879tpAEOL5Je6FSifDx09Oq8MNnL3r+KtztK52 -+wl1ylwWf20Z8BIN7Pnq5mq3/f3CwD65SzKb3JXf2Bxk9HN6iJWfEOfLV1w0lKqU -aRwOnsGpdqPb97psrlFbdylSedGTgzvB4Ox1uUEktg8AipMYyZkKF/+IoDPqL0T4 -VG5eLeuEUkpQQa/7meHs+N3/0HGZXKOX1/1JaF74CSKY6z4JiuGVwYAcnpcqrkoA -wHjd6VnFJeC/pfl5NWFCyp/tfR7yfQ6tcqo/5+qtAw+eQ6xW8SOr3SZwbWa+8+sy -hc3Rc3V18kSHJEH+HCLlAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEALWj/APq2 -xfNqyRM4cf8uSpRiIe7OjE9HABFXWowLfFMJ6E337n9TnV/srCNxgdBPZ78pPJLR -EWFRJUtB/cwYqxSauWYg7+x1HtNn2yQnyKX1Fep8LBREs2ykXPQAmiTaUrxja0+W -D880Ck8uy+C8HKF/cBQA3ZCrdkrV9Q6829WG3FNtRdIu72SDZb9opxvufiOxCRgX -6E1CiZL4fTcgUXVcR9MxJfSNj+HgsO3mU5DiyvbsOpXxCfWDy2O0/CmonfhyDL8o -2EF870bTHMJ4sxMOIB9ZFj4TFwoVUnl08G+XEx2mFR1Hb2ooUDUO5LXt80lftJR0 -qBGJW/RYyCRrPA== ------END CERTIFICATE REQUEST----- diff --git a/managed/testdata/haproxy/localhost.key b/managed/testdata/haproxy/localhost.key deleted file mode 100644 index 81e8700b34b..00000000000 --- a/managed/testdata/haproxy/localhost.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAmp+Xbi1C79S2l7IawE0dIBaKlx4vO/baQBDi+SXuhUonw8dP -TqvDDZy96/irc7SudvsJdcpcFn9tGfASDez56uZqt/39wsA+uUsym9yV39gcZPRz -eoiVnxDny1dcNJSqlGkcDp7BqXaj2/e6bK5RW3cpUnnRk4M7weDsdblBJLYPAIqT -GMmZChf/iKAz6i9E+FRuXi3rhFJKUEGv+5nh7Pjd/9BxmVyjl9f9SWhe+AkimOs+ -CYrhlcGAHJ6XKq5KAMB43elZxSXgv6X5eTVhQsqf7X0e8n0OrXKqP+fqrQMPnkOs -VvEjq90mcG1mvvPrMoXN0XN1dfJEhyRB/hwi5QIDAQABAoIBAEk7zT0hstJkrRas -BH+QBntsMbfhU/3SrQwq81WN4aq/tJXFkIpyT6/izRE2df4XVYqE27YuYe9F6yad -ze9KjhPzjhgW9FmJNCwOsamgkFu0v74RCaC/kB4Go8JrXgCJaUFhhyhliNP6nSFR -87oF1gK8LZYinGCBh4wMO/KGC5SW6X9W6xf7f6RJYSPUH7h80XwnDRvsSnldH/3e -QPciQXP8fPUUW9EP9WERjpWTBZ8YqUG5P2w6ZlCrJ7L+/STMbscD3dYTUXxQUsfh -oxjsi9BQKTPFqiLH7gstCfdoufQJkfWMLSxpn8bBiHf5nhehuRRd9hKwz8+S1jdE -Lv0FYAECgYEAy8h9t5QwW3NSD1WclIFcxQhUeHIWHjLB8KJZi9eYJ4oZ7zWa5duV -0DTs3vJfVvxJ8XLbjnaap7ULWDZETbm2m6d5zJBaEGqLYShzlr15vNp0ZzDZghw+ -9hG/TqAB1jj76RYbI9h4TvzSI3+mbq3nl5Sykz1Envuznz2JiKbefIUCgYEAwj5h -fS0wAa5zjR8Lgs1nRFmg37qH6gn6w40yDDYwxDtM2L1l96ONPROvm6RZkM6hkHge -dKzabpHrh7eViQOgbUO+tyxttOdBspdK1ubi7UZTG6xq4zuzqiITP+BnhSw2mzDR -J276DMNalsmzQdI8v+eIMK0yxqOobcgRK979iuECgYBw/NP/onGBcxpPmEc968/1 -Cx5SvebXjYsMkeeWas5ZNfAVOqKMychx7bZcEwSbpTyWW/myLr6nN/F3UndipRLD -kQMuUect7PUkxJn6PUovVOxvfp1Kz8B1DPgGbx81mNjLrs8Te+WQ3grhVdiAy3l6 -CR9OFg1jHOnF5AfKtcLsRQKBgGL800WtX4eb1XsXVRBliLjGTDt3nYfhag95xwV+ -IEAAUFsruekHShTUEWvpx1MKWj97V1nyNKagaj0Ri3z1gi3sliZW19mW+F4Ax7zY -kNCGRBgYN6hxZk/Paavluhudun4/1HaaEYerjmDFjTp/30GUxky4FuYvxMeda1LG -IsNBAoGBAIeBiXXaB8QhlP4vJu6HT9IDZKRRiYTNjiS1o8CQ2U86FfyO7OVQ+19R -DJCYOc10foiwv+HEsEVXAjux79Z2h2dqqxtVoWPNh6yGs0SDKObmPWOEZsFABeF4 -RFTKlWMq0tvbXmnGwciA3Oy9DbsHp5jTo6qbuewVvE5PL7GTnokF ------END RSA PRIVATE KEY----- diff --git a/managed/testdata/haproxy/localhost.pem b/managed/testdata/haproxy/localhost.pem deleted file mode 100644 index e4013614818..00000000000 --- a/managed/testdata/haproxy/localhost.pem +++ /dev/null @@ -1,47 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAmp+Xbi1C79S2l7IawE0dIBaKlx4vO/baQBDi+SXuhUonw8dP -TqvDDZy96/irc7SudvsJdcpcFn9tGfASDez56uZqt/39wsA+uUsym9yV39gcZPRz -eoiVnxDny1dcNJSqlGkcDp7BqXaj2/e6bK5RW3cpUnnRk4M7weDsdblBJLYPAIqT -GMmZChf/iKAz6i9E+FRuXi3rhFJKUEGv+5nh7Pjd/9BxmVyjl9f9SWhe+AkimOs+ -CYrhlcGAHJ6XKq5KAMB43elZxSXgv6X5eTVhQsqf7X0e8n0OrXKqP+fqrQMPnkOs -VvEjq90mcG1mvvPrMoXN0XN1dfJEhyRB/hwi5QIDAQABAoIBAEk7zT0hstJkrRas -BH+QBntsMbfhU/3SrQwq81WN4aq/tJXFkIpyT6/izRE2df4XVYqE27YuYe9F6yad -ze9KjhPzjhgW9FmJNCwOsamgkFu0v74RCaC/kB4Go8JrXgCJaUFhhyhliNP6nSFR -87oF1gK8LZYinGCBh4wMO/KGC5SW6X9W6xf7f6RJYSPUH7h80XwnDRvsSnldH/3e -QPciQXP8fPUUW9EP9WERjpWTBZ8YqUG5P2w6ZlCrJ7L+/STMbscD3dYTUXxQUsfh -oxjsi9BQKTPFqiLH7gstCfdoufQJkfWMLSxpn8bBiHf5nhehuRRd9hKwz8+S1jdE -Lv0FYAECgYEAy8h9t5QwW3NSD1WclIFcxQhUeHIWHjLB8KJZi9eYJ4oZ7zWa5duV -0DTs3vJfVvxJ8XLbjnaap7ULWDZETbm2m6d5zJBaEGqLYShzlr15vNp0ZzDZghw+ -9hG/TqAB1jj76RYbI9h4TvzSI3+mbq3nl5Sykz1Envuznz2JiKbefIUCgYEAwj5h -fS0wAa5zjR8Lgs1nRFmg37qH6gn6w40yDDYwxDtM2L1l96ONPROvm6RZkM6hkHge -dKzabpHrh7eViQOgbUO+tyxttOdBspdK1ubi7UZTG6xq4zuzqiITP+BnhSw2mzDR -J276DMNalsmzQdI8v+eIMK0yxqOobcgRK979iuECgYBw/NP/onGBcxpPmEc968/1 -Cx5SvebXjYsMkeeWas5ZNfAVOqKMychx7bZcEwSbpTyWW/myLr6nN/F3UndipRLD -kQMuUect7PUkxJn6PUovVOxvfp1Kz8B1DPgGbx81mNjLrs8Te+WQ3grhVdiAy3l6 -CR9OFg1jHOnF5AfKtcLsRQKBgGL800WtX4eb1XsXVRBliLjGTDt3nYfhag95xwV+ -IEAAUFsruekHShTUEWvpx1MKWj97V1nyNKagaj0Ri3z1gi3sliZW19mW+F4Ax7zY -kNCGRBgYN6hxZk/Paavluhudun4/1HaaEYerjmDFjTp/30GUxky4FuYvxMeda1LG -IsNBAoGBAIeBiXXaB8QhlP4vJu6HT9IDZKRRiYTNjiS1o8CQ2U86FfyO7OVQ+19R -DJCYOc10foiwv+HEsEVXAjux79Z2h2dqqxtVoWPNh6yGs0SDKObmPWOEZsFABeF4 -RFTKlWMq0tvbXmnGwciA3Oy9DbsHp5jTo6qbuewVvE5PL7GTnokF ------END RSA PRIVATE KEY----- ------BEGIN CERTIFICATE----- -MIIDPjCCAiYCCQC8Y6/8ayWo6DANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQGEwJU -UjEQMA4GA1UECgwHUGVyY29uYTESMBAGA1UEAwwJbG9jYWxob3N0MSwwKgYJKoZI -hvcNAQkBFh1udXJsYW4ubW9sZG9tdXJvdkBwZXJjb25hLmNvbTAeFw0yMzA3MDYy -MDA2NDNaFw0yNDA3MDUyMDA2NDNaMGExCzAJBgNVBAYTAlRSMRAwDgYDVQQKDAdQ -ZXJjb25hMRIwEAYDVQQDDAlsb2NhbGhvc3QxLDAqBgkqhkiG9w0BCQEWHW51cmxh -bi5tb2xkb211cm92QHBlcmNvbmEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAmp+Xbi1C79S2l7IawE0dIBaKlx4vO/baQBDi+SXuhUonw8dPTqvD -DZy96/irc7SudvsJdcpcFn9tGfASDez56uZqt/39wsA+uUsym9yV39gcZPRzeoiV -nxDny1dcNJSqlGkcDp7BqXaj2/e6bK5RW3cpUnnRk4M7weDsdblBJLYPAIqTGMmZ -Chf/iKAz6i9E+FRuXi3rhFJKUEGv+5nh7Pjd/9BxmVyjl9f9SWhe+AkimOs+CYrh -lcGAHJ6XKq5KAMB43elZxSXgv6X5eTVhQsqf7X0e8n0OrXKqP+fqrQMPnkOsVvEj -q90mcG1mvvPrMoXN0XN1dfJEhyRB/hwi5QIDAQABMA0GCSqGSIb3DQEBCwUAA4IB -AQB9BKnOT2KiKTdnydorEpuMgzD1RZ9bfX8mGiucjPh5lcjO6L9haUbFN/6PupZP -x1WRKNwYm+R2vP/Q1tlkOwVDfBtycAhyx5NMRyHkmG90ap/hJUThF2D9Q+5A81Ma -tnpg5jbxPEBeMHujGZmDEiBNPHc9oP7HNuPXMzZWxAOjRAg2WtaqjyJi8ExnCP1t -4ELKdjojtSefhxQzZmdHNBKWa0kUJhDGfhvSo0//H9n8Q7VMmtVS94Klu/H+IG88 -EYmEzgkmty2eie+Jiv+S2WGDEUuopAReifGscFI3tYvNBbeU4GbtUCXKoNX8N6hO -1POaPPj84EK2ncLJXffk0XYq ------END CERTIFICATE----- diff --git a/managed/testdata/supervisord.d/nomad-server.ini b/managed/testdata/supervisord.d/nomad-server.ini new file mode 100644 index 00000000000..0d19765084f --- /dev/null +++ b/managed/testdata/supervisord.d/nomad-server.ini @@ -0,0 +1,15 @@ +; Managed by pmm-managed. DO NOT EDIT. + +[program:nomad-server] +priority = 5 +command = /usr/local/percona/pmm/tools/nomad agent -config /srv/nomad/nomad-server-192.168.0.42:8443.hcl +autorestart = true +autostart = true +startretries = 10 +startsecs = 1 +stopsignal = INT +stopwaitsecs = 300 +stdout_logfile = /srv/logs/nomad-server.log +stdout_logfile_maxbytes = 10MB +stdout_logfile_backups = 3 +redirect_stderr = true diff --git a/managed/testdata/supervisord.d/vmalert.ini b/managed/testdata/supervisord.d/vmalert.ini index eb784853158..c7dc6914348 100644 --- a/managed/testdata/supervisord.d/vmalert.ini +++ b/managed/testdata/supervisord.d/vmalert.ini @@ -7,7 +7,6 @@ command = --external.url=http://127.0.0.1:9090/prometheus/ --datasource.url=http://127.0.0.1:9090/prometheus/ --remoteRead.url=http://127.0.0.1:9090/prometheus/ - --remoteRead.ignoreRestoreErrors=false --remoteWrite.url=http://127.0.0.1:9090/prometheus/ --rule=/srv/prometheus/rules/*.yml --httpListenAddr=127.0.0.1:8880 diff --git a/managed/utils/encryption/encryption.go b/managed/utils/encryption/encryption.go index dc64d37e7f7..a45dd435576 100644 --- a/managed/utils/encryption/encryption.go +++ b/managed/utils/encryption/encryption.go @@ -39,14 +39,25 @@ var ( DefaultEncryptionKeyPath = "/srv/pmm-encryption.key" // ErrEncryptionNotInitialized is error in case of encryption is not initialized. ErrEncryptionNotInitialized = errors.New("encryption is not initialized") - // DefaultEncryption is the default implementation of encryption. - DefaultEncryption = New() + // DefaultEncryption is the default implementation of encryption, lazily initialized. + defaultEncryption *Encryption defaultEncryptionMtx sync.Mutex ) // CustomEncryptionKeyPathEnvVar is an environment variable to set custom encryption key path. const CustomEncryptionKeyPathEnvVar = "PMM_ENCRYPTION_KEY_PATH" +// getDefaultEncryption returns the default encryption instance, initializing it lazily if needed. +func getDefaultEncryption() *Encryption { + defaultEncryptionMtx.Lock() + defer defaultEncryptionMtx.Unlock() + + if defaultEncryption == nil { + defaultEncryption = New() + } + return defaultEncryption +} + // Encryption contains fields required for encryption. type Encryption struct { Path string @@ -101,7 +112,7 @@ func New() *Encryption { bytes, err := os.ReadFile(e.Path) switch { case os.IsNotExist(err): - err = e.generateKey() + err = e.generateAndPersistKey() if err != nil { logrus.Panicf("Encryption: %v", err) } @@ -128,7 +139,7 @@ func RotateEncryptionKey() error { } defaultEncryptionMtx.Lock() - DefaultEncryption = New() + defaultEncryption = New() defaultEncryptionMtx.Unlock() return nil @@ -153,19 +164,28 @@ func backupOldEncryptionKey() error { return nil } -func (e *Encryption) generateKey() error { +// GenerateKey generates a new encryption key. +func (e *Encryption) GenerateKey() (string, error) { handle, err := keyset.NewHandle(aead.AES256GCMKeyTemplate()) if err != nil { - return fmt.Errorf("failed to create keyset: %w", err) + return "", fmt.Errorf("failed to create keyset: %w", err) } buff := &bytes.Buffer{} err = insecurecleartextkeyset.Write(handle, keyset.NewBinaryWriter(buff)) if err != nil { - return fmt.Errorf("failed to write encryption key: %w", err) + return "", fmt.Errorf("failed to write encryption key: %w", err) } - e.Key = base64.StdEncoding.EncodeToString(buff.Bytes()) + return base64.StdEncoding.EncodeToString(buff.Bytes()), nil +} + +func (e *Encryption) generateAndPersistKey() error { + key, err := e.GenerateKey() + if err != nil { + return err + } + e.Key = key return e.saveKeyToFile() } @@ -175,7 +195,7 @@ func (e *Encryption) saveKeyToFile() error { // Encrypt is a wrapper around DefaultEncryption.Encrypt. func Encrypt(secret string) (string, error) { - return DefaultEncryption.Encrypt(secret) + return getDefaultEncryption().Encrypt(secret) } // Encrypt returns input string encrypted. @@ -196,7 +216,7 @@ func (e *Encryption) Encrypt(secret string) (string, error) { // EncryptItems is a wrapper around DefaultEncryption.EncryptItems. func EncryptItems(tx *reform.TX, tables []Table) error { - return DefaultEncryption.EncryptItems(tx, tables) + return getDefaultEncryption().EncryptItems(tx, tables) } // EncryptItems will encrypt all columns provided in DB connection. @@ -241,7 +261,7 @@ func (e *Encryption) EncryptItems(tx *reform.TX, tables []Table) error { // Decrypt is wrapper around DefaultEncryption.Decrypt. func Decrypt(cipherText string) (string, error) { - return DefaultEncryption.Decrypt(cipherText) + return getDefaultEncryption().Decrypt(cipherText) } // Decrypt returns input string decrypted. @@ -266,7 +286,7 @@ func (e *Encryption) Decrypt(cipherText string) (string, error) { // DecryptItems is wrapper around DefaultEncryption.DecryptItems. func DecryptItems(tx *reform.TX, tables []Table) error { - return DefaultEncryption.DecryptItems(tx, tables) + return getDefaultEncryption().DecryptItems(tx, tables) } // DecryptItems will decrypt all columns provided in DB connection. diff --git a/managed/utils/encryption/encryption_test.go b/managed/utils/encryption/encryption_test.go new file mode 100644 index 00000000000..21ebff0adca --- /dev/null +++ b/managed/utils/encryption/encryption_test.go @@ -0,0 +1,74 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package encryption + +import ( + "encoding/base64" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncryptionGenerateKey(t *testing.T) { + e := &Encryption{} + + key1, err := e.GenerateKey() + require.NoError(t, err) + assert.NotEmpty(t, key1) + + // Verify it's valid base64 + _, err = base64.StdEncoding.DecodeString(key1) + assert.NoError(t, err) + + // Generate another key and ensure they are different + key2, err := e.GenerateKey() + require.NoError(t, err) + assert.NotEmpty(t, key2) + assert.NotEqual(t, key1, key2) + + // Verify second key is also valid base64 + _, err = base64.StdEncoding.DecodeString(key2) + assert.NoError(t, err) +} + +func TestEncryptionGenerateAndPersistKey(t *testing.T) { + // Create a temporary file path for testing + tempFile, err := os.CreateTemp(t.TempDir(), "encryption_test_*.key") + require.NoError(t, err) + err = tempFile.Close() + assert.NoError(t, err) + + t.Cleanup(func() { + _ = os.Remove(tempFile.Name()) + }) + + e := &Encryption{Path: tempFile.Name()} + + err = e.generateAndPersistKey() + assert.NoError(t, err) + assert.NotEmpty(t, e.Key) + + // Verify the file was written with the correct content + content, err := os.ReadFile(tempFile.Name()) + assert.NoError(t, err) + assert.Equal(t, e.Key, string(content)) + + // Verify it's valid base64 + _, err = base64.StdEncoding.DecodeString(e.Key) + assert.NoError(t, err) +} diff --git a/managed/utils/env/env.go b/managed/utils/env/env.go index 3c88dda020f..3e53ffbd410 100644 --- a/managed/utils/env/env.go +++ b/managed/utils/env/env.go @@ -19,6 +19,7 @@ package env import ( "os" "strconv" + "strings" ) const ( @@ -42,6 +43,9 @@ const ( // EnableInternalPgQAN is used to enable Query Analytics for PMM's internal PostgreSQL. EnableInternalPgQAN = "PMM_ENABLE_INTERNAL_PG_QAN" + + // ClickHouseNodes is used to store the ClickHouse nodes. + ClickHouseNodes = "PMM_CLICKHOUSE_NODES" ) // GetBool returns the boolean value of the environment variable. @@ -58,3 +62,14 @@ func GetBool(key string) bool { } return b } + +// GetStringSlice returns the string slice value of the environment variable. +// Returns an empty slice if the variable is not set. +func GetStringSlice(key string) []string { + v, ok := os.LookupEnv(key) + if !ok || v == "" { + return []string{} + } + + return strings.Split(v, ",") +} diff --git a/managed/utils/env/env_test.go b/managed/utils/env/env_test.go new file mode 100644 index 00000000000..4768e6f8a11 --- /dev/null +++ b/managed/utils/env/env_test.go @@ -0,0 +1,141 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package env + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetBool(t *testing.T) { + tests := []struct { + name string + envKey string + envValue string + expected bool + }{ + { + name: "not set", + envKey: "TEST_BOOL_NOT_SET", + expected: false, + }, + { + name: "true", + envKey: "TEST_BOOL_TRUE", + envValue: "true", + expected: true, + }, + { + name: "false", + envKey: "TEST_BOOL_FALSE", + envValue: "false", + expected: false, + }, + { + name: "invalid", + envKey: "TEST_BOOL_INVALID", + envValue: "invalid", + expected: false, + }, + { + name: "1", + envKey: "TEST_BOOL_1", + envValue: "1", + expected: true, + }, + { + name: "0", + envKey: "TEST_BOOL_0", + envValue: "0", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv(tt.envKey, tt.envValue) + } + result := GetBool(tt.envKey) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetStringSlice(t *testing.T) { + tests := []struct { + name string + envKey string + envValue string + expected []string + }{ + { + name: "not set", + envKey: "TEST_SLICE_NOT_SET", + expected: []string{}, + }, + { + name: "empty", + envKey: "TEST_SLICE_EMPTY", + envValue: "", + expected: []string{}, + }, + { + name: "single", + envKey: "TEST_SLICE_SINGLE", + envValue: "a", + expected: []string{"a"}, + }, + { + name: "multiple", + envKey: "TEST_SLICE_MULTIPLE", + envValue: "a,b,c", + expected: []string{"a", "b", "c"}, + }, + { + name: "with empty", + envKey: "TEST_SLICE_EMPTY", + envValue: "a,,c", + expected: []string{"a", "", "c"}, + }, + { + name: "leading comma", + envKey: "TEST_SLICE_LEADING", + envValue: ",a", + expected: []string{"", "a"}, + }, + { + name: "trailing comma", + envKey: "TEST_SLICE_TRAILING", + envValue: "a,", + expected: []string{"a", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv(tt.envKey, tt.envValue) + } + result := GetStringSlice(tt.envKey) + assert.Len(t, tt.expected, len(result)) + for i, v := range result { + assert.Equal(t, tt.expected[i], v) + } + }) + } +} diff --git a/managed/utils/envvars/parser.go b/managed/utils/envvars/parser.go index 3ac8dcc1d61..a66fd8352ff 100644 --- a/managed/utils/envvars/parser.go +++ b/managed/utils/envvars/parser.go @@ -35,8 +35,16 @@ import ( const ( defaultPlatformAddress = "https://check.percona.com" defaultPlatformAPITimeout = 30 * time.Second - // ENVvmAgentPrefix is the prefix for environment variables related to the VM agent. - ENVvmAgentPrefix = "VMAGENT_" + // EnvVMAgentPrefix is the prefix for environment variables related to the VMAgent. + EnvVMAgentPrefix = "VMAGENT_" + // EnvVMAuthPrefix is the prefix for environment variables related to VMAuth. + EnvVMAuthPrefix = "VMAUTH_" + // EnvVMSelectPrefix is the prefix for environment variables related to VMSelect. + EnvVMSelectPrefix = "VMSELECT_" + // EnvVMInsertPrefix is the prefix for environment variables related to VMInsert. + EnvVMInsertPrefix = "VMINSERT_" + // EnvVMStoragePrefix is the prefix for environment variables related to VMStorage. + EnvVMStoragePrefix = "VMSTORAGE_" ) // InvalidDurationError invalid duration error. @@ -68,9 +76,9 @@ func ParseEnvVars(envs []string) (*models.ChangeSettingsParams, []error, []strin var warns []string for _, env := range envs { - p := strings.SplitN(env, "=", 2) + p := strings.SplitN(env, "=", 2) //nolint:mnd - if len(p) != 2 { + if len(p) != 2 { //nolint:mnd errs = append(errs, fmt.Errorf("failed to parse environment variable %q", env)) continue } @@ -85,18 +93,35 @@ func ParseEnvVars(envs []string) (*models.ChangeSettingsParams, []error, []strin continue case "NO_PROXY", "HTTP_PROXY", "HTTPS_PROXY": continue - case "CONTAINER": continue + case "NSS_WRAPPER_GROUP", "NSS_WRAPPER_PASSWD", "LD_PRELOAD": + // skip nss_wrapper environment variables + continue + case "AWS_ACCESS_KEY", "AWS_SECRET_KEY": + continue + case "PMM_DEBUG", "PMM_TRACE": // skip cross-component environment variables that are already handled by kingpin continue case "PMM_CLICKHOUSE_DATABASE", "PMM_CLICKHOUSE_ADDR", "PMM_CLICKHOUSE_USER", "PMM_CLICKHOUSE_PASSWORD", "PMM_CLICKHOUSE_HOST", "PMM_CLICKHOUSE_PORT", - "PMM_DISABLE_BUILTIN_CLICKHOUSE": + "PMM_CLICKHOUSE_IS_CLUSTER", "PMM_CLICKHOUSE_CLUSTER_NAME", + "PMM_CLICKHOUSE_NODES", "PMM_DISABLE_BUILTIN_CLICKHOUSE": // skip env variables for external clickhouse continue + case "PMM_POSTGRES_ADDR", + "PMM_POSTGRES_DBNAME", + "PMM_POSTGRES_USERNAME", + "PMM_POSTGRES_DBPASSWORD", + "PMM_POSTGRES_SSL_MODE", + "PMM_POSTGRES_SSL_CA_PATH", + "PMM_POSTGRES_SSL_KEY_PATH", + "PMM_POSTGRES_SSL_CERT_PATH", + "PMM_DISABLE_BUILTIN_POSTGRES": + // skip env variables for external postgres + continue case "PMM_WATCHTOWER_TOKEN", "PMM_WATCHTOWER_HOST": // skip watchtower environement variables continue @@ -200,6 +225,9 @@ func ParseEnvVars(envs []string) (*models.ChangeSettingsParams, []error, []strin case "PMM_INSTALL_METHOD", "PMM_DISTRIBUTION_METHOD": continue + case "PMM_ENCRYPTION_KEY_PATH": + continue + case pkgenv.EnableAccessControl: b, err := strconv.ParseBool(v) if err != nil { @@ -222,13 +250,33 @@ func ParseEnvVars(envs []string) (*models.ChangeSettingsParams, []error, []strin continue } - // skip Victoria Metric's environment variables + // skip Victoria Metrics' environment variables if strings.HasPrefix(k, "VM_") { continue } // skip VM Agents environment variables - if strings.HasPrefix(k, ENVvmAgentPrefix) { + if strings.HasPrefix(k, EnvVMAgentPrefix) { + continue + } + + // skip VMAuth environment variables + if strings.HasPrefix(k, EnvVMAuthPrefix) { + continue + } + + // skip VMSelect environment variables + if strings.HasPrefix(k, EnvVMSelectPrefix) { + continue + } + + // skip VMInsert environment variables + if strings.HasPrefix(k, EnvVMInsertPrefix) { + continue + } + + // skip VMStorage environment variables + if strings.HasPrefix(k, EnvVMStoragePrefix) { continue } @@ -252,9 +300,14 @@ func ParseEnvVars(envs []string) (*models.ChangeSettingsParams, []error, []strin continue } + // skip PMM HA environment variables + if strings.HasPrefix(k, "PMM_HA_") { + continue + } + // skip PMM test environment variables if strings.HasPrefix(k, "PMM_TEST_") { - warns = append(warns, fmt.Sprintf("environment variable %q may be removed or replaced in the future", env)) + warns = append(warns, fmt.Sprintf("environment variable %s may be removed or replaced in the future", env)) continue } @@ -263,7 +316,7 @@ func ParseEnvVars(envs []string) (*models.ChangeSettingsParams, []error, []strin continue } - warns = append(warns, fmt.Sprintf("unknown environment variable %q", env)) + warns = append(warns, fmt.Sprintf("unknown environment variable %s", env)) } } @@ -282,18 +335,15 @@ func parseStringDuration(value string) (time.Duration, error) { func parsePlatformAPITimeout(d string) (time.Duration, string) { if d == "" { - msg := fmt.Sprintf( - "Environment variable %q is not set, using %q as a default timeout for platform API.", - pkgenv.PlatformAPITimeout, - defaultPlatformAPITimeout.String()) + msg := fmt.Sprintf("Setting the default timeout for Platform API to %s.", defaultPlatformAPITimeout.String()) return defaultPlatformAPITimeout, msg } duration, err := parseStringDuration(d) if err != nil { - msg := fmt.Sprintf("Using %q as a default: failed to parse platform API timeout %q: %s.", defaultPlatformAPITimeout.String(), d, err) + msg := fmt.Sprintf("Set the default Platform API to %s: failed to parse timeout %s: %s.", defaultPlatformAPITimeout.String(), d, err) return defaultPlatformAPITimeout, msg } - msg := fmt.Sprintf("Using %q as a timeout for platform API.", duration.String()) + msg := fmt.Sprintf("Set the timeout for Platform API to %s.", duration.String()) return duration, msg } @@ -310,7 +360,7 @@ func GetPlatformAPITimeout(l *logrus.Entry) time.Duration { func GetPlatformAddress() (string, error) { address := os.Getenv(pkgenv.PlatformAddress) if address == "" { - logrus.Infof("Using default Percona Platform address %q.", defaultPlatformAddress) + logrus.Infof("Using default Percona Platform address: %s.", defaultPlatformAddress) return defaultPlatformAddress, nil } @@ -318,7 +368,7 @@ func GetPlatformAddress() (string, error) { return "", errors.Errorf("invalid percona platform address: %s", err) } - logrus.Infof("Using Percona Platform address %q.", address) + logrus.Infof("Using Percona Platform address: %s.", address) return address, nil } diff --git a/managed/utils/envvars/parser_test.go b/managed/utils/envvars/parser_test.go index 9b831869f6e..5b8a233ea2e 100644 --- a/managed/utils/envvars/parser_test.go +++ b/managed/utils/envvars/parser_test.go @@ -13,7 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package validators contains environment variables validator. package envvars import ( @@ -67,8 +66,8 @@ func TestEnvVarValidator(t *testing.T) { envs := []string{"UNKNOWN_VAR=VAL", "ANOTHER_UNKNOWN_VAR=VAL"} expectedEnvVars := &models.ChangeSettingsParams{} expectedWarns := []string{ - `unknown environment variable "UNKNOWN_VAR=VAL"`, - `unknown environment variable "ANOTHER_UNKNOWN_VAR=VAL"`, + "unknown environment variable UNKNOWN_VAR=VAL", + "unknown environment variable ANOTHER_UNKNOWN_VAR=VAL", } gotEnvVars, gotErrs, gotWarns := ParseEnvVars(envs) @@ -187,15 +186,15 @@ func TestEnvVarValidator(t *testing.T) { }{ { value: "", respVal: time.Second * 30, - msg: "Environment variable \"PMM_DEV_PERCONA_PLATFORM_API_TIMEOUT\" is not set, using \"30s\" as a default timeout for platform API.", + msg: "Setting the default timeout for Platform API to 30s.", }, { value: "10s", respVal: time.Second * 10, - msg: "Using \"10s\" as a timeout for platform API.", + msg: "Set the timeout for Platform API to 10s.", }, { value: "xxx", respVal: time.Second * 30, - msg: "Using \"30s\" as a default: failed to parse platform API timeout \"xxx\": invalid duration error.", + msg: "Set the default Platform API to 30s: failed to parse timeout xxx: invalid duration error.", }, } for _, c := range userCase { diff --git a/managed/utils/interceptors/interceptors.go b/managed/utils/interceptors/interceptors.go index dfbdc37058b..189b8a48ca1 100644 --- a/managed/utils/interceptors/interceptors.go +++ b/managed/utils/interceptors/interceptors.go @@ -19,6 +19,7 @@ package interceptors import ( "context" "io" + "regexp" "runtime/debug" "runtime/pprof" "time" @@ -81,6 +82,8 @@ func logRequest(l *logrus.Entry, prefix string, f func() error) (err error) { // UnaryInterceptorType represents the type of a unary gRPC interceptor. type UnaryInterceptorType = func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) +var dropEndpointsRE = regexp.MustCompile(`^/server.v1.ServerService/(Readiness|LeaderHealthCheck)$`) + // Unary adds context logger and Prometheus metrics to unary server RPC. func Unary(interceptor grpc.UnaryServerInterceptor) UnaryInterceptorType { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { @@ -92,7 +95,7 @@ func Unary(interceptor grpc.UnaryServerInterceptor) UnaryInterceptorType { // set logger l := logrus.WithField("request", logger.MakeRequestID()) ctx = logger.SetEntry(ctx, l) - if info.FullMethod == "/server.v1.ServerService/Readiness" && l.Level < logrus.DebugLevel { + if l.Level < logrus.DebugLevel && dropEndpointsRE.MatchString(info.FullMethod) { l = logrus.NewEntry(logrus.New()) l.Logger.SetOutput(io.Discard) } diff --git a/qan-api2/Makefile b/qan-api2/Makefile index fb0af44b853..bcc108e986c 100644 --- a/qan-api2/Makefile +++ b/qan-api2/Makefile @@ -41,22 +41,11 @@ test-env-up: ## Start docker containers used for testing clickhouse/clickhouse-server:25.3.6.56 make test-env -test-env-up-apple: ## Start docker containers used for testing on Apple chips - docker run -d \ - --platform=linux/arm64 \ - --name pmm-clickhouse-test \ - -p 19000:9000 \ - -p 18123:8123 \ - -e CLICKHOUSE_USER=default \ - -e CLICKHOUSE_PASSWORD=clickhouse \ - clickhouse/clickhouse-server:25.3.6.56 - make test-env - test-env: ## Create pmm_test DB and load test data sleep 10 docker exec pmm-clickhouse-test clickhouse client --password=clickhouse --query="CREATE DATABASE IF NOT EXISTS pmm_test;" - cat migrations/sql/*.up.sql | docker exec -i pmm-clickhouse-test clickhouse client -d pmm_test --password=clickhouse --multiline --multiquery - cat fixture/metrics.part_*.json | docker exec -i pmm-clickhouse-test clickhouse client -d pmm_test --password=clickhouse --query="INSERT INTO metrics FORMAT JSONEachRow" + go run ./cmd/render-migrations | docker exec -i pmm-clickhouse-test clickhouse client --password=clickhouse -d pmm_test --multiline --multiquery + cat fixture/metrics.part_*.json | docker exec -i pmm-clickhouse-test clickhouse client --password=clickhouse -d pmm_test --query="INSERT INTO metrics FORMAT JSONEachRow" test-env-down: ## Stop and remove docker containers used for testing docker stop pmm-clickhouse-test diff --git a/qan-api2/cmd/render-migrations/main.go b/qan-api2/cmd/render-migrations/main.go new file mode 100644 index 00000000000..d3b23024339 --- /dev/null +++ b/qan-api2/cmd/render-migrations/main.go @@ -0,0 +1,64 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package main is used for prepared SQL migrations for Clickhouse client. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "text/template" +) + +func main() { + var ( + engine string + cluster string + ) + flag.StringVar(&engine, "engine", "MergeTree", "Engine to use in templates") + flag.StringVar(&cluster, "cluster", "", "Cluster clause (e.g. ON CLUSTER 'test_cluster')") + flag.Parse() + + data := map[string]any{ + "engine": engine, + "cluster": cluster, + } + + files, err := filepath.Glob(filepath.Join("migrations/sql", "*.up.sql")) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to list migration files: %v\n", err) + os.Exit(1) + } + + for _, file := range files { + content, err := os.ReadFile(file) // #nosec G304 + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read %s: %v\n", file, err) + os.Exit(1) + } + tmpl, err := template.New(filepath.Base(file)).Parse(string(content)) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse template %s: %v\n", file, err) + os.Exit(1) + } + err = tmpl.Execute(os.Stdout, data) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to render template %s: %v\n", file, err) + os.Exit(1) + } + } +} diff --git a/qan-api2/db.go b/qan-api2/db.go index 31797ee321b..0c427d0a612 100644 --- a/qan-api2/db.go +++ b/qan-api2/db.go @@ -16,19 +16,20 @@ package main import ( - "embed" "errors" "fmt" - "log" "net/url" "strings" + "time" - clickhouse "github.com/ClickHouse/clickhouse-go/v2" // register database/sql driver - "github.com/golang-migrate/migrate/v4" + clickhouse "github.com/ClickHouse/clickhouse-go/v2" // register database/sql driver _ "github.com/golang-migrate/migrate/v4/database/clickhouse" // register golang-migrate driver - "github.com/golang-migrate/migrate/v4/source/iofs" - "github.com/jmoiron/sqlx" // TODO: research alternatives. Ex.: https://github.com/go-reform/reform + "github.com/jmoiron/sqlx" // TODO: research alternatives. Ex.: https://github.com/go-reform/reform "github.com/jmoiron/sqlx/reflectx" + "github.com/sirupsen/logrus" + + "github.com/percona/pmm/qan-api2/migrations" + "github.com/percona/pmm/utils/dsnutils" ) const ( @@ -36,20 +37,51 @@ const ( ) // NewDB return updated db. -func NewDB(dsn string, maxIdleConns, maxOpenConns int) *sqlx.DB { +func NewDB(dsn string, maxIdleConns, maxOpenConns int, isCluster bool, clusterName string) *sqlx.DB { + l := logrus.WithField("component", "db") + // If ClickHouse is a cluster, wait until the cluster is ready. + if isCluster { + l.Info("PMM_CLICKHOUSE_IS_CLUSTER is set to 1") + dsnURL, err := url.Parse(dsn) + if err != nil { + l.Fatalf("Error parsing DSN: %v", err) + } + dsnURL.Path = "/default" + dsnDefault := dsnURL.String() + l.Infof("DSN for cluster check: %s", dsnutils.RedactDSN(dsnDefault)) + + for { + isClusterReady, err := migrations.IsClickhouseClusterReady(dsnDefault, clusterName) + if err != nil { + l.Fatalf("Error checking ClickHouse cluster status: %v", err) + } + if isClusterReady { + l.Info("ClickHouse cluster is ready") + break + } + + l.Info("Waiting for ClickHouse cluster to be ready... (system.clusters where remote_hosts > 0)") + time.Sleep(1 * time.Second) + } + } + + l.Infof("New connection with DSN: %s", dsnutils.RedactDSN(dsn)) db, err := sqlx.Connect("clickhouse", dsn) if err != nil { - if exception, ok := err.(*clickhouse.Exception); ok && exception.Code == databaseNotExistErrorCode { //nolint:errorlint - err = createDB(dsn) + l.Errorf("Error connecting to ClickHouse: %v", err) + var exception *clickhouse.Exception + if errors.As(err, &exception) && exception.Code == databaseNotExistErrorCode { + err = createDB(dsn, clusterName) if err != nil { - log.Fatalf("Database wasn't created: %v", err) + l.Fatalf("Database wasn't created: %v", err) } + l.Infof("Database created, connecting again %s", dsnutils.RedactDSN(dsn)) db, err = sqlx.Connect("clickhouse", dsn) if err != nil { - log.Fatalf("Connection: %v", err) + l.Fatalf("Connection: %v", err) } } else { - log.Fatalf("Connection: %v", err) + l.Fatalf("Connection: %v", err) } } @@ -65,15 +97,24 @@ func NewDB(dsn string, maxIdleConns, maxOpenConns int) *sqlx.DB { db.SetMaxIdleConns(maxIdleConns) db.SetMaxOpenConns(maxOpenConns) - if err := runMigrations(dsn); err != nil { - log.Fatal("Migrations: ", err) + data := map[string]any{ + "engine": migrations.GetEngine(isCluster), + } + if clusterName != "" { + l.Infof("Using ClickHouse cluster name: %s", clusterName) + data["cluster"] = fmt.Sprintf("ON CLUSTER %s", clusterName) } - log.Println("Migrations applied.") + if err := migrations.Run(dsn, data, isCluster, clusterName); err != nil { + l.Fatalf("migrations: %v", err) + } + l.Info("Migrations applied successfully") + return db } -func createDB(dsn string) error { - log.Println("Creating database") +func createDB(dsn string, clusterName string) error { + l := logrus.WithField("component", "db") + l.Info("Creating database...") clickhouseURL, err := url.Parse(dsn) if err != nil { return err @@ -87,68 +128,32 @@ func createDB(dsn string) error { } defer defaultDB.Close() //nolint:errcheck - result, err := defaultDB.Exec(fmt.Sprintf(`CREATE DATABASE %s ENGINE = Atomic`, databaseName)) - if err != nil { - log.Printf("Result: %v", result) - return err - } - log.Println("Database was created") - return nil - // The qan-api2 will exit after creating the database, it'll be restarted by supervisor -} - -//go:embed migrations/sql/*.sql -var fs embed.FS - -func runMigrations(dsn string) error { - d, err := iofs.New(fs, "migrations/sql") - if err != nil { - return err + sql := fmt.Sprintf("CREATE DATABASE %s", databaseName) + if clusterName != "" { + l.Infof("Using ClickHouse cluster name: %s", clusterName) + sql = fmt.Sprintf("%s ON CLUSTER \"%s\"", sql, clusterName) } + sql = fmt.Sprintf("%s ENGINE = Atomic", sql) - m, err := migrate.NewWithSourceInstance("iofs", d, dsn) + result, err := defaultDB.Exec(sql) if err != nil { + l.Infof("Result: %v", result) return err } + l.Info("Database was created") - // run up to the latest migration - err = m.Up() - if err == nil || errors.Is(err, migrate.ErrNoChange) { - return nil - } - - // If the database is in dirty state, try to fix it (PMM-14305) - var errDirty migrate.ErrDirty - if errors.As(err, &errDirty) { - log.Printf("Migration %d was unsuccessful, trying to fix it...", errDirty.Version) - - ver := errDirty.Version - 1 - if ver == 0 { - // Note: since 0th migration does not exist, we set it to -1, which means "start from scratch" - ver = -1 - } - err = m.Force(ver) - if err != nil { - return fmt.Errorf("can't force the migration %d: %w", ver, err) - } - - // try to run migrations again, starting from the forced version - err = m.Up() - if errors.Is(err, migrate.ErrNoChange) { - return nil - } - } - - return err + // The qan-api2 will exit after creating the database, it'll be restarted by supervisor + return nil } // DropOldPartition drops number of days old partitions of pmm.metrics in ClickHouse. func DropOldPartition(db *sqlx.DB, dbName string, days uint) { + l := logrus.WithField("component", "db") partitions := []string{} const query = ` SELECT DISTINCT partition FROM system.parts - WHERE toUInt32(partition) < toYYYYMMDD(now() - toIntervalDay(?)) AND database = ? and visible = 1 ORDER BY partition + WHERE toUInt32OrZero(partition) < toYYYYMMDD(now() - toIntervalDay(?)) AND database = ? and visible = 1 ORDER BY partition ` err := db.Select( &partitions, @@ -156,11 +161,11 @@ func DropOldPartition(db *sqlx.DB, dbName string, days uint) { days, dbName) if err != nil { - log.Printf("Select %d days old partitions of system.parts. Result: %v, Error: %v", days, partitions, err) + l.Infof("Select %d days old partitions of system.parts. Result: %v, Error: %v", days, partitions, err) return } for _, part := range partitions { result, err := db.Exec(fmt.Sprintf(`ALTER TABLE metrics DROP PARTITION %s`, part)) - log.Printf("Drop %s partitions of metrics. Result: %v, Error: %v", part, result, err) + l.Infof("Drop %s partitions of metrics. Result: %v, Error: %v", part, result, err) } } diff --git a/qan-api2/db_test.go b/qan-api2/db_test.go index ed72533f141..0196e92717d 100644 --- a/qan-api2/db_test.go +++ b/qan-api2/db_test.go @@ -30,6 +30,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/percona/pmm/qan-api2/migrations" ) func setup() *sqlx.DB { @@ -47,7 +49,11 @@ func setup() *sqlx.DB { if err != nil { log.Fatal("Connection: ", err) } - err = runMigrations(dsn) + + data := map[string]any{ + "engine": migrations.GetEngine(false), + } + err = migrations.Run(dsn, data, false, "") if err != nil { log.Fatal("Migration: ", err) } @@ -118,7 +124,7 @@ func TestCreateDbIfNotExists(t *testing.T) { dsn = "clickhouse://default:clickhouse@127.0.0.1:19000/pmm_created_db" } - err := createDB(dsn) + err := createDB(dsn, "") require.NoError(t, err, "Check connection after we create database") }) diff --git a/qan-api2/main.go b/qan-api2/main.go index eaf78a94e38..3a805dd6bfb 100644 --- a/qan-api2/main.go +++ b/qan-api2/main.go @@ -26,7 +26,6 @@ import ( "net" "net/http" _ "net/http/pprof" //nolint:gosec - "net/url" "os" "os/signal" "strings" @@ -56,6 +55,7 @@ import ( aservice "github.com/percona/pmm/qan-api2/services/analytics" rservice "github.com/percona/pmm/qan-api2/services/receiver" "github.com/percona/pmm/qan-api2/utils/interceptors" + "github.com/percona/pmm/utils/dsnutils" pmmerrors "github.com/percona/pmm/utils/errors" "github.com/percona/pmm/utils/logger" "github.com/percona/pmm/utils/sqlmetrics" @@ -63,10 +63,11 @@ import ( ) const ( - shutdownTimeout = 3 * time.Second - defaultDsnF = "clickhouse://%s:%s@%s/%s" - maxIdleConns = 5 - maxOpenConns = 10 + shutdownTimeout = 3 * time.Second + defaultDropOldPartitionInterval = 24 * time.Hour + defaultDsnF = "clickhouse://%s:%s@%s/%s" + maxIdleConns = 5 + maxOpenConns = 10 ) // runGRPCServer runs gRPC server until context is canceled, then gracefully stops it. @@ -170,7 +171,7 @@ func runJSONServer(ctx context.Context, grpcBindF, jsonBindF string) { server := &http.Server{ //nolint:gosec Addr: jsonBindF, - ErrorLog: log.New(os.Stderr, "runJSONServer: ", 0), + ErrorLog: log.New(logrus.StandardLogger().WriterLevel(logrus.ErrorLevel), "runJSONServer: ", 0), Handler: mux, } go func() { @@ -232,7 +233,7 @@ func runDebugServer(ctx context.Context, debugBindF string) { server := &http.Server{ //nolint:gosec Addr: debugBindF, - ErrorLog: log.New(os.Stderr, "runDebugServer: ", 0), + ErrorLog: log.New(logrus.StandardLogger().WriterLevel(logrus.ErrorLevel), "runDebugServer: ", 0), } go func() { if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { @@ -251,7 +252,6 @@ func runDebugServer(ctx context.Context, debugBindF string) { func main() { log.SetFlags(0) - log.SetPrefix("stdlog: ") kingpin.Version(version.ShortInfo()) kingpin.HelpFlag.Short('h') @@ -265,15 +265,19 @@ func main() { clickhouseUserF := kingpin.Flag("clickhouse-user", "ClickHouse database user").Default("default").Envar("PMM_CLICKHOUSE_USER").String() clickhousePasswordF := kingpin.Flag("clickhouse-password", "ClickHouse database user password").Default("clickhouse").Envar("PMM_CLICKHOUSE_PASSWORD").String() + clickhouseIsClusterF := kingpin.Flag("clickhouse-cluster", "Is ClickHouse a cluster").Default("false").Envar("PMM_CLICKHOUSE_IS_CLUSTER").Bool() + clickhouseClusterNameF := kingpin.Flag("clickhouse-cluster-name", "ClickHouse cluster name").Default("").Envar("PMM_CLICKHOUSE_CLUSTER_NAME").String() + debugF := kingpin.Flag("debug", "Enable debug logging").Bool() traceF := kingpin.Flag("trace", "Enable trace logging (implies debug)").Bool() kingpin.Parse() - log.Printf("%s.", version.ShortInfo()) - logger.SetupGlobalLogger() + logrus.Printf("%s.", version.ShortInfo()) + logrus.Printf("Clickhouse address: %s", *clickhouseAddrF) + if *debugF { logrus.SetLevel(logrus.DebugLevel) } @@ -295,16 +299,9 @@ func main() { } else { dsn = *dsnF } + l.Info("DSN: ", dsnutils.RedactDSN(dsn)) - u, err := url.Parse(dsn) - if err != nil { - l.Error("Failed to parse DSN: ", err) - } else { - l.Info("DSN: ", u.Redacted()) - } - - db := NewDB(dsn, maxIdleConns, maxOpenConns) - + db := NewDB(dsn, maxIdleConns, maxOpenConns, *clickhouseIsClusterF, *clickhouseClusterNameF) prom.MustRegister(sqlmetrics.NewCollector("clickhouse", "qan-api2", db.DB)) // handle termination signals @@ -313,7 +310,7 @@ func main() { go func() { s := <-signals signal.Stop(signals) - log.Printf("Got %s, shutting down...\n", unix.SignalName(s.(unix.Signal))) //nolint:forcetypeassert + l.Infof("Got %s, shutting down...\n", unix.SignalName(s.(unix.Signal))) //nolint:forcetypeassert cancel() }() @@ -323,11 +320,10 @@ func main() { mbm := models.NewMetricsBucket(db) prom.MustRegister(mbm) mbmCtx, mbmCancel := context.WithCancel(context.Background()) - wg.Add(1) - go func() { - defer wg.Done() + + wg.Go(func() { mbm.Run(mbmCtx) - }() + }) wg.Add(1) go func() { @@ -339,24 +335,19 @@ func main() { runGRPCServer(ctx, db, mbm, *grpcBindF) }() - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { runJSONServer(ctx, *grpcBindF, *jsonBindF) - }() + }) - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { runDebugServer(ctx, *debugBindF) - }() + }) - ticker := time.NewTicker(24 * time.Hour) - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { + ticker := time.NewTicker(defaultDropOldPartitionInterval) + defer ticker.Stop() for { - // Drop old partitions once in 24h. + // Drop old partitions once per interval. DropOldPartition(db, *clickhouseDatabaseF, *dataRetentionF) select { case <-ctx.Done(): @@ -365,7 +356,7 @@ func main() { // nothing } } - }() + }) wg.Wait() } diff --git a/qan-api2/migrations/migrations.go b/qan-api2/migrations/migrations.go new file mode 100644 index 00000000000..5e153098ee6 --- /dev/null +++ b/qan-api2/migrations/migrations.go @@ -0,0 +1,158 @@ +package migrations + +import ( + "embed" + "errors" + "fmt" + "io" + "net/url" + + "github.com/golang-migrate/migrate/v4" + bindata "github.com/golang-migrate/migrate/v4/source/go_bindata" + "github.com/jmoiron/sqlx" + "github.com/sirupsen/logrus" + + "github.com/percona/pmm/qan-api2/utils/templatefs" + "github.com/percona/pmm/utils/dsnutils" +) + +const ( + metricsEngineSimple = "MergeTree" + metricsEngineCluster = "ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/metrics', '{replica}')" + schemaMigrationsEngineCluster = "ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/schema_migrations', '{replica}') ORDER BY version" +) + +//go:embed sql/*.sql +var eFS embed.FS + +func IsClickhouseClusterReady(dsn string, clusterName string) (bool, error) { + var args []interface{} + sql := "SELECT sum(is_local = 0) AS remote_hosts FROM system.clusters" + if clusterName != "" { + sql = fmt.Sprintf("%s WHERE cluster = ?", sql) + args = append(args, clusterName) + } + + db, err := sqlx.Connect("clickhouse", dsn) + if err != nil { + return false, err + } + defer db.Close() //nolint:errcheck + + logrus.WithField("component", "migrations").Printf("Executing query: %s; args: %v", sql, args) + rows, err := db.Queryx(fmt.Sprintf("%s;", sql), args...) + if err != nil { + return false, err + } + defer rows.Close() //nolint:errcheck + + if rows.Next() { + var remoteHosts int + if err := rows.Scan(&remoteHosts); err != nil { + return false, err + } + + return remoteHosts > 0, nil + } + + return false, nil +} + +// Force schema_migrations table cluster engine, optionally cluster name in DSN. +func addClusterSchemaMigrationsParams(dsn string, clusterName string) (string, error) { + u, err := url.Parse(dsn) + if err != nil { + return "", err + } + + // Values x-cluster-name and x-migrations-table-engine goes as part of query. + // Since only x-migrations-table-engine contains special chars only this one is needed not to be escaped. + q := u.Query() + if clusterName != "" { + logrus.Printf("Using ClickHouse cluster name: %s", clusterName) + q.Set("x-cluster-name", clusterName) + } + + encoded := q.Encode() + if encoded != "" { + u.RawQuery = encoded + "&x-migrations-table-engine=" + schemaMigrationsEngineCluster + } else { + u.RawQuery = "x-migrations-table-engine=" + schemaMigrationsEngineCluster + } + logrus.Debugf("ClickHouse cluster detected, setting schema_migrations table engine to: %s", schemaMigrationsEngineCluster) + + return u.String(), nil +} + +func GetEngine(isCluster bool) string { + if isCluster { + return metricsEngineCluster + } + + return metricsEngineSimple +} + +func Run(dsn string, templateData map[string]any, isCluster bool, clusterName string) error { + l := logrus.WithField("component", "migrations") + if isCluster { + isClusterReady, err := IsClickhouseClusterReady(dsn, clusterName) + if err != nil { + return err + } + if isClusterReady { + l.Infof("ClickHouse cluster detected, adjusting DSN for migrations, original dsn: %s", dsnutils.RedactDSN(dsn)) + dsn, err = addClusterSchemaMigrationsParams(dsn, clusterName) + if err != nil { + return err + } + l.Infof("Adjusted DSN for migrations: %s", dsnutils.RedactDSN(dsn)) + } + } + + tfs := templatefs.NewTemplateFS(eFS, templateData, "sql") + names, err := tfs.Names() + if err != nil { + return err + } + instance, err := bindata.WithInstance( + bindata.Resource( + names, + tfs.ReadFile)) + if err != nil { + return err + } + + m, err := migrate.NewWithSourceInstance("go-bindata", instance, dsn) + if err != nil { + return err + } + + err = m.Up() + if err == nil || errors.Is(err, migrate.ErrNoChange) || errors.Is(err, io.EOF) { + return nil + } + + // If the database is in dirty state, try to fix it (PMM-14305) + var errDirty migrate.ErrDirty + if errors.As(err, &errDirty) { + l.Infof("Migration %d was unsuccessful, trying to fix it...", errDirty.Version) + + ver := errDirty.Version - 1 + if ver == 0 { + // Note: since 0th migration does not exist, we set it to -1, which means "start from scratch" + ver = -1 + } + err = m.Force(ver) + if err != nil { + return fmt.Errorf("can't force the migration %d: %w", ver, err) + } + + // try to run migrations again, starting from the forced version + err = m.Up() + if errors.Is(err, migrate.ErrNoChange) || errors.Is(err, io.EOF) { + return nil + } + } + + return err +} diff --git a/qan-api2/migrations/sql/01_init.up.sql b/qan-api2/migrations/sql/01_init.up.sql index 98cefbf242a..0f579316b22 100644 --- a/qan-api2/migrations/sql/01_init.up.sql +++ b/qan-api2/migrations/sql/01_init.up.sql @@ -1,4 +1,4 @@ -CREATE TABLE metrics ( +CREATE TABLE metrics{{ if .cluster }} {{ .cluster }}{{ end }} ( -- Main dimensions `queryid` LowCardinality(String) COMMENT 'hash of query fingerprint', `service_name` LowCardinality(String) COMMENT 'Name of service (IP or hostname of DB server by default)', @@ -188,7 +188,7 @@ CREATE TABLE metrics ( `m_docs_scanned_min` Float32, `m_docs_scanned_max` Float32, `m_docs_scanned_p99` Float32 -) ENGINE = MergeTree PARTITION BY toYYYYMMDD(period_start) +) ENGINE = {{ .engine }} PARTITION BY toYYYYMMDD(period_start) ORDER BY ( queryid, diff --git a/qan-api2/services/analytics/object_details.go b/qan-api2/services/analytics/object_details.go index 158d474a80f..fced221f911 100644 --- a/qan-api2/services/analytics/object_details.go +++ b/qan-api2/services/analytics/object_details.go @@ -20,7 +20,6 @@ import ( "fmt" "time" - "github.com/pkg/errors" "github.com/sirupsen/logrus" qanpb "github.com/percona/pmm/api/qan/v1" @@ -30,11 +29,11 @@ import ( // GetMetrics implements rpc to get metrics for specific filtering. func (s *Service) GetMetrics(ctx context.Context, in *qanpb.GetMetricsRequest) (*qanpb.GetMetricsResponse, error) { if in.PeriodStartFrom == nil { - return nil, fmt.Errorf("period_start_from is required:%v", in.PeriodStartFrom) + return nil, fmt.Errorf("period_start_from is required: %v", in.PeriodStartFrom) } periodStartFromSec := in.PeriodStartFrom.Seconds if in.PeriodStartTo == nil { - return nil, fmt.Errorf("period_start_to is required:%v", in.PeriodStartTo) + return nil, fmt.Errorf("period_start_to is required: %v", in.PeriodStartTo) } periodStartToSec := in.PeriodStartTo.Seconds @@ -69,7 +68,7 @@ func (s *Service) GetMetrics(ctx context.Context, in *qanpb.GetMetricsRequest) ( labels, in.Totals) if err != nil { - return nil, fmt.Errorf("error in quering metrics:%w", err) + return nil, fmt.Errorf("error in quering metrics: %w", err) } if len(metricsList) < 2 { @@ -90,7 +89,7 @@ func (s *Service) GetMetrics(ctx context.Context, in *qanpb.GetMetricsRequest) ( labels, true) // get Totals if err != nil { - return nil, errors.Wrapf(err, "cannot get metrics totals") + return nil, fmt.Errorf("cannot get metrics totals: %w", err) } totalLen := len(totalsList) @@ -226,10 +225,10 @@ func makeMetrics(mm, t models.M, durationSec int64) map[string]*qanpb.MetricValu // GetQueryExample gets query examples in given time range for queryid. func (s *Service) GetQueryExample(ctx context.Context, in *qanpb.GetQueryExampleRequest) (*qanpb.GetQueryExampleResponse, error) { if in.PeriodStartFrom == nil { - return nil, fmt.Errorf("period_start_from is required:%v", in.PeriodStartFrom) + return nil, fmt.Errorf("period_start_from is required: %v", in.PeriodStartFrom) } if in.PeriodStartTo == nil { - return nil, fmt.Errorf("period_start_to is required:%v", in.PeriodStartTo) + return nil, fmt.Errorf("period_start_to is required: %v", in.PeriodStartTo) } from := time.Unix(in.PeriodStartFrom.Seconds, 0) @@ -265,7 +264,7 @@ func (s *Service) GetQueryExample(ctx context.Context, in *qanpb.GetQueryExample dimensions, labels) if err != nil { - return nil, errors.Wrap(err, "error in selecting query examples") + return nil, fmt.Errorf("error in selecting query examples: %w", err) } return resp, nil } @@ -295,7 +294,7 @@ func (s *Service) GetLabels(ctx context.Context, in *qanpb.GetLabelsRequest) (*q in.FilterBy, in.GroupBy) if err != nil { - return nil, fmt.Errorf("error in selecting object details labels:%w", err) + return nil, fmt.Errorf("error in selecting object details labels: %w", err) } return resp, nil } @@ -306,7 +305,7 @@ func (s *Service) GetQueryPlan(ctx context.Context, in *qanpb.GetQueryPlanReques ctx, in.Queryid) if err != nil { - return nil, errors.Wrap(err, "error in selecting query plans") + return nil, fmt.Errorf("error in selecting query plans: %w", err) } return resp, nil } @@ -314,16 +313,16 @@ func (s *Service) GetQueryPlan(ctx context.Context, in *qanpb.GetQueryPlanReques // GetHistogram gets histogram for given queryid. func (s *Service) GetHistogram(ctx context.Context, in *qanpb.GetHistogramRequest) (*qanpb.GetHistogramResponse, error) { if in.PeriodStartFrom == nil { - return nil, fmt.Errorf("period_start_from is required:%v", in.PeriodStartFrom) + return nil, fmt.Errorf("period_start_from is required: %v", in.PeriodStartFrom) } periodStartFromSec := in.PeriodStartFrom.Seconds if in.PeriodStartTo == nil { - return nil, fmt.Errorf("period_start_to is required:%v", in.PeriodStartTo) + return nil, fmt.Errorf("period_start_to is required: %v", in.PeriodStartTo) } periodStartToSec := in.PeriodStartTo.Seconds if in.Queryid == "" { - return nil, fmt.Errorf("queryid is required:%v", in.Queryid) + return nil, fmt.Errorf("queryid is required: %v", in.Queryid) } labels := make(map[string][]string) @@ -345,7 +344,7 @@ func (s *Service) GetHistogram(ctx context.Context, in *qanpb.GetHistogramReques labels, in.Queryid) if err != nil { - return nil, fmt.Errorf("error in selecting histogram:%w", err) + return nil, fmt.Errorf("error in selecting histogram: %w", err) } return resp, nil @@ -358,7 +357,7 @@ func (s *Service) QueryExists(ctx context.Context, in *qanpb.QueryExistsRequest) in.Serviceid, in.Query) if err != nil { - return nil, fmt.Errorf("error in checking query:%w", err) + return nil, fmt.Errorf("error in checking query: %w", err) } return &qanpb.QueryExistsResponse{Exists: resp}, nil @@ -371,7 +370,7 @@ func (s *Service) ExplainFingerprintByQueryID(ctx context.Context, in *qanpb.Exp in.Serviceid, in.QueryId) if err != nil { - return nil, fmt.Errorf("error in checking query:%w", err) + return nil, fmt.Errorf("error in checking query: %w", err) } return res, nil @@ -384,7 +383,7 @@ func (s *Service) SchemaByQueryID(ctx context.Context, in *qanpb.SchemaByQueryID in.ServiceId, in.QueryId) if err != nil { - return nil, fmt.Errorf("error in checking query:%w", err) + return nil, fmt.Errorf("error in checking query: %w", err) } return res, nil diff --git a/qan-api2/services/analytics/object_details_test.go b/qan-api2/services/analytics/object_details_test.go index 1ad9ff26121..adf2467ad7b 100644 --- a/qan-api2/services/analytics/object_details_test.go +++ b/qan-api2/services/analytics/object_details_test.go @@ -530,7 +530,7 @@ func TestService_GetLabels(t *testing.T) { fields{rm: rm, mm: mm}, request, nil, - fmt.Errorf("error in selecting object details labels:cannot select object details labels"), + fmt.Errorf("error in selecting object details labels: cannot select object details labels"), } t.Run(tt.name, func(t *testing.T) { @@ -540,6 +540,6 @@ func TestService_GetLabels(t *testing.T) { } _, err := s.GetLabels(context.TODO(), tt.in) // errors start with same text. - require.Regexp(t, "^error in selecting object details labels:cannot select object details labels.*", err.Error()) + require.Regexp(t, "^error in selecting object details labels: cannot select object details labels.*", err.Error()) }) } diff --git a/qan-api2/utils/templatefs/templatefs.go b/qan-api2/utils/templatefs/templatefs.go new file mode 100644 index 00000000000..ba0ebdab018 --- /dev/null +++ b/qan-api2/utils/templatefs/templatefs.go @@ -0,0 +1,84 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package templatefs provides an embedded filesystem with templating capabilities. +package templatefs + +import ( + "bytes" + "embed" + "fmt" + "path/filepath" + "text/template" +) + +// TemplateFS wraps an embed.FS and applies templating to file content during reads. +// It implements the fs.FS interface and delegates most operations to the underlying embed.FS, +// but applies Go text/template processing when reading file content via ReadFile. +type TemplateFS struct { + // EmbedFS is the underlying embedded filesystem + EmbedFS embed.FS + // Data contains template data that will be used for all files + Data map[string]any + dir string +} + +// NewTemplateFS creates a new TemplateFS with the given embedded filesystem and template data. +func NewTemplateFS(embedFS embed.FS, data map[string]any, dir string) *TemplateFS { + return &TemplateFS{ + EmbedFS: embedFS, + Data: data, + dir: dir, + } +} + +// ReadFile reads the named file and returns its content with templating applied. +// This is where the templating magic happens. +func (tfs *TemplateFS) ReadFile(name string) ([]byte, error) { + // Read original content from embed.FS + fullName := filepath.Join(tfs.dir, name) + content, err := tfs.EmbedFS.ReadFile(fullName) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", fullName, err) + } + + // Apply templating using the same logic as in the user's example + upSQL := string(content) + + // Apply template if data exists + if tfs.Data != nil { + if tmpl, err := template.New(name).Parse(upSQL); err == nil { + var buf bytes.Buffer + if err := tmpl.Execute(&buf, tfs.Data); err == nil { + upSQL = buf.String() + } + } + } + + return []byte(upSQL), nil +} + +// Names returns a slice of file names in the directory managed by TemplateFS. +func (tfs *TemplateFS) Names() ([]string, error) { + dir, err := tfs.EmbedFS.ReadDir(tfs.dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(dir)) + for _, entry := range dir { + names = append(names, entry.Name()) + } + return names, nil +} diff --git a/qan-api2/utils/templatefs/templatefs_test.go b/qan-api2/utils/templatefs/templatefs_test.go new file mode 100644 index 00000000000..61575026d08 --- /dev/null +++ b/qan-api2/utils/templatefs/templatefs_test.go @@ -0,0 +1,144 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package templatefs + +import ( + "embed" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed testdata +var testFS embed.FS + +func TestNewTemplateFS(t *testing.T) { + data := map[string]any{ + "TableName": "users", + "DatabaseName": "testdb", + } + tfs := NewTemplateFS(testFS, data, "testdata") + assert.NotNil(t, tfs) + assert.Equal(t, testFS, tfs.EmbedFS) + assert.Equal(t, data, tfs.Data) +} + +func TestTemplateFS_ReadFile_WithTemplating(t *testing.T) { + data := map[string]any{ + "TableName": "users", + "DatabaseName": "testdb", + } + tfs := NewTemplateFS(testFS, data, "testdata") + content, err := tfs.ReadFile("simple.sql") + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "users") + assert.Contains(t, contentStr, "testdb") + assert.NotContains(t, contentStr, "{{.TableName}}") + assert.NotContains(t, contentStr, "{{.DatabaseName}}") +} + +func TestTemplateFS_ReadFile_WithoutTemplateData(t *testing.T) { + tfs := NewTemplateFS(testFS, nil, "testdata") + content, err := tfs.ReadFile("simple.sql") + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "{{.TableName}}") + assert.Contains(t, contentStr, "{{.DatabaseName}}") +} + +func TestTemplateFS_ReadFile_WithEmptyTemplateData(t *testing.T) { + data := make(map[string]any) + tfs := NewTemplateFS(testFS, data, "testdata") + content, err := tfs.ReadFile("simple.sql") + require.NoError(t, err) + contentStr := string(content) + assert.NotContains(t, contentStr, "{{.TableName}}") + assert.NotContains(t, contentStr, "{{.DatabaseName}}") +} + +func TestTemplateFS_ReadFile_InvalidTemplate(t *testing.T) { + data := map[string]any{ + "TableName": "users", + } + tfs := NewTemplateFS(testFS, data, "testdata") + content, err := tfs.ReadFile("invalid.sql") + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "{{.TableName") +} + +func TestTemplateFS_ReadFile_NonexistentFile(t *testing.T) { + tfs := NewTemplateFS(testFS, nil, "") + _, err := tfs.ReadFile("nonexistent.sql") + assert.Error(t, err) +} + +func TestTemplateFS_ReadDir(t *testing.T) { + tfs := NewTemplateFS(testFS, nil, "testdata") + entries, err := tfs.Names() + require.NoError(t, err) + assert.NotEmpty(t, entries) + names := append([]string{}, entries...) + assert.Contains(t, names, "simple.sql") +} + +func TestTemplateFS_ReadDir_NonexistentDir(t *testing.T) { + tfs := NewTemplateFS(testFS, nil, "nonexistent") + _, err := tfs.Names() + assert.Error(t, err) +} + +func TestTemplateFS_FilenameExtraction(t *testing.T) { + data := map[string]any{ + "TableName": "extracted", + } + tfs := NewTemplateFS(testFS, data, "testdata") + content, err := tfs.ReadFile("simple.sql") + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "extracted") +} + +func TestTemplateFS_ConditionalTemplating(t *testing.T) { + data := map[string]any{ + "TableName": "users", + "AddIndexes": true, + "IndexName": "idx_users_email", + "ColumnName": "email", + } + tfs := NewTemplateFS(testFS, data, "testdata") + content, err := tfs.ReadFile("conditional.sql") + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "CREATE TABLE users") + assert.Contains(t, contentStr, "CREATE INDEX idx_users_email") + assert.Contains(t, contentStr, "ON users (email)") +} + +func TestTemplateFS_ConditionalTemplating_False(t *testing.T) { + data := map[string]any{ + "TableName": "users", + "AddIndexes": false, + } + tfs := NewTemplateFS(testFS, data, "testdata") + content, err := tfs.ReadFile("conditional.sql") + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "CREATE TABLE users") + assert.NotContains(t, contentStr, "CREATE INDEX") +} diff --git a/qan-api2/utils/templatefs/testdata/conditional.sql b/qan-api2/utils/templatefs/testdata/conditional.sql new file mode 100644 index 00000000000..0fdcf494a08 --- /dev/null +++ b/qan-api2/utils/templatefs/testdata/conditional.sql @@ -0,0 +1,10 @@ +-- Template with conditional logic +CREATE TABLE {{.TableName}} ( + id BIGINT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL +); + +{{if .AddIndexes}} +CREATE INDEX {{.IndexName}} ON {{.TableName}} ({{.ColumnName}}); +{{end}} diff --git a/qan-api2/utils/templatefs/testdata/invalid.sql b/qan-api2/utils/templatefs/testdata/invalid.sql new file mode 100644 index 00000000000..d1f4ab76ba2 --- /dev/null +++ b/qan-api2/utils/templatefs/testdata/invalid.sql @@ -0,0 +1,4 @@ +-- Template with invalid syntax +CREATE TABLE {{.TableName ( + id BIGINT PRIMARY KEY +); diff --git a/qan-api2/utils/templatefs/testdata/simple.sql b/qan-api2/utils/templatefs/testdata/simple.sql new file mode 100644 index 00000000000..00c215ad131 --- /dev/null +++ b/qan-api2/utils/templatefs/testdata/simple.sql @@ -0,0 +1,6 @@ +-- Simple template file for testing +CREATE TABLE {{.DatabaseName}}.{{.TableName}} ( + id BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); diff --git a/scripts/authfix.sh b/scripts/authfix.sh deleted file mode 100644 index 52bbbea4c45..00000000000 --- a/scripts/authfix.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -set -eu -trap "echo FAILED" ERR -sed -i 's^error_page 401 = /auth_request;^\0\nif ($request ~ (\\.\\.|%2[eE]%2[eE])) { return 403; }^' /etc/nginx/conf.d/pmm.conf -grep -Fq 'request ~ (\.\.|%2[eE]%2[eE])' /etc/nginx/conf.d/pmm.conf -nginx -t -nginx -s reload diff --git a/tools/go.mod b/tools/go.mod index a4658205819..9b07e368d2c 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -1,6 +1,6 @@ module github.com/percona/pmm/tools -go 1.25.0 +go 1.25.5 replace github.com/go-openapi/spec => github.com/Percona-Lab/spec v0.21.0-percona @@ -11,7 +11,7 @@ require ( github.com/bufbuild/buf v1.61.0 github.com/daixiang0/gci v0.13.0 github.com/envoyproxy/protoc-gen-validate v1.3.0 - github.com/go-delve/delve v1.25.0 + github.com/go-delve/delve v1.26.0 github.com/go-openapi/runtime v0.29.0 github.com/go-openapi/spec v0.22.0 github.com/go-swagger/go-swagger v0.33.1 @@ -76,7 +76,7 @@ require ( github.com/cyphar/filepath-securejoin v0.2.5 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/denisenkom/go-mssqldb v0.9.0 // indirect - github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d // indirect + github.com/derekparker/trie/v3 v3.2.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v28.5.1+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect @@ -137,7 +137,6 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.7.0 // indirect - github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/haya14busa/go-actions-toolkit v0.0.0-20200105081403-ca0307860f01 // indirect github.com/haya14busa/go-sarif v0.0.0-20240630170108-a3ba8d79599f // indirect github.com/hexops/gotextdiff v1.0.3 // indirect diff --git a/tools/go.sum b/tools/go.sum index 20b57364b60..655b49c7e89 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -173,8 +173,8 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/denisenkom/go-mssqldb v0.9.0 h1:RSohk2RsiZqLZ0zCjtfn3S4Gp4exhpBWHyQ7D0yGjAk= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d h1:hUWoLdw5kvo2xCsqlsIBMvWUc1QCSsCYD2J2+Fg6YoU= -github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d/go.mod h1:C7Es+DLenIpPc9J6IYw4jrK0h7S9bKj4DNl8+KxGEXU= +github.com/derekparker/trie/v3 v3.2.0 h1:fET3Qbp9xSB7yc7tz6Y2GKMNl0SycYFo3cmiRI3Gpf0= +github.com/derekparker/trie/v3 v3.2.0/go.mod h1:P94lW0LPgiaMgKAEQD59IDZD2jMK9paKok8Nli/nQbE= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v28.5.1+incompatible h1:ESutzBALAD6qyCLqbQSEf1a/U8Ybms5agw59yGVc+yY= @@ -211,8 +211,8 @@ github.com/gliderlabs/ssh v0.3.9-0.20241212082318-d137aad99cd6 h1:U4ro/84p9hMZly github.com/gliderlabs/ssh v0.3.9-0.20241212082318-d137aad99cd6/go.mod h1:1nh+SYql7iCMwTrbe9K8l/lah6xcgJTH7W4ZlVoq22c= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/go-delve/delve v1.25.0 h1:JN2S3iVptvayUS2w+d0UEPmijgkodW1AFM4I8UViHGE= -github.com/go-delve/delve v1.25.0/go.mod h1:kJk12wo6PqzWknTP6M+Pg3/CrNhFMZvNq1iHESKkhv8= +github.com/go-delve/delve v1.26.0 h1:YZT1kXD76mxba4/wr+tyUa/tSmy7qzoDsmxutT42PIs= +github.com/go-delve/delve v1.26.0/go.mod h1:8BgFFOXTi1y1M+d/4ax1LdFw0mlqezQiTZQpbpwgBxo= github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62 h1:IGtvsNyIuRjl04XAOFGACozgUD7A82UffYxZt4DWbvA= github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62/go.mod h1:biJCRbqp51wS+I92HMqn5H8/A0PAhxn2vyOT+JqhiGI= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= @@ -384,8 +384,6 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= -github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/haya14busa/go-actions-toolkit v0.0.0-20200105081403-ca0307860f01 h1:HiJF8Mek+I7PY0Bm+SuhkwaAZSZP83sw6rrTMrgZ0io= github.com/haya14busa/go-actions-toolkit v0.0.0-20200105081403-ca0307860f01/go.mod h1:1DWDZmeYf0LX30zscWb7K9rUMeirNeBMd5Dum+seUhc= github.com/haya14busa/go-checkstyle v0.0.0-20170303121022-5e9d09f51fa1/go.mod h1:RsN5RGgVYeXpcXNtWyztD5VIe7VNSEqpJvF2iEH7QvI= diff --git a/ui/apps/pmm-compat/src/lib/utils/navigator.ts b/ui/apps/pmm-compat/src/lib/utils/navigator.ts new file mode 100644 index 00000000000..85027b19d37 --- /dev/null +++ b/ui/apps/pmm-compat/src/lib/utils/navigator.ts @@ -0,0 +1 @@ +export const isUserAgentApple = /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); diff --git a/ui/apps/pmm-compat/src/lib/utils/shortcuts.ts b/ui/apps/pmm-compat/src/lib/utils/shortcuts.ts index 15c77a7cde6..6f6ab997350 100644 --- a/ui/apps/pmm-compat/src/lib/utils/shortcuts.ts +++ b/ui/apps/pmm-compat/src/lib/utils/shortcuts.ts @@ -1,3 +1,5 @@ +import { isUserAgentApple } from './navigator'; + export const triggerShortcut = (shortcut: 'view-shortcuts' | 'toggle-theme' | 'search') => { if (shortcut === 'search') { triggerKeypress({ @@ -5,7 +7,8 @@ export const triggerShortcut = (shortcut: 'view-shortcuts' | 'toggle-theme' | 's code: 'KeyK', keyCode: 75, which: 75, - metaKey: true, + metaKey: isUserAgentApple, + ctrlKey: !isUserAgentApple, }); } else if (shortcut === 'view-shortcuts') { triggerKeypress({ diff --git a/ui/apps/pmm/src/api/__mocks__/ha.ts b/ui/apps/pmm/src/api/__mocks__/ha.ts new file mode 100644 index 00000000000..61b7bbb7973 --- /dev/null +++ b/ui/apps/pmm/src/api/__mocks__/ha.ts @@ -0,0 +1,95 @@ +import { + GetHANodesResponse, + GetHAStatusResponse, + NodeRole, +} from 'types/ha.types'; + +export const HA_STATUS_MOCK: GetHAStatusResponse = { + status: 'Enabled', +}; + +export const HA_NODES_MOCK_HEALTHY: GetHANodesResponse = { + nodes: [ + { + nodeName: 'pmm-ha-0', + role: NodeRole.follower, + status: 'alive', + }, + { + nodeName: 'pmm-ha-1', + role: NodeRole.leader, + status: 'alive', + }, + { + nodeName: 'pmm-ha-2', + role: NodeRole.follower, + status: 'alive', + }, + ], +}; + +export const HA_NODES_MOCK_DEGRADED: GetHANodesResponse = { + nodes: [ + { + nodeName: 'pmm-ha-0', + role: NodeRole.follower, + status: 'alive', + }, + { + nodeName: 'pmm-ha-1', + role: NodeRole.leader, + status: 'alive', + }, + { + nodeName: 'pmm-ha-2', + role: NodeRole.follower, + status: 'dead', + }, + ], +}; + +export const HA_NODES_MOCK_CRITICAL: GetHANodesResponse = { + nodes: [ + { + nodeName: 'pmm-ha-0', + role: NodeRole.follower, + status: 'dead', + }, + { + nodeName: 'pmm-ha-1', + role: NodeRole.leader, + status: 'alive', + }, + { + nodeName: 'pmm-ha-2', + role: NodeRole.follower, + status: 'dead', + }, + ], +}; + +export const HA_NODES_MOCK_DOWN: GetHANodesResponse = { + nodes: [ + { + nodeName: 'pmm-ha-0', + role: NodeRole.follower, + status: 'suspect', + }, + { + nodeName: 'pmm-ha-1', + role: NodeRole.leader, + status: 'suspect', + }, + { + nodeName: 'pmm-ha-2', + role: NodeRole.follower, + status: 'suspect', + }, + ], +}; + +export const getHAStatus = async (): Promise => + Promise.resolve(HA_STATUS_MOCK); + +export const getHANodes = async (): Promise => + Promise.resolve(HA_NODES_MOCK_HEALTHY); diff --git a/ui/apps/pmm/src/api/ha.ts b/ui/apps/pmm/src/api/ha.ts new file mode 100644 index 00000000000..bea40059d2a --- /dev/null +++ b/ui/apps/pmm/src/api/ha.ts @@ -0,0 +1,12 @@ +import { GetHANodesResponse, GetHAStatusResponse } from 'types/ha.types'; +import { api } from './api'; + +export const getHAStatus = async (): Promise => { + const response = await api.get('/ha/status'); + return response.data; +}; + +export const getHANodes = async (): Promise => { + const response = await api.get('/ha/nodes'); + return response.data; +}; diff --git a/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.constants.ts b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.constants.ts new file mode 100644 index 00000000000..36192e8d010 --- /dev/null +++ b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.constants.ts @@ -0,0 +1,8 @@ +import { HAHealth } from 'types/ha.types'; + +export const HIGH_AVAILABILITY_BADGE_HEALTH: Record = { + healthy: 'Healthy', + degraded: 'Degraded', + critical: 'Critical', + down: 'Down', +}; diff --git a/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.styles.ts b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.styles.ts new file mode 100644 index 00000000000..a832dd2982f --- /dev/null +++ b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.styles.ts @@ -0,0 +1,49 @@ +import { Theme } from '@mui/material/styles'; +import { PEAK_DARK_THEME, PEAK_LIGHT_THEME } from '@pmm/shared'; + +export const getStyles = (theme: Theme) => ({ + healthy: { + color: theme.palette.text.primary, + borderColor: theme.palette.text.primary, + }, + degraded: { + color: + theme.palette.mode === 'light' + ? PEAK_LIGHT_THEME.brand.sunrise[700] + : PEAK_DARK_THEME.extra.yellow[100], + borderColor: + theme.palette.mode === 'light' + ? PEAK_LIGHT_THEME.brand.sunrise[700] + : PEAK_DARK_THEME.extra.yellow[100], + }, + critical: { + color: + theme.palette.mode === 'light' + ? PEAK_DARK_THEME.error.dark + : theme.palette.error.contrastText, + borderColor: + theme.palette.mode === 'light' + ? PEAK_LIGHT_THEME.extra.red[50] + : PEAK_DARK_THEME.error.dark, + backgroundColor: + theme.palette.mode === 'light' + ? PEAK_LIGHT_THEME.extra.red[50] + : PEAK_DARK_THEME.error.dark, + transition: 'none', + }, + down: { + color: + theme.palette.mode === 'light' + ? PEAK_DARK_THEME.error.dark + : theme.palette.error.contrastText, + borderColor: + theme.palette.mode === 'light' + ? PEAK_LIGHT_THEME.extra.red[50] + : PEAK_DARK_THEME.error.dark, + backgroundColor: + theme.palette.mode === 'light' + ? PEAK_LIGHT_THEME.extra.red[50] + : PEAK_DARK_THEME.error.dark, + transition: 'none', + }, +}); diff --git a/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.test.tsx b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.test.tsx new file mode 100644 index 00000000000..e40f505855e --- /dev/null +++ b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.test.tsx @@ -0,0 +1,26 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import HighAvailabilityBadge from './HighAvailabilityBadge'; +import { HAHealth } from 'types/ha.types'; +import { HIGH_AVAILABILITY_BADGE_HEALTH } from './HighAvailabilityBadge.constants'; + +describe('HighAvailabilityBadge', () => { + it('should render the badge', () => { + render(); + + expect(screen.getByText('Healthy')).toBeInTheDocument(); + }); + + it('should render the badge for all health statuses', () => { + const healthTypes: HAHealth[] = ['degraded', 'critical', 'down']; + + for (const health of healthTypes) { + render(); + + expect( + screen.getByText(HIGH_AVAILABILITY_BADGE_HEALTH[health]) + ).toBeInTheDocument(); + + cleanup(); + } + }); +}); diff --git a/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.tsx b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.tsx new file mode 100644 index 00000000000..c4ac35ee900 --- /dev/null +++ b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.tsx @@ -0,0 +1,32 @@ +import Chip from '@mui/material/Chip'; +import { FC } from 'react'; +import { HIGH_AVAILABILITY_BADGE_HEALTH } from './HighAvailabilityBadge.constants'; +import { useTheme } from '@mui/material/styles'; +import { getStyles } from './HighAvailabilityBadge.styles'; +import Stack from '@mui/material/Stack'; +import { HighAvailabilityBadgeProps } from './HighAvailabilityBadge.types'; + +const HighAvailabilityBadge: FC = ({ + health, + ...props +}) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( + + + + ); +}; + +export default HighAvailabilityBadge; diff --git a/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.types.ts b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.types.ts new file mode 100644 index 00000000000..d30a10b5113 --- /dev/null +++ b/ui/apps/pmm/src/components/ha-badge/HighAvailabilityBadge.types.ts @@ -0,0 +1,6 @@ +import { ChipProps } from '@mui/material/Chip'; +import { HAHealth } from 'types/ha.types'; + +export interface HighAvailabilityBadgeProps extends ChipProps { + health: HAHealth; +} diff --git a/ui/apps/pmm/src/components/ha-badge/index.ts b/ui/apps/pmm/src/components/ha-badge/index.ts new file mode 100644 index 00000000000..8c5573e3129 --- /dev/null +++ b/ui/apps/pmm/src/components/ha-badge/index.ts @@ -0,0 +1 @@ +export { default as HighAvailabilityBadge } from './HighAvailabilityBadge'; diff --git a/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.constants.ts b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.constants.ts new file mode 100644 index 00000000000..7e730b800e7 --- /dev/null +++ b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.constants.ts @@ -0,0 +1,8 @@ +import { IconName } from 'components/icon/Icon.types'; +import { HAHealth } from 'types/ha.types'; + +export const HA_ICON_MAP: Record, IconName> = { + degraded: 'status-at-risk', + critical: 'status-down', + down: 'status-down', +}; diff --git a/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.styles.ts b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.styles.ts new file mode 100644 index 00000000000..6e3ba2ebbd9 --- /dev/null +++ b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.styles.ts @@ -0,0 +1,31 @@ +import { Theme } from '@mui/material'; +import { PEAK_DARK_THEME, PEAK_LIGHT_THEME } from '@pmm/shared'; + +export const getStyles = ({ palette: { mode, background } }: Theme) => ({ + icon: { + width: 20, + height: 20, + + '.background': { + fill: background.default, + }, + '.status-at-risk': { + fill: + mode === 'light' + ? PEAK_LIGHT_THEME.brand.sunrise[700] + : PEAK_DARK_THEME.extra.yellow[100], + }, + '.status-down': { + fill: + mode === 'light' + ? PEAK_LIGHT_THEME.extra.red[500] + : PEAK_DARK_THEME.extra.red[200], + }, + '.status-updating': { + fill: + mode === 'light' + ? PEAK_LIGHT_THEME.brand.sky[600] + : PEAK_DARK_THEME.brand.sky[400], + }, + }, +}); diff --git a/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.test.tsx b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.test.tsx new file mode 100644 index 00000000000..fd27eb286d1 --- /dev/null +++ b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.test.tsx @@ -0,0 +1,35 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import HighAvailabilityIcon from './HighAvailabilityIcon'; +import { HAHealth } from 'types/ha.types'; + +describe('HighAvailabilityIcon', () => { + it('should render the main icon', async () => { + render(); + + await waitFor(() => + expect(screen.queryByTestId('ha-icon')).toBeInTheDocument() + ); + }); + + it('should render the health icon for all types except healthy', async () => { + const healthTypes: HAHealth[] = ['degraded', 'critical', 'down']; + + for (const health of healthTypes) { + render(); + + await waitFor(() => + expect(screen.queryByTestId('ha-health-icon')).toBeInTheDocument() + ); + + cleanup(); + } + }); + + it('should not render the health icon for healthy', async () => { + render(); + + await waitFor(() => + expect(screen.queryByTestId('ha-health-icon')).not.toBeInTheDocument() + ); + }); +}); diff --git a/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.tsx b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.tsx new file mode 100644 index 00000000000..09aa518d01e --- /dev/null +++ b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.tsx @@ -0,0 +1,28 @@ +import { useTheme } from '@mui/material'; +import Box from '@mui/material/Box'; +import { Icon } from 'components/icon'; +import { FC } from 'react'; +import { getStyles } from './HighAvailabilityIcon.styles'; +import { HighAvailabilityIconProps } from './HighAvailabilityIcon.types'; +import { HA_ICON_MAP } from './HighAvailabilityIcon.constants'; + +const HighAvailabilityIcon: FC = ({ health }) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( + + + {health !== 'healthy' && ( + + + + )} + + ); +}; + +export default HighAvailabilityIcon; diff --git a/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.types.ts b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.types.ts new file mode 100644 index 00000000000..5743d55befd --- /dev/null +++ b/ui/apps/pmm/src/components/ha-icon/HighAvailabilityIcon.types.ts @@ -0,0 +1,5 @@ +import { HAHealth } from 'types/ha.types'; + +export interface HighAvailabilityIconProps { + health: HAHealth; +} diff --git a/ui/apps/pmm/src/components/ha-icon/index.ts b/ui/apps/pmm/src/components/ha-icon/index.ts new file mode 100644 index 00000000000..23d8a1f13bb --- /dev/null +++ b/ui/apps/pmm/src/components/ha-icon/index.ts @@ -0,0 +1 @@ +export { default as HighAvailabilityIcon } from './HighAvailabilityIcon'; diff --git a/ui/apps/pmm/src/components/icon/Icon.constants.ts b/ui/apps/pmm/src/components/icon/Icon.constants.ts index 3009216d6d7..42c3de271ac 100644 --- a/ui/apps/pmm/src/components/icon/Icon.constants.ts +++ b/ui/apps/pmm/src/components/icon/Icon.constants.ts @@ -37,6 +37,11 @@ export const DYNAMIC_ICON_IMPORT_MAP = { 'my-organization': () => import('icons/my-organization.svg?react'), memory: () => import('icons/memory.svg?react'), network: () => import('icons/network.svg?react'), + cluster: () => import('icons/cluster.svg?react'), + 'status-at-risk': () => import('icons/status-at-risk.svg?react'), + 'status-down': () => import('icons/status-down.svg?react'), + 'status-updating': () => import('icons/status-updating.svg?react'), + 'arrow-link': () => import('icons/arrow-link.svg?react'), }; export const VIEWBOX_MAP: Partial< diff --git a/ui/apps/pmm/src/components/main/update-modal/UpdateModal.tsx b/ui/apps/pmm/src/components/main/update-modal/UpdateModal.tsx index 60189c42365..46f167554a4 100644 --- a/ui/apps/pmm/src/components/main/update-modal/UpdateModal.tsx +++ b/ui/apps/pmm/src/components/main/update-modal/UpdateModal.tsx @@ -22,7 +22,8 @@ const UpdateModal: FC = () => { const isOnUpdatesPage = location.pathname.startsWith( PMM_NEW_NAV_UPDATES_PATH ); - const latestVersion = versionInfo?.latest.version || ''; + + const latestVersion = versionInfo?.latest?.version || ''; const releaseNotesUrl = versionInfo?.latest?.releaseNotesUrl ?? ''; const updateAvailable = Boolean(versionInfo?.updateAvailable); diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.styles.ts b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.styles.ts index 8badbe26107..ba18b75e18b 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.styles.ts +++ b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.styles.ts @@ -82,4 +82,22 @@ export const getStyles = ( color: active ? 'inherit' : theme.palette.warning.contrastText, }, }, + textOnly: { + m: 0, + pl: 3, + + [`.${listItemTextClasses.primary}`]: { + fontSize: 12, + fontWeight: 500, + color: theme.palette.text.secondary, + fontFamily: theme.typography.body1.fontFamily, + }, + + [`.${listItemTextClasses.secondary}`]: { + fontSize: 14, + fontWeight: 475, + color: theme.palette.text.secondary, + fontFamily: 'Roboto Mono, monospace', + }, + }, }); diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.test.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.test.tsx index e6093ad0683..56a068776a8 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.test.tsx +++ b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.test.tsx @@ -166,4 +166,31 @@ describe('NavItem', () => { expect(screen.getByTestId('navitem-dot')).toBeInTheDocument(); }); + + it('renders divider if item has type "menu-divider"', () => { + renderNavItem({ + props: { + item: { id: 'divider', type: 'menu-divider' }, + }, + }); + + expect(screen.queryByTestId('navitem-divider-divider')).toBeInTheDocument(); + }); + + it('renders text item if item has type "menu-text"', () => { + renderNavItem({ + props: { + item: { + id: 'desc', + type: 'menu-text', + text: 'description', + secondaryText: 'secondary text', + }, + }, + }); + + expect(screen.queryByTestId('navitem-desc-text-item')).toBeInTheDocument(); + expect(screen.getByText('description')).toBeInTheDocument(); + expect(screen.getByText('secondary text')).toBeInTheDocument(); + }); }); diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx index 36e230c3d74..9099e9e159e 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx +++ b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx @@ -19,7 +19,7 @@ import IconButton from '@mui/material/IconButton'; import NavItemTooltip from './nav-item-tooltip/NavItemTooltip'; import { DRAWER_WIDTH } from '../drawer/Drawer.constants'; import NavItemDot from './nav-item-dot/NavItemDot'; -import Chip from '@mui/material/Chip'; +import NavItemBadge from './nav-item-badge/NavItemBadge'; import Box from '@mui/material/Box'; const NavItem: FC = ({ @@ -112,6 +112,9 @@ const NavItem: FC = ({ className="navitem-primary-text" sx={styles.text} /> + {item.badge && item.badgeAlwaysVisible && drawerOpen && ( + + )} {drawerOpen && ( = ({ ); } - if (item.isDivider) { + if (item.type === 'menu-divider') { return ( - + ); } + if (item.type === 'menu-text') { + return ( + + + + ); + } + return ( = ({ className="navitem-primary-text" sx={styles.text} /> - {item.badge && ( - - )} + {item.badge && } diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.utils.ts b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.utils.ts index a3f23440875..c57449c3f89 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.utils.ts +++ b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.utils.ts @@ -22,7 +22,7 @@ export const getLinkProps = (item: NavItem, url?: string) => { }; export const shouldShowBadge = (item: NavItem, expanded: boolean): boolean => { - if (item.badge && !expanded) { + if (item.badge && !expanded && !item.badgeAlwaysVisible) { return true; } diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-badge/NavItemBadge.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-badge/NavItemBadge.tsx new file mode 100644 index 00000000000..ab0fdc037e5 --- /dev/null +++ b/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-badge/NavItemBadge.tsx @@ -0,0 +1,21 @@ +import Chip from '@mui/material/Chip'; +import { FC, isValidElement } from 'react'; +import { NavItem } from 'types/navigation.types'; + +interface Props { + badge: NavItem['badge']; +} + +const NavItemBadge: FC = ({ badge: Badge }) => { + if (isValidElement(Badge)) { + return Badge; + } + + if (typeof Badge === 'object' && Badge !== null) { + return ; + } + + return null; +}; + +export default NavItemBadge; diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-icon/NavItemIcon.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-icon/NavItemIcon.tsx index 625cb92eae9..567ccde23f5 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-icon/NavItemIcon.tsx +++ b/ui/apps/pmm/src/components/sidebar/nav-item/nav-item-icon/NavItemIcon.tsx @@ -6,7 +6,16 @@ interface Props { icon: NonNullable; } -const NavItemIcon: FC = ({ icon: NavIcon }) => - typeof NavIcon === 'string' ? : ; +const NavItemIcon: FC = ({ icon: NavIcon }) => { + if (typeof NavIcon === 'string') { + return ; + } + + if (typeof NavIcon === 'function') { + return ; + } + + return NavIcon; +}; export default NavItemIcon; diff --git a/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts b/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts index 53217ca5184..74696229be0 100644 --- a/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts +++ b/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts @@ -2,18 +2,18 @@ import { PMM_NEW_NAV_GRAFANA_PATH, PMM_NEW_NAV_PATH } from 'lib/constants'; import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined'; import { NavItem } from 'types/navigation.types'; -export const NAV_DIVIDERS = { +export const NAV_DIVIDERS: Record<'home' | 'inventory' | 'backups', NavItem> = { home: { id: 'home-divider', - isDivider: true, + type: 'menu-divider', }, inventory: { id: 'inventory-divider', - isDivider: true, + type: 'menu-divider', }, backups: { id: 'backups-divider', - isDivider: true, + type: 'menu-divider', }, }; @@ -789,3 +789,26 @@ export const NAV_OTHER_DASHBOARDS_TEMPLATE: Partial = { icon: 'search', text: 'Other dashboards', }; + +/* + * High Availability + */ +export const NAV_HIGH_AVAILABILITY: NavItem = { + id: 'high-availability', + icon: 'cluster', + text: 'PMM HA', + url: `${PMM_NEW_NAV_GRAFANA_PATH}/high-availability`, +}; + +export const NAV_HIGH_AVAILABILITY_LEADER: NavItem = { + id: 'high-availability-leader', + text: 'Leader:', + type: 'menu-text', +}; + +export const NAV_HIGH_AVAILABILITY_NODES: NavItem = { + id: 'high-availability-nodes', + icon: 'arrow-link', + text: 'Identify Nodes', + url: `${PMM_NEW_NAV_GRAFANA_PATH}/inventory/nodes?isPmmServerNode=true`, +}; diff --git a/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx b/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx index 750b7dd5b59..7e764deff0e 100644 --- a/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx +++ b/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx @@ -9,6 +9,7 @@ import { addConfiguration, addDashboardItems, addExplore, + addHighAvailability, addUsersAndAccess, } from './navigation.utils'; import { useUser } from 'contexts/user'; @@ -28,6 +29,7 @@ import { import { useFolders } from 'hooks/api/useFolders'; import { useUpdates } from 'contexts/updates'; import { useLocalStorage } from 'hooks/utils/useLocalStorage'; +import { useHaInfo } from 'hooks/api/useHA'; export const NavigationProvider: FC = ({ children }) => { const { user } = useUser(); @@ -45,6 +47,7 @@ export const NavigationProvider: FC = ({ children }) => { 'pmm-ui.sidebar.expanded', true ); + const { data: haInfo } = useHaInfo(); const navTree = useMemo(() => { const items: NavItem[] = []; @@ -55,6 +58,10 @@ export const NavigationProvider: FC = ({ children }) => { items.push(NAV_HOME_PAGE); + if (haInfo.enabled) { + items.push(addHighAvailability(haInfo)); + } + items.push(NAV_DIVIDERS.home); items.push(...addDashboardItems(currentServiceTypes, folders, user)); @@ -107,6 +114,7 @@ export const NavigationProvider: FC = ({ children }) => { settings, advisors, colorMode, + haInfo, toggleColorMode, ]); diff --git a/ui/apps/pmm/src/contexts/navigation/navigation.utils.ts b/ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx similarity index 90% rename from ui/apps/pmm/src/contexts/navigation/navigation.utils.ts rename to ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx index cde17e47db0..8259671a859 100644 --- a/ui/apps/pmm/src/contexts/navigation/navigation.utils.ts +++ b/ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx @@ -41,13 +41,18 @@ import { NAV_ALERTS_SILENCES, NAV_ALERTS_GROUPS, NAV_VALKEY, + NAV_HIGH_AVAILABILITY, NAV_USERS_AND_ACCESS, NAV_ACCESS_CONTROL, + NAV_HIGH_AVAILABILITY_LEADER, } from './navigation.constants'; import { CombinedSettings } from 'contexts/settings'; import { capitalize } from 'utils/text.utils'; import { DashboardFolder } from 'types/folders.types'; import { GetUpdatesResponse, UpdateStatus } from 'types/updates.types'; +import { HighAvailabilityIcon } from 'components/ha-icon'; +import { HighAvailabilityBadge } from 'components/ha-badge'; +import { HAInfo } from 'types/ha.types'; export const addOtherDashboardsItem = ( rootNode: NavItem, @@ -242,6 +247,25 @@ export const addConfiguration = ( return NAV_CONFIGURATION; }; +export const addHighAvailability = ({ health, leader }: HAInfo): NavItem => { + const item = { ...NAV_HIGH_AVAILABILITY }; + + item.badge = ; + item.icon = ; + item.badgeAlwaysVisible = true; + + item.children = [ + { + ...NAV_HIGH_AVAILABILITY_LEADER, + secondaryText: leader?.nodeName || 'Unknown', + }, + // Remove Identify Nodes link for now + // NAV_HIGH_AVAILABILITY_NODES, + ]; + + return item; +}; + export const addUsersAndAccess = (settings?: CombinedSettings): NavItem => { const children: NavItem[] = [...(NAV_USERS_AND_ACCESS.children || [])]; diff --git a/ui/apps/pmm/src/contexts/tour/tour.provider.tsx b/ui/apps/pmm/src/contexts/tour/tour.provider.tsx index d86006901c9..766ad65afbc 100644 --- a/ui/apps/pmm/src/contexts/tour/tour.provider.tsx +++ b/ui/apps/pmm/src/contexts/tour/tour.provider.tsx @@ -96,7 +96,9 @@ export const TourProvider: FC = ({ children }) => { currentStep={currentStep} setCurrentStep={setCurrentStep} onClickClose={endTour} - onClickMask={endTour} + onClickMask={() => { + // Prevent tour from closing when clicking on backdrop + }} position="right" components={{ Badge: () => null, diff --git a/ui/apps/pmm/src/hooks/api/useHA.ts b/ui/apps/pmm/src/hooks/api/useHA.ts new file mode 100644 index 00000000000..b55d45e4770 --- /dev/null +++ b/ui/apps/pmm/src/hooks/api/useHA.ts @@ -0,0 +1,50 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { getHANodes, getHAStatus } from 'api/ha'; +import { + GetHANodesResponse, + GetHAStatusResponse, + NodeRole, +} from 'types/ha.types'; +import { getHAHealth } from 'utils/ha.utils'; + +export const useHAStatus = ( + options?: Partial> +) => + useQuery({ + queryKey: ['ha:status'], + queryFn: () => getHAStatus(), + ...options, + }); + +export const useHANodes = ( + options?: Partial> +) => + useQuery({ + queryKey: ['ha:nodes'], + queryFn: () => getHANodes(), + ...options, + }); + +export const useHaInfo = () => { + const statusQuery = useHAStatus(); + const nodesQuery = useHANodes({ + enabled: statusQuery.data?.status === 'Enabled', + refetchInterval: 15000, + }); + + const health = getHAHealth(nodesQuery.data?.nodes || []); + const enabled = statusQuery.data?.status === 'Enabled'; + const leader = nodesQuery.data?.nodes.find( + (node) => node.role === NodeRole.leader + ); + + return { + data: { + enabled, + health, + leader, + nodes: nodesQuery.data?.nodes || [], + }, + isLoading: nodesQuery.isLoading || statusQuery.isLoading, + }; +}; diff --git a/ui/apps/pmm/src/icons/arrow-link.svg b/ui/apps/pmm/src/icons/arrow-link.svg new file mode 100644 index 00000000000..947c6bb0153 --- /dev/null +++ b/ui/apps/pmm/src/icons/arrow-link.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/apps/pmm/src/icons/cluster.svg b/ui/apps/pmm/src/icons/cluster.svg new file mode 100644 index 00000000000..19915311197 --- /dev/null +++ b/ui/apps/pmm/src/icons/cluster.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/apps/pmm/src/icons/status-at-risk.svg b/ui/apps/pmm/src/icons/status-at-risk.svg new file mode 100644 index 00000000000..1a08f6deb3a --- /dev/null +++ b/ui/apps/pmm/src/icons/status-at-risk.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui/apps/pmm/src/icons/status-down.svg b/ui/apps/pmm/src/icons/status-down.svg new file mode 100644 index 00000000000..9166f45f623 --- /dev/null +++ b/ui/apps/pmm/src/icons/status-down.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/apps/pmm/src/icons/status-updating.svg b/ui/apps/pmm/src/icons/status-updating.svg new file mode 100644 index 00000000000..93c85de294c --- /dev/null +++ b/ui/apps/pmm/src/icons/status-updating.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/apps/pmm/src/types/ha.types.ts b/ui/apps/pmm/src/types/ha.types.ts new file mode 100644 index 00000000000..717553c5afa --- /dev/null +++ b/ui/apps/pmm/src/types/ha.types.ts @@ -0,0 +1,32 @@ +export interface GetHAStatusResponse { + status: HAStatus; +} + +export type HAStatus = 'Enabled' | 'Disabled'; + +export enum NodeRole { + leader = 'NODE_ROLE_LEADER', + follower = 'NODE_ROLE_FOLLOWER', + unspecified = 'NODE_ROLE_UNSPECIFIED', +} + +export type NodeStatus = 'alive' | 'suspect' | 'dead' | 'left' | 'unknown'; + +export interface GetHANodesResponse { + nodes: GetHANodeResponse[]; +} + +export interface GetHANodeResponse { + nodeName: string; + role: NodeRole; + status: NodeStatus; +} + +export interface HAInfo { + enabled: boolean; + health: HAHealth; + leader?: GetHANodeResponse; + nodes: GetHANodeResponse[]; +} + +export type HAHealth = 'healthy' | 'degraded' | 'critical' | 'down'; diff --git a/ui/apps/pmm/src/types/navigation.types.ts b/ui/apps/pmm/src/types/navigation.types.ts index 7d5a26155b3..7b866e56014 100644 --- a/ui/apps/pmm/src/types/navigation.types.ts +++ b/ui/apps/pmm/src/types/navigation.types.ts @@ -6,14 +6,15 @@ export interface NavItem { id: string; text?: string; secondaryText?: string; - icon?: IconName | SvgIconComponent; + icon?: IconName | SvgIconComponent | React.ReactElement; url?: string; children?: NavItem[]; isActive?: boolean; target?: HTMLAnchorElement['target']; - isDivider?: boolean; onClick?: () => void; hidden?: boolean; - badge?: ChipProps; + badge?: ChipProps | React.ReactElement; + badgeAlwaysVisible?: boolean; matches?: string[]; + type?: 'menu-item' | 'menu-text' | 'menu-divider'; } diff --git a/ui/apps/pmm/src/utils/ha.utils.test.ts b/ui/apps/pmm/src/utils/ha.utils.test.ts new file mode 100644 index 00000000000..857c8b78508 --- /dev/null +++ b/ui/apps/pmm/src/utils/ha.utils.test.ts @@ -0,0 +1,88 @@ +import { GetHANodeResponse, NodeRole } from 'types/ha.types'; +import { getHAHealth } from './ha.utils'; + +describe('ha.utils', () => { + it('should return "healthy" if all alive', () => { + const nodes: GetHANodeResponse[] = [ + { nodeName: 'pmm-ha-1', role: NodeRole.follower, status: 'alive' }, + { nodeName: 'pmm-ha-0', role: NodeRole.leader, status: 'alive' }, + { nodeName: 'pmm-ha-2', role: NodeRole.follower, status: 'alive' }, + ]; + + const health = getHAHealth(nodes); + + expect(health).toBe('healthy'); + }); + + it('should return "down" if all dead', () => { + const nodes: GetHANodeResponse[] = [ + { nodeName: 'pmm-ha-1', role: NodeRole.follower, status: 'dead' }, + { nodeName: 'pmm-ha-0', role: NodeRole.leader, status: 'dead' }, + { nodeName: 'pmm-ha-2', role: NodeRole.follower, status: 'dead' }, + ]; + + const health = getHAHealth(nodes); + + expect(health).toBe('down'); + }); + + it('should return "down" if all suspect', () => { + const nodes: GetHANodeResponse[] = [ + { nodeName: 'pmm-ha-1', role: NodeRole.follower, status: 'suspect' }, + { nodeName: 'pmm-ha-0', role: NodeRole.leader, status: 'suspect' }, + { nodeName: 'pmm-ha-2', role: NodeRole.follower, status: 'suspect' }, + ]; + + const health = getHAHealth(nodes); + + expect(health).toBe('down'); + }); + + it('should return "down" if all left', () => { + const nodes: GetHANodeResponse[] = [ + { nodeName: 'pmm-ha-1', role: NodeRole.follower, status: 'left' }, + { nodeName: 'pmm-ha-0', role: NodeRole.leader, status: 'left' }, + { nodeName: 'pmm-ha-2', role: NodeRole.follower, status: 'left' }, + ]; + + const health = getHAHealth(nodes); + + expect(health).toBe('down'); + }); + + it('should return "down" if all unknown', () => { + const nodes: GetHANodeResponse[] = [ + { nodeName: 'pmm-ha-1', role: NodeRole.follower, status: 'unknown' }, + { nodeName: 'pmm-ha-0', role: NodeRole.leader, status: 'unknown' }, + { nodeName: 'pmm-ha-2', role: NodeRole.follower, status: 'unknown' }, + ]; + + const health = getHAHealth(nodes); + + expect(health).toBe('down'); + }); + + it('should return "degraded" if not alive <= 1/3', () => { + const nodes: GetHANodeResponse[] = [ + { nodeName: 'pmm-ha-1', role: NodeRole.follower, status: 'alive' }, + { nodeName: 'pmm-ha-0', role: NodeRole.leader, status: 'alive' }, + { nodeName: 'pmm-ha-2', role: NodeRole.follower, status: 'dead' }, + ]; + + const health = getHAHealth(nodes); + + expect(health).toBe('degraded'); + }); + + it('should return "critical" if not alive <= 2/3', () => { + const nodes: GetHANodeResponse[] = [ + { nodeName: 'pmm-ha-1', role: NodeRole.follower, status: 'alive' }, + { nodeName: 'pmm-ha-0', role: NodeRole.leader, status: 'dead' }, + { nodeName: 'pmm-ha-2', role: NodeRole.follower, status: 'dead' }, + ]; + + const health = getHAHealth(nodes); + + expect(health).toBe('critical'); + }); +}); diff --git a/ui/apps/pmm/src/utils/ha.utils.ts b/ui/apps/pmm/src/utils/ha.utils.ts new file mode 100644 index 00000000000..9d423a5d19b --- /dev/null +++ b/ui/apps/pmm/src/utils/ha.utils.ts @@ -0,0 +1,19 @@ +import { GetHANodeResponse, HAHealth } from 'types/ha.types'; + +export const getHAHealth = (nodes: GetHANodeResponse[]): HAHealth => { + const nonAliveCount = nodes.filter((node) => node.status !== 'alive').length; + + if (nonAliveCount === nodes.length) { + return 'down'; + } + + if (nonAliveCount >= 2 * (nodes.length / 3.0)) { + return 'critical'; + } + + if (nonAliveCount >= nodes.length / 3.0) { + return 'degraded'; + } + + return 'healthy'; +}; diff --git a/ui/apps/pmm/src/utils/navigation.utils.ts b/ui/apps/pmm/src/utils/navigation.utils.ts index a0a3b392b2e..2f3ac92feee 100644 --- a/ui/apps/pmm/src/utils/navigation.utils.ts +++ b/ui/apps/pmm/src/utils/navigation.utils.ts @@ -31,7 +31,7 @@ export const findActiveNavItem = ( }; export const isActive = (item: NavItem, pathname: string): boolean => { - if (item.isDivider || !item.url) { + if (item.type === 'menu-divider' || item.type === 'menu-text' || !item.url) { return false; } diff --git a/ui/yarn.lock b/ui/yarn.lock index 35ff809398d..c47bf881195 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -10536,16 +10536,7 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10635,14 +10626,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11749,16 +11733,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== diff --git a/utils/dsnutils/redact.go b/utils/dsnutils/redact.go new file mode 100644 index 00000000000..4798e4c321e --- /dev/null +++ b/utils/dsnutils/redact.go @@ -0,0 +1,29 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package dsnutils provides DNS related utilities. +package dsnutils + +import "net/url" + +// RedactDSN redacts sensitive information from the given DSN string. +func RedactDSN(dsn string) string { + u, err := url.Parse(dsn) + if err != nil { + return dsn + } + + return u.Redacted() +}