Repository: honeycombio/terraform-provider-honeycombio
Pull request #501 (2024-07-17 21:02:09)

feat: environment management support
Adds the ability to manage a team's Environments ✨ by introducing a pair of data sources and a shiny new resource.

---
- To see the specific tasks where the Asana app for GitHub is being used, see below:
  - https://app.asana.com/0/0/1207759682365320

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/501?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) Report
Attention: Patch coverage is `58.33333%` with `30 lines` in your changes missing coverage. Please review.
> Project coverage is 79.98%. Comparing base [(`3ecd18f`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/3ecd18f37ae0e8cab9165d912ac740af8bc72db9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) to head [(`db362df`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/db362dfd05c9b27753768790cd73c950fe8653a7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).

| [Files](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/501?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) | Patch % | Lines |
|---|---|---|
| [client/v2/environments.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/501?src=pr&el=tree&filepath=client%2Fv2%2Fenvironments.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-Y2xpZW50L3YyL2Vudmlyb25tZW50cy5nbw==) | 57.74% | [21 Missing and 9 partials :warning: ](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #501      +/-   ##
==========================================
- Coverage   80.36%   79.98%   -0.38%     
==========================================
  Files          67       68       +1     
  Lines        5174     5246      +72     
==========================================
+ Hits         4158     4196      +38     
- Misses        753      778      +25     
- Partials      263      272       +9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/501?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).


Patch:
diff --git a/client/v2/client.go b/client/v2/client.go
index a2c0116..120afff 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 0000000..1201fb8
--- /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/models.go b/client/v2/models.go
index 6df9dec..e5866c2 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 0000000..e3de2e0
--- /dev/null
+++ b/docs/data-sources/environment.md
@@ -0,0 +1,33 @@
+# Data Source: honeycombio_environment
+
+The `honeycombio_environment` data source retrieves the details of a single Environment.
+If you want to retrieve multiple Environments, use the `honeycombio_environments` data source instead.
+
+-> **NOTE** This data source requires the provider be configured with a Management Key with `environments:read` in the configured scopes.
+
+
+## Example Usage
+
+```hcl
+# Retrieve the details of an 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 0000000..31b5e3b
--- /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/slo.md b/docs/data-sources/slo.md
index 152c25b..7c22724 100644
--- a/docs/data-sources/slo.md
+++ b/docs/data-sources/slo.md
@@ -1,9 +1,7 @@
 # Data Source: honeycombio_slo
 
 The `honeycombio_slo` data source retrieves the details of a single SLO for a dataset.
-
--> **Note** Terraform will fail unless an SLO is returned by the search. Ensure that your search is specific enough to return an SLO.
-If you want to match multiple SLOs, use the `honeycombio_slos` data source instead.
+If you want to retreive multiple SLOs, use the `honeycombio_slos` data source instead.
 
 ## Example Usage
 
diff --git a/docs/data-sources/slos.md b/docs/data-sources/slos.md
index 376745d..19a0e35 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 0000000..bba6cfe
--- /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 d7bf88d..62c5bd1 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/helper/modifiers/deletion_protection.go b/internal/helper/modifiers/deletion_protection.go
new file mode 100644
index 0000000..77ed598
--- /dev/null
+++ b/internal/helper/modifiers/deletion_protection.go
@@ -0,0 +1,37 @@
+package modifiers
+
+import (
+	"context"
+
+	"github.com/hashicorp/terraform-plugin-framework/path"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+)
+
+type deletionProtectionModifier struct{}
+
+var _ planmodifier.Bool = &deletionProtectionModifier{}
+
+func (m deletionProtectionModifier) Description(_ context.Context) string {
+	return "Ensures delete protection cannot be disabled at creation."
+}
+
+func (m deletionProtectionModifier) MarkdownDescription(ctx context.Context) string {
+	return m.Description(ctx)
+}
+
+func (m deletionProtectionModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) {
+	// previous state doesn't exist (creation), and planned value false (disabled)
+	if req.State.Raw.IsNull() && !req.PlanValue.ValueBool() {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("").AtName("delete_protected"),
+			"Environment validation error",
+			"Delete protection cannot be disabled at creation.",
+		)
+	}
+}
+
+// EnforceDeletionProtection creates a plan modifier that ensures deletion
+// protection cannot be disabled at creation.
+func EnforceDeletionProtection() planmodifier.Bool {
+	return deletionProtectionModifier{}
+}
diff --git a/internal/models/detail_filter.go b/internal/models/detail_filter.go
new file mode 100644
index 0000000..653935a
--- /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 0000000..331d454
--- /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 0000000..f344605
--- /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/api_key_resource.go b/internal/provider/api_key_resource.go
index cb04eb7..9caa8c8 100644
--- a/internal/provider/api_key_resource.go
+++ b/internal/provider/api_key_resource.go
@@ -271,7 +271,7 @@ func (r *apiKeyResource) Delete(ctx context.Context, req resource.DeleteRequest,
 	if err != nil {
 		if errors.As(err, &detailedErr) {
 			resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic(
-				"Error Reading Honeycomb API Key",
+				"Error Deleting Honeycomb API Key",
 				&detailedErr,
 			))
 		} else {
diff --git a/internal/provider/detail_filter.go b/internal/provider/detail_filter.go
new file mode 100644
index 0000000..2f0a3dd
--- /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 0000000..0db4894
--- /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_resource.go b/internal/provider/environment_resource.go
new file mode 100644
index 0000000..6de0ebe
--- /dev/null
+++ b/internal/provider/environment_resource.go
@@ -0,0 +1,275 @@
+package provider
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+
+	hny "github.com/honeycombio/terraform-provider-honeycombio/client"
+	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/modifiers"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
+)
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+	_ resource.Resource                = &environmentResource{}
+	_ resource.ResourceWithConfigure   = &environmentResource{}
+	_ resource.ResourceWithModifyPlan  = &environmentResource{}
+	_ resource.ResourceWithImportState = &environmentResource{}
+)
+
+type environmentResource struct {
+	client *v2client.Client
+}
+
+func NewEnvironmentResource() resource.Resource {
+	return &environmentResource{}
+}
+
+func (*environmentResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_environment"
+}
+
+func (r *environmentResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+	w := getClientFromResourceRequest(&req)
+	if w == nil {
+		return
+	}
+
+	c, err := w.V2Client()
+	if err != nil || c == nil {
+		resp.Diagnostics.AddError("Failed to configure client", err.Error())
+		return
+	}
+	r.client = c
+}
+
+func (*environmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		Description: "Manages an Environment in a Honeycomb Team.",
+		Attributes: map[string]schema.Attribute{
+			"id": schema.StringAttribute{
+				Computed:    true,
+				Required:    false,
+				Optional:    false,
+				Description: "The ID of the Environment.",
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+			},
+			"name": schema.StringAttribute{
+				Required:    true,
+				Description: "The name of the Environment.",
+				Validators: []validator.String{
+					stringvalidator.LengthBetween(1, 255),
+				},
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+			"slug": schema.StringAttribute{
+				Description: "The slug of the Environment.",
+				Computed:    true,
+				Optional:    false,
+				Required:    false,
+			},
+			"description": schema.StringAttribute{
+				Description: "The Environment's description.",
+				Optional:    true,
+				Computed:    true,
+				Default:     stringdefault.StaticString(""),
+				Validators: []validator.String{
+					stringvalidator.LengthAtMost(255),
+				},
+			},
+			"color": schema.StringAttribute{
+				Description: "The color of the Environment. If one is not provided, a random color will be assigned.",
+				Computed:    true,
+				Optional:    true,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+				Validators: []validator.String{
+					stringvalidator.OneOf(v2client.EnvironmentColorTypes()...),
+				},
+			},
+			"delete_protected": schema.BoolAttribute{
+				Description: "The current delete protection status of the Environment. Cannot be set to false on creation.",
+				Default:     booldefault.StaticBool(true),
+				Computed:    true,
+				Optional:    true,
+				PlanModifiers: []planmodifier.Bool{
+					modifiers.EnforceDeletionProtection(),
+				},
+			},
+		},
+	}
+}
+
+func (r *environmentResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	if req.ID == "" {
+		resp.Diagnostics.AddError("Invalid Import ID", "The Environment ID must be provided")
+		return
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, &models.EnvironmentResourceModel{
+		ID: types.StringValue(req.ID),
+	})...)
+}
+
+func (r *environmentResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
+	if req.Plan.Raw.IsNull() {
+		// If the entire plan is null, the resource is planned for destruction -- let's add a warning
+		resp.Diagnostics.AddWarning(
+			"Resource Destruction Warning",
+			"Applying this plan will delete the Environment and all of its contents. "+
+				"This is an irreversible operation.",
+		)
+	}
+}
+
+func (r *environmentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var plan models.EnvironmentResourceModel
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	env, err := r.client.Environments.Create(ctx, &v2client.Environment{
+		Name:        plan.Name.ValueString(),
+		Description: plan.Description.ValueStringPointer(),
+		Color:       plan.Color.ValueStringPointer(),
+	})
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Creating Environment", err) {
+		return
+	}
+
+	var state models.EnvironmentResourceModel
+	state.ID = types.StringValue(env.ID)
+	state.Name = types.StringValue(env.Name)
+	state.Slug = types.StringValue(env.Slug)
+	state.Color = types.StringPointerValue(env.Color)
+	state.Description = types.StringPointerValue(env.Description)
+	state.DeleteProtected = types.BoolPointerValue(env.Settings.DeleteProtected)
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *environmentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var state models.EnvironmentResourceModel
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	var detailedErr hny.DetailedError
+	env, err := r.client.Environments.Get(ctx, state.ID.ValueString())
+	if errors.As(err, &detailedErr) {
+		if detailedErr.IsNotFound() {
+			// if not found consider it deleted -- so just remove it from state
+			resp.State.RemoveResource(ctx)
+			return
+		} else {
+			resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic(
+				"Error Reading Honeycomb Environment",
+				&detailedErr,
+			))
+		}
+	} else if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Reading Honeycomb Environment",
+			"Unexpected error reading Environment ID "+state.ID.ValueString()+": "+err.Error(),
+		)
+	}
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	state.ID = types.StringValue(env.ID)
+	state.Name = types.StringValue(env.Name)
+	state.Slug = types.StringValue(env.Slug)
+	state.Color = types.StringPointerValue(env.Color)
+	state.Description = types.StringPointerValue(env.Description)
+	state.DeleteProtected = types.BoolPointerValue(env.Settings.DeleteProtected)
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *environmentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var plan, state models.EnvironmentResourceModel
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	_, err := r.client.Environments.Update(ctx, &v2client.Environment{
+		ID:          plan.ID.ValueString(),
+		Description: plan.Description.ValueStringPointer(),
+		Color:       plan.Color.ValueStringPointer(),
+		Settings: &v2client.EnvironmentSettings{
+			DeleteProtected: plan.DeleteProtected.ValueBoolPointer(),
+		},
+	})
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Environment", err) {
+		return
+	}
+
+	env, err := r.client.Environments.Get(ctx, plan.ID.ValueString())
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Environment", err) {
+		return
+	}
+
+	state.ID = types.StringValue(env.ID)
+	state.Name = types.StringValue(env.Name)
+	state.Slug = types.StringValue(env.Slug)
+	state.Color = types.StringPointerValue(env.Color)
+	state.Description = types.StringPointerValue(env.Description)
+	state.DeleteProtected = types.BoolPointerValue(env.Settings.DeleteProtected)
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *environmentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var state models.EnvironmentResourceModel
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	err := r.client.Environments.Delete(ctx, state.ID.ValueString())
+	var detailedErr hny.DetailedError
+	if err != nil {
+		if errors.As(err, &detailedErr) {
+			if detailedErr.Status == http.StatusConflict {
+				resp.Diagnostics.AddError(
+					"Unable to Delete Environment",
+					"Delete Protection is enabled. "+
+						"You must disable delete protection before it can be deleted.",
+				)
+			} else {
+				resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic(
+					"Error Deleting Honeycomb Environment",
+					&detailedErr,
+				))
+			}
+		} else {
+			resp.Diagnostics.AddError(
+				"Error Deleting Honeycomb Environment",
+				"Could not delete Environment ID "+state.ID.ValueString()+": "+err.Error(),
+			)
+		}
+	}
+}
diff --git a/internal/provider/environments_data_source.go b/internal/provider/environments_data_source.go
new file mode 100644
index 0000000..c069849
--- /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/provider.go b/internal/provider/provider.go
index 417d0c0..daa87e1 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 	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/log"
 )
 
@@ -79,6 +80,7 @@ func (p *HoneycombioProvider) Resources(ctx context.Context) []func() resource.R
 		NewTriggerResource,
 		NewQueryResource,
 		NewAPIKeyResource,
+		NewEnvironmentResource,
 	}
 }
 
@@ -87,6 +89,8 @@ func (p *HoneycombioProvider) DataSources(ctx context.Context) []func() datasour
 		NewAuthMetadataDataSource,
 		NewDerivedColumnDataSource,
 		NewDerivedColumnsDataSource,
+		NewEnvironmentDataSource,
+		NewEnvironmentsDataSource,
 		NewSLODataSource,
 		NewSLOsDataSource,
 		NewQuerySpecDataSource,
@@ -200,12 +204,7 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config
 			Debug:     debug,
 			UserAgent: userAgent,
 		})
-		if err != nil {
-			resp.Diagnostics.AddError(
-				"Unable to create Honeycomb API Client",
-				"An unexpected error occurred when creating the Honeycomb API client.\n\n "+
-					"Honeycomb Client Error: "+err.Error(),
-			)
+		if helper.AddDiagnosticOnError(&resp.Diagnostics, "Unable to create Honeycomb API V1 Client", err) {
 			return
 		}
 		cc.v1client = client
@@ -219,12 +218,7 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config
 			Debug:        debug,
 			UserAgent:    userAgent,
 		})
-		if err != nil {
-			resp.Diagnostics.AddError(
-				"Unable to create Honeycomb API V2 Client",
-				"An unexpected error occurred when creating the Honeycomb API V2 client.\n\n "+
-					"Honeycomb V2 Client Error: "+err.Error(),
-			)
+		if helper.AddDiagnosticOnError(&resp.Diagnostics, "Unable to create Honeycomb API V2 Client", err) {
 			return
 		}
 		cc.v2client = v2client
diff --git a/internal/provider/slo_data_source.go b/internal/provider/slo_data_source.go
index 94587fb..6358205 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/slos_data_source.go b/internal/provider/slos_data_source.go
index 056c569..3e9d695 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))


Test patch:
diff --git a/client/v2/api_keys_test.go b/client/v2/api_keys_test.go
index 5771034..e949d06 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/environments_test.go b/client/v2/environments_test.go
new file mode 100644
index 0000000..89d4f32
--- /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/internal/provider/environment_data_source_test.go b/internal/provider/environment_data_source_test.go
new file mode 100644
index 0000000..87a7f14
--- /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:                 testAccPreCheckV2API(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_test.go b/internal/provider/environment_resource_test.go
new file mode 100644
index 0000000..c315843
--- /dev/null
+++ b/internal/provider/environment_resource_test.go
@@ -0,0 +1,129 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/hashicorp/terraform-plugin-testing/terraform"
+)
+
+func TestAcc_EnvironmentResource(t *testing.T) {
+
+	t.Run("happy path", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheckV2API(t),
+			ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+			Steps: []resource.TestStep{
+				{
+					Config: testAccConfigBasicEnvironmentTest("test environment", "test description", "blue", true),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureEnvironmentExists(t, "honeycombio_environment.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_environment.test", "id"),
+						resource.TestCheckResourceAttrSet("honeycombio_environment.test", "slug"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "name", "test environment"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "description", "test description"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "color", "blue"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "delete_protected", "true"),
+					),
+				},
+				{ // fail to delete protected environment
+					Config:      testAccConfigBasicEnvironmentTest("test environment", "test description", "blue", true),
+					Destroy:     true,
+					ExpectError: regexp.MustCompile(`must disable delete protection`),
+				},
+				{ // now update the description and color, and disable delete protection
+					Config: testAccConfigBasicEnvironmentTest("test environment", "new description", "green", false),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureEnvironmentExists(t, "honeycombio_environment.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_environment.test", "id"),
+						resource.TestCheckResourceAttrSet("honeycombio_environment.test", "slug"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "name", "test environment"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "description", "new description"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "color", "green"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "delete_protected", "false"),
+					),
+				},
+				{
+					ResourceName: "honeycombio_environment.test",
+					ImportState:  true,
+				},
+			},
+		})
+	})
+
+	t.Run("default values", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheckV2API(t),
+			ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+			Steps: []resource.TestStep{
+				{
+					Config: `
+resource "honeycombio_environment" "test" {
+  name = "default"
+}`,
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureEnvironmentExists(t, "honeycombio_environment.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_environment.test", "id"),
+						resource.TestCheckResourceAttrSet("honeycombio_environment.test", "slug"),
+						resource.TestCheckResourceAttrSet("honeycombio_environment.test", "color"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "name", "default"),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "description", ""),
+						resource.TestCheckResourceAttr("honeycombio_environment.test", "delete_protected", "true"),
+					),
+				},
+				{ // disable delete protection to allow cleanup
+					Config: `
+resource "honeycombio_environment" "test" {
+  name             = "default"
+  delete_protected = false
+}`,
+				},
+			},
+		})
+	})
+
+	t.Run("fails to create environment with delete protection disabled", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheckV2API(t),
+			ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+			Steps: []resource.TestStep{
+				{
+					Config:      testAccConfigBasicEnvironmentTest("nope", "", "red", false),
+					PlanOnly:    true,
+					ExpectError: regexp.MustCompile(`protection cannot be disabled at creation`),
+				},
+			},
+		})
+	})
+}
+
+func testAccConfigBasicEnvironmentTest(name, description, color string, protected bool) string {
+	return fmt.Sprintf(`
+resource "honeycombio_environment" "test" {
+  name        = "%s"
+  description = "%s"
+  color       = "%s"
+
+  delete_protected = %t
+}`, name, description, color, protected)
+}
+
+func testAccEnsureEnvironmentExists(t *testing.T, name string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		rs, ok := s.RootModule().Resources[name]
+		if !ok {
+			return fmt.Errorf("resource not found: %s", name)
+		}
+
+		client := testAccV2Client(t)
+		_, err := client.Environments.Get(context.Background(), rs.Primary.ID)
+		if err != nil {
+			return fmt.Errorf("failed to fetch created Environment: %s", err)
+		}
+
+		return nil
+	}
+}
diff --git a/internal/provider/environments_data_source_test.go b/internal/provider/environments_data_source_test.go
new file mode 100644
index 0000000..561ba5a
--- /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:                 testAccPreCheckV2API(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/slo_data_source_test.go b/internal/provider/slo_data_source_test.go
index 97d8aca..fe2deac 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)
-}