diff --git a/go.mod b/go.mod index aa0921d7..9ae235b4 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( ) require ( + dario.cat/mergo v1.0.1 // indirect github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -31,6 +32,7 @@ require ( github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/fatih/color v1.18.0 // indirect github.com/go-akka/configuration v0.0.0-20200606091224-a002c0330665 // indirect + github.com/go-test/deep v1.0.8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.21.2 // indirect @@ -80,5 +82,6 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ee7d9519..b5126907 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Jeffail/gabs/v2 v2.7.0 h1:Y2edYaTcE8ZpRsR2AtmPu5xQdFDIthFG0jYhu5PY8kg= github.com/Jeffail/gabs/v2 v2.7.0/go.mod h1:dp5ocw1FvBBQYssgHsG7I1WYsiLRtkUaB1FEtSwvNUw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -57,8 +57,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= -github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= @@ -295,8 +295,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= diff --git a/sysdig/common.go b/sysdig/common.go index d3bf84c6..b5eef924 100644 --- a/sysdig/common.go +++ b/sysdig/common.go @@ -52,6 +52,11 @@ const ( SchemaScopeKey = "scope" SchemaScopesKey = "scopes" SchemaTargetTypeKey = "target_type" + SchemaExpressionKey = "expression" + SchemaFieldKey = "field" + SchemaOperatorKey = "operator" + SchemaValueKey = "value" + SchemaValuesKey = "values" SchemaRoleKey = "role" SchemaSystemRoleKey = "system_role" SchemaRulesKey = "rules" diff --git a/sysdig/data_source_sysdig_secure_zone.go b/sysdig/data_source_sysdig_secure_zone.go index ddb2c2ef..f653c5e9 100644 --- a/sysdig/data_source_sysdig_secure_zone.go +++ b/sysdig/data_source_sysdig_secure_zone.go @@ -50,13 +50,34 @@ func dataSourceSysdigSecureZone() *schema.Resource { Type: schema.TypeString, Computed: true, }, + // Not marked Deprecated: rules with v2-compatible syntax are fully supported. + // Only v1 syntax (labels, labelValues, agentTags) is deprecated, but since + // this is a Computed field, SDK v2 has no mechanism for conditional deprecation. + // The resource-side ValidateDiagFunc handles the v1-only warning. SchemaRulesKey: { Type: schema.TypeString, Computed: true, }, + SchemaExpressionKey: { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + SchemaFieldKey: {Type: schema.TypeString, Computed: true}, + SchemaOperatorKey: {Type: schema.TypeString, Computed: true}, + SchemaValueKey: {Type: schema.TypeString, Computed: true}, + SchemaValuesKey: { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, + }, }, }, }, + "id": { Type: schema.TypeString, Optional: true, @@ -74,52 +95,80 @@ func dataSourceSysdigSecureZone() *schema.Resource { } func dataSourceSysdigSecureZoneRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { - client, err := getZoneClient(m.(SysdigClients)) + clientV2, err := getZoneV2Client(m.(SysdigClients)) if err != nil { return diag.FromErr(err) } - - var zone *v2.Zone + var zoneV2 *v2.ZoneV2 zoneIDRaw, hasZoneID := d.GetOk("id") if hasZoneID { zoneID, err := strconv.Atoi(zoneIDRaw.(string)) if err != nil { return diag.FromErr(fmt.Errorf("invalid zone id: %s", err)) } - zone, err = client.GetZoneByID(ctx, zoneID) + zoneV2, err = clientV2.GetZoneV2(ctx, zoneID) if err != nil { - return diag.FromErr(fmt.Errorf("error fetching zone by ID: %s", err)) + return diag.FromErr(fmt.Errorf("error fetching zone v2 by ID: %s", err)) } } else if nameRaw, hasName := d.GetOk("name"); hasName { name := nameRaw.(string) - zones, err := client.GetZones(ctx, name) + zones, err := clientV2.GetZonesV2(ctx, name) if err != nil { return diag.FromErr(fmt.Errorf("error fetching zones: %s", err)) } for _, z := range zones { if z.Name == name { - zone = &z + zoneV2 = &z break } } - if zone == nil { + if zoneV2 == nil { return diag.FromErr(fmt.Errorf("zone with name '%s' not found", name)) } + zoneV2, err = clientV2.GetZoneV2(ctx, zoneV2.ID) + if err != nil { + return diag.FromErr(fmt.Errorf("error fetching zone by name: %s", err)) + } } else { return diag.FromErr(fmt.Errorf("either id or name must be specified")) } - d.SetId(fmt.Sprintf("%d", zone.ID)) - _ = d.Set(SchemaNameKey, zone.Name) - _ = d.Set(SchemaDescriptionKey, zone.Description) - _ = d.Set(SchemaIsSystemKey, zone.IsSystem) - _ = d.Set(SchemaAuthorKey, zone.Author) - _ = d.Set(SchemaLastModifiedBy, zone.LastModifiedBy) - _ = d.Set(SchemaLastUpdated, time.UnixMilli(zone.LastUpdated).Format(time.RFC3339)) + d.SetId(fmt.Sprintf("%d", zoneV2.ID)) + _ = d.Set(SchemaNameKey, zoneV2.Name) + _ = d.Set(SchemaDescriptionKey, zoneV2.Description) + _ = d.Set(SchemaIsSystemKey, zoneV2.IsSystem) + _ = d.Set(SchemaAuthorKey, zoneV2.Author) + _ = d.Set(SchemaLastModifiedBy, zoneV2.LastModifiedBy) + _ = d.Set(SchemaLastUpdated, time.UnixMilli(zoneV2.LastUpdated).Format(time.RFC3339)) - if err := d.Set(SchemaScopeKey, fromZoneScopesResponse(zone.Scopes)); err != nil { + if err := d.Set(SchemaScopeKey, getZoneScopes(zoneV2)); err != nil { return diag.FromErr(fmt.Errorf("error setting scope: %s", err)) } return nil } + +func getZoneScopes(zoneV2 *v2.ZoneV2) []any { + // Build expression lookup by filter ID from the v2 response. + out := make([]any, 0) + if zoneV2 != nil { + for _, s := range zoneV2.Scopes { + for _, f := range s.Filters { + if f.ID != 0 && (len(f.Expressions) > 0 || f.Rules != "") { + var exprs []any + for _, e := range f.Expressions { + exprs = append(exprs, flattenExpressionV2(e)) + } + m := map[string]any{ + SchemaIDKey: f.ID, + SchemaTargetTypeKey: f.ResourceType, + SchemaRulesKey: f.Rules, + } + m[SchemaExpressionKey] = exprs + out = append(out, m) + } + } + } + } + return out +} diff --git a/sysdig/data_source_sysdig_secure_zone_test.go b/sysdig/data_source_sysdig_secure_zone_test.go index 203aad03..20d47e29 100644 --- a/sysdig/data_source_sysdig_secure_zone_test.go +++ b/sysdig/data_source_sysdig_secure_zone_test.go @@ -3,6 +3,7 @@ package sysdig_test import ( + "fmt" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -43,6 +44,125 @@ func TestAccDataSourceSysdigSecureZone(t *testing.T) { }) } +func TestAccDataSourceSysdigSecureZone_ByName(t *testing.T) { + zoneName := "Zone_DS_" + randomText(5) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: preCheckAnyEnv(t, SysdigSecureApiTokenEnv, SysdigIBMSecureAPIKeyEnv), + ProviderFactories: map[string]func() (*schema.Provider, error){ + "sysdig": func() (*schema.Provider, error) { + return sysdig.Provider(), nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceSecureZoneByName(zoneName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "data.sysdig_secure_zone.test", + "name", + zoneName, + ), + resource.TestCheckResourceAttr( + "data.sysdig_secure_zone.test", + "scope.0.target_type", + "aws", + ), + + // v2 expressions + resource.TestCheckResourceAttr( + "data.sysdig_secure_zone.test", + "scope.0.expression.#", + "1", + ), + resource.TestCheckResourceAttr( + "data.sysdig_secure_zone.test", + "scope.0.expression.0.field", + "organization", + ), + ), + }, + }, + }) +} + +func TestAccDataSourceSysdigSecureZone_ByID(t *testing.T) { + zoneName := "Zone_DS_ID_" + randomText(5) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: preCheckAnyEnv(t, SysdigSecureApiTokenEnv, SysdigIBMSecureAPIKeyEnv), + ProviderFactories: map[string]func() (*schema.Provider, error){ + "sysdig": func() (*schema.Provider, error) { + return sysdig.Provider(), nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceSecureZoneByID(zoneName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + "data.sysdig_secure_zone.test", + "id", + ), + resource.TestCheckResourceAttr( + "data.sysdig_secure_zone.test", + "scope.0.expression.#", + "1", + ), + ), + }, + }, + }) +} + +func testAccDataSourceSecureZoneByName(name string) string { + return fmt.Sprintf(` +resource "sysdig_secure_zone" "test" { + name = "%s" + description = "ds acceptance test" + + scope { + target_type = "aws" + + expression { + field = "organization" + operator = "in" + values = ["o1", "o2"] + } + } +} + +data "sysdig_secure_zone" "test" { + depends_on = [sysdig_secure_zone.test] + name = "%s" +} +`, name, name) +} + +func testAccDataSourceSecureZoneByID(name string) string { + return fmt.Sprintf(` +resource "sysdig_secure_zone" "test" { + name = "%s" + description = "ds acceptance test" + + scope { + target_type = "aws" + + expression { + field = "organization" + operator = "in" + values = ["o1", "o2"] + } + } +} + +data "sysdig_secure_zone" "test" { + depends_on = [sysdig_secure_zone.test] + id = sysdig_secure_zone.test.id +} +`, name) +} + func testAccDataSourceSysdigSecureZoneConfig() string { return ` resource "sysdig_secure_zone" "sample" { diff --git a/sysdig/data_source_sysdig_secure_zone_unit_test.go b/sysdig/data_source_sysdig_secure_zone_unit_test.go new file mode 100644 index 00000000..d74fc497 --- /dev/null +++ b/sysdig/data_source_sysdig_secure_zone_unit_test.go @@ -0,0 +1,55 @@ +package sysdig + +import ( + "testing" + + v2 "github.com/draios/terraform-provider-sysdig/sysdig/internal/client/v2" +) + +func TestGetZoneScopes_MatchByID(t *testing.T) { + zoneV2 := &v2.ZoneV2{ + Scopes: []v2.ScopeV2{ + { + Filters: []v2.FilterV2{ + { + ID: 10, + ResourceType: "kubernetes", + Expressions: []v2.ExpressionV2{ + {Field: "cluster", Operator: "in", Values: []string{"a"}}, + }, + }, + { + ID: 20, + ResourceType: "kubernetes", + Expressions: []v2.ExpressionV2{ + {Field: "cluster", Operator: "in", Values: []string{"b"}}, + }, + }, + }, + }, + }, + } + + result := getZoneScopes(zoneV2) + if len(result) != 2 { + t.Fatalf("expected 2 scopes, got %d", len(result)) + } + + // Verify each scope got its own expressions by checking the ID-expression pairing. + for _, raw := range result { + scope := raw.(map[string]any) + id := scope[SchemaIDKey].(int) + exprs, ok := scope[SchemaExpressionKey].([]any) + if !ok || len(exprs) != 1 { + t.Fatalf("scope ID=%d: expected 1 expression, got %v", id, scope[SchemaExpressionKey]) + } + expr := exprs[0].(map[string]any) + vals := expr["values"].([]string) + if id == 10 && vals[0] != "a" { + t.Errorf("scope ID=10: expected values [a], got %v", vals) + } + if id == 20 && vals[0] != "b" { + t.Errorf("scope ID=20: expected values [b], got %v", vals) + } + } +} diff --git a/sysdig/internal/client/v2/client.go b/sysdig/internal/client/v2/client.go index 5b975a2f..e7a05b05 100644 --- a/sysdig/internal/client/v2/client.go +++ b/sysdig/internal/client/v2/client.go @@ -62,6 +62,7 @@ type SecureCommon interface { PostureAcceptRiskInterface PostureVulnerabilityAcceptRiskInterface ZoneInterface + ZoneV2Interface } type Requester interface { @@ -74,6 +75,22 @@ type Client struct { requester Requester } +type APIError struct { + StatusCode int + Status string + Message string +} + +func (e *APIError) Error() string { + if e.Message != "" { + return e.Message + } + if e.Status != "" { + return e.Status + } + return "api error" +} + func (c *Client) ErrorFromResponse(response *http.Response) error { var data any err := json.NewDecoder(response.Body).Decode(&data) @@ -98,6 +115,35 @@ func (c *Client) ErrorFromResponse(response *http.Response) error { return errors.New(response.Status) } +// APIErrorFromResponse Introduces a new method that extracts error details from the API response and constructs an APIError with the relevant information. +func (c *Client) APIErrorFromResponse(response *http.Response) error { + statusCode := response.StatusCode + status := response.Status + + var data any + if err := json.NewDecoder(response.Body).Decode(&data); err != nil { + return &APIError{StatusCode: statusCode, Status: status} + } + + search, err := jmespath.Search("[message, error, details[], errors[].[reason, message]][][] | join(', ', @)", data) + if err != nil { + return &APIError{StatusCode: statusCode, Status: status} + } + + msg := "" + if searchArray, ok := search.([]any); ok { + msg = strings.Join(cast.ToStringSlice(searchArray), ", ") + } else { + msg = cast.ToString(search) + } + + return &APIError{ + StatusCode: statusCode, + Status: status, + Message: msg, + } +} + func Unmarshal[T any](data io.Reader) (T, error) { var result T diff --git a/sysdig/internal/client/v2/client_test.go b/sysdig/internal/client/v2/client_test.go index d03e0986..5e1e8955 100644 --- a/sysdig/internal/client/v2/client_test.go +++ b/sysdig/internal/client/v2/client_test.go @@ -170,6 +170,104 @@ func TestClient_ErrorFromResponse_json_nonStandard_error_format(t *testing.T) { } } +func TestClient_APIErrorFromResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + status string + body string + wantMessage string + wantStatusCode int + }{ + { + name: "message field", + statusCode: 400, + status: "400 Bad Request", + body: `{"message":"invalid zone name"}`, + wantMessage: "invalid zone name", + wantStatusCode: 400, + }, + { + name: "error field", + statusCode: 401, + status: "401 Unauthorized", + body: `{"timestamp":1715255725613,"status":401,"error":"Unauthorized","path":"/api/v2/zones/123"}`, + wantMessage: "Unauthorized", + wantStatusCode: 401, + }, + { + name: "errors array with reason and message", + statusCode: 422, + status: "422 Unprocessable Entity", + body: `{"errors":[ + {"reason":"validation_error","message":"name is required"}, + {"reason":"validation_error","message":"scope is required"} + ]}`, + wantMessage: "validation_error, name is required, validation_error, scope is required", + wantStatusCode: 422, + }, + { + name: "details array", + statusCode: 400, + status: "400 Bad Request", + body: `{"details":["field 'name' is required","field 'scope' is required"]}`, + wantMessage: "field 'name' is required, field 'scope' is required", + wantStatusCode: 400, + }, + { + name: "non-json body falls back to status", + statusCode: 502, + status: "502 Bad Gateway", + body: "not json", + wantMessage: "502 Bad Gateway", + wantStatusCode: 502, + }, + { + name: "empty json falls back to status", + statusCode: 500, + status: "500 Internal Server Error", + body: `{}`, + wantMessage: "500 Internal Server Error", + wantStatusCode: 500, + }, + { + name: "404 not found", + statusCode: 404, + status: "404 Not Found", + body: `{"message":"zone not found"}`, + wantMessage: "zone not found", + wantStatusCode: 404, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := Client{} + resp := &http.Response{ + StatusCode: tt.statusCode, + Status: tt.status, + Body: io.NopCloser(strings.NewReader(tt.body)), + } + + err := c.APIErrorFromResponse(resp) + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != tt.wantStatusCode { + t.Errorf("StatusCode: want %d, got %d", tt.wantStatusCode, apiErr.StatusCode) + } + if apiErr.Error() != tt.wantMessage { + t.Errorf("Message: want %q, got %q", tt.wantMessage, apiErr.Error()) + } + }) + } +} + func TestRequest(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { agent := r.Header.Get(UserAgentHeader) diff --git a/sysdig/internal/client/v2/model.go b/sysdig/internal/client/v2/model.go index 399b3794..18dbc333 100644 --- a/sysdig/internal/client/v2/model.go +++ b/sysdig/internal/client/v2/model.go @@ -3,6 +3,7 @@ package v2 import ( "encoding/json" "errors" + "fmt" cloudauth "github.com/draios/terraform-provider-sysdig/sysdig/internal/client/v2/cloudauth/go" ) @@ -1192,6 +1193,10 @@ type ZonesWrapper struct { Zones []Zone `json:"data"` } +type ZonesV2Wrapper struct { + Zones []ZoneV2 `json:"data"` +} + type ZoneRequest struct { ID int `json:"id,omitempty"` Name string `json:"name"` @@ -1321,3 +1326,107 @@ type SSOGlobalSettings struct { Product string `json:"product,omitempty"` IsPasswordLoginEnabled bool `json:"isPasswordLoginEnabled"` } + +// ZoneV2 models the REST API contract for zones v2. +type ZoneV2 struct { + ID int `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Scopes []ScopeV2 `json:"scopes"` + Author string `json:"author,omitempty"` + LastModifiedBy string `json:"lastModifiedBy,omitempty"` + LastUpdated int64 `json:"lastUpdated,omitempty"` + IsSystem bool `json:"isSystem,omitempty"` +} + +// ScopeV2 corresponds to one scope entry +type ScopeV2 struct { + Filters []FilterV2 `json:"filters"` +} + +// FilterV2 matches the REST filter definition. +// It supports both structured expressions and a rules string. +// When Rules is set, Expressions should be empty and vice versa. +type FilterV2 struct { + ID int `json:"id,omitempty"` + ResourceType string `json:"resourceType"` + Expressions []ExpressionV2 `json:"expressions,omitempty"` + Rules string `json:"rules,omitempty"` +} + +// ExpressionV2 matches REST expression definition +type ExpressionV2 struct { + Field string `json:"field"` + Operator string `json:"operator"` + Value string `json:"value,omitempty"` + Values []string `json:"-"` +} + +// MarshalJSON ensures the API receives the "value" key always as an array. +// The backend accepts both scalar and array formats for single-value operators, +// but we normalize to arrays for consistency. +func (e ExpressionV2) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{ + "field": e.Field, + "operator": e.Operator, + } + if len(e.Values) > 0 { + m["value"] = e.Values + } else if e.Value != "" { + m["value"] = []string{e.Value} + } + return json.Marshal(m) +} + +// UnmarshalJSON accepts either "value": []string or "value": string (or "values": []) +// and normalizes into ExpressionV2.Value or ExpressionV2.Values. +func (e *ExpressionV2) UnmarshalJSON(b []byte) error { + // minimal struct to grab field/operator + type alias struct { + Field string `json:"field"` + Operator string `json:"operator"` + } + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + e.Field = a.Field + e.Operator = a.Operator + + // reset + e.Value = "" + e.Values = nil + + // try to decode "value" (array or string) + var raw map[string]json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + + if v, ok := raw["value"]; ok { + // try array + var arr []string + if err := json.Unmarshal(v, &arr); err == nil { + e.Values = arr + return nil + } + // try single string + var s string + if err := json.Unmarshal(v, &s); err == nil { + e.Value = s + return nil + } + return fmt.Errorf("unsupported value type for ExpressionV2.value") + } + + // fallback to "values" + if v, ok := raw["values"]; ok { + var arr []string + if err := json.Unmarshal(v, &arr); err != nil { + return err + } + e.Values = arr + } + + return nil +} diff --git a/sysdig/internal/client/v2/model_test.go b/sysdig/internal/client/v2/model_test.go new file mode 100644 index 00000000..6c4701b3 --- /dev/null +++ b/sysdig/internal/client/v2/model_test.go @@ -0,0 +1,110 @@ +package v2 + +import ( + "encoding/json" + "testing" +) + +func TestExpressionV2MarshalJSON(t *testing.T) { + tests := []struct { + name string + expr ExpressionV2 + wantArray bool // true = expect "value" to be []interface{} + wantLen int // expected array length (when wantArray) + wantFirst string // expected first element (when wantArray) + wantAbsent bool // true = expect "value" key to be absent + }{ + { + name: "scalar Value field wraps to single-element array", + expr: ExpressionV2{Field: "agent.tag.key", Operator: "is not", Value: "asd"}, + wantArray: true, + wantLen: 1, + wantFirst: "asd", + }, + { + name: "single-element Values stays as array", + expr: ExpressionV2{Field: "org", Operator: "in", Values: []string{"x"}}, + wantArray: true, + wantLen: 1, + wantFirst: "x", + }, + { + name: "multi-element Values stays as array", + expr: ExpressionV2{Field: "org", Operator: "in", Values: []string{"a", "b"}}, + wantArray: true, + wantLen: 2, + wantFirst: "a", + }, + { + name: "empty Value and nil Values omits key", + expr: ExpressionV2{Field: "f", Operator: "op"}, + wantAbsent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := json.Marshal(tt.expr) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal marshalled json: %v", err) + } + + v, ok := got["value"] + + if tt.wantAbsent { + if ok { + t.Fatalf("expected value key absent, got: %v", v) + } + return + } + + if !ok { + t.Fatalf("expected value key in marshalled json: %s", string(b)) + } + + arr, isArr := v.([]interface{}) + if !isArr { + t.Fatalf("expected value to be array, got %T: %v (json: %s)", v, v, string(b)) + } + if len(arr) != tt.wantLen { + t.Fatalf("expected array length %d, got %d", tt.wantLen, len(arr)) + } + if tt.wantFirst != "" && arr[0].(string) != tt.wantFirst { + t.Fatalf("expected first element %q, got %q", tt.wantFirst, arr[0]) + } + }) + } +} + +func TestExpressionV2UnmarshalVariants(t *testing.T) { + var e ExpressionV2 + + // value as array + if err := json.Unmarshal([]byte(`{"field":"f","operator":"op","value":["a","b"]}`), &e); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(e.Values) != 2 || e.Values[0] != "a" || e.Values[1] != "b" { + t.Fatalf("unexpected values: %#v", e.Values) + } + + // value as single string -> should populate Value (string) + if err := json.Unmarshal([]byte(`{"field":"f","operator":"op","value":"x"}`), &e); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if e.Value != "x" { + t.Fatalf("expected Value == 'x', got: %#v (Values: %#v)", e.Value, e.Values) + } + + // values key + if err := json.Unmarshal([]byte(`{"field":"f","operator":"op","values":["y"]}`), &e); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(e.Values) != 1 || e.Values[0] != "y" { + t.Fatalf("unexpected values: %#v", e.Values) + } +} diff --git a/sysdig/internal/client/v2/zones.go b/sysdig/internal/client/v2/zones.go index 994ae98d..5f90d93a 100644 --- a/sysdig/internal/client/v2/zones.go +++ b/sysdig/internal/client/v2/zones.go @@ -36,7 +36,7 @@ func (c *Client) GetZones(ctx context.Context, name string) (zones []Zone, err e }() if response.StatusCode != http.StatusOK { - return nil, c.ErrorFromResponse(response) + return nil, c.APIErrorFromResponse(response) } wrapper, err := Unmarshal[ZonesWrapper](response.Body) if err != nil { @@ -58,7 +58,7 @@ func (c *Client) GetZoneByID(ctx context.Context, id int) (zone *Zone, err error }() if response.StatusCode != http.StatusOK { - return nil, c.ErrorFromResponse(response) + return nil, c.APIErrorFromResponse(response) } return Unmarshal[*Zone](response.Body) @@ -81,7 +81,7 @@ func (c *Client) CreateZone(ctx context.Context, zone *ZoneRequest) (createdZone }() if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { - return nil, c.ErrorFromResponse(response) + return nil, c.APIErrorFromResponse(response) } return Unmarshal[*Zone](response.Body) @@ -104,7 +104,7 @@ func (c *Client) UpdateZone(ctx context.Context, zone *ZoneRequest) (updatedZone }() if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { - return nil, c.ErrorFromResponse(response) + return nil, c.APIErrorFromResponse(response) } return Unmarshal[*Zone](response.Body) @@ -122,7 +122,7 @@ func (c *Client) DeleteZone(ctx context.Context, id int) (err error) { }() if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNotFound { - return c.ErrorFromResponse(response) + return c.APIErrorFromResponse(response) } return nil diff --git a/sysdig/internal/client/v2/zonesV2.go b/sysdig/internal/client/v2/zonesV2.go new file mode 100644 index 00000000..13193510 --- /dev/null +++ b/sysdig/internal/client/v2/zonesV2.go @@ -0,0 +1,137 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +const ( + platformZonesPathV2 = "%s/platform/v2/zones" + platformZonePathV2 = "%s/platform/v2/zones/%d" +) + +type ZoneV2Interface interface { + Base + GetZonesV2(ctx context.Context, name string) ([]ZoneV2, error) + GetZoneV2(ctx context.Context, id int) (*ZoneV2, error) + CreateZoneV2(ctx context.Context, zone *ZoneV2) (*ZoneV2, error) + UpdateZoneV2(ctx context.Context, zone *ZoneV2) (*ZoneV2, error) + DeleteZoneV2(ctx context.Context, id int) error +} + +func (c *Client) GetZonesV2(ctx context.Context, name string) (zones []ZoneV2, err error) { + zonesURL := c.getZonesV2URL() + zonesURL = fmt.Sprintf("%s?filter=name:%s", zonesURL, url.QueryEscape(name)) + + response, err := c.requester.Request(ctx, http.MethodGet, zonesURL, nil) + if err != nil { + return nil, err + } + defer func() { + if dErr := response.Body.Close(); dErr != nil { + err = fmt.Errorf("unable to close response body: %w", dErr) + } + }() + + if response.StatusCode != http.StatusOK { + return nil, c.APIErrorFromResponse(response) + } + wrapper, err := Unmarshal[ZonesV2Wrapper](response.Body) + if err != nil { + return nil, err + } + + return wrapper.Zones, nil +} + +func (c *Client) GetZoneV2(ctx context.Context, id int) (zone *ZoneV2, err error) { + response, err := c.requester.Request(ctx, http.MethodGet, c.getZoneV2URL(id), nil) + if err != nil { + return nil, err + } + defer func() { + if dErr := response.Body.Close(); dErr != nil { + err = fmt.Errorf("unable to close response body: %w", dErr) + } + }() + + if response.StatusCode != http.StatusOK { + return nil, c.APIErrorFromResponse(response) + } + + return Unmarshal[*ZoneV2](response.Body) +} + +func (c *Client) CreateZoneV2(ctx context.Context, zone *ZoneV2) (createdZone *ZoneV2, err error) { + payload, err := Marshal(zone) + if err != nil { + return nil, err + } + + response, err := c.requester.Request(ctx, http.MethodPost, c.getZonesV2URL(), payload) + if err != nil { + return nil, err + } + defer func() { + if dErr := response.Body.Close(); dErr != nil { + err = fmt.Errorf("unable to close response body: %w", dErr) + } + }() + + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { + return nil, c.APIErrorFromResponse(response) + } + + return Unmarshal[*ZoneV2](response.Body) +} + +func (c *Client) UpdateZoneV2(ctx context.Context, zone *ZoneV2) (updatedZone *ZoneV2, err error) { + payload, err := Marshal(zone) + if err != nil { + return nil, err + } + + response, err := c.requester.Request(ctx, http.MethodPut, c.getZoneV2URL(zone.ID), payload) + if err != nil { + return nil, err + } + defer func() { + if dErr := response.Body.Close(); dErr != nil { + err = fmt.Errorf("unable to close response body: %w", dErr) + } + }() + + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { + return nil, c.APIErrorFromResponse(response) + } + + return Unmarshal[*ZoneV2](response.Body) +} + +func (c *Client) DeleteZoneV2(ctx context.Context, id int) (err error) { + response, err := c.requester.Request(ctx, http.MethodDelete, c.getZoneV2URL(id), nil) + if err != nil { + return err + } + defer func() { + if dErr := response.Body.Close(); dErr != nil { + err = fmt.Errorf("unable to close response body: %w", dErr) + } + }() + + if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNotFound { + return c.APIErrorFromResponse(response) + } + + return nil +} + +func (c *Client) getZonesV2URL() string { + return fmt.Sprintf(platformZonesPathV2, c.config.url) +} + +func (c *Client) getZoneV2URL(id int) string { + return fmt.Sprintf(platformZonePathV2, c.config.url, id) +} diff --git a/sysdig/resource_sysdig_secure_zone.go b/sysdig/resource_sysdig_secure_zone.go index 6cfa9806..22e37cd7 100644 --- a/sysdig/resource_sysdig_secure_zone.go +++ b/sysdig/resource_sysdig_secure_zone.go @@ -2,16 +2,27 @@ package sysdig import ( "context" + "errors" "fmt" + "net/http" + "regexp" "strconv" "time" v2 "github.com/draios/terraform-provider-sysdig/sysdig/internal/client/v2" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +// legacyAttributePattern matches legacy v1 attribute names that need migration. +// These are: labelValues, labels, agentTags (without dot notation). +// v2 attributes use dot notation like: label.key, agent.tag.key +var legacyAttributePattern = regexp.MustCompile(`\b(labelValues|labels|agentTags)\b`) + func resourceSysdigSecureZone() *schema.Resource { + timeout := 5 * time.Minute + return &schema.Resource{ CreateContext: resourceSysdigSecureZoneCreate, ReadContext: resourceSysdigSecureZoneRead, @@ -20,6 +31,61 @@ func resourceSysdigSecureZone() *schema.Resource { Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, + CustomizeDiff: func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) error { + scopeSet, ok := d.Get("scope").(*schema.Set) + if !ok || scopeSet == nil { + return nil + } + + for i, raw := range scopeSet.List() { + scope := raw.(map[string]interface{}) + + hasRules := false + if v, ok := scope["rules"]; ok && v != nil { + hasRules = v.(string) != "" + } + + hasExpr := false + if v, ok := scope["expression"]; ok && v != nil { + switch expr := v.(type) { + case []interface{}: + hasExpr = len(expr) > 0 + case *schema.Set: + hasExpr = expr.Len() > 0 + } + } + + if hasRules && hasExpr { + return fmt.Errorf( + "scope[%d]: 'rules' cannot be used together with 'expression'", + i, + ) + } + + if !hasRules && !hasExpr { + return fmt.Errorf( + "scope[%d]: either 'rules' or 'expression' must be specified", + i, + ) + } + } + + // Validate expression fields against the target_type allowlist. + // Uses GetRawPlan() for reliable type access — nested TypeList + // elements inside a TypeSet may not materialize as + // map[string]interface{} during diff computation. + if err := validateExpressionsFromPlan(d); err != nil { + return err + } + + return nil + }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(timeout), + Update: schema.DefaultTimeout(timeout), + Read: schema.DefaultTimeout(timeout), + Delete: schema.DefaultTimeout(timeout), + }, Schema: map[string]*schema.Schema{ SchemaNameKey: { @@ -63,6 +129,46 @@ func resourceSysdigSecureZone() *schema.Resource { SchemaRulesKey: { Type: schema.TypeString, Optional: true, + ValidateDiagFunc: func(v interface{}, path cty.Path) diag.Diagnostics { + rules := v.(string) + if rules != "" && legacyAttributePattern.MatchString(rules) { + return diag.Diagnostics{ + diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Deprecated legacy rules syntax", + Detail: "The 'rules' field with legacy attributes (labels, labelValues, agentTags) is deprecated. Use 'expression' blocks or `rules` with v2 syntax (label., agent.tag.) instead. See the documentation for migration guidance.", + }, + } + } + return nil + }, + }, + SchemaExpressionKey: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + SchemaFieldKey: { + Type: schema.TypeString, + Required: true, + }, + SchemaOperatorKey: { + Type: schema.TypeString, + Required: true, + }, + SchemaValueKey: { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + SchemaValuesKey: { + Type: schema.TypeList, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, }, }, }, @@ -72,44 +178,171 @@ func resourceSysdigSecureZone() *schema.Resource { } func resourceSysdigSecureZoneCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { - client, err := getZoneClient(m.(SysdigClients)) + clientV1, err := getZoneClient(m.(SysdigClients)) if err != nil { return diag.FromErr(err) } - zoneRequest := zoneRequestFromResourceData(d) - - createdZone, err := client.CreateZone(ctx, zoneRequest) + clientV2, err := getZoneV2Client(m.(SysdigClients)) if err != nil { - return diag.FromErr(fmt.Errorf("error creating Sysdig Zone: %s", err)) + return diag.FromErr(err) + } + + zoneId, diags := createZone(ctx, d, clientV1, clientV2) + if diags.HasError() { + return diags } - d.SetId(fmt.Sprintf("%d", createdZone.ID)) + d.SetId(fmt.Sprintf("%d", zoneId)) return resourceSysdigSecureZoneRead(ctx, d, m) } +func createZone(ctx context.Context, d *schema.ResourceData, clientV1 v2.ZoneInterface, clientV2 v2.ZoneV2Interface) (int, diag.Diagnostics) { + legacyZone, e := categorizeZone(d) + if e != nil { + return 0, diag.FromErr(fmt.Errorf("error analyzing zone scope: %s", e)) + } + if legacyZone { + zoneRequest := zoneRequestFromResourceData(d) + createdZone, err := clientV1.CreateZone(ctx, zoneRequest) + if err != nil { + return 0, diag.FromErr(fmt.Errorf("error creating Sysdig Zone: %s", err)) + } + return createdZone.ID, nil + } + + if err := validateZoneExpressions(d); err != nil { + return 0, diag.FromErr(err) + } + + zone := expandZoneV2(d) + created, err := clientV2.CreateZoneV2(ctx, zone) + if err != nil { + return 0, diag.FromErr(fmt.Errorf("error creating zone: %w", err)) + } + return created.ID, nil +} + +func isNotFound(err error) bool { + var apiErr *v2.APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode == http.StatusNotFound + } + return false +} + +func categorizeZone(d *schema.ResourceData) (bool, error) { + rawScopes := d.Get(SchemaScopeKey) + if rawScopes == nil { + return false, fmt.Errorf("scope is required and cannot be nil") + } + scopes, ok := rawScopes.(*schema.Set) + if !ok || scopes == nil { + return false, fmt.Errorf("expected scope to be a *schema.Set, got %T", rawScopes) + } + scopeList := scopes.List() + + var hasLegacyRules, hasV2Rules, hasExpr bool + + for _, raw := range scopeList { + scope := raw.(map[string]any) + + if rules, ok := scope["rules"].(string); ok && rules != "" { + // Check if rules contain legacy v1 attributes + if legacyAttributePattern.MatchString(rules) { + hasLegacyRules = true + } else { + hasV2Rules = true + } + } + + if expr, ok := scope["expression"].([]interface{}); ok && len(expr) > 0 { + hasExpr = true + } + } + + // Expression blocks always mean v2 + if hasExpr { + if hasLegacyRules { + return false, fmt.Errorf("cannot mix expression blocks with legacy v1 rules syntax") + } + return false, nil // v2 + } + + // Legacy v1 rules cannot be mixed with v2 rules + if hasLegacyRules && hasV2Rules { + return false, fmt.Errorf("cannot mix legacy v1 rules (labelValues, labels, agentTags) with v2 rules (label.key, agent.tag.key)") + } + + if hasLegacyRules { + return true, nil // v1 + } + + // V2 rules or no rules (import/read) - treat as v2 + return false, nil +} + func resourceSysdigSecureZoneRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { client, err := getZoneClient(m.(SysdigClients)) if err != nil { return diag.FromErr(err) } - id, _ := strconv.Atoi(d.Id()) - - zone, err := client.GetZoneByID(ctx, id) + clientv2, err := getZoneV2Client(m.(SysdigClients)) if err != nil { - d.SetId("") return diag.FromErr(err) } + id, _ := strconv.Atoi(d.Id()) + + legacyZone, e := categorizeZone(d) + if e != nil { + return diag.FromErr(fmt.Errorf("error analyzing zone scope: %s", e)) + } + if legacyZone { + zone, err := client.GetZoneByID(ctx, id) + if err != nil { + if isNotFound(err) { + d.SetId("") + return nil + } + return diag.FromErr(fmt.Errorf("error reading zone %d: %w", id, err)) + } + + _ = d.Set("name", zone.Name) + _ = d.Set("description", zone.Description) + _ = d.Set("is_system", zone.IsSystem) + _ = d.Set("author", zone.Author) + _ = d.Set("last_modified_by", zone.LastModifiedBy) + _ = d.Set("last_updated", time.UnixMilli(zone.LastUpdated).Format(time.RFC3339)) + // For legacy zones, we need to set the rules field in the scope + if err := d.Set(SchemaScopeKey, fromZoneScopesResponse(zone.Scopes)); err != nil { + return diag.FromErr(fmt.Errorf("error setting scope: %s", err)) + } + return nil + } + + zone, err := clientv2.GetZoneV2(ctx, id) + if err != nil { + if isNotFound(err) { + d.SetId("") + return nil + } + return diag.FromErr(fmt.Errorf("error reading zone %d: %w", id, err)) + } _ = d.Set("name", zone.Name) _ = d.Set("description", zone.Description) _ = d.Set("is_system", zone.IsSystem) _ = d.Set("author", zone.Author) _ = d.Set("last_modified_by", zone.LastModifiedBy) _ = d.Set("last_updated", time.UnixMilli(zone.LastUpdated).Format(time.RFC3339)) - - if err := d.Set(SchemaScopeKey, fromZoneScopesResponse(zone.Scopes)); err != nil { + // "State follows config": if the user configured rules, write rules + // into state; if they configured expressions, write expressions. + // On the first Read (called from Create), d.Get returns the config + // values. On subsequent Reads, state reflects the prior Read — which + // already matched config — so the choice is self-reinforcing. + preferRules := !stateHasExpressions(d) + if err := d.Set(SchemaScopeKey, flattenZoneV2(zone, preferRules)); err != nil { return diag.FromErr(fmt.Errorf("error setting scope: %s", err)) } @@ -122,11 +355,39 @@ func resourceSysdigSecureZoneUpdate(ctx context.Context, d *schema.ResourceData, return diag.FromErr(err) } + clientV2, err := getZoneV2Client(m.(SysdigClients)) + if err != nil { + return diag.FromErr(err) + } + + legacyZone, e := categorizeZone(d) + if e != nil { + return diag.FromErr(fmt.Errorf("error analyzing zone scope: %s", e)) + } + if !legacyZone { + if err := validateZoneExpressions(d); err != nil { + return diag.FromErr(err) + } + + zone := expandZoneV2(d) + + id, err := strconv.Atoi(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("invalid zone id %q: %w", d.Id(), err)) + } + zone.ID = id + + if _, err := clientV2.UpdateZoneV2(ctx, zone); err != nil { + return diag.FromErr(fmt.Errorf("error updating zone: %w", err)) + } + + return resourceSysdigSecureZoneRead(ctx, d, m) + } + zoneRequest := zoneRequestFromResourceData(d) - _, err = client.UpdateZone(ctx, zoneRequest) - if err != nil { - return diag.FromErr(fmt.Errorf("error updating Sysdig Zone: %s", err)) + if _, err := client.UpdateZone(ctx, zoneRequest); err != nil { + return diag.FromErr(fmt.Errorf("error updating Sysdig Zone: %w", err)) } return resourceSysdigSecureZoneRead(ctx, d, m) @@ -138,12 +399,28 @@ func resourceSysdigSecureZoneDelete(ctx context.Context, d *schema.ResourceData, return diag.FromErr(err) } - id, _ := strconv.Atoi(d.Id()) - err = client.DeleteZone(ctx, id) + clientV2, err := getZoneV2Client(m.(SysdigClients)) if err != nil { - return diag.FromErr(fmt.Errorf("error deleting Sysdig Zone: %s", err)) + return diag.FromErr(err) } + id, _ := strconv.Atoi(d.Id()) + legacyZone, e := categorizeZone(d) + if e != nil { + return diag.FromErr(fmt.Errorf("error analyzing zone scope: %s", e)) + } + if legacyZone { + if err := client.DeleteZone(ctx, id); err != nil { + return diag.FromErr(fmt.Errorf("error deleting Sysdig Zone: %w", err)) + } + + d.SetId("") + return nil + } + + if err := clientV2.DeleteZoneV2(ctx, id); err != nil { + return diag.FromErr(fmt.Errorf("error deleting Sysdig Zone: %w", err)) + } d.SetId("") return nil } @@ -178,8 +455,8 @@ func toZoneScopesRequest(scopes *schema.Set) []v2.ZoneScope { return zoneScopes } -func fromZoneScopesResponse(scopes []v2.ZoneScope) []any { - var flattenedScopes []any +func fromZoneScopesResponse(scopes []v2.ZoneScope) []map[string]any { + var flattenedScopes []map[string]any for _, scope := range scopes { flattenedScopes = append(flattenedScopes, map[string]any{ SchemaIDKey: scope.ID, @@ -207,3 +484,182 @@ func getZoneClient(clients SysdigClients) (v2.ZoneInterface, error) { } return client, nil } + +func getZoneV2Client(clients SysdigClients) (v2.ZoneV2Interface, error) { + var client v2.ZoneV2Interface + var err error + switch clients.GetClientType() { + case IBMSecure: + client, err = clients.ibmSecureClient() + if err != nil { + return nil, err + } + default: + client, err = clients.sysdigSecureClientV2() + if err != nil { + return nil, err + } + } + return client, nil +} + +// stateHasExpressions reports whether any scope in the current Terraform state +// contains expression blocks. Used by Read to decide whether to flatten the +// API response as expressions or rules ("state follows config"). +func stateHasExpressions(d *schema.ResourceData) bool { + scopeSet, ok := d.Get(SchemaScopeKey).(*schema.Set) + if !ok || scopeSet == nil { + return false + } + for _, raw := range scopeSet.List() { + m := raw.(map[string]interface{}) + if exprs, ok := m[SchemaExpressionKey].([]interface{}); ok && len(exprs) > 0 { + return true + } + } + return false +} + +// flattenZoneV2 flattens the backend ZoneV2 representation into the Terraform schema. +// +// When preferRules is true, the rules string from the backend is written into state +// (matching a user config that uses rules). When false, structured expressions are +// preferred (matching a user config that uses expression blocks). +// +// NOTE: In the backend model, ScopeV2 is only a structural wrapper and has no semantic meaning. +// Each FilterV2 represents a logical scope and is mapped 1:1 to a Terraform "scope" block. +func flattenZoneV2(z *v2.ZoneV2, preferRules bool) []map[string]any { + if z == nil { + return nil + } + + var allScopes []map[string]any + + for _, s := range z.Scopes { + // ScopeV2 has no semantic meaning; it only groups filters in the backend model. + for _, f := range s.Filters { + allScopes = append(allScopes, flattenFilterV2(f, preferRules)) + } + } + return allScopes +} + +// flattenFilterV2 converts a backend FilterV2 into a Terraform "scope" block. +// It handles both structured expressions and rules strings. +func flattenFilterV2(f v2.FilterV2, preferRules bool) map[string]any { + out := map[string]any{ + SchemaIDKey: f.ID, + SchemaTargetTypeKey: f.ResourceType, + } + + // When the user configured expressions, prefer them over rules. + if !preferRules && len(f.Expressions) > 0 { + var exps []interface{} + for _, e := range f.Expressions { + exps = append(exps, flattenExpressionV2(e)) + } + out[SchemaExpressionKey] = exps + return out + } + + // When the user configured rules (or on import where there's no prior + // state), write the rules string. Legacy zones without expressions + // also land here. + if f.Rules != "" { + out[SchemaRulesKey] = f.Rules + } + return out +} + +func flattenExpressionV2(e v2.ExpressionV2) map[string]any { + m := map[string]any{ + "field": e.Field, + "operator": e.Operator, + "value": "", + "values": []string{}, + } + + if len(e.Values) > 0 { + m["values"] = e.Values + } else if e.Value != "" { + m["value"] = e.Value + } + + return m +} + +// expandZoneV2 builds a ZoneV2 from Terraform data. +// +// NOTE: In the backend model, ScopeV2 is only a structural wrapper and has no semantic meaning. +// Each Terraform "scope" block is mapped 1:1 to a FilterV2. +func expandZoneV2(d *schema.ResourceData) *v2.ZoneV2 { + zone := &v2.ZoneV2{ + Name: d.Get(SchemaNameKey).(string), + Description: d.Get(SchemaDescriptionKey).(string), + } + if rawScopes, ok := d.Get(SchemaScopeKey).(*schema.Set); ok { + scope := v2.ScopeV2{} + scopeList := rawScopes.List() + for _, raw := range scopeList { + scope.Filters = append(scope.Filters, expandFilterV2(raw)) + } + if len(scope.Filters) > 0 { + zone.Scopes = []v2.ScopeV2{scope} + } + } + + return zone +} + +// expandFilterV2 converts the raw filter block into a v2.FilterV2. +// It handles both structured expressions and rules strings. +func expandFilterV2(raw interface{}) v2.FilterV2 { + m := raw.(map[string]interface{}) + + filter := v2.FilterV2{ + ID: m[SchemaIDKey].(int), + ResourceType: m[SchemaTargetTypeKey].(string), + } + + // Check for rules string first (v2 rules syntax) + if rules, ok := m[SchemaRulesKey].(string); ok && rules != "" { + filter.Rules = rules + return filter + } + + // Otherwise, expand expressions + if exprs, ok := m[SchemaExpressionKey].([]interface{}); ok { + for _, e := range exprs { + filter.Expressions = append(filter.Expressions, expandExpressionV2(e)) + } + } + + return filter +} + +// expandExpressionV2 converts the raw expression block into a v2.ExpressionV2. +func expandExpressionV2(raw interface{}) v2.ExpressionV2 { + m := raw.(map[string]interface{}) + + expr := v2.ExpressionV2{ + Field: m["field"].(string), + Operator: m["operator"].(string), + } + + if vals, ok := m["values"].([]interface{}); ok && len(vals) > 0 { + expr.Values = interfaceSliceToStrings(vals) + } else if v, ok := m["value"].(string); ok && v != "" { + expr.Value = v + } + + return expr +} + +// interfaceSliceToStrings converts a []interface{} (Terraform list) to []string. +func interfaceSliceToStrings(v []interface{}) []string { + out := make([]string, len(v)) + for i, x := range v { + out[i] = x.(string) + } + return out +} diff --git a/sysdig/resource_sysdig_secure_zone_migration_test.go b/sysdig/resource_sysdig_secure_zone_migration_test.go new file mode 100644 index 00000000..cad82bca --- /dev/null +++ b/sysdig/resource_sysdig_secure_zone_migration_test.go @@ -0,0 +1,148 @@ +package sysdig + +import ( + "testing" + + v2 "github.com/draios/terraform-provider-sysdig/sysdig/internal/client/v2" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/require" +) + +func TestExpandFlattenExpression_SingleValue(t *testing.T) { + raw := map[string]interface{}{ + "field": "agent.tag.key", + "operator": "is_not", + "value": "test", + } + + exp := expandExpressionV2(raw) + flat := flattenExpressionV2(exp) + + require.Equal(t, "agent.tag.key", flat["field"]) + require.Equal(t, "is_not", flat["operator"]) + require.Equal(t, "test", flat["value"]) +} + +func TestExpandFlattenExpression_MultipleValues(t *testing.T) { + raw := map[string]interface{}{ + "field": "organization", + "operator": "in", + "values": []interface{}{"o1", "o2"}, + } + + exp := expandExpressionV2(raw) + flat := flattenExpressionV2(exp) + + require.Equal(t, "organization", flat["field"]) + require.Equal(t, "in", flat["operator"]) + + values, ok := flat["values"].([]string) + require.True(t, ok) + require.ElementsMatch(t, []string{"o1", "o2"}, values) +} + +func TestExpandExpression_ValuesWinOverValue(t *testing.T) { + raw := map[string]interface{}{ + "field": "organization", + "operator": "in", + "value": "SHOULD_NOT_BE_USED", + "values": []interface{}{"o1", "o2"}, + } + + exp := expandExpressionV2(raw) + + require.Equal(t, []string{"o1", "o2"}, exp.Values) + require.Empty(t, exp.Value) +} + +func TestExpandFlattenFilter_MultipleExpressions(t *testing.T) { + raw := map[string]interface{}{ + "id": 0, + "target_type": "kubernetes", + "expression": []interface{}{ + map[string]interface{}{ + "field": "agent.tag.key", + "operator": "is_not", + "value": "test", + }, + map[string]interface{}{ + "field": "agent.tag.key2", + "operator": "not_contains", + "value": "value2", + }, + }, + } + + filter := expandFilterV2(raw) + flat := flattenFilterV2(filter, false) + + require.Equal(t, "kubernetes", flat["target_type"]) + require.Len(t, flat["expression"], 2) +} + +func TestFlattenZoneV2_MultipleScopesAndFilters(t *testing.T) { + zone := &v2.ZoneV2{ + Scopes: []v2.ScopeV2{ + { + Filters: []v2.FilterV2{ + {ResourceType: "kubernetes", Expressions: []v2.ExpressionV2{ + {Field: "agent.tag.env", Operator: "in", Values: []string{"prod"}}, + }}, + }, + }, + }, + } + d := schema.TestResourceDataRaw(t, resourceSysdigSecureZone().Schema, nil) + + scopes := flattenZoneV2(zone, false) + err := d.Set(SchemaScopeKey, scopes) + require.NoError(t, err) + + if rawScopes, ok := d.Get(SchemaScopeKey).(*schema.Set); ok { + scopes := rawScopes.List() + require.Len(t, scopes, 1) + sc := scopes[0].(map[string]interface{}) + require.Equal(t, "kubernetes", sc["target_type"]) + + exprs := sc["expression"].([]interface{}) + require.Len(t, exprs, 1) + + } else { + t.Fatalf("expected *schema.Set for scopes, got %T", d.Get(SchemaScopeKey)) + } +} + +func TestExpandFlattenZoneV2_RoundTrip(t *testing.T) { + hclInput := map[string]interface{}{ + "name": "example-zone-legacy", + "description": "Migrated to expressions", + "scope": []interface{}{ + map[string]interface{}{ + "target_type": "kubernetes", + "expression": []interface{}{ + map[string]interface{}{ + "field": "agent.tag.key", + "operator": "is_not", + "value": "test", + }, + map[string]interface{}{ + "field": "agent.tag.key2", + "operator": "not_contains", + "value": "value2", + }, + }, + }, + }, + } + + d1 := schema.TestResourceDataRaw(t, resourceSysdigSecureZone().Schema, hclInput) + + zone := expandZoneV2(d1) + + d2 := schema.TestResourceDataRaw(t, resourceSysdigSecureZone().Schema, nil) + scopes := flattenZoneV2(zone, false) + err := d2.Set(SchemaScopeKey, scopes) + require.NoError(t, err) + + require.ElementsMatch(t, d1.Get("scope").(*schema.Set).List(), d2.Get("scope").(*schema.Set).List()) +} diff --git a/sysdig/resource_sysdig_secure_zone_test.go b/sysdig/resource_sysdig_secure_zone_test.go index 0dfb1016..2dfbda5c 100644 --- a/sysdig/resource_sysdig_secure_zone_test.go +++ b/sysdig/resource_sysdig_secure_zone_test.go @@ -4,6 +4,7 @@ package sysdig_test import ( "fmt" + "regexp" "testing" "github.com/draios/terraform-provider-sysdig/sysdig" @@ -11,6 +12,9 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +// ImportStateVerify is intentionally disabled here because +// legacy rules are normalized into expression blocks during Read. +// Structural equality is not preserved, but semantic equivalence is. func TestAccSysdigZone_basic(t *testing.T) { zoneName := "Zone_TF_" + randomText(5) zoneDescription := "Test Zone Description" @@ -39,9 +43,12 @@ func TestAccSysdigZone_basic(t *testing.T) { ), }, { - ResourceName: "sysdig_secure_zone.test", - ImportState: true, - ImportStateVerify: true, + ResourceName: "sysdig_secure_zone.test", + ImportState: true, + }, + { + Config: zoneConfig(zoneName, zoneDescription), + PlanOnly: true, }, { Config: zoneConfig(zoneName, "Updated Description"), @@ -53,6 +60,136 @@ func TestAccSysdigZone_basic(t *testing.T) { }) } +func TestAccSysdigSecureZone_LegacyRules(t *testing.T) { + resourceName := "sysdig_secure_zone.legacy" + + resource.Test(t, resource.TestCase{ + PreCheck: preCheckAnyEnv(t, SysdigSecureApiTokenEnv, SysdigIBMSecureAPIKeyEnv), + ProviderFactories: map[string]func() (*schema.Provider, error){ + "sysdig": func() (*schema.Provider, error) { + return sysdig.Provider(), nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccSecureZoneLegacy(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", "acc-legacy"), + resource.TestCheckResourceAttr(resourceName, "scope.0.target_type", "kubernetes"), + ), + }, + { + // refresh only + PlanOnly: true, + Config: testAccSecureZoneLegacy(), + }, + }, + }) +} + +func TestAccSysdigSecureZone_ExpressionOnly(t *testing.T) { + resourceName := "sysdig_secure_zone.expr" + + resource.Test(t, resource.TestCase{ + PreCheck: preCheckAnyEnv(t, SysdigSecureApiTokenEnv, SysdigIBMSecureAPIKeyEnv), + ProviderFactories: map[string]func() (*schema.Provider, error){ + "sysdig": func() (*schema.Provider, error) { + return sysdig.Provider(), nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccSecureZoneExpression(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", "acc-expr"), + resource.TestCheckResourceAttr(resourceName, "scope.0.target_type", "kubernetes"), + resource.TestCheckResourceAttr(resourceName, "scope.0.expression.#", "2"), + // In SDK v2, optional attributes in nested TypeSet elements are always + // materialized in state (as empty string). We verify rules is empty, not absent. + resource.TestCheckResourceAttr(resourceName, "scope.0.rules", ""), + ), + }, + { + PlanOnly: true, + Config: testAccSecureZoneExpression(), + }, + }, + }) +} + +func TestAccSysdigSecureZone_MigrateRulesToExpression(t *testing.T) { + resourceName := "sysdig_secure_zone.migrate" + + resource.Test(t, resource.TestCase{ + PreCheck: preCheckAnyEnv(t, SysdigSecureApiTokenEnv, SysdigIBMSecureAPIKeyEnv), + ProviderFactories: map[string]func() (*schema.Provider, error){ + "sysdig": func() (*schema.Provider, error) { + return sysdig.Provider(), nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccSecureZoneLegacyMigration(), + }, + { + Config: testAccSecureZoneExpressionMigration(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "description", "migrated"), + resource.TestCheckResourceAttr(resourceName, "scope.0.expression.#", "2"), + ), + }, + { + PlanOnly: true, + Config: testAccSecureZoneExpressionMigration(), + }, + }, + }) +} + +func TestAccSysdigSecureZone_V2RulesOnly(t *testing.T) { + resourceName := "sysdig_secure_zone.v2rules" + + resource.Test(t, resource.TestCase{ + PreCheck: preCheckAnyEnv(t, SysdigSecureApiTokenEnv, SysdigIBMSecureAPIKeyEnv), + ProviderFactories: map[string]func() (*schema.Provider, error){ + "sysdig": func() (*schema.Provider, error) { + return sysdig.Provider(), nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccSecureZoneV2Rules(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", "acc-v2rules"), + resource.TestCheckResourceAttr(resourceName, "scope.0.target_type", "kubernetes"), + resource.TestCheckResourceAttr(resourceName, "scope.0.expression.#", "0"), + ), + }, + { + PlanOnly: true, + Config: testAccSecureZoneV2Rules(), + }, + }, + }) +} + +func TestAccSysdigSecureZone_InvalidRulesAndExpression(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: preCheckAnyEnv(t, SysdigSecureApiTokenEnv, SysdigIBMSecureAPIKeyEnv), + ProviderFactories: map[string]func() (*schema.Provider, error){ + "sysdig": func() (*schema.Provider, error) { + return sysdig.Provider(), nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccSecureZoneInvalid(), + ExpectError: regexp.MustCompile("cannot be used together with"), + }, + }, + }) +} + func zoneConfig(name, description string) string { return fmt.Sprintf(` resource "sysdig_secure_zone" "test" { @@ -65,3 +202,114 @@ resource "sysdig_secure_zone" "test" { } `, name, description) } + +func testAccSecureZoneLegacy() string { + return ` +resource "sysdig_secure_zone" "legacy" { + name = "acc-legacy" + description = "legacy rules" + + scope { + target_type = "kubernetes" + rules = "agentTags != \"key: value\" and not agentTags contains \"key2: value2\"" + } +} +` +} + +func testAccSecureZoneExpression() string { + return ` +resource "sysdig_secure_zone" "expr" { + name = "acc-expr" + description = "expression test" + + scope { + target_type = "kubernetes" + + expression { + field = "agent.tag.key" + operator = "is_not" + value = "value" + } + + expression { + field = "agent.tag.key2" + operator = "not_contains" + value = "value2" + } + } +} +` +} + +func testAccSecureZoneLegacyMigration() string { + return ` +resource "sysdig_secure_zone" "migrate" { + name = "acc-migrate" + description = "legacy" + + scope { + target_type = "kubernetes" + rules = "agentTags != \"key: value\" and not agentTags contains \"key2: value2\"" + } +} +` +} + +func testAccSecureZoneExpressionMigration() string { + return ` +resource "sysdig_secure_zone" "migrate" { + name = "acc-migrate" + description = "migrated" + + scope { + target_type = "kubernetes" + + expression { + field = "agent.tag.key" + operator = "is_not" + value = "value" + } + + expression { + field = "agent.tag.key2" + operator = "not_contains" + value = "value2" + } + } +} +` +} + +func testAccSecureZoneV2Rules() string { + return ` +resource "sysdig_secure_zone" "v2rules" { + name = "acc-v2rules" + description = "v2 rules test" + + scope { + target_type = "kubernetes" + rules = "agent.tag.key != \"value\" and not agent.tag.key2 contains \"value2\"" + } +} +` +} + +func testAccSecureZoneInvalid() string { + return ` +resource "sysdig_secure_zone" "invalid" { + name = "acc-invalid" + + scope { + target_type = "kubernetes" + rules = "agentTags != \"key: value\"" + + expression { + field = "agent.tag.key" + operator = "is_not" + value = "value" + } + } +} +` +} diff --git a/sysdig/resource_sysdig_secure_zone_validation.go b/sysdig/resource_sysdig_secure_zone_validation.go new file mode 100644 index 00000000..2f3d764f --- /dev/null +++ b/sysdig/resource_sysdig_secure_zone_validation.go @@ -0,0 +1,296 @@ +package sysdig + +import ( + "fmt" + "sort" + "strings" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// ruleIdentifier represents a backend field identifier for zone scope filters. +type ruleIdentifier string + +const ( + identAccount ruleIdentifier = "account" + identClusterId ruleIdentifier = "clusterId" + identDistribution ruleIdentifier = "distribution" + identNamespace ruleIdentifier = "namespace" + identLabels ruleIdentifier = "labels" + identLabelValues ruleIdentifier = "labelValues" + identAgentTags ruleIdentifier = "agentTags" + identLocation ruleIdentifier = "location" + identOrganization ruleIdentifier = "organization" + identName ruleIdentifier = "name" + identRegistry ruleIdentifier = "registry" + identRepository ruleIdentifier = "repository" + identGitIntegrationId ruleIdentifier = "gitIntegrationId" + identGitSourceId ruleIdentifier = "gitSourceId" + identResourceGroupId ruleIdentifier = "resourceGroupId" + identAccountGroupId ruleIdentifier = "accountGroupId" + identAccountGroupName ruleIdentifier = "accountGroupName" +) + +// allowedIdentifiers maps each target_type to the set of backend rule identifiers +// it accepts. This mirrors the backend's validIdentifiers map. +var allowedIdentifiers = map[string]map[ruleIdentifier]struct{}{ + "aws": {identAccount: {}, identOrganization: {}, identLabels: {}, identLocation: {}}, + "gcp": {identAccount: {}, identOrganization: {}, identLabels: {}, identLocation: {}}, + "azure": {identAccount: {}, identOrganization: {}, identLabels: {}, identLocation: {}}, + "kubernetes": {identClusterId: {}, identNamespace: {}, identLabelValues: {}, identAgentTags: {}, identDistribution: {}}, + "host": {identClusterId: {}, identName: {}, identAgentTags: {}}, + "image": {identRegistry: {}, identRepository: {}}, + "git": {identGitIntegrationId: {}, identGitSourceId: {}}, + "ibm": {identAccount: {}, identOrganization: {}, identLabels: {}, identLocation: {}, identResourceGroupId: {}, identAccountGroupId: {}, identAccountGroupName: {}}, + "oci": {identAccount: {}, identOrganization: {}, identLabels: {}, identLocation: {}}, +} + +// directFieldMap maps v2 expression field names (without prefixes) to their +// corresponding backend rule identifiers. +var directFieldMap = map[string]ruleIdentifier{ + "account": identAccount, + "organization": identOrganization, + "clusterId": identClusterId, + "namespace": identNamespace, + "distribution": identDistribution, + "location": identLocation, + "name": identName, + "registry": identRegistry, + "repository": identRepository, + "gitIntegrationId": identGitIntegrationId, + "gitSourceId": identGitSourceId, + "resourceGroupId": identResourceGroupId, + "accountGroupId": identAccountGroupId, + "accountGroupName": identAccountGroupName, +} + +// labelAsLabelsTargets lists target types where label. maps to +// LabelsRuleIdentifier. All other target types use LabelValuesRuleIdentifier. +var labelAsLabelsTargets = map[string]struct{}{ + "aws": {}, + "gcp": {}, + "azure": {}, + "ibm": {}, + "oci": {}, +} + +// identToFieldPattern maps backend identifiers to user-facing field patterns +// for error messages. +var identToFieldPattern = map[ruleIdentifier]string{ + identAccount: "account", + identOrganization: "organization", + identClusterId: "clusterId", + identNamespace: "namespace", + identDistribution: "distribution", + identLabels: "label.", + identLabelValues: "label.", + identAgentTags: "agent.tag.", + identLocation: "location", + identName: "name", + identRegistry: "registry", + identRepository: "repository", + identGitIntegrationId: "gitIntegrationId", + identGitSourceId: "gitSourceId", + identResourceGroupId: "resourceGroupId", + identAccountGroupId: "accountGroupId", + identAccountGroupName: "accountGroupName", +} + +// resolveIdentifier maps a v2 expression field to the backend's rule identifier, +// taking target_type into account for label. disambiguation. +// +// Returns the identifier and true if the field is recognized, or ("", false) +// for fields the provider doesn't know about (forward compatibility). +func resolveIdentifier(targetType, field string) (ruleIdentifier, bool) { + if strings.HasPrefix(field, "label.") && len(field) > len("label.") { + if _, ok := labelAsLabelsTargets[targetType]; ok { + return identLabels, true + } + return identLabelValues, true + } + + if strings.HasPrefix(field, "agent.tag.") && len(field) > len("agent.tag.") { + return identAgentTags, true + } + + if id, ok := directFieldMap[field]; ok { + return id, true + } + + return "", false +} + +// allowedFieldFamilies returns a sorted list of user-facing field patterns +// allowed for a given target_type. +func allowedFieldFamilies(targetType string) []string { + allowed, ok := allowedIdentifiers[targetType] + if !ok { + return nil + } + + seen := map[string]struct{}{} + var fields []string + for id := range allowed { + f := identToFieldPattern[id] + if _, dup := seen[f]; !dup { + seen[f] = struct{}{} + fields = append(fields, f) + } + } + sort.Strings(fields) + return fields +} + +// validateExpressionsFromPlan validates expression fields using the raw plan's +// cty representation. This avoids type-assertion issues that occur when reading +// nested TypeList elements inside a TypeSet via d.Get() during CustomizeDiff. +func validateExpressionsFromPlan(d *schema.ResourceDiff) error { + rawPlan := d.GetRawPlan() + if rawPlan.IsNull() || !rawPlan.IsKnown() { + return nil + } + + scopeSet := rawPlan.GetAttr(SchemaScopeKey) + if scopeSet.IsNull() || !scopeSet.IsKnown() { + return nil + } + + scopeIndex := 0 + for it := scopeSet.ElementIterator(); it.Next(); { + _, scopeVal := it.Element() + + targetTypeVal := scopeVal.GetAttr(SchemaTargetTypeKey) + if targetTypeVal.IsNull() || !targetTypeVal.IsKnown() { + scopeIndex++ + continue + } + targetType := targetTypeVal.AsString() + + exprsVal := scopeVal.GetAttr(SchemaExpressionKey) + if exprsVal.IsNull() || !exprsVal.IsKnown() || exprsVal.LengthInt() == 0 { + scopeIndex++ + continue + } + + allowed, ok := allowedIdentifiers[targetType] + if !ok { + known := make([]string, 0, len(allowedIdentifiers)) + for k := range allowedIdentifiers { + known = append(known, k) + } + sort.Strings(known) + return fmt.Errorf( + "scope[%d]: unknown target_type %q; supported types: %s", + scopeIndex, targetType, strings.Join(known, ", "), + ) + } + + for i := 0; i < exprsVal.LengthInt(); i++ { + exprVal := exprsVal.Index(cty.NumberIntVal(int64(i))) + fieldVal := exprVal.GetAttr(SchemaFieldKey) + if fieldVal.IsNull() || !fieldVal.IsKnown() { + continue + } + field := fieldVal.AsString() + if field == "" { + continue + } + + ident, recognized := resolveIdentifier(targetType, field) + if !recognized { + continue + } + + if _, ok := allowed[ident]; !ok { + return fmt.Errorf( + "scope[%d].expression[%d]: field %q is not allowed for target_type %q; "+ + "allowed fields: %s", + scopeIndex, i, field, targetType, + strings.Join(allowedFieldFamilies(targetType), ", "), + ) + } + } + + scopeIndex++ + } + + return nil +} + +// validateExpressionFields validates that all expression fields in a scope +// are allowed for the given target_type. +// +// Validation rules: +// - Unknown target_type → error (target_type has a fixed set validated by the schema) +// - Unrecognized field → skip (forward compatibility with new backend fields) +// - Recognized field not in allowlist → error +func validateExpressionFields(targetType string, expressions []interface{}, scopeIndex int) error { + allowed, ok := allowedIdentifiers[targetType] + if !ok { + known := make([]string, 0, len(allowedIdentifiers)) + for k := range allowedIdentifiers { + known = append(known, k) + } + sort.Strings(known) + return fmt.Errorf( + "scope[%d]: unknown target_type %q; supported types: %s", + scopeIndex, targetType, strings.Join(known, ", "), + ) + } + + for i, raw := range expressions { + expr, ok := raw.(map[string]interface{}) + if !ok { + continue + } + field, _ := expr[SchemaFieldKey].(string) + if field == "" { + continue + } + + ident, recognized := resolveIdentifier(targetType, field) + if !recognized { + continue + } + + if _, ok := allowed[ident]; !ok { + return fmt.Errorf( + "scope[%d].expression[%d]: field %q is not allowed for target_type %q; "+ + "allowed fields: %s", + scopeIndex, i, field, targetType, + strings.Join(allowedFieldFamilies(targetType), ", "), + ) + } + } + + return nil +} + +// validateZoneExpressions runs expression field validation as a safety net +// for Create/Update, complementing the plan-time CustomizeDiff validation. +func validateZoneExpressions(d *schema.ResourceData) error { + scopeSet, ok := d.Get(SchemaScopeKey).(*schema.Set) + if !ok || scopeSet == nil { + return nil + } + + for i, raw := range scopeSet.List() { + scope, ok := raw.(map[string]interface{}) + if !ok { + continue + } + + expressions, ok := scope[SchemaExpressionKey].([]interface{}) + if !ok || len(expressions) == 0 { + continue + } + + targetType, _ := scope[SchemaTargetTypeKey].(string) + if err := validateExpressionFields(targetType, expressions, i); err != nil { + return err + } + } + + return nil +} diff --git a/sysdig/resource_sysdig_secure_zone_validation_test.go b/sysdig/resource_sysdig_secure_zone_validation_test.go new file mode 100644 index 00000000..8eb78d1d --- /dev/null +++ b/sysdig/resource_sysdig_secure_zone_validation_test.go @@ -0,0 +1,304 @@ +package sysdig + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveIdentifier(t *testing.T) { + tests := []struct { + name string + targetType string + field string + wantIdent ruleIdentifier + wantOK bool + }{ + // label. disambiguation + { + name: "label on aws resolves to labels", + targetType: "aws", + field: "label.team", + wantIdent: identLabels, + wantOK: true, + }, + { + name: "label on kubernetes resolves to labelValues", + targetType: "kubernetes", + field: "label.team", + wantIdent: identLabelValues, + wantOK: true, + }, + { + name: "label on host resolves to labelValues", + targetType: "host", + field: "label.env", + wantIdent: identLabelValues, + wantOK: true, + }, + { + name: "label on ibm resolves to labels", + targetType: "ibm", + field: "label.cost-center", + wantIdent: identLabels, + wantOK: true, + }, + { + name: "label on oci resolves to labels", + targetType: "oci", + field: "label.env", + wantIdent: identLabels, + wantOK: true, + }, + // agent.tag. + { + name: "agent.tag resolves to agentTags", + targetType: "kubernetes", + field: "agent.tag.cluster", + wantIdent: identAgentTags, + wantOK: true, + }, + // Direct fields + { + name: "account direct match", + targetType: "aws", + field: "account", + wantIdent: identAccount, + wantOK: true, + }, + { + name: "clusterId direct match", + targetType: "kubernetes", + field: "clusterId", + wantIdent: identClusterId, + wantOK: true, + }, + { + name: "namespace direct match", + targetType: "kubernetes", + field: "namespace", + wantIdent: identNamespace, + wantOK: true, + }, + { + name: "gitIntegrationId direct match", + targetType: "git", + field: "gitIntegrationId", + wantIdent: identGitIntegrationId, + wantOK: true, + }, + // Unrecognized fields + { + name: "completely unknown field", + targetType: "aws", + field: "foobar", + wantIdent: "", + wantOK: false, + }, + { + name: "label. with no key", + targetType: "aws", + field: "label.", + wantIdent: "", + wantOK: false, + }, + { + name: "agent.tag. with no key", + targetType: "kubernetes", + field: "agent.tag.", + wantIdent: "", + wantOK: false, + }, + { + name: "agent.tag without trailing dot", + targetType: "kubernetes", + field: "agent.tag", + wantIdent: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ident, ok := resolveIdentifier(tt.targetType, tt.field) + require.Equal(t, tt.wantOK, ok) + require.Equal(t, tt.wantIdent, ident) + }) + } +} + +func TestValidateExpressionFields(t *testing.T) { + tests := []struct { + name string + targetType string + exprs []interface{} + wantErr string // empty = no error + }{ + // --- Recognized but not allowed → ERROR --- + { + name: "kubernetes + account => error", + targetType: "kubernetes", + exprs: []interface{}{ + map[string]interface{}{"field": "account", "operator": "in"}, + }, + wantErr: `field "account" is not allowed for target_type "kubernetes"`, + }, + { + name: "aws + namespace => error", + targetType: "aws", + exprs: []interface{}{ + map[string]interface{}{"field": "namespace", "operator": "in"}, + }, + wantErr: `field "namespace" is not allowed for target_type "aws"`, + }, + { + name: "aws + agent.tag.env => error", + targetType: "aws", + exprs: []interface{}{ + map[string]interface{}{"field": "agent.tag.env", "operator": "in"}, + }, + wantErr: `field "agent.tag.env" is not allowed for target_type "aws"`, + }, + { + name: "host + label.team => error (host doesn't support label fields)", + targetType: "host", + exprs: []interface{}{ + map[string]interface{}{"field": "label.team", "operator": "in"}, + }, + wantErr: `field "label.team" is not allowed for target_type "host"`, + }, + { + name: "multiple expressions, second invalid", + targetType: "kubernetes", + exprs: []interface{}{ + map[string]interface{}{"field": "clusterId", "operator": "in"}, + map[string]interface{}{"field": "account", "operator": "in"}, + }, + wantErr: `scope[0].expression[1]: field "account" is not allowed`, + }, + + // --- Recognized and allowed → OK --- + { + name: "kubernetes + label.team => OK", + targetType: "kubernetes", + exprs: []interface{}{ + map[string]interface{}{"field": "label.team", "operator": "in"}, + }, + wantErr: "", + }, + { + name: "aws + label.team => OK", + targetType: "aws", + exprs: []interface{}{ + map[string]interface{}{"field": "label.team", "operator": "in"}, + }, + wantErr: "", + }, + { + name: "kubernetes + agent.tag.env => OK", + targetType: "kubernetes", + exprs: []interface{}{ + map[string]interface{}{"field": "agent.tag.env", "operator": "in"}, + }, + wantErr: "", + }, + { + name: "image + registry => OK", + targetType: "image", + exprs: []interface{}{ + map[string]interface{}{"field": "registry", "operator": "in"}, + }, + wantErr: "", + }, + { + name: "ibm + resourceGroupId => OK", + targetType: "ibm", + exprs: []interface{}{ + map[string]interface{}{"field": "resourceGroupId", "operator": "in"}, + }, + wantErr: "", + }, + + // --- Unrecognized field → silently allowed (forward compat) --- + { + name: "kubernetes + unknown field => no error", + targetType: "kubernetes", + exprs: []interface{}{ + map[string]interface{}{"field": "someUnknownNewField", "operator": "in"}, + }, + wantErr: "", + }, + { + name: "aws + future field => no error", + targetType: "aws", + exprs: []interface{}{ + map[string]interface{}{"field": "compartmentId", "operator": "in"}, + }, + wantErr: "", + }, + { + name: "mixed: known-invalid + unknown => error on known-invalid", + targetType: "kubernetes", + exprs: []interface{}{ + map[string]interface{}{"field": "futureField", "operator": "in"}, + map[string]interface{}{"field": "account", "operator": "in"}, + }, + wantErr: `field "account" is not allowed for target_type "kubernetes"`, + }, + { + name: "mixed: unknown + known-valid => OK", + targetType: "kubernetes", + exprs: []interface{}{ + map[string]interface{}{"field": "futureField", "operator": "in"}, + map[string]interface{}{"field": "clusterId", "operator": "in"}, + }, + wantErr: "", + }, + + // --- Unknown target_type → ERROR (fixed set) --- + { + name: "unknown target_type => error", + targetType: "lambda", + exprs: []interface{}{ + map[string]interface{}{"field": "account", "operator": "in"}, + }, + wantErr: `unknown target_type "lambda"`, + }, + + // --- Edge cases --- + { + name: "empty expressions => OK", + targetType: "aws", + exprs: []interface{}{}, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateExpressionFields(tt.targetType, tt.exprs, 0) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + +func TestAllowedFieldFamilies(t *testing.T) { + k8s := allowedFieldFamilies("kubernetes") + require.Contains(t, k8s, "label.") + require.Contains(t, k8s, "agent.tag.") + require.Contains(t, k8s, "clusterId") + require.Contains(t, k8s, "namespace") + require.NotContains(t, k8s, "account") + + aws := allowedFieldFamilies("aws") + require.Contains(t, aws, "label.") + require.NotContains(t, aws, "agent.tag.") + require.Contains(t, aws, "account") + + require.Nil(t, allowedFieldFamilies("unknown")) +} diff --git a/website/docs/d/secure_zone.md b/website/docs/d/secure_zone.md index 9b8f3723..4c2af1bc 100644 --- a/website/docs/d/secure_zone.md +++ b/website/docs/d/secure_zone.md @@ -3,19 +3,54 @@ subcategory: "Sysdig Secure" layout: "sysdig" page_title: "Sysdig: sysdig_secure_zone" description: |- - Retrieves Zone by ID. + Retrieves Zone by ID or name. --- # sysdig\_secure\_zone Data Source The `sysdig_secure_zone` data source allows you to retrieve information about a specific Sysdig Secure Zone. +-> **Note:** The `rules` attribute supports both v2 and legacy (v1) syntax. Legacy v1 syntax (`labels`, `labelValues`, `agentTags`) is deprecated — use `expression` blocks instead. Rules using v2-compatible field names are fully supported. See the [resource documentation](../r/secure_zone.md) for migration guidance. + ## Example Usage +### With expression-based scopes (recommended) + +```hcl +resource "sysdig_secure_zone" "sample" { + name = "test-secure-zone" + description = "Test secure zone" + + scope { + target_type = "aws" + + expression { + field = "organization" + operator = "in" + values = ["o1", "o2"] + } + + expression { + field = "account" + operator = "in" + values = ["a1", "a2"] + } + } +} + +data "sysdig_secure_zone" "test" { + depends_on = [sysdig_secure_zone.sample] + name = sysdig_secure_zone.sample.name +} +``` + +### With legacy `rules` scopes (deprecated) + ```hcl resource "sysdig_secure_zone" "sample" { name = "test-secure-zone" description = "Test secure zone" + scope { target_type = "aws" rules = "organization in (\"o1\", \"o2\") and account in (\"a1\", \"a2\")" @@ -41,14 +76,31 @@ In addition to all arguments above, the following attributes are exported: - `is_system` - (Computed) Whether the Zone is a system zone. - `author` - (Computed) The zone author. -- `scope` - (Computed) The scope of the zone. - `last_modified_by` - (Computed) By whom is last modification made. - `last_updated` - (Computed) Timestamp of last modification of zone. +- `scope` - (Computed) The scope of the zone. Each scope contains: + - `id` - The ID of the scope. + - `target_type` - The resource type this scope applies to (e.g., `aws`, `gcp`, `azure`, `kubernetes`, `host`, `image`, `git`, `ibm`, `oci`). + - `rules` - (Computed) Query language expression. Legacy v1 syntax is deprecated; v2-compatible syntax is fully supported. + - `expression` - List of filter expressions, each containing: + - `field` - Field name to filter on (e.g., `organization`, `account`, `label.`, `agent.tag.`). + - `operator` - Operator applied (e.g., `in`, `contains`, `not_in`, `not_contains`, `is_not`). + - `value` - Single value (for operators like `contains`). + - `values` - List of values (for operators like `in`). -## Import +### Understanding scope structure -Zone can be imported using the ID, e.g. +- **Within a single scope**: all `expression` blocks are combined with **AND**. +- **Between scopes**: multiple scopes are combined with **OR**. +- **Within `in` operator**: values are combined with **OR**. -``` -$ terraform import sysdig_secure_zone.example 12345 -``` +### Expression fields (v2) + +The v2 expression model uses explicit field names for labels and agent tags: + +- `label.` - Filters on label values (replaces legacy `labels` and `labelValues`). +- `agent.tag.` - Filters on agent tag values (replaces legacy `agentTags`). + +For detailed migration examples from `rules` to `expression`, see the [resource documentation](../r/secure_zone.md#migrating-from-rules-to-expression). + +-> **Note:** The data source returns **both** `rules` and `expression` for each scope when available. This differs from the resource, where they are mutually exclusive. The `rules` field contains the v1/v2 string representation, while `expression` contains the structured equivalent. Use whichever representation is more convenient for your use case. diff --git a/website/docs/r/secure_zone.md b/website/docs/r/secure_zone.md index 6b57ea1d..1a77ee0e 100644 --- a/website/docs/r/secure_zone.md +++ b/website/docs/r/secure_zone.md @@ -14,6 +14,8 @@ Creates a Sysdig Secure Zone. ## Example Usage +### Expression-based scopes (recommended) + ```terraform resource "sysdig_secure_zone" "example" { name = "example-zone" @@ -21,12 +23,42 @@ resource "sysdig_secure_zone" "example" { scope { target_type = "aws" - rules = "organization in (\"o1\", \"o2\") and account in (\"a1\", \"a2\")" + + expression { + field = "organization" + operator = "in" + values = ["o1", "o2"] + } + + expression { + field = "account" + operator = "in" + values = ["a1", "a2"] + } } scope { target_type = "azure" - rules = "organization contains \"o1\"" + + expression { + field = "organization" + operator = "contains" + value = "o1" + } + } +} +``` + +### Legacy rule-based scopes (deprecated) + +```terraform +resource "sysdig_secure_zone" "legacy" { + name = "example-zone-legacy" + description = "Legacy rules test" + + scope { + target_type = "kubernetes" + rules = "agentTags != \"environment: production\" and not agentTags contains \"team: platform\"" } } ``` @@ -35,13 +67,15 @@ resource "sysdig_secure_zone" "example" { - `name` - (Required) The name of the Zone. - `description` - (Optional) The description of the Zone. -- `scopes` - (Required) Scopes block defines list of scopes attached to Zone. +- `scope` - (Required) One or more `scope` blocks attached to the Zone. + +### Scope block -### Scopes block +A `scope` defines what resources belong to this zone. - `id` - (Computed) The ID of the scope. -- `target_type` - (Required) The target type for the scope. Supported types: +- `target_type` - (Required) The resource type this scope applies to. Supported types: - AWS - `aws` - GCP - `gcp` @@ -50,115 +84,370 @@ resource "sysdig_secure_zone" "example" { - Image - `image` - Host - `host` - Git - `git` + - IBM - `ibm` + - OCI - `oci` + +- `rules` - (Optional) Query language expression for filtering results. + + ~> **Note:** The `rules` field supports both v2 and legacy (v1) syntax. When using legacy v1 attributes (`labels`, `labelValues`, `agentTags`), a deprecation warning will be shown — migrate to `expression` blocks with v2 field names (`label.`, `agent.tag.`). Rules using v2-compatible syntax (e.g., `organization`, `account`, `cluster`) are fully supported and produce no warning. `rules` and `expression` cannot be used together within the same `scope`. + +- `expression` - One or more blocks that define the scope as a list of filter expressions. + + A scope must specify either `rules` or at least one `expression` block. + +#### Expression block + +Each `expression` block represents a single condition. + +- `field` - (Required) Field name to filter on. See the "Supported fields" section below. +- `operator` - (Required) Operator to apply. +- `value` - (Optional) Single value for operators that take one argument. +- `values` - (Optional) List of values for operators such as `in`. + +~> **Note:** Provide either `value` or `values` for an `expression` block (depending on the operator). If both are set, `values` takes precedence. + +## Migrating from `rules` to `expression` + +The `rules` attribute is deprecated and will be removed in a future version. New zones should be created using `expression` blocks. Existing zones that use `rules` can be migrated. + +### What to expect during migration + +- Migration is done by updating your Terraform configuration. +- An **update in place** is expected. Terraform may show changes under `scope` because the representation changes from a single `rules` string to structured `expression` blocks. +- Within a single `scope`, `rules` and `expression` are **mutually exclusive**. + +### Understanding scope logic + +To migrate correctly, you must understand how expressions combine: -- `rules` - (Optional) Query language expression for filtering results. Empty rules means no filtering. - - Operators: - - - `and`, `or` logical operators - - `in` - - `contains` to check partial values of attributes - - List of supported fields by target type: - - - `aws`: - - `account` - - Type: string - - Description: AWS account ID - - Example query: `account in ("123456789012")` - - `organization` - - Type: string - - Description: AWS organization ID - - Example query: `organization in ("o-1234567890")` - - `labels` - - Type: string - - Description: AWS account labels - - Example query: `labels in ("label1")` - - `location` - - Type: string - - Description: AWS account location - - Example query: `location in ("us-east-1")` - - `gcp`: - - `account` - - Type: string - - Description: GCP account ID - - Example query: `account in ("123456789012")` - - `organization` - - Type: string - - Description: GCP organization ID - - Example query: `organization in ("1234567890")` - - `labels` - - Type: string - - Description: GCP account labels - - Example query: `labels in ("label1")` - - `location` - - Type: string - - Description: GCP account location - - Example query: `location in ("us-east-1")` - - `azure`: - - `account` - - Type: string - - Description: Azure account ID - - Example query: `account in ("123456789012")` - - `organization` - - Type: string - - Description: Azure organization ID - - Example query: `organization in ("1234567890")` - - `labels` - - Type: string - - Description: Azure account labels - - Example query: `labels in ("label1")` - - `location` - - Type: string - - Description: Azure account location - - Example query: `location in ("us-east-1")` - - `kubernetes`: - - `clusterId` - - Type: string - - Description: Kubernetes cluster ID - - Example query: `clusterId in ("cluster")` - - `namespace` - - Type: string - - Description: Kubernetes namespace - - Example query: `namespace in ("namespace")` - - `labelValues` - - Type: string - - Description: Kubernetes label values - - Example query: `labelValues in ("label1")` - - `distribution` - - Type: string - - Description: Kubernetes distribution - - Example query: `distribution in ("eks")` - - `host`: - - `clusterId` - - Type: string - - Description: Kubernetes cluster ID - - Example query: `clusterId in ("cluster")` - - `name` - - Type: string - - Description: Host name - - Example query: `name in ("host")` - - `image`: - - `registry` - - Type: string - - Description: Image registry - - Example query: `registry in ("registry")` - - `repository` - - Type: string - - Description: Image repository - - Example query: `repository in ("repository")` - - `git`: - - `gitIntegrationId` - - Type: string - - Description: Git integration ID - - Example query: `gitIntegrationId in ("gitIntegrationId")` - - `gitSourceId` - - Type: string - - Description: Git source ID - - Example query: `gitSourceId in ("gitSourceId")` - - **Note**: Whenever filtering for values with special characters, the values need to be encoded. - When “ or \ are the special characters, they need to be escaped with \ and then encoded. +- **Within a single scope**: all `expression` blocks are combined with **AND**. +- **Between scopes**: multiple scopes are combined with **OR**. +- **Within `in` operator**: values are combined with **OR** (e.g., `field in ("a", "b")` means `a OR b`). + +This means that some legacy rules that use `in` with multiple values for different keys will need to be **split into multiple scopes** to preserve the same semantic behavior. + +### Semantic change: labels / agentTags + +In legacy `rules`, fields like `labels` (cloud accounts), `labelValues` (kubernetes), and `agentTags` (kubernetes/host) were represented as a single string in the form `"key: value"`. +This had important side effects: + +- `labels in ("key: value")` filtered on both key and value combined. +- `labels contains "e"` could match the `e` in either the key or the value. + +In the v2 expression model this ambiguity is removed. +Instead of querying the combined `"key: value"` string, you explicitly select the key in the field name and filters apply to the **value only**: + +- `label.` (replaces legacy `labels` and `labelValues`) +- `agent.tag.` (replaces legacy `agentTags`) + +### Migration strategy + +1. Pick one `scope` block at a time. +2. Translate each condition in `rules` into one or more `expression` blocks. +3. Replace the `rules = "..."` line with `expression { ... }` blocks. +4. If your legacy rule uses `in` with multiple **different keys**, split into multiple scopes (see examples below). +5. Run `terraform plan` and verify the plan shows an in-place update. + +### Example migrations + +#### Simple case: single key + +When all values in an `in` clause share the same key, they stay in a single scope: + +Legacy: + +```hcl +rules = "agentTags in (\"cluster: auto-do-not-delete-683\", \"cluster: qa-integrations\")" +``` + +Migrated (single scope): + +```terraform +scope { + target_type = "kubernetes" + + expression { + field = "agent.tag.cluster" + operator = "in" + values = ["auto-do-not-delete-683", "qa-integrations"] + } +} +``` + +#### Multiple different keys: split into multiple scopes + +When an `in` clause contains values with **different keys**, they must be split into separate scopes to preserve the OR semantics: + +Legacy: + +```hcl +rules = "agentTags in (\"env: prod\", \"region: us-west\")" +``` + +Migrated (two scopes, combined with OR): +```terraform +scope { + target_type = "kubernetes" + + expression { + field = "agent.tag.env" + operator = "in" + values = ["prod"] + } +} + +scope { + target_type = "kubernetes" + + expression { + field = "agent.tag.region" + operator = "in" + values = ["us-west"] + } +} +``` + +#### Mixed tags and static filters with same key + +When tags/labels share the same key, they can be combined in a single scope: + +Legacy: + +```hcl +rules = "agentTags in (\"team: a\", \"team: b\") and labelValues in (\"env: dev\", \"env: staging\") and namespace in (\"core\") and clusterId in (\"dev\")" +``` + +Migrated (single scope): + +```terraform +scope { + target_type = "kubernetes" + + expression { + field = "agent.tag.team" + operator = "in" + values = ["a", "b"] + } + + expression { + field = "label.env" + operator = "in" + values = ["dev", "staging"] + } + + expression { + field = "namespace" + operator = "in" + values = ["core"] + } + + expression { + field = "clusterId" + operator = "in" + values = ["dev"] + } +} +``` + +### Operator mapping notes + +The legacy query language and the new structured expressions use different operator spellings. + +Common patterns: + +- `field in ("a", "b")` → `operator = "in"` with `values = ["a", "b"]` +- `field contains "x"` → `operator = "contains"` with `value = "x"` +- `not field contains "x"` → `operator = "not_contains"` with `value = "x"` +- `field != "x"` → `operator = "is_not"` with `value = "x"` + +~> **Note:** Operators with multiple words use underscore notation: `is_not`, `not_contains`, `not_in`. The backend normalizes space-separated forms (e.g., `"is not"`) to underscores. + +## Supported fields and legacy query language notes + +When using `rules` (**deprecated**), the following operators are supported: + +- `and` logical operator +- `or` logical operator +- `in` +- `contains` to check partial values of attributes + +When using `expression`, you specify each condition in a dedicated block and the provider translates it to the backend model. + +### Legacy-only fields (`rules` only) + +The following fields are supported only by the deprecated `rules` syntax and are **not** available in `expression`. + +- `labels` (cloud target types) +- `labelValues` (kubernetes) +- `agentTags` (kubernetes and host) + +Use the v2 expression fields described below instead. + +### Expression fields (v2) + +The v2 expression model avoids ambiguous matching by requiring the label/tag key to be encoded in the `field`: + +- `label.` (replaces legacy `labels` and `labelValues`) +- `agent.tag.` (replaces legacy `agentTags`) + +### Supported fields by target type (legacy `rules` reference) + +The following list is kept as a reference for the legacy `rules` language. + +-> **Forward compatibility:** If the backend introduces new fields that the provider does not yet recognize, they are silently accepted during validation. Only fields the provider knows about are checked against the target_type allowlist. + +- `aws`: + - `account` + - Type: string + - Description: AWS account ID + - Example query: `account in ("123456789012")` + - `organization` + - Type: string + - Description: AWS organization ID + - Example query: `organization in ("o-1234567890")` + - `labels` + - Type: string + - Description: AWS account labels (legacy `rules` only) + - Example query: `labels in ("key: value")` + - `location` + - Type: string + - Description: AWS account location + - Example query: `location in ("us-east-1")` +- `gcp`: + - `account` + - Type: string + - Description: GCP account ID + - Example query: `account in ("123456789012")` + - `organization` + - Type: string + - Description: GCP organization ID + - Example query: `organization in ("1234567890")` + - `labels` + - Type: string + - Description: GCP account labels (legacy `rules` only) + - Example query: `labels in ("key: value")` + - `location` + - Type: string + - Description: GCP account location + - Example query: `location in ("us-east-1")` +- `azure`: + - `account` + - Type: string + - Description: Azure account ID + - Example query: `account in ("123456789012")` + - `organization` + - Type: string + - Description: Azure organization ID + - Example query: `organization in ("1234567890")` + - `labels` + - Type: string + - Description: Azure account labels (legacy `rules` only) + - Example query: `labels in ("key: value")` + - `location` + - Type: string + - Description: Azure account location + - Example query: `location in ("us-east-1")` +- `kubernetes`: + - `clusterId` + - Type: string + - Description: Kubernetes cluster ID + - Example query: `clusterId in ("cluster")` + - `namespace` + - Type: string + - Description: Kubernetes namespace + - Example query: `namespace in ("namespace")` + - `labelValues` + - Type: string + - Description: Kubernetes label values (legacy `rules` only) + - Example query: `labelValues in ("label1")` + - `distribution` + - Type: string + - Description: Kubernetes distribution + - Example query: `distribution in ("eks")` + - `agentTags` + - Type: string + - Description: Agent tags in the form `"key: value"` (legacy `rules` only) + - Example query: `agentTags contains "key: value"` +- `host`: + - `clusterId` + - Type: string + - Description: Kubernetes cluster ID + - Example query: `clusterId in ("cluster")` + - `name` + - Type: string + - Description: Host name + - Example query: `name in ("host")` + - `agentTags` + - Type: string + - Description: Agent tags in the form `"key: value"` (legacy `rules` only) + - Example query: `agentTags contains "key: value"` +- `image`: + - `registry` + - Type: string + - Description: Image registry + - Example query: `registry in ("registry")` + - `repository` + - Type: string + - Description: Image repository + - Example query: `repository in ("repository")` +- `git`: + - `gitIntegrationId` + - Type: string + - Description: Git integration ID + - Example query: `gitIntegrationId in ("gitIntegrationId")` + - `gitSourceId` + - Type: string + - Description: Git source ID + - Example query: `gitSourceId in ("gitSourceId")` +- `ibm`: + - `account` + - Type: string + - Description: IBM account ID + - Example query: `account in ("123456789012")` + - `organization` + - Type: string + - Description: IBM organization ID + - Example query: `organization in ("1234567890")` + - `labels` + - Type: string + - Description: IBM account labels (legacy `rules` only) + - Example query: `labels in ("key: value")` + - `location` + - Type: string + - Description: IBM account location + - Example query: `location in ("us-east-1")` + - `resourceGroupId` + - Type: string + - Description: IBM resource group ID + - Example query: `resourceGroupId in ("rg-1234")` + - `accountGroupId` + - Type: string + - Description: IBM account group ID + - Example query: `accountGroupId in ("ag-1234")` + - `accountGroupName` + - Type: string + - Description: IBM account group name + - Example query: `accountGroupName in ("my-group")` +- `oci`: + - `account` + - Type: string + - Description: OCI account ID + - Example query: `account in ("ocid1.tenancy.oc1..example")` + - `organization` + - Type: string + - Description: OCI organization ID + - Example query: `organization in ("1234567890")` + - `labels` + - Type: string + - Description: OCI account labels (legacy `rules` only) + - Example query: `labels in ("key: value")` + - `location` + - Type: string + - Description: OCI account location + - Example query: `location in ("us-ashburn-1")` + +**Note**: Whenever filtering for values with special characters, the values need to be encoded. +When `"` or `\` are the special characters, they need to be escaped with `\` and then encoded. ## Attributes Reference @@ -170,6 +459,15 @@ In addition to all arguments above, the following attributes are exported: - `last_modified_by` - (Computed) By whom is last modification made. - `last_updated` - (Computed) Timestamp of last modification of zone. +## How state is managed (drift prevention) + +When reading a zone from the API, the provider preserves the representation format from your configuration: + +- If your config uses `expression` blocks, the state stores expressions. +- If your config uses `rules`, the state stores the rules string. + +This prevents perpetual plan diffs when the backend returns both representations. On import (where there is no prior config), the state defaults to `rules`. + ## Import Zone can be imported using the ID, e.g. @@ -177,3 +475,5 @@ Zone can be imported using the ID, e.g. ``` $ terraform import sysdig_secure_zone.example 12345 ``` + +~> **Note:** Imported zones are always represented using the `rules` string in initial state. If your configuration uses `expression` blocks, the first `terraform plan` after import will show changes to converge to the expression-based representation. Apply once to align state with your config.