diff --git a/client/v2/api_keys_test.go b/client/v2/api_keys_test.go index 57710344..e949d066 100644 --- a/client/v2/api_keys_test.go +++ b/client/v2/api_keys_test.go @@ -85,14 +85,14 @@ func TestClient_APIKeys_Pagination(t *testing.T) { testKeys := make([]*APIKey, numKeys) for i := 0; i < numKeys; i++ { k, err := c.APIKeys.Create(ctx, &APIKey{ - Name: helper.ToPtr(fmt.Sprintf("testkey-%d", i)), + Name: helper.ToPtr(fmt.Sprintf("test.%d", i)), KeyType: "ingest", Environment: &Environment{ ID: testEnvironmentID, }, }) - testKeys[i] = k require.NoError(t, err) + testKeys[i] = k } t.Cleanup(func() { for _, k := range testKeys { @@ -116,7 +116,8 @@ func TestClient_APIKeys_Pagination(t *testing.T) { require.NoError(t, err) keys = append(keys, items...) } - assert.Len(t, keys, numKeys) + // we can't guarantee that there are exactly numKeys keys, but there should be at least that many + assert.GreaterOrEqual(t, len(keys), numKeys, "should have at least %d keys", numKeys) }) t.Run("works with custom page size", func(t *testing.T) { diff --git a/client/v2/client.go b/client/v2/client.go index a2c0116c..120afffc 100644 --- a/client/v2/client.go +++ b/client/v2/client.go @@ -45,7 +45,8 @@ type Client struct { http *retryablehttp.Client // API handlers here - APIKeys APIKeys + APIKeys APIKeys + Environments Environments } func NewClient() (*Client, error) { @@ -124,6 +125,7 @@ func NewClientWithConfig(config *Config) (*Client, error) { // bind API handlers here client.APIKeys = &apiKeys{client: client, authinfo: authinfo} + client.Environments = &environments{client: client, authinfo: authinfo} return client, nil } diff --git a/client/v2/environments.go b/client/v2/environments.go new file mode 100644 index 00000000..1201fb8d --- /dev/null +++ b/client/v2/environments.go @@ -0,0 +1,143 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/jsonapi" + + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" +) + +// Compile-time proof of interface implementation. +var _ Environments = (*environments)(nil) + +type Environments interface { + Create(ctx context.Context, env *Environment) (*Environment, error) + Get(ctx context.Context, id string) (*Environment, error) + Update(ctx context.Context, env *Environment) (*Environment, error) + Delete(ctx context.Context, id string) error + List(ctx context.Context, opts ...ListOption) (*Pager[Environment], error) +} + +type environments struct { + client *Client + authinfo *AuthMetadata +} + +const ( + environmentsPath = "/2/teams/%s/environments" + environmentsByIDPath = "/2/teams/%s/environments/%s" +) + +const ( + EnvironmentColorBlue = "blue" + EnvironmentColorGreen = "green" + EnvironmentColorGold = "gold" + EnvironmentColorRed = "red" + EnvironmentColorPurple = "purple" + EnvironmentColorLightBlue = "lightBlue" + EnvironmentColorLightGreen = "lightGreen" + EnvironmentColorLightGold = "lightGold" + EnvironmentColorLightRed = "lightRed" + EnvironmentColorLightPurple = "lightPurple" +) + +func EnvironmentColorTypes() []string { + return []string{ + EnvironmentColorBlue, + EnvironmentColorGreen, + EnvironmentColorGold, + EnvironmentColorRed, + EnvironmentColorPurple, + EnvironmentColorLightBlue, + EnvironmentColorLightGreen, + EnvironmentColorLightGold, + EnvironmentColorLightRed, + EnvironmentColorLightPurple, + } +} + +func (e *environments) Create(ctx context.Context, env *Environment) (*Environment, error) { + r, err := e.client.Do(ctx, + http.MethodPost, + fmt.Sprintf(environmentsPath, e.authinfo.Team.Slug), + env, + ) + if err != nil { + return nil, err + } + if r.StatusCode != http.StatusCreated { + return nil, hnyclient.ErrorFromResponse(r) + } + + envrion := new(Environment) + if err := jsonapi.UnmarshalPayload(r.Body, envrion); err != nil { + return nil, err + } + return envrion, nil +} + +func (e *environments) Get(ctx context.Context, id string) (*Environment, error) { + r, err := e.client.Do(ctx, + http.MethodGet, + fmt.Sprintf(environmentsByIDPath, e.authinfo.Team.Slug, id), + nil, + ) + if err != nil { + return nil, err + } + if r.StatusCode != http.StatusOK { + return nil, hnyclient.ErrorFromResponse(r) + } + + envrion := new(Environment) + if err := jsonapi.UnmarshalPayload(r.Body, envrion); err != nil { + return nil, err + } + return envrion, nil +} + +func (e *environments) Update(ctx context.Context, env *Environment) (*Environment, error) { + r, err := e.client.Do(ctx, + http.MethodPatch, + fmt.Sprintf(environmentsByIDPath, e.authinfo.Team.Slug, env.ID), + env, + ) + if err != nil { + return nil, err + } + if r.StatusCode != http.StatusOK { + return nil, hnyclient.ErrorFromResponse(r) + } + + envrion := new(Environment) + if err := jsonapi.UnmarshalPayload(r.Body, envrion); err != nil { + return nil, err + } + return envrion, nil +} + +func (e *environments) Delete(ctx context.Context, id string) error { + r, err := e.client.Do(ctx, + http.MethodDelete, + fmt.Sprintf(environmentsByIDPath, e.authinfo.Team.Slug, id), + nil, + ) + if err != nil { + return err + } + if r.StatusCode != http.StatusNoContent { + return hnyclient.ErrorFromResponse(r) + } + return nil +} + +func (e *environments) List(ctx context.Context, os ...ListOption) (*Pager[Environment], error) { + return NewPager[Environment]( + e.client, + fmt.Sprintf(environmentsPath, e.authinfo.Team.Slug), + os..., + ) +} diff --git a/client/v2/environments_test.go b/client/v2/environments_test.go new file mode 100644 index 00000000..89d4f327 --- /dev/null +++ b/client/v2/environments_test.go @@ -0,0 +1,141 @@ +package v2 + +import ( + "context" + "math" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" +) + +func TestClient_Environments(t *testing.T) { + ctx := context.Background() + c := newTestClient(t) + + t.Run("happy path", func(t *testing.T) { + // create a new Environment + newEnv := &Environment{ + Name: test.RandomStringWithPrefix("test.", 20), + Description: helper.ToPtr(test.RandomString(50)), + Color: helper.ToPtr(EnvironmentColorBlue), + } + e, err := c.Environments.Create(ctx, newEnv) + require.NoError(t, err) + assert.NotEmpty(t, e.ID) + assert.Equal(t, newEnv.Name, e.Name) + assert.NotEmpty(t, e.Slug) + assert.Equal(t, newEnv.Description, e.Description) + assert.Equal(t, newEnv.Color, e.Color) + if assert.NotNil(t, e.Settings) { + assert.True(t, *e.Settings.DeleteProtected) + } + + // read the Environment back and compare + env, err := c.Environments.Get(ctx, e.ID) + require.NoError(t, err) + assert.Equal(t, e.ID, env.ID) + assert.Equal(t, e.Name, env.Name) + assert.Equal(t, e.Slug, env.Slug) + assert.Equal(t, e.Description, env.Description) + assert.Equal(t, e.Color, env.Color) + if assert.NotNil(t, env.Settings) { + assert.True(t, *env.Settings.DeleteProtected) + } + + // update the Environment's description and color + newDescription := helper.ToPtr(test.RandomString(50)) + env.Description = newDescription + env.Color = helper.ToPtr(EnvironmentColorGreen) + env, err = c.Environments.Update(ctx, env) + require.NoError(t, err) + assert.Equal(t, e.ID, env.ID) + assert.Equal(t, newDescription, env.Description) + assert.Equal(t, EnvironmentColorGreen, *env.Color) + + // try to delete the environment with delete protection enabled + var de hnyclient.DetailedError + err = c.Environments.Delete(ctx, env.ID) + require.ErrorAs(t, err, &de) + assert.Equal(t, http.StatusConflict, de.Status) + + // disable deletion protection and delete the Environment + _, err = c.Environments.Update(ctx, &Environment{ + ID: env.ID, + Settings: &EnvironmentSettings{ + DeleteProtected: helper.ToPtr(false), + }, + }) + require.NoError(t, err) + err = c.Environments.Delete(ctx, env.ID) + require.NoError(t, err) + + // verify the Environment was deleted + _, err = c.Environments.Get(ctx, env.ID) + require.ErrorAs(t, err, &de) + assert.True(t, de.IsNotFound()) + }) +} + +func TestClient_Environments_Pagination(t *testing.T) { + ctx := context.Background() + c := newTestClient(t) + + // create a bunch of environments + numEnvs := int(math.Floor(1.5 * float64(defaultPageSize))) + testEnvs := make([]*Environment, numEnvs) + for i := 0; i < numEnvs; i++ { + e, err := c.Environments.Create(ctx, &Environment{ + Name: test.RandomStringWithPrefix("test.", 20), + }) + require.NoError(t, err) + testEnvs[i] = e + } + t.Cleanup(func() { + for _, e := range testEnvs { + c.Environments.Update(ctx, &Environment{ + ID: e.ID, + Settings: &EnvironmentSettings{ + DeleteProtected: helper.ToPtr(false), + }, + }) + c.Environments.Delete(ctx, e.ID) + } + }) + + t.Run("happy path", func(t *testing.T) { + envs := make([]*Environment, 0) + pager, err := c.Environments.List(ctx) + require.NoError(t, err) + + items, err := pager.Next(ctx) + require.NoError(t, err) + assert.Len(t, items, defaultPageSize, "incorrect number of items") + assert.True(t, pager.HasNext(), "should have more pages") + envs = append(envs, items...) + + for pager.HasNext() { + items, err = pager.Next(ctx) + require.NoError(t, err) + envs = append(envs, items...) + } + // we can't guarantee that there are exactly numEnvs environments + assert.GreaterOrEqual(t, len(envs), numEnvs, "should have at least %d environments", numEnvs) + }) + + t.Run("works with custom page size", func(t *testing.T) { + pageSize := 5 + pager, err := c.Environments.List(ctx, PageSize(pageSize)) + require.NoError(t, err) + + items, err := pager.Next(ctx) + require.NoError(t, err) + assert.Len(t, items, pageSize, "incorrect number of items") + assert.True(t, pager.HasNext(), "should have more pages") + }) +} diff --git a/client/v2/models.go b/client/v2/models.go index 6df9decb..e5866c22 100644 --- a/client/v2/models.go +++ b/client/v2/models.go @@ -5,9 +5,16 @@ import ( ) type Environment struct { - ID string `jsonapi:"primary,environments"` - Name string `jsonapi:"attr,name"` - Slug string `jsonapi:"attr,slug"` + ID string `jsonapi:"primary,environments"` + Name string `jsonapi:"attr,name"` + Slug string `jsonapi:"attr,slug"` + Description *string `jsonapi:"attr,description,omitempty"` + Color *string `jsonapi:"attr,color,omitempty"` + Settings *EnvironmentSettings `jsonapi:"attr,settings,omitempty"` +} + +type EnvironmentSettings struct { + DeleteProtected *bool `json:"delete_protected" jsonapi:"attr,delete_protected,omitempty"` } type Team struct { diff --git a/docs/data-sources/environment.md b/docs/data-sources/environment.md new file mode 100644 index 00000000..21848634 --- /dev/null +++ b/docs/data-sources/environment.md @@ -0,0 +1,35 @@ +# Data Source: honeycombio_environment + +The `honeycombio_environment` data source retrieves the details of a single Environment. + +-> **NOTE** This data source requires the provider be configured with a Management Key with `environments:read` in the configured scopes. + +-> **Note** Terraform will fail unless a single Environment is returned by the search. +Ensure that your search is specific enough to return an Environment. +If you want to match multiple Environments, use the `honeycombio_environments` data source instead. + +## Example Usage + +```hcl +# Retrieve the details of a Environment +data "honeycombio_environment" "prod" { + id = "hcaen_01j1d7t02zf7wgw7q89z3t60vf" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `id` - (Required) The ID of the Environment + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `name` - the Environment's name. +* `slug` - the Environment's slug. +* `description` - the Environment's description. +* `color` - the Environment's color. +* `delete_protected` - the current state of the Environment's deletion protection status. + diff --git a/docs/data-sources/environments.md b/docs/data-sources/environments.md new file mode 100644 index 00000000..31b5e3b3 --- /dev/null +++ b/docs/data-sources/environments.md @@ -0,0 +1,40 @@ +# Data Source: honeycombio_environments + +The Environments data source retrieves the Team's environments. + +-> **NOTE** This data source requires the provider be configured with a Management Key with `environments:read` in the configured scopes. + +## Example Usage + +```hcl +# returns all Environments +data "honeycombio_environments" "all" {} + +# only returns the Environments starting with 'foo_' +data "honeycombio_environments" "foo" { + detail_filter { + name = "name" + value_regex = "foo_*" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `detail_filter` - (Optional) a block to further filter results as described below. `name` must be set when providing a filter. + +To filter the results, a `detail_filter` block can be provided which accepts the following arguments: + +* `name` - (Required) The name of the detail field to filter by. Currently only `name` is supported. +* `value` - (Optional) The value of the detail field to match on. +* `value_regex` - (Optional) A regular expression string to apply to the value of the detail field to match on. + +~> **Note** one of `value` or `value_regex` is required. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `ids` - a list of all the Environment IDs found in the Team. diff --git a/docs/data-sources/slos.md b/docs/data-sources/slos.md index 376745dd..19a0e351 100644 --- a/docs/data-sources/slos.md +++ b/docs/data-sources/slos.md @@ -30,7 +30,7 @@ data "honeycombio_slos" "foo" { The following arguments are supported: * `dataset` - (Required) The dataset to retrieve the SLOs list from -* `detail_filter` - (Optional) a block to further filter recipients as described below. `name` must be set when providing a filter. +* `detail_filter` - (Optional) a block to further filter results as described below. `name` must be set when providing a filter. To further filter the SLO results, a `detail_filter` block can be provided which accepts the following arguments: diff --git a/docs/resources/environment.md b/docs/resources/environment.md new file mode 100644 index 00000000..bba6cfe6 --- /dev/null +++ b/docs/resources/environment.md @@ -0,0 +1,41 @@ +# Resource: honeycombio_environment + +Creates a Honeycomb Environment. + +-> **NOTE** This resource requires the provider be configured with a Management Key with `environments:write` in the configured scopes. + +## Example Usage + +```hcl +resource "honeycombio_environment" "uat" { + name = "UAT-1" + color = "green" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the Environment. Must be unique to the Team. +* `description` - (Optional) A description for the Environment. +* `color` - (Optional) The color to display the Environment in the navigation bar. + If not provided one will be randomly selected at creation. + One of `blue`, `green`, `gold`, `red`, `purple`, `lightBlue`, `lightGreen`, `lightGold`, `lightRed`, `lightPurple`. +* `delete_protected` - (Optional) the current state of the Environment's deletion protection status. + Defaults to `true`. Cannot be set to `false` on create. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The ID of the Environment. +* `slug` - The slug of the Environment. + +## Import + +Environments can be imported by their ID. e.g. + +``` +$ terraform import honeycombio_environment.myenv hcaen_01j1jrsewaha3m0z6fwffpcrxg +``` diff --git a/internal/helper/filter/slo_filter.go b/internal/helper/filter/detail_filter.go similarity index 72% rename from internal/helper/filter/slo_filter.go rename to internal/helper/filter/detail_filter.go index d7bf88df..62c5bd10 100644 --- a/internal/helper/filter/slo_filter.go +++ b/internal/helper/filter/detail_filter.go @@ -4,17 +4,16 @@ import ( "fmt" "regexp" - "github.com/honeycombio/terraform-provider-honeycombio/client" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" ) -type SLODetailFilter struct { +type DetailFilter struct { Type string Value *string ValueRegex *regexp.Regexp } -func NewDetailSLOFilter(filterType, v, r string) (*SLODetailFilter, error) { +func NewDetailFilter(filterType, v, r string) (*DetailFilter, error) { if filterType != "name" { return nil, fmt.Errorf("only name is supported as a filter type") } @@ -34,23 +33,23 @@ func NewDetailSLOFilter(filterType, v, r string) (*SLODetailFilter, error) { valRegexp = regexp.MustCompile(r) } - return &SLODetailFilter{ + return &DetailFilter{ Type: filterType, Value: value, ValueRegex: valRegexp, }, nil } -func (f *SLODetailFilter) Match(s client.SLO) bool { +func (f *DetailFilter) MatchName(name string) bool { // nil filter fails open if f == nil { return true } if f.Value != nil { - return s.Name == *f.Value + return name == *f.Value } if f.ValueRegex != nil { - return f.ValueRegex.MatchString(s.Name) + return f.ValueRegex.MatchString(name) } return true } diff --git a/internal/models/detail_filter.go b/internal/models/detail_filter.go new file mode 100644 index 00000000..653935a9 --- /dev/null +++ b/internal/models/detail_filter.go @@ -0,0 +1,20 @@ +package models + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/filter" +) + +type DetailFilterModel struct { + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` + ValueRegex types.String `tfsdk:"value_regex"` +} + +func (f *DetailFilterModel) NewFilter() (*filter.DetailFilter, error) { + if f == nil { + return nil, nil + } + return filter.NewDetailFilter(f.Name.ValueString(), f.Value.ValueString(), f.ValueRegex.ValueString()) +} diff --git a/internal/models/environments.go b/internal/models/environments.go new file mode 100644 index 00000000..331d4541 --- /dev/null +++ b/internal/models/environments.go @@ -0,0 +1,20 @@ +package models + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type EnvironmentResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Slug types.String `tfsdk:"slug"` + Description types.String `tfsdk:"description"` + Color types.String `tfsdk:"color"` + DeleteProtected types.Bool `tfsdk:"delete_protected"` +} + +type EnvironmentsDataSourceModel struct { + ID types.String `tfsdk:"id"` + DetailFilter []DetailFilterModel `tfsdk:"detail_filter"` + IDs []types.String `tfsdk:"ids"` +} diff --git a/internal/models/slo.go b/internal/models/slo.go new file mode 100644 index 00000000..f3446051 --- /dev/null +++ b/internal/models/slo.go @@ -0,0 +1,22 @@ +package models + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type SLOsDataSourceModel struct { + ID types.String `tfsdk:"id"` + Dataset types.String `tfsdk:"dataset"` + DetailFilter []DetailFilterModel `tfsdk:"detail_filter"` + IDs []types.String `tfsdk:"ids"` +} + +type SLODataSourceModel struct { + ID types.String `tfsdk:"id"` + Dataset types.String `tfsdk:"dataset"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + SLI types.String `tfsdk:"sli"` + TargetPercentage types.Float64 `tfsdk:"target_percentage"` + TimePeriod types.Int64 `tfsdk:"time_period"` +} diff --git a/internal/provider/detail_filter.go b/internal/provider/detail_filter.go new file mode 100644 index 00000000..2f0a3dd9 --- /dev/null +++ b/internal/provider/detail_filter.go @@ -0,0 +1,42 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation" +) + +func detailFilterSchema() schema.ListNestedBlock { + return schema.ListNestedBlock{ + Description: "Attributes to filter the results with. `name` must be set when providing a filter.", + Validators: []validator.List{listvalidator.SizeAtMost(1)}, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + Description: "The name of the detail field to filter by. Currently only 'name' is supported.", + Validators: []validator.String{stringvalidator.OneOf("name")}, + }, + "value": schema.StringAttribute{ + Optional: true, + Description: "The value of the detail field to match on.", + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("value_regex")), + }, + }, + "value_regex": schema.StringAttribute{ + Optional: true, + Description: "A regular expression string to apply to the value of the detail field to match on.", + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("value")), + validation.IsValidRegExp(), + }, + }, + }, + }, + } +} diff --git a/internal/provider/environment_data_source.go b/internal/provider/environment_data_source.go new file mode 100644 index 00000000..0db4894d --- /dev/null +++ b/internal/provider/environment_data_source.go @@ -0,0 +1,112 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + v2client "github.com/honeycombio/terraform-provider-honeycombio/client/v2" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/models" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &sloDataSource{} + _ datasource.DataSourceWithConfigure = &sloDataSource{} +) + +func NewEnvironmentDataSource() datasource.DataSource { + return &environmentDataSource{} +} + +type environmentDataSource struct { + client *v2client.Client +} + +func (d *environmentDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_environment" +} + +func (d *environmentDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Fetches the details of a single Environment.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the Environment to fetch.", + Required: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the Environment.", + Computed: true, + Optional: false, + Required: false, + }, + "slug": schema.StringAttribute{ + Description: "The slug of the Environment.", + Computed: true, + Optional: false, + Required: false, + }, + "description": schema.StringAttribute{ + Description: "The Environment's description.", + Computed: true, + Optional: false, + Required: false, + }, + "color": schema.StringAttribute{ + Description: "The color of the Environment.", + Computed: true, + Optional: false, + Required: false, + }, + "delete_protected": schema.BoolAttribute{ + Description: "The current delete protection status of the Environment.", + Computed: true, + Optional: false, + Required: false, + }, + }, + } +} + +func (d *environmentDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V2Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c +} + +func (d *environmentDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data models.EnvironmentResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + env, err := d.client.Environments.Get(ctx, data.ID.ValueString()) + if helper.AddDiagnosticOnError(&resp.Diagnostics, + fmt.Sprintf("Looking up Environment %q", data.ID.ValueString()), err) { + return + } + + data.ID = types.StringValue(env.ID) + data.Name = types.StringValue(env.Name) + data.Slug = types.StringValue(env.Slug) + data.Description = types.StringPointerValue(env.Description) + data.Color = types.StringPointerValue(env.Color) + data.DeleteProtected = types.BoolPointerValue(env.Settings.DeleteProtected) + + diags := resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/provider/environment_data_source_test.go b/internal/provider/environment_data_source_test.go new file mode 100644 index 00000000..04aa14c2 --- /dev/null +++ b/internal/provider/environment_data_source_test.go @@ -0,0 +1,57 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stretchr/testify/require" + + v2client "github.com/honeycombio/terraform-provider-honeycombio/client/v2" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" +) + +func TestAcc_EnvironmentDataSource(t *testing.T) { + ctx := context.Background() + c := testAccV2Client(t) + + env, err := c.Environments.Create(ctx, &v2client.Environment{ + Name: test.RandomStringWithPrefix("test.", 20), + Description: helper.ToPtr("test environment"), + }) + require.NoError(t, err) + + t.Cleanup(func() { + c.Environments.Update(ctx, &v2client.Environment{ + ID: env.ID, + Settings: &v2client.EnvironmentSettings{ + DeleteProtected: helper.ToPtr(false), + }, + }) + c.Environments.Delete(ctx, env.ID) + }) + + resource.Test(t, resource.TestCase{ + PreCheck: testAccPreCheck(t), + ProtoV5ProviderFactories: testAccProtoV5ProviderFactory, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +data "honeycombio_environment" "test" { + id = "%s" +}`, env.ID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.honeycombio_environment.test", "id", env.ID), + resource.TestCheckResourceAttr("data.honeycombio_environment.test", "name", env.Name), + resource.TestCheckResourceAttr("data.honeycombio_environment.test", "slug", env.Slug), + resource.TestCheckResourceAttr("data.honeycombio_environment.test", "color", *env.Color), + resource.TestCheckResourceAttr("data.honeycombio_environment.test", "description", *env.Description), + resource.TestCheckResourceAttr("data.honeycombio_environment.test", "delete_protected", "true"), + ), + }, + }, + }) + +} diff --git a/internal/provider/environment_resource.go b/internal/provider/environment_resource.go new file mode 100644 index 00000000..4f504f66 --- /dev/null +++ b/internal/provider/environment_resource.go @@ -0,0 +1 @@ +package provider diff --git a/internal/provider/environment_resource_test.go b/internal/provider/environment_resource_test.go new file mode 100644 index 00000000..4f504f66 --- /dev/null +++ b/internal/provider/environment_resource_test.go @@ -0,0 +1 @@ +package provider diff --git a/internal/provider/environments_data_source.go b/internal/provider/environments_data_source.go new file mode 100644 index 00000000..c0698494 --- /dev/null +++ b/internal/provider/environments_data_source.go @@ -0,0 +1,111 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + v2client "github.com/honeycombio/terraform-provider-honeycombio/client/v2" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/filter" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/hashcode" + "github.com/honeycombio/terraform-provider-honeycombio/internal/models" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &environmentsDataSource{} + _ datasource.DataSourceWithConfigure = &environmentsDataSource{} +) + +func NewEnvironmentsDataSource() datasource.DataSource { + return &environmentsDataSource{} +} + +type environmentsDataSource struct { + client *v2client.Client +} + +func (d *environmentsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_environments" +} + +func (d *environmentsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V2Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c +} + +func (d *environmentsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Fetches the Environments in a Team.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Optional: false, + Required: false, + }, + "ids": schema.ListAttribute{ + Description: "The list returned of Environments IDs.", + Computed: true, + Optional: false, + Required: false, + ElementType: types.StringType, + }, + }, + Blocks: map[string]schema.Block{ + "detail_filter": detailFilterSchema(), + }, + } +} + +func (d *environmentsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data models.EnvironmentsDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + pager, err := d.client.Environments.List(ctx) + if helper.AddDiagnosticOnError(&resp.Diagnostics, "Listing Environments", err) { + return + } + envs := []*v2client.Environment{} + for pager.HasNext() { + items, err := pager.Next(ctx) + if helper.AddDiagnosticOnError(&resp.Diagnostics, "Listing Environments", err) { + return + } + envs = append(envs, items...) + } + + var envFilter *filter.DetailFilter + if len(data.DetailFilter) > 0 { + envFilter, err = data.DetailFilter[0].NewFilter() + if err != nil { + resp.Diagnostics.AddError("Unable to create Environment filter", err.Error()) + return + } + } + + for _, e := range envs { + if !envFilter.MatchName(e.Name) { + continue + } + data.IDs = append(data.IDs, types.StringValue(e.ID)) + } + data.ID = types.StringValue(hashcode.StringValues(data.IDs)) + + diags := resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/provider/environments_data_source_test.go b/internal/provider/environments_data_source_test.go new file mode 100644 index 00000000..72b4c9c5 --- /dev/null +++ b/internal/provider/environments_data_source_test.go @@ -0,0 +1,86 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stretchr/testify/require" + + v2client "github.com/honeycombio/terraform-provider-honeycombio/client/v2" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" +) + +func TestAcc_EnvironmentsDatasource(t *testing.T) { + ctx := context.Background() + c := testAccV2Client(t) + const numEnvs = 15 + + // create a bunch of environments + testEnvs := make([]*v2client.Environment, numEnvs+1) + for i := 0; i < numEnvs; i++ { + e, err := c.Environments.Create(ctx, &v2client.Environment{ + Name: test.RandomStringWithPrefix("test.", 20), + }) + require.NoError(t, err) + testEnvs[i] = e + } + // one additional with a different prefix for filter testing + e, err := c.Environments.Create(ctx, &v2client.Environment{ + Name: "test." + test.RandomString(20), + }) + require.NoError(t, err) + testEnvs[numEnvs] = e + + t.Cleanup(func() { + for _, e := range testEnvs { + c.Environments.Update(ctx, &v2client.Environment{ + ID: e.ID, + Settings: &v2client.EnvironmentSettings{ + DeleteProtected: helper.ToPtr(false), + }, + }) + c.Environments.Delete(ctx, e.ID) + } + }) + + resource.Test(t, resource.TestCase{ + PreCheck: testAccPreCheck(t), + ProtoV5ProviderFactories: testAccProtoV5ProviderFactory, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +data "honeycombio_environments" "all" {} + +data "honeycombio_environments" "regex" { + detail_filter { + name = "name" + value_regex = "test.*" + } +} + +data "honeycombio_environments" "exact" { + detail_filter { + name = "name" + value = "%s" + } +}`, e.Name), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "data.honeycombio_environments.all", + "ids.#", + fmt.Sprintf("%d", numEnvs+2), // +2 because of the additional environment created and the 'ci' environment + ), + resource.TestCheckResourceAttr( + "data.honeycombio_environments.regex", + "ids.#", + fmt.Sprintf("%d", numEnvs+1), // +1 because of the additional environment created + ), + resource.TestCheckResourceAttr("data.honeycombio_environments.exact", "ids.#", "1"), + ), + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 417d0c0a..d7c68d43 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -87,6 +87,8 @@ func (p *HoneycombioProvider) DataSources(ctx context.Context) []func() datasour NewAuthMetadataDataSource, NewDerivedColumnDataSource, NewDerivedColumnsDataSource, + NewEnvironmentDataSource, + NewEnvironmentsDataSource, NewSLODataSource, NewSLOsDataSource, NewQuerySpecDataSource, diff --git a/internal/provider/slo_data_source.go b/internal/provider/slo_data_source.go index 94587fb0..6358205b 100644 --- a/internal/provider/slo_data_source.go +++ b/internal/provider/slo_data_source.go @@ -10,6 +10,7 @@ import ( "github.com/honeycombio/terraform-provider-honeycombio/client" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/models" ) // Ensure the implementation satisfies the expected interfaces. @@ -27,16 +28,6 @@ type sloDataSource struct { client *client.Client } -type sloDataSourceModel struct { - ID types.String `tfsdk:"id"` - Dataset types.String `tfsdk:"dataset"` - Name types.String `tfsdk:"name"` - Description types.String `tfsdk:"description"` - SLI types.String `tfsdk:"sli"` - TargetPercentage types.Float64 `tfsdk:"target_percentage"` - TimePeriod types.Int64 `tfsdk:"time_period"` -} - func (d *sloDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_slo" } @@ -102,8 +93,7 @@ func (d *sloDataSource) Configure(_ context.Context, req datasource.ConfigureReq } func (d *sloDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data sloDataSourceModel - + var data models.SLODataSourceModel resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return diff --git a/internal/provider/slo_data_source_test.go b/internal/provider/slo_data_source_test.go index 97d8aca2..fe2deac1 100644 --- a/internal/provider/slo_data_source_test.go +++ b/internal/provider/slo_data_source_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" ) @@ -21,9 +22,8 @@ func TestAcc_SLODataSource(t *testing.T) { Description: "test SLI", Expression: "BOOL(1)", }) - if err != nil { - t.Error(err) - } + require.NoError(t, err) + slo, err := c.SLOs.Create(ctx, dataset, &client.SLO{ Name: acctest.RandString(4) + "_slo", Description: "test SLO", @@ -31,9 +31,7 @@ func TestAcc_SLODataSource(t *testing.T) { TargetPerMillion: 995000, SLI: client.SLIRef{Alias: sli.Alias}, }) - if err != nil { - t.Error(err) - } + require.NoError(t, err) //nolint:errcheck t.Cleanup(func() { @@ -46,7 +44,11 @@ func TestAcc_SLODataSource(t *testing.T) { ProtoV5ProviderFactories: testAccProtoV5ProviderFactory, Steps: []resource.TestStep{ { - Config: testAccSLODataSourceConfig(dataset, slo.ID), + Config: fmt.Sprintf(` +data "honeycombio_slo" "test" { + id = "%s" + dataset = "%s" +}`, slo.ID, dataset), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("data.honeycombio_slo.test", "name", slo.Name), resource.TestCheckResourceAttr("data.honeycombio_slo.test", "description", slo.Description), @@ -58,12 +60,3 @@ func TestAcc_SLODataSource(t *testing.T) { }, }) } - -func testAccSLODataSourceConfig(dataset, id string) string { - return fmt.Sprintf(` -data "honeycombio_slo" "test" { - id = "%s" - dataset = "%s" -} -`, id, dataset) -} diff --git a/internal/provider/slos_data_source.go b/internal/provider/slos_data_source.go index 056c569c..3e9d6952 100644 --- a/internal/provider/slos_data_source.go +++ b/internal/provider/slos_data_source.go @@ -3,19 +3,15 @@ package provider import ( "context" - "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/honeycombio/terraform-provider-honeycombio/client" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/filter" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/hashcode" - "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation" + "github.com/honeycombio/terraform-provider-honeycombio/internal/models" ) // Ensure the implementation satisfies the expected interfaces. @@ -33,26 +29,6 @@ type slosDataSource struct { client *client.Client } -type slosDataSourceModel struct { - ID types.String `tfsdk:"id"` - Dataset types.String `tfsdk:"dataset"` - DetailFilter []slosDetailFilter `tfsdk:"detail_filter"` - IDs []types.String `tfsdk:"ids"` -} - -type slosDetailFilter struct { - Name types.String `tfsdk:"name"` - Value types.String `tfsdk:"value"` - ValueRegex types.String `tfsdk:"value_regex"` -} - -func (f *slosDetailFilter) SLOFilter() (*filter.SLODetailFilter, error) { - if f == nil { - return nil, nil - } - return filter.NewDetailSLOFilter(f.Name.ValueString(), f.Value.ValueString(), f.ValueRegex.ValueString()) -} - func (d *slosDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_slos" } @@ -63,6 +39,8 @@ func (d *slosDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, r Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, + Optional: false, + Required: false, }, "dataset": schema.StringAttribute{ Description: "The dataset to fetch the SLOs from.", @@ -77,34 +55,7 @@ func (d *slosDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, r }, }, Blocks: map[string]schema.Block{ - "detail_filter": schema.ListNestedBlock{ - Description: "Attributes to filter the SLOs with. `name` must be set when providing a filter.", - Validators: []validator.List{listvalidator.SizeAtMost(1)}, - NestedObject: schema.NestedBlockObject{ - Attributes: map[string]schema.Attribute{ - "name": schema.StringAttribute{ - Required: true, - Description: "The name of the detail field to filter by.", - Validators: []validator.String{stringvalidator.OneOf("name")}, - }, - "value": schema.StringAttribute{ - Optional: true, - Description: "The value of the detail field to match on.", - Validators: []validator.String{ - stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("value_regex")), - }, - }, - "value_regex": schema.StringAttribute{ - Optional: true, - Description: "A regular expression string to apply to the value of the detail field to match on.", - Validators: []validator.String{ - stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("value")), - validation.IsValidRegExp(), - }, - }, - }, - }, - }, + "detail_filter": detailFilterSchema(), }, } } @@ -124,8 +75,7 @@ func (d *slosDataSource) Configure(_ context.Context, req datasource.ConfigureRe } func (d *slosDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data slosDataSourceModel - + var data models.SLOsDataSourceModel resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return @@ -136,16 +86,16 @@ func (d *slosDataSource) Read(ctx context.Context, req datasource.ReadRequest, r return } - var sloFilter *filter.SLODetailFilter + var sloFilter *filter.DetailFilter if len(data.DetailFilter) > 0 { - sloFilter, err = data.DetailFilter[0].SLOFilter() + sloFilter, err = data.DetailFilter[0].NewFilter() if err != nil { resp.Diagnostics.AddError("Unable to create SLO filter", err.Error()) return } } for _, s := range slos { - if sloFilter != nil && !sloFilter.Match(s) { + if !sloFilter.MatchName(s.Name) { continue } data.IDs = append(data.IDs, types.StringValue(s.ID))