diff --git a/tools/terraform/.gitignore b/tools/terraform/.gitignore new file mode 100644 index 0000000000..979d27ce54 --- /dev/null +++ b/tools/terraform/.gitignore @@ -0,0 +1,2 @@ +provider_code_spec.json +tf/terraformrc-dev-override diff --git a/tools/terraform/Makefile b/tools/terraform/Makefile new file mode 100644 index 0000000000..26cf94ea20 --- /dev/null +++ b/tools/terraform/Makefile @@ -0,0 +1,42 @@ +#! /usr/bin/env make +# +# While not very elegant as far as Makefiles go, this Makefile does +# contain the basic commands to get you terraforming your FleetDM +# teams. See the README for details. + +provider_code_spec.json: openapi.json + tfplugingen-openapi generate --config generator.yaml --output ./provider_code_spec.json ./openapi.json + +provider/team_resource_gen.go: provider_code_spec.json + tfplugingen-framework generate resources --input provider_code_spec.json --output ./provider --package provider + +.PHONY: install build test tidy gen plan apply + +gen: provider/team_resource_gen.go + +install: gen + go install ./... + +build: gen + go build ./... + +test: gen + @test -n "$(FLEETDM_APIKEY)" || (echo "FLEETDM_APIKEY is not set" && exit 1) + FLEETDM_URL='https://rbx.cloud.fleetdm.com' TF_ACC=1 go test ./... + +tidy: + go mod tidy + +plan: tf/terraformrc-dev-override + cd tf && TF_CLI_CONFIG_FILE=./terraformrc-dev-override terraform plan + +apply: tf/terraformrc-dev-override + cd tf && TF_CLI_CONFIG_FILE=./terraformrc-dev-override terraform apply -auto-approve + +tf/terraformrc-dev-override: + @echo "provider_installation { \\n\ + dev_overrides { \\n\ + \"fleetdm.com/tf/fleetdm\" = \"$$HOME/go/bin\" \\n\ + } \\n\ + direct {} \\n\ +}" > $@ diff --git a/tools/terraform/README.md b/tools/terraform/README.md new file mode 100644 index 0000000000..8969fc4f70 --- /dev/null +++ b/tools/terraform/README.md @@ -0,0 +1,61 @@ +# Terraform Provider for FleetDM Teams + +This is a Terraform provider for managing FleetDM teams. When you have +100+ teams in FleetDM, and manually managing them is not feasible. The +primary setting of concern is the team's "agent options" which +consists of some settings and command line flags. These (potentially +dangerously) configure FleetDM all machines. + +## Usage + +All the interesting commands are in the Makefile. If you just want +to use the thing, see `make install` and `make apply`. + +Note that if you run `terraform apply` in the `tf` directory, it won't +work out of the box. That's because you need to set the +`TF_CLI_CONFIG_FILE` environment variable to point to a file that +enables local development of this provider. The Makefile does this +for you. + +Future work: actually publish this provider. + +## Development + +### Code Generation + +See `make gen`. It will create team_resource_gen.go, which defines +the types that Terraform knows about. This is automatically run +when you run `make install`. + +### Running locally + +See `make plan` and `make apply`. + +### Running Tests + +You probably guessed this. See `make test`. Note that these tests +require a FleetDM server to be running. The tests will create teams +and delete them when they're done. The tests also require a valid +FleetDM API token to be in the `FLEETDM_APIKEY` environment variable. + +### Debugging locally + +The basic idea is that you want to run the provider in a debugger. +When terraform normally runs, it will execute the provider a few +times in the course of operations. What you want to do instead is +to run the provider in debug mode and tell terraform to contact it. + +To do this, you need to start the provider with the `-debug` flag +inside a debugger. You'll also need to give it the FLEETDM_APIKEY +environment variable. The provider will print out a big environment +variable that you can copy and paste to your command line. + +When you run `terraform apply` or the like, you'll invoke it with +that big environment variable. It'll look something like + +```shell +TF_REATTACH_PROVIDERS='{"fleetdm.com/tf/fleetdm":{"Protocol":"grpc","ProtocolVersion":6,"Pid":33644,"Test":true,"Addr":{"Network":"unix","String":"/var/folders/32/xw2p1jtd4w10hpnsyrb_4nmm0000gq/T/plugin771405263"}}}' terraform apply +``` + +With this magic, terraform will look to your provider that's running +in a debugger. You get breakpoints and the goodness of a debugger. diff --git a/tools/terraform/fleetdm_client/fleetdm_client.go b/tools/terraform/fleetdm_client/fleetdm_client.go new file mode 100644 index 0000000000..002865cdbe --- /dev/null +++ b/tools/terraform/fleetdm_client/fleetdm_client.go @@ -0,0 +1,301 @@ +package fleetdm_client + +// This file gives us a nice API to use to call FleetDM's API. It's focused +// only on teams. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const prefix = "/api/v1/fleet" +const teamPrefix = prefix + "/teams" + +type Team struct { + Name string `json:"name"` + ID int64 `json:"id"` + Description string `json:"description"` + AgentOptions interface{} `json:"agent_options"` + Scripts interface{} `json:"scripts"` + Secrets []struct { + Secret string `json:"secret"` + Created time.Time `json:"created_at"` + TeamID int `json:"team_id"` + } `json:"secrets"` +} + +type TeamCreate struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +type TeamGetResponse struct { + Team Team `json:"team"` +} + +type TeamQueryResponse struct { + Teams []Team `json:"teams"` +} + +// FleetDMClient is a FleetDM HTTP client that overrides http.DefaultClient. +type FleetDMClient struct { + *http.Client + URL string + APIKey string +} + +// NewFleetDMClient creates a new instance of FleetDMClient with the provided +// URL and API key. +func NewFleetDMClient(url, apiKey string) *FleetDMClient { + // Ensure the URL ends with a trailing slash for bizarre parsing reasons + if !strings.HasSuffix(url, "/") { + url += "/" + } + return &FleetDMClient{ + Client: http.DefaultClient, + URL: url, + APIKey: apiKey, + } +} + +// Do will add necessary headers and call the http.Client.Do method. +func (c *FleetDMClient) do(req *http.Request, query *url.Values) (*http.Response, error) { + // Add the API key to the request header + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.APIKey)) + req.Header.Add("Accept", `application/json`) + // Set the request URL based on the client URL + baseURL, err := url.Parse(c.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse base URL %s: %w", c.URL, err) + } + req.URL = baseURL.JoinPath(req.URL.Path) + if query != nil { + req.URL.RawQuery = query.Encode() + } + // Send the request using the embedded http.Client + return c.Client.Do(req) +} + +// TeamNameToID will return the ID of a team given the name. +func (c *FleetDMClient) TeamNameToID(name string) (int64, error) { + req, err := http.NewRequest(http.MethodGet, teamPrefix, nil) + if err != nil { + return 0, fmt.Errorf("failed to create GET request for %s: %w", teamPrefix, err) + } + vals := make(url.Values) + vals.Add("query", name) + resp, err := c.do(req, &vals) + if err != nil { + return 0, fmt.Errorf("failed to GET %s %s: %w", teamPrefix, name, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("failed to get team: %s %s", name, resp.Status) + } + + var teamqry TeamQueryResponse + b, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("failed to read team response: %w", err) + } + err = json.Unmarshal(b, &teamqry) + if err != nil { + return 0, fmt.Errorf("failed to decode get team response: %w", err) + } + + for _, team := range teamqry.Teams { + if team.Name == name { + return team.ID, nil + } + } + + return 0, fmt.Errorf("team %s not found", name) +} + +// CreateTeam creates a new team with the provided name and description. +func (c *FleetDMClient) CreateTeam(name string, description string) (*TeamGetResponse, error) { + teamCreate := TeamCreate{ + Name: name, + Description: description, + } + nameJson, err := json.Marshal(teamCreate) + if err != nil { + return nil, fmt.Errorf("failed to create team body: %w", err) + } + req, err := http.NewRequest(http.MethodPost, teamPrefix, bytes.NewReader(nameJson)) + if err != nil { + return nil, fmt.Errorf("failed to create POST request for %s name %s: %w", + req.URL.String(), name, err) + } + resp, err := c.do(req, nil) + if err != nil { + return nil, fmt.Errorf("failed to POST %s name %s: %w", + req.URL.String(), name, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to create team %s: %s %s", name, resp.Status, string(b)) + } + + var newTeam TeamGetResponse + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read team response: %w", err) + } + err = json.Unmarshal(b, &newTeam) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &newTeam, nil +} + +// GetTeam returns the team with the provided ID. +func (c *FleetDMClient) GetTeam(id int64) (*TeamGetResponse, error) { + url := teamPrefix + "/" + strconv.FormatInt(id, 10) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create GET request for %s: %w", + url, err) + } + resp, err := c.do(req, nil) + if err != nil { + return nil, fmt.Errorf("failed to GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get team %d: %s %s", id, resp.Status, string(b)) + } + + var team TeamGetResponse + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read team response: %w", err) + } + err = json.Unmarshal(b, &team) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &team, nil +} + +// UpdateTeam updates the team with the provided ID with the provided name and description. +func (c *FleetDMClient) UpdateTeam(id int64, name, description *string) (*TeamGetResponse, error) { + if name == nil && description == nil { + return nil, fmt.Errorf("both name and description are nil") + } + + url := teamPrefix + "/" + strconv.FormatInt(id, 10) + var teamUpdate TeamCreate + if name != nil { + teamUpdate.Name = *name + } + if description != nil { + teamUpdate.Description = *description + } + updateJson, err := json.Marshal(teamUpdate) + if err != nil { + return nil, fmt.Errorf("failed to update team body request: %w", err) + } + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(updateJson)) + if err != nil { + return nil, fmt.Errorf("failed to create PATCH request for %s body %s: %w", + url, updateJson, err) + } + resp, err := c.do(req, nil) + if err != nil { + return nil, fmt.Errorf("failed to PATCH %s body %s: %w", + url, updateJson, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed PATCH %s body %s: %s %s", + url, updateJson, resp.Status, string(b)) + } + + var newTeam TeamGetResponse + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read team response: %w", err) + } + err = json.Unmarshal(b, &newTeam) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &newTeam, nil +} + +// UpdateAgentOptions pretends that the agent options are a string, when it's really actually json. +// Strangely the body that comes back is a team, not just the agent options. +func (c *FleetDMClient) UpdateAgentOptions(id int64, ao string) (*TeamGetResponse, error) { + + // First verify it's actually json. + valid := json.Valid([]byte(ao)) + if !valid { + return nil, fmt.Errorf("agent_options isn't json: %s", ao) + } + + aoUrl := teamPrefix + "/" + strconv.FormatInt(id, 10) + "/" + "agent_options" + req, err := http.NewRequest(http.MethodPost, aoUrl, strings.NewReader(ao)) + if err != nil { + return nil, fmt.Errorf("failed to create agent_options POST request for %s id %d: %w", + teamPrefix, id, err) + } + resp, err := c.do(req, nil) + if err != nil { + return nil, fmt.Errorf("failed to POST agent_options %s id %d: %w", + teamPrefix, id, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to modify agent_options %d: %s %s", id, resp.Status, string(b)) + } + + var team TeamGetResponse + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read team response: %w", err) + } + err = json.Unmarshal(b, &team) + if err != nil { + return nil, fmt.Errorf("failed to decode agent_options team response: %w", err) + } + return &team, nil +} + +// DeleteTeam deletes the team with the provided ID. +func (c *FleetDMClient) DeleteTeam(id int64) error { + url := teamPrefix + "/" + strconv.FormatInt(id, 10) + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("failed to create DELETE request for %s: %w", url, err) + } + resp, err := c.do(req, nil) + if err != nil { + return fmt.Errorf("failed to DELETE %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return fmt.Errorf("failed to delete team %d: %s", id, resp.Status) + } + + return nil +} diff --git a/tools/terraform/fleetdm_client/fleetdm_client_test.go b/tools/terraform/fleetdm_client/fleetdm_client_test.go new file mode 100644 index 0000000000..364268d0cf --- /dev/null +++ b/tools/terraform/fleetdm_client/fleetdm_client_test.go @@ -0,0 +1,93 @@ +package fleetdm_client + +import ( + "encoding/json" + "github.com/stretchr/testify/require" + "math/rand" + "os" + "testing" +) + +var letters = []rune("abcdefghijklmnopqrstuvwxyz") +var defaultDescription = "Awesome description" +var defaultAgentOptions = `{"config":{"decorators":{"load":["SELECT uuid AS host_uuid FROM system_info;","SELECT hostname AS hostname FROM system_info;"]},"options":{"disable_distributed":false,"distributed_interval":10,"distributed_plugin":"tls","distributed_tls_max_attempts":3,"logger_tls_endpoint":"/api/osquery/log","logger_tls_period":10,"pack_delimiter":"/"}},"overrides":{}}` + +func randTeam() string { + b := make([]rune, 6) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return "aaa-" + string(b) +} + +func TestBasic(t *testing.T) { + apiKey := os.Getenv("FLEETDM_APIKEY") + if apiKey == "" { + t.Skip("FLEETDM_APIKEY not set") + } + apiURL := os.Getenv("FLEETDM_URL") + if apiURL == "" { + t.Skip("FLEETDM_URL not set") + } + client := NewFleetDMClient(apiURL, apiKey) + require.NotNil(t, client) + + // Create a nice default team + teamName := randTeam() + team, err := client.CreateTeam(teamName, defaultDescription) + require.NoError(t, err) + require.NotNil(t, team) + require.Equal(t, teamName, team.Team.Name) + require.Equal(t, defaultDescription, team.Team.Description) + aoBytes, err := json.Marshal(team.Team.AgentOptions) + require.NoError(t, err) + require.Equal(t, defaultAgentOptions, string(aoBytes)) + + // And verify we can get that team back + teamGotten, err := client.GetTeam(team.Team.ID) + require.NoError(t, err) + require.NotNil(t, teamGotten) + require.Equal(t, team.Team.ID, teamGotten.Team.ID) + require.Equal(t, team.Team.Name, teamGotten.Team.Name) + require.Equal(t, teamName, teamGotten.Team.Name) + require.Equal(t, defaultDescription, teamGotten.Team.Description) + aoBytes, err = json.Marshal(teamGotten.Team.AgentOptions) + require.NoError(t, err) + require.Equal(t, defaultAgentOptions, string(aoBytes)) + + // Set different agent options + newAO := `{"command_line_flags":{"disable_events":true},"config":{"options":{"logger_tls_endpoint":"/test"}},"overrides":{}}` + teamAO, err := client.UpdateAgentOptions(team.Team.ID, newAO) + require.NoError(t, err) + require.NotNil(t, teamAO) + require.Equal(t, team.Team.ID, teamAO.Team.ID) + aoBytes, err = json.Marshal(teamAO.Team.AgentOptions) + require.NoError(t, err) + require.Equal(t, newAO, string(aoBytes)) + + // Set a different description and different name + newName := randTeam() + newDescription := "New description" + teamUp, err := client.UpdateTeam(team.Team.ID, &newName, &newDescription) + require.NoError(t, err) + require.NotNil(t, teamUp) + require.Equal(t, team.Team.ID, teamUp.Team.ID) + require.Equal(t, newName, teamUp.Team.Name) + require.Equal(t, newDescription, teamUp.Team.Description) + + // Lookup the team based on the new name + teamId, err := client.TeamNameToID(newName) + require.NoError(t, err) + require.NotEqual(t, 0, teamId) + require.Equal(t, teamUp.Team.ID, teamId) + require.Equal(t, newName, teamUp.Team.Name) + + // And finally, delete the team + err = client.DeleteTeam(team.Team.ID) + require.NoError(t, err) + + // And verify it's gone + teamDNE, err := client.GetTeam(team.Team.ID) + require.Error(t, err) + require.Nil(t, teamDNE) +} diff --git a/tools/terraform/generator.yaml b/tools/terraform/generator.yaml new file mode 100644 index 0000000000..ad61e218f0 --- /dev/null +++ b/tools/terraform/generator.yaml @@ -0,0 +1,23 @@ +provider: + name: fleetdm_teams + schema_ref: '#/components/schemas/Team' + +resources: + team: + create: + path: /api/v1/fleet/teams + method: POST + read: + path: /api/v1/fleet/teams/{id} + method: GET + update: + path: /api/v1/fleet/teams/{id} + method: PUT + delete: + path: /api/v1/fleet/teams/{id} + method: DELETE + schema: + attributes: + overrides: + name: + description: Team name diff --git a/tools/terraform/go.mod b/tools/terraform/go.mod new file mode 100644 index 0000000000..5dfa7fc71c --- /dev/null +++ b/tools/terraform/go.mod @@ -0,0 +1,66 @@ +module terraform-provider-fleetdm + +go 1.22 + +require ( + github.com/hashicorp/terraform-plugin-framework v1.7.0 + github.com/hashicorp/terraform-plugin-go v0.22.1 + github.com/hashicorp/terraform-plugin-log v0.9.0 + github.com/hashicorp/terraform-plugin-testing v1.7.0 + github.com/stretchr/testify v1.8.1 +) + +require ( + github.com/ProtonMail/go-crypto v1.1.0-alpha.0 // indirect + github.com/agext/levenshtein v1.2.2 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect + github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/hc-install v0.6.3 // indirect + github.com/hashicorp/hcl/v2 v2.20.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.20.0 // indirect + github.com/hashicorp/terraform-json v0.21.0 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.3 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zclconf/go-cty v1.14.3 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/mod v0.15.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.13.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tools/terraform/go.sum b/tools/terraform/go.sum new file mode 100644 index 0000000000..05a5a46324 --- /dev/null +++ b/tools/terraform/go.sum @@ -0,0 +1,228 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ProtonMail/go-crypto v1.1.0-alpha.0 h1:nHGfwXmFvJrSR9xu8qL7BkO4DqTHXE9N5vPhgY2I+j0= +github.com/ProtonMail/go-crypto v1.1.0-alpha.0/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= +github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +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/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= +github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/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-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= +github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= +github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= +github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.6.3 h1:yE/r1yJvWbtrJ0STwScgEnCanb0U9v7zp0Gbkmcoxqs= +github.com/hashicorp/hc-install v0.6.3/go.mod h1:KamGdbodYzlufbWh4r9NRo8y6GLHWZP2GBtdnms1Ln0= +github.com/hashicorp/hcl/v2 v2.20.0 h1:l++cRs/5jQOiKVvqXZm/P1ZEfVXJmvLS9WSVxkaeTb4= +github.com/hashicorp/hcl/v2 v2.20.0/go.mod h1:WmcD/Ym72MDOOx5F62Ly+leloeu6H7m0pG7VBiU6pQk= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/terraform-exec v0.20.0 h1:DIZnPsqzPGuUnq6cH8jWcPunBfY+C+M8JyYF3vpnuEo= +github.com/hashicorp/terraform-exec v0.20.0/go.mod h1:ckKGkJWbsNqFKV1itgMnE0hY9IYf1HoiekpuN0eWoDw= +github.com/hashicorp/terraform-json v0.21.0 h1:9NQxbLNqPbEMze+S6+YluEdXgJmhQykRyRNd+zTI05U= +github.com/hashicorp/terraform-json v0.21.0/go.mod h1:qdeBs11ovMzo5puhrRibdD6d2Dq6TyE/28JiU4tIQxk= +github.com/hashicorp/terraform-plugin-framework v1.7.0 h1:wOULbVmfONnJo9iq7/q+iBOBJul5vRovaYJIu2cY/Pw= +github.com/hashicorp/terraform-plugin-framework v1.7.0/go.mod h1:jY9Id+3KbZ17OMpulgnWLSfwxNVYSoYBQFTgsx044CI= +github.com/hashicorp/terraform-plugin-go v0.22.1 h1:iTS7WHNVrn7uhe3cojtvWWn83cm2Z6ryIUDTRO0EV7w= +github.com/hashicorp/terraform-plugin-go v0.22.1/go.mod h1:qrjnqRghvQ6KnDbB12XeZ4FluclYwptntoWCr9QaXTI= +github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= +github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 h1:qHprzXy/As0rxedphECBEQAh3R4yp6pKksKHcqZx5G8= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0/go.mod h1:H+8tjs9TjV2w57QFVSMBQacf8k/E1XwLXGCARgViC6A= +github.com/hashicorp/terraform-plugin-testing v1.7.0 h1:I6aeCyZ30z4NiI3tzyDoO6fS7YxP5xSL1ceOon3gTe8= +github.com/hashicorp/terraform-plugin-testing v1.7.0/go.mod h1:sbAreCleJNOCz+y5vVHV8EJkIWZKi/t4ndKiUjM9vao= +github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= +github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= +github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.14.3 h1:1JXy1XroaGrzZuG6X9dt7HL6s9AwbY+l4UNL8o5B6ho= +github.com/zclconf/go-cty v1.14.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/terraform/main.go b/tools/terraform/main.go new file mode 100644 index 0000000000..2c9545614a --- /dev/null +++ b/tools/terraform/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "terraform-provider-fleetdm/provider" +) + +// Run "go generate" to format example terraform files and generate the docs for the registry/website + +// If you do not have terraform installed, you can remove the formatting command, but its suggested to +// ensure the documentation is formatted properly. +//go:generate terraform fmt -recursive ./examples/ + +// Run the docs generation tool, check its repository for more information on how it works and how docs +// can be customized. +//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate -provider-name scaffolding + +var ( + // these will be set by the goreleaser configuration + // to appropriate values for the compiled binary. + version string = "dev" + + // goreleaser can pass other information to the main package, such as the specific commit + // https://goreleaser.com/cookbooks/using-main.version/ +) + +func main() { + var debug bool + + flag.BoolVar(&debug, "debug", false, + "set to true to run the provider with support for debuggers like delve") + flag.Parse() + + opts := providerserver.ServeOpts{ + // TODO: Update this string with the published name of your provider. + // Also update the tfplugindocs generate command to either remove the + // -provider-name flag or set its value to the updated provider name. + //Address: "registry.terraform.io/hashicorp/scaffolding", + Address: "fleetdm.com/tf/fleetdm", + Debug: debug, + } + + err := providerserver.Serve(context.Background(), provider.New(version), opts) + + if err != nil { + log.Fatal(err.Error()) + } +} diff --git a/tools/terraform/openapi.json b/tools/terraform/openapi.json new file mode 100644 index 0000000000..d505f74ffb --- /dev/null +++ b/tools/terraform/openapi.json @@ -0,0 +1,210 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Fleet Premium API", + "description": "API for managing teams and related functionalities in Fleet Premium", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://rbx.cloud.fleetdm.com/" + } + ], + "paths": { + "/api/v1/fleet/teams": { + "get": { + "summary": "List teams", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number of the results to fetch.", + "schema": { + "type": "integer" + } + }, + { + "name": "per_page", + "in": "query", + "description": "Results per page.", + "schema": { + "type": "integer" + } + }, + { + "name": "order_key", + "in": "query", + "description": "What to order results by. Can be any column in the teams table.", + "schema": { + "type": "string" + } + }, + { + "name": "order_direction", + "in": "query", + "description": "The direction of the order given the order key. Options include asc and desc. Default is asc.", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "query", + "in": "query", + "description": "Search query keywords. Searchable fields include name.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of teams", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team" + } + } + } + } + } + } + } + } + }, + "post": { + "summary": "Create team", + "requestBody": { + "description": "Team details", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Team created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + } + } + } + }, + "/api/v1/fleet/teams/{id}": { + "get": { + "summary": "Get team", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The desired team's ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Team details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + } + } + }, + "delete": { + "summary": "Delete team", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The desired team's ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Team deleted successfully" + } + } + } + } + }, + "components": { + "schemas": { + "Team": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent_options": { + "type": "string" + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "secret": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "team_id": { + "type": "integer" + } + } + } + } + } + }, + "TeamCreateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent_options": { + "type": "string" + } + }, + "required": ["name"] + } + } + } +} diff --git a/tools/terraform/provider/provider.go b/tools/terraform/provider/provider.go new file mode 100644 index 0000000000..6e6706e285 --- /dev/null +++ b/tools/terraform/provider/provider.go @@ -0,0 +1,146 @@ +package provider + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-log/tflog" + "os" + "terraform-provider-fleetdm/fleetdm_client" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// This Terraform provider is based on the example provider from the Terraform +// documentation. It is a simple provider that interacts with the FleetDM API. + +// Ensure FleetDMProvider satisfies various provider interfaces. +var _ provider.Provider = &FleetDMProvider{} +var _ provider.ProviderWithFunctions = &FleetDMProvider{} + +// FleetDMProvider defines the provider implementation. +type FleetDMProvider struct { + // version is set to the provider version on release, "dev" when the + // provider is built and ran locally, and "test" when running acceptance + // testing. + version string +} + +// FleetDMProviderModel describes the provider data model. It requires a URL +// and api key to communicate with FleetDM. +type FleetDMProviderModel struct { + Url types.String `tfsdk:"url"` + ApiKey types.String `tfsdk:"apikey"` +} + +func (p *FleetDMProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "fleetdm" + resp.Version = p.version +} + +func (p *FleetDMProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{ + MarkdownDescription: "URL of your FleetDM server", + Optional: true, + }, + "apikey": schema.StringAttribute{ + MarkdownDescription: "API Key for authentication", + Optional: true, + Sensitive: true, + }, + }, + } +} + +func (p *FleetDMProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config FleetDMProviderModel + + tflog.Info(ctx, "Configuring FleetDM client") + + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + if config.Url.IsUnknown() { + resp.Diagnostics.AddAttributeError( + path.Root("url"), + "Unknown FleetDM url", + "Url is unknown") + } + + if config.ApiKey.IsUnknown() { + resp.Diagnostics.AddAttributeError( + path.Root("apikey"), + "Unknown FleetDM apikey", + "api key is unknown") + } + + if resp.Diagnostics.HasError() { + return + } + + url := os.Getenv("FLEETDM_URL") + apikey := os.Getenv("FLEETDM_APIKEY") + + if !config.Url.IsNull() { + url = config.Url.ValueString() + } + + if !config.ApiKey.IsNull() { + apikey = config.ApiKey.ValueString() + } + + if url == "" { + resp.Diagnostics.AddAttributeError( + path.Root("url"), + "Missing url", + "Really, the url is required") + } + + if apikey == "" { + resp.Diagnostics.AddAttributeError( + path.Root("apikey"), + "Missing apikey", + "Really, the apikey is required") + } + + if resp.Diagnostics.HasError() { + return + } + + // Example client configuration for data sources and resources + client := fleetdm_client.NewFleetDMClient(url, apikey) + resp.DataSourceData = client + resp.ResourceData = client +} + +func (p *FleetDMProvider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewTeamsResource, + } +} + +func (p *FleetDMProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{} +} + +func (p *FleetDMProvider) Functions(ctx context.Context) []func() function.Function { + return []func() function.Function{} +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &FleetDMProvider{ + version: version, + } + } +} diff --git a/tools/terraform/provider/provider_test.go b/tools/terraform/provider/provider_test.go new file mode 100644 index 0000000000..2f6a9eff5e --- /dev/null +++ b/tools/terraform/provider/provider_test.go @@ -0,0 +1,33 @@ +package provider + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// testAccProtoV6ProviderFactories are used to instantiate a provider during +// acceptance testing. The factory function will be invoked for every Terraform +// CLI command executed to create a provider server to which the CLI can +// reattach. +var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "fleetdm": providerserver.NewProtocol6WithError(New("test")()), +} + +func testAccPreCheck(t *testing.T) { + // You can add code here to run prior to any test case execution, for example assertions + // about the appropriate environment variables being set are common to see in a pre-check + // function. + apiKey := os.Getenv("FLEETDM_APIKEY") + if apiKey == "" { + t.Skip("FLEETDM_APIKEY not set") + } + + // I don't like this, but I can't figure out how to pass the url otherwise... + url := os.Getenv("FLEETDM_URL") + if url == "" { + t.Skip("FLEETDM_URL not set") + } +} diff --git a/tools/terraform/provider/team_resource_gen.go b/tools/terraform/provider/team_resource_gen.go new file mode 100644 index 0000000000..de174dbe3d --- /dev/null +++ b/tools/terraform/provider/team_resource_gen.go @@ -0,0 +1,494 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package provider + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" +) + +func TeamResourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "agent_options": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + "id": schema.Int64Attribute{ + Computed: true, + Description: "The desired team's ID.", + MarkdownDescription: "The desired team's ID.", + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Team name", + MarkdownDescription: "Team name", + }, + "secrets": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "created_at": schema.StringAttribute{ + Computed: true, + }, + "secret": schema.StringAttribute{ + Computed: true, + }, + "team_id": schema.Int64Attribute{ + Computed: true, + }, + }, + CustomType: SecretsType{ + ObjectType: types.ObjectType{ + AttrTypes: SecretsValue{}.AttributeTypes(ctx), + }, + }, + }, + Computed: true, + }, + }, + } +} + +type TeamModel struct { + AgentOptions types.String `tfsdk:"agent_options"` + Description types.String `tfsdk:"description"` + Id types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Secrets types.List `tfsdk:"secrets"` +} + +var _ basetypes.ObjectTypable = SecretsType{} + +type SecretsType struct { + basetypes.ObjectType +} + +func (t SecretsType) Equal(o attr.Type) bool { + other, ok := o.(SecretsType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t SecretsType) String() string { + return "SecretsType" +} + +func (t SecretsType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + createdAtAttribute, ok := attributes["created_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created_at is missing from object`) + + return nil, diags + } + + createdAtVal, ok := createdAtAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created_at expected to be basetypes.StringValue, was: %T`, createdAtAttribute)) + } + + secretAttribute, ok := attributes["secret"] + + if !ok { + diags.AddError( + "Attribute Missing", + `secret is missing from object`) + + return nil, diags + } + + secretVal, ok := secretAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`secret expected to be basetypes.StringValue, was: %T`, secretAttribute)) + } + + teamIdAttribute, ok := attributes["team_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `team_id is missing from object`) + + return nil, diags + } + + teamIdVal, ok := teamIdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`team_id expected to be basetypes.Int64Value, was: %T`, teamIdAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return SecretsValue{ + CreatedAt: createdAtVal, + Secret: secretVal, + TeamId: teamIdVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewSecretsValueNull() SecretsValue { + return SecretsValue{ + state: attr.ValueStateNull, + } +} + +func NewSecretsValueUnknown() SecretsValue { + return SecretsValue{ + state: attr.ValueStateUnknown, + } +} + +func NewSecretsValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (SecretsValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing SecretsValue Attribute Value", + "While creating a SecretsValue value, a missing attribute value was detected. "+ + "A SecretsValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("SecretsValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid SecretsValue Attribute Type", + "While creating a SecretsValue value, an invalid attribute value was detected. "+ + "A SecretsValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("SecretsValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("SecretsValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra SecretsValue Attribute Value", + "While creating a SecretsValue value, an extra attribute value was detected. "+ + "A SecretsValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra SecretsValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewSecretsValueUnknown(), diags + } + + createdAtAttribute, ok := attributes["created_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created_at is missing from object`) + + return NewSecretsValueUnknown(), diags + } + + createdAtVal, ok := createdAtAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created_at expected to be basetypes.StringValue, was: %T`, createdAtAttribute)) + } + + secretAttribute, ok := attributes["secret"] + + if !ok { + diags.AddError( + "Attribute Missing", + `secret is missing from object`) + + return NewSecretsValueUnknown(), diags + } + + secretVal, ok := secretAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`secret expected to be basetypes.StringValue, was: %T`, secretAttribute)) + } + + teamIdAttribute, ok := attributes["team_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `team_id is missing from object`) + + return NewSecretsValueUnknown(), diags + } + + teamIdVal, ok := teamIdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`team_id expected to be basetypes.Int64Value, was: %T`, teamIdAttribute)) + } + + if diags.HasError() { + return NewSecretsValueUnknown(), diags + } + + return SecretsValue{ + CreatedAt: createdAtVal, + Secret: secretVal, + TeamId: teamIdVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewSecretsValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) SecretsValue { + object, diags := NewSecretsValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewSecretsValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t SecretsType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewSecretsValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewSecretsValueUnknown(), nil + } + + if in.IsNull() { + return NewSecretsValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewSecretsValueMust(SecretsValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t SecretsType) ValueType(ctx context.Context) attr.Value { + return SecretsValue{} +} + +var _ basetypes.ObjectValuable = SecretsValue{} + +type SecretsValue struct { + CreatedAt basetypes.StringValue `tfsdk:"created_at"` + Secret basetypes.StringValue `tfsdk:"secret"` + TeamId basetypes.Int64Value `tfsdk:"team_id"` + state attr.ValueState +} + +func (v SecretsValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 3) + + var val tftypes.Value + var err error + + attrTypes["created_at"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["secret"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["team_id"] = basetypes.Int64Type{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 3) + + val, err = v.CreatedAt.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["created_at"] = val + + val, err = v.Secret.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["secret"] = val + + val, err = v.TeamId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["team_id"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v SecretsValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v SecretsValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v SecretsValue) String() string { + return "SecretsValue" +} + +func (v SecretsValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + objVal, diags := types.ObjectValue( + map[string]attr.Type{ + "created_at": basetypes.StringType{}, + "secret": basetypes.StringType{}, + "team_id": basetypes.Int64Type{}, + }, + map[string]attr.Value{ + "created_at": v.CreatedAt, + "secret": v.Secret, + "team_id": v.TeamId, + }) + + return objVal, diags +} + +func (v SecretsValue) Equal(o attr.Value) bool { + other, ok := o.(SecretsValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.CreatedAt.Equal(other.CreatedAt) { + return false + } + + if !v.Secret.Equal(other.Secret) { + return false + } + + if !v.TeamId.Equal(other.TeamId) { + return false + } + + return true +} + +func (v SecretsValue) Type(ctx context.Context) attr.Type { + return SecretsType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v SecretsValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "created_at": basetypes.StringType{}, + "secret": basetypes.StringType{}, + "team_id": basetypes.Int64Type{}, + } +} diff --git a/tools/terraform/provider/teams_resource.go b/tools/terraform/provider/teams_resource.go new file mode 100644 index 0000000000..201ddef521 --- /dev/null +++ b/tools/terraform/provider/teams_resource.go @@ -0,0 +1,299 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "strconv" + "terraform-provider-fleetdm/fleetdm_client" +) + +// This file implements the "fleetdm_teams" Terraform resource. + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &teamsResource{} + _ resource.ResourceWithConfigure = &teamsResource{ + client: nil, + } + _ resource.ResourceWithImportState = &teamsResource{} +) + +// NewTeamsResource is a helper function to simplify the provider implementation. +func NewTeamsResource() resource.Resource { + return &teamsResource{ + client: nil, + } +} + +// teamsResource is the resource implementation. +type teamsResource struct { + client *fleetdm_client.FleetDMClient +} + +// Configure adds the provider configured client to the resource. +func (r *teamsResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*fleetdm_client.FleetDMClient) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *FleetDMClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +// Metadata returns the resource type name. +func (r *teamsResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_teams" +} + +// Schema defines the schema for the resource. +func (r *teamsResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = TeamResourceSchema(ctx) +} + +// teamModelToTF is a handy function to take the results of the API call +// to the FleetDM API and convert it to the Terraform state. +func teamModelToTF(ctx context.Context, tm *fleetdm_client.TeamGetResponse, tf *TeamModel) error { + tf.Id = types.Int64Value(tm.Team.ID) + tf.Name = types.StringValue(tm.Team.Name) + tf.Description = types.StringValue(tm.Team.Description) + tf.Secrets = basetypes.NewListNull(NewSecretsValueNull().Type(ctx)) + // Re-marshal agent_options into a string so TF can store it sanely. + aoBytes, err := json.Marshal(tm.Team.AgentOptions) + if err != nil { + return fmt.Errorf("failed to re-marshal agent options: %w", err) + } + tf.AgentOptions = types.StringValue(string(aoBytes)) + + return nil +} + +// Create creates the resource from the plan and sets the initial Terraform state. +func (r *teamsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Retrieve values from plan + var plan TeamModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + newTeam, err := r.client.CreateTeam(plan.Name.ValueString(), plan.Description.ValueString()) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Failed to create team", + fmt.Sprintf("Failed to create team: %s", err))) + return + } + + if !plan.AgentOptions.IsNull() && !plan.AgentOptions.IsUnknown() { + aoPlan := plan.AgentOptions.ValueString() + if aoPlan != "" { + newTeam, err = r.client.UpdateAgentOptions(newTeam.Team.ID, aoPlan) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "failed to create agent options", + fmt.Sprintf("failed to save agent options: %s", err))) + // This is a problem. The interface terraform presents is that + // team creation with agent options is atomic, however under the + // hood it's two api calls. We need to clean up from the first + // call here, but this isn't atomic and it might fail. + err = r.client.DeleteTeam(newTeam.Team.ID) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "failed to clean up after failed team creation", + fmt.Sprintf("failed to delete team %s while cleaning up "+ + "failure setting agent options: %s. Team will need to be "+ + "manually deleted.", plan.Name.ValueString(), err))) + } + return + } + } + } + + err = teamModelToTF(ctx, newTeam, &plan) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "failed to convert fleet api return to TF structs", + fmt.Sprintf("failed to convert fleet api return to TF structs: %s", err))) + _ = r.client.DeleteTeam(newTeam.Team.ID) // Problematic. :-/ + return + } + + // Set our state to match that of the plan, now that we have + // completed successfully + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read queries the FleetDM API and sets the TF state to what it finds. +func (r *teamsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state TeamModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + apiTeam, err := r.client.GetTeam(state.Id.ValueInt64()) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Failed to get team", + fmt.Sprintf("Failed to get team: %s", err))) + return + } + + if state.Id != types.Int64Value(apiTeam.Team.ID) { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "ID mismatch", + fmt.Sprintf("ID mismatch: %s != %s", + state.Id, strconv.FormatInt(apiTeam.Team.ID, 10)))) + return + } + + err = teamModelToTF(ctx, apiTeam, &state) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "failed to convert fleet api return to TF structs", + fmt.Sprintf("failed to convert fleet api return to TF structs: %s", err))) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update will compare the plan and the current state and see how they +// differ. It will then update the team in FleetDM to match the plan, +// and then update the Terraform state to match that plan. +func (r *teamsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var state TeamModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var plan TeamModel + diags = req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var name, description *string + name = nil + description = nil + if !plan.Name.Equal(state.Name) { + n := plan.Name.ValueString() + name = &n + } + if !plan.Description.Equal(state.Description) { + d := plan.Description.ValueString() + description = &d + } + + if name == nil && description == nil && plan.AgentOptions.Equal(state.AgentOptions) { + // Nothing to do + return + } + + var upTeam *fleetdm_client.TeamGetResponse + var err error + + // Deal with agent options first because it has a higher chance of failure + if !plan.AgentOptions.Equal(state.AgentOptions) { + ao := plan.AgentOptions.ValueString() + if ao != "" { + upTeam, err = r.client.UpdateAgentOptions(state.Id.ValueInt64(), ao) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Failed to update agent options", + fmt.Sprintf("Failed to update agent options: %s", err))) + return + } + } + } + + if name != nil || description != nil { + upTeam, err = r.client.UpdateTeam(state.Id.ValueInt64(), name, description) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Failed to update team", + fmt.Sprintf("Failed to update team: %s", err))) + return + } + } + + err = teamModelToTF(ctx, upTeam, &state) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "failed to convert fleet api return to TF structs", + fmt.Sprintf("failed to convert fleet api return to TF structs: %s", err))) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + +} + +// ImportState enables import of teams. We accept the name of the team +// as input, query the FleetDM API to get the ID, and then set the ID. +// Terraform will turn around and call Read() on that id. +func (r *teamsResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := r.client.TeamNameToID(req.ID) + if err != nil { + resp.Diagnostics.AddError("Failed to convert team name to ID", + fmt.Sprintf("Failed to convert team name to ID: %s", err)) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *teamsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state TeamModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteTeam(state.Id.ValueInt64()) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Failed to delete team", + fmt.Sprintf("Failed to delete team: %s", err))) + return + } + + resp.State.RemoveResource(ctx) +} diff --git a/tools/terraform/provider/teams_resource_test.go b/tools/terraform/provider/teams_resource_test.go new file mode 100644 index 0000000000..db2225968a --- /dev/null +++ b/tools/terraform/provider/teams_resource_test.go @@ -0,0 +1,222 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +var defaultAgentOptions = `{"config":{"decorators":{"load":["SELECT uuid AS host_uuid FROM system_info;","SELECT hostname AS hostname FROM system_info;"]},"options":{"disable_distributed":false,"distributed_interval":10,"distributed_plugin":"tls","distributed_tls_max_attempts":3,"logger_tls_endpoint":"/api/osquery/log","logger_tls_period":10,"pack_delimiter":"/"}},"overrides":{}}` +var newAO = `{"command_line_flags":{"disable_events":true},"config":{"options":{"logger_tls_endpoint":"/test"}},"overrides":{}}` +var upAO = `{"command_line_flags":{"disable_events":true},"config":{"options":{"logger_tls_endpoint":"/update"}},"overrides":{}}` + +// Notes: +// Set env var TF_ACC=1 +// Set env var FLEETDM_APIKEY +// Set env var FLEETDM_URL (I couldn't figure out how to set this otherwise...) + +// These tests use the Terraform acceptance testing framework to test +// our provider. It's a little bit magical, in that you define what +// the resource looks like in the Config and then write some Checks +// against what you actually get back. Implicit in each test is that +// it deletes the created resource at the end of the test. + +func TestAccFleetdmTeams_basic(t *testing.T) { + teamName := fmt.Sprintf("aaa-%s", + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesome description" + } + `, teamName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", teamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesome description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", defaultAgentOptions), + ), + }, + }, + }) +} + +func TestAccFleetdmTeams_agent_options(t *testing.T) { + teamName := fmt.Sprintf("aaa-%s", + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesome description" + agent_options = jsonencode(%s) + } + `, teamName, newAO), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", teamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesome description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", newAO), + ), + }, + }, + }) +} + +func TestAccFleetdmTeams_update_all(t *testing.T) { + teamName := fmt.Sprintf("aaa-%s", + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)) + NewTeamName := fmt.Sprintf("aaa-%s-new", + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesome description" + } + `, teamName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", teamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesome description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", defaultAgentOptions), + ), + }, + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesomer description" + agent_options = jsonencode(%s) + } + `, NewTeamName, upAO), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", NewTeamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesomer description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", upAO), + ), + }, + }, + }) +} + +func TestAccFleetdmTeams_update_each(t *testing.T) { + teamName := fmt.Sprintf("aaa-%s", + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)) + NewTeamName := fmt.Sprintf("aaa-%s-new", + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + } + `, teamName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", teamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", ""), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", defaultAgentOptions), + ), + }, + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesome description" + } + `, teamName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", teamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesome description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", defaultAgentOptions), + ), + }, + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesome description" + } + `, teamName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", teamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesome description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", defaultAgentOptions), + ), + }, + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesome description" + } + `, NewTeamName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", NewTeamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesome description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", defaultAgentOptions), + ), + }, + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesomer description" + } + `, NewTeamName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", NewTeamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesomer description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", defaultAgentOptions), + ), + }, + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesomer description" + agent_options = jsonencode(%s) + } + `, NewTeamName, newAO), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", NewTeamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesomer description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", newAO), + ), + }, + { + Config: fmt.Sprintf(` + resource "fleetdm_teams" "test_team" { + name = "%s" + description = "Awesomer description" + agent_options = jsonencode(%s) + } + `, NewTeamName, upAO), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "name", NewTeamName), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "description", "Awesomer description"), + resource.TestCheckResourceAttr("fleetdm_teams.test_team", "agent_options", upAO), + ), + }, + }, + }) +} diff --git a/tools/terraform/tf/main.tf b/tools/terraform/tf/main.tf new file mode 100644 index 0000000000..ae6a289ed1 --- /dev/null +++ b/tools/terraform/tf/main.tf @@ -0,0 +1,46 @@ +terraform { + required_providers { + fleetdm = { + source = "fleetdm.com/tf/fleetdm" + } + } +} + +provider "fleetdm" { + url = "https://something.cloud.fleetdm.com" + // apikey provided via FLEETDM_APIKEY +} + +locals { + # Here are some default agent options. + default_agent_options = jsonencode(local.raw_agent_options) + raw_agent_options = { + "config" = { + "options" = { + pack_delimiter = "/" + logger_tls_period = 10 + distributed_plugin = "tls" + disable_distributed = false + logger_tls_endpoint = "/api/osquery/log" + distributed_interval = 15 + distributed_tls_max_attempts = 3 + } + "decorators" = { + "load" = [ + "SELECT uuid AS host_uuid FROM system_info;", + "SELECT hostname AS hostname FROM system_info;" + ] + } + } + command_line_flags = { + disable_events = true + enable_bpf_events = false + } + } +} + +resource "fleetdm_teams" "hello_world" { + name = "my_first_team" + description = "This is my first team" + agent_options = local.default_agent_options +}