diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1f4e114 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Go + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: 1.18 + - run: go test -v ./... diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..acfe145 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "context" + "errors" + "flag" + "os/signal" + "time" + + "github.com/takescoop/terraform-cloud-metrics-exporter/internal/exporter" +) + +func Exec(args []string) error { + ctx := context.Background() + + ctx, cancel := signal.NotifyContext(ctx) + defer cancel() + + flags := flag.NewFlagSet("terraform-cloud-metrics-exporter", flag.ExitOnError) + + var organization string + flags.StringVar(&organization, "organization", "", "The Terraform Cloud organization") + + var interval time.Duration + flags.DurationVar(&interval, "interval", time.Minute, "The interval to fetch agent data") + + if err := flags.Parse(args); err != nil { + return err + } + + if organization == "" { + return errors.New("organization is required") + } + + return exporter.New(&exporter.Config{ + Organization: organization, + Interval: interval, + }).Start(ctx) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d2911b --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/takescoop/terraform-cloud-metrics-exporter + +go 1.18 + +require ( + github.com/hashicorp/go-tfe v1.2.0 + github.com/stretchr/testify v1.7.1 + go.opentelemetry.io/otel v1.7.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.30.0 + go.opentelemetry.io/otel/metric v0.30.0 + go.opentelemetry.io/otel/sdk/metric v0.30.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.0 // indirect + github.com/hashicorp/go-slug v0.8.0 // indirect + github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/otel/sdk v1.7.0 // indirect + go.opentelemetry.io/otel/trace v1.7.0 // indirect + golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 // indirect + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c97cfdd --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4= +github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-slug v0.8.0 h1:h7AGtXVAI/cJ/Wwa/JQQaftQnWQmZbAzkzgZeZVVmLw= +github.com/hashicorp/go-slug v0.8.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= +github.com/hashicorp/go-tfe v1.2.0 h1:L29LCo/qIjOqBUjfiUsZSAzBdxmsOLzwnwZpA+68WW8= +github.com/hashicorp/go-tfe v1.2.0/go.mod h1:tJF/OlAXzVbmjiimAPLplSLgwg6kZDUOy0MzHuMwvF4= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d h1:9ARUJJ1VVynB176G1HCwleORqCaXm/Vx0uUi0dL26I0= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.opentelemetry.io/otel v1.7.0 h1:Z2lA3Tdch0iDcrhJXDIlC94XE+bxok1F9B+4Lz/lGsM= +go.opentelemetry.io/otel v1.7.0/go.mod h1:5BdUoMIz5WEs0vt0CUEMtSSaTSHBBVwrhnz7+nrD5xk= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.30.0 h1:2glg1ZFVVZf47zFuX0iwBPPid4tqzBYYWTVVu0pc+us= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.30.0/go.mod h1:LGFXSl/Js7uN7mDcrzCcHVj48JOtoYDjm4oUI4dLif0= +go.opentelemetry.io/otel/metric v0.30.0 h1:Hs8eQZ8aQgs0U49diZoaS6Uaxw3+bBE3lcMUKBFIk3c= +go.opentelemetry.io/otel/metric v0.30.0/go.mod h1:/ShZ7+TS4dHzDFmfi1kSXMhMVubNoP0oIaBp70J6UXU= +go.opentelemetry.io/otel/sdk v1.7.0 h1:4OmStpcKVOfvDOgCt7UriAPtKolwIhxpnSNI/yK+1B0= +go.opentelemetry.io/otel/sdk v1.7.0/go.mod h1:uTEOTwaqIVuTGiJN7ii13Ibp75wJmYUDe374q6cZwUU= +go.opentelemetry.io/otel/sdk/metric v0.30.0 h1:XTqQ4y3erR2Oj8xSAOL5ovO5011ch2ELg51z4fVkpME= +go.opentelemetry.io/otel/sdk/metric v0.30.0/go.mod h1:8AKFRi5HyvTR0RRty3paN1aMC9HMT+NzcEhw/BLkLX8= +go.opentelemetry.io/otel/trace v1.7.0 h1:O37Iogk1lEkMRXewVtZ1BBTVn5JEp8GrJvP92bJqC6o= +go.opentelemetry.io/otel/trace v1.7.0/go.mod h1:fzLSB9nqR2eXzxPXb2JW9IKE+ScyXA48yyE4TNvoHqU= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agentstatus/agents.go b/internal/agentstatus/agents.go new file mode 100644 index 0000000..99415b5 --- /dev/null +++ b/internal/agentstatus/agents.go @@ -0,0 +1,52 @@ +package agentstatus + +import ( + "context" + + "github.com/takescoop/terraform-cloud-metrics-exporter/internal/tfcloud" +) + +// AgentPool represents a Terraform Cloud agent pool, including its agents. +type AgentPool struct { + *tfcloud.AgentPool + Agents []*tfcloud.Agent +} + +// ByStatus returns the number of agents in each status for the pool. +func (a *AgentPool) ByStatus() map[string]uint { + status := make(map[string]uint) + + for _, agent := range a.Agents { + status[agent.Status]++ + } + + return status +} + +// Summary is a summary of the current status of agent pools and their agents. +type Summary struct { + Pools []*AgentPool +} + +// Get returns a summary for the specified organization. +func Get(ctx context.Context, client *tfcloud.Client, organization string) (*Summary, error) { + pools, err := client.ListAgentPools(ctx, organization) + if err != nil { + return nil, err + } + + summary := &Summary{} + for _, pool := range pools { + agents, err := client.ListAgents(ctx, pool.ID) + if err != nil { + return nil, err + } + + summary.Pools = append(summary.Pools, &AgentPool{ + AgentPool: pool, + Agents: agents, + }) + } + + return summary, nil +} diff --git a/internal/exporter/main.go b/internal/exporter/main.go new file mode 100644 index 0000000..a06402c --- /dev/null +++ b/internal/exporter/main.go @@ -0,0 +1,40 @@ +package exporter + +import ( + "context" + "log" + "time" +) + +type Config struct { + Interval time.Duration + Organization string +} + +type Exporter struct { + interval time.Duration + organization string +} + +func New(config *Config) *Exporter { + return &Exporter{ + interval: config.Interval, + organization: config.Organization, + } +} + +func (e *Exporter) Start(ctx context.Context) error { + ticker := time.NewTicker(e.interval) + + log.Printf("starting exporter with interval: %s", e.interval) + + for { + select { + case <-ctx.Done(): + log.Println("context canceled, stopping exporter") + return ctx.Err() + case <-ticker.C: + log.Println("fetching agent data") + } + } +} diff --git a/internal/tfcloud/agent.go b/internal/tfcloud/agent.go new file mode 100644 index 0000000..9d937f3 --- /dev/null +++ b/internal/tfcloud/agent.go @@ -0,0 +1,60 @@ +package tfcloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// ListAgents lists the agents in a given pool +// TODO: use go-tfe when support for this API is added +func (c *Client) ListAgents(ctx context.Context, poolId string) ([]*Agent, error) { + url := fmt.Sprintf("%s%sagent-pools/%s/agents", c.config.Address, c.config.BasePath, poolId) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.config.Token)) + + resp, err := c.config.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list agents: %d", resp.StatusCode) + } + + var result agentsResponse + defer resp.Body.Close() + + if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result.Agents(), nil +} + +type Agent struct { + Name string `json:"name"` + Status string `json:"status"` +} + +type agentsResponse struct { + Data []struct { + Attributes *Agent `json:"attributes"` + } `json:"data"` +} + +func (r *agentsResponse) Agents() []*Agent { + agents := make([]*Agent, len(r.Data)) + + for i, d := range r.Data { + agents[i] = d.Attributes + } + + return agents +} diff --git a/internal/tfcloud/agent_pool.go b/internal/tfcloud/agent_pool.go new file mode 100644 index 0000000..9d462a4 --- /dev/null +++ b/internal/tfcloud/agent_pool.go @@ -0,0 +1,18 @@ +package tfcloud + +import ( + "context" + + "github.com/hashicorp/go-tfe" +) + +type AgentPool = tfe.AgentPool + +func (c *Client) ListAgentPools(ctx context.Context, organization string) ([]*AgentPool, error) { + pools, err := c.client.AgentPools.List(ctx, organization, &tfe.AgentPoolListOptions{}) + if err != nil { + return nil, err + } + + return pools.Items, nil +} diff --git a/internal/tfcloud/agent_test.go b/internal/tfcloud/agent_test.go new file mode 100644 index 0000000..4a5b798 --- /dev/null +++ b/internal/tfcloud/agent_test.go @@ -0,0 +1,77 @@ +package tfcloud + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/go-tfe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListAgents_ok(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/v2/agent-pools/the-pool-id/agents", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Write([]byte(` + { + "data": [ + { + "attributes": { + "name": "agent-1", + "status": "running" + } + } + ] + } + `)) + }) + + server := httptest.NewServer(mux) + + t.Cleanup(server.Close) + + config := tfe.DefaultConfig() + config.Address = server.URL + config.Token = "fake" + + client, err := New(config) + require.NoError(t, err) + + agents, err := client.ListAgents(context.Background(), "the-pool-id") + require.NoError(t, err) + + assert.Equal(t, []*Agent{ + { + Name: "agent-1", + Status: "running", + }, + }, agents) +} + +func TestListAgents_error(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/v2/agent-pools/the-pool-id/agents", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + w.WriteHeader(http.StatusForbidden) + }) + + server := httptest.NewServer(mux) + + t.Cleanup(server.Close) + + config := tfe.DefaultConfig() + config.Address = server.URL + config.Token = "fake" + + client, err := New(config) + require.NoError(t, err) + + _, err = client.ListAgents(context.Background(), "the-pool-id") + require.Error(t, err) +} diff --git a/internal/tfcloud/client.go b/internal/tfcloud/client.go new file mode 100644 index 0000000..7890046 --- /dev/null +++ b/internal/tfcloud/client.go @@ -0,0 +1,24 @@ +package tfcloud + +import "github.com/hashicorp/go-tfe" + +type Client struct { + client *tfe.Client + config *tfe.Config +} + +func New(config *tfe.Config) (*Client, error) { + if config == nil { + config = tfe.DefaultConfig() + } + + client, err := tfe.NewClient(config) + if err != nil { + return nil, err + } + + return &Client{ + client: client, + config: config, + }, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..867523e --- /dev/null +++ b/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "log" + "os" + + "github.com/takescoop/terraform-cloud-metrics-exporter/cmd" +) + +func main() { + if err := cmd.Exec(os.Args[1:]); err != nil { + log.Fatal(err) + } + + // ctx := context.Background() + + // client, err := tfcloud.New(nil) + // if err != nil { + // panic(err) + // } + + // exporter, err := stdoutmetric.New(stdoutmetric.WithPrettyPrint()) + // if err != nil { + // panic(err) + // } + + // pusher := controller.New( + // processor.NewFactory( + // simple.NewWithInexpensiveDistribution(), + // exporter, + // ), + // controller.WithExporter(exporter), + // ) + + // if err = pusher.Start(ctx); err != nil { + // panic(err) + // } + + // global.SetMeterProvider(pusher) + // meter := global.Meter("terraform_cloud") + + // gauge, err := meter.AsyncInt64().Gauge( + // "agents", + // instrument.WithDescription("The count of Terraform Cloud agents"), + // ) + + // if err != nil { + // panic(err) + // } + + // summary, err := agentstatus.Get(ctx, client, "takescoop") + // if err != nil { + // panic(err) + // } + + // for _, pool := range summary.Pools { + // for status, count := range pool.ByStatus() { + // gauge.Observe( + // ctx, + // int64(count), + // attribute.String("pool", pool.Name), + // attribute.String("status", status), + // ) + // } + // } + + // if err = pusher.Stop(ctx); err != nil { + // panic(err) + // } +}