From 4474ee7bd686a9211a9070c1e6ed3bef44681fc8 Mon Sep 17 00:00:00 2001 From: Mikolaj Krzyzanowski <99648778+mikolaj-krzyzanowski-f3@users.noreply.github.com> Date: Fri, 11 Aug 2023 10:41:51 +0100 Subject: [PATCH] feat: Add runners metrics (#7) --- go.mod | 8 +- go.sum | 12 +- internal/server/billing_metrics_exporter.go | 17 +- internal/server/github_client.go | 116 ++++++++++ internal/server/metrics.go | 27 ++- internal/server/runners_metrics_exporter.go | 99 ++++++++ .../server/runners_metrics_exporter_test.go | 219 ++++++++++++++++++ internal/server/server.go | 60 +++-- internal/server/workflow_metrics_exporter.go | 4 +- main.go | 10 +- 10 files changed, 527 insertions(+), 45 deletions(-) create mode 100644 internal/server/github_client.go create mode 100644 internal/server/runners_metrics_exporter.go create mode 100644 internal/server/runners_metrics_exporter_test.go diff --git a/go.mod b/go.mod index 4f3e60c..fd2c758 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.19 require ( github.com/go-kit/log v0.2.1 github.com/google/go-github/v47 v47.1.0 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/prometheus/client_golang v1.14.0 github.com/prometheus/common v0.39.0 github.com/stretchr/testify v1.8.1 @@ -23,13 +24,12 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect - golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 // indirect - golang.org/x/net v0.5.0 // indirect - golang.org/x/sys v0.4.0 // indirect + golang.org/x/crypto v0.1.0 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/sys v0.5.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index f8143d0..f31a405 100644 --- a/go.sum +++ b/go.sum @@ -57,17 +57,17 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 h1:3erb+vDS8lU1sxfDHF4/hhWyaXnhIaO+7RgL4fDZORA= -golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M= golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/server/billing_metrics_exporter.go b/internal/server/billing_metrics_exporter.go index 9e56183..4360d40 100644 --- a/internal/server/billing_metrics_exporter.go +++ b/internal/server/billing_metrics_exporter.go @@ -7,24 +7,15 @@ import ( "github.com/go-kit/log" "github.com/go-kit/log/level" - "github.com/google/go-github/v47/github" - "golang.org/x/oauth2" ) type BillingMetricsExporter struct { - GHClient *github.Client + GHClient GitHubClient Logger log.Logger Opts Opts } -func NewBillingMetricsExporter(logger log.Logger, opts Opts) *BillingMetricsExporter { - ctx := context.Background() - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: opts.GitHubAPIToken}, - ) - tc := oauth2.NewClient(ctx, ts) - client := github.NewClient(tc) - +func NewBillingMetricsExporter(logger log.Logger, opts Opts, client GitHubClient) *BillingMetricsExporter { return &BillingMetricsExporter{ Logger: logger, Opts: opts, @@ -83,7 +74,7 @@ func (c *BillingMetricsExporter) StartUserBilling(ctx context.Context) error { // CollectActionBilling collect the action billing. func (c *BillingMetricsExporter) collectOrgBilling(ctx context.Context) { - actionsBilling, _, err := c.GHClient.Billing.GetActionsBillingOrg(ctx, c.Opts.GitHubOrg) + actionsBilling, err := c.GHClient.GetActionsBillingOrg(ctx, c.Opts.GitHubOrg) if err != nil { _ = c.Logger.Log("msg", "failed to retrieve the actions billing for an org", "org", c.Opts.GitHubOrg, "err", err) return @@ -98,7 +89,7 @@ func (c *BillingMetricsExporter) collectOrgBilling(ctx context.Context) { } func (c *BillingMetricsExporter) collectUserBilling(ctx context.Context) { - actionsBilling, _, err := c.GHClient.Billing.GetActionsBillingUser(ctx, c.Opts.GitHubUser) + actionsBilling, err := c.GHClient.GetActionsBillingUser(ctx, c.Opts.GitHubUser) if err != nil { _ = c.Logger.Log("msg", "failed to retrieve the actions billing for an user", "user", c.Opts.GitHubUser, "err", err) return diff --git a/internal/server/github_client.go b/internal/server/github_client.go new file mode 100644 index 0000000..d4db436 --- /dev/null +++ b/internal/server/github_client.go @@ -0,0 +1,116 @@ +package server + +import ( + "context" + "errors" + "github.com/google/go-github/v47/github" + "strconv" +) + +const ( + pageSize = 100 +) + +type GitHubClient interface { + GetOrganisationRunnerGroups(ctx context.Context, orgName string) ([]*github.RunnerGroup, error) + GetEnterpriseRunners(ctx context.Context, enterpriseName string) ([]*github.Runner, error) + GetGroupRunners(ctx context.Context, groupID int64, orgName string) ([]*github.Runner, error) + GetActionsBillingOrg(ctx context.Context, org string) (*github.ActionBilling, error) + GetActionsBillingUser(ctx context.Context, user string) (*github.ActionBilling, error) +} + +type DefaultGitHubClient struct { + Client *github.Client + Opts *Opts +} + +func NewGitHubClient(opts *Opts, client *github.Client) *DefaultGitHubClient { + return &DefaultGitHubClient{Client: client, Opts: opts} +} + +func (c *DefaultGitHubClient) GetOrganisationRunnerGroups(ctx context.Context, orgName string) ([]*github.RunnerGroup, error) { + nextPage := 1 + var allGroups []*github.RunnerGroup + + for nextPage > 0 { + runnerGroups, response, err := c.Client.Actions.ListOrganizationRunnerGroups(ctx, orgName, &github.ListOrgRunnerGroupOptions{ + ListOptions: github.ListOptions{ + Page: nextPage, + PerPage: pageSize, + }, + }) + + if err != nil { + return nil, err + } + + if response.StatusCode != 200 { + return nil, errors.New("unexpected response from GitHub API: " + strconv.Itoa(response.StatusCode)) + } + + allGroups = append(allGroups, runnerGroups.RunnerGroups...) + nextPage = response.NextPage + } + + return allGroups, nil +} + +func (c *DefaultGitHubClient) GetEnterpriseRunners(ctx context.Context, enterpriseName string) ([]*github.Runner, error) { + var enterpriseRunners []*github.Runner + var nextPage = 1 + + for nextPage > 0 { + runners, response, err := c.Client.Enterprise.ListRunners(ctx, enterpriseName, &github.ListOptions{ + Page: nextPage, + PerPage: pageSize, + }) + + if err != nil { + return nil, err + } + + if response.StatusCode != 200 { + return nil, errors.New("unexpected response from GitHub API: " + strconv.Itoa(response.StatusCode)) + } + + enterpriseRunners = append(enterpriseRunners, runners.Runners...) + nextPage = response.NextPage + } + + return enterpriseRunners, nil +} + +func (c *DefaultGitHubClient) GetGroupRunners(ctx context.Context, groupID int64, orgName string) ([]*github.Runner, error) { + var groupRunners []*github.Runner + var nextPage = 1 + + for nextPage > 0 { + runners, response, err := c.Client.Actions.ListRunnerGroupRunners(ctx, orgName, groupID, &github.ListOptions{ + Page: nextPage, + PerPage: pageSize, + }) + + if err != nil { + return nil, err + } + + if response.StatusCode != 200 { + return nil, errors.New("unexpected response from GitHub API: " + strconv.Itoa(response.StatusCode)) + } + + groupRunners = append(groupRunners, runners.Runners...) + nextPage = response.NextPage + } + + return groupRunners, nil +} + +func (c *DefaultGitHubClient) GetActionsBillingOrg(ctx context.Context, org string) (*github.ActionBilling, error) { + billing, _, err := c.Client.Billing.GetActionsBillingOrg(ctx, org) + return billing, err +} + +func (c *DefaultGitHubClient) GetActionsBillingUser(ctx context.Context, user string) (*github.ActionBilling, error) { + billing, _, err := c.Client.Billing.GetActionsBillingUser(ctx, user) + return billing, err +} diff --git a/internal/server/metrics.go b/internal/server/metrics.go index 5b6839f..b721798 100644 --- a/internal/server/metrics.go +++ b/internal/server/metrics.go @@ -1,6 +1,9 @@ package server -import "github.com/prometheus/client_golang/prometheus" +import ( + "github.com/prometheus/client_golang/prometheus" + "strconv" +) var ( workflowJobHistogramVec = prometheus.NewHistogramVec(prometheus.HistogramOpts{ @@ -96,6 +99,13 @@ var ( }, []string{"org", "user"}, ) + + registeredRunnersTotal = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "actions_registered_runners_total", + Help: "The number of registered Actions runners.", + }, + []string{"busy", "status", "runner_group"}, + ) ) func init() { @@ -112,6 +122,7 @@ func init() { prometheus.MustRegister(totalMinutesUsedUbuntuActions) prometheus.MustRegister(totalMinutesUsedMacOSActions) prometheus.MustRegister(totalMinutesUsedWindowsActions) + prometheus.MustRegister(registeredRunnersTotal) } type WorkflowObserver interface { @@ -123,7 +134,13 @@ type WorkflowObserver interface { CountWorkflowRunStatus(org, repo, status, conclusion, workflow string) } +type RunnersObserver interface { + ResetRegisteredRunnersTotal() + IncreaseRegisteredRunnersTotal(busy bool, status string, runnerGroup string) +} + var _ WorkflowObserver = (*PrometheusObserver)(nil) +var _ RunnersObserver = (*PrometheusObserver)(nil) type PrometheusObserver struct{} @@ -151,3 +168,11 @@ func (o *PrometheusObserver) ObserveWorkflowRunDuration(org, repo, workflowName func (o *PrometheusObserver) CountWorkflowRunStatus(org, repo, status, conclusion, workflowName string) { workflowRunStatusCounter.WithLabelValues(org, repo, status, conclusion, workflowName).Inc() } + +func (o *PrometheusObserver) ResetRegisteredRunnersTotal() { + registeredRunnersTotal.Reset() +} + +func (o *PrometheusObserver) IncreaseRegisteredRunnersTotal(busy bool, status string, runnerGroup string) { + registeredRunnersTotal.WithLabelValues(strconv.FormatBool(busy), status, runnerGroup).Inc() +} diff --git a/internal/server/runners_metrics_exporter.go b/internal/server/runners_metrics_exporter.go new file mode 100644 index 0000000..98bde3a --- /dev/null +++ b/internal/server/runners_metrics_exporter.go @@ -0,0 +1,99 @@ +package server + +import ( + "context" + "errors" + "time" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/google/go-github/v47/github" +) + +type RunnersMetricsExporter struct { + GHClient GitHubClient + Logger log.Logger + Opts Opts + Observer RunnersObserver +} + +func NewRunnersMetricsExporter(logger log.Logger, opts Opts, client GitHubClient, observer RunnersObserver) *RunnersMetricsExporter { + return &RunnersMetricsExporter{ + Logger: logger, + Opts: opts, + GHClient: client, + Observer: observer, + } +} + +func (c *RunnersMetricsExporter) Start(ctx context.Context) error { + if c.Opts.GitHubOrg == "" { + return errors.New("github org not configured") + } + if c.Opts.GitHubAPIToken == "" { + return errors.New("github token not configured") + } + + ticker := time.NewTicker(time.Duration(c.Opts.RunnersAPIPollSeconds) * time.Second) + go func() { + for { + select { + case <-ticker.C: + c.collectRunnersInformation(ctx) + case <-ctx.Done(): + _ = level.Info(c.Logger).Log("msg", "stopped polling for runner metrics") + return + } + } + }() + + return nil +} + +func (c *RunnersMetricsExporter) collectRunnersInformation(ctx context.Context) { + // Resetting, otherwise a certain label combination might retain its old value despite not being present in the pool + // For example, if there are no busy runners then group[true] will be empty and the old value of group[true] will + // continue to be reported rather than set to 0 as expected. Same would be true if API calls fail so we reset first. + c.Observer.ResetRegisteredRunnersTotal() + + allRunners := make(map[string][]*github.Runner) + runnerGroups, err := c.GHClient.GetOrganisationRunnerGroups(ctx, c.Opts.GitHubOrg) + + if err != nil { + _ = level.Error(c.Logger).Log("msg", "unable to retrieve runner groups", "error", err.Error()) + return + } + + for _, runnerGroup := range runnerGroups { + groupRunners, err := c.GHClient.GetGroupRunners(ctx, *runnerGroup.ID, c.Opts.GitHubOrg) + + if err != nil { + _ = level.Error(c.Logger).Log("msg", "unable to retrieve organisation runners' info", "error", err.Error()) + return + } + + allRunners[*runnerGroup.Name] = groupRunners + } + + // Collect information from the Enterprise runners, if an Enterprise name has been configured. + // Requires the GitHub API Token to have manage_runners:enterprise scope. + if c.Opts.GitHubEnterprise != "" { + enterpriseRunners, err := c.GHClient.GetEnterpriseRunners(ctx, c.Opts.GitHubEnterprise) + + // We are putting the enterprise runners into a fake runner group named after the enterprise + // This is because we already have that name in Grafana and also because there is no way in the API at the moment + // to tie them to their real runner group + allRunners[c.Opts.GitHubEnterprise] = enterpriseRunners + + if err != nil { + _ = level.Error(c.Logger).Log("msg", "unable to retrieve enterprise runners' info", "error", err.Error()) + return + } + } + + for group, runners := range allRunners { + for _, runner := range runners { + c.Observer.IncreaseRegisteredRunnersTotal(runner.GetBusy(), runner.GetStatus(), group) + } + } +} diff --git a/internal/server/runners_metrics_exporter_test.go b/internal/server/runners_metrics_exporter_test.go new file mode 100644 index 0000000..a6cc386 --- /dev/null +++ b/internal/server/runners_metrics_exporter_test.go @@ -0,0 +1,219 @@ +package server_test + +import ( + "context" + "errors" + "github.com/cpanato/github_actions_exporter/internal/server" + "github.com/go-kit/log" + "github.com/google/go-github/v47/github" + "github.com/stretchr/testify/assert" + "os" + "sync" + "testing" + "time" +) + +var ( + logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout)) + opts = server.Opts{ + GitHubOrg: "gh-org", + GitHubEnterprise: "gh-enterprise", + RunnersAPIPollSeconds: 1, + RunnersMetricsEnabled: true, + GitHubAPIToken: "fake", + } +) + +const ( + offline = "offline" + online = "online" +) + +type key struct { + runnerGroup string + busy bool + status string +} + +type TestGitHubClient struct { + runnerGroups []*github.RunnerGroup + runnersPerGroup map[int64][]*github.Runner + enterpriseRunners []*github.Runner + + getRunnerGroupsError error + getEnterpriseRunnersError error + getGroupRunnersError error +} + +func (t *TestGitHubClient) GetOrganisationRunnerGroups(ctx context.Context, orgName string) ([]*github.RunnerGroup, error) { + return t.runnerGroups, t.getRunnerGroupsError +} + +func (t *TestGitHubClient) GetEnterpriseRunners(ctx context.Context, enterpriseName string) ([]*github.Runner, error) { + return t.enterpriseRunners, t.getEnterpriseRunnersError +} + +func (t *TestGitHubClient) GetGroupRunners(ctx context.Context, groupID int64, orgName string) ([]*github.Runner, error) { + return t.runnersPerGroup[groupID], t.getGroupRunnersError +} + +func (t *TestGitHubClient) GetActionsBillingOrg(ctx context.Context, org string) (*github.ActionBilling, error) { + return nil, errors.New("GetActionsBillingOrg should not be called") +} + +func (t *TestGitHubClient) GetActionsBillingUser(ctx context.Context, user string) (*github.ActionBilling, error) { + return nil, errors.New("GetActionsBillingUser should not be called") +} + +type TestRunnerObserver struct { + // Map of busy -> status -> runner group to hold precise counts per label combination + metrics *sync.Map +} + +func (t *TestRunnerObserver) ResetRegisteredRunnersTotal() { + t.metrics.Range(func(key, value any) bool { + t.metrics.Delete(key) + return true + }) +} + +func (t *TestRunnerObserver) IncreaseRegisteredRunnersTotal(busy bool, status string, runnerGroup string) { + k := key{busy: busy, status: status, runnerGroup: runnerGroup} + value, found := t.metrics.Load(k) + if !found { + t.metrics.Store(k, 1) + } else { + t.metrics.Store(k, value.(int)+1) + } +} + +func TestRunnersMetricsExporter_collectRunnersInformation(t *testing.T) { + ghClient := TestGitHubClient{ + runnerGroups: []*github.RunnerGroup{runnerGroup(1, "group_one"), runnerGroup(2, "group_two")}, + runnersPerGroup: map[int64][]*github.Runner{ + // Busy but offline - those are returned from the API sometimes + 1: {runner(1, "i-1", true, offline)}, + 2: {runner(1, "i-a", false, online), runner(2, "i-b", false, online), runner(3, "i-c", false, online)}, + }, + enterpriseRunners: []*github.Runner{runner(1, "e-1", false, offline), runner(2, "e-2", false, online)}, + } + + observer := TestRunnerObserver{metrics: &sync.Map{}} + exporter := server.NewRunnersMetricsExporter(logger, opts, &ghClient, &observer) + + err := exporter.Start(context.Background()) + assert.NoError(t, err, "exporter could not be started") + + // Time for metrics to be retrieved by the ticket + time.Sleep(time.Millisecond * 1200) + + value, found := observer.metrics.Load(key{runnerGroup: "group_one", busy: true, status: offline}) + assert.True(t, found) + assert.Equal(t, 1, value) + + value, found = observer.metrics.Load(key{runnerGroup: "group_two", busy: false, status: online}) + assert.True(t, found) + assert.Equal(t, 3, value) + + value, found = observer.metrics.Load(key{runnerGroup: "gh-enterprise", busy: false, status: offline}) + assert.True(t, found) + assert.Equal(t, 1, value) + + value, found = observer.metrics.Load(key{runnerGroup: "gh-enterprise", busy: false, status: online}) + assert.True(t, found) + assert.Equal(t, 1, value) +} + +func TestRunnersMetricsExporter_StartError(t *testing.T) { + + tests := []struct { + name string + incompleteOpts server.Opts + expectedError string + }{ + { + name: "Start_noOrgName", expectedError: "github org not configured", incompleteOpts: server.Opts{ + GitHubOrg: "", + GitHubAPIToken: "", + }}, + { + name: "Start_noAPIToken", expectedError: "github token not configured", incompleteOpts: server.Opts{ + GitHubOrg: "gh-org", + GitHubAPIToken: "", + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + exporter := server.NewRunnersMetricsExporter(logger, test.incompleteOpts, nil, nil) + + err := exporter.Start(context.Background()) + assert.ErrorContains(t, err, test.expectedError) + }) + } +} + +// This test validates that existing metrics are cleared in the case of an API error to not produce misleading data +func TestRunnersMetricsExporter_collectRunnersInformationApiError(t *testing.T) { + tests := []struct { + name string + ghClient server.GitHubClient + }{ + { + name: "getRunnerGroups_error", ghClient: &TestGitHubClient{ + getRunnerGroupsError: errors.New("expected API error"), + }}, + { + name: "getGroupRunners_error", ghClient: &TestGitHubClient{ + getGroupRunnersError: errors.New("expected API error"), + }}, + { + name: "getEnterpriseRunners_error", ghClient: &TestGitHubClient{ + getEnterpriseRunnersError: errors.New("expected API error"), + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Start with existing metrics to ensure they are cleared when errors occur + metrics := &sync.Map{} + k := key{runnerGroup: "g1", busy: true, status: "offline"} + metrics.Store(k, 3) + observer := TestRunnerObserver{metrics: metrics} + exporter := server.NewRunnersMetricsExporter(logger, opts, test.ghClient, &observer) + + err := exporter.Start(context.Background()) + assert.NoError(t, err, "exporter could not be started") + + // Time for metrics to be retrieved by the ticket + time.Sleep(time.Millisecond * 1200) + + _, found := observer.metrics.Load(k) + assert.False(t, found) + }) + } +} + +func runner(id int64, name string, busy bool, status string) *github.Runner { + return &github.Runner{ + ID: github.Int64(id), + Name: github.String(name), + OS: github.String("linux"), + Status: github.String(status), + Busy: github.Bool(busy), + Labels: []*github.RunnerLabels{}, + } +} + +func runnerGroup(id int64, name string) *github.RunnerGroup { + return &github.RunnerGroup{ + ID: github.Int64(id), + Name: github.String(name), + Visibility: github.String("public"), + Default: github.Bool(false), + SelectedRepositoriesURL: github.String("https://fake-url/repos"), + RunnersURL: github.String("https://fake-url/id/runners"), + Inherited: github.Bool(false), + AllowsPublicRepositories: github.Bool(false), + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 2f94f38..6dd10ed 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "github.com/google/go-github/v47/github" + "golang.org/x/oauth2" "net" "net/http" "strings" @@ -24,43 +26,65 @@ type Opts struct { // GitHub API token. GitHubAPIToken string GitHubOrg string + GitHubEnterprise string GitHubUser string BillingAPIPollSeconds int + RunnersAPIPollSeconds int + BillingMetricsEnabled bool + RunnersMetricsEnabled bool } type Server struct { - logger log.Logger - server *http.Server - workflowMetricsExporter *WorkflowMetricsExporter - billingExporter *BillingMetricsExporter - opts Opts + logger log.Logger + server *http.Server + opts Opts } func NewServer(logger log.Logger, opts Opts) *Server { mux := http.NewServeMux() + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: opts.GitHubAPIToken}, + ) + tc := oauth2.NewClient(ctx, ts) + ghClient := NewGitHubClient(&opts, github.NewClient(tc)) + observer := PrometheusObserver{} + httpServer := &http.Server{ Handler: mux, ReadHeaderTimeout: 10 * time.Second, } - billingExporter := NewBillingMetricsExporter(logger, opts) - err := billingExporter.StartOrgBilling(context.TODO()) - if err != nil { - _ = level.Info(logger).Log("msg", fmt.Sprintf("not exporting org billing: %v", err)) + if opts.BillingMetricsEnabled { + billingExporter := NewBillingMetricsExporter(logger, opts, ghClient) + err := billingExporter.StartOrgBilling(context.TODO()) + if err != nil { + _ = level.Info(logger).Log("msg", fmt.Sprintf("not exporting org billing: %v", err)) + } + err = billingExporter.StartUserBilling(context.TODO()) + if err != nil { + _ = level.Info(logger).Log("msg", fmt.Sprintf("not exporting user billing: %v", err)) + } + } else { + _ = level.Info(logger).Log("msg", "billing metrics are disabled") } - err = billingExporter.StartUserBilling(context.TODO()) - if err != nil { - _ = level.Info(logger).Log("msg", fmt.Sprintf("not exporting user billing: %v", err)) + + if opts.RunnersMetricsEnabled { + runnersExporter := NewRunnersMetricsExporter(logger, opts, ghClient, &observer) + err := runnersExporter.Start(context.TODO()) + if err != nil { + _ = level.Info(logger).Log("msg", fmt.Sprintf("not exporting runners: %v", err)) + } + } else { + _ = level.Info(logger).Log("msg", "runners metrics are disabled") } - workflowExporter := NewWorkflowMetricsExporter(logger, opts) + workflowExporter := NewWorkflowMetricsExporter(logger, opts, &observer) server := &Server{ - logger: logger, - server: httpServer, - workflowMetricsExporter: workflowExporter, - billingExporter: billingExporter, - opts: opts, + logger: logger, + server: httpServer, + opts: opts, } mux.Handle(opts.MetricsPath, promhttp.Handler()) diff --git a/internal/server/workflow_metrics_exporter.go b/internal/server/workflow_metrics_exporter.go index afdb48c..936ba40 100644 --- a/internal/server/workflow_metrics_exporter.go +++ b/internal/server/workflow_metrics_exporter.go @@ -29,11 +29,11 @@ type WorkflowMetricsExporter struct { Cache *cache.Cache } -func NewWorkflowMetricsExporter(logger log.Logger, opts Opts) *WorkflowMetricsExporter { +func NewWorkflowMetricsExporter(logger log.Logger, opts Opts, observer WorkflowObserver) *WorkflowMetricsExporter { return &WorkflowMetricsExporter{ Logger: logger, Opts: opts, - PrometheusObserver: &PrometheusObserver{}, + PrometheusObserver: observer, Cache: cache.New(24*time.Hour, 30*time.Minute), } } diff --git a/main.go b/main.go index ef9c3bd..f4c640b 100644 --- a/main.go +++ b/main.go @@ -23,9 +23,13 @@ var ( ghWebHookPath = kingpin.Flag("web.gh-webhook-path", "Path that will be called by the GitHub webhook.").Default("/gh_event").String() githubWebhookToken = kingpin.Flag("gh.github-webhook-token", "GitHub Webhook Token.").Envar("GITHUB_WEBHOOK_TOKEN").Default("").String() gitHubAPIToken = kingpin.Flag("gh.github-api-token", "GitHub API Token.").Envar("GITHUB_API_TOKEN").Default("").String() - gitHubOrg = kingpin.Flag("gh.github-org", "GitHub Organization.").Default("").String() + gitHubOrg = kingpin.Flag("gh.github-org", "GitHub Organization.").Envar("GITHUB_ORG").Default("").String() + gitHubEnterprise = kingpin.Flag("gh.github-enterprise", "GitHub Enterprise.").Envar("GITHUB_ENTERPRISE").Default("").String() gitHubUser = kingpin.Flag("gh.github-user", "GitHub User.").Default("").String() gitHubBillingPollingSeconds = kingpin.Flag("gh.billing-poll-seconds", "Frequency at which to poll billing API.").Default("5").Int() + gitHubBillingMetricsEnabled = kingpin.Flag("gh.billing-metrics-enabled", "Whether to gather billing metrics.").Envar("GITHUB_BILLING_METRICS_ENABLED").Default("false").Bool() + githubRunnersPollingSeconds = kingpin.Flag("gh.runners-poll-seconds", "Frequency at which to poll the runners API.").Default("60").Int() + gitHubRunnersMetricsEnabled = kingpin.Flag("gh.runners-metrics-enabled", "Whether to gather runners metrics.").Envar("GITHUB_RUNNERS_METRICS_ENABLED").Default("false").Bool() ) func init() { @@ -59,7 +63,11 @@ func main() { GitHubAPIToken: *gitHubAPIToken, GitHubUser: *gitHubUser, GitHubOrg: *gitHubOrg, + GitHubEnterprise: *gitHubEnterprise, BillingAPIPollSeconds: *gitHubBillingPollingSeconds, + BillingMetricsEnabled: *gitHubBillingMetricsEnabled, + RunnersAPIPollSeconds: *githubRunnersPollingSeconds, + RunnersMetricsEnabled: *gitHubRunnersMetricsEnabled, }) go func() { err := srv.Serve(context.Background())