diff --git a/docs/index.md b/docs/index.md index 5419deaa8..598700bf7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -225,6 +225,7 @@ See this [example](https://professional-service.git.onstackit.cloud/professional - `telemetrylink_custom_endpoint` (String) Custom endpoint for the Telemetry Link service - `telemetryrouter_custom_endpoint` (String) Custom endpoint for the Telemetry Router service - `token_custom_endpoint` (String) Custom endpoint for the token API, which is used to request access tokens when using the key flow +- `ufw_custom_endpoint` (String) Custom endpoint for the UFW service - `use_oidc` (Boolean) Enables OIDC for Authentication. This can also be sourced from the `STACKIT_USE_OIDC` Environment Variable. Defaults to `false`. - `valkey_custom_endpoint` (String) Custom endpoint for the Key Value Store service - `vpn_custom_endpoint` (String) Custom endpoint for the VPN service diff --git a/docs/resources/ufw_instance.md b/docs/resources/ufw_instance.md new file mode 100644 index 000000000..0334da2c4 --- /dev/null +++ b/docs/resources/ufw_instance.md @@ -0,0 +1,41 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_ufw_instance Resource - stackit" +subcategory: "" +description: |- + UFW Instance (Rule) resource schema. +--- + +# stackit_ufw_instance (Resource) + +UFW Instance (Rule) resource schema. + +## Example Usage + +```terraform +resource "stackit_ufw_instance" "rule" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + instance_id = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + product = "edge-cloud" + source_ip = "1.2.3.4/32" + type = "ACL" +} +``` + + +## Schema + +### Required + +- `instance_id` (String) The target service instance ID. +- `product` (String) The source service product (e.g. 'edge-cloud'). +- `project_id` (String) STACKIT Project ID associated with the rule. +- `region` (String) The resource region. +- `source_ip` (String) The source IP (CIDR) to which the rule applies. +- `type` (String) The type of the rule (e.g., 'ACL'). + +### Read-Only + +- `id` (String) Terraform internal resource identifier in format 'project_id,region,rule_id'. +- `rule_id` (String) The rule UUID. diff --git a/examples/resources/stackit_ufw_instance/resource.tf b/examples/resources/stackit_ufw_instance/resource.tf new file mode 100644 index 000000000..b35a8d48d --- /dev/null +++ b/examples/resources/stackit_ufw_instance/resource.tf @@ -0,0 +1,8 @@ +resource "stackit_ufw_instance" "rule" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + instance_id = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + product = "edge-cloud" + source_ip = "1.2.3.4/32" + type = "ACL" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 7e319920f..4d2f68fe9 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0 github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.5.1 github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.5.1 + github.com/stackitcloud/stackit-sdk-go/services/ufw v0.1.0 github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0 github.com/teambition/rrule-go v1.8.2 diff --git a/go.sum b/go.sum index 647cc3593..e86a17db2 100644 --- a/go.sum +++ b/go.sum @@ -233,6 +233,8 @@ github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.5.1 h1:iIF9cRmW github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.5.1/go.mod h1:hgw8janWmDfP2bnuZensxqcAePr49BX5ug8Rq85o+h8= github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.5.1 h1:WzpbI9wK3/xSplKOWpHg2FA8MYDwqO0jYGlDANe2cgY= github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.5.1/go.mod h1:WUmgKtwpe90Yq3YbgNxc2clTTULVxCu0ha6lMTjUnII= +github.com/stackitcloud/stackit-sdk-go/services/ufw v0.1.0 h1:LnDUxI7v507T341Smff/fVzSSRP59uIOUmcHAbUf1hE= +github.com/stackitcloud/stackit-sdk-go/services/ufw v0.1.0/go.mod h1:c/JxzGai01jCK/5OCi0TxCUiQ1Gl7SboGuErflWxLqQ= github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.0 h1:XC238ExUFeDHOlJzu4gcspsbcr5v70T/cuNuC/8tUZQ= github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.0/go.mod h1:etzt/a723p327dqha1V72cCoBWjUBS2DtHy3ZTac0SA= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0 h1:JpMJjWBa6fwNNcAHoq00v+8+DwMD0/fe4WPjvbHdY2o= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 6be2d30bd..28bf96d86 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -76,6 +76,7 @@ type ProviderData struct { ServiceAccountCustomEndpoint string TelemetryLinkCustomEndpoint string TelemetryRouterCustomEndpoint string + UfwCustomEndpoint string ValkeyCustomEndpoint string VpnCustomEndpoint string EnableBetaResources bool diff --git a/stackit/internal/services/ufw/instance/resource.go b/stackit/internal/services/ufw/instance/resource.go new file mode 100644 index 000000000..d55645747 --- /dev/null +++ b/stackit/internal/services/ufw/instance/resource.go @@ -0,0 +1,460 @@ +package instance + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + ufw "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api/wait" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ resource.Resource = &instanceResource{} + _ resource.ResourceWithConfigure = &instanceResource{} + _ resource.ResourceWithImportState = &instanceResource{} + _ resource.ResourceWithModifyPlan = &instanceResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` + RuleId types.String `tfsdk:"rule_id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + InstanceId types.String `tfsdk:"instance_id"` + Product types.String `tfsdk:"product"` + SourceIP types.String `tfsdk:"source_ip"` + Type types.String `tfsdk:"type"` +} + +func NewInstanceResource() resource.Resource { + return &instanceResource{} +} + +type instanceResource struct { + client ufw.DefaultAPI + providerData core.ProviderData +} + +func (r *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_ufw_instance" +} + +func (r *instanceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + r.providerData = providerData + + apiClient, err := ufw.NewAPIClient( + ufwUtilsConfigureOptions(&providerData)..., + ) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v", err)) + return + } + + r.client = apiClient.DefaultAPI + tflog.Info(ctx, "UFW instance client configured") +} + +func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "UFW Instance (Rule) resource schema.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform internal resource identifier in format 'project_id,region,rule_id'.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "rule_id": schema.StringAttribute{ + Description: "The rule UUID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT Project ID associated with the rule.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "The resource region.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: "The target service instance ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "product": schema.StringAttribute{ + Description: "The source service product (e.g. 'edge-cloud').", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "source_ip": schema.StringAttribute{ + Description: "The source IP (CIDR) to which the rule applies.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "type": schema.StringAttribute{ + Description: "The type of the rule (e.g., 'ACL').", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + + ctx = core.InitProviderContext(ctx) + + ctx, cancel := context.WithTimeout(ctx, core.DefaultOperationTimeout) + defer cancel() + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating UFW instance", fmt.Sprintf("Building payload: %v", err)) + return + } + + createResp, err := r.client.CreateRule(ctx, projectId, region).CreateRulePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating UFW instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + ruleId := createResp.GetRefId() + if ruleId == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating UFW instance", "API returned an empty rule ID") + return + } + + model.RuleId = types.StringValue(ruleId) + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + "rule_id": ruleId, + "id": utils.BuildInternalTerraformId(projectId, region, ruleId).ValueString(), + }) + if resp.Diagnostics.HasError() { + return + } + + ruleData, err := wait.CreateRuleWaitHandler(ctx, r.client, projectId, region, ruleId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating UFW instance", fmt.Sprintf("Waiting state: %v", err)) + return + } + + err = mapFields(ruleData, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating UFW instance", fmt.Sprintf("Mapping fields: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "UFW instance created") +} + +func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + ctx, cancel := context.WithTimeout(ctx, core.DefaultOperationTimeout) + defer cancel() + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + ruleId := model.RuleId.ValueString() + + if ruleId == "" { + resp.State.RemoveResource(ctx) + return + } + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "rule_id", ruleId) + + ruleData, err := r.client.GetRule(ctx, projectId, region, ruleId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading UFW instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ruleData, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading UFW instance", fmt.Sprintf("Mapping fields: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "UFW instance read") +} + +func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + ruleId := model.RuleId.ValueString() + + ctx = core.InitProviderContext(ctx) + + ctx, cancel := context.WithTimeout(ctx, core.DefaultOperationTimeout) + defer cancel() + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "rule_id", ruleId) + + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating UFW instance", fmt.Sprintf("Building payload: %v", err)) + return + } + + _, err = r.client.UpdateRule(ctx, projectId, region, ruleId).UpdateRulePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating UFW instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + ruleData, err := wait.UpdateRuleWaitHandler(ctx, r.client, projectId, region, ruleId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating UFW instance", fmt.Sprintf("Waiting state: %v", err)) + return + } + + err = mapFields(ruleData, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating UFW instance", fmt.Sprintf("Mapping fields: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "UFW instance updated") +} + +func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + ruleId := model.RuleId.ValueString() + + ctx = core.InitProviderContext(ctx) + + ctx, cancel := context.WithTimeout(ctx, core.DefaultOperationTimeout) + defer cancel() + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "rule_id", ruleId) + + _, err := r.client.DeleteRule(ctx, projectId, region, ruleId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting UFW instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + _, err = wait.DeleteRuleWaitHandler(ctx, r.client, projectId, region, ruleId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting UFW instance", fmt.Sprintf("Waiting state: %v", err)) + return + } + + tflog.Info(ctx, "UFW instance deleted") +} + +func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing UFW instance", + fmt.Sprintf("Expected import identifier format '[project_id],[region],[rule_id]', got %q", req.ID), + ) + return + } + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "rule_id": idParts[2], + }) + + tflog.Info(ctx, "UFW instance imported") +} + +func (r *instanceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel, planModel Model + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) +} + +func mapFields(ruleResp *ufw.RuleResponse, model *Model) error { + if ruleResp == nil { + return fmt.Errorf("response payload is nil") + } + if model == nil { + return fmt.Errorf("model pointer is nil") + } + + if ruleResp.HasRegion() { + model.Region = types.StringValue(ruleResp.GetRegion()) + } + + model.Id = utils.BuildInternalTerraformId( + model.ProjectId.ValueString(), + model.Region.ValueString(), + model.RuleId.ValueString(), + ) + + model.InstanceId = types.StringValue(ruleResp.InstanceId) + model.Product = types.StringValue(ruleResp.Product) + model.SourceIP = types.StringValue(ruleResp.SourceIP) + model.Type = types.StringValue(ruleResp.Type) + + return nil +} + +func toCreatePayload(model *Model) (*ufw.CreateRulePayload, error) { + if model == nil { + return nil, fmt.Errorf("model is nil") + } + + payload := ufw.NewCreateRulePayload( + model.InstanceId.ValueString(), + model.Product.ValueString(), + model.SourceIP.ValueString(), + model.Type.ValueString(), + ) + + return payload, nil +} + +func toUpdatePayload(model *Model) (*ufw.UpdateRulePayload, error) { + if model == nil { + return nil, fmt.Errorf("model is nil") + } + + payload := ufw.NewUpdateRulePayload(model.SourceIP.ValueString()) + + return payload, nil +} + +func ufwUtilsConfigureOptions(providerData *core.ProviderData) []config.ConfigurationOption { + options := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + + if providerData.UfwCustomEndpoint != "" { + options = append(options, config.WithEndpoint(providerData.UfwCustomEndpoint)) + } + + return options +} diff --git a/stackit/internal/services/ufw/instance/resource_test.go b/stackit/internal/services/ufw/instance/resource_test.go new file mode 100644 index 000000000..844c6fab2 --- /dev/null +++ b/stackit/internal/services/ufw/instance/resource_test.go @@ -0,0 +1,210 @@ +package instance_test + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stackitcloud/stackit-sdk-go/services/ufw/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +func TestUfwInstanceResource(t *testing.T) { + projectId := uuid.NewString() + ruleId := uuid.NewString() + region := "eu01" + + s := testutil.NewMockServer(t) + defer s.Server.Close() + + tfConfig := fmt.Sprintf(` + provider "stackit" { + ufw_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" + } + + resource "stackit_ufw_instance" "example" { + project_id = "%s" + region = "%s" + instance_id = "target-instance-123" + product = "edge-cloud" + source_ip = "192.168.0.0/24" + type = "ACL" + } + `, s.Server.URL, projectId, region) + + tfConfigUpdated := fmt.Sprintf(` + provider "stackit" { + ufw_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" + } + + resource "stackit_ufw_instance" "example" { + project_id = "%s" + region = "%s" + instance_id = "target-instance-123" + product = "edge-cloud" + source_ip = "10.0.0.0/8" + type = "ACL" + } + `, s.Server.URL, projectId, region) + + validRuleResponse := v1api.RuleResponse{ + Destination: "0.0.0.0/0", + InstanceId: "target-instance-123", + Product: "edge-cloud", + SourceIP: "192.168.0.0/24", + Status: v1api.RULERESPONSESTATUS_ACTIVE, + Type: "ACL", + } + + validRuleResponseUpdated := validRuleResponse + validRuleResponseUpdated.SourceIP = "10.0.0.0/8" + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "Create UFW Rule", + ToJsonBody: v1api.CreateRuleResponse{ + RefId: &ruleId, + }, + }, + testutil.MockResponse{ + Description: "Get UFW Rule (Create Waiter)", + ToJsonBody: validRuleResponse, + }, + testutil.MockResponse{ + Description: "Read UFW Rule", + ToJsonBody: validRuleResponse, + }, + ) + }, + Config: tfConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_ufw_instance.example", "project_id", projectId), + resource.TestCheckResourceAttr("stackit_ufw_instance.example", "source_ip", "192.168.0.0/24"), + resource.TestCheckResourceAttrSet("stackit_ufw_instance.example", "rule_id"), + ), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "Read UFW Rule (Plan)", + ToJsonBody: validRuleResponse, + }, + testutil.MockResponse{ + Description: "Delete UFW Rule (Replace)", + StatusCode: http.StatusAccepted, + }, + testutil.MockResponse{ + Description: "Get UFW Rule (Delete Waiter)", + StatusCode: http.StatusNotFound, + }, + testutil.MockResponse{ + Description: "Create UFW Rule (Replace)", + ToJsonBody: v1api.CreateRuleResponse{ + RefId: &ruleId, + }, + }, + testutil.MockResponse{ + Description: "Get UFW Rule (Create Waiter)", + ToJsonBody: validRuleResponseUpdated, + }, + testutil.MockResponse{ + Description: "Read UFW Rule (Post-Replace)", + ToJsonBody: validRuleResponseUpdated, + }, + testutil.MockResponse{ + Description: "Delete UFW Rule (Cleanup)", + StatusCode: http.StatusAccepted, + }, + testutil.MockResponse{ + Description: "Get UFW Rule (Delete Waiter)", + StatusCode: http.StatusNotFound, + }, + ) + }, + Config: tfConfigUpdated, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_ufw_instance.example", "source_ip", "10.0.0.0/8"), + ), + }, + }, + }) +} + +func TestUfwInstanceSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + ruleId = uuid.NewString() + ) + const region = "eu01" + s := testutil.NewMockServer(t) + defer s.Server.Close() + + tfConfig := fmt.Sprintf(` + provider "stackit" { + ufw_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" + } + + resource "stackit_ufw_instance" "example" { + project_id = "%s" + region = "%s" + instance_id = "target-instance-123" + product = "edge-cloud" + source_ip = "192.168.0.0/24" + type = "ACL" + } + `, s.Server.URL, projectId, region) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "Create UFW Rule", + ToJsonBody: &v1api.CreateRuleResponse{ + RefId: &ruleId, + }, + }, + testutil.MockResponse{Description: "Failing waiter", StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating .*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "Refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/regions/%s/rules/%s", projectId, region, ruleId) + if req.URL.Path != expected { + t.Errorf("unexpected URL path: got %s, want %s", req.URL.Path, expected) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "Delete UFW Rule", StatusCode: http.StatusAccepted}, + testutil.MockResponse{Description: "Delete Waiter", StatusCode: http.StatusNotFound}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading .*"), + }, + }, + }) +} diff --git a/stackit/internal/services/ufw/ufw_acc_test.go b/stackit/internal/services/ufw/ufw_acc_test.go new file mode 100644 index 000000000..95dfb1336 --- /dev/null +++ b/stackit/internal/services/ufw/ufw_acc_test.go @@ -0,0 +1,83 @@ +package ufw_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +func TestAccUfwInstanceResource(t *testing.T) { + projectId := testutil.ProjectId + + providerConfig := testutil.NewConfigBuilder().BuildProviderConfig() + + tfConfig := fmt.Sprintf(` + %s + + resource "stackit_edgecloud_instance" "target" { + project_id = "%s" + display_name = "edge" + plan_id = "4916c0e2-e719-445a-9920-58e491cd06c5" + description = "cats live on the edge" + region = "eu01" + } + + resource "stackit_ufw_instance" "example" { + project_id = "%s" + region = "eu01" + instance_id = stackit_edgecloud_instance.target.instance_id + product = "edge-cloud" + source_ip = "192.168.0.0/24" + type = "ACL" + } + `, providerConfig, projectId, projectId) + + tfConfigUpdated := fmt.Sprintf(` + %s + + resource "stackit_edgecloud_instance" "target" { + project_id = "%s" + display_name = "edge" + plan_id = "4916c0e2-e719-445a-9920-58e491cd06c5" + description = "cats live on the edge" + region = "eu01" + } + + resource "stackit_ufw_instance" "example" { + project_id = "%s" + region = "eu01" + instance_id = stackit_edgecloud_instance.target.instance_id + product = "edge-cloud" + source_ip = "10.0.0.0/8" + type = "ACL" + } + `, providerConfig, projectId, projectId) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tfConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_ufw_instance.example", "project_id", projectId), + resource.TestCheckResourceAttr("stackit_ufw_instance.example", "source_ip", "192.168.0.0/24"), + resource.TestCheckResourceAttrSet("stackit_ufw_instance.example", "rule_id"), + ), + }, + { + ResourceName: "stackit_ufw_instance.example", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: tfConfigUpdated, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_ufw_instance.example", "source_ip", "10.0.0.0/8"), + ), + }, + }, + }) +} diff --git a/stackit/provider.go b/stackit/provider.go index 59d1cee5d..27b378fab 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -140,6 +140,7 @@ import ( telemetryRouterAccessToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/accesstoken" telemetryRouterDestination "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/destination" telemetryRouterInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/instance" + ufwInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ufw/instance" valkeyCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/credential" valkeyInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/instance" vpnConnection "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/connection" @@ -227,6 +228,7 @@ type providerModel struct { TelemetryLinkCustomEndpoint types.String `tfsdk:"telemetrylink_custom_endpoint"` TelemetryRouterCustomEndpoint types.String `tfsdk:"telemetryrouter_custom_endpoint"` TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"` + UfwCustomEndpoint types.String `tfsdk:"ufw_custom_endpoint"` ValkeyCustomEndpoint types.String `tfsdk:"valkey_custom_endpoint"` VpnCustomEndpoint types.String `tfsdk:"vpn_custom_endpoint"` OIDCTokenRequestURL types.String `tfsdk:"oidc_request_url"` @@ -293,6 +295,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "telemetrylink_custom_endpoint": "Custom endpoint for the Telemetry Link service", "telemetryrouter_custom_endpoint": "Custom endpoint for the Telemetry Router service", "token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow", + "ufw_custom_endpoint": "Custom endpoint for the UFW service", "valkey_custom_endpoint": "Custom endpoint for the Key Value Store service", "vpn_custom_endpoint": "Custom endpoint for the VPN service", "enable_beta_resources": "Enable beta resources. Default is false.", @@ -522,6 +525,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["telemetrylink_custom_endpoint"], }, + "ufw_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["ufw_custom_endpoint"], + }, "valkey_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["valkey_custom_endpoint"], @@ -622,6 +629,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.SqlServerFlexCustomEndpoint, func(v string) { providerData.SQLServerFlexCustomEndpoint = v }) setStringField(providerConfig.TelemetryRouterCustomEndpoint, func(v string) { providerData.TelemetryRouterCustomEndpoint = v }) setStringField(providerConfig.TelemetryLinkCustomEndpoint, func(v string) { providerData.TelemetryLinkCustomEndpoint = v }) + setStringField(providerConfig.UfwCustomEndpoint, func(v string) { providerData.UfwCustomEndpoint = v }) setStringField(providerConfig.ValkeyCustomEndpoint, func(v string) { providerData.ValkeyCustomEndpoint = v }) setStringField(providerConfig.VpnCustomEndpoint, func(v string) { providerData.VpnCustomEndpoint = v }) @@ -921,6 +929,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { telemetryRouterInstance.NewTelemetryRouterInstanceResource, telemetryRouterDestination.NewTelemetryRouterDestinationResource, telemetryLink.NewTelemetryLinkResource, + ufwInstance.NewInstanceResource, valkeyInstance.NewInstanceResource, valkeyCredential.NewCredentialResource, vpnConnection.NewVpnConnectionResource,