Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Terraforming Fleet Teams #18750

Merged
merged 3 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tools/terraform/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
provider_code_spec.json
tf/terraformrc-dev-override
42 changes: 42 additions & 0 deletions tools/terraform/Makefile
Original file line number Diff line number Diff line change
@@ -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\
}" > $@
61 changes: 61 additions & 0 deletions tools/terraform/README.md
Original file line number Diff line number Diff line change
@@ -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.
276 changes: 276 additions & 0 deletions tools/terraform/fleetdm_client/fleetdm_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
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"
)

var prefix = "/api/v1/fleet"
var teamPrefix = prefix + "/teams"
yoderme marked this conversation as resolved.
Show resolved Hide resolved

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 struct {
Team
} `json:"team"`
yoderme marked this conversation as resolved.
Show resolved Hide resolved
}

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 {
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 string) (*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
req.URL, _ = url.Parse(c.URL + req.URL.Path)
yoderme marked this conversation as resolved.
Show resolved Hide resolved
if query != "" {
req.URL.RawQuery = query
}
// 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) {
yoderme marked this conversation as resolved.
Show resolved Hide resolved
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)
}
query := fmt.Sprintf("query=%s", name)
yoderme marked this conversation as resolved.
Show resolved Hide resolved
resp, err := c.do(req, query)
if err != nil {
return 0, fmt.Errorf("failed to GET %s %s: %w", teamPrefix, query, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to get team: %s %s", query, resp.Status)
}

var teamqry TeamQueryResponse
err = json.NewDecoder(resp.Body).Decode(&teamqry)
yoderme marked this conversation as resolved.
Show resolved Hide resolved
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",
teamPrefix, name, err)
}
resp, err := c.do(req, "")
if err != nil {
return nil, fmt.Errorf("failed to POST %s name %s: %w",
teamPrefix, 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
err = json.NewDecoder(resp.Body).Decode(&newTeam)
yoderme marked this conversation as resolved.
Show resolved Hide resolved
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, "")
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
err = json.NewDecoder(resp.Body).Decode(&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, "")
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
err = json.NewDecoder(resp.Body).Decode(&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.
var aoJson interface{}
err := json.Unmarshal([]byte(ao), &aoJson)
yoderme marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, fmt.Errorf("agent_options might not be json: %s", err)
}

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, "")
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
err = json.NewDecoder(resp.Body).Decode(&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, "")
if err != nil {
return fmt.Errorf("failed to DELETE %s: %w", url, err)
}

if resp.StatusCode >= 300 {
return fmt.Errorf("failed to delete team %d: %s", id, resp.Status)
}

defer resp.Body.Close()
yoderme marked this conversation as resolved.
Show resolved Hide resolved

return nil
}
Loading
Loading