diff --git a/docs/data-sources/scf_organization.md b/docs/data-sources/scf_organization.md new file mode 100644 index 000000000..43be29ff7 --- /dev/null +++ b/docs/data-sources/scf_organization.md @@ -0,0 +1,43 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_scf_organization Data Source - stackit" +subcategory: "" +description: |- + STACKIT Cloud Foundry organization datasource schema. Must have a region specified in the provider configuration. +--- + +# stackit_scf_organization (Data Source) + +STACKIT Cloud Foundry organization datasource schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +data "stackit_scf_organization" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + org_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `org_id` (String) The ID of the Cloud Foundry Organization +- `project_id` (String) The ID of the project associated with the organization + +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used + +### Read-Only + +- `created_at` (String) The time when the organization was created +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`region`,`org_id`". +- `name` (String) The name of the organization +- `platform_id` (String) The ID of the platform associated with the organization +- `quota_id` (String) The ID of the quota associated with the organization +- `status` (String) The status of the organization (e.g., deleting, delete_failed) +- `suspended` (Boolean) A boolean indicating whether the organization is suspended +- `updated_at` (String) The time when the organization was last updated diff --git a/docs/data-sources/scf_organization_manager.md b/docs/data-sources/scf_organization_manager.md new file mode 100644 index 000000000..92e1ffa7b --- /dev/null +++ b/docs/data-sources/scf_organization_manager.md @@ -0,0 +1,41 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_scf_organization_manager Data Source - stackit" +subcategory: "" +description: |- + STACKIT Cloud Foundry organization manager datasource schema. +--- + +# stackit_scf_organization_manager (Data Source) + +STACKIT Cloud Foundry organization manager datasource schema. + +## Example Usage + +```terraform +data "stackit_scf_organization_manager" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + org_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `org_id` (String) The ID of the Cloud Foundry Organization +- `project_id` (String) The ID of the project associated with the organization of the organization manager + +### Optional + +- `region` (String) The region where the organization of the organization manager is located. If not defined, the provider region is used + +### Read-Only + +- `created_at` (String) The time when the organization manager was created +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`region`,`org_id`,`user_id`". +- `platform_id` (String) The ID of the platform associated with the organization of the organization manager +- `updated_at` (String) The time when the organization manager was last updated +- `user_id` (String) The ID of the organization manager user +- `username` (String) An auto-generated organization manager user name diff --git a/docs/data-sources/scf_platform.md b/docs/data-sources/scf_platform.md new file mode 100644 index 000000000..eddbe3bae --- /dev/null +++ b/docs/data-sources/scf_platform.md @@ -0,0 +1,40 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_scf_platform Data Source - stackit" +subcategory: "" +description: |- + STACKIT Cloud Foundry Platform datasource schema. +--- + +# stackit_scf_platform (Data Source) + +STACKIT Cloud Foundry Platform datasource schema. + +## Example Usage + +```terraform +data "stackit_scf_platform" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + platform_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `platform_id` (String) The unique id of the platform +- `project_id` (String) The ID of the project associated with the platform + +### Optional + +- `region` (String) The region where the platform is located. If not defined, the provider region is used + +### Read-Only + +- `api_url` (String) The CF API Url of the platform +- `console_url` (String) The Stratos URL of the platform +- `display_name` (String) The name of the platform +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`region`,`platform_id`". +- `system_id` (String) The ID of the platform System diff --git a/docs/guides/scf_cloudfoundry.md b/docs/guides/scf_cloudfoundry.md new file mode 100644 index 000000000..30ee42eae --- /dev/null +++ b/docs/guides/scf_cloudfoundry.md @@ -0,0 +1,248 @@ +# How to Provisioning Cloud Foundry using Terrform + +## Objective + +This tutorial demonstrates how to provision Cloud Foundry resources by +integrating the STACKIT Terraform provider with the Cloud Foundry Terraform +provider. The STACKIT Terraform provider will create a managed Cloud Foundry +organization and set up a technical "org manager" user with +`organization_manager` permissions. These credentials, along with the Cloud +Foundry API URL (retrieved dynamically from a platform data resource), are +passed to the Cloud Foundry Terraform provider to manage resources within the +new organization. + +### Output + +This configuration creates a Cloud Foundry organization, mirroring the structure +created via the portal. It sets up three distinct spaces: `dev`, `qa`, and +`prod`. The configuration assigns, a specified user the `organization_manager` +and `organization_user` roles at the organization level, and the +`space_developer` role in each space. + +### Scope + +This tutorial covers the interaction between the STACKIT Terraform provider and +the Cloud Foundry Terraform provider. It assumes you are familiar with: + +- Setting up a STACKIT project and configuring the STACKIT Terraform provider + with a service account (see the general STACKIT documentation for details). +- Basic Terraform concepts, such as variables and locals. + +This document does not cover foundational topics or every feature of the Cloud +Foundry Terraform provider. + +### Example configuration + +The following Terraform configuration provisions a Cloud Foundry organization +and related resources using the STACKIT Terraform provider and the Cloud Foundry +Terraform provider: + +``` +terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + } + cloudfoundry = { + source = "cloudfoundry/cloudfoundry" + } + } +} + +variable "project_id" { + type = string + description = "Id of the Project" +} + +variable "org_name" { + type = string + description = "Name of the Organization" +} + +variable "admin_email" { + type = string + description = "Users who are granted permissions" +} + +provider "stackit" { + default_region = "eu01" +} + +resource "stackit_scf_organization" "scf_org" { + name = var.org_name + project_id = var.project_id +} + +data "stackit_scf_platform" "scf_platform" { + project_id = var.project_id + platform_id = stackit_scf_organization.scf_org.platform_id +} + +resource "stackit_scf_organization_manager" "scf_manager" { + project_id = var.project_id + org_id = stackit_scf_organization.scf_org.org_id +} + +provider "cloudfoundry" { + api_url = data.stackit_scf_platform.scf_platform.api_url + user = stackit_scf_organization_manager.scf_manager.username + password = stackit_scf_organization_manager.scf_manager.password +} + +locals { + spaces = ["dev", "qa", "prod"] +} + +resource "cloudfoundry_org_role" "org_user" { + username = var.admin_email + type = "organization_user" + org = stackit_scf_organization.scf_org.org_id +} + +resource "cloudfoundry_org_role" "org_manager" { + username = var.admin_email + type = "organization_manager" + org = stackit_scf_organization.scf_org.org_id +} + +resource "cloudfoundry_space" "spaces" { + for_each = toset(local.spaces) + name = each.key + org = stackit_scf_organization.scf_org.org_id +} + +resource "cloudfoundry_space_role" "space_developer" { + for_each = toset(local.spaces) + username = var.admin_email + type = "space_developer" + depends_on = [ cloudfoundry_org_role.org_user ] + space = cloudfoundry_space.spaces[each.key].id +} +``` + +## Explanation of configuration + +### STACKIT provider configuration + +``` +provider "stackit" { + default_region = "eu01" +} +``` + +The STACKIT Cloud Foundry Application Programming Interface (SCF API) is +regionalized. Each region operates independently. Set `default_region` in the +provider configuration, to specify the region for all resources, unless you +override it for individual resources. You must also provide access data for the +relevant STACKIT project for the provider to function. + +For more details, see +the:[STACKIT Terraform Provider documentation.](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs) + +### stackit_scf_organization.scf_org resource + +``` +resource "stackit_scf_organization" "scf_org" { + name = var.org_name + project_id = var.project_id +} +``` + +This resource provisions a Cloud Foundry organization, which acts as the +foundational container in the Cloud Foundry environment. Each Cloud Foundry +provider configuration is scoped to a specific organization. The organization’s +name, defined by a variable, must be unique across the platform. The +organization is created within a designated STACKIT project, which requires the +STACKIT provider to be configured with the necessary permissions for that +project. + +### stackit_scf_organization_manager.scf_manager resource + +``` +resource "stackit_scf_organization_manager" "scf_manager" { + project_id = var.project_id + org_id = stackit_scf_organization.scf_org.org_id +} +``` + +This resource creates a technical user in the Cloud Foundry organization with +the organization_manager permission. The user is linked to the organization and +is automatically deleted when the organization is removed. + +### stackit_scf_platform.scf_platform data source + +``` +data "stackit_scf_platform" "scf_platform" { + project_id = var.project_id + platform_id = stackit_scf_organization.scf_org.platform_id +} +``` + +This data source retrieves properties of the Cloud Foundry platform where the +organization is provisioned. It does not create resources, but provides +information about the existing platform. + +### Cloud Foundry provider configuration + +``` +provider "cloudfoundry" { + api_url = data.stackit_scf_platform.scf_platform.api_url + user = stackit_scf_organization_manager.scf_manager.username + password = stackit_scf_organization_manager.scf_manager.password +} +``` + +The Cloud Foundry provider is configured to manage resources in the new +organization. The provider uses the API URL from the `stackit_scf_platform` data +source and authenticates using the credentials of the technical user created by +the `stackit_scf_organization_manager` resource. + +For more information, see the: +[Cloud Foundry Terraform Provider documentation.](https://registry.terraform.io/providers/cloudfoundry/cloudfoundry/latest/docs) + +## Deploy resources + +Follow these steps to initialize your environment and provision Cloud Foundry +resources using Terraform. + +### Initialize Terraform + +Run the following command to initialize the working directory and download the +required provider plugins: + +``` +terraform init +``` + +### Create the organization manager user + +Run this command to provision the organization and technical user needed to +initialize the Cloud Foundry Terraform provider. This step is required only +during the initial setup. For later changes, you do not need the -target flag. + +``` +terraform apply -target stackit_scf_organization_manager.scf_manager +``` + +### Apply the full configuration + +Run this command to provision all resources defined in your Terraform +configuration within the Cloud Foundry organization: + +``` +terraform apply +``` + +## Verify the deployment + +Verify that your Cloud Foundry resources are provisioned correctly. Use the +following Cloud Foundry CLI commands to check applications, services, and +routes: + +- `cf apps` +- `cf services` +- `cf routes` + +For more information, see the +[Cloud Foundry documentation](https://docs.cloudfoundry.org/) and the +[Cloud Foundry CLI Reference Guide](https://cli.cloudfoundry.org/). \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 55fd20abb..efc6ca4f8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -177,6 +177,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `redis_custom_endpoint` (String) Custom endpoint for the Redis service - `region` (String, Deprecated) Region will be used as the default location for regional services. Not all services require a region, some are global - `resourcemanager_custom_endpoint` (String) Custom endpoint for the Resource Manager service +- `scf_custom_endpoint` (String) Custom endpoint for the Cloud Foundry (SCF) service - `secretsmanager_custom_endpoint` (String) Custom endpoint for the Secrets Manager service - `server_backup_custom_endpoint` (String) Custom endpoint for the Server Backup service - `server_update_custom_endpoint` (String) Custom endpoint for the Server Update service diff --git a/docs/resources/scf_organization.md b/docs/resources/scf_organization.md new file mode 100644 index 000000000..28c2d3a19 --- /dev/null +++ b/docs/resources/scf_organization.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_scf_organization Resource - stackit" +subcategory: "" +description: |- + STACKIT Cloud Foundry organization resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_scf_organization (Resource) + +STACKIT Cloud Foundry organization resource schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +resource "stackit_scf_organization" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" +} + +resource "stackit_scf_organization" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + platform_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + quota_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + suspended = false +} + +# Only use the import statement, if you want to import an existing scf organization +import { + to = stackit_scf_organization.import-example + id = "${var.project_id},${var.region},${var.org_id}" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the organization +- `project_id` (String) The ID of the project associated with the organization + +### Optional + +- `platform_id` (String) The ID of the platform associated with the organization +- `quota_id` (String) The ID of the quota associated with the organization +- `region` (String) The resource region. If not defined, the provider region is used +- `suspended` (Boolean) A boolean indicating whether the organization is suspended + +### Read-Only + +- `created_at` (String) The time when the organization was created +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`region`,`org_id`". +- `org_id` (String) The ID of the Cloud Foundry Organization +- `status` (String) The status of the organization (e.g., deleting, delete_failed) +- `updated_at` (String) The time when the organization was last updated diff --git a/docs/resources/scf_organization_manager.md b/docs/resources/scf_organization_manager.md new file mode 100644 index 000000000..3ed0b008f --- /dev/null +++ b/docs/resources/scf_organization_manager.md @@ -0,0 +1,49 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_scf_organization_manager Resource - stackit" +subcategory: "" +description: |- + STACKIT Cloud Foundry organization manager resource schema. +--- + +# stackit_scf_organization_manager (Resource) + +STACKIT Cloud Foundry organization manager resource schema. + +## Example Usage + +```terraform +resource "stackit_scf_organization_manager" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + org_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +# Only use the import statement, if you want to import an existing scf org user +# The password field is still null after import and must be entered manually in the state. +import { + to = stackit_scf_organization_manager.import-example + id = "${var.project_id},${var.region},${var.org_id},${var.user_id}" +} +``` + + +## Schema + +### Required + +- `org_id` (String) The ID of the Cloud Foundry Organization +- `project_id` (String) The ID of the project associated with the organization of the organization manager + +### Optional + +- `region` (String) The region where the organization of the organization manager is located. If not defined, the provider region is used + +### Read-Only + +- `created_at` (String) The time when the organization manager was created +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`region`,`org_id`,`user_id`". +- `password` (String, Sensitive) An auto-generated password +- `platform_id` (String) The ID of the platform associated with the organization of the organization manager +- `updated_at` (String) The time when the organization manager was last updated +- `user_id` (String) The ID of the organization manager user +- `username` (String) An auto-generated organization manager user name diff --git a/examples/data-sources/stackit_scf_organization/data-source.tf b/examples/data-sources/stackit_scf_organization/data-source.tf new file mode 100644 index 000000000..4d466602b --- /dev/null +++ b/examples/data-sources/stackit_scf_organization/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_scf_organization" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + org_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/data-sources/stackit_scf_organization_manager/data-source.tf b/examples/data-sources/stackit_scf_organization_manager/data-source.tf new file mode 100644 index 000000000..53487c9c2 --- /dev/null +++ b/examples/data-sources/stackit_scf_organization_manager/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_scf_organization_manager" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + org_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/data-sources/stackit_scf_platform/data-source.tf b/examples/data-sources/stackit_scf_platform/data-source.tf new file mode 100644 index 000000000..0ddf316ac --- /dev/null +++ b/examples/data-sources/stackit_scf_platform/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_scf_platform" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + platform_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/resources/stackit_scf_organization/resource.tf b/examples/resources/stackit_scf_organization/resource.tf new file mode 100644 index 000000000..fc38820ed --- /dev/null +++ b/examples/resources/stackit_scf_organization/resource.tf @@ -0,0 +1,18 @@ +resource "stackit_scf_organization" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" +} + +resource "stackit_scf_organization" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + platform_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + quota_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + suspended = false +} + +# Only use the import statement, if you want to import an existing scf organization +import { + to = stackit_scf_organization.import-example + id = "${var.project_id},${var.region},${var.org_id}" +} diff --git a/examples/resources/stackit_scf_organization_manager/resource.tf b/examples/resources/stackit_scf_organization_manager/resource.tf new file mode 100644 index 000000000..a16638a6a --- /dev/null +++ b/examples/resources/stackit_scf_organization_manager/resource.tf @@ -0,0 +1,11 @@ +resource "stackit_scf_organization_manager" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + org_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +# Only use the import statement, if you want to import an existing scf org user +# The password field is still null after import and must be entered manually in the state. +import { + to = stackit_scf_organization_manager.import-example + id = "${var.project_id},${var.region},${var.org_id},${var.user_id}" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 9f0afa63f..99a11cd3a 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.25.1 github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.1 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.17.1 + github.com/stackitcloud/stackit-sdk-go/services/scf v0.2.1 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.13.1 github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.2 github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.1 diff --git a/go.sum b/go.sum index 4ba01dd0d..029a342c1 100644 --- a/go.sum +++ b/go.sum @@ -188,6 +188,8 @@ github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.1 h1:8uPt82Ez34OYMOi github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.1/go.mod h1:1Y2GEICmZDt+kr8aGnBx/sjYVAIYHmtfC8xYi9oxNEE= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.17.1 h1:r7oaINTwLmIG31AaqKTuQHHFF8YNuYGzi+46DOuSjw4= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.17.1/go.mod h1:ipcrPRbwfQXHH18dJVfY7K5ujHF5dTT6isoXgmA7YwQ= +github.com/stackitcloud/stackit-sdk-go/services/scf v0.2.1 h1:OdofRB6uj6lwN/TXLVHVrEOwNMG34MlFNwkiHD+eOts= +github.com/stackitcloud/stackit-sdk-go/services/scf v0.2.1/go.mod h1:5p7Xi8jadpJNDYr0t+07DXS104/RJLfhhA1r6P7PlGs= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.13.1 h1:WKFzlHllql3JsVcAq+Y1m5pSMkvwp1qH3Vf2N7i8CPg= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.13.1/go.mod h1:WGMFtGugBmUxI+nibI7eUZIQk4AGlDvwqX+m17W1y5w= github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.2 h1:tfKC4Z6Uah9AQZrtCn/ytqOgc//ChQRfJ6ozxovgads= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index e477c9064..d993cddb3 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -45,6 +45,7 @@ type ProviderData struct { RabbitMQCustomEndpoint string RedisCustomEndpoint string ResourceManagerCustomEndpoint string + ScfCustomEndpoint string SecretsManagerCustomEndpoint string SQLServerFlexCustomEndpoint string ServerBackupCustomEndpoint string diff --git a/stackit/internal/services/scf/organization/datasource.go b/stackit/internal/services/scf/organization/datasource.go new file mode 100644 index 000000000..528e46da9 --- /dev/null +++ b/stackit/internal/services/scf/organization/datasource.go @@ -0,0 +1,176 @@ +package organization + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/services/scf" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + scfUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &scfOrganizationDataSource{} + _ datasource.DataSourceWithConfigure = &scfOrganizationDataSource{} +) + +// NewScfOrganizationDataSource creates a new instance of the scfOrganizationDataSource. +func NewScfOrganizationDataSource() datasource.DataSource { + return &scfOrganizationDataSource{} +} + +// scfOrganizationDataSource is the datasource implementation. +type scfOrganizationDataSource struct { + client *scf.APIClient + providerData core.ProviderData +} + +func (s *scfOrganizationDataSource) Configure(ctx context.Context, request datasource.ConfigureRequest, response *datasource.ConfigureResponse) { + var ok bool + s.providerData, ok = conversion.ParseProviderData(ctx, request.ProviderData, &response.Diagnostics) + if !ok { + return + } + + apiClient := scfUtils.ConfigureClient(ctx, &s.providerData, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + s.client = apiClient + tflog.Info(ctx, "scf client configured") +} + +func (s *scfOrganizationDataSource) Metadata(_ context.Context, request datasource.MetadataRequest, response *datasource.MetadataResponse) { // nolint:gocritic // function signature required by Terraform + response.TypeName = request.ProviderTypeName + "_scf_organization" +} + +func (s *scfOrganizationDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, response *datasource.SchemaResponse) { // nolint:gocritic // function signature required by Terraform + response.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "created_at": schema.StringAttribute{ + Description: descriptions["created_at"], + Computed: true, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + }, + }, + "platform_id": schema.StringAttribute{ + Description: descriptions["platform_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "org_id": schema.StringAttribute{ + Description: descriptions["org_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "quota_id": schema.StringAttribute{ + Description: descriptions["quota_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + Computed: true, + }, + "status": schema.StringAttribute{ + Description: descriptions["status"], + Computed: true, + }, + "suspended": schema.BoolAttribute{ + Description: descriptions["suspended"], + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: descriptions["updated_at"], + Computed: true, + }, + }, + Description: "STACKIT Cloud Foundry organization datasource schema. Must have a `region` specified in the provider configuration.", + } +} + +func (s *scfOrganizationDataSource) Read(ctx context.Context, request datasource.ReadRequest, response *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the current state of the resource. + var model Model + diags := request.Config.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Extract the project ID and instance id of the model + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + + // Extract the region + region := s.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_id", orgId) + ctx = tflog.SetField(ctx, "region", region) + + // Read the current scf organization via orgId + scfOrgResponse, err := s.client.GetOrganization(ctx, projectId, region, orgId).Execute() + if err != nil { + utils.LogError( + ctx, + &response.Diagnostics, + err, + "Reading scf organization", + fmt.Sprintf("Organization with ID %q does not exist in project %q.", orgId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Organization with ID %q not found or forbidden access", orgId), + }, + ) + response.State.RemoveResource(ctx) + return + } + + err = mapFields(scfOrgResponse, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading scf organization", fmt.Sprintf("Processing API response: %v", err)) + return + } + + // Set the updated state. + diags = response.State.Set(ctx, &model) + response.Diagnostics.Append(diags...) + tflog.Info(ctx, fmt.Sprintf("read scf organization %s", orgId)) +} diff --git a/stackit/internal/services/scf/organization/resource.go b/stackit/internal/services/scf/organization/resource.go new file mode 100644 index 000000000..bdec91405 --- /dev/null +++ b/stackit/internal/services/scf/organization/resource.go @@ -0,0 +1,540 @@ +package organization + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/boolplanmodifier" + "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/scf" + "github.com/stackitcloud/stackit-sdk-go/services/scf/wait" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + scfUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &scfOrganizationResource{} + _ resource.ResourceWithConfigure = &scfOrganizationResource{} + _ resource.ResourceWithImportState = &scfOrganizationResource{} + _ resource.ResourceWithModifyPlan = &scfOrganizationResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // Required by Terraform + CreateAt types.String `tfsdk:"created_at"` + Name types.String `tfsdk:"name"` + PlatformId types.String `tfsdk:"platform_id"` + ProjectId types.String `tfsdk:"project_id"` + QuotaId types.String `tfsdk:"quota_id"` + OrgId types.String `tfsdk:"org_id"` + Region types.String `tfsdk:"region"` + Status types.String `tfsdk:"status"` + Suspended types.Bool `tfsdk:"suspended"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +// NewScfOrganizationResource is a helper function to create a new scf organization resource. +func NewScfOrganizationResource() resource.Resource { + return &scfOrganizationResource{} +} + +// scfOrganizationResource implements the resource interface for scf organization. +type scfOrganizationResource struct { + client *scf.APIClient + providerData core.ProviderData +} + +// descriptions for the attributes in the Schema +var descriptions = map[string]string{ + "id": "Terraform's internal resource ID, structured as \"`project_id`,`region`,`org_id`\".", + "created_at": "The time when the organization was created", + "name": "The name of the organization", + "platform_id": "The ID of the platform associated with the organization", + "project_id": "The ID of the project associated with the organization", + "quota_id": "The ID of the quota associated with the organization", + "region": "The resource region. If not defined, the provider region is used", + "status": "The status of the organization (e.g., deleting, delete_failed)", + "suspended": "A boolean indicating whether the organization is suspended", + "org_id": "The ID of the Cloud Foundry Organization", + "updated_at": "The time when the organization was last updated", +} + +func (s *scfOrganizationResource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) { + var ok bool + s.providerData, ok = conversion.ParseProviderData(ctx, request.ProviderData, &response.Diagnostics) + if !ok { + return + } + + apiClient := scfUtils.ConfigureClient(ctx, &s.providerData, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + s.client = apiClient + tflog.Info(ctx, "scf client configured") +} + +func (s *scfOrganizationResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_scf_organization" +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *scfOrganizationResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + 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 + } + + utils.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 (s *scfOrganizationResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) { + response.Schema = schema.Schema{ + Description: "STACKIT Cloud Foundry organization resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "created_at": schema.StringAttribute{ + Description: descriptions["created_at"], + Computed: true, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + }, + }, + "platform_id": schema.StringAttribute{ + Description: descriptions["platform_id"], + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "org_id": schema.StringAttribute{ + Description: descriptions["org_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "quota_id": schema.StringAttribute{ + Description: descriptions["quota_id"], + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "status": schema.StringAttribute{ + Description: descriptions["status"], + Computed: true, + }, + "suspended": schema.BoolAttribute{ + Description: descriptions["suspended"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "updated_at": schema.StringAttribute{ + Description: descriptions["updated_at"], + Computed: true, + }, + }, + } +} + +func (s *scfOrganizationResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the planned values for the resource. + var model Model + diags := request.Plan.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Set logging context with the project ID and instance ID. + region := model.Region.ValueString() + projectId := model.ProjectId.ValueString() + orgName := model.Name.ValueString() + quotaId := model.QuotaId.ValueString() + suspended := model.Suspended.ValueBool() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_name", orgName) + ctx = tflog.SetField(ctx, "region", region) + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", fmt.Sprintf("Creating API payload: %v\n", err)) + return + } + + // Create the new scf organization via the API client. + scfOrgCreateResponse, err := s.client.CreateOrganization(ctx, projectId, region). + CreateOrganizationPayload(payload). + Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", fmt.Sprintf("Calling API to create org: %v", err)) + return + } + orgId := *scfOrgCreateResponse.Guid + + // Apply the org quota if provided + if quotaId != "" { + applyOrgQuota, err := s.client.ApplyOrganizationQuota(ctx, projectId, region, orgId).ApplyOrganizationQuotaPayload( + scf.ApplyOrganizationQuotaPayload{ + QuotaId: "aId, + }).Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", fmt.Sprintf("Calling API to apply quota: %v", err)) + return + } + model.QuotaId = types.StringPointerValue(applyOrgQuota.QuotaId) + } + + if suspended { + _, err := s.client.UpdateOrganization(ctx, projectId, region, orgId).UpdateOrganizationPayload( + + scf.UpdateOrganizationPayload{ + Suspended: &suspended, + }).Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", fmt.Sprintf("Calling API to update suspended: %v", err)) + return + } + } + + // Load the newly created scf organization + scfOrgResponse, err := s.client.GetOrganization(ctx, projectId, region, orgId).Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", fmt.Sprintf("Calling API to load created org: %v", err)) + return + } + + err = mapFields(scfOrgResponse, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set the state with fully populated data. + diags = response.State.Set(ctx, model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Scf organization created") +} + +// Read refreshes the Terraform state with the latest scf organization data. +func (s *scfOrganizationResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the current state of the resource. + var model Model + diags := request.State.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Extract the project ID and instance id of the model + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + // Extract the region + region := s.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_id", orgId) + ctx = tflog.SetField(ctx, "region", region) + // Read the current scf organization via guid + scfOrgResponse, err := s.client.GetOrganization(ctx, projectId, region, orgId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok && oapiErr.StatusCode == http.StatusNotFound { + response.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading scf organization", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(scfOrgResponse, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading scf organization", fmt.Sprintf("Processing API response: %v", err)) + return + } + + // Set the updated state. + diags = response.State.Set(ctx, &model) + response.Diagnostics.Append(diags...) + tflog.Info(ctx, fmt.Sprintf("read scf organization %s", orgId)) +} + +// Update attempts to update the resource. +func (s *scfOrganizationResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := request.Plan.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + region := model.Region.ValueString() + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + name := model.Name.ValueString() + quotaId := model.QuotaId.ValueString() + suspended := model.Suspended.ValueBool() + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_id", orgId) + ctx = tflog.SetField(ctx, "region", region) + + org, err := s.client.GetOrganization(ctx, projectId, region, orgId).Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error retrieving organization state", fmt.Sprintf("Getting organization state: %v", err)) + return + } + + // handle a change of the organization name or the suspended flag + if name != org.GetName() || suspended != org.GetSuspended() { + updatedOrg, err := s.client.UpdateOrganization(ctx, projectId, region, orgId).UpdateOrganizationPayload( + scf.UpdateOrganizationPayload{ + Name: &name, + Suspended: &suspended, + }).Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error updating organization", fmt.Sprintf("Processing API payload: %v", err)) + return + } + org = updatedOrg + } + + // handle a quota change of the org + if quotaId != org.GetQuotaId() { + applyOrgQuota, err := s.client.ApplyOrganizationQuota(ctx, projectId, region, orgId).ApplyOrganizationQuotaPayload( + scf.ApplyOrganizationQuotaPayload{ + QuotaId: "aId, + }).Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error applying organization quota", fmt.Sprintf("Processing API payload: %v", err)) + return + } + org.QuotaId = applyOrgQuota.QuotaId + } + + err = mapFields(org, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error updating organization", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = response.State.Set(ctx, model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "organization updated") +} + +// Delete deletes the git instance and removes it from the Terraform state on success. +func (s *scfOrganizationResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve current state of the resource. + var model Model + diags := request.State.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + + // Extract the region + region := model.Region.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_id", orgId) + ctx = tflog.SetField(ctx, "region", region) + + // Call API to delete the existing scf organization. + _, err := s.client.DeleteOrganization(ctx, projectId, region, orgId).Execute() + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error deleting scf organization", fmt.Sprintf("Calling API: %v", err)) + return + } + + _, err = wait.DeleteOrganizationWaitHandler(ctx, s.client, projectId, model.Region.ValueString(), orgId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error waiting for scf org deletion", fmt.Sprintf("SCFOrganization deleting waiting: %v", err)) + return + } + + tflog.Info(ctx, "Scf organization deleted") +} + +func (s *scfOrganizationResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + // Split the import identifier to extract project ID and email. + idParts := strings.Split(request.ID, core.Separator) + + // Ensure the import identifier format is correct. + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &response.Diagnostics, + "Error importing scf organization", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[org_id] Got: %q", request.ID), + ) + return + } + + projectId := idParts[0] + region := idParts[1] + orgId := idParts[2] + // Set the project id and organization id in the state + response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("region"), region)...) + response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("org_id"), orgId)...) + tflog.Info(ctx, "Scf organization state imported") +} + +// mapFields maps a SCF Organization response to the model. +func mapFields(response *scf.Organization, model *Model) error { + if response == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var orgId string + if response.Guid != nil { + orgId = *response.Guid + } else if model.OrgId.ValueString() != "" { + orgId = model.OrgId.ValueString() + } else { + return fmt.Errorf("org id is not present") + } + + var projectId string + if response.ProjectId != nil { + projectId = *response.ProjectId + } else if model.ProjectId.ValueString() != "" { + projectId = model.ProjectId.ValueString() + } else { + return fmt.Errorf("project id is not present") + } + + var region string + if response.Region != nil { + region = *response.Region + } else if model.Region.ValueString() != "" { + region = model.Region.ValueString() + } else { + return fmt.Errorf("region is not present") + } + + // Build the ID by combining the project ID and organization id and assign the model's fields. + model.Id = utils.BuildInternalTerraformId(projectId, region, orgId) + model.ProjectId = types.StringValue(projectId) + model.Region = types.StringValue(region) + model.PlatformId = types.StringPointerValue(response.PlatformId) + model.OrgId = types.StringValue(orgId) + model.Name = types.StringPointerValue(response.Name) + model.Status = types.StringPointerValue(response.Status) + model.Suspended = types.BoolPointerValue(response.Suspended) + model.QuotaId = types.StringPointerValue(response.QuotaId) + model.CreateAt = types.StringValue(response.CreatedAt.String()) + model.UpdatedAt = types.StringValue(response.UpdatedAt.String()) + return nil +} + +// toCreatePayload creates the payload to create a scf organization instance +func toCreatePayload(model *Model) (scf.CreateOrganizationPayload, error) { + if model == nil { + return scf.CreateOrganizationPayload{}, fmt.Errorf("nil model") + } + + payload := scf.CreateOrganizationPayload{ + Name: model.Name.ValueStringPointer(), + } + if !model.PlatformId.IsNull() && !model.PlatformId.IsUnknown() { + payload.PlatformId = model.PlatformId.ValueStringPointer() + } + return payload, nil +} diff --git a/stackit/internal/services/scf/organization/resource_test.go b/stackit/internal/services/scf/organization/resource_test.go new file mode 100644 index 000000000..956933ff6 --- /dev/null +++ b/stackit/internal/services/scf/organization/resource_test.go @@ -0,0 +1,177 @@ +package organization + +import ( + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/scf" +) + +var ( + testOrgId = uuid.New().String() + testProjectId = uuid.New().String() + testPlatformId = uuid.New().String() + testQuotaId = uuid.New().String() + testRegion = "eu01" +) + +func TestMapFields(t *testing.T) { + createdTime, err := time.Parse("2006-01-02 15:04:05 -0700 MST", "2025-01-01 00:00:00 +0000 UTC") + if err != nil { + t.Fatalf("failed to parse test time: %v", err) + } + + tests := []struct { + description string + input *scf.Organization + expected *Model + isValid bool + }{ + { + description: "minimal_input", + input: &scf.Organization{ + Guid: utils.Ptr(testOrgId), + Name: utils.Ptr("scf-org-min-instance"), + Region: utils.Ptr(testRegion), + CreatedAt: &createdTime, + UpdatedAt: &createdTime, + ProjectId: utils.Ptr(testProjectId), + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s", testProjectId, testRegion, testOrgId)), + ProjectId: types.StringValue(testProjectId), + Region: types.StringValue(testRegion), + Name: types.StringValue("scf-org-min-instance"), + PlatformId: types.StringNull(), + OrgId: types.StringValue(testOrgId), + QuotaId: types.StringNull(), + Status: types.StringNull(), + Suspended: types.BoolNull(), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + }, + isValid: true, + }, + { + description: "max_input", + input: &scf.Organization{ + CreatedAt: &createdTime, + Guid: utils.Ptr(testOrgId), + Name: utils.Ptr("scf-full-org"), + PlatformId: utils.Ptr(testPlatformId), + ProjectId: utils.Ptr(testProjectId), + QuotaId: utils.Ptr(testQuotaId), + Region: utils.Ptr(testRegion), + Status: nil, + Suspended: utils.Ptr(true), + UpdatedAt: &createdTime, + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s", testProjectId, testRegion, testOrgId)), + ProjectId: types.StringValue(testProjectId), + OrgId: types.StringValue(testOrgId), + Name: types.StringValue("scf-full-org"), + Region: types.StringValue(testRegion), + PlatformId: types.StringValue(testPlatformId), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + QuotaId: types.StringValue(testQuotaId), + Status: types.StringNull(), + Suspended: types.BoolValue(true), + }, + isValid: true, + }, + { + description: "nil_org", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "empty_org", + input: &scf.Organization{}, + expected: nil, + isValid: false, + }, + { + description: "missing_id", + input: &scf.Organization{ + Name: utils.Ptr("scf-missing-id"), + }, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{} + if tt.expected != nil { + state.ProjectId = tt.expected.ProjectId + } + err := mapFields(tt.input, state) + + if tt.isValid && err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("expected error, got nil") + } + if tt.isValid { + if diff := cmp.Diff(tt.expected, state); diff != "" { + t.Errorf("unexpected diff (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected scf.CreateOrganizationPayload + expectError bool + }{ + { + description: "default values", + input: &Model{ + Name: types.StringValue("example-org"), + PlatformId: types.StringValue(testPlatformId), + }, + expected: scf.CreateOrganizationPayload{ + Name: utils.Ptr("example-org"), + PlatformId: utils.Ptr(testPlatformId), + }, + expectError: false, + }, + { + description: "nil input model", + input: nil, + expected: scf.CreateOrganizationPayload{}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(tt.input) + + if tt.expectError && err == nil { + t.Fatalf("expected diagnostics error but got none") + } + + if !tt.expectError && err != nil { + t.Fatalf("unexpected diagnostics error: %v", err) + } + + if diff := cmp.Diff(tt.expected, output); diff != "" { + t.Fatalf("unexpected payload (-want +got):\n%s", diff) + } + }) + } +} diff --git a/stackit/internal/services/scf/organizationmanager/datasource.go b/stackit/internal/services/scf/organizationmanager/datasource.go new file mode 100644 index 000000000..bf64b31e7 --- /dev/null +++ b/stackit/internal/services/scf/organizationmanager/datasource.go @@ -0,0 +1,238 @@ +package organizationmanager + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/services/scf" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + scfUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &scfOrganizationManagerDataSource{} + _ datasource.DataSourceWithConfigure = &scfOrganizationManagerDataSource{} +) + +type DataSourceModel struct { + Id types.String `tfsdk:"id"` // Required by Terraform + Region types.String `tfsdk:"region"` + PlatformId types.String `tfsdk:"platform_id"` + ProjectId types.String `tfsdk:"project_id"` + OrgId types.String `tfsdk:"org_id"` + UserId types.String `tfsdk:"user_id"` + UserName types.String `tfsdk:"username"` + CreateAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +// NewScfOrganizationManagerDataSource creates a new instance of the scfOrganizationDataSource. +func NewScfOrganizationManagerDataSource() datasource.DataSource { + return &scfOrganizationManagerDataSource{} +} + +// scfOrganizationManagerDataSource is the datasource implementation. +type scfOrganizationManagerDataSource struct { + client *scf.APIClient + providerData core.ProviderData +} + +func (s *scfOrganizationManagerDataSource) Configure(ctx context.Context, request datasource.ConfigureRequest, response *datasource.ConfigureResponse) { + var ok bool + s.providerData, ok = conversion.ParseProviderData(ctx, request.ProviderData, &response.Diagnostics) + if !ok { + return + } + + apiClient := scfUtils.ConfigureClient(ctx, &s.providerData, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + s.client = apiClient + tflog.Info(ctx, "scf client configured for scfOrganizationManagerDataSource") +} + +func (s *scfOrganizationManagerDataSource) Metadata(_ context.Context, request datasource.MetadataRequest, response *datasource.MetadataResponse) { // nolint:gocritic // function signature required by Terraform + response.TypeName = request.ProviderTypeName + "_scf_organization_manager" +} + +func (s *scfOrganizationManagerDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, response *datasource.SchemaResponse) { // nolint:gocritic // function signature required by Terraform + response.Schema = schema.Schema{ + Description: "STACKIT Cloud Foundry organization manager datasource schema.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + Computed: true, + }, + "platform_id": schema.StringAttribute{ + Description: descriptions["platform_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "org_id": schema.StringAttribute{ + Description: descriptions["org_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "user_id": schema.StringAttribute{ + Description: descriptions["user_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "username": schema.StringAttribute{ + Description: descriptions["username"], + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + }, + }, + "created_at": schema.StringAttribute{ + Description: descriptions["created_at"], + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: descriptions["updated_at"], + Computed: true, + }, + }, + } +} + +func (s *scfOrganizationManagerDataSource) Read(ctx context.Context, request datasource.ReadRequest, response *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the current state of the resource. + var model DataSourceModel + diags := request.Config.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Extract the project ID and instance id of the model + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + + region := s.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_id", orgId) + ctx = tflog.SetField(ctx, "region", region) + // Read the current scf organization manager via orgId + ScfOrgManager, err := s.client.GetOrgManagerExecute(ctx, projectId, region, orgId) + if err != nil { + utils.LogError( + ctx, + &response.Diagnostics, + err, + "Reading scf organization manager", + fmt.Sprintf("Organization with ID %q does not exist in project %q.", orgId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Organization with ID %q not found or forbidden access", orgId), + }, + ) + response.State.RemoveResource(ctx) + return + } + + err = mapFieldsDataSource(ScfOrgManager, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading scf organization manager", fmt.Sprintf("Processing API response: %v", err)) + return + } + + // Set the updated state. + diags = response.State.Set(ctx, &model) + response.Diagnostics.Append(diags...) + tflog.Info(ctx, fmt.Sprintf("read scf organization manager %s", orgId)) +} + +func mapFieldsDataSource(response *scf.OrgManager, model *DataSourceModel) error { + if response == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var projectId string + if response.ProjectId != nil { + projectId = *response.ProjectId + } else if model.ProjectId.ValueString() != "" { + projectId = model.ProjectId.ValueString() + } else { + return fmt.Errorf("project id is not present") + } + + var region string + if response.Region != nil { + region = *response.Region + } else if model.Region.ValueString() != "" { + region = model.Region.ValueString() + } else { + return fmt.Errorf("region is not present") + } + + var orgId string + if response.OrgId != nil { + orgId = *response.OrgId + } else if model.OrgId.ValueString() != "" { + orgId = model.OrgId.ValueString() + } else { + return fmt.Errorf("org id is not present") + } + + var userId string + if response.Guid != nil { + userId = *response.Guid + if model.UserId.ValueString() != "" && userId != model.UserId.ValueString() { + return fmt.Errorf("user id mismatch in response and model") + } + } else if model.UserId.ValueString() != "" { + userId = model.UserId.ValueString() + } else { + return fmt.Errorf("user id is not present") + } + + model.Id = utils.BuildInternalTerraformId(projectId, region, orgId, userId) + model.Region = types.StringValue(region) + model.PlatformId = types.StringPointerValue(response.PlatformId) + model.ProjectId = types.StringValue(projectId) + model.OrgId = types.StringValue(orgId) + model.UserId = types.StringValue(userId) + model.UserName = types.StringPointerValue(response.Username) + model.CreateAt = types.StringValue(response.CreatedAt.String()) + model.UpdatedAt = types.StringValue(response.UpdatedAt.String()) + return nil +} diff --git a/stackit/internal/services/scf/organizationmanager/datasource_test.go b/stackit/internal/services/scf/organizationmanager/datasource_test.go new file mode 100644 index 000000000..4ed5e0040 --- /dev/null +++ b/stackit/internal/services/scf/organizationmanager/datasource_test.go @@ -0,0 +1,116 @@ +package organizationmanager + +import ( + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/scf" +) + +func TestMapFieldsDataSource(t *testing.T) { + createdTime, err := time.Parse("2006-01-02 15:04:05 -0700 MST", "2025-01-01 00:00:00 +0000 UTC") + if err != nil { + t.Fatalf("failed to parse test time: %v", err) + } + + tests := []struct { + description string + input *scf.OrgManager + expected *DataSourceModel + isValid bool + }{ + { + description: "minimal_input", + input: &scf.OrgManager{ + Guid: utils.Ptr(testUserId), + OrgId: utils.Ptr(testOrgId), + ProjectId: utils.Ptr(testProjectId), + Region: utils.Ptr(testRegion), + CreatedAt: &createdTime, + UpdatedAt: &createdTime, + }, + expected: &DataSourceModel{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", testProjectId, testRegion, testOrgId, testUserId)), + UserId: types.StringValue(testUserId), + OrgId: types.StringValue(testOrgId), + ProjectId: types.StringValue(testProjectId), + Region: types.StringValue(testRegion), + UserName: types.StringNull(), + PlatformId: types.StringNull(), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + }, + isValid: true, + }, + { + description: "max_input", + input: &scf.OrgManager{ + Guid: utils.Ptr(testUserId), + OrgId: utils.Ptr(testOrgId), + ProjectId: utils.Ptr(testProjectId), + PlatformId: utils.Ptr(testPlatformId), + Region: utils.Ptr(testRegion), + CreatedAt: &createdTime, + UpdatedAt: &createdTime, + Username: utils.Ptr("test-user"), + }, + expected: &DataSourceModel{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", testProjectId, testRegion, testOrgId, testUserId)), + UserId: types.StringValue(testUserId), + OrgId: types.StringValue(testOrgId), + ProjectId: types.StringValue(testProjectId), + PlatformId: types.StringValue(testPlatformId), + Region: types.StringValue(testRegion), + UserName: types.StringValue("test-user"), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + }, + isValid: true, + }, + { + description: "nil_org", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "empty_org", + input: &scf.OrgManager{}, + expected: nil, + isValid: false, + }, + { + description: "missing_id", + input: &scf.OrgManager{ + Username: utils.Ptr("scf-missing-id"), + }, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &DataSourceModel{} + if tt.expected != nil { + state.ProjectId = tt.expected.ProjectId + } + err := mapFieldsDataSource(tt.input, state) + + if tt.isValid && err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("expected error, got nil") + } + if tt.isValid { + if diff := cmp.Diff(tt.expected, state); diff != "" { + t.Errorf("unexpected diff (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/scf/organizationmanager/resource.go b/stackit/internal/services/scf/organizationmanager/resource.go new file mode 100644 index 000000000..1d823b676 --- /dev/null +++ b/stackit/internal/services/scf/organizationmanager/resource.go @@ -0,0 +1,471 @@ +package organizationmanager + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/scf" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + scfUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &scfOrganizationManagerResource{} + _ resource.ResourceWithConfigure = &scfOrganizationManagerResource{} + _ resource.ResourceWithImportState = &scfOrganizationManagerResource{} + _ resource.ResourceWithModifyPlan = &scfOrganizationManagerResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // Required by Terraform + Region types.String `tfsdk:"region"` + PlatformId types.String `tfsdk:"platform_id"` + ProjectId types.String `tfsdk:"project_id"` + OrgId types.String `tfsdk:"org_id"` + UserId types.String `tfsdk:"user_id"` + UserName types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + CreateAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +// NewScfOrganizationManagerResource is a helper function to create a new scf organization manager resource. +func NewScfOrganizationManagerResource() resource.Resource { + return &scfOrganizationManagerResource{} +} + +// scfOrganizationManagerResource implements the resource interface for scf organization manager. +type scfOrganizationManagerResource struct { + client *scf.APIClient + providerData core.ProviderData +} + +// descriptions for the attributes in the Schema +var descriptions = map[string]string{ + "id": "Terraform's internal resource ID, structured as \"`project_id`,`region`,`org_id`,`user_id`\".", + "region": "The region where the organization of the organization manager is located. If not defined, the provider region is used", + "platform_id": "The ID of the platform associated with the organization of the organization manager", + "project_id": "The ID of the project associated with the organization of the organization manager", + "org_id": "The ID of the Cloud Foundry Organization", + "user_id": "The ID of the organization manager user", + "username": "An auto-generated organization manager user name", + "password": "An auto-generated password", + "created_at": "The time when the organization manager was created", + "updated_at": "The time when the organization manager was last updated", +} + +func (s *scfOrganizationManagerResource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) { // nolint:gocritic // function signature required by Terraform + var ok bool + s.providerData, ok = conversion.ParseProviderData(ctx, request.ProviderData, &response.Diagnostics) + if !ok { + return + } + + apiClient := scfUtils.ConfigureClient(ctx, &s.providerData, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + s.client = apiClient + tflog.Info(ctx, "scf client configured") +} + +func (s *scfOrganizationManagerResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { // nolint:gocritic // function signature required by Terraform + response.TypeName = request.ProviderTypeName + "_scf_organization_manager" +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *scfOrganizationManagerResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + 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 + } + + utils.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 (s *scfOrganizationManagerResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) { // nolint:gocritic // function signature required by Terraform + response.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "platform_id": schema.StringAttribute{ + Description: descriptions["platform_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "org_id": schema.StringAttribute{ + Description: descriptions["org_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "user_id": schema.StringAttribute{ + Description: descriptions["user_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "username": schema.StringAttribute{ + Description: descriptions["username"], + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + }, + }, + "password": schema.StringAttribute{ + Description: descriptions["password"], + Computed: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + }, + }, + "created_at": schema.StringAttribute{ + Description: descriptions["created_at"], + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: descriptions["updated_at"], + Computed: true, + }, + }, + Description: "STACKIT Cloud Foundry organization manager resource schema.", + } +} + +func (s *scfOrganizationManagerResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the planned values for the resource. + var model Model + diags := request.Plan.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Set logging context with the project ID and username. + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + userName := model.UserName.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "username", userName) + ctx = tflog.SetField(ctx, "region", region) + + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Create the new scf organization manager via the API client. + scfOrgManagerCreateResponse, err := s.client.CreateOrgManagerExecute(ctx, projectId, region, orgId) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization manager", fmt.Sprintf("Calling API to create org manager: %v", err)) + return + } + + err = mapFieldsCreate(scfOrgManagerCreateResponse, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", fmt.Sprintf("Mapping fields: %v", err)) + return + } + + // Set the state with fully populated data. + diags = response.State.Set(ctx, model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Scf organization created") +} + +func (s *scfOrganizationManagerResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the current state of the resource. + var model Model + diags := request.State.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Extract the project ID, region and org id of the model + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + region := s.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_id", orgId) + ctx = tflog.SetField(ctx, "region", region) + + // Read the current scf organization manager via orgId + scfOrgManager, err := s.client.GetOrgManagerExecute(ctx, projectId, region, orgId) + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok && oapiErr.StatusCode == http.StatusNotFound { + core.LogAndAddWarning(ctx, &response.Diagnostics, "SCF Organization manager not found", "SCF Organization manager not found, remove from state") + response.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading scf organization manager", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFieldsRead(scfOrgManager, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading scf organization manager", fmt.Sprintf("Processing API response: %v", err)) + return + } + + // Set the updated state. + diags = response.State.Set(ctx, &model) + response.Diagnostics.Append(diags...) + tflog.Info(ctx, fmt.Sprintf("read scf organization %s", orgId)) +} + +func (s *scfOrganizationManagerResource) Update(ctx context.Context, _ resource.UpdateRequest, response *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // organization manager cannot be updated, so we log an error. + core.LogAndAddError(ctx, &response.Diagnostics, "Error updating organization manager", "Organization Manager can't be updated") +} + +func (s *scfOrganizationManagerResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve current state of the resource. + var model Model + diags := request.State.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + orgId := model.OrgId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "org_id", orgId) + ctx = tflog.SetField(ctx, "region", region) + + // Call API to delete the existing scf organization manager. + _, err := s.client.DeleteOrgManagerExecute(ctx, projectId, region, orgId) + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok && oapiErr.StatusCode == http.StatusGone { + tflog.Info(ctx, "Scf organization manager was already deleted") + return + } + core.LogAndAddError(ctx, &response.Diagnostics, "Error deleting scf organization manager", fmt.Sprintf("Calling API: %v", err)) + return + } + tflog.Info(ctx, "Scf organization manager deleted") +} + +func (s *scfOrganizationManagerResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { // nolint:gocritic // function signature required by Terraform + // Split the import identifier to extract project ID, region org ID and user ID. + idParts := strings.Split(request.ID, core.Separator) + + // Ensure the import identifier format is correct. + if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" { + core.LogAndAddError(ctx, &response.Diagnostics, + "Error importing scf organization manager", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[org_id],[user_id] Got: %q", request.ID), + ) + return + } + + projectId := idParts[0] + region := idParts[1] + orgId := idParts[2] + userId := idParts[3] + // Set the project id, region organization id and user id in the state + response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("region"), region)...) + response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("org_id"), orgId)...) + response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("user_id"), userId)...) + tflog.Info(ctx, "Scf organization manager state imported") +} + +func mapFieldsCreate(response *scf.OrgManagerResponse, model *Model) error { + if response == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var projectId string + if response.ProjectId != nil { + projectId = *response.ProjectId + } else if model.ProjectId.ValueString() != "" { + projectId = model.ProjectId.ValueString() + } else { + return fmt.Errorf("project id is not present") + } + + var region string + if response.Region != nil { + region = *response.Region + } else if model.Region.ValueString() != "" { + region = model.Region.ValueString() + } else { + return fmt.Errorf("region is not present") + } + + var orgId string + if response.OrgId != nil { + orgId = *response.OrgId + } else if model.OrgId.ValueString() != "" { + orgId = model.OrgId.ValueString() + } else { + return fmt.Errorf("org id is not present") + } + + var userId string + if response.Guid != nil { + userId = *response.Guid + } else if model.UserId.ValueString() != "" { + userId = model.UserId.ValueString() + } else { + return fmt.Errorf("user id is not present") + } + + model.Id = utils.BuildInternalTerraformId(projectId, region, orgId, userId) + model.Region = types.StringValue(region) + model.PlatformId = types.StringPointerValue(response.PlatformId) + model.ProjectId = types.StringValue(projectId) + model.OrgId = types.StringValue(orgId) + model.UserId = types.StringValue(userId) + model.UserName = types.StringPointerValue(response.Username) + model.Password = types.StringPointerValue(response.Password) + model.CreateAt = types.StringValue(response.CreatedAt.String()) + model.UpdatedAt = types.StringValue(response.UpdatedAt.String()) + return nil +} + +func mapFieldsRead(response *scf.OrgManager, model *Model) error { + if response == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var projectId string + if response.ProjectId != nil { + projectId = *response.ProjectId + } else if model.ProjectId.ValueString() != "" { + projectId = model.ProjectId.ValueString() + } else { + return fmt.Errorf("project id is not present") + } + + var region string + if response.Region != nil { + region = *response.Region + } else if model.Region.ValueString() != "" { + region = model.Region.ValueString() + } else { + return fmt.Errorf("region is not present") + } + + var orgId string + if response.OrgId != nil { + orgId = *response.OrgId + } else if model.OrgId.ValueString() != "" { + orgId = model.OrgId.ValueString() + } else { + return fmt.Errorf("org id is not present") + } + + var userId string + if response.Guid != nil { + userId = *response.Guid + if model.UserId.ValueString() != "" && userId != model.UserId.ValueString() { + return fmt.Errorf("user id mismatch in response and model") + } + } else if model.UserId.ValueString() != "" { + userId = model.UserId.ValueString() + } else { + return fmt.Errorf("user id is not present") + } + + model.Id = utils.BuildInternalTerraformId(projectId, region, orgId, userId) + model.Region = types.StringValue(region) + model.PlatformId = types.StringPointerValue(response.PlatformId) + model.ProjectId = types.StringValue(projectId) + model.OrgId = types.StringValue(orgId) + model.UserId = types.StringValue(userId) + model.UserName = types.StringPointerValue(response.Username) + model.CreateAt = types.StringValue(response.CreatedAt.String()) + model.UpdatedAt = types.StringValue(response.UpdatedAt.String()) + return nil +} diff --git a/stackit/internal/services/scf/organizationmanager/resource_test.go b/stackit/internal/services/scf/organizationmanager/resource_test.go new file mode 100644 index 000000000..1ca257596 --- /dev/null +++ b/stackit/internal/services/scf/organizationmanager/resource_test.go @@ -0,0 +1,233 @@ +package organizationmanager + +import ( + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/scf" +) + +var ( + testOrgId = uuid.New().String() + testProjectId = uuid.New().String() + testPlatformId = uuid.New().String() + testUserId = uuid.New().String() + testRegion = "eu01" +) + +func TestMapFields(t *testing.T) { + createdTime, err := time.Parse("2006-01-02 15:04:05 -0700 MST", "2025-01-01 00:00:00 +0000 UTC") + if err != nil { + t.Fatalf("failed to parse test time: %v", err) + } + + tests := []struct { + description string + input *scf.OrgManager + expected *Model + isValid bool + }{ + { + description: "minimal_input", + input: &scf.OrgManager{ + Guid: utils.Ptr(testUserId), + OrgId: utils.Ptr(testOrgId), + ProjectId: utils.Ptr(testProjectId), + Region: utils.Ptr(testRegion), + CreatedAt: &createdTime, + UpdatedAt: &createdTime, + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", testProjectId, testRegion, testOrgId, testUserId)), + UserId: types.StringValue(testUserId), + OrgId: types.StringValue(testOrgId), + ProjectId: types.StringValue(testProjectId), + Region: types.StringValue(testRegion), + UserName: types.StringNull(), + PlatformId: types.StringNull(), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + }, + isValid: true, + }, + { + description: "max_input", + input: &scf.OrgManager{ + Guid: utils.Ptr(testUserId), + OrgId: utils.Ptr(testOrgId), + ProjectId: utils.Ptr(testProjectId), + PlatformId: utils.Ptr(testPlatformId), + Region: utils.Ptr(testRegion), + CreatedAt: &createdTime, + UpdatedAt: &createdTime, + Username: utils.Ptr("test-user"), + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", testProjectId, testRegion, testOrgId, testUserId)), + UserId: types.StringValue(testUserId), + OrgId: types.StringValue(testOrgId), + ProjectId: types.StringValue(testProjectId), + PlatformId: types.StringValue(testPlatformId), + Region: types.StringValue(testRegion), + Password: types.StringNull(), + UserName: types.StringValue("test-user"), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + }, + isValid: true, + }, + { + description: "nil_org", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "empty_org", + input: &scf.OrgManager{}, + expected: nil, + isValid: false, + }, + { + description: "missing_id", + input: &scf.OrgManager{ + Username: utils.Ptr("scf-missing-id"), + }, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{} + if tt.expected != nil { + state.ProjectId = tt.expected.ProjectId + } + err := mapFieldsRead(tt.input, state) + + if tt.isValid && err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("expected error, got nil") + } + if tt.isValid { + if diff := cmp.Diff(tt.expected, state); diff != "" { + t.Errorf("unexpected diff (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestMapFieldsCreate(t *testing.T) { + createdTime, err := time.Parse("2006-01-02 15:04:05 -0700 MST", "2025-01-01 00:00:00 +0000 UTC") + if err != nil { + t.Fatalf("failed to parse test time: %v", err) + } + + tests := []struct { + description string + input *scf.OrgManagerResponse + expected *Model + isValid bool + }{ + { + description: "minimal_input", + input: &scf.OrgManagerResponse{ + Guid: utils.Ptr(testUserId), + OrgId: utils.Ptr(testOrgId), + ProjectId: utils.Ptr(testProjectId), + Region: utils.Ptr(testRegion), + CreatedAt: &createdTime, + UpdatedAt: &createdTime, + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", testProjectId, testRegion, testOrgId, testUserId)), + UserId: types.StringValue(testUserId), + OrgId: types.StringValue(testOrgId), + ProjectId: types.StringValue(testProjectId), + Region: types.StringValue(testRegion), + UserName: types.StringNull(), + PlatformId: types.StringNull(), + Password: types.StringNull(), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + }, + isValid: true, + }, + { + description: "max_input", + input: &scf.OrgManagerResponse{ + Guid: utils.Ptr(testUserId), + OrgId: utils.Ptr(testOrgId), + ProjectId: utils.Ptr(testProjectId), + PlatformId: utils.Ptr(testPlatformId), + Region: utils.Ptr(testRegion), + CreatedAt: &createdTime, + UpdatedAt: &createdTime, + Username: utils.Ptr("test-user"), + Password: utils.Ptr("test-password"), + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s,%s", testProjectId, testRegion, testOrgId, testUserId)), + UserId: types.StringValue(testUserId), + OrgId: types.StringValue(testOrgId), + ProjectId: types.StringValue(testProjectId), + PlatformId: types.StringValue(testPlatformId), + Region: types.StringValue(testRegion), + UserName: types.StringValue("test-user"), + Password: types.StringValue("test-password"), + CreateAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + UpdatedAt: types.StringValue("2025-01-01 00:00:00 +0000 UTC"), + }, + isValid: true, + }, + { + description: "nil_org", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "empty_org", + input: &scf.OrgManagerResponse{}, + expected: nil, + isValid: false, + }, + { + description: "missing_id", + input: &scf.OrgManagerResponse{ + Username: utils.Ptr("scf-missing-id"), + }, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{} + if tt.expected != nil { + state.ProjectId = tt.expected.ProjectId + } + err := mapFieldsCreate(tt.input, state) + + if tt.isValid && err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("expected error, got nil") + } + if tt.isValid { + if diff := cmp.Diff(tt.expected, state); diff != "" { + t.Errorf("unexpected diff (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/scf/platform/datasource.go b/stackit/internal/services/scf/platform/datasource.go new file mode 100644 index 000000000..a4bfacc1e --- /dev/null +++ b/stackit/internal/services/scf/platform/datasource.go @@ -0,0 +1,219 @@ +package platform + +import ( + "context" + "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/services/scf" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + scfUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &scfPlatformDataSource{} + _ datasource.DataSourceWithConfigure = &scfPlatformDataSource{} +) + +// NewScfPlatformDataSource creates a new instance of the ScfPlatformDataSource. +func NewScfPlatformDataSource() datasource.DataSource { + return &scfPlatformDataSource{} +} + +// scfPlatformDataSource is the datasource implementation. +type scfPlatformDataSource struct { + client *scf.APIClient + providerData core.ProviderData +} + +func (s *scfPlatformDataSource) Configure(ctx context.Context, request datasource.ConfigureRequest, response *datasource.ConfigureResponse) { + var ok bool + s.providerData, ok = conversion.ParseProviderData(ctx, request.ProviderData, &response.Diagnostics) + if !ok { + return + } + + apiClient := scfUtils.ConfigureClient(ctx, &s.providerData, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + s.client = apiClient + tflog.Info(ctx, "scf client configured for platform") +} + +func (s *scfPlatformDataSource) Metadata(_ context.Context, request datasource.MetadataRequest, response *datasource.MetadataResponse) { // nolint:gocritic // function signature required by Terraform + response.TypeName = request.ProviderTypeName + "_scf_platform" +} + +type Model struct { + Id types.String `tfsdk:"id"` // Required by Terraform + PlatformId types.String `tfsdk:"platform_id"` + ProjectId types.String `tfsdk:"project_id"` + SystemId types.String `tfsdk:"system_id"` + DisplayName types.String `tfsdk:"display_name"` + Region types.String `tfsdk:"region"` + ApiUrl types.String `tfsdk:"api_url"` + ConsoleUrl types.String `tfsdk:"console_url"` +} + +// descriptions for the attributes in the Schema +var descriptions = map[string]string{ + "id": "Terraform's internal resource ID, structured as \"`project_id`,`region`,`platform_id`\".", + "platform_id": "The unique id of the platform", + "project_id": "The ID of the project associated with the platform", + "system_id": "The ID of the platform System", + "display_name": "The name of the platform", + "region": "The region where the platform is located. If not defined, the provider region is used", + "api_url": "The CF API Url of the platform", + "console_url": "The Stratos URL of the platform", +} + +func (s *scfPlatformDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, response *datasource.SchemaResponse) { // nolint:gocritic // function signature required by Terraform + response.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "platform_id": schema.StringAttribute{ + Description: descriptions["platform_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "system_id": schema.StringAttribute{ + Description: descriptions["system_id"], + Computed: true, + }, + "display_name": schema.StringAttribute{ + Description: descriptions["display_name"], + Computed: true, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + Computed: true, + }, + "api_url": schema.StringAttribute{ + Description: descriptions["api_url"], + Computed: true, + }, + "console_url": schema.StringAttribute{ + Description: descriptions["console_url"], + Computed: true, + }, + }, + Description: "STACKIT Cloud Foundry Platform datasource schema.", + } +} + +func (s *scfPlatformDataSource) Read(ctx context.Context, request datasource.ReadRequest, response *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the current state of the resource. + var model Model + diags := request.Config.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + // Extract the project ID region and platform id of the model + projectId := model.ProjectId.ValueString() + platformId := model.PlatformId.ValueString() + region := s.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "platform_id", platformId) + ctx = tflog.SetField(ctx, "region", region) + + // Read the scf platform + scfPlatformResponse, err := s.client.GetPlatformExecute(ctx, projectId, region, platformId) + if err != nil { + utils.LogError( + ctx, + &response.Diagnostics, + err, + "Reading scf platform", + fmt.Sprintf("Platform with ID %q does not exist in project %q.", platformId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Platform with ID %q not found or forbidden access", platformId), + }, + ) + response.State.RemoveResource(ctx) + return + } + + err = mapFields(scfPlatformResponse, &model) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading scf platform", fmt.Sprintf("Processing API response: %v", err)) + return + } + + // Set the updated state. + diags = response.State.Set(ctx, &model) + response.Diagnostics.Append(diags...) + tflog.Info(ctx, fmt.Sprintf("read scf Platform %s", platformId)) +} + +// mapFields maps a SCF Platform response to the model. +func mapFields(response *scf.Platforms, model *Model) error { + if response == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var projectId string + if model.ProjectId.ValueString() == "" { + return fmt.Errorf("project id is not present") + } + projectId = model.ProjectId.ValueString() + + var region string + if response.Region != nil { + region = *response.Region + } else if model.Region.ValueString() != "" { + region = model.Region.ValueString() + } else { + return fmt.Errorf("region is not present") + } + + var platformId string + if response.Guid != nil { + platformId = *response.Guid + } else if model.PlatformId.ValueString() != "" { + platformId = model.PlatformId.ValueString() + } else { + return fmt.Errorf("platform id is not present") + } + + // Build the ID + model.Id = utils.BuildInternalTerraformId(projectId, region, platformId) + model.PlatformId = types.StringValue(platformId) + model.ProjectId = types.StringValue(projectId) + model.SystemId = types.StringPointerValue(response.SystemId) + model.DisplayName = types.StringPointerValue(response.DisplayName) + model.Region = types.StringValue(region) + model.ApiUrl = types.StringPointerValue(response.ApiUrl) + model.ConsoleUrl = types.StringPointerValue(response.ConsoleUrl) + return nil +} diff --git a/stackit/internal/services/scf/platform/datasource_test.go b/stackit/internal/services/scf/platform/datasource_test.go new file mode 100644 index 000000000..b15ee231c --- /dev/null +++ b/stackit/internal/services/scf/platform/datasource_test.go @@ -0,0 +1,109 @@ +package platform + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/scf" +) + +var ( + testProjectId = uuid.New().String() + testPlatformId = uuid.New().String() + testRegion = "eu01" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *scf.Platforms + expected *Model + isValid bool + }{ + { + description: "minimal_input", + input: &scf.Platforms{ + Guid: utils.Ptr(testPlatformId), + Region: utils.Ptr(testRegion), + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s", testProjectId, testRegion, testPlatformId)), + PlatformId: types.StringValue(testPlatformId), + ProjectId: types.StringValue(testProjectId), + Region: types.StringValue(testRegion), + SystemId: types.StringNull(), + DisplayName: types.StringNull(), + ApiUrl: types.StringNull(), + ConsoleUrl: types.StringNull(), + }, + isValid: true, + }, + { + description: "max_input", + input: &scf.Platforms{ + Guid: utils.Ptr(testPlatformId), + SystemId: utils.Ptr("eu01.01"), + DisplayName: utils.Ptr("scf-full-org"), + Region: utils.Ptr(testRegion), + ApiUrl: utils.Ptr("https://example.scf.stackit.cloud"), + ConsoleUrl: utils.Ptr("https://example.console.scf.stackit.cloud"), + }, + expected: &Model{ + Id: types.StringValue(fmt.Sprintf("%s,%s,%s", testProjectId, testRegion, testPlatformId)), + ProjectId: types.StringValue(testProjectId), + PlatformId: types.StringValue(testPlatformId), + Region: types.StringValue(testRegion), + SystemId: types.StringValue("eu01.01"), + DisplayName: types.StringValue("scf-full-org"), + ApiUrl: types.StringValue("https://example.scf.stackit.cloud"), + ConsoleUrl: types.StringValue("https://example.console.scf.stackit.cloud"), + }, + isValid: true, + }, + { + description: "nil_org", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "empty_org", + input: &scf.Platforms{}, + expected: nil, + isValid: false, + }, + { + description: "missing_id", + input: &scf.Platforms{ + DisplayName: utils.Ptr("scf-missing-id"), + }, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{} + if tt.expected != nil { + state.ProjectId = tt.expected.ProjectId + } + err := mapFields(tt.input, state) + + if tt.isValid && err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("expected error, got nil") + } + if tt.isValid { + if diff := cmp.Diff(tt.expected, state); diff != "" { + t.Errorf("unexpected diff (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/scf/scf_acc_test.go b/stackit/internal/services/scf/scf_acc_test.go new file mode 100644 index 000000000..3003ba6d6 --- /dev/null +++ b/stackit/internal/services/scf/scf_acc_test.go @@ -0,0 +1,456 @@ +package scf + +import ( + "context" + _ "embed" + "fmt" + "maps" + "strings" + "testing" + + "github.com/stackitcloud/stackit-sdk-go/services/scf" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + stackitSdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + + "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 randName = acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) +var nameMin = fmt.Sprintf("scf-min-%s-org", randName) +var nameMinUpdated = fmt.Sprintf("scf-min-%s-upd-org", randName) +var nameMax = fmt.Sprintf("scf-max-%s-org", randName) +var nameMaxUpdated = fmt.Sprintf("scf-max-%s-upd-org", randName) + +const ( + platformName = "Shared Cloud Foundry (public)" + platformSystemId = "01.cf.eu01" + platformIdMax = "0a3d1188-353a-4004-832c-53039c0e3868" + platformApiUrl = "https://api.system.01.cf.eu01.stackit.cloud" + platformConsoleUrl = "https://console.apps.01.cf.eu01.stackit.cloud" + quotaIdMax = "e22cfe1a-0318-473f-88db-61d62dc629c0" // small + quotaIdMaxUpdated = "5ea6b9ab-4048-4bd9-8a8a-5dd7fc40745d" // medium + suspendedMax = true + region = "eu01" +) + +var testConfigVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(nameMin), +} + +var testConfigVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(nameMax), + "platform_id": config.StringVariable(platformIdMax), + "quota_id": config.StringVariable(quotaIdMax), + "suspended": config.BoolVariable(suspendedMax), + "region": config.StringVariable(region), +} + +func testScfOrgConfigVarsMinUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigVarsMin)) + maps.Copy(tempConfig, testConfigVarsMin) + // update scf organization to a new name + tempConfig["name"] = config.StringVariable(nameMinUpdated) + return tempConfig +} + +func testScfOrgConfigVarsMaxUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigVarsMax)) + maps.Copy(tempConfig, testConfigVarsMax) + // update scf organization to a new name, unsuspend it and assign a new quota + tempConfig["name"] = config.StringVariable(nameMaxUpdated) + tempConfig["quota_id"] = config.StringVariable(quotaIdMaxUpdated) + tempConfig["suspended"] = config.BoolVariable(!suspendedMax) + return tempConfig +} + +func TestAccScfOrganizationMin(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckScfOrganizationDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigVarsMin, + Config: testutil.ScfProviderConfig() + resourceMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_scf_organization.org", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "name", testutil.ConvertConfigVariable(testConfigVarsMin["name"])), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "created_at"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "platform_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "org_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "quota_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "region"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "status"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "suspended"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "updated_at"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "org_id"), + resource.TestCheckResourceAttr("stackit_scf_organization_manager.orgmanager", "platform_id", testutil.ConvertConfigVariable(testConfigVarsMax["platform_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization_manager.orgmanager", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "user_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "username"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "password"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "created_at"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "updated_at"), + ), + }, + // Data source + { + ConfigVariables: testConfigVarsMin, + Config: fmt.Sprintf(` + %s + data "stackit_scf_organization" "org" { + project_id = stackit_scf_organization.org.project_id + org_id = stackit_scf_organization.org.org_id + } + data "stackit_scf_organization_manager" "orgmanager" { + org_id = stackit_scf_organization.org.org_id + project_id = stackit_scf_organization.org.project_id + } + `, testutil.ScfProviderConfig()+resourceMin, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_scf_organization.org", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "project_id", + "data.stackit_scf_organization.org", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "created_at", + "data.stackit_scf_organization.org", "created_at", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "name", + "data.stackit_scf_organization.org", "name", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "platform_id", + "data.stackit_scf_organization.org", "platform_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "org_id", + "data.stackit_scf_organization.org", "org_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "quota_id", + "data.stackit_scf_organization.org", "quota_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "region", + "data.stackit_scf_organization.org", "region", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "status", + "data.stackit_scf_organization.org", "status", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "suspended", + "data.stackit_scf_organization.org", "suspended", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "updated_at", + "data.stackit_scf_organization.org", "updated_at", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "region", + "data.stackit_scf_organization_manager.orgmanager", "region", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "platform_id", + "data.stackit_scf_organization_manager.orgmanager", "platform_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "project_id", + "data.stackit_scf_organization_manager.orgmanager", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "org_id", + "data.stackit_scf_organization_manager.orgmanager", "org_id", + ), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "user_id"), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "username"), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "created_at"), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "updated_at"), + ), + }, + // Import + { + ConfigVariables: testConfigVarsMin, + ResourceName: "stackit_scf_organization.org", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_scf_organization.org"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_scf_organization.org") + } + orgId, ok := r.Primary.Attributes["org_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute org_id") + } + regionInAttributes, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, regionInAttributes, orgId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testScfOrgConfigVarsMinUpdated(), + Config: testutil.ScfProviderConfig() + resourceMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_scf_organization.org", "project_id", testutil.ConvertConfigVariable(testScfOrgConfigVarsMinUpdated()["project_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "name", testutil.ConvertConfigVariable(testScfOrgConfigVarsMinUpdated()["name"])), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "created_at"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "platform_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "org_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "quota_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "region"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "suspended"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "updated_at"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccScfOrgMax(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckScfOrganizationDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigVarsMax, + Config: testutil.ScfProviderConfig() + resourceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_scf_organization.org", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "platform_id", testutil.ConvertConfigVariable(testConfigVarsMax["platform_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "quota_id", testutil.ConvertConfigVariable(testConfigVarsMax["quota_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "suspended", testutil.ConvertConfigVariable(testConfigVarsMax["suspended"])), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "created_at"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "org_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "region"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "updated_at"), + resource.TestCheckResourceAttr("data.stackit_scf_platform.scf_platform", "platform_id", testutil.ConvertConfigVariable(testConfigVarsMax["platform_id"])), + resource.TestCheckResourceAttr("data.stackit_scf_platform.scf_platform", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("data.stackit_scf_platform.scf_platform", "display_name", platformName), + resource.TestCheckResourceAttr("data.stackit_scf_platform.scf_platform", "system_id", platformSystemId), + resource.TestCheckResourceAttr("data.stackit_scf_platform.scf_platform", "api_url", platformApiUrl), + resource.TestCheckResourceAttr("data.stackit_scf_platform.scf_platform", "console_url", platformConsoleUrl), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "org_id"), + resource.TestCheckResourceAttr("stackit_scf_organization_manager.orgmanager", "platform_id", testutil.ConvertConfigVariable(testConfigVarsMax["platform_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization_manager.orgmanager", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "user_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "username"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "password"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "created_at"), + resource.TestCheckResourceAttrSet("stackit_scf_organization_manager.orgmanager", "updated_at"), + ), + }, + // Data source + { + ConfigVariables: testConfigVarsMax, + Config: fmt.Sprintf(` + %s + data "stackit_scf_organization" "org" { + project_id = stackit_scf_organization.org.project_id + org_id = stackit_scf_organization.org.org_id + region = var.region + } + data "stackit_scf_organization_manager" "orgmanager" { + org_id = stackit_scf_organization.org.org_id + project_id = stackit_scf_organization.org.project_id + } + data "stackit_scf_platform" "platform" { + platform_id = stackit_scf_organization.org.platform_id + project_id = stackit_scf_organization.org.project_id + } + `, testutil.ScfProviderConfig()+resourceMax, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_scf_organization.org", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "project_id", + "data.stackit_scf_organization.org", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "created_at", + "data.stackit_scf_organization.org", "created_at", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "name", + "data.stackit_scf_organization.org", "name", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "platform_id", + "data.stackit_scf_organization.org", "platform_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "org_id", + "data.stackit_scf_organization.org", "org_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "quota_id", + "data.stackit_scf_organization.org", "quota_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "region", + "data.stackit_scf_organization.org", "region", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "status", + "data.stackit_scf_organization.org", "status", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "suspended", + "data.stackit_scf_organization.org", "suspended", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "updated_at", + "data.stackit_scf_organization.org", "updated_at", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "platform_id", + "data.stackit_scf_platform.platform", "platform_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "project_id", + "data.stackit_scf_platform.platform", "project_id", + ), + resource.TestCheckResourceAttr("data.stackit_scf_platform.platform", "display_name", platformName), + resource.TestCheckResourceAttr("data.stackit_scf_platform.platform", "system_id", platformSystemId), + resource.TestCheckResourceAttr("data.stackit_scf_platform.platform", "display_name", platformName), + resource.TestCheckResourceAttr("data.stackit_scf_platform.platform", "region", region), + resource.TestCheckResourceAttr("data.stackit_scf_platform.platform", "api_url", platformApiUrl), + resource.TestCheckResourceAttr("data.stackit_scf_platform.platform", "console_url", platformConsoleUrl), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "region", + "data.stackit_scf_organization_manager.orgmanager", "region", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "platform_id", + "data.stackit_scf_organization_manager.orgmanager", "platform_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "project_id", + "data.stackit_scf_organization_manager.orgmanager", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_scf_organization.org", "org_id", + "data.stackit_scf_organization_manager.orgmanager", "org_id", + ), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "user_id"), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "username"), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "created_at"), + resource.TestCheckResourceAttrSet("data.stackit_scf_organization_manager.orgmanager", "updated_at"), + ), + }, + // Import + { + ConfigVariables: testConfigVarsMax, + ResourceName: "stackit_scf_organization.org", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_scf_organization.org"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_scf_organization.org") + } + orgId, ok := r.Primary.Attributes["org_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute org_id") + } + regionInAttributes, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, regionInAttributes, orgId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testScfOrgConfigVarsMaxUpdated(), + Config: testutil.ScfProviderConfig() + resourceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_scf_organization.org", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "name", testutil.ConvertConfigVariable(testScfOrgConfigVarsMaxUpdated()["name"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "platform_id", testutil.ConvertConfigVariable(testConfigVarsMax["platform_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "quota_id", testutil.ConvertConfigVariable(testScfOrgConfigVarsMaxUpdated()["quota_id"])), + resource.TestCheckResourceAttr("stackit_scf_organization.org", "suspended", testutil.ConvertConfigVariable(testScfOrgConfigVarsMaxUpdated()["suspended"])), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "created_at"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "org_id"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "region"), + resource.TestCheckResourceAttrSet("stackit_scf_organization.org", "updated_at"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckScfOrganizationDestroy(s *terraform.State) error { + ctx := context.Background() + var client *scf.APIClient + var err error + + if testutil.ScfCustomEndpoint == "" { + client, err = scf.NewAPIClient() + } else { + client, err = scf.NewAPIClient( + stackitSdkConfig.WithEndpoint(testutil.ScfCustomEndpoint), + ) + } + + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + var orgsToDestroy []string + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_scf_organization" { + continue + } + orgId := strings.Split(rs.Primary.ID, core.Separator)[1] + orgsToDestroy = append(orgsToDestroy, orgId) + } + + organizationsList, err := client.ListOrganizations(ctx, testutil.ProjectId, testutil.Region).Execute() + if err != nil { + return fmt.Errorf("getting scf organizations: %w", err) + } + + scfOrgs := organizationsList.GetResources() + for i := range scfOrgs { + if scfOrgs[i].Guid == nil { + continue + } + if utils.Contains(orgsToDestroy, *scfOrgs[i].Guid) { + _, err := client.DeleteOrganizationExecute(ctx, testutil.ProjectId, testutil.Region, *scfOrgs[i].Guid) + if err != nil { + return fmt.Errorf("destroying scf organization %s during CheckDestroy: %w", *scfOrgs[i].Guid, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/scf/testdata/resource-max.tf b/stackit/internal/services/scf/testdata/resource-max.tf new file mode 100644 index 000000000..f17a46bb2 --- /dev/null +++ b/stackit/internal/services/scf/testdata/resource-max.tf @@ -0,0 +1,23 @@ + +variable "project_id" {} +variable "name" {} +variable "quota_id" {} +variable "suspended" {} +variable "region" {} + +resource "stackit_scf_organization" "org" { + project_id = var.project_id + name = var.name + suspended = var.suspended + quota_id = var.quota_id + region = var.region +} + +resource "stackit_scf_organization_manager" "orgmanager" { + project_id = var.project_id + org_id = stackit_scf_organization.org.org_id +} +data "stackit_scf_platform" "scf_platform" { + project_id = var.project_id + platform_id = stackit_scf_organization.org.platform_id +} \ No newline at end of file diff --git a/stackit/internal/services/scf/testdata/resource-min.tf b/stackit/internal/services/scf/testdata/resource-min.tf new file mode 100644 index 000000000..f2d11ef39 --- /dev/null +++ b/stackit/internal/services/scf/testdata/resource-min.tf @@ -0,0 +1,13 @@ + +variable "project_id" {} +variable "name" {} + +resource "stackit_scf_organization" "org" { + project_id = var.project_id + name = var.name +} + +resource "stackit_scf_organization_manager" "orgmanager" { + project_id = var.project_id + org_id = stackit_scf_organization.org.org_id +} \ No newline at end of file diff --git a/stackit/internal/services/scf/utils/utils.go b/stackit/internal/services/scf/utils/utils.go new file mode 100644 index 000000000..347e109bb --- /dev/null +++ b/stackit/internal/services/scf/utils/utils.go @@ -0,0 +1,30 @@ +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/scf" + + "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) *scf.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.ScfCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ScfCustomEndpoint)) + } + apiClient, err := scf.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/services/scf/utils/utils_test.go b/stackit/internal/services/scf/utils/utils_test.go new file mode 100644 index 000000000..1a77a0ae3 --- /dev/null +++ b/stackit/internal/services/scf/utils/utils_test.go @@ -0,0 +1,94 @@ +package utils + +import ( + "context" + "os" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/scf" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + testVersion = "0.8.15" + testCustomEndpoint = "https://scf-custom-endpoint.api.stackit.cloud" +) + +func TestConfigureClient(t *testing.T) { + /* mock authentication by setting service account token env variable */ + os.Clearenv() + err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val") + if err != nil { + t.Errorf("error setting env variable: %v", err) + } + + type args struct { + providerData *core.ProviderData + } + tests := []struct { + name string + args args + wantErr bool + expected *scf.APIClient + }{ + { + name: "default endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + }, + }, + expected: func() *scf.APIClient { + apiClient, err := scf.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + { + name: "custom endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + ScfCustomEndpoint: testCustomEndpoint, + }, + }, + expected: func() *scf.APIClient { + apiClient, err := scf.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + config.WithEndpoint(testCustomEndpoint), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + diags := diag.Diagnostics{} + + actual := ConfigureClient(ctx, tt.args.providerData, &diags) + if diags.HasError() != tt.wantErr { + t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + } + + if !reflect.DeepEqual(actual, tt.expected) { + t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 45660fa70..fb09bb477 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/stackitcloud/terraform-provider-stackit/stackit" ) @@ -67,6 +68,7 @@ var ( RabbitMQCustomEndpoint = os.Getenv("TF_ACC_RABBITMQ_CUSTOM_ENDPOINT") RedisCustomEndpoint = os.Getenv("TF_ACC_REDIS_CUSTOM_ENDPOINT") ResourceManagerCustomEndpoint = os.Getenv("TF_ACC_RESOURCEMANAGER_CUSTOM_ENDPOINT") + ScfCustomEndpoint = os.Getenv("TF_ACC_SCF_CUSTOM_ENDPOINT") SecretsManagerCustomEndpoint = os.Getenv("TF_ACC_SECRETSMANAGER_CUSTOM_ENDPOINT") SQLServerFlexCustomEndpoint = os.Getenv("TF_ACC_SQLSERVERFLEX_CUSTOM_ENDPOINT") ServerBackupCustomEndpoint = os.Getenv("TF_ACC_SERVER_BACKUP_CUSTOM_ENDPOINT") @@ -474,6 +476,22 @@ func GitProviderConfig() string { ) } +func ScfProviderConfig() string { + if ScfCustomEndpoint == "" { + return ` + provider "stackit" { + default_region = "eu01" + }` + } + return fmt.Sprintf(` + provider "stackit" { + default_region = "eu01" + scf_custom_endpoint = "%s" + }`, + ScfCustomEndpoint, + ) +} + func ResourceNameWithDateTime(name string) string { dateTime := time.Now().Format(time.RFC3339) // Remove timezone to have a smaller datetime diff --git a/stackit/provider.go b/stackit/provider.go index c3ac8f336..e76128e05 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -75,6 +75,9 @@ import ( redisCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/redis/credential" redisInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/redis/instance" resourceManagerProject "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/project" + scfOrganization "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/organization" + scfOrganizationmanager "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/organizationmanager" + scfPlatform "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/platform" secretsManagerInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/secretsmanager/instance" secretsManagerUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/secretsmanager/user" serverBackupSchedule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serverbackup/schedule" @@ -146,6 +149,7 @@ type providerModel struct { ServerUpdateCustomEndpoint types.String `tfsdk:"server_update_custom_endpoint"` ServiceAccountCustomEndpoint types.String `tfsdk:"service_account_custom_endpoint"` ResourceManagerCustomEndpoint types.String `tfsdk:"resourcemanager_custom_endpoint"` + ScfCustomEndpoint types.String `tfsdk:"scf_custom_endpoint"` TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"` EnableBetaResources types.Bool `tfsdk:"enable_beta_resources"` ServiceEnablementCustomEndpoint types.String `tfsdk:"service_enablement_custom_endpoint"` @@ -184,6 +188,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "server_update_custom_endpoint": "Custom endpoint for the Server Update service", "service_account_custom_endpoint": "Custom endpoint for the Service Account service", "resourcemanager_custom_endpoint": "Custom endpoint for the Resource Manager service", + "scf_custom_endpoint": "Custom endpoint for the Cloud Foundry (SCF) service", "secretsmanager_custom_endpoint": "Custom endpoint for the Secrets Manager service", "sqlserverflex_custom_endpoint": "Custom endpoint for the SQL Server Flex service", "ske_custom_endpoint": "Custom endpoint for the Kubernetes Engine (SKE) service", @@ -306,6 +311,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["redis_custom_endpoint"], }, + "scf_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["scf_custom_endpoint"], + }, "resourcemanager_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["resourcemanager_custom_endpoint"], @@ -416,6 +425,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.OpenSearchCustomEndpoint, func(v string) { providerData.OpenSearchCustomEndpoint = v }) setStringField(providerConfig.RedisCustomEndpoint, func(v string) { providerData.RedisCustomEndpoint = v }) setStringField(providerConfig.ResourceManagerCustomEndpoint, func(v string) { providerData.ResourceManagerCustomEndpoint = v }) + setStringField(providerConfig.ScfCustomEndpoint, func(v string) { providerData.ScfCustomEndpoint = v }) setStringField(providerConfig.SecretsManagerCustomEndpoint, func(v string) { providerData.SecretsManagerCustomEndpoint = v }) setStringField(providerConfig.SQLServerFlexCustomEndpoint, func(v string) { providerData.SQLServerFlexCustomEndpoint = v }) setStringField(providerConfig.ServiceAccountCustomEndpoint, func(v string) { providerData.ServiceAccountCustomEndpoint = v }) @@ -499,6 +509,9 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource redisInstance.NewInstanceDataSource, redisCredential.NewCredentialDataSource, resourceManagerProject.NewProjectDataSource, + scfOrganization.NewScfOrganizationDataSource, + scfOrganizationmanager.NewScfOrganizationManagerDataSource, + scfPlatform.NewScfPlatformDataSource, secretsManagerInstance.NewInstanceDataSource, secretsManagerUser.NewUserDataSource, sqlServerFlexInstance.NewInstanceDataSource, @@ -565,6 +578,8 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { redisInstance.NewInstanceResource, redisCredential.NewCredentialResource, resourceManagerProject.NewProjectResource, + scfOrganization.NewScfOrganizationResource, + scfOrganizationmanager.NewScfOrganizationManagerResource, secretsManagerInstance.NewInstanceResource, secretsManagerUser.NewUserResource, sqlServerFlexInstance.NewInstanceResource, diff --git a/templates/guides/scf_cloudfoundry.md.tmpl b/templates/guides/scf_cloudfoundry.md.tmpl new file mode 100644 index 000000000..30ee42eae --- /dev/null +++ b/templates/guides/scf_cloudfoundry.md.tmpl @@ -0,0 +1,248 @@ +# How to Provisioning Cloud Foundry using Terrform + +## Objective + +This tutorial demonstrates how to provision Cloud Foundry resources by +integrating the STACKIT Terraform provider with the Cloud Foundry Terraform +provider. The STACKIT Terraform provider will create a managed Cloud Foundry +organization and set up a technical "org manager" user with +`organization_manager` permissions. These credentials, along with the Cloud +Foundry API URL (retrieved dynamically from a platform data resource), are +passed to the Cloud Foundry Terraform provider to manage resources within the +new organization. + +### Output + +This configuration creates a Cloud Foundry organization, mirroring the structure +created via the portal. It sets up three distinct spaces: `dev`, `qa`, and +`prod`. The configuration assigns, a specified user the `organization_manager` +and `organization_user` roles at the organization level, and the +`space_developer` role in each space. + +### Scope + +This tutorial covers the interaction between the STACKIT Terraform provider and +the Cloud Foundry Terraform provider. It assumes you are familiar with: + +- Setting up a STACKIT project and configuring the STACKIT Terraform provider + with a service account (see the general STACKIT documentation for details). +- Basic Terraform concepts, such as variables and locals. + +This document does not cover foundational topics or every feature of the Cloud +Foundry Terraform provider. + +### Example configuration + +The following Terraform configuration provisions a Cloud Foundry organization +and related resources using the STACKIT Terraform provider and the Cloud Foundry +Terraform provider: + +``` +terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + } + cloudfoundry = { + source = "cloudfoundry/cloudfoundry" + } + } +} + +variable "project_id" { + type = string + description = "Id of the Project" +} + +variable "org_name" { + type = string + description = "Name of the Organization" +} + +variable "admin_email" { + type = string + description = "Users who are granted permissions" +} + +provider "stackit" { + default_region = "eu01" +} + +resource "stackit_scf_organization" "scf_org" { + name = var.org_name + project_id = var.project_id +} + +data "stackit_scf_platform" "scf_platform" { + project_id = var.project_id + platform_id = stackit_scf_organization.scf_org.platform_id +} + +resource "stackit_scf_organization_manager" "scf_manager" { + project_id = var.project_id + org_id = stackit_scf_organization.scf_org.org_id +} + +provider "cloudfoundry" { + api_url = data.stackit_scf_platform.scf_platform.api_url + user = stackit_scf_organization_manager.scf_manager.username + password = stackit_scf_organization_manager.scf_manager.password +} + +locals { + spaces = ["dev", "qa", "prod"] +} + +resource "cloudfoundry_org_role" "org_user" { + username = var.admin_email + type = "organization_user" + org = stackit_scf_organization.scf_org.org_id +} + +resource "cloudfoundry_org_role" "org_manager" { + username = var.admin_email + type = "organization_manager" + org = stackit_scf_organization.scf_org.org_id +} + +resource "cloudfoundry_space" "spaces" { + for_each = toset(local.spaces) + name = each.key + org = stackit_scf_organization.scf_org.org_id +} + +resource "cloudfoundry_space_role" "space_developer" { + for_each = toset(local.spaces) + username = var.admin_email + type = "space_developer" + depends_on = [ cloudfoundry_org_role.org_user ] + space = cloudfoundry_space.spaces[each.key].id +} +``` + +## Explanation of configuration + +### STACKIT provider configuration + +``` +provider "stackit" { + default_region = "eu01" +} +``` + +The STACKIT Cloud Foundry Application Programming Interface (SCF API) is +regionalized. Each region operates independently. Set `default_region` in the +provider configuration, to specify the region for all resources, unless you +override it for individual resources. You must also provide access data for the +relevant STACKIT project for the provider to function. + +For more details, see +the:[STACKIT Terraform Provider documentation.](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs) + +### stackit_scf_organization.scf_org resource + +``` +resource "stackit_scf_organization" "scf_org" { + name = var.org_name + project_id = var.project_id +} +``` + +This resource provisions a Cloud Foundry organization, which acts as the +foundational container in the Cloud Foundry environment. Each Cloud Foundry +provider configuration is scoped to a specific organization. The organization’s +name, defined by a variable, must be unique across the platform. The +organization is created within a designated STACKIT project, which requires the +STACKIT provider to be configured with the necessary permissions for that +project. + +### stackit_scf_organization_manager.scf_manager resource + +``` +resource "stackit_scf_organization_manager" "scf_manager" { + project_id = var.project_id + org_id = stackit_scf_organization.scf_org.org_id +} +``` + +This resource creates a technical user in the Cloud Foundry organization with +the organization_manager permission. The user is linked to the organization and +is automatically deleted when the organization is removed. + +### stackit_scf_platform.scf_platform data source + +``` +data "stackit_scf_platform" "scf_platform" { + project_id = var.project_id + platform_id = stackit_scf_organization.scf_org.platform_id +} +``` + +This data source retrieves properties of the Cloud Foundry platform where the +organization is provisioned. It does not create resources, but provides +information about the existing platform. + +### Cloud Foundry provider configuration + +``` +provider "cloudfoundry" { + api_url = data.stackit_scf_platform.scf_platform.api_url + user = stackit_scf_organization_manager.scf_manager.username + password = stackit_scf_organization_manager.scf_manager.password +} +``` + +The Cloud Foundry provider is configured to manage resources in the new +organization. The provider uses the API URL from the `stackit_scf_platform` data +source and authenticates using the credentials of the technical user created by +the `stackit_scf_organization_manager` resource. + +For more information, see the: +[Cloud Foundry Terraform Provider documentation.](https://registry.terraform.io/providers/cloudfoundry/cloudfoundry/latest/docs) + +## Deploy resources + +Follow these steps to initialize your environment and provision Cloud Foundry +resources using Terraform. + +### Initialize Terraform + +Run the following command to initialize the working directory and download the +required provider plugins: + +``` +terraform init +``` + +### Create the organization manager user + +Run this command to provision the organization and technical user needed to +initialize the Cloud Foundry Terraform provider. This step is required only +during the initial setup. For later changes, you do not need the -target flag. + +``` +terraform apply -target stackit_scf_organization_manager.scf_manager +``` + +### Apply the full configuration + +Run this command to provision all resources defined in your Terraform +configuration within the Cloud Foundry organization: + +``` +terraform apply +``` + +## Verify the deployment + +Verify that your Cloud Foundry resources are provisioned correctly. Use the +following Cloud Foundry CLI commands to check applications, services, and +routes: + +- `cf apps` +- `cf services` +- `cf routes` + +For more information, see the +[Cloud Foundry documentation](https://docs.cloudfoundry.org/) and the +[Cloud Foundry CLI Reference Guide](https://cli.cloudfoundry.org/). \ No newline at end of file