From e36ed2e19d0fc8421de7cf5a1b27de0f3d9ce54c Mon Sep 17 00:00:00 2001 From: Mikhail Grigorev Date: Tue, 16 Apr 2024 18:41:17 +0500 Subject: [PATCH] Added support collect Patroni metrics (#12) Co-authored-by: Mikhail Grigorev --- Dockerfile | 1 - Makefile | 1 + README.md | 2 +- README.ru.md | 2 +- internal/collector/collector.go | 26 +- internal/collector/config.go | 22 +- internal/collector/patroni_common.go | 619 ++++++++++++++++++ internal/collector/patroni_common_test.go | 201 ++++++ internal/model/model.go | 7 +- internal/pgscv/config.go | 10 + internal/pgscv/config_test.go | 13 +- internal/service/config.go | 41 +- internal/service/config_test.go | 32 +- internal/service/service.go | 82 ++- .../testdata/patroni/patroni.golden.yml | 14 - 15 files changed, 1022 insertions(+), 51 deletions(-) create mode 100644 internal/collector/patroni_common.go create mode 100644 internal/collector/patroni_common_test.go delete mode 100644 internal/service/testdata/patroni/patroni.golden.yml diff --git a/Dockerfile b/Dockerfile index 1079919..5b2f87f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,3 @@ COPY --from=build /app/bin/pgscv /bin/pgscv EXPOSE 9890 ENTRYPOINT ["/bin/pgscv"] #ENTRYPOINT ["/bin/docker_entrypoint.sh"] -CMD ["--log-level=info"] diff --git a/Makefile b/Makefile index b31ddc4..11f8479 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ DOCKER_ACCOUNT = cherts APPNAME = pgscv APPOS = linux +#APPOS = ${GOOS} TAG=$(shell git tag -l --sort=-creatordate | head -n 1) COMMIT=$(shell git rev-parse --short HEAD) diff --git a/README.md b/README.md index 73bf648..87508bc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This project is a continuation of the development of the original pgSCV by [Alexey Lesovsky](https://github.com/lesovsky) ### Features -- **Supported services:** support collecting metrics of PostgreSQL and Pgbouncer. +- **Supported services:** support collecting metrics of PostgreSQL, Pgbouncer and Patroni. - **OS metrics:** support collecting metrics of operating system. - **TLS and authentication**. `/metrics` endpoint could be protected with basic authentication and TLS. - **Collecting metrics from multiple services**. pgSCV can collect metrics from many databases instances. diff --git a/README.ru.md b/README.ru.md index 9ec9caf..bc9b0ff 100644 --- a/README.ru.md +++ b/README.ru.md @@ -10,7 +10,7 @@ Данный проект является продолжением развития оригинального pgSCV авторства [Alexey Lesovsky](https://github.com/lesovsky) ### Основные возможности -- **Поддерживаемые сервисы**: поддержка сбора показателей работы PostgreSQL и Pgbouncer; +- **Поддерживаемые сервисы**: поддержка сбора показателей работы PostgreSQL, Pgbouncer и Patroni; - **Метрики ОС:** поддержка сбора показателей работы операционной системы; - **TLS и аутентификация**: Эндпойнт `/metrics` может быть защищен с помощью базовой аутентификации и TLS; - **Сбор показателей из нескольких сервисов**: pgSCV может собирать метрики из многих экземпляров баз данных, включая базы данных расположенные в облачных средах (Amazon AWS, Yandex.Cloud, VK.Cloud); diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 0f638d7..f5a6738 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -1,11 +1,12 @@ package collector import ( + "sync" + "github.com/cherts/pgscv/internal/filter" "github.com/cherts/pgscv/internal/log" "github.com/cherts/pgscv/internal/model" "github.com/prometheus/client_golang/prometheus" - "sync" ) // Factories defines collector functions which used for collecting metrics. @@ -106,6 +107,29 @@ func (f Factories) RegisterPgbouncerCollectors(disabled []string) { } } +// RegisterPatroniCollectors unions all patroni-related collectors and registers them in single place. +func (f Factories) RegisterPatroniCollectors(disabled []string) { + if stringsContains(disabled, "patroni") { + log.Debugln("disable all patroni collectors") + return + } + + funcs := map[string]func(labels, model.CollectorSettings) (Collector, error){ + "patroni/pgscv": NewPgscvServicesCollector, + "patroni/common": NewPatroniCommonCollector, + } + + for name, fn := range funcs { + if stringsContains(disabled, name) { + log.Debugln("disable ", name) + continue + } + + log.Debugln("enable ", name) + f.register(name, fn) + } +} + // register is the generic routine which register any kind of collectors. func (f Factories) register(collector string, factory func(labels, model.CollectorSettings) (Collector, error)) { f[collector] = factory diff --git a/internal/collector/config.go b/internal/collector/config.go index 7e14ab0..509bb7f 100644 --- a/internal/collector/config.go +++ b/internal/collector/config.go @@ -2,14 +2,16 @@ package collector import ( "context" - "github.com/jackc/pgx/v4" - "github.com/cherts/pgscv/internal/log" - "github.com/cherts/pgscv/internal/model" - "github.com/cherts/pgscv/internal/store" + "fmt" "net" "regexp" "strconv" "strings" + + "github.com/cherts/pgscv/internal/log" + "github.com/cherts/pgscv/internal/model" + "github.com/cherts/pgscv/internal/store" + "github.com/jackc/pgx/v4" ) // Config defines collector's global configuration. @@ -19,6 +21,8 @@ type Config struct { ServiceType string // ConnString defines a connection string used to connecting to the service ConnString string + // BaseURL defines a URL string for connecting to HTTP service + BaseURL string // NoTrackMode controls collector to gather and send sensitive information, such as queries texts. NoTrackMode bool // postgresServiceConfig defines collector's options specific for Postgres service @@ -79,7 +83,7 @@ func newPostgresServiceConfig(connStr string) (postgresServiceConfig, error) { // Get Postgres block size. err = conn.Conn().QueryRow(context.Background(), "SELECT setting FROM pg_settings WHERE name = 'block_size'").Scan(&setting) if err != nil { - return config, err + return config, fmt.Errorf("failed to get block_size setting from pg_settings, %s, please check user grants", err) } bsize, err := strconv.ParseUint(setting, 10, 64) if err != nil { @@ -91,7 +95,7 @@ func newPostgresServiceConfig(connStr string) (postgresServiceConfig, error) { // Get Postgres WAL segment size. err = conn.Conn().QueryRow(context.Background(), "SELECT setting FROM pg_settings WHERE name = 'wal_segment_size'").Scan(&setting) if err != nil { - return config, err + return config, fmt.Errorf("failed to get wal_segment_size setting from pg_settings, %s, please check user grants", err) } walSegSize, err := strconv.ParseUint(setting, 10, 64) if err != nil { @@ -103,7 +107,7 @@ func newPostgresServiceConfig(connStr string) (postgresServiceConfig, error) { // Get Postgres server version err = conn.Conn().QueryRow(context.Background(), "SELECT setting FROM pg_settings WHERE name = 'server_version_num'").Scan(&setting) if err != nil { - return config, err + return config, fmt.Errorf("failed to get server_version_num setting from pg_settings, %s, please check user grants", err) } version, err := strconv.Atoi(setting) if err != nil { @@ -119,7 +123,7 @@ func newPostgresServiceConfig(connStr string) (postgresServiceConfig, error) { // Get Postgres data directory err = conn.Conn().QueryRow(context.Background(), "SELECT setting FROM pg_settings WHERE name = 'data_directory'").Scan(&setting) if err != nil { - return config, err + return config, fmt.Errorf("failed to get data_directory setting from pg_settings, %s, please check user grants", err) } config.dataDirectory = setting @@ -127,7 +131,7 @@ func newPostgresServiceConfig(connStr string) (postgresServiceConfig, error) { // Get setting of 'logging_collector' GUC. err = conn.Conn().QueryRow(context.Background(), "SELECT setting FROM pg_settings WHERE name = 'logging_collector'").Scan(&setting) if err != nil { - return config, err + return config, fmt.Errorf("failed to get logging_collector setting from pg_settings, %s, please check user grants", err) } if setting == "on" { diff --git a/internal/collector/patroni_common.go b/internal/collector/patroni_common.go new file mode 100644 index 0000000..10c9435 --- /dev/null +++ b/internal/collector/patroni_common.go @@ -0,0 +1,619 @@ +package collector + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/cherts/pgscv/internal/http" + "github.com/cherts/pgscv/internal/model" + "github.com/prometheus/client_golang/prometheus" +) + +type patroniCommonCollector struct { + client *http.Client + up typedDesc + name typedDesc + version typedDesc + pgup typedDesc + pgstart typedDesc + roleMaster typedDesc + roleStandbyLeader typedDesc + roleReplica typedDesc + xlogLoc typedDesc + xlogRecvLoc typedDesc + xlogReplLoc typedDesc + xlogReplTs typedDesc + xlogPaused typedDesc + pgversion typedDesc + unlocked typedDesc + timeline typedDesc + dcslastseen typedDesc + changetime typedDesc + replicationState typedDesc + pendingRestart typedDesc + pause typedDesc + inArchiveRecovery typedDesc + failsafeMode typedDesc + loopWait typedDesc + maximumLagOnFailover typedDesc + retryTimeout typedDesc + ttl typedDesc +} + +// NewPatroniCommonCollector returns a new Collector exposing Patroni common info. +// For details see https://patroni.readthedocs.io/en/latest/rest_api.html#monitoring-endpoint +func NewPatroniCommonCollector(constLabels labels, settings model.CollectorSettings) (Collector, error) { + varLabels := []string{"scope"} + + return &patroniCommonCollector{ + client: http.NewClient(http.ClientConfig{Timeout: time.Second}), + up: newBuiltinTypedDesc( + descOpts{"patroni", "", "up", "State of Patroni service: 1 is up, 0 otherwise.", 0}, + prometheus.GaugeValue, + nil, constLabels, + settings.Filters, + ), + name: newBuiltinTypedDesc( + descOpts{"patroni", "node", "name", "Node name.", 0}, + prometheus.GaugeValue, + []string{"scope", "node_name"}, constLabels, + settings.Filters, + ), + version: newBuiltinTypedDesc( + descOpts{"patroni", "", "version", "Numeric representation of Patroni version.", 0}, + prometheus.GaugeValue, + []string{"scope", "version"}, constLabels, + settings.Filters, + ), + pgup: newBuiltinTypedDesc( + descOpts{"patroni", "postgres", "running", "Value is 1 if Postgres is running, 0 otherwise.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + pgstart: newBuiltinTypedDesc( + descOpts{"patroni", "postmaster", "start_time", "Epoch seconds since Postgres started.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + roleMaster: newBuiltinTypedDesc( + descOpts{"patroni", "", "master", "Value is 1 if this node is the leader, 0 otherwise.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + roleStandbyLeader: newBuiltinTypedDesc( + descOpts{"patroni", "", "standby_leader", "Value is 1 if this node is the standby_leader, 0 otherwise.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + roleReplica: newBuiltinTypedDesc( + descOpts{"patroni", "", "replica", "Value is 1 if this node is a replica, 0 otherwise.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + xlogLoc: newBuiltinTypedDesc( + descOpts{"patroni", "xlog", "location", "Current location of the Postgres transaction log, 0 if this node is a replica.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + xlogRecvLoc: newBuiltinTypedDesc( + descOpts{"patroni", "xlog", "received_location", "Current location of the received Postgres transaction log, 0 if this node is the leader.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + xlogReplLoc: newBuiltinTypedDesc( + descOpts{"patroni", "xlog", "replayed_location", "Current location of the replayed Postgres transaction log, 0 if this node is the leader.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + xlogReplTs: newBuiltinTypedDesc( + descOpts{"patroni", "xlog", "replayed_timestamp", "Current timestamp of the replayed Postgres transaction log, 0 if null.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + xlogPaused: newBuiltinTypedDesc( + descOpts{"patroni", "xlog", "paused", "Value is 1 if the replaying of Postgres transaction log is paused, 0 otherwise.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + pgversion: newBuiltinTypedDesc( + descOpts{"patroni", "postgres", "server_version", "Version of Postgres (if running), 0 otherwise.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + unlocked: newBuiltinTypedDesc( + descOpts{"patroni", "cluster", "unlocked", "Value is 1 if the cluster is unlocked, 0 if locked.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + timeline: newBuiltinTypedDesc( + descOpts{"patroni", "postgres", "timeline", "Postgres timeline of this node (if running), 0 otherwise.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + replicationState: newBuiltinTypedDesc( + descOpts{"patroni", "postgres", "streaming", "Value is 1 if Postgres is streaming, 0 otherwise.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + dcslastseen: newBuiltinTypedDesc( + descOpts{"patroni", "", "dcs_last_seen", "Epoch timestamp when DCS was last contacted successfully by Patroni.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + changetime: newBuiltinTypedDesc( + descOpts{"patroni", "last_timeline", "change_seconds", "Epoch seconds since latest timeline switched.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + pendingRestart: newBuiltinTypedDesc( + descOpts{"patroni", "", "pending_restart", "Value is 1 if the node needs a restart, 0 otherwise.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + pause: newBuiltinTypedDesc( + descOpts{"patroni", "", "is_paused", "Value is 1 if auto failover is disabled, 0 otherwise.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + inArchiveRecovery: newBuiltinTypedDesc( + descOpts{"patroni", "postgres", "in_archive_recovery", "Value is 1 if Postgres is replicating from archive, 0 otherwise.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + failsafeMode: newBuiltinTypedDesc( + descOpts{"patroni", "", "failsafe_mode_is_active", "Value is 1 if failsafe mode is active, 0 if inactive.", 0}, + prometheus.CounterValue, + varLabels, constLabels, + settings.Filters, + ), + loopWait: newBuiltinTypedDesc( + descOpts{"patroni", "", "loop_wait", "Current loop_wait setting of the Patroni configuration.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + maximumLagOnFailover: newBuiltinTypedDesc( + descOpts{"patroni", "", "maximum_lag_on_failover", "Current maximum_lag_on_failover setting of the Patroni configuration.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + retryTimeout: newBuiltinTypedDesc( + descOpts{"patroni", "", "retry_timeout", "Current retry_timeout setting of the Patroni configuration.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + ttl: newBuiltinTypedDesc( + descOpts{"patroni", "", "ttl", "Current ttl setting of the Patroni configuration.", 0}, + prometheus.GaugeValue, + varLabels, constLabels, + settings.Filters, + ), + }, nil +} + +func (c *patroniCommonCollector) Update(config Config, ch chan<- prometheus.Metric) error { + if strings.HasPrefix(config.BaseURL, "https://") { + c.client.EnableTLSInsecure() + } + + // Check liveness. + err := requestApiLiveness(c.client, config.BaseURL) + if err != nil { + ch <- c.up.newConstMetric(0) + return err + } + + ch <- c.up.newConstMetric(1) + + // Request general info. + respInfo, err := requestApiPatroni(c.client, config.BaseURL) + if err != nil { + return err + } + + info, err := parsePatroniResponse(respInfo) + if err != nil { + return err + } + + ch <- c.name.newConstMetric(0, info.scope, info.name) + ch <- c.version.newConstMetric(info.version, info.scope, info.versionStr) + ch <- c.pgup.newConstMetric(info.running, info.scope) + ch <- c.pgstart.newConstMetric(info.startTime, info.scope) + + ch <- c.roleMaster.newConstMetric(info.master, info.scope) + ch <- c.roleStandbyLeader.newConstMetric(info.standbyLeader, info.scope) + ch <- c.roleReplica.newConstMetric(info.replica, info.scope) + + ch <- c.xlogLoc.newConstMetric(info.xlogLoc, info.scope) + ch <- c.xlogRecvLoc.newConstMetric(info.xlogRecvLoc, info.scope) + ch <- c.xlogReplLoc.newConstMetric(info.xlogReplLoc, info.scope) + ch <- c.xlogReplTs.newConstMetric(info.xlogReplTs, info.scope) + ch <- c.xlogPaused.newConstMetric(info.xlogPaused, info.scope) + + ch <- c.pgversion.newConstMetric(info.pgversion, info.scope) + ch <- c.unlocked.newConstMetric(info.unlocked, info.scope) + ch <- c.timeline.newConstMetric(info.timeline, info.scope) + ch <- c.dcslastseen.newConstMetric(info.dcslastseen, info.scope) + ch <- c.replicationState.newConstMetric(info.replicationState, info.scope) + ch <- c.pendingRestart.newConstMetric(info.pendingRestart, info.scope) + ch <- c.pause.newConstMetric(info.pause, info.scope) + ch <- c.inArchiveRecovery.newConstMetric(info.inArchiveRecovery, info.scope) + + // Request and parse config. + respConfig, err := requestApiPatroniConfig(c.client, config.BaseURL) + if err != nil { + return err + } + + patroniConfig, err := parsePatroniConfigResponse(respConfig) + if err == nil { + ch <- c.failsafeMode.newConstMetric(patroniConfig.failsafeMode, info.scope) + ch <- c.loopWait.newConstMetric(patroniConfig.loopWait, info.scope) + ch <- c.maximumLagOnFailover.newConstMetric(patroniConfig.maximumLagOnFailover, info.scope) + ch <- c.retryTimeout.newConstMetric(patroniConfig.retryTimeout, info.scope) + ch <- c.ttl.newConstMetric(patroniConfig.ttl, info.scope) + } + + // Request and parse history. + respHist, err := requestApiHistory(c.client, config.BaseURL) + if err != nil { + return err + } + + history, err := parseHistoryResponse(respHist) + if err == nil { + ch <- c.changetime.newConstMetric(history.lastTimelineChangeUnix, info.scope) + } + + return nil +} + +// requestApiLiveness requests to /liveness endpoint of API and returns error if failed. +func requestApiLiveness(c *http.Client, baseurl string) error { + _, err := c.Get(baseurl + "/liveness") + if err != nil { + return err + } + + return err +} + +// patroniInfo implements 'patroni' object of API response. +type patroni struct { + Version string `json:"version"` + Scope string `json:"scope"` + Name string `json:"name"` +} + +// patroniXlogInfo implements 'xlog' object of API response. +type patroniXlogInfo struct { + Location int64 `json:"location"` // master only + ReceivedLocation int64 `json:"received_location"` // standby only + ReplayedLocation int64 `json:"replayed_location"` // standby only + ReplayedTimestamp string `json:"replayed_timestamp"` // standby only + Paused bool `json:"paused"` // standby only +} + +// apiPatroniResponse implements API response returned by '/patroni' endpoint. +type apiPatroniResponse struct { + State string `json:"state"` + Unlocked bool `json:"cluster_unlocked"` + Timeline int `json:"timeline"` + PmStartTime string `json:"postmaster_start_time"` + ServerVersion int `json:"server_version"` + Patroni patroni `json:"patroni"` + Role string `json:"role"` + Xlog patroniXlogInfo `json:"xlog"` + DcsLastSeen int `json:"dcs_last_seen"` + ReplicationState string `json:"replication_state"` + PendingRestart bool `json:"pending_restart"` + Pause bool `json:"pause"` +} + +// patroniInfo implements metrics values extracted from the response of '/patroni' endpoint. +type patroniInfo struct { + name string + scope string + version float64 + versionStr string + running float64 + startTime float64 + master float64 + standbyLeader float64 + replica float64 + xlogLoc float64 + xlogRecvLoc float64 + xlogReplLoc float64 + xlogReplTs float64 + xlogPaused float64 + pgversion float64 + unlocked float64 + timeline float64 + dcslastseen float64 + replicationState float64 + pendingRestart float64 + pause float64 + inArchiveRecovery float64 +} + +// apiPatroniResponse implements API response returned by '/config' endpoint. +type apiPatroniConfigResponse struct { + FailSafeMode bool `json:"failsafe_mode"` + LoopWait int `json:"loop_wait"` + MaxLagOnFailover int `json:"maximum_lag_on_failover"` + RetryTimeout int `json:"retry_timeout"` + Ttl int `json:"ttl"` +} + +// patroniConfigInfo implements metrics values extracted from the response of '/config' endpoint. +type patroniConfigInfo struct { + failsafeMode float64 + loopWait float64 + maximumLagOnFailover float64 + retryTimeout float64 + ttl float64 +} + +// requestPatroniConfigInfo requests to /config endpoint of API and returns parsed response. +func requestApiPatroniConfig(c *http.Client, baseurl string) (*apiPatroniConfigResponse, error) { + resp, err := c.Get(baseurl + "/config") + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad response: %s", resp.Status) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + _ = resp.Body.Close() + + r := &apiPatroniConfigResponse{} + + err = json.Unmarshal(content, r) + if err != nil { + return nil, err + } + + return r, nil +} + +// parsePatroniConfigResponse parses info from API response and returns info object. +func parsePatroniConfigResponse(resp *apiPatroniConfigResponse) (*patroniConfigInfo, error) { + var failsafeMode float64 + if resp.FailSafeMode { + failsafeMode = 1 + } + + return &patroniConfigInfo{ + failsafeMode: failsafeMode, + loopWait: float64(resp.LoopWait), + maximumLagOnFailover: float64(resp.MaxLagOnFailover), + retryTimeout: float64(resp.RetryTimeout), + ttl: float64(resp.Ttl), + }, nil +} + +// requestPatroniInfo requests to /patroni endpoint of API and returns parsed response. +func requestApiPatroni(c *http.Client, baseurl string) (*apiPatroniResponse, error) { + resp, err := c.Get(baseurl + "/patroni") + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad response: %s", resp.Status) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + _ = resp.Body.Close() + + r := &apiPatroniResponse{} + + err = json.Unmarshal(content, r) + if err != nil { + return nil, err + } + + return r, nil +} + +// parsePatroniResponse parses info from API response and returns info object. +func parsePatroniResponse(resp *apiPatroniResponse) (*patroniInfo, error) { + version, err := semverStringToInt(resp.Patroni.Version) + if err != nil { + return nil, fmt.Errorf("parse version string '%s' failed: %s", resp.Patroni.Version, err) + } + + var running float64 + if resp.State == "running" { + running = 1 + } + + t1, err := time.Parse("2006-01-02 15:04:05.999999Z07:00", resp.PmStartTime) + if err != nil { + return nil, fmt.Errorf("parse patroni postmaster_start_time string '%s' failed: %s", resp.PmStartTime, err) + } + + var master, stdleader, replica float64 + switch resp.Role { + case "master": + master, stdleader, replica = 1, 0, 0 + case "standby_leader": + master, stdleader, replica = 0, 1, 0 + case "replica": + master, stdleader, replica = 0, 0, 1 + } + + var xlogReplTimeSecs float64 + if resp.Xlog.ReplayedTimestamp != "null" && resp.Xlog.ReplayedTimestamp != "" { + t, err := time.Parse("2006-01-02 15:04:05.999999Z07:00", resp.Xlog.ReplayedTimestamp) + if err != nil { + return nil, fmt.Errorf("parse patroni xlog.replayed_timestamp string '%s' failed: %s", resp.PmStartTime, err) + } + xlogReplTimeSecs = float64(t.UnixNano()) / 1000000000 + } + + var xlogPaused float64 + if resp.Xlog.Paused { + xlogPaused = 1 + } + + var unlocked float64 + if resp.Unlocked { + unlocked = 1 + } + + var replicationState float64 + if resp.ReplicationState == "streaming" { + replicationState = 1 + } + + var pendingRestart float64 + if resp.PendingRestart { + pendingRestart = 1 + } + + var pause float64 + if resp.Pause { + pause = 1 + } + + var inArchiveRecovery float64 + if resp.ReplicationState == "in archive recovery" { + inArchiveRecovery = 1 + } + + return &patroniInfo{ + name: resp.Patroni.Name, + scope: resp.Patroni.Scope, + version: float64(version), + versionStr: resp.Patroni.Version, + running: running, + startTime: float64(t1.UnixNano()) / 1000000000, + master: master, + standbyLeader: stdleader, + replica: replica, + xlogLoc: float64(resp.Xlog.Location), + xlogRecvLoc: float64(resp.Xlog.ReceivedLocation), + xlogReplLoc: float64(resp.Xlog.ReplayedLocation), + xlogReplTs: xlogReplTimeSecs, + xlogPaused: xlogPaused, + pgversion: float64(resp.ServerVersion), + unlocked: unlocked, + timeline: float64(resp.Timeline), + dcslastseen: float64(resp.DcsLastSeen), + replicationState: replicationState, + pendingRestart: pendingRestart, + pause: pause, + inArchiveRecovery: inArchiveRecovery, + }, nil +} + +// patroniHistoryUnit defines single item of Patroni history in the API response. +// Basically this is array like [ int, int, string, string ]. +type patroniHistoryUnit []interface{} + +// apiHistoryResponse defines the API response with complete history. +type apiHistoryResponse []patroniHistoryUnit + +// patroniHistory describes details (UNIX timestamp and reason) of the latest timeline change. +type patroniHistory struct { + lastTimelineChangeReason string + lastTimelineChangeUnix float64 +} + +// requestApiHistory requests /history endpoint of API and returns parsed response. +func requestApiHistory(c *http.Client, baseurl string) (apiHistoryResponse, error) { + resp, err := c.Get(baseurl + "/history") + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad response: %s", resp.Status) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + _ = resp.Body.Close() + + r := apiHistoryResponse{} + + err = json.Unmarshal(content, &r) + if err != nil { + return nil, err + } + + return r, nil +} + +// parseHistoryResponse parses history and returns info about latest event in the history. +func parseHistoryResponse(resp apiHistoryResponse) (patroniHistory, error) { + if len(resp) == 0 { + return patroniHistory{}, nil + } + + unit := resp[len(resp)-1] + + if len(unit) < 4 { + return patroniHistory{}, fmt.Errorf("history unit invalid len") + } + + // Check value types. + reason, ok := unit[2].(string) + if !ok { + return patroniHistory{}, fmt.Errorf("history unit invalid message value type") + } + + timestamp, ok := unit[3].(string) + if !ok { + return patroniHistory{}, fmt.Errorf("history unit invalid timestamp value type") + } + + t, err := time.Parse("2006-01-02T15:04:05.999999Z07:00", timestamp) + if err != nil { + return patroniHistory{}, err + } + + return patroniHistory{ + lastTimelineChangeReason: reason, + lastTimelineChangeUnix: float64(t.UnixNano()) / 1000000000, + }, nil +} diff --git a/internal/collector/patroni_common_test.go b/internal/collector/patroni_common_test.go new file mode 100644 index 0000000..8e179f1 --- /dev/null +++ b/internal/collector/patroni_common_test.go @@ -0,0 +1,201 @@ +package collector + +import ( + "fmt" + "testing" + + "github.com/cherts/pgscv/internal/http" + "github.com/stretchr/testify/assert" +) + +func Test_requestApiLiveness(t *testing.T) { + ts := http.TestServer(t, http.StatusOK, "") + defer ts.Close() + + c := http.NewClient(http.ClientConfig{}) + + err := requestApiLiveness(c, ts.URL) + assert.NoError(t, err) + + // Test errors + err = requestApiLiveness(c, "http://[") + assert.Error(t, err) + fmt.Println(err) +} + +func Test_requestApiPatroni(t *testing.T) { + testcases := []struct { + name string + response string + want *apiPatroniResponse + }{ + { + name: "leader", + response: `{"database_system_identifier": "6978740738459312158", "xlog": {"location": 67111576}, "timeline": 1, "server_version": 100016, "replication": [{"sync_state": "async", "usename": "replicator", "sync_priority": 0, "client_addr": "172.21.0.2", "application_name": "patroni1", "state": "streaming"}, {"sync_state": "async", "usename": "replicator", "sync_priority": 0, "client_addr": "172.21.0.6", "application_name": "patroni2", "state": "streaming"}], "postmaster_start_time": "2021-06-28 07:18:44.565317+00:00", "patroni": {"version": "2.0.2", "scope": "demo"}, "state": "running", "cluster_unlocked": false, "role": "master"}`, + want: &apiPatroniResponse{ + State: "running", + Unlocked: false, + Timeline: 1, + PmStartTime: "2021-06-28 07:18:44.565317+00:00", + ServerVersion: 100016, + Patroni: patroni{Version: "2.0.2", Scope: "demo"}, + Role: "master", + Xlog: patroniXlogInfo{Location: 67111576, ReceivedLocation: 0, ReplayedLocation: 0, ReplayedTimestamp: "", Paused: false}, + }, + }, + { + name: "replica", + response: `{"patroni": {"scope": "demo", "version": "2.1.0"}, "database_system_identifier": "6981836146590883870", "postmaster_start_time": "2021-07-06 15:31:03.056298+00:00", "cluster_unlocked": false, "timeline": 1, "state": "running", "server_version": 100016, "role": "replica", "xlog": {"received_location": 67211944, "replayed_timestamp": "2021-07-09 05:30:41.207477+00:00", "replayed_location": 67211944, "paused": false}}`, + want: &apiPatroniResponse{ + State: "running", + Unlocked: false, + Timeline: 1, + PmStartTime: "2021-07-06 15:31:03.056298+00:00", + ServerVersion: 100016, + Patroni: patroni{Version: "2.1.0", Scope: "demo"}, + Role: "replica", + Xlog: patroniXlogInfo{Location: 0, ReceivedLocation: 67211944, ReplayedLocation: 67211944, ReplayedTimestamp: "2021-07-09 05:30:41.207477+00:00", Paused: false}, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ts := http.TestServer(t, http.StatusOK, tc.response) + defer ts.Close() + + c := http.NewClient(http.ClientConfig{}) + + got, err := requestApiPatroni(c, ts.URL) + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + + // Test errors + t.Run("invalid url", func(t *testing.T) { + c := http.NewClient(http.ClientConfig{}) + _, err := requestApiPatroni(c, "http://127.0.0.1:30080/invalid") + assert.Error(t, err) + }) +} + +func Test_parsePatroniResponse(t *testing.T) { + testcases := []struct { + valid bool + resp *apiPatroniResponse + want *patroniInfo + }{ + { + valid: true, + resp: &apiPatroniResponse{ + State: "running", + Unlocked: false, + Timeline: 1, + PmStartTime: "2021-06-28 07:18:44.565317+00:00", + ServerVersion: 100016, + Patroni: patroni{Version: "2.0.2", Scope: "demo"}, + Role: "master", + Xlog: patroniXlogInfo{Location: 67111576, ReceivedLocation: 0, ReplayedLocation: 0, ReplayedTimestamp: "", Paused: false}, + }, + want: &patroniInfo{ + scope: "demo", version: 20002, versionStr: "2.0.2", running: 1, startTime: 1624864724.5653172, + master: 1, standbyLeader: 0, replica: 0, + xlogLoc: 67111576, xlogRecvLoc: 0, xlogReplLoc: 0, xlogReplTs: 0, xlogPaused: 0, + pgversion: 100016, unlocked: 0, timeline: 1, + }, + }, + { + valid: true, + resp: &apiPatroniResponse{ + State: "running", + Unlocked: true, + Timeline: 1, + PmStartTime: "2021-07-06 15:31:03.056298+00:00", + ServerVersion: 100016, + Patroni: patroni{Version: "2.1.0", Scope: "demo"}, + Role: "replica", + Xlog: patroniXlogInfo{Location: 0, ReceivedLocation: 67211944, ReplayedLocation: 67211944, ReplayedTimestamp: "2021-07-09 05:30:41.207477+00:00", Paused: true}, + }, + want: &patroniInfo{ + scope: "demo", version: 20100, versionStr: "2.1.0", running: 1, startTime: 1625585463.056298, + master: 0, standbyLeader: 0, replica: 1, + xlogLoc: 0, xlogRecvLoc: 67211944, xlogReplLoc: 67211944, xlogReplTs: 1625808641.207477, xlogPaused: 1, + pgversion: 100016, unlocked: 1, timeline: 1, + }, + }, + {valid: false, resp: &apiPatroniResponse{Patroni: patroni{Version: "invalid"}}}, + {valid: false, resp: &apiPatroniResponse{PmStartTime: "invalid", Patroni: patroni{Version: "1.2.0"}}}, + { + valid: false, + resp: &apiPatroniResponse{ + Patroni: patroni{Version: "1.2.0"}, + PmStartTime: "2021-07-06 15:31:03.056298+00:00", + Xlog: patroniXlogInfo{ReplayedTimestamp: "invalid"}, + }, + }, + } + + for _, tc := range testcases { + if tc.valid { + got, err := parsePatroniResponse(tc.resp) + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + } else { + _, err := parsePatroniResponse(tc.resp) + assert.Error(t, err) + } + } +} + +func Test_requestApiHistory(t *testing.T) { + ts := http.TestServer(t, http.StatusOK, + `[[1, 1234, "no recovery target specified", "2021-06-30T00:00:00.123456+00:00"],[2, 2345, "no recovery target specified", "2021-06-30T10:00:00+00:00"]]`, + ) + defer ts.Close() + + c := http.NewClient(http.ClientConfig{}) + + got, err := requestApiHistory(c, ts.URL) + assert.NoError(t, err) + assert.EqualValues(t, apiHistoryResponse{ + {float64(1), float64(1234), "no recovery target specified", "2021-06-30T00:00:00.123456+00:00"}, + {float64(2), float64(2345), "no recovery target specified", "2021-06-30T10:00:00+00:00"}, + }, got) + + // Test errors + _, err = requestApiHistory(c, "http://127.0.0.1:30080/invalid") + assert.Error(t, err) +} + +func Test_parseHistoryResponse(t *testing.T) { + testcases := []struct { + valid bool + resp apiHistoryResponse + want patroniHistory + }{ + {valid: true, resp: apiHistoryResponse{ + {int64(1), int64(12345678), "no recovery target specified", "2021-06-30T00:00:00+00:00"}, + {int64(2), int64(23456789), "no recovery target specified", "2021-06-30T10:00:00.123456+00:00"}, + }, want: patroniHistory{ + lastTimelineChangeUnix: 1625047200.123456, + lastTimelineChangeReason: "no recovery target specified", + }}, + {valid: true, resp: apiHistoryResponse{}}, + // invalid test data + {valid: false, resp: apiHistoryResponse{{int64(1), int64(1)}}}, + {valid: false, resp: apiHistoryResponse{{int64(1), int64(1), int64(1), "example"}}}, + {valid: false, resp: apiHistoryResponse{{int64(1), int64(1), "example", int64(1)}}}, + {valid: false, resp: apiHistoryResponse{{int64(1), int64(1), "example", "invalid"}}}, + } + + for _, tc := range testcases { + got, err := parseHistoryResponse(tc.resp) + if tc.valid { + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + } else { + assert.Error(t, err) + } + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 0795ec1..e04349d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -2,9 +2,10 @@ package model import ( "database/sql" - "github.com/jackc/pgproto3/v2" - "github.com/cherts/pgscv/internal/filter" "regexp" + + "github.com/cherts/pgscv/internal/filter" + "github.com/jackc/pgproto3/v2" ) const ( @@ -14,6 +15,8 @@ const ( ServiceTypePostgresql = "postgres" // ServiceTypePgbouncer defines label string for Pgbouncer services. ServiceTypePgbouncer = "pgbouncer" + // ServiceTypePatroni defines label string for Patroni services. + ServiceTypePatroni = "patroni" ) // PGResult is the iterable store that contains query result (data and metadata) returned from Postgres diff --git a/internal/pgscv/config.go b/internal/pgscv/config.go index b9445eb..9f70df9 100644 --- a/internal/pgscv/config.go +++ b/internal/pgscv/config.go @@ -278,6 +278,16 @@ func newConfigFromEnv() (*Config, error) { config.ServicesConnsSettings[id] = cs } + // Parse PATRONI_URL. + if strings.HasPrefix(key, "PATRONI_URL") { + id, cs, err := service.ParsePatroniURLEnv(key, value) + if err != nil { + return nil, err + } + + config.ServicesConnsSettings[id] = cs + } + switch key { case "PGSCV_LISTEN_ADDRESS": config.ListenAddress = value diff --git a/internal/pgscv/config_test.go b/internal/pgscv/config_test.go index 24309c3..69bd149 100644 --- a/internal/pgscv/config_test.go +++ b/internal/pgscv/config_test.go @@ -1,13 +1,14 @@ package pgscv import ( + "os" + "testing" + "github.com/cherts/pgscv/internal/filter" "github.com/cherts/pgscv/internal/http" "github.com/cherts/pgscv/internal/model" "github.com/cherts/pgscv/internal/service" "github.com/stretchr/testify/assert" - "os" - "testing" ) func TestNewConfig(t *testing.T) { @@ -397,6 +398,8 @@ func Test_newConfigFromEnv(t *testing.T) { "POSTGRES_DSN_EXAMPLE1": "example_dsn", "PGBOUNCER_DSN": "example_dsn", "PGBOUNCER_DSN_EXAMPLE2": "example_dsn", + "PATRONI_URL": "example_url", + "PATRONI_URL_EXAMPLE3": "example_url", "PGSCV_AUTH_USERNAME": "user", "PGSCV_AUTH_PASSWORD": "pass", "PGSCV_AUTH_KEYFILE": "keyfile.key", @@ -412,6 +415,8 @@ func Test_newConfigFromEnv(t *testing.T) { "EXAMPLE1": {ServiceType: model.ServiceTypePostgresql, Conninfo: "example_dsn"}, "pgbouncer": {ServiceType: model.ServiceTypePgbouncer, Conninfo: "example_dsn"}, "EXAMPLE2": {ServiceType: model.ServiceTypePgbouncer, Conninfo: "example_dsn"}, + "patroni": {ServiceType: model.ServiceTypePatroni, BaseURL: "example_url"}, + "EXAMPLE3": {ServiceType: model.ServiceTypePatroni, BaseURL: "example_url"}, }, AuthConfig: http.AuthConfig{ Username: "user", @@ -430,6 +435,10 @@ func Test_newConfigFromEnv(t *testing.T) { valid: false, // Invalid pgbouncer DSN key envvars: map[string]string{"PGBOUNCER_DSN_": "example_dsn"}, }, + { + valid: false, // Invalid patroni URL key + envvars: map[string]string{"PATRONI_URL_": "example_dsn"}, + }, } for _, tc := range testcases { diff --git a/internal/service/config.go b/internal/service/config.go index a8139bc..131bbc9 100644 --- a/internal/service/config.go +++ b/internal/service/config.go @@ -2,8 +2,9 @@ package service import ( "fmt" - "github.com/cherts/pgscv/internal/model" "strings" + + "github.com/cherts/pgscv/internal/model" ) // ConnSetting describes connection settings required for connecting to particular service. @@ -13,6 +14,8 @@ type ConnSetting struct { ServiceType string `yaml:"service_type"` // Conninfo is the connection string in service-specific format. Conninfo string `yaml:"conninfo"` + // BaseURL is the base URL for connecting to HTTP services. + BaseURL string `yaml:"baseurl"` } // ConnsSettings defines a set of all connection settings of exact services. @@ -60,3 +63,39 @@ func parseDSNEnv(prefix, key, value string) (string, ConnSetting, error) { return id, ConnSetting{ServiceType: stype, Conninfo: value}, nil } + +// ParsePatroniURLEnv is a public wrapper over parseURLEnv. +func ParsePatroniURLEnv(key, value string) (string, ConnSetting, error) { + return parseURLEnv("PATRONI_URL", key, value) +} + +// parseURLEnv returns valid ConnSetting accordingly to passed prefix and environment key/value. +func parseURLEnv(prefix, key, value string) (string, ConnSetting, error) { + var stype string + switch prefix { + case "PATRONI_URL": + stype = model.ServiceTypePatroni + default: + return "", ConnSetting{}, fmt.Errorf("invalid prefix %s", prefix) + } + + // Prefix must be the part of key. + if !strings.HasPrefix(key, prefix) { + return "", ConnSetting{}, fmt.Errorf("invalid key %s", key) + } + + // Nothing to parse if prefix and key are the same, just use the type as service ID. + if key == prefix { + return stype, ConnSetting{ServiceType: stype, BaseURL: value}, nil + } + + // If prefix and key are not the same, strip prefix from key and use the rest as service ID. + // Use double Trim to avoid leaking 'prefix' string into ID value (see unit tests for examples). + id := strings.TrimPrefix(strings.TrimPrefix(key, prefix), "_") + + if id == "" { + return "", ConnSetting{}, fmt.Errorf("invalid value '%s' is in %s", value, key) + } + + return id, ConnSetting{ServiceType: stype, BaseURL: value}, nil +} diff --git a/internal/service/config_test.go b/internal/service/config_test.go index 9ef9b1d..4bebfa0 100644 --- a/internal/service/config_test.go +++ b/internal/service/config_test.go @@ -1,8 +1,9 @@ package service import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func Test_ParsePostgresDSNEnv(t *testing.T) { @@ -62,3 +63,32 @@ func Test_parseDSNEnv(t *testing.T) { } } } + +func Test_parseURLEnv(t *testing.T) { + testcases := []struct { + valid bool + prefix string + key string + wantId string + wantType string + }{ + {valid: true, prefix: "PATRONI_URL", key: "PATRONI_URL", wantId: "patroni", wantType: "patroni"}, + {valid: true, prefix: "PATRONI_URL", key: "PATRONI_URL1", wantId: "1", wantType: "patroni"}, + {valid: true, prefix: "PATRONI_URL", key: "PATRONI_URL_PATRONI_123", wantId: "PATRONI_123", wantType: "patroni"}, + // + {valid: false, prefix: "PATRONI_URL", key: "PATRONI_URL_"}, + {valid: false, prefix: "PATRONI_URL", key: "INVALID"}, + {valid: false, prefix: "INVALID", key: "INVALID"}, + } + + for _, tc := range testcases { + gotID, gotCS, err := parseURLEnv(tc.prefix, tc.key, "baseurl") + if tc.valid { + assert.NoError(t, err) + assert.Equal(t, tc.wantId, gotID) + assert.Equal(t, ConnSetting{ServiceType: tc.wantType, BaseURL: "baseurl"}, gotCS) + } else { + assert.Error(t, err) + } + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 6ab1bbb..5846e8c 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1,14 +1,19 @@ package service import ( - "github.com/jackc/pgx/v4" + "fmt" + "regexp" + "strings" + "sync" + "time" + "github.com/cherts/pgscv/internal/collector" + "github.com/cherts/pgscv/internal/http" "github.com/cherts/pgscv/internal/log" "github.com/cherts/pgscv/internal/model" "github.com/cherts/pgscv/internal/store" + "github.com/jackc/pgx/v4" "github.com/prometheus/client_golang/prometheus" - "regexp" - "sync" ) // Service struct describes service - the target from which should be collected metrics. @@ -123,22 +128,36 @@ func (repo *Repository) addServicesFromConfig(config Config) { // Check all passed connection settings and try to connect using them. In case of success, create a 'Service' instance // in the repo. for k, cs := range config.ConnsSettings { - // each ConnSetting struct is used for - // 1) doing connection; - // 2) getting connection properties to define service-specific parameters. - pgconfig, err := pgx.ParseConfig(cs.Conninfo) - if err != nil { - log.Warnf("%s: %s, skip", cs.Conninfo, err) - continue - } + var msg string + + if cs.ServiceType == model.ServiceTypePatroni { + err := attemptRequest(cs.BaseURL) + if err != nil { + log.Warnf("%s: %s, skip", cs.BaseURL, err) + continue + } + + msg = fmt.Sprintf("service [%s] available through: %s", k, cs.BaseURL) + } else { + // each ConnSetting struct is used for + // 1) doing connection; + // 2) getting connection properties to define service-specific parameters. + pgconfig, err := pgx.ParseConfig(cs.Conninfo) + if err != nil { + log.Warnf("%s: %s, skip", cs.Conninfo, err) + continue + } - // Check connection using created *ConnConfig, go next if connection failed. - db, err := store.NewWithConfig(pgconfig) - if err != nil { - log.Warnf("%s: %s, skip", cs.Conninfo, err) - continue + // Check connection using created *ConnConfig, go next if connection failed. + db, err := store.NewWithConfig(pgconfig) + if err != nil { + log.Warnf("%s: %s, skip", cs.Conninfo, err) + continue + } + db.Close() + + msg = fmt.Sprintf("service [%s] available through: %s@%s:%d/%s", k, pgconfig.User, pgconfig.Host, pgconfig.Port, pgconfig.Database) } - db.Close() // Connection was successful, create 'Service' struct with service-related properties and add it to service repo. s := Service{ @@ -151,7 +170,8 @@ func (repo *Repository) addServicesFromConfig(config Config) { repo.addService(s) log.Infof("registered new service [%s]", s.ServiceID) - log.Debugf("service [%s] available through: %s@%s:%d/%s", s.ServiceID, pgconfig.User, pgconfig.Host, pgconfig.Port, pgconfig.Database) + + log.Debugln(msg) } } @@ -178,6 +198,9 @@ func (repo *Repository) setupServices(config Config) error { factories.RegisterPostgresCollectors(config.DisabledCollectors) case model.ServiceTypePgbouncer: factories.RegisterPgbouncerCollectors(config.DisabledCollectors) + case model.ServiceTypePatroni: + factories.RegisterPatroniCollectors(config.DisabledCollectors) + collectorConfig.BaseURL = service.ConnSettings.BaseURL default: continue } @@ -199,3 +222,26 @@ func (repo *Repository) setupServices(config Config) error { return nil } + +// attemptRequest tries to make a real HTTP request using passed URL string. +func attemptRequest(baseurl string) error { + url := baseurl + "/health" + log.Debugln("making test http request: ", url) + + var client = http.NewClient(http.ClientConfig{Timeout: time.Second}) + + if strings.HasPrefix(url, "https://") { + client.EnableTLSInsecure() + } + + resp, err := client.Get(url) // #nosec G107 + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad response: %s", resp.Status) + } + + return nil +} diff --git a/internal/service/testdata/patroni/patroni.golden.yml b/internal/service/testdata/patroni/patroni.golden.yml deleted file mode 100644 index 8044c2e..0000000 --- a/internal/service/testdata/patroni/patroni.golden.yml +++ /dev/null @@ -1,14 +0,0 @@ -scope: example -name: example0 - -restapi: - listen: 0.0.0.0:8008 - connect_address: 1.2.3.4:8008 - certfile: /etc/ssl/example.crt - authentication: - username: user - password: secret - -postgresql: - listen: 0.0.0.0:5432 - data_dir: /home/postgres/data