Initial Release
This commit is contained in:
commit
76ec4ba39f
31 changed files with 3023 additions and 0 deletions
62
internal/client/client.go
Normal file
62
internal/client/client.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"friendica-exporter/serverinfo"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotAuthorized = errors.New("wrong credentials")
|
||||
ErrRatelimit = errors.New("too many requests")
|
||||
)
|
||||
|
||||
type InfoClient func() (*serverinfo.ServerInfo, error)
|
||||
|
||||
func New(statsURL string, timeout time.Duration, userAgent string, tlsSkipVerify bool) InfoClient {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
// disable TLS certification verification, if desired
|
||||
InsecureSkipVerify: tlsSkipVerify,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return func() (*serverinfo.ServerInfo, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, statsURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
switch res.StatusCode {
|
||||
case http.StatusOK:
|
||||
break
|
||||
case http.StatusUnauthorized:
|
||||
return nil, ErrNotAuthorized
|
||||
case http.StatusTooManyRequests:
|
||||
return nil, ErrRatelimit
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
status, err := serverinfo.ParseJSON(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can not parse server info: %w", err)
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
}
|
97
internal/client/client_test.go
Normal file
97
internal/client/client_test.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"friendica-exporter/internal/testutil"
|
||||
"friendica-exporter/serverinfo"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestClient(t *testing.T) {
|
||||
wantUserAgent := "test-ua"
|
||||
|
||||
tt := []struct {
|
||||
desc string
|
||||
handler func(t *testing.T) http.Handler
|
||||
wantInfo *serverinfo.ServerInfo
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
desc: "statstoken",
|
||||
handler: func(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
fmt.Fprintln(w, "{}")
|
||||
})
|
||||
},
|
||||
wantInfo: &serverinfo.ServerInfo{},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
desc: "user-agent",
|
||||
handler: func(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ua := req.UserAgent()
|
||||
if ua != wantUserAgent {
|
||||
t.Errorf("got user-agent %q, want %q", ua, wantUserAgent)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "{}")
|
||||
})
|
||||
},
|
||||
wantInfo: &serverinfo.ServerInfo{},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
desc: "parse error",
|
||||
handler: func(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
},
|
||||
wantInfo: nil,
|
||||
wantErr: errors.New("can not parse server info: EOF"),
|
||||
},
|
||||
{
|
||||
desc: "ratelimit",
|
||||
handler: func(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
})
|
||||
},
|
||||
wantErr: ErrRatelimit,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
tc := tc
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := httptest.NewServer(tc.handler(t))
|
||||
defer s.Close()
|
||||
|
||||
client := New(s.URL, time.Second, wantUserAgent, false)
|
||||
|
||||
info, err := client()
|
||||
|
||||
if !testutil.EqualErrorMessage(err, tc.wantErr) {
|
||||
t.Errorf("got error %q, want %q", err, tc.wantErr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(info, tc.wantInfo); diff != "" {
|
||||
t.Errorf("info differs: -got+want\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
229
internal/config/config.go
Normal file
229
internal/config/config.go
Normal file
|
@ -0,0 +1,229 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
envPrefix = "FRIENDICA_"
|
||||
envListenAddress = envPrefix + "LISTEN_ADDRESS"
|
||||
envTimeout = envPrefix + "TIMEOUT"
|
||||
envServerURL = envPrefix + "SERVER"
|
||||
envStatsToken = envPrefix + "STATS_TOKEN"
|
||||
envTLSSkipVerify = envPrefix + "TLS_SKIP_VERIFY"
|
||||
)
|
||||
|
||||
// RunMode signals what the main application should do after parsing the options.
|
||||
type RunMode int
|
||||
|
||||
const (
|
||||
// RunModeExporter is the normal operation as an exporter serving metrics via HTTP.
|
||||
RunModeExporter RunMode = iota
|
||||
// RunModeHelp shows information about available options.
|
||||
RunModeHelp
|
||||
// RunModeVersion shows version information.
|
||||
RunModeVersion
|
||||
)
|
||||
|
||||
func (m RunMode) String() string {
|
||||
switch m {
|
||||
case RunModeExporter:
|
||||
return "exporter"
|
||||
case RunModeHelp:
|
||||
return "help"
|
||||
case RunModeVersion:
|
||||
return "version"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
// Config contains the configuration options for friendica-exporter.
|
||||
type Config struct {
|
||||
ListenAddr string `yaml:"listenAddress"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
ServerURL string `yaml:"server"`
|
||||
StatsToken string `yaml:"statsToken"`
|
||||
TLSSkipVerify bool `yaml:"tlsSkipVerify"`
|
||||
RunMode RunMode
|
||||
}
|
||||
|
||||
var (
|
||||
errValidateNoServerURL = errors.New("need to set a server URL")
|
||||
errValidateNoStatsToken = errors.New("need to provide a stats token")
|
||||
)
|
||||
|
||||
// Validate checks if the configuration contains all necessary parameters.
|
||||
func (c Config) Validate() error {
|
||||
if len(c.ServerURL) == 0 {
|
||||
return errValidateNoServerURL
|
||||
}
|
||||
|
||||
if len(c.StatsToken) == 0 {
|
||||
return errValidateNoStatsToken
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get loads the configuration. Flags, environment variables and configuration file are considered.
|
||||
func Get() (Config, error) {
|
||||
return parseConfig(os.Args, os.Getenv)
|
||||
}
|
||||
|
||||
func parseConfig(args []string, envFunc func(string) string) (Config, error) {
|
||||
result, configFile, err := loadConfigFromFlags(args)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("error parsing flags: %w", err)
|
||||
}
|
||||
|
||||
if configFile != "" {
|
||||
rawFile, err := loadConfigFromFile(configFile)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("error reading configuration file: %w", err)
|
||||
}
|
||||
|
||||
result = mergeConfig(result, rawFile)
|
||||
}
|
||||
|
||||
env, err := loadConfigFromEnv(envFunc)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("error reading environment variables: %w", err)
|
||||
}
|
||||
result = mergeConfig(result, env)
|
||||
|
||||
if strings.HasPrefix(result.StatsToken, "@") {
|
||||
fileName := strings.TrimPrefix(result.StatsToken, "@")
|
||||
statsToken, err := readStatsTokenFile(fileName)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("can not read stats token file: %w", err)
|
||||
}
|
||||
|
||||
result.StatsToken = statsToken
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
ListenAddr: ":9205",
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigFromFlags(args []string) (result Config, configFile string, err error) {
|
||||
defaults := defaultConfig()
|
||||
|
||||
flags := pflag.NewFlagSet(args[0], pflag.ContinueOnError)
|
||||
flags.StringVarP(&configFile, "config-file", "c", "", "Path to YAML configuration file.")
|
||||
flags.StringVarP(&result.ListenAddr, "addr", "a", defaults.ListenAddr, "Address to listen on for connections.")
|
||||
flags.DurationVarP(&result.Timeout, "timeout", "t", defaults.Timeout, "Timeout for getting server info document.")
|
||||
flags.StringVarP(&result.ServerURL, "server", "s", "", "URL to Friendica server.")
|
||||
flags.StringVarP(&result.StatsToken, "stats-token", "u", defaults.StatsToken, "Token for statistics Endpoint of Friendica.")
|
||||
flags.BoolVar(&result.TLSSkipVerify, "tls-skip-verify", defaults.TLSSkipVerify, "Skip certificate verification of Friendica server.")
|
||||
modeVersion := flags.BoolP("version", "V", false, "Show version information and exit.")
|
||||
|
||||
if err := flags.Parse(args[1:]); err != nil {
|
||||
if err == pflag.ErrHelp {
|
||||
return Config{
|
||||
RunMode: RunModeHelp,
|
||||
}, "", nil
|
||||
}
|
||||
|
||||
return Config{}, "", err
|
||||
}
|
||||
|
||||
if *modeVersion {
|
||||
return Config{
|
||||
RunMode: RunModeVersion,
|
||||
}, "", nil
|
||||
}
|
||||
|
||||
return result, configFile, nil
|
||||
}
|
||||
|
||||
func loadConfigFromFile(fileName string) (Config, error) {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var result Config
|
||||
if err := yaml.NewDecoder(file).Decode(&result); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadConfigFromEnv(getEnv func(string) string) (Config, error) {
|
||||
tlsSkipVerify := false
|
||||
if rawValue := getEnv(envTLSSkipVerify); rawValue != "" {
|
||||
value, err := strconv.ParseBool(rawValue)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("can not parse value for %q: %s", envTLSSkipVerify, rawValue)
|
||||
}
|
||||
tlsSkipVerify = value
|
||||
}
|
||||
|
||||
result := Config{
|
||||
ListenAddr: getEnv(envListenAddress),
|
||||
ServerURL: getEnv(envServerURL),
|
||||
StatsToken: getEnv(envStatsToken),
|
||||
TLSSkipVerify: tlsSkipVerify,
|
||||
}
|
||||
|
||||
if raw := getEnv(envTimeout); raw != "" {
|
||||
value, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
result.Timeout = value
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mergeConfig(base, override Config) Config {
|
||||
result := base
|
||||
if override.ListenAddr != "" {
|
||||
result.ListenAddr = override.ListenAddr
|
||||
}
|
||||
|
||||
if override.ServerURL != "" {
|
||||
result.ServerURL = override.ServerURL
|
||||
}
|
||||
|
||||
if override.StatsToken != "" {
|
||||
result.StatsToken = override.StatsToken
|
||||
}
|
||||
|
||||
if override.Timeout != 0 {
|
||||
result.Timeout = override.Timeout
|
||||
}
|
||||
|
||||
if override.TLSSkipVerify {
|
||||
result.TLSSkipVerify = override.TLSSkipVerify
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func readStatsTokenFile(fileName string) (string, error) {
|
||||
bytes, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(string(bytes), "\n"), nil
|
||||
}
|
300
internal/config/config_test.go
Normal file
300
internal/config/config_test.go
Normal file
|
@ -0,0 +1,300 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"friendica-exporter/internal/testutil"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func testEnv(env map[string]string) func(string) string {
|
||||
return func(key string) string {
|
||||
return env[key]
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
defaults := defaultConfig()
|
||||
tt := []struct {
|
||||
desc string
|
||||
args []string
|
||||
env map[string]string
|
||||
wantErr error
|
||||
wantConfig Config
|
||||
}{
|
||||
{
|
||||
desc: "flags",
|
||||
args: []string{
|
||||
"test",
|
||||
"--addr",
|
||||
"127.0.0.1:9205",
|
||||
"--timeout",
|
||||
"30s",
|
||||
"--server",
|
||||
"http://localhost",
|
||||
"--stats-token",
|
||||
"/$&/bdgersert6756!",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
ListenAddr: "127.0.0.1:9205",
|
||||
Timeout: 30 * time.Second,
|
||||
ServerURL: "http://localhost",
|
||||
StatsToken: "/$&/bdgersert6756!",
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "password from file",
|
||||
args: []string{
|
||||
"test",
|
||||
"--server",
|
||||
"http://localhost",
|
||||
"--stats-token",
|
||||
"@testdata/statsToken",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
ListenAddr: defaults.ListenAddr,
|
||||
Timeout: defaults.Timeout,
|
||||
ServerURL: "http://localhost",
|
||||
StatsToken: "234563twtwewr",
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "config from file",
|
||||
args: []string{
|
||||
"test",
|
||||
"--config-file",
|
||||
"testdata/all.yml",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
ListenAddr: "127.0.0.10:9205",
|
||||
Timeout: 10 * time.Second,
|
||||
ServerURL: "http://localhost",
|
||||
StatsToken: "12w4352345zt§&/&)(&/",
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "config and token from file",
|
||||
args: []string{
|
||||
"test",
|
||||
"--config-file",
|
||||
"testdata/statsTokenFile.yml",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
ListenAddr: "127.0.0.10:9205",
|
||||
Timeout: 10 * time.Second,
|
||||
ServerURL: "http://localhost",
|
||||
StatsToken: "234563twtwewr",
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "don't check tls certificates",
|
||||
args: []string{
|
||||
"test",
|
||||
"--tls-skip-verify",
|
||||
"true",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
ListenAddr: ":9205",
|
||||
Timeout: 5 * time.Second,
|
||||
ServerURL: "",
|
||||
StatsToken: "",
|
||||
TLSSkipVerify: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "env config",
|
||||
args: []string{
|
||||
"test",
|
||||
},
|
||||
env: map[string]string{
|
||||
envListenAddress: "127.0.0.11:9205",
|
||||
envTimeout: "15s",
|
||||
envServerURL: "http://localhost",
|
||||
envStatsToken: "testpass/()/(g21!",
|
||||
envTLSSkipVerify: "true",
|
||||
},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
ListenAddr: "127.0.0.11:9205",
|
||||
Timeout: 15 * time.Second,
|
||||
ServerURL: "http://localhost",
|
||||
StatsToken: "testpass/()/(g21!",
|
||||
TLSSkipVerify: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "minimal env",
|
||||
args: []string{
|
||||
"test",
|
||||
},
|
||||
env: map[string]string{
|
||||
envServerURL: "http://localhost",
|
||||
envStatsToken: "testpass/()/(g21!",
|
||||
},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
ListenAddr: defaults.ListenAddr,
|
||||
Timeout: defaults.Timeout,
|
||||
ServerURL: "http://localhost",
|
||||
StatsToken: "testpass/()/(g21!",
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "show help",
|
||||
args: []string{
|
||||
"test",
|
||||
"--help",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
RunMode: RunModeHelp,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "show version",
|
||||
args: []string{
|
||||
"test",
|
||||
"--version",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: nil,
|
||||
wantConfig: Config{
|
||||
RunMode: RunModeVersion,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "wrongflag",
|
||||
args: []string{
|
||||
"test",
|
||||
"--unknown",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: errors.New("error parsing flags: unknown flag: --unknown"),
|
||||
},
|
||||
{
|
||||
desc: "env wrong duration",
|
||||
args: []string{
|
||||
"test",
|
||||
},
|
||||
env: map[string]string{
|
||||
"FRIENDICA_TIMEOUT": "unknown",
|
||||
},
|
||||
wantErr: errors.New("error reading environment variables: time: invalid duration \"unknown\""),
|
||||
},
|
||||
{
|
||||
desc: "token from file error",
|
||||
args: []string{
|
||||
"test",
|
||||
"--server",
|
||||
"http://localhost",
|
||||
"--stats-token",
|
||||
"@testdata/notfound",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: errors.New("can not read stats token file: open testdata/notfound: no such file or directory"),
|
||||
},
|
||||
{
|
||||
desc: "config from file error",
|
||||
args: []string{
|
||||
"test",
|
||||
"--config-file",
|
||||
"testdata/notfound.yml",
|
||||
},
|
||||
env: map[string]string{},
|
||||
wantErr: errors.New("error reading configuration file: open testdata/notfound.yml: no such file or directory"),
|
||||
},
|
||||
{
|
||||
desc: "fail parsing tlsSkipVerify env",
|
||||
args: []string{
|
||||
"test",
|
||||
},
|
||||
env: map[string]string{
|
||||
envTLSSkipVerify: "invalid",
|
||||
},
|
||||
wantErr: errors.New(`error reading environment variables: can not parse value for "FRIENDICA_TLS_SKIP_VERIFY": invalid`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
tc := tc
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config, err := parseConfig(tc.args, testEnv(tc.env))
|
||||
|
||||
if !testutil.EqualErrorMessage(err, tc.wantErr) {
|
||||
t.Errorf("got error %q, want %q", err, tc.wantErr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(config, tc.wantConfig); diff != "" {
|
||||
t.Errorf("config differs: -got +want\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
tt := []struct {
|
||||
desc string
|
||||
config Config
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
desc: "minimal",
|
||||
config: Config{
|
||||
ServerURL: "https://example.com",
|
||||
StatsToken: "testtoken",
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
desc: "no url",
|
||||
config: Config{
|
||||
StatsToken: "testtoken",
|
||||
},
|
||||
wantErr: errValidateNoServerURL,
|
||||
},
|
||||
{
|
||||
desc: "auth help",
|
||||
config: Config{
|
||||
ServerURL: "https://example.com",
|
||||
},
|
||||
wantErr: errValidateNoStatsToken,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
tc := tc
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tc.config.Validate()
|
||||
|
||||
if !testutil.EqualErrorMessage(err, tc.wantErr) {
|
||||
t.Errorf("got error %q, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
4
internal/config/testdata/all.yml
vendored
Normal file
4
internal/config/testdata/all.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
listenAddress: 127.0.0.10:9205
|
||||
timeout: 10s
|
||||
server: http://localhost
|
||||
statsToken: 12w4352345zt§&/&)(&/
|
1
internal/config/testdata/statsToken
vendored
Normal file
1
internal/config/testdata/statsToken
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
234563twtwewr
|
5
internal/config/testdata/statsTokenFile.yml
vendored
Normal file
5
internal/config/testdata/statsTokenFile.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
listenAddress: 127.0.0.10:9205
|
||||
timeout: 10s
|
||||
server: http://localhost
|
||||
username: testuser
|
||||
statsToken: "@testdata/statsToken"
|
363
internal/metrics/collector.go
Normal file
363
internal/metrics/collector.go
Normal file
|
@ -0,0 +1,363 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"friendica-exporter/internal/client"
|
||||
"friendica-exporter/serverinfo"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
metricPrefix = "friendica_"
|
||||
|
||||
labelErrorCauseOther = "other"
|
||||
labelErrorCauseAuth = "auth"
|
||||
labelErrorCauseRateLimit = "ratelimit"
|
||||
)
|
||||
|
||||
var (
|
||||
updateAvailableDesc = prometheus.NewDesc(
|
||||
metricPrefix+"update_available",
|
||||
"Contains information whether a system update is available (0 = no, 1 = yes). The available_version level contains the latest available Friendica version, whereas the version level contains the current installed Friendica version.",
|
||||
[]string{"version", "available_version"}, nil)
|
||||
updateStatus = prometheus.NewDesc(
|
||||
metricPrefix+"update_status",
|
||||
"Contains information whether a system update was successful (1 = no, 0 = yes).",
|
||||
nil, nil)
|
||||
updateDatabaseStatus = prometheus.NewDesc(
|
||||
metricPrefix+"update_db_status",
|
||||
"Contains information whether a database update was successful (0 = no, 1 = yes).",
|
||||
nil, nil)
|
||||
cronLastExecution = prometheus.NewDesc(
|
||||
metricPrefix+"cron_last_execution",
|
||||
"Contains information about the last execution of cron of this Instance.",
|
||||
[]string{"datetime"}, nil)
|
||||
workerLastExecution = prometheus.NewDesc(
|
||||
metricPrefix+"worker_last_execution",
|
||||
"Contains information about the last execution of worker of this Instance.",
|
||||
[]string{"datetime"}, nil)
|
||||
workerJPM = prometheus.NewDesc(
|
||||
metricPrefix+"worker_jpm",
|
||||
"Number of jobs per Minute.",
|
||||
[]string{"frequency"}, nil)
|
||||
workerTasksTotal = prometheus.NewDesc(
|
||||
metricPrefix+"worker_tasks_total",
|
||||
"Number of worker tasks of this Instance.",
|
||||
[]string{"level"}, nil)
|
||||
workerTasksActive = prometheus.NewDesc(
|
||||
metricPrefix+"worker_tasks_active",
|
||||
"Number of active worker tasks of this Instance.",
|
||||
[]string{"level"}, nil)
|
||||
usersTotal = prometheus.NewDesc(
|
||||
metricPrefix+"users_total",
|
||||
"Number of total users of this Instance.",
|
||||
nil, nil)
|
||||
usersActiveWeek = prometheus.NewDesc(
|
||||
metricPrefix+"users_active_week",
|
||||
"Number of active users of the last week.",
|
||||
nil, nil)
|
||||
usersActiveMonth = prometheus.NewDesc(
|
||||
metricPrefix+"users_active_month",
|
||||
"Number of active users of the last month.",
|
||||
nil, nil)
|
||||
usersActiveHalfYear = prometheus.NewDesc(
|
||||
metricPrefix+"users_active_half_year",
|
||||
"Number of active users of the last half year.",
|
||||
nil, nil)
|
||||
usersPending = prometheus.NewDesc(
|
||||
metricPrefix+"users_pending",
|
||||
"Number of pending users.",
|
||||
nil, nil)
|
||||
)
|
||||
|
||||
type friendicaCollector struct {
|
||||
log logrus.FieldLogger
|
||||
infoClient client.InfoClient
|
||||
|
||||
upMetric prometheus.Gauge
|
||||
scrapeErrorsMetric *prometheus.CounterVec
|
||||
}
|
||||
|
||||
func RegisterCollector(log logrus.FieldLogger, infoClient client.InfoClient) error {
|
||||
c := &friendicaCollector{
|
||||
log: log,
|
||||
infoClient: infoClient,
|
||||
|
||||
upMetric: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: metricPrefix + "up",
|
||||
Help: "Indicates if the metrics could be scraped by the exporter.",
|
||||
}),
|
||||
scrapeErrorsMetric: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: metricPrefix + "scrape_errors_total",
|
||||
Help: "Counts the number of scrape errors by this collector.",
|
||||
}, []string{"cause"}),
|
||||
}
|
||||
|
||||
return prometheus.Register(c)
|
||||
}
|
||||
|
||||
func (c *friendicaCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
c.upMetric.Describe(ch)
|
||||
c.scrapeErrorsMetric.Describe(ch)
|
||||
ch <- updateAvailableDesc
|
||||
ch <- updateStatus
|
||||
ch <- updateDatabaseStatus
|
||||
ch <- cronLastExecution
|
||||
ch <- workerLastExecution
|
||||
ch <- workerJPM
|
||||
ch <- workerTasksTotal
|
||||
ch <- workerTasksActive
|
||||
ch <- usersTotal
|
||||
ch <- usersActiveWeek
|
||||
ch <- usersActiveMonth
|
||||
ch <- usersActiveHalfYear
|
||||
ch <- usersPending
|
||||
}
|
||||
|
||||
func (c *friendicaCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
if err := c.collectFriendica(ch); err != nil {
|
||||
c.log.Errorf("Error during scrape: %s", err)
|
||||
|
||||
cause := labelErrorCauseOther
|
||||
if err == client.ErrNotAuthorized {
|
||||
cause = labelErrorCauseAuth
|
||||
} else if err == client.ErrRatelimit {
|
||||
cause = labelErrorCauseRateLimit
|
||||
}
|
||||
c.scrapeErrorsMetric.WithLabelValues(cause).Inc()
|
||||
c.upMetric.Set(0)
|
||||
} else {
|
||||
c.upMetric.Set(1)
|
||||
}
|
||||
|
||||
c.upMetric.Collect(ch)
|
||||
c.scrapeErrorsMetric.Collect(ch)
|
||||
}
|
||||
|
||||
func (c *friendicaCollector) collectFriendica(ch chan<- prometheus.Metric) error {
|
||||
status, err := c.infoClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return readMetrics(ch, status)
|
||||
}
|
||||
|
||||
func readMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
|
||||
if err := collectSimpleMetrics(ch, status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collectWorkerTotal(ch, status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collectJPM(ch, status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collectLastExecutions(ch, status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collectUpdate(ch, status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectUpdate(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
|
||||
updateInfo := status.Update
|
||||
serverInfo := status.Server
|
||||
updateAvailableValue := 0.0
|
||||
|
||||
// Fix small bug: its indicated as "true" even if there is no real update available.
|
||||
if updateInfo.Available && serverInfo.Version != updateInfo.AvailableVersion {
|
||||
updateAvailableValue = 1.0
|
||||
}
|
||||
|
||||
metric, err := prometheus.NewConstMetric(updateAvailableDesc, prometheus.GaugeValue, updateAvailableValue, serverInfo.Version, updateInfo.AvailableVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating metric for %s: %w", updateAvailableDesc, err)
|
||||
}
|
||||
ch <- metric
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type simpleMetric struct {
|
||||
desc *prometheus.Desc
|
||||
value float64
|
||||
}
|
||||
|
||||
func collectSimpleMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
|
||||
metrics := []simpleMetric{
|
||||
{
|
||||
desc: updateStatus,
|
||||
value: float64(status.Update.Status),
|
||||
},
|
||||
{
|
||||
desc: updateDatabaseStatus,
|
||||
value: float64(status.Update.DatabaseStatus),
|
||||
},
|
||||
{
|
||||
desc: usersTotal,
|
||||
value: float64(status.Users.Total),
|
||||
},
|
||||
{
|
||||
desc: usersActiveWeek,
|
||||
value: float64(status.Users.ActiveWeek),
|
||||
},
|
||||
{
|
||||
desc: usersActiveMonth,
|
||||
value: float64(status.Users.ActiveMonth),
|
||||
},
|
||||
{
|
||||
desc: usersActiveHalfYear,
|
||||
value: float64(status.Users.ActiveHalfYear),
|
||||
},
|
||||
{
|
||||
desc: usersPending,
|
||||
value: float64(status.Users.Pending),
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range metrics {
|
||||
metric, err := prometheus.NewConstMetric(m.desc, prometheus.GaugeValue, m.value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating metric for %s: %w", m.desc, err)
|
||||
}
|
||||
ch <- metric
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type workerMetric struct {
|
||||
desc *prometheus.Desc
|
||||
value float64
|
||||
level string
|
||||
}
|
||||
|
||||
func collectWorkerTotal(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
|
||||
metrics := []workerMetric{
|
||||
{
|
||||
desc: workerTasksActive,
|
||||
value: float64(status.Worker.Active.Critical),
|
||||
level: "critical",
|
||||
},
|
||||
{
|
||||
desc: workerTasksActive,
|
||||
value: float64(status.Worker.Active.High),
|
||||
level: "high",
|
||||
},
|
||||
{
|
||||
desc: workerTasksActive,
|
||||
value: float64(status.Worker.Active.Medium),
|
||||
level: "medium",
|
||||
},
|
||||
{
|
||||
desc: workerTasksActive,
|
||||
value: float64(status.Worker.Active.Low),
|
||||
level: "low",
|
||||
},
|
||||
{
|
||||
desc: workerTasksActive,
|
||||
value: float64(status.Worker.Active.Negligible),
|
||||
level: "negligible",
|
||||
},
|
||||
{
|
||||
desc: workerTasksTotal,
|
||||
value: float64(status.Worker.Total.Critical),
|
||||
level: "critical",
|
||||
},
|
||||
{
|
||||
desc: workerTasksTotal,
|
||||
value: float64(status.Worker.Total.High),
|
||||
level: "high",
|
||||
},
|
||||
{
|
||||
desc: workerTasksTotal,
|
||||
value: float64(status.Worker.Total.Medium),
|
||||
level: "medium",
|
||||
},
|
||||
{
|
||||
desc: workerTasksTotal,
|
||||
value: float64(status.Worker.Total.Low),
|
||||
level: "low",
|
||||
},
|
||||
{
|
||||
desc: workerTasksTotal,
|
||||
value: float64(status.Worker.Total.Negligible),
|
||||
level: "negligible",
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range metrics {
|
||||
metric, err := prometheus.NewConstMetric(m.desc, prometheus.GaugeValue, m.value, m.level)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating metric for %s: %w", m.desc, err)
|
||||
}
|
||||
ch <- metric
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type jpmMetric struct {
|
||||
desc *prometheus.Desc
|
||||
value float64
|
||||
frequency string
|
||||
}
|
||||
|
||||
func collectJPM(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
|
||||
metrics := []jpmMetric{
|
||||
{
|
||||
desc: workerJPM,
|
||||
value: float64(status.Worker.JPM.OneMinute),
|
||||
frequency: "1 minute",
|
||||
},
|
||||
{
|
||||
desc: workerJPM,
|
||||
value: float64(status.Worker.JPM.ThreeMinutes),
|
||||
frequency: "3 minutes",
|
||||
},
|
||||
{
|
||||
desc: workerJPM,
|
||||
value: float64(status.Worker.JPM.FiveMinutes),
|
||||
frequency: "5 minutes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range metrics {
|
||||
metric, err := prometheus.NewConstMetric(m.desc, prometheus.GaugeValue, m.value, m.frequency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating metric for %s: %w", m.desc, err)
|
||||
}
|
||||
ch <- metric
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectLastExecutions(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
|
||||
workerInfo := status.Worker
|
||||
|
||||
metric, err := prometheus.NewConstMetric(workerLastExecution, prometheus.GaugeValue, float64(workerInfo.LastExecution.Timestamp), workerInfo.LastExecution.DateTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating metric for %s: %w", workerLastExecution, err)
|
||||
}
|
||||
ch <- metric
|
||||
|
||||
cronInfo := status.Cron
|
||||
|
||||
metric, err = prometheus.NewConstMetric(cronLastExecution, prometheus.GaugeValue, float64(cronInfo.LastExecution.Timestamp), cronInfo.LastExecution.DateTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating metric for %s: %w", cronLastExecution, err)
|
||||
}
|
||||
ch <- metric
|
||||
|
||||
return nil
|
||||
}
|
17
internal/metrics/info.go
Normal file
17
internal/metrics/info.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
func RegisterStatsMetrics(version, gitCommit string) error {
|
||||
statsMetric := prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: metricPrefix + "exporter_info",
|
||||
Help: "Information about the friendica-exporter.",
|
||||
ConstLabels: prometheus.Labels{
|
||||
"version": version,
|
||||
"commit": gitCommit,
|
||||
},
|
||||
})
|
||||
statsMetric.Set(1)
|
||||
|
||||
return prometheus.Register(statsMetric)
|
||||
}
|
20
internal/testutil/testutil.go
Normal file
20
internal/testutil/testutil.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package testutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EqualErrorMessage compares two errors by just comparing their messages.
|
||||
func EqualErrorMessage(a, b error) bool {
|
||||
aMsg := "<nil>"
|
||||
if a != nil {
|
||||
aMsg = a.Error()
|
||||
}
|
||||
|
||||
bMsg := "<nil>"
|
||||
if b != nil {
|
||||
bMsg = b.Error()
|
||||
}
|
||||
|
||||
return strings.Compare(aMsg, bMsg) == 0
|
||||
}
|
70
internal/testutil/testutil_test.go
Normal file
70
internal/testutil/testutil_test.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package testutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testError struct{}
|
||||
|
||||
func (e testError) Error() string {
|
||||
return "test message"
|
||||
}
|
||||
|
||||
func TestEqualErrorMessage(t *testing.T) {
|
||||
tt := []struct {
|
||||
desc string
|
||||
a error
|
||||
b error
|
||||
wantEqual bool
|
||||
}{
|
||||
{
|
||||
desc: "two nil",
|
||||
a: nil,
|
||||
b: nil,
|
||||
wantEqual: true,
|
||||
},
|
||||
{
|
||||
desc: "a not nil",
|
||||
a: errors.New("error A"),
|
||||
b: nil,
|
||||
wantEqual: false,
|
||||
},
|
||||
{
|
||||
desc: "b not nil",
|
||||
a: nil,
|
||||
b: errors.New("error B"),
|
||||
wantEqual: false,
|
||||
},
|
||||
{
|
||||
desc: "both not nil",
|
||||
a: errors.New("error A"),
|
||||
b: errors.New("error B"),
|
||||
wantEqual: false,
|
||||
},
|
||||
{
|
||||
desc: "equal message, same type",
|
||||
a: errors.New("test message"),
|
||||
b: errors.New("test message"),
|
||||
wantEqual: true,
|
||||
},
|
||||
{
|
||||
desc: "equal message, different type",
|
||||
a: errors.New("test message"),
|
||||
b: testError{},
|
||||
wantEqual: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
tc := tc
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
equal := EqualErrorMessage(tc.a, tc.b)
|
||||
if equal != tc.wantEqual {
|
||||
t.Errorf("got equal %v, want %v", equal, tc.wantEqual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue