From 929b484e23cb1793d07e69e3d7f2c87180776819 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Wed, 20 Nov 2024 22:45:52 -0800 Subject: [PATCH] finalize and add tests --- go.mod | 7 +- go.sum | 2 + internal/api/automations.go | 195 ++++++++++++++--------- internal/api/automations_test.go | 259 +++++++++++++++++++++++++++++++ internal/client/automations.go | 6 +- 5 files changed, 390 insertions(+), 79 deletions(-) create mode 100644 internal/api/automations_test.go diff --git a/go.mod b/go.mod index 246299d2..449f40c0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module github.com/prefecthq/terraform-provider-prefect -go 1.22.0 +go 1.22.7 + toolchain go1.22.9 require ( @@ -15,6 +16,8 @@ require ( github.com/hashicorp/terraform-plugin-go v0.25.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.10.0 + github.com/stretchr/testify v1.9.0 + k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078 ) require ( @@ -30,6 +33,7 @@ require ( github.com/bgentry/speakeasy v0.1.0 // indirect github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect github.com/cloudflare/circl v1.3.7 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -63,6 +67,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect diff --git a/go.sum b/go.sum index 1a9a392d..4a2666ab 100644 --- a/go.sum +++ b/go.sum @@ -289,3 +289,5 @@ gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078 h1:jGnCPejIetjiy2gqaJ5V0NLwTpF4wbQ6cZIItJCSHno= +k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/internal/api/automations.go b/internal/api/automations.go index 01fd9b41..207fa3c3 100644 --- a/internal/api/automations.go +++ b/internal/api/automations.go @@ -2,6 +2,8 @@ package api import ( "context" + "encoding/json" + "fmt" "github.com/google/uuid" ) @@ -9,106 +11,147 @@ import ( // AutomationsClient is a client for working with automations. type AutomationsClient interface { Get(ctx context.Context, id uuid.UUID) (*Automation, error) - Create(ctx context.Context, data AutomationCreate) (*Automation, error) - Update(ctx context.Context, id uuid.UUID, data AutomationUpdate) error + Create(ctx context.Context, data AutomationUpsert) (*Automation, error) + Update(ctx context.Context, id uuid.UUID, data AutomationUpsert) error Delete(ctx context.Context, id uuid.UUID) error } -type TriggerTypes interface { - // No methods needed - this is just for type union +// Automation represents an automation response. +type Automation struct { + BaseModel + AutomationUpsert + AccountID uuid.UUID `json:"account_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} +type AutomationUpsert struct { + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Trigger Trigger `json:"trigger"` + Actions []Action `json:"actions"` + ActionsOnTrigger []Action `json:"actions_on_trigger"` + ActionsOnResolve []Action `json:"actions_on_resolve"` } -type TriggerBase struct { +type Trigger struct { Type string `json:"type"` - ID string `json:"id"` -} -type EventTrigger struct { - TriggerBase - After []string `json:"after"` - Expect []string `json:"expect"` - ForEach []string `json:"for_each"` - Posture string `json:"posture"` - Threshold int `json:"threshold"` - Within int `json:"within"` + // For EventTrigger + Match *ResourceSpecification `json:"match,omitempty"` + MatchRelated *ResourceSpecification `json:"match_related,omitempty"` + Posture *string `json:"posture,omitempty"` + After []string `json:"after,omitempty"` + Expect []string `json:"expect,omitempty"` + ForEach []string `json:"for_each,omitempty"` + Threshold *int `json:"threshold,omitempty"` + Within *string `json:"within,omitempty"` // Duration string + // For MetricTrigger + Metric *MetricTriggerQuery `json:"metric,omitempty"` + // For CompoundTrigger + Triggers []Trigger `json:"triggers,omitempty"` + Require interface{} `json:"require,omitempty"` // int or string ("any"/"all") } -// Ensure EventTrigger implements TriggerTypes. -var _ TriggerTypes = (*EventTrigger)(nil) +type MetricTriggerQuery struct { + Name string `json:"name"` + Threshold float64 `json:"threshold"` + Operator string `json:"operator"` // "<", "<=", ">", ">=" + Range int `json:"range"` + FiringFor int `json:"firing_for"` +} -type MetricTriggerOperator string +// ResourceSpecification is a composite type that returns a map +// where the keys are strings and the values +// can be either (1) a string or (2) a list of strings. +// +// ex: +// +// { +// "resource_type": "aws_s3_bucket", +// "tags": ["tag1", "tag2"] +// } +// +// This is used for the `match` and `match_related` fields. +type ResourceSpecification map[string]StringOrSlice + +type StringOrSlice struct { + String string + StringList []string + IsList bool +} -const ( - LT MetricTriggerOperator = "<" - LTE MetricTriggerOperator = "<=" - GT MetricTriggerOperator = ">" - GTE MetricTriggerOperator = ">=" -) +// For marshalling a ResourceSpecification to JSON. +func (s StringOrSlice) MarshalJSON() ([]byte, error) { + var val interface{} + val = s.StringList + if !s.IsList { + val = s.String + } -type PrefectMetric string + bytes, err := json.Marshal(val) + if err != nil { + return nil, fmt.Errorf("marshal string or slice: %w", err) + } -type MetricTriggerQuery struct { - Name PrefectMetric `json:"name"` - Threshold float64 `json:"threshold"` - Operator MetricTriggerOperator `json:"operator"` - Range int `json:"range"` // duration in seconds, min 300 - FiringFor int `json:"firing_for"` // duration in seconds, min 300 + return bytes, nil } -type MetricTrigger struct { - TriggerBase - Posture string `json:"posture"` - Metric MetricTriggerQuery `json:"metric"` -} +// For unmarshalling a ResourceSpecification from JSON. +func (s *StringOrSlice) UnmarshalJSON(data []byte) error { + // Try as string first + var str string + if err := json.Unmarshal(data, &str); err == nil { + s.String = str + s.IsList = false -// Ensure MetricTrigger implements TriggerTypes. -var _ TriggerTypes = (*MetricTrigger)(nil) + return nil + } -type CompoundTrigger struct { - TriggerBase - Triggers []TriggerTypes `json:"triggers"` - Require interface{} `json:"require"` // int or "any"/"all" - Within *int `json:"within,omitempty"` -} + // Try as string slice + var strList []string + if err := json.Unmarshal(data, &strList); err == nil { + s.StringList = strList + s.IsList = true -var _ TriggerTypes = (*CompoundTrigger)(nil) + return nil + } -type SequenceTrigger struct { - TriggerBase - Triggers []TriggerTypes `json:"triggers"` - Within *int `json:"within,omitempty"` + return fmt.Errorf("ResourceSpecification must be string or string array") } -// Ensure SequenceTrigger implements TriggerTypes. -var _ TriggerTypes = (*SequenceTrigger)(nil) +type Action struct { + // On all actions + Type string `json:"type"` -type Action struct{} + // On WorkPoolAction, WorkQueueAction, DeploymentAction, and AutomationAction + Source *string `json:"source,omitempty"` -type AutomationCore struct { - Name string `json:"name"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - Trigger TriggerTypes `json:"trigger"` - Actions []Action `json:"actions"` - ActionsOnTrigger []Action `json:"actions_on_trigger"` - ActionsOnResolve []Action `json:"actions_on_resolve"` -} + // DeploymentAction fields + DeploymentID *uuid.UUID `json:"deployment_id,omitempty"` -// Automation represents an automation response. -type Automation struct { - BaseModel - AutomationCore - AccountID uuid.UUID `json:"account"` - WorkspaceID uuid.UUID `json:"workspace"` -} + // WorkPoolAction fields + WorkPoolID *uuid.UUID `json:"work_pool_id,omitempty"` -// AutomationCreate is the payload for creating automations. -type AutomationCreate struct { - AutomationCore - OwnerResource *string `json:"owner_resource"` -} + // WorkQueueAction fields + WorkQueueID *uuid.UUID `json:"work_queue_id,omitempty"` + + // AutomationAction fields + AutomationID *uuid.UUID `json:"automation_id,omitempty"` + + // RunDeployment fields + Parameters map[string]interface{} `json:"parameters,omitempty"` + JobVariables map[string]interface{} `json:"job_variables,omitempty"` + + // ChangeFlowRunState fields + Name *string `json:"name,omitempty"` + State *string `json:"state,omitempty"` + Message *string `json:"message,omitempty"` + + // Webhook fields + BlockDocumentID *uuid.UUID `json:"block_document_id,omitempty"` + Payload *string `json:"payload,omitempty"` -// AutomationUpdate is the payload for updating automations. -type AutomationUpdate struct { - AutomationCore + // Notification fields + Subject *string `json:"subject,omitempty"` + Body *string `json:"body,omitempty"` } diff --git a/internal/api/automations_test.go b/internal/api/automations_test.go new file mode 100644 index 00000000..8849a38e --- /dev/null +++ b/internal/api/automations_test.go @@ -0,0 +1,259 @@ +package api_test + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/prefecthq/terraform-provider-prefect/internal/api" + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" +) + +func TestResourceSpecificationModel(t *testing.T) { + t.Parallel() + tests := []struct { + name string + json string + want api.ResourceSpecification + }{ + { + name: "Single String", + json: `{"prefect.resource.id":"flow-123"}`, + want: api.ResourceSpecification{"prefect.resource.id": api.StringOrSlice{String: "flow-123", IsList: false}}, + }, + { + name: "String List", + json: `{"prefect.resource.id":["flow-123","flow-456"]}`, + want: api.ResourceSpecification{"prefect.resource.id": api.StringOrSlice{StringList: []string{"flow-123", "flow-456"}, IsList: true}}, + }, + { + name: "Empty Dict", + json: `{}`, + want: api.ResourceSpecification{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got api.ResourceSpecification + err := json.Unmarshal([]byte(tt.json), &got) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + + bytes, err := json.Marshal(got) + assert.NoError(t, err) + assert.Equal(t, tt.json, string(bytes)) + }) + } +} + +func TestActionModel(t *testing.T) { + t.Parallel() + tests := []struct { + name string + json string + want api.Action + }{ + { + name: "DoNothing", + json: `{"type": "do-nothing"}`, + want: api.Action{Type: "do-nothing"}, + }, + { + name: "RunDeployment", + json: `{ + "type": "run-deployment", + "source": "selected", + "deployment_id": "123e4567-e89b-12d3-a456-426614174000", + "parameters": {"foo": "bar"}, + "job_variables": {"env": "prod"} + }`, + want: api.Action{ + Type: "run-deployment", + Source: ptr.To("selected"), + DeploymentID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + Parameters: map[string]interface{}{"foo": "bar"}, + JobVariables: map[string]interface{}{"env": "prod"}, + }, + }, + { + name: "PauseDeployment", + json: `{ + "type": "pause-deployment", + "source": "selected", + "deployment_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "pause-deployment", + Source: ptr.To("selected"), + DeploymentID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + { + name: "ResumeDeployment", + json: `{ + "type": "resume-deployment", + "source": "selected", + "deployment_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "resume-deployment", + Source: ptr.To("selected"), + DeploymentID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + { + name: "CancelFlowRun", + json: `{"type": "cancel-flow-run"}`, + want: api.Action{Type: "cancel-flow-run"}, + }, + { + name: "ChangeFlowRunState", + json: `{ + "type": "change-flow-run-state", + "name": "Failed", + "state": "FAILED", + "message": "Flow run failed" + }`, + want: api.Action{ + Type: "change-flow-run-state", + Name: ptr.To("Failed"), + State: ptr.To("FAILED"), + Message: ptr.To("Flow run failed"), + }, + }, + { + name: "PauseWorkQueue", + json: `{ + "type": "pause-work-queue", + "source": "selected", + "work_queue_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "pause-work-queue", + Source: ptr.To("selected"), + WorkQueueID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + { + name: "ResumeWorkQueue", + json: `{ + "type": "resume-work-queue", + "source": "selected", + "work_queue_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "resume-work-queue", + Source: ptr.To("selected"), + WorkQueueID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + { + name: "SendNotification", + json: `{ + "type": "send-notification", + "block_document_id": "123e4567-e89b-12d3-a456-426614174000", + "subject": "Alert", + "body": "Something happened" + }`, + want: api.Action{ + Type: "send-notification", + BlockDocumentID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + Subject: ptr.To("Alert"), + Body: ptr.To("Something happened"), + }, + }, + { + name: "CallWebhook", + json: `{ + "type": "call-webhook", + "block_document_id": "123e4567-e89b-12d3-a456-426614174000", + "payload": "{\"message\": \"test\"}" + }`, + want: api.Action{ + Type: "call-webhook", + BlockDocumentID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + Payload: ptr.To("{\"message\": \"test\"}"), + }, + }, + { + name: "PauseAutomation", + json: `{ + "type": "pause-automation", + "source": "selected", + "automation_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "pause-automation", + Source: ptr.To("selected"), + AutomationID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + { + name: "ResumeAutomation", + json: `{ + "type": "resume-automation", + "source": "selected", + "automation_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "resume-automation", + Source: ptr.To("selected"), + AutomationID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + { + name: "SuspendFlowRun", + json: `{"type": "suspend-flow-run"}`, + want: api.Action{Type: "suspend-flow-run"}, + }, + { + name: "ResumeFlowRun", + json: `{"type": "resume-flow-run"}`, + want: api.Action{Type: "resume-flow-run"}, + }, + { + name: "DeclareIncident", + json: `{"type": "declare-incident"}`, + want: api.Action{Type: "declare-incident"}, + }, + { + name: "PauseWorkPool", + json: `{ + "type": "pause-work-pool", + "source": "selected", + "work_pool_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "pause-work-pool", + Source: ptr.To("selected"), + WorkPoolID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + { + name: "ResumeWorkPool", + json: `{ + "type": "resume-work-pool", + "source": "selected", + "work_pool_id": "123e4567-e89b-12d3-a456-426614174000" + }`, + want: api.Action{ + Type: "resume-work-pool", + Source: ptr.To("selected"), + WorkPoolID: ptr.To(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got api.Action + err := json.Unmarshal([]byte(tt.json), &got) + assert.NoError(t, err) + assert.Equal(t, tt.want, got, "Expected %+v but got %+v", tt.want, got) + }) + } +} diff --git a/internal/client/automations.go b/internal/client/automations.go index 2167e81c..2f199153 100644 --- a/internal/client/automations.go +++ b/internal/client/automations.go @@ -13,6 +13,8 @@ import ( "github.com/prefecthq/terraform-provider-prefect/internal/provider/helpers" ) +var _ = api.AutomationsClient(&AutomationsClient{}) + type AutomationsClient struct { hc *http.Client apiKey string @@ -70,7 +72,7 @@ func (c *AutomationsClient) Get(ctx context.Context, id uuid.UUID) (*api.Automat return &automation, nil } -func (c *AutomationsClient) Create(ctx context.Context, payload api.AutomationCreate) (*api.Automation, error) { +func (c *AutomationsClient) Create(ctx context.Context, payload api.AutomationUpsert) (*api.Automation, error) { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(&payload); err != nil { return nil, fmt.Errorf("failed to encode create payload: %w", err) @@ -103,7 +105,7 @@ func (c *AutomationsClient) Create(ctx context.Context, payload api.AutomationCr return &automation, nil } -func (c *AutomationsClient) Update(ctx context.Context, id uuid.UUID, payload api.AutomationUpdate) error { +func (c *AutomationsClient) Update(ctx context.Context, id uuid.UUID, payload api.AutomationUpsert) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(&payload); err != nil { return fmt.Errorf("failed to encode update payload: %w", err)