From c41b1b59a5da74326e6e0ee049c9ced22eb7c255 Mon Sep 17 00:00:00 2001 From: Lukas Schreiner Date: Sat, 13 Apr 2024 14:45:16 +0200 Subject: [PATCH] Include Nextcloud update availability information This commit introduces new option and metric about nextcloud update availabilty information. A new option was introduced called `--enable-info-update` which will append the to the Nextcloud serverinfo-URL a `&skipUpdate=false`. In response, the update information is returned and provided in a new metric called `nextcloud_system_update_available`. Fixes xperimental/nextcloud-exporter#115 --- README.md | 66 +++++++++++++++++++---------------- internal/config/config.go | 21 +++++++++-- internal/metrics/collector.go | 51 ++++++++++++++++++++++----- main.go | 4 +-- serverinfo/serverinfo.go | 11 ++++++ serverinfo/url.go | 6 ++-- serverinfo/url_test.go | 30 +++++++++++++--- 7 files changed, 137 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index e12ce0a..2a524ab 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Usage of nextcloud-exporter: --auth-token string Authentication token. Can replace username and password when using Nextcloud 22 or newer. -c, --config-file string Path to YAML configuration file. --enable-info-apps Enable gathering of apps-related metrics. + --enable-info-update Enable gathering of system update-related metrics. --login Use interactive login to create app password. -p, --password string Password for connecting to Nextcloud. -s, --server string URL to Nextcloud server. @@ -115,16 +116,17 @@ There are three methods of configuring the nextcloud-exporter (higher methods ta All settings can also be specified through environment variables: -| Environment variable | Flag equivalent | -|----------------------------:|:-------------------| -| `NEXTCLOUD_SERVER` | --server | -| `NEXTCLOUD_USERNAME` | --username | -| `NEXTCLOUD_PASSWORD` | --password | -| `NEXTCLOUD_AUTH_TOKEN` | --auth-token | -| `NEXTCLOUD_LISTEN_ADDRESS` | --addr | -| `NEXTCLOUD_TIMEOUT` | --timeout | -| `NEXTCLOUD_TLS_SKIP_VERIFY` | --tls-skip-verify | -| `NEXTCLOUD_INFO_APPS` | --enable-info-apps | +| Environment variable | Flag equivalent | +|----------------------------:|:---------------------| +| `NEXTCLOUD_SERVER` | --server | +| `NEXTCLOUD_USERNAME` | --username | +| `NEXTCLOUD_PASSWORD` | --password | +| `NEXTCLOUD_AUTH_TOKEN` | --auth-token | +| `NEXTCLOUD_LISTEN_ADDRESS` | --addr | +| `NEXTCLOUD_TIMEOUT` | --timeout | +| `NEXTCLOUD_TLS_SKIP_VERIFY` | --tls-skip-verify | +| `NEXTCLOUD_INFO_APPS` | --enable-info-apps | +| `NEXTCLOUD_INFO_UPDATE` | --enable-info-update | #### Configuration file @@ -144,6 +146,7 @@ timeout: "5s" tlsSkipVerify: false info: apps: false + update: false ``` ### Loading Credentials from Files @@ -191,24 +194,25 @@ scrape_configs: These metrics are exported by `nextcloud-exporter`: -| name | description | -|----------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| nextcloud_active_users_daily_total | Number of active users in the last 24 hours | -| nextcloud_active_users_hourly_total | Number of active users in the last hour | -| nextcloud_active_users_total | Number of active users for the last five minutes | -| nextcloud_apps_installed_total | Number of currently installed apps | -| nextcloud_apps_updates_available_total | Number of apps that have available updates | -| nextcloud_database_info | Contains meta information about the database as labels. Value is always 1. | -| nextcloud_database_size_bytes | Size of database in bytes as reported from engine | -| nextcloud_exporter_info | Contains meta information of the exporter. Value is always 1. | -| nextcloud_files_total | Number of files served by the instance | -| nextcloud_free_space_bytes | Free disk space in data directory in bytes | -| nextcloud_php_info | Contains meta information about PHP as labels. Value is always 1. | -| nextcloud_php_memory_limit_bytes | Configured PHP memory limit in bytes | -| nextcloud_php_upload_max_size_bytes | Configured maximum upload size in bytes | -| nextcloud_scrape_errors_total | Counts the number of scrape errors by this collector | -| nextcloud_shares_federated_total | Number of federated shares by direction `sent` / `received` | -| nextcloud_shares_total | Number of shares by type:
`authlink`: shared password protected links
`group`: shared groups
`link`: all shared links
`user`: shared users
`mail`: shared by mail
`room`: shared with room | -| nextcloud_system_info | Contains meta information about Nextcloud as labels. Value is always 1. | -| nextcloud_up | Indicates if the metrics could be scraped by the exporter:
`1`: successful
`0`: unsuccessful (server down, server/endpoint not reachable, invalid credentials, ...) | -| nextcloud_users_total | Number of users of the instance | +| name | description | +|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| nextcloud_active_users_daily_total | Number of active users in the last 24 hours | +| nextcloud_active_users_hourly_total | Number of active users in the last hour | +| nextcloud_active_users_total | Number of active users for the last five minutes | +| nextcloud_apps_installed_total | Number of currently installed apps | +| nextcloud_apps_updates_available_total | Number of apps that have available updates | +| nextcloud_database_info | Contains meta information about the database as labels. Value is always 1. | +| nextcloud_database_size_bytes | Size of database in bytes as reported from engine | +| nextcloud_exporter_info | Contains meta information of the exporter. Value is always 1. | +| nextcloud_files_total | Number of files served by the instance | +| nextcloud_free_space_bytes | Free disk space in data directory in bytes | +| nextcloud_php_info | Contains meta information about PHP as labels. Value is always 1. | +| nextcloud_php_memory_limit_bytes | Configured PHP memory limit in bytes | +| nextcloud_php_upload_max_size_bytes | Configured maximum upload size in bytes | +| nextcloud_scrape_errors_total | Counts the number of scrape errors by this collector | +| nextcloud_shares_federated_total | Number of federated shares by direction `sent` / `received` | +| nextcloud_shares_total | Number of shares by type:
`authlink`: shared password protected links
`group`: shared groups
`link`: all shared links
`user`: shared users
`mail`: shared by mail
`room`: shared with room | +| nextcloud_system_info | Contains meta information about Nextcloud as labels. Value is always 1. | +| nextcloud_system_update_available | Contains information whether a system update is available:
`0`: no update available
`1`: nextcloud update available
In case of 1=yes, `available_version` label contains the new version. This metric is only available if activated. | +| nextcloud_up | Indicates if the metrics could be scraped by the exporter:
`1`: successful
`0`: unsuccessful (server down, server/endpoint not reachable, invalid credentials, ...) | +| nextcloud_users_total | Number of users of the instance | diff --git a/internal/config/config.go b/internal/config/config.go index 465070d..eacd3a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ const ( envAuthToken = envPrefix + "AUTH_TOKEN" envTLSSkipVerify = envPrefix + "TLS_SKIP_VERIFY" envInfoApps = envPrefix + "INFO_APPS" + envInfoUpdate = envPrefix + "INFO_UPDATE" ) // RunMode signals what the main application should do after parsing the options. @@ -68,7 +69,8 @@ type Config struct { // InfoConfig contains configuration related to what information is read from serverinfo. type InfoConfig struct { - Apps bool `yaml:"apps"` + Apps bool `yaml:"apps"` + Update bool `yaml:"update"` } var ( @@ -170,6 +172,7 @@ func loadConfigFromFlags(args []string) (result Config, configFile string, err e flags.StringVar(&result.AuthToken, "auth-token", defaults.AuthToken, "Authentication token. Can replace username and password when using Nextcloud 22 or newer.") flags.BoolVar(&result.TLSSkipVerify, "tls-skip-verify", defaults.TLSSkipVerify, "Skip certificate verification of Nextcloud server.") flags.BoolVar(&result.Info.Apps, "enable-info-apps", defaults.Info.Apps, "Enable gathering of apps-related metrics.") + flags.BoolVar(&result.Info.Update, "enable-info-update", defaults.Info.Update, "Enable gathering of system update-related metrics.") modeLogin := flags.Bool("login", false, "Use interactive login to create app password.") modeVersion := flags.BoolP("version", "V", false, "Show version information and exit.") @@ -229,6 +232,15 @@ func loadConfigFromEnv(getEnv func(string) string) (Config, error) { infoApps = value } + infoUpdate := false + if rawValue := getEnv(envInfoUpdate); rawValue != "" { + value, err := strconv.ParseBool(rawValue) + if err != nil { + return Config{}, fmt.Errorf("can not parse value for %q: %s", envInfoUpdate, rawValue) + } + infoUpdate = value + } + result := Config{ ListenAddr: getEnv(envListenAddress), ServerURL: getEnv(envServerURL), @@ -237,7 +249,8 @@ func loadConfigFromEnv(getEnv func(string) string) (Config, error) { AuthToken: getEnv(envAuthToken), TLSSkipVerify: tlsSkipVerify, Info: InfoConfig{ - Apps: infoApps, + Apps: infoApps, + Update: infoUpdate, }, } @@ -287,6 +300,10 @@ func mergeConfig(base, override Config) Config { result.Info.Apps = override.Info.Apps } + if override.Info.Update { + result.Info.Update = override.Info.Update + } + return result } diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go index 68f3441..23f7fbd 100644 --- a/internal/metrics/collector.go +++ b/internal/metrics/collector.go @@ -22,6 +22,10 @@ var ( metricPrefix+"system_info", "Contains meta information about Nextcloud as labels. Value is always 1.", []string{"version"}, nil) + systemUpdateAvailableDesc = prometheus.NewDesc( + metricPrefix+"system_update_available", + "Contains information whether a system update is available (0 = no, 1 = yes). In case of 1=yes, available_version label contains the new version.", + []string{"available_version"}, nil) appsInstalledDesc = prometheus.NewDesc( metricPrefix+"apps_installed_total", "Number of currently installed apps", @@ -85,19 +89,21 @@ var ( ) type nextcloudCollector struct { - log logrus.FieldLogger - infoClient client.InfoClient - appsMetrics bool + log logrus.FieldLogger + infoClient client.InfoClient + appsMetrics bool + updateMetrics bool upMetric prometheus.Gauge scrapeErrorsMetric *prometheus.CounterVec } -func RegisterCollector(log logrus.FieldLogger, infoClient client.InfoClient, appsMetrics bool) error { +func RegisterCollector(log logrus.FieldLogger, infoClient client.InfoClient, appsMetrics bool, updateMetrics bool) error { c := &nextcloudCollector{ - log: log, - infoClient: infoClient, - appsMetrics: appsMetrics, + log: log, + infoClient: infoClient, + appsMetrics: appsMetrics, + updateMetrics: updateMetrics, upMetric: prometheus.NewGauge(prometheus.GaugeOpts{ Name: metricPrefix + "up", @@ -151,14 +157,20 @@ func (c *nextcloudCollector) collectNextcloud(ch chan<- prometheus.Metric) error return err } - return readMetrics(ch, status, c.appsMetrics) + return readMetrics(ch, status, c.appsMetrics, c.updateMetrics) } -func readMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo, appsMetrics bool) error { +func readMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo, appsMetrics bool, updateMetrics bool) error { if err := collectSimpleMetrics(ch, status, appsMetrics); err != nil { return err } + if updateMetrics { + if err := collectUpdate(ch, status); err != nil { + return err + } + } + if err := collectShares(ch, status.Data.Nextcloud.Shares); err != nil { return err } @@ -261,6 +273,27 @@ func collectSimpleMetrics(ch chan<- prometheus.Metric, status *serverinfo.Server return nil } +func collectUpdate(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error { + var updateAvailable float64 + updateInfo := []string{} + // Fix small bug: its indicated as "true" even if there is no real update available. + if status.Data.Nextcloud.System.Update.Available && status.Data.Nextcloud.System.Version != status.Data.Nextcloud.System.Update.AvailableVersion { + updateAvailable = 1 + updateInfo = append(updateInfo, status.Data.Nextcloud.System.Update.AvailableVersion) + } else { + updateAvailable = 0 + updateInfo = append(updateInfo, "") + } + + metric, err := prometheus.NewConstMetric(systemUpdateAvailableDesc, prometheus.GaugeValue, updateAvailable, updateInfo...) + if err != nil { + return fmt.Errorf("error creating metric for %s: %w", systemUpdateAvailableDesc, err) + } + ch <- metric + + return nil +} + func collectShares(ch chan<- prometheus.Metric, shares serverinfo.Shares) error { values := make(map[string]float64) values["user"] = float64(shares.SharesUser) diff --git a/main.go b/main.go index 17f6668..7f9a8cf 100644 --- a/main.go +++ b/main.go @@ -74,14 +74,14 @@ func main() { log.Infof("Nextcloud server: %s Authentication using token.", cfg.ServerURL) } - infoURL := serverinfo.InfoURL(cfg.ServerURL, !cfg.Info.Apps) + infoURL := serverinfo.InfoURL(cfg.ServerURL, !cfg.Info.Apps, !cfg.Info.Update) if cfg.TLSSkipVerify { log.Warn("HTTPS certificate verification is disabled.") } infoClient := client.New(infoURL, cfg.Username, cfg.Password, cfg.AuthToken, cfg.Timeout, userAgent, cfg.TLSSkipVerify) - if err := metrics.RegisterCollector(log, infoClient, cfg.Info.Apps); err != nil { + if err := metrics.RegisterCollector(log, infoClient, cfg.Info.Apps, cfg.Info.Update); err != nil { log.Fatalf("Failed to register collector: %s", err) } diff --git a/serverinfo/serverinfo.go b/serverinfo/serverinfo.go index fe15e0f..d69559d 100644 --- a/serverinfo/serverinfo.go +++ b/serverinfo/serverinfo.go @@ -47,6 +47,7 @@ type System struct { Debug bool `json:"debug"` FreeSpace float64 `json:"freespace"` Apps Apps `json:"apps"` + Update Update `json:"update"` } const boolYes = "yes" @@ -64,6 +65,7 @@ func (s *System) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { Debug string `xml:"debug"` FreeSpace float64 `xml:"freespace"` Apps Apps `xml:"apps"` + Update Update `xml:"update"` } if err := d.DecodeElement(&raw, &start); err != nil { return err @@ -79,6 +81,7 @@ func (s *System) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { s.Debug = raw.Debug == boolYes s.FreeSpace = raw.FreeSpace s.Apps = raw.Apps + s.Update = raw.Update return nil } @@ -95,6 +98,7 @@ func (s *System) UnmarshalJSON(data []byte) error { Debug string `json:"debug"` FreeSpace float64 `json:"freespace"` Apps Apps `json:"apps"` + Update Update `json:"update"` } if err := json.Unmarshal(data, &raw); err != nil { return err @@ -110,6 +114,7 @@ func (s *System) UnmarshalJSON(data []byte) error { s.Debug = raw.Debug == boolYes s.FreeSpace = raw.FreeSpace s.Apps = raw.Apps + s.Update = raw.Update return nil } @@ -119,6 +124,12 @@ type Apps struct { AvailableUpdates uint `json:"num_updates_available"` } +// Update contains information about updates. +type Update struct { + Available bool `json:"available"` + AvailableVersion string `json:"available_version"` +} + // Storage contains information about the nextcloud storage system. type Storage struct { Users uint `json:"num_users"` diff --git a/serverinfo/url.go b/serverinfo/url.go index 3e680bc..6bb69a2 100644 --- a/serverinfo/url.go +++ b/serverinfo/url.go @@ -5,10 +5,10 @@ import ( ) const ( - infoPathFormat = "%s/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=%v" + infoPathFormat = "%s/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=%v&skipUpdate=%v" ) // InfoURL constructs the URL of the info endpoint from the server base URL and optional parameters. -func InfoURL(serverURL string, skipApps bool) string { - return fmt.Sprintf(infoPathFormat, serverURL, skipApps) +func InfoURL(serverURL string, skipApps bool, skipUpdate bool) string { + return fmt.Sprintf(infoPathFormat, serverURL, skipApps, skipUpdate) } diff --git a/serverinfo/url_test.go b/serverinfo/url_test.go index d3ac912..f5d1522 100644 --- a/serverinfo/url_test.go +++ b/serverinfo/url_test.go @@ -6,10 +6,11 @@ import ( func TestInfoURL(t *testing.T) { tt := []struct { - desc string - serverURL string - skipApps bool - wantURL string + desc string + serverURL string + skipApps bool + skipUpdate bool + wantURL string }{ { desc: "do not skip apps", @@ -22,6 +23,25 @@ func TestInfoURL(t *testing.T) { skipApps: true, wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=true", }, + { + desc: "do not skip update", + serverURL: "https://nextcloud.example.com", + skipUpdate: false, + wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipUpdate=false", + }, + { + desc: "skip update", + serverURL: "https://nextcloud.example.com", + skipUpdate: true, + wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipUpdate=true", + }, + { + desc: "do not skip update and do not skip apps", + serverURL: "https://nextcloud.example.com", + skipApps: false, + skipUpdate: false, + wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipUpdate=false&skipApps=false", + }, } for _, tc := range tt { @@ -29,7 +49,7 @@ func TestInfoURL(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { t.Parallel() - url := InfoURL(tc.serverURL, tc.skipApps) + url := InfoURL(tc.serverURL, tc.skipApps, tc.skipUpdate) if url != tc.wantURL { t.Errorf("got url %q, want %q", url, tc.wantURL) }