diff --git a/docs/data-sources/logs_instance.md b/docs/data-sources/logs_instance.md new file mode 100644 index 000000000..a842ae0ea --- /dev/null +++ b/docs/data-sources/logs_instance.md @@ -0,0 +1,43 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_logs_instance Data Source - stackit" +subcategory: "" +description: |- + Logs instance resource schema. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_logs_instance (Data Source) + +Logs instance resource schema. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + + + + +## Schema + +### Required + +- `instance_id` (String) The Logs instance ID +- `project_id` (String) STACKIT project ID associated with the logs instance + +### Optional + +- `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. + +### Read-Only + +- `acl` (List of String) ACL entries for the logs instance +- `created` (String) Time when the distribution was created +- `datasource_url` (String) Logs instance datasource URL, can be used in Grafana as datasource URL +- `description` (String) The description of the Logs instance +- `display_name` (String) The displayed name of the Logs instance +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`instance_id`". +- `ingest_otlp_url` (String) The Logs instance's ingest logs via OTLP URL +- `ingest_url` (String) The logs instance's ingest logs URL +- `query_range_url` (String) The Logs instance's query range URL +- `query_url` (String) The Logs instance's query URL +- `retention_days` (Number) The log retention time in days +- `status` (String) The status of the Logs instance diff --git a/docs/index.md b/docs/index.md index ce090ead6..800086b3b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -165,6 +165,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `kms_custom_endpoint` (String) Custom endpoint for the KMS service - `loadbalancer_custom_endpoint` (String) Custom endpoint for the Load Balancer service - `logme_custom_endpoint` (String) Custom endpoint for the LogMe service +- `logs_custom_endpoint` (String) Custom endpoint for the Logs service - `mariadb_custom_endpoint` (String) Custom endpoint for the MariaDB service - `modelserving_custom_endpoint` (String) Custom endpoint for the AI Model Serving service - `mongodbflex_custom_endpoint` (String) Custom endpoint for the MongoDB Flex service diff --git a/docs/resources/logs_instance.md b/docs/resources/logs_instance.md new file mode 100644 index 000000000..bc4e1deb4 --- /dev/null +++ b/docs/resources/logs_instance.md @@ -0,0 +1,43 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_logs_instance Resource - stackit" +subcategory: "" +description: |- + Logs instance resource schema. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_logs_instance (Resource) + +Logs instance resource schema. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + + + + +## Schema + +### Required + +- `display_name` (String) The displayed name of the Logs instance +- `project_id` (String) STACKIT project ID associated with the logs instance +- `retention_days` (Number) The log retention time in days + +### Optional + +- `acl` (List of String) ACL entries for the logs instance +- `description` (String) The description of the Logs instance +- `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. + +### Read-Only + +- `created` (String) Time when the distribution was created +- `datasource_url` (String) Logs instance datasource URL, can be used in Grafana as datasource URL +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`instance_id`". +- `ingest_otlp_url` (String) The Logs instance's ingest logs via OTLP URL +- `ingest_url` (String) The logs instance's ingest logs URL +- `instance_id` (String) The Logs instance ID +- `query_range_url` (String) The Logs instance's query range URL +- `query_url` (String) The Logs instance's query URL +- `status` (String) The status of the Logs instance diff --git a/examples/data-sources/stackit_logs/data-source.tf b/examples/data-sources/stackit_logs/data-source.tf new file mode 100644 index 000000000..42ac2b635 --- /dev/null +++ b/examples/data-sources/stackit_logs/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_logs_instance" "logs" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/resources/stackit_logs/resource.tf b/examples/resources/stackit_logs/resource.tf new file mode 100644 index 000000000..38380f909 --- /dev/null +++ b/examples/resources/stackit_logs/resource.tf @@ -0,0 +1,23 @@ +resource "stackit_logs_instance" "git" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "logs-instance-example" + retention_days = 30 +} + +resource "stackit_logs_instance" "logs" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "logs-instance-example" + retention_days = 30 + acl = [ + "0.0.0.0/0" + ] + description = "Example description" +} + +# Only use the import statement, if you want to import an existing git resource +import { + to = stackit_logs_instance.import-example + id = "${var.project_id},${var.region},${var.logs_instance_id}" +} diff --git a/go.mod b/go.mod index 1f989b4d8..407eb7fff 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.29.0 github.com/hashicorp/terraform-plugin-log v0.10.0 github.com/hashicorp/terraform-plugin-testing v1.14.0 - github.com/stackitcloud/stackit-sdk-go/core v0.20.0 + github.com/stackitcloud/stackit-sdk-go/core v0.20.1 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1 github.com/stackitcloud/stackit-sdk-go/services/git v0.8.0 @@ -20,6 +20,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/kms v1.0.0 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.1 + github.com/stackitcloud/stackit-sdk-go/services/logs v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.1 github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.6.0 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.2 diff --git a/go.sum b/go.sum index 1384240bf..f771f6ac5 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/stackitcloud/stackit-sdk-go/core v0.20.0 h1:4rrUk6uT1g4nOn5/g1uXukP07Tux/o5xbMz/f/qE1rY= -github.com/stackitcloud/stackit-sdk-go/core v0.20.0/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ= +github.com/stackitcloud/stackit-sdk-go/core v0.20.1 h1:odiuhhRXmxvEvnVTeZSN9u98edvw2Cd3DcnkepncP3M= +github.com/stackitcloud/stackit-sdk-go/core v0.20.1/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0 h1:7ZKd3b+E/R4TEVShLTXxx5FrsuDuJBOyuVOuKTMa4mo= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0/go.mod h1:/FoXa6hF77Gv8brrvLBCKa5ie1Xy9xn39yfHwaln9Tw= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0 h1:Q+qIdejeMsYMkbtVoI9BpGlKGdSVFRBhH/zj44SP8TM= @@ -169,6 +169,8 @@ github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0 h1:q33ZaCBVE github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0/go.mod h1:20QOZ3rBC9wTGgzXzLz9M6YheX0VaxWE0/JI+s8On7k= github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.1 h1:hv5WrRU9rN6Jx4OwdOGJRyaQrfA9p1tzEoQK6/CDyoA= github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.1/go.mod h1:ivt8lvnAoBZsde2jSAuicyn6RgTmHvvNAJ3whaUbAD4= +github.com/stackitcloud/stackit-sdk-go/services/logs v0.3.0 h1:N1gerABK2vH7/PBkxZeaWYJ7dz3rjeCHuto+FAuGx3w= +github.com/stackitcloud/stackit-sdk-go/services/logs v0.3.0/go.mod h1:m4IjH1/RtJOF072kjAB0E/ejoIc++myrKmIahphfO6Q= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.1 h1:Db/ebOL2vbpIeh5XB2Ews2B9Lj5DJlMWIEJh60FfZ4Y= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.1/go.mod h1:8jdN4v2euK3f9gfdzbRi8e4nBJ8g/Q5YF9aPB4M4fCQ= github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.6.0 h1:JZI+3sLVAtTFk4QJ/ao2bAumzBq+iV6dUvDoIrOKTcw= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index e3dd02e0f..f8a391fc7 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -51,6 +51,7 @@ type ProviderData struct { KMSCustomEndpoint string LoadBalancerCustomEndpoint string LogMeCustomEndpoint string + LogsCustomEndpoint string MariaDBCustomEndpoint string MongoDBFlexCustomEndpoint string ModelServingCustomEndpoint string diff --git a/stackit/internal/services/logs/instance/datasource.go b/stackit/internal/services/logs/instance/datasource.go new file mode 100644 index 000000000..f369113b9 --- /dev/null +++ b/stackit/internal/services/logs/instance/datasource.go @@ -0,0 +1,167 @@ +package instance + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "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/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/logs" + "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/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logs/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ datasource.DataSource = &logsInstanceDataSource{} +) + +func NewLogsInstanceDataSource() datasource.DataSource { + return &logsInstanceDataSource{} +} + +type logsInstanceDataSource struct { + client *logs.APIClient +} + +func (d *logsInstanceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_logs_instance" +} + +func (d *logsInstanceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := utils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + d.client = apiClient + tflog.Info(ctx, "Logs client configured") +} + +func (d *logsInstanceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Logs instance resource schema.", core.Resource), + Description: fmt.Sprintf("Logs instance resource schema. %s", core.ResourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: schemaDescriptions["id"], + Computed: true, + }, + "instance_id": schema.StringAttribute{ + Description: schemaDescriptions["instance_id"], + Required: true, + Validators: []validator.String{validate.UUID()}, + }, + "region": schema.StringAttribute{ + Description: schemaDescriptions["region"], + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: schemaDescriptions["project_id"], + Required: true, + }, + "acl": schema.ListAttribute{ + Description: schemaDescriptions["acl"], + ElementType: types.StringType, + Computed: true, + }, + "created": schema.StringAttribute{ + Description: schemaDescriptions["created"], + Computed: true, + }, + "datasource_url": schema.StringAttribute{ + Description: schemaDescriptions["datasource_url"], + Computed: true, + }, + "description": schema.StringAttribute{ + Description: schemaDescriptions["description"], + Computed: true, + }, + "display_name": schema.StringAttribute{ + Description: schemaDescriptions["display_name"], + Computed: true, + }, + "ingest_otlp_url": schema.StringAttribute{ + Description: schemaDescriptions["ingest_otlp_url"], + Computed: true, + }, + "ingest_url": schema.StringAttribute{ + Description: schemaDescriptions["ingest_url"], + Computed: true, + }, + "query_range_url": schema.StringAttribute{ + Description: schemaDescriptions["query_range_url"], + Computed: true, + }, + "query_url": schema.StringAttribute{ + Description: schemaDescriptions["query_url"], + Computed: true, + }, + "retention_days": schema.Int64Attribute{ + Description: schemaDescriptions["retention_days"], + Computed: true, + }, + "status": schema.StringAttribute{ + Description: schemaDescriptions["status"], + Computed: true, + }, + }, + } +} + +func (d *logsInstanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectID := model.ProjectID.ValueString() + region := model.Region.ValueString() + instanceID := model.InstanceID.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectID) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceID) + + instanceResponse, err := d.client.GetLogsInstance(ctx, projectID, region, instanceID).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading logs instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, instanceResponse, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading logs instance", fmt.Sprintf("Processing response: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Logs Instance read") +} diff --git a/stackit/internal/services/logs/instance/resource.go b/stackit/internal/services/logs/instance/resource.go new file mode 100644 index 000000000..e26362bbd --- /dev/null +++ b/stackit/internal/services/logs/instance/resource.go @@ -0,0 +1,503 @@ +package instance + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "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/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/logs" + "github.com/stackitcloud/stackit-sdk-go/services/logs/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/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logs/utils" + tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ resource.Resource = &logsInstanceResource{} + _ resource.ResourceWithConfigure = &logsInstanceResource{} + _ resource.ResourceWithImportState = &logsInstanceResource{} + _ resource.ResourceWithModifyPlan = &logsInstanceResource{} +) + +var schemaDescriptions = map[string]string{ + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`instance_id`\".", + "instance_id": "The Logs instance ID", + "region": "STACKIT region name the resource is located in. If not defined, the provider region is used.", + "project_id": "STACKIT project ID associated with the logs instance", + "acl": "ACL entries for the logs instance", + "created": "Time when the distribution was created", + "datasource_url": "Logs instance datasource URL, can be used in Grafana as datasource URL", + "description": "The description of the Logs instance", + "display_name": "The displayed name of the Logs instance", + "ingest_otlp_url": "The Logs instance's ingest logs via OTLP URL", + "ingest_url": "The logs instance's ingest logs URL", + "query_range_url": "The Logs instance's query range URL", + "query_url": "The Logs instance's query URL", + "retention_days": "The log retention time in days", + "status": "The status of the Logs instance", +} + +type Model struct { + ID types.String `tfsdk:"id"` // Required by Terraform + InstanceID types.String `tfsdk:"instance_id"` // The Logs instance ID + Region types.String `tfsdk:"region"` // STACKIT region name the resource is located in + ProjectID types.String `tfsdk:"project_id"` // ProjectID associated with the logs instance + ACL types.List `tfsdk:"acl"` // ACL entries for the logs instance + Created types.String `tfsdk:"created"` // When the instance was created + DatasourceURL types.String `tfsdk:"datasource_url"` // Logs instance datasource URL, can be used in Grafana as datasource URL + Description types.String `tfsdk:"description"` // The description of the Logs instance + DisplayName types.String `tfsdk:"display_name"` // The displayed name of the Logs instance + IngestOTLPURL types.String `tfsdk:"ingest_otlp_url"` // The Logs instance's ingest logs via OTLP URL + IngestURL types.String `tfsdk:"ingest_url"` // The logs instance's ingest logs URL + QueryRangeURL types.String `tfsdk:"query_range_url"` // The Logs instance's query range URL + QueryURL types.String `tfsdk:"query_url"` // The Logs instance's query URL + RetentionDays types.Int64 `tfsdk:"retention_days"` // The log retention time in days + Status types.String `tfsdk:"status"` // The status of the Logs instance +} + +type logsInstanceResource struct { + client *logs.APIClient + providerData core.ProviderData +} + +func NewLogsInstanceResource() resource.Resource { + return &logsInstanceResource{} +} + +func (r *logsInstanceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_logs_instance", "resource") + if resp.Diagnostics.HasError() { + return + } + + apiClient := utils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + r.providerData = providerData + tflog.Info(ctx, "Logs client configured") +} + +func (r *logsInstanceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + tfutils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *logsInstanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_logs_instance" +} + +func (r *logsInstanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Logs instance resource schema.", core.Resource), + Description: fmt.Sprintf("Logs instance resource schema. %s", core.ResourceRegionFallbackDocstring), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: schemaDescriptions["id"], + Computed: true, + }, + "instance_id": schema.StringAttribute{ + Description: schemaDescriptions["instance_id"], + Computed: true, + Validators: []validator.String{validate.UUID()}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Description: schemaDescriptions["region"], + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "project_id": schema.StringAttribute{ + Description: schemaDescriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "acl": schema.ListAttribute{ + Description: schemaDescriptions["acl"], + ElementType: types.StringType, + Optional: true, + }, + "created": schema.StringAttribute{ + Description: schemaDescriptions["created"], + Computed: true, + }, + "datasource_url": schema.StringAttribute{ + Description: schemaDescriptions["datasource_url"], + Computed: true, + }, + "description": schema.StringAttribute{ + Description: schemaDescriptions["description"], + Optional: true, + }, + "display_name": schema.StringAttribute{ + Description: schemaDescriptions["display_name"], + Required: true, + }, + "ingest_otlp_url": schema.StringAttribute{ + Description: schemaDescriptions["ingest_otlp_url"], + Computed: true, + }, + "ingest_url": schema.StringAttribute{ + Description: schemaDescriptions["ingest_url"], + Computed: true, + }, + "query_range_url": schema.StringAttribute{ + Description: schemaDescriptions["query_range_url"], + Computed: true, + }, + "query_url": schema.StringAttribute{ + Description: schemaDescriptions["query_url"], + Computed: true, + }, + "retention_days": schema.Int64Attribute{ + Description: schemaDescriptions["retention_days"], + Required: true, + }, + "status": schema.StringAttribute{ + Description: schemaDescriptions["status"], + Computed: true, + }, + }, + } +} + +func (r *logsInstanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectID.ValueString() + region := model.Region.ValueString() + 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 Logs Instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + regionId := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "region", regionId) + createResp, err := r.client.CreateLogsInstance(ctx, projectId, regionId).CreateLogsInstancePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Logs Instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + waitResp, err := wait.CreateLogsInstanceWaitHandler(ctx, r.client, projectId, regionId, *createResp.Id).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Logs Instance", fmt.Sprintf("Waiting for Logs Instance to become active: %v", err)) + return + } + + err = mapFields(ctx, waitResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Logs Instance", fmt.Sprintf("Processing response: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Logs instance created") +} + +func (r *logsInstanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectID := model.ProjectID.ValueString() + region := model.Region.ValueString() + instanceID := model.InstanceID.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectID) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceID) + + instanceResponse, err := r.client.GetLogsInstance(ctx, projectID, region, instanceID).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading logs instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, instanceResponse, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading logs instance", fmt.Sprintf("Processing response: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Logs Instance read") +} + +func (r *logsInstanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectID := model.ProjectID.ValueString() + region := model.Region.ValueString() + instanceID := model.InstanceID.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectID) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceID) + + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Logs Instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + updateResp, err := r.client.UpdateLogsInstance(ctx, projectID, region, instanceID).UpdateLogsInstancePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Logs Instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, updateResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Logs Instance", fmt.Sprintf("Processing response: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Logs Instance updated") +} + +func (r *logsInstanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectID := model.ProjectID.ValueString() + region := model.Region.ValueString() + instanceID := model.InstanceID.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectID) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceID) + + err := r.client.DeleteLogsInstance(ctx, projectID, region, instanceID).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Logs Instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + _, err = wait.DeleteLogsInstanceWaitHandler(ctx, r.client, projectID, region, instanceID).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Logs Instance", fmt.Sprintf("Waiting for Logs Instance to be deleted: %v", err)) + return + } + + tflog.Info(ctx, "Logs Instance deleted") +} + +func (r *logsInstanceResource) 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 Logs Instance", fmt.Sprintf("Invalid import ID %q: expected format is `project_id`,`region`,`instance_id`", req.ID)) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[2])...) + tflog.Info(ctx, "Logs Instance state imported") +} + +func toCreatePayload(model *Model) (*logs.CreateLogsInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("missing model") + } + + var acls []string + for i, acl := range model.ACL.Elements() { + aclString, ok := acl.(types.String) + if !ok { + return nil, fmt.Errorf("expected acl at index %d to be of type %T, got %T", i, types.String{}, acl) + } + acls = append(acls, aclString.ValueString()) + } + var payloadACLs *[]string + if len(acls) > 0 { + payloadACLs = &acls + } + + payload := &logs.CreateLogsInstancePayload{ + Acl: payloadACLs, + Description: conversion.StringValueToPointer(model.Description), + DisplayName: conversion.StringValueToPointer(model.DisplayName), + RetentionDays: conversion.Int64ValueToPointer(model.RetentionDays), + } + + return payload, nil +} + +func mapFields(ctx context.Context, instance *logs.LogsInstance, model *Model) error { + if instance == nil { + return fmt.Errorf("instance is nil") + } + if model == nil { + return fmt.Errorf("model is nil") + } + if instance.Status == nil { + return fmt.Errorf("instance status is nil") + } + if instance.Created == nil { + return fmt.Errorf("instance created is nil") + } + var instanceID string + if model.InstanceID.ValueString() != "" { + instanceID = model.InstanceID.ValueString() + } else if instance.Id != nil { + instanceID = *instance.Id + } else { + return fmt.Errorf("instance id not present") + } + + aclList := types.ListNull(types.StringType) + var diags diag.Diagnostics + if instance.Acl != nil && len(*instance.Acl) > 0 { + aclList, diags = types.ListValueFrom(ctx, types.StringType, instance.Acl) + if diags.HasError() { + return fmt.Errorf("mapping ACL: %w", core.DiagsToError(diags)) + } + } + + model.ID = tfutils.BuildInternalTerraformId(model.ProjectID.ValueString(), model.Region.ValueString(), instanceID) + model.InstanceID = types.StringValue(instanceID) + model.ACL = aclList + model.Created = types.StringValue(instance.Created.String()) + model.DatasourceURL = types.StringPointerValue(instance.DatasourceUrl) + model.Description = types.StringPointerValue(instance.Description) + model.DisplayName = types.StringPointerValue(instance.DisplayName) + model.IngestOTLPURL = types.StringPointerValue(instance.IngestOtlpUrl) + model.IngestURL = types.StringPointerValue(instance.IngestUrl) + model.QueryRangeURL = types.StringPointerValue(instance.QueryRangeUrl) + model.QueryURL = types.StringPointerValue(instance.QueryUrl) + model.RetentionDays = types.Int64PointerValue(instance.RetentionDays) + model.Status = types.StringValue(string(*instance.Status)) + + return nil +} + +func toUpdatePayload(model *Model) (*logs.UpdateLogsInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("missing model") + } + + var acls []string + for i, acl := range model.ACL.Elements() { + aclString, ok := acl.(types.String) + if !ok { + return nil, fmt.Errorf("expected acl at index %d to be of type %T, got %T", i, types.String{}, acl) + } + acls = append(acls, aclString.ValueString()) + } + var payloadACLs *[]string + if len(acls) > 0 { + payloadACLs = &acls + } + + payload := &logs.UpdateLogsInstancePayload{ + Acl: payloadACLs, + Description: conversion.StringValueToPointer(model.Description), + DisplayName: conversion.StringValueToPointer(model.DisplayName), + RetentionDays: conversion.Int64ValueToPointer(model.RetentionDays), + } + + return payload, nil +} diff --git a/stackit/internal/services/logs/instance/resource_test.go b/stackit/internal/services/logs/instance/resource_test.go new file mode 100644 index 000000000..789d4cf44 --- /dev/null +++ b/stackit/internal/services/logs/instance/resource_test.go @@ -0,0 +1,240 @@ +package instance + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/logs" +) + +var testTime = time.Now() + +func fixtureInstance(mods ...func(instance *logs.LogsInstance)) *logs.LogsInstance { + instance := &logs.LogsInstance{ + Id: utils.Ptr("iid"), + Created: utils.Ptr(testTime), + Status: utils.Ptr(logs.LOGSINSTANCESTATUS_ACTIVE), + } + for _, mod := range mods { + mod(instance) + } + return instance +} + +func fixtureModel(mods ...func(model *Model)) *Model { + model := &Model{ + ID: types.StringValue("pid,rid,iid"), + InstanceID: types.StringValue("iid"), + Region: types.StringValue("rid"), + ProjectID: types.StringValue("pid"), + ACL: types.ListNull(types.StringType), + Created: types.StringValue(testTime.String()), + DatasourceURL: types.String{}, + Description: types.String{}, + DisplayName: types.String{}, + IngestOTLPURL: types.String{}, + IngestURL: types.String{}, + QueryRangeURL: types.String{}, + QueryURL: types.String{}, + RetentionDays: types.Int64{}, + Status: types.StringValue(string(logs.LOGSINSTANCESTATUS_ACTIVE)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *logs.LogsInstance + expected *Model + wantErr bool + }{ + { + description: "min values", + input: fixtureInstance(), + expected: fixtureModel(), + }, + { + description: "max values", + input: fixtureInstance(func(instance *logs.LogsInstance) { + instance.Acl = &[]string{"acl-entry-1", "acl-entry-2"} + instance.DatasourceUrl = utils.Ptr("datasource-url") + instance.Description = utils.Ptr("description") + instance.DisplayName = utils.Ptr("display-name") + instance.IngestOtlpUrl = utils.Ptr("ingest-otlp-url") + instance.IngestUrl = utils.Ptr("ingest-url") + instance.QueryRangeUrl = utils.Ptr("query-range-url") + instance.QueryUrl = utils.Ptr("query-url") + instance.RetentionDays = utils.Ptr(int64(7)) + }), + expected: fixtureModel(func(model *Model) { + model.ACL = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("acl-entry-1"), + types.StringValue("acl-entry-2"), + }) + model.DatasourceURL = types.StringValue("datasource-url") + model.Description = types.StringValue("description") + model.DisplayName = types.StringValue("display-name") + model.IngestOTLPURL = types.StringValue("ingest-otlp-url") + model.IngestURL = types.StringValue("ingest-url") + model.QueryRangeURL = types.StringValue("query-range-url") + model.QueryURL = types.StringValue("query-url") + model.RetentionDays = types.Int64Value(7) + }), + }, + { + description: "nil input", + wantErr: true, + expected: fixtureModel(), + }, + { + description: "nil status", + input: fixtureInstance(func(instance *logs.LogsInstance) { + instance.Status = nil + }), + expected: fixtureModel(), + wantErr: true, + }, + { + description: "nil created", + input: fixtureInstance(func(instance *logs.LogsInstance) { + instance.Created = nil + }), + expected: fixtureModel(), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectID: tt.expected.ProjectID, + Region: tt.expected.Region, + } + err := mapFields(context.Background(), tt.input, state) + if tt.wantErr && err == nil { + t.Fatalf("Should have failed") + } + if !tt.wantErr && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if !tt.wantErr { + diff := cmp.Diff(state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + model *Model + expected *logs.CreateLogsInstancePayload + wantErrMessage string + }{ + { + description: "min values", + model: fixtureModel(), + expected: &logs.CreateLogsInstancePayload{}, + }, + { + description: "max values", + model: fixtureModel(func(model *Model) { + model.ACL = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("acl-entry-1"), + types.StringValue("acl-entry-2"), + }) + model.Description = types.StringValue("description") + model.DisplayName = types.StringValue("display-name") + model.RetentionDays = types.Int64Value(7) + }), + expected: &logs.CreateLogsInstancePayload{ + Acl: &[]string{"acl-entry-1", "acl-entry-2"}, + Description: utils.Ptr("description"), + DisplayName: utils.Ptr("display-name"), + RetentionDays: utils.Ptr(int64(7)), + }, + }, + { + description: "nil model", + wantErrMessage: "missing model", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := toCreatePayload(tt.model) + if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { + t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) + } + if tt.wantErrMessage == "" && err != nil { + t.Fatalf("Unexpected error: %v", err) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Fatalf("Payload does not match: %s", diff) + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + model *Model + expected *logs.UpdateLogsInstancePayload + wantErrMessage string + }{ + { + description: "min values", + model: fixtureModel(), + expected: &logs.UpdateLogsInstancePayload{}, + }, + { + description: "max values", + model: fixtureModel(func(model *Model) { + model.ACL = types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("acl-entry-1"), + types.StringValue("acl-entry-2"), + }) + model.Description = types.StringValue("description") + model.DisplayName = types.StringValue("display-name") + model.RetentionDays = types.Int64Value(7) + }), + expected: &logs.UpdateLogsInstancePayload{ + Acl: &[]string{"acl-entry-1", "acl-entry-2"}, + Description: utils.Ptr("description"), + DisplayName: utils.Ptr("display-name"), + RetentionDays: utils.Ptr(int64(7)), + }, + }, + { + description: "nil model", + wantErrMessage: "missing model", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := toUpdatePayload(tt.model) + if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { + t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) + } + if tt.wantErrMessage == "" && err != nil { + t.Fatalf("Unexpected error: %v", err) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Fatalf("Payload does not match: %s", diff) + } + }) + } +} diff --git a/stackit/internal/services/logs/logs_acc_test.go b/stackit/internal/services/logs/logs_acc_test.go new file mode 100644 index 000000000..72a9bd5a8 --- /dev/null +++ b/stackit/internal/services/logs/logs_acc_test.go @@ -0,0 +1,373 @@ +package logs_test + +import ( + "context" + _ "embed" + "fmt" + "maps" + "slices" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + coreConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/logs" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +//go:embed testdata/resource-min.tf +var resourceMin string + +//go:embed testdata/resource-max.tf +var resourceMax string + +var testConfigVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "display_name": config.StringVariable("tf-acc-test-logs-min"), + "retention_days": config.IntegerVariable(7), +} + +func testConfigVarsMinUpdated() config.Variables { + newVars := make(config.Variables, len(testConfigVarsMin)) + maps.Copy(newVars, testConfigVarsMin) + newVars["display_name"] = config.StringVariable("tf-acc-test-logs-updated") + newVars["retention_days"] = config.IntegerVariable(14) + return newVars +} + +var testConfigVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "display_name": config.StringVariable("tf-acc-test-logs-max"), + "retention_days": config.IntegerVariable(7), + "acl": config.StringVariable("192.168.0.1/24"), + "description": config.StringVariable("Terraform Acceptance Test Logs Instance"), +} + +func testConfigVarsMaxUpdated() config.Variables { + newVars := make(config.Variables, len(testConfigVarsMin)) + maps.Copy(newVars, testConfigVarsMin) + newVars["display_name"] = config.StringVariable("tf-acc-test-logs-updated") + newVars["retention_days"] = config.IntegerVariable(14) + newVars["acl"] = config.StringVariable("192.168.0.1/16") + newVars["description"] = config.StringVariable("Terraform Acceptance Test Logs Instance Updated") + return newVars +} + +func TestAccLogsInstanceMin(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckLogsInstanceDestroy, + Steps: []resource.TestStep{ + // Create + { + ConfigVariables: testConfigVarsMin, + Config: testutil.LogsProviderConfig() + resourceMin, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "region", testutil.ConvertConfigVariable(testConfigVarsMin["region"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "display_name", testutil.ConvertConfigVariable(testConfigVarsMin["display_name"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "retention_days", testutil.ConvertConfigVariable(testConfigVarsMin["retention_days"])), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "created"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "datasource_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_otlp_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_range_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "status"), + ), + }, + // Datasource + { + ConfigVariables: testConfigVarsMin, + Config: testutil.LogsProviderConfig() + resourceMin + ` +data "stackit_logs_instance" "logs" { + project_id = stackit_logs_instance.logs.project_id + region = stackit_logs_instance.logs.region + instance_id = stackit_logs_instance.logs.instance_id +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_logs_instance.logs", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "region", + "data.stackit_logs_instance.logs", "region", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "display_name", + "data.stackit_logs_instance.logs", "display_name", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "retention_days", + "data.stackit_logs_instance.logs", "retention_days", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "id", + "data.stackit_logs_instance.logs", "id", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "instance_id", + "data.stackit_logs_instance.logs", "instance_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "created", + "data.stackit_logs_instance.logs", "created", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "datasource_url", + "data.stackit_logs_instance.logs", "datasource_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "ingest_otlp_url", + "data.stackit_logs_instance.logs", "ingest_otlp_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "ingest_url", + "data.stackit_logs_instance.logs", "ingest_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "query_range_url", + "data.stackit_logs_instance.logs", "query_range_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "query_url", + "data.stackit_logs_instance.logs", "query_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "status", + "data.stackit_logs_instance.logs", "status", + ), + ), + }, + // Import + { + ConfigVariables: testConfigVarsMin, + ResourceName: "stackit_logs_instance.logs", + ImportStateIdFunc: func(state *terraform.State) (string, error) { + rs, ok := state.RootModule().Resources["stackit_logs_instance.logs"] + if !ok { + return "", fmt.Errorf("not found: %s", "stackit_logs_instance.logs") + } + instanceId, ok := rs.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("instance_id not set") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testConfigVarsMinUpdated(), + Config: testutil.LogsProviderConfig() + resourceMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "project_id", testutil.ConvertConfigVariable(testConfigVarsMinUpdated()["project_id"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "region", testutil.ConvertConfigVariable(testConfigVarsMinUpdated()["region"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "display_name", testutil.ConvertConfigVariable(testConfigVarsMinUpdated()["display_name"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "retention_days", testutil.ConvertConfigVariable(testConfigVarsMinUpdated()["retention_days"])), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "created"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "datasource_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_otlp_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_range_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "status"), + ), + }, + // Deletion handled by framework + }, + }) +} + +func TestAccLogsInstanceMax(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckLogsInstanceDestroy, + Steps: []resource.TestStep{ + // Create + { + ConfigVariables: testConfigVarsMax, + Config: testutil.LogsProviderConfig() + resourceMax, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "region", testutil.ConvertConfigVariable(testConfigVarsMax["region"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "display_name", testutil.ConvertConfigVariable(testConfigVarsMax["display_name"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "retention_days", testutil.ConvertConfigVariable(testConfigVarsMax["retention_days"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "acl.0", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "description", testutil.ConvertConfigVariable(testConfigVarsMax["description"])), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "created"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "datasource_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_otlp_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_range_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "status"), + ), + }, + // Datasource + { + ConfigVariables: testConfigVarsMax, + Config: testutil.LogsProviderConfig() + resourceMax + ` +data "stackit_logs_instance" "logs" { + project_id = stackit_logs_instance.logs.project_id + region = stackit_logs_instance.logs.region + instance_id = stackit_logs_instance.logs.instance_id +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_logs_instance.logs", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "region", + "data.stackit_logs_instance.logs", "region", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "display_name", + "data.stackit_logs_instance.logs", "display_name", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "retention_days", + "data.stackit_logs_instance.logs", "retention_days", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "id", + "data.stackit_logs_instance.logs", "id", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "instance_id", + "data.stackit_logs_instance.logs", "instance_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "created", + "data.stackit_logs_instance.logs", "created", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "datasource_url", + "data.stackit_logs_instance.logs", "datasource_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "ingest_otlp_url", + "data.stackit_logs_instance.logs", "ingest_otlp_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "ingest_url", + "data.stackit_logs_instance.logs", "ingest_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "query_range_url", + "data.stackit_logs_instance.logs", "query_range_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "query_url", + "data.stackit_logs_instance.logs", "query_url", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "status", + "data.stackit_logs_instance.logs", "status", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "acl.0", + "data.stackit_logs_instance.logs", "acl.0", + ), + resource.TestCheckResourceAttrPair( + "stackit_logs_instance.logs", "description", + "data.stackit_logs_instance.logs", "description", + ), + ), + }, + // Import + { + ConfigVariables: testConfigVarsMax, + ResourceName: "stackit_logs_instance.logs", + ImportStateIdFunc: func(state *terraform.State) (string, error) { + rs, ok := state.RootModule().Resources["stackit_logs_instance.logs"] + if !ok { + return "", fmt.Errorf("not found: %s", "stackit_logs_instance.logs") + } + instanceId, ok := rs.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("instance_id not set") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testConfigVarsMaxUpdated(), + Config: testutil.LogsProviderConfig() + resourceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "project_id", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["project_id"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "region", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["region"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "display_name", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["display_name"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "retention_days", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["retention_days"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "acl.0", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["acl"])), + resource.TestCheckResourceAttr("stackit_logs_instance.logs", "description", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["description"])), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "created"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "datasource_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_otlp_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "ingest_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_range_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "query_url"), + resource.TestCheckResourceAttrSet("stackit_logs_instance.logs", "status"), + ), + }, + // Deletion handled by framework + }, + }) +} + +func testAccCheckLogsInstanceDestroy(s *terraform.State) error { + ctx := context.Background() + var client *logs.APIClient + var err error + if testutil.LogsCustomEndpoint == "" { + client, err = logs.NewAPIClient( + coreConfig.WithRegion("eu01"), + ) + } else { + client, err = logs.NewAPIClient( + coreConfig.WithEndpoint(testutil.LogsCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + var instancesToDestroy []string + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_logs_instance" { + continue + } + instanceId := strings.Split(rs.Primary.ID, core.Separator)[2] + instancesToDestroy = append(instancesToDestroy, instanceId) + } + + response, err := client.ListLogsInstances(ctx, testutil.ProjectId, "eu01").Execute() + if err != nil { + return fmt.Errorf("getting instances: %w", err) + } + for _, i := range *response.Instances { + if !slices.Contains(instancesToDestroy, *i.Id) { + continue + } + err := client.DeleteLogsInstance(ctx, testutil.ProjectId, "eu01", *i.Id).Execute() + if err != nil { + return fmt.Errorf("deleting instance %s: %w", *i.Id, err) + } + } + return nil +} diff --git a/stackit/internal/services/logs/testdata/resource-max.tf b/stackit/internal/services/logs/testdata/resource-max.tf new file mode 100644 index 000000000..a57b4f05b --- /dev/null +++ b/stackit/internal/services/logs/testdata/resource-max.tf @@ -0,0 +1,18 @@ + +variable "project_id" {} +variable "region" {} +variable "display_name" {} +variable "retention_days" {} +variable "acl" {} +variable "description" {} + +resource "stackit_logs_instance" "logs" { + project_id = var.project_id + region = var.region + display_name = var.display_name + retention_days = var.retention_days + acl = [ + var.acl + ] + description = var.description +} diff --git a/stackit/internal/services/logs/testdata/resource-min.tf b/stackit/internal/services/logs/testdata/resource-min.tf new file mode 100644 index 000000000..a0b82bb6d --- /dev/null +++ b/stackit/internal/services/logs/testdata/resource-min.tf @@ -0,0 +1,12 @@ + +variable "project_id" {} +variable "region" {} +variable "display_name" {} +variable "retention_days" {} + +resource "stackit_logs_instance" "logs" { + project_id = var.project_id + region = var.region + display_name = var.display_name + retention_days = var.retention_days +} diff --git a/stackit/internal/services/logs/utils/utils.go b/stackit/internal/services/logs/utils/utils.go new file mode 100644 index 000000000..43346954a --- /dev/null +++ b/stackit/internal/services/logs/utils/utils.go @@ -0,0 +1,31 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/logs" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *logs.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.LogsCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.LogsCustomEndpoint)) + } else { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithRegion(providerData.GetRegion())) + } + apiClient, err := logs.NewAPIClient(apiClientConfigOptions...) + if err != nil { + core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return nil + } + + return apiClient +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index c7e5654aa..3dd409f00 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -71,6 +71,7 @@ var ( KMSCustomEndpoint = os.Getenv("TF_ACC_KMS_CUSTOM_ENDPOINT") LoadBalancerCustomEndpoint = os.Getenv("TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT") LogMeCustomEndpoint = os.Getenv("TF_ACC_LOGME_CUSTOM_ENDPOINT") + LogsCustomEndpoint = os.Getenv("TF_ACC_LOGS_CUSTOM_ENDPOINT") MariaDBCustomEndpoint = os.Getenv("TF_ACC_MARIADB_CUSTOM_ENDPOINT") ModelServingCustomEndpoint = os.Getenv("TF_ACC_MODELSERVING_CUSTOM_ENDPOINT") AuthorizationCustomEndpoint = os.Getenv("TF_ACC_authorization_custom_endpoint") @@ -228,6 +229,23 @@ func LogMeProviderConfig() string { ) } +func LogsProviderConfig() string { + if LogsCustomEndpoint == "" { + return ` + provider "stackit" { + enable_beta_resources = true + default_region = "eu01" + }` + } + return fmt.Sprintf(` + provider "stackit" { + enable_beta_resources = true + logs_custom_endpoint = "%s" + }`, + LogsCustomEndpoint, + ) +} + func MariaDBProviderConfig() string { if MariaDBCustomEndpoint == "" { return ` diff --git a/stackit/provider.go b/stackit/provider.go index de2120643..2c72c8cc0 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -57,6 +57,7 @@ import ( loadBalancerObservabilityCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/observability-credential" logMeCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logme/credential" logMeInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logme/instance" + logsInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logs/instance" mariaDBCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/credential" mariaDBInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/instance" modelServingToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelserving/token" @@ -143,6 +144,7 @@ type providerModel struct { KmsCustomEndpoint types.String `tfsdk:"kms_custom_endpoint"` LoadBalancerCustomEndpoint types.String `tfsdk:"loadbalancer_custom_endpoint"` LogMeCustomEndpoint types.String `tfsdk:"logme_custom_endpoint"` + LogsCustomEndpoint types.String `tfsdk:"logs_custom_endpoint"` MariaDBCustomEndpoint types.String `tfsdk:"mariadb_custom_endpoint"` ModelServingCustomEndpoint types.String `tfsdk:"modelserving_custom_endpoint"` MongoDBFlexCustomEndpoint types.String `tfsdk:"mongodbflex_custom_endpoint"` @@ -188,6 +190,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service", "loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service", "logme_custom_endpoint": "Custom endpoint for the LogMe service", + "logs_custom_endpoint": "Custom endpoint for the Logs service", "rabbitmq_custom_endpoint": "Custom endpoint for the RabbitMQ service", "mariadb_custom_endpoint": "Custom endpoint for the MariaDB service", "authorization_custom_endpoint": "Custom endpoint for the Membership service", @@ -317,6 +320,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["logme_custom_endpoint"], }, + "logs_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["logs_custom_endpoint"], + }, "rabbitmq_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["rabbitmq_custom_endpoint"], @@ -434,6 +441,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.KmsCustomEndpoint, func(v string) { providerData.KMSCustomEndpoint = v }) setStringField(providerConfig.LoadBalancerCustomEndpoint, func(v string) { providerData.LoadBalancerCustomEndpoint = v }) setStringField(providerConfig.LogMeCustomEndpoint, func(v string) { providerData.LogMeCustomEndpoint = v }) + setStringField(providerConfig.LogsCustomEndpoint, func(v string) { providerData.LogsCustomEndpoint = v }) setStringField(providerConfig.MariaDBCustomEndpoint, func(v string) { providerData.MariaDBCustomEndpoint = v }) setStringField(providerConfig.ModelServingCustomEndpoint, func(v string) { providerData.ModelServingCustomEndpoint = v }) setStringField(providerConfig.MongoDBFlexCustomEndpoint, func(v string) { providerData.MongoDBFlexCustomEndpoint = v }) @@ -521,6 +529,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, logMeCredential.NewCredentialDataSource, + logsInstance.NewLogsInstanceDataSource, logAlertGroup.NewLogAlertGroupDataSource, machineType.NewMachineTypeDataSource, mariaDBInstance.NewInstanceDataSource, @@ -594,6 +603,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { logMeInstance.NewInstanceResource, logMeCredential.NewCredentialResource, logAlertGroup.NewLogAlertGroupResource, + logsInstance.NewLogsInstanceResource, mariaDBInstance.NewInstanceResource, mariaDBCredential.NewCredentialResource, modelServingToken.NewTokenResource,