From 17c333d26840dda405947a00b1c786fbbcec93be Mon Sep 17 00:00:00 2001 From: wuzhuanhong Date: Fri, 22 Nov 2024 11:57:18 +0800 Subject: [PATCH] feat(workspace/app): add new resource to manage image server --- docs/resources/workspace_app_image_server.md | 255 ++++++++ huaweicloud/provider.go | 1 + huaweicloud/services/acceptance/acceptance.go | 16 + ...eicloud_workspace_app_image_server_test.go | 165 ++++++ ..._huaweicloud_workspace_app_image_server.go | 554 ++++++++++++++++++ 5 files changed, 991 insertions(+) create mode 100644 docs/resources/workspace_app_image_server.md create mode 100644 huaweicloud/services/acceptance/workspace/resource_huaweicloud_workspace_app_image_server_test.go create mode 100644 huaweicloud/services/workspace/resource_huaweicloud_workspace_app_image_server.go diff --git a/docs/resources/workspace_app_image_server.md b/docs/resources/workspace_app_image_server.md new file mode 100644 index 0000000000..74dd0135da --- /dev/null +++ b/docs/resources/workspace_app_image_server.md @@ -0,0 +1,255 @@ +--- +subcategory: "Workspace" +layout: "huaweicloud" +page_title: "HuaweiCloud: huaweicloud_workspace_app_image_server" +description: |- + Manages a image server resource of Workspace APP within HuaweiCloud. +--- + +# huaweicloud_workspace_app_image_server + +Manages a image server resource of Workspace APP within HuaweiCloud. + +## Example Usage + +```hcl +variable "image_server_name" {} +variable "flavor_id" {} +variable "vpc_id" {} +variable "subnet_id" {} +variable "image_id" {} +variable "image_spec_code" {} +variable "image_product_id" {} +variable "user_name" {} + +resource "huaweicloud_workspace_app_image_server" "test" { + name = var.image_server_name + flavor_id = var.flavor_id + vpc_id = var.vpc_id + subnet_id = var.subnet_id + image_id = var.image_id + image_type = "gold" + spec_code = var.image_spec_code + image_product_id = var.image_product_id + is_vdi = true + + authorize_accounts { + account = var.user_name + type = "USER" + } + + root_volume { + type = "SAS" + size = 80 + } + + is_delete_associated_resources = true +} +``` + +## Argument Reference + +The following arguments are supported: + +* `region` - (Optional, String, ForceNew) Specifies the region in which to create the resource. + If omitted, the provider-level region will be used. + Changing this creates a new resource. + +* `name` - (Required, String, ForceNew) Specifies the name of the image server. + Changing this creates a new resource. + The name valid length is limited from `1` to `64`, only Chinese and English characters, digits, underscores (_) and + hyphens (-) are allowed and cannot contain spaces. + +* `flavor_id` - (Required, String, ForceNew) Specifies the flavor ID of the image server. + Changing this creates a new resource. + +* `vpc_id` - (Required, String, ForceNew) Specifies the VPC ID to which the image server belongs. + Changing this creates a new resource. + This parameter value must be the VPC ID corresponding to the Workspace service. + +* `subnet_id` - (Required, String, ForceNew) Specifies the subnet ID to which the image server belongs. + Changing this creates a new resource. + This parameter value must be the VPC ID corresponding to the Workspace service. + +* `root_volume` - (Required, List, ForceNew) Specifies the system disk configuration of the image server. + Changing this creates a new resource. + The [root_volume](#app_image_server_root_volume) structure is documented below. + +* `authorize_accounts` - (Required, List, ForceNew) Specifies the list of the management accounts for creating the image. + Changing this creates a new resource. + The [authorize_accounts](#app_image_server_authorize_accounts) structure is documented below. + +* `image_id` - (Required, String, ForceNew) Specifies the basic image ID of the image server. + Changing this creates a new resource. + +* `image_type` - (Required, String, ForceNew) Specifies the basic image type of the image server. + Changing this creates a new resource. + The valid values are as follows: + + **gold**: The market image. + + **public**: The public image. + + **private**: The private image. + + **shared**: The shared image. + + **other** + +* `spec_code` - (Optional, String, ForceNew) Specifies the specification code of the basic image to which the image + server belongs. Changing this creates a new resource. + This parameter is required wnen the `image_type` parameter is set to **gold**. + +* `image_product_id` - (Optional, String, ForceNew) Specifies the basic image product ID of the image server. + Changing this creates a new resource. + This parameter is required wnen the `image_type` parameter is set to **gold**. + +* `is_vdi` - (Optional, Bool, ForceNew) Specifies the session mode of the image server. + Changing this creates a new resource. + + **false**: Multi-session mode (default value). + + **true**: Single-session mode. + + If the AD server is not connected, only the single-session mode is supported. + +* `availability_zone` - (Optional, String, ForceNew) Specifies the availability zone of the image server. + Changing this creates a new resource. + If omitted, the AZ randomly assigned by the system is used. + +* `description` - (Optional, String) Specifies the description of the image server. + +* `ou_name` - (Optional, String, ForceNew) Specifies the OU name corresponding to the AD server. + Changing this creates a new resource. + This parameter is available only when the AD server is connected. + +* `extra_session_type` - (Optional, String, ForceNew) Specifies the additional session type. + Changing this creates a new resource. + This parameter is available only wnen the `is_vdi` parameter is set to **false**. + The valid values are as follows: + + **GPU** + + **CPU** + +* `extra_session_size` - (Optional, Int, ForceNew) Specifies the number of additional sessions for a single server. + Changing this creates a new resource. + This parameter is available only wnen the `is_vdi` parameter is set to **false**. + The `extra_session_size` must be used together with `extra_session_type`. + The upper limit of the number of additional sessions for a single server is `10` times the number of vCPUs in the server + specification minus the default number of sessions in the package. + +* `route_policy` - (Optional, List, ForceNew) Specifies the session scheduling policy of the server associated with + the image server. Changing this creates a new resource. + This parameter is available only wnen the `is_vdi` parameter is set to **false**. + The [route_policy](#app_image_server_route_policy) structure is documented below. + + -> If any metric of the server exceeds the threshold, new sessions will be rejected. The sessions will + be automatically scheduled to other available servers. + +* `scheduler_hints` - (Optional, List, ForceNew) Specifies the configuration of the dedicate host. + Changing this creates a new resource. + The [scheduler_hints](#app_image_server_scheduler_hints) structure is documented below. + +* `tags` - (Optional, Map, ForceNew) Specifies the key/value pairs to associate with the image server. + Supports up to 20 tags. + Changing this creates a new resource. + +* `enterprise_project_id` - (Optional, String, ForceNew) Specifies the ID of the enterprise project to which the image + server belong. Changing this creates a new resource. + This parameter is only valid for enterprise users, if omitted, default enterprise project will be used. + +* `is_delete_associated_resources` - (Optional, Bool) Specifies whether to delete resources associated with this image server + after deleting it, defaults to **false** + + -> If this parameter is set to **true**, deleting the resource will also delete the associated server group, server + and application group resources, but the image product related resources will be retained. + + +The `authorize_accounts` block supports: + +* `account` - (Required, String, ForceNew) Specifies the name of the account. + Changing this creates a new resource. + +* `type` - (Required, String, ForceNew) Specifies the type of the account. + Changing this creates a new resource. + The valid values are as follows: + + **USER** + +* `domain` - (Optional, String, ForceNew) Specifies the domain name of the Workspace service. + + +The `root_volume` block supports: + +* `type` - (Required, String, ForceNew) Specifies the disk type of the image server. + Changing this creates a new resource. + The valid values are as follows: + + **ESSD**: Extreme SSD type. + + **SSD**: Ultra-high I/O type. + + **GPSSD**: General purpose SSD type. + + **SAS**: High I/O type. + + **SATA**: Common I/O type. + +* `size` - (Required, Int, ForceNew) Specifies the disk size of the image server, in GB. + Changing this creates a new resource. + The system disk size must be sufficient for the basic image and the application to be installed. + + +The `route_policy` block supports: + +* `max_session` - (Optional, Int, ForceNew) Specifies the number of session connections of the server. + Changing this creates a new resource. + The maximum number of sessions is equal to the default number of sessions plus the number of additional sessions. + +* `cpu_threshold` - (Optional, Int, ForceNew) Specifies the CPU usage of the server. The unit is `%`. + Changing this creates a new resource. + The valid value ranges from `1` to `100`. + +* `mem_threshold` - (Optional, Int, ForceNew) Specifies the memory usage of the server. The unit is `%`. + Changing this creates a new resource. + The valid value ranges from `1` to `100`. + + +The `scheduler_hints` block supports: + +* `dedicated_host_id` - (Optional, String, ForceNew) Specifies the ID of the dedicate host. + Changing this creates a new resource. + +* `tenancy` - (Optional, String, ForceNew) Specifies the type of the dedicate host. + Changing this creates a new resource. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The resource ID, also iamge server ID. + +* `created_at` - The creation time of the image server, in RFC339 format. + +## Timeouts + +This resource provides the following timeouts configuration options: + +* `create` - Default is 60 minutes. +* `delete` - Default is 20 minutes. + +## Import + +The image server resource can be imported using `id`, e.g. + +```bash +$ terraform import huaweicloud_workspace_app_image_server.test +``` + +Note that the imported state may not be identical to your resource definition, due to some attributes missing from the +API response, security or some other reason. +The missing attributes include: `flavor_id`, `vpc_id`, `subnet_id`, `root_volume`, `image_product_id`, `is_vdi`, +`availability_zone`, `ou_name`, `extra_session_type`, `extra_session_size`, `route_policy`, `scheduler_hints`, `tags`, +`enterprise_project_id`, `is_delete_associated_resources`. +It is generally recommended running `terraform plan` after importing the resource. +You can then decide if changes should be applied to the instance, or the resource definition should be updated to +align with the instance. Also you can ignore changes as below. + +```hcl +resource "huaweicloud_workspace_app_image_server" "test" { + ... + + lifecycle { + ignore_changes = [ + flavor_id, vpc_id, subnet_id, root_volume, image_product_id, is_vdi, availability_zone, ou_name, extra_session_type, + extra_session_size, route_policy, scheduler_hints, tags, enterprise_project_id, is_delete_associated_resources, + ] + } +} +``` diff --git a/huaweicloud/provider.go b/huaweicloud/provider.go index e7ef0ae739..b6b2233e26 100644 --- a/huaweicloud/provider.go +++ b/huaweicloud/provider.go @@ -2088,6 +2088,7 @@ func Provider() *schema.Provider { "huaweicloud_workspace_app_group_authorization": workspace.ResourceAppGroupAuthorization(), "huaweicloud_workspace_app_group": workspace.ResourceWorkspaceAppGroup(), + "huaweicloud_workspace_app_image_server": workspace.ResourceAppImageServer(), "huaweicloud_workspace_app_nas_storage": workspace.ResourceAppNasStorage(), "huaweicloud_workspace_app_policy_group": workspace.ResourceAppPolicyGroup(), "huaweicloud_workspace_app_publishment": workspace.ResourceAppPublishment(), diff --git a/huaweicloud/services/acceptance/acceptance.go b/huaweicloud/services/acceptance/acceptance.go index 8b83cd052a..f1a41d23d2 100644 --- a/huaweicloud/services/acceptance/acceptance.go +++ b/huaweicloud/services/acceptance/acceptance.go @@ -208,8 +208,10 @@ var ( HW_WORKSPACE_APP_SERVER_GROUP_FLAVOR_ID = os.Getenv("HW_WORKSPACE_APP_SERVER_GROUP_FLAVOR_ID") HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_ID = os.Getenv("HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_ID") HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_PRODUCT_ID = os.Getenv("HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_PRODUCT_ID") + HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_SPEC_CODE = os.Getenv("HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_SPEC_CODE") HW_WORKSPACE_OU_NAME = os.Getenv("HW_WORKSPACE_OU_NAME") HW_WORKSPACE_APP_FILE_STRORE_OBS_PATH = os.Getenv("HW_WORKSPACE_APP_FILE_STRORE_OBS_PATH") + HW_WORKSPACE_USER_NAMES = os.Getenv("HW_WORKSPACE_USER_NAMES") HW_FGS_AGENCY_NAME = os.Getenv("HW_FGS_AGENCY_NAME") HW_FGS_TEMPLATE_ID = os.Getenv("HW_FGS_TEMPLATE_ID") @@ -1505,6 +1507,20 @@ func TestAccPreCheckWorkspaceAppServerGroup(t *testing.T) { } } +// lintignore:AT003 +func TestAccPreCheckWorkspaceAppImageSpecCode(t *testing.T) { + if HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_SPEC_CODE == "" { + t.Skip("HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_SPEC_CODE must be set for Workspace APP acceptance tests.") + } +} + +// lintignore:AT003 +func TestAccPrecheckWorkspaceUserNames(t *testing.T) { + if len(strings.Split(HW_WORKSPACE_USER_NAMES, ",")) < 1 { + t.Skip("At least one of user must be configured in the HW_WORKSPACE_USER_NAMES, and separated by commas") + } +} + // lintignore:AT003 func TestAccPreCheckWorkspaceOUName(t *testing.T) { if HW_WORKSPACE_OU_NAME == "" { diff --git a/huaweicloud/services/acceptance/workspace/resource_huaweicloud_workspace_app_image_server_test.go b/huaweicloud/services/acceptance/workspace/resource_huaweicloud_workspace_app_image_server_test.go new file mode 100644 index 0000000000..79e16705e4 --- /dev/null +++ b/huaweicloud/services/acceptance/workspace/resource_huaweicloud_workspace_app_image_server_test.go @@ -0,0 +1,165 @@ +package workspace + +import ( + "fmt" + "strconv" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/huaweicloud/terraform-provider-huaweicloud/huaweicloud/config" + "github.com/huaweicloud/terraform-provider-huaweicloud/huaweicloud/services/acceptance" + "github.com/huaweicloud/terraform-provider-huaweicloud/huaweicloud/services/workspace" +) + +func getResourceAppImageServerFunc(cfg *config.Config, state *terraform.ResourceState) (interface{}, error) { + client, err := cfg.NewServiceClient("appstream", acceptance.HW_REGION_NAME) + if err != nil { + return nil, fmt.Errorf("error creating Workspace APP client: %s", err) + } + + return workspace.GetAppImageServerById(client, state.Primary.ID) +} + +func getAcceptanceEpsId() string { + if acceptance.HW_ENTERPRISE_PROJECT_ID_TEST == "" { + return "0" + } + return acceptance.HW_ENTERPRISE_PROJECT_ID_TEST +} + +func TestAccResourceAppImageServer_basic(t *testing.T) { + var ( + resourceName = "huaweicloud_workspace_app_image_server.test" + name = acceptance.RandomAccResourceName() + updateName = acceptance.RandomAccResourceName() + + serverGroup interface{} + rc = acceptance.InitResourceCheck( + resourceName, + &serverGroup, + getResourceAppImageServerFunc, + ) + ) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acceptance.TestAccPreCheck(t) + acceptance.TestAccPreCheckWorkspaceAppServerGroup(t) + acceptance.TestAccPreCheckWorkspaceAppImageSpecCode(t) + acceptance.TestAccPrecheckWorkspaceUserNames(t) + acceptance.TestAccPreCheckWorkspaceOUName(t) + }, + ProviderFactories: acceptance.TestAccProviderFactories, + CheckDestroy: rc.CheckResourceDestroy(), + Steps: []resource.TestStep{ + { + Config: testResourceAppImageServer_basic(name, "Created by script"), + Check: resource.ComposeTestCheckFunc( + rc.CheckResourceExists(), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttr(resourceName, "authorize_accounts.#", strconv.Itoa(len(strings.Split(acceptance.HW_WORKSPACE_USER_NAMES, ",")))), + resource.TestCheckResourceAttr(resourceName, "authorize_accounts.0.type", "USER"), + resource.TestCheckResourceAttr(resourceName, "authorize_accounts.0.domain", acceptance.HW_WORKSPACE_AD_DOMAIN_NAME), + resource.TestCheckResourceAttr(resourceName, "image_id", acceptance.HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_ID), + resource.TestCheckResourceAttr(resourceName, "image_type", "gold"), + resource.TestCheckResourceAttr(resourceName, "spec_code", + acceptance.HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_SPEC_CODE), + resource.TestCheckResourceAttr(resourceName, "description", "Created by script"), + resource.TestCheckResourceAttr(resourceName, "enterprise_project_id", getAcceptanceEpsId()), + ), + }, + { + Config: testResourceAppImageServer_basic(updateName, ""), + Check: resource.ComposeTestCheckFunc( + rc.CheckResourceExists(), + resource.TestCheckResourceAttr(resourceName, "name", updateName), + resource.TestCheckResourceAttr(resourceName, "description", ""), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "flavor_id", + "vpc_id", + "subnet_id", + "root_volume", + "image_product_id", + "is_vdi", + "availability_zone", + "ou_name", + "extra_session_type", + "extra_session_size", + "route_policy", + "scheduler_hints", + "tags", + "enterprise_project_id", + "is_delete_associated_resources", + }, + }, + }, + }) +} + +func testResourceAppImageServer_basic(name, description string) string { + return fmt.Sprintf(` +data "huaweicloud_availability_zones" "test" {} + +resource "huaweicloud_workspace_app_image_server" "test" { + name = "%[1]s" + flavor_id = "%[2]s" + vpc_id = "%[3]s" + subnet_id = "%[4]s" + image_id = "%[5]s" + image_type = "gold" + image_product_id = "%[6]s" + spec_code = "%[7]s" + + # Currently only one user can be set. + authorize_accounts { + account = split(",", "%[8]s")[0] + type = "USER" + domain = "%[9]s" + } + + root_volume { + type = "SAS" + size = 80 + } + + is_vdi = false + availability_zone = data.huaweicloud_availability_zones.test.names[0] + description = "%[10]s" + ou_name = "%[11]s" + extra_session_type = "CPU" + extra_session_size = 2 + + route_policy { + max_session = 3 + cpu_threshold = 80 + mem_threshold = 80 + } + + tags = { + "foo" = "bar" + } + + enterprise_project_id = "%[12]s" + is_delete_associated_resources = true +} +`, name, acceptance.HW_WORKSPACE_APP_SERVER_GROUP_FLAVOR_ID, + acceptance.HW_WORKSPACE_AD_VPC_ID, + acceptance.HW_WORKSPACE_AD_NETWORK_ID, + acceptance.HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_ID, + acceptance.HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_PRODUCT_ID, + acceptance.HW_WORKSPACE_APP_SERVER_GROUP_IMAGE_SPEC_CODE, + acceptance.HW_WORKSPACE_USER_NAMES, + acceptance.HW_WORKSPACE_AD_DOMAIN_NAME, + description, + acceptance.HW_WORKSPACE_OU_NAME, + acceptance.HW_ENTERPRISE_PROJECT_ID_TEST, + ) +} diff --git a/huaweicloud/services/workspace/resource_huaweicloud_workspace_app_image_server.go b/huaweicloud/services/workspace/resource_huaweicloud_workspace_app_image_server.go new file mode 100644 index 0000000000..327aa993c7 --- /dev/null +++ b/huaweicloud/services/workspace/resource_huaweicloud_workspace_app_image_server.go @@ -0,0 +1,554 @@ +package workspace + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/chnsz/golangsdk" + + "github.com/huaweicloud/terraform-provider-huaweicloud/huaweicloud/common" + "github.com/huaweicloud/terraform-provider-huaweicloud/huaweicloud/config" + "github.com/huaweicloud/terraform-provider-huaweicloud/huaweicloud/utils" +) + +// @API Workspace POST /v1/{project_id}/image-servers +// @API Workspace GET /v1/{project_id}/image-servers +// @API Workspace PATCH /v1/{project_id}/image-servers/{server_id} +// @API Workspace PATCH /v1/{project_id}/image-servers/actions/batch-delete +func ResourceAppImageServer() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceAppImageServerCreate, + ReadContext: resourceAppImageServerRead, + UpdateContext: resourceAppImageServerUpdate, + DeleteContext: resourceAppImageServerDelete, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(60 * time.Minute), + Delete: schema.DefaultTimeout(20 * time.Minute), + }, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "region": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the image server.", + }, + "flavor_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The flavor ID of the image server.", + }, + "vpc_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The VPC ID to which the image server belongs.", + }, + "subnet_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The subnet ID to which the image server belongs.", + }, + "root_volume": { + Type: schema.TypeList, + Required: true, + ForceNew: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The disk type of the image server.", + }, + "size": { + Type: schema.TypeInt, + Required: true, + ForceNew: true, + Description: "The disk size of the image server, in GB.", + }, + }, + }, + Description: "The system disk configuration of the image server.", + }, + "authorize_accounts": { + Type: schema.TypeSet, + Required: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "account": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the account.", + }, + "type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The type of the account.", + }, + "domain": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The domain name of the Workspace service.", + }, + }, + }, + Description: "The list of the management accounts for creating the image.", + }, + "image_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The basic image ID of the image server.", + }, + "image_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The basic image type of the image server.", + }, + "spec_code": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + Description: "The specification code of the basic image to which the image server belongs.", + }, + "image_product_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The basic image product ID of the image server.", + }, + "is_vdi": { + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + Description: "The session mode of the image server.", + }, + "availability_zone": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The availability zone of the image server.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "The description of the image server.", + }, + "ou_name": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The OU name corresponding to the AD server.", + }, + "extra_session_type": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The additional session type.", + }, + "extra_session_size": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Description: "The number of additional sessions for a single server.", + }, + "route_policy": { + Type: schema.TypeList, + Optional: true, + ForceNew: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "max_session": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Description: "The number of session connections of the server.", + }, + "cpu_threshold": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Description: "The CPU usage of the server.", + }, + "mem_threshold": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Description: "The memory usage of the server.", + }, + }, + }, + Description: "The session scheduling policy of the server associated with the image server.", + }, + "tags": common.TagsForceNewSchema("The key/value pairs to associate with the image server."), + "enterprise_project_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + Description: "The ID of the enterprise project to which the image server belong.", + }, + "scheduler_hints": { + Type: schema.TypeList, + Optional: true, + ForceNew: true, + MaxItems: 1, + Description: "The configuration of the dedicate host.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "dedicated_host_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The ID of the dedicate host.", + }, + "tenancy": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The type of the dedicate host.", + }, + }, + }, + }, + "is_delete_associated_resources": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether to delete resources associated with this image server after deleting it.", + }, + "attach_apps": { + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: utils.SchemaDesc("The list of the warehouse apps.", utils.SchemaDescInput{Internal: true}), + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "The creation time of the image server, in RFC339 format.", + }, + }, + } +} + +func buildCreateAppImageServerOpts(d *schema.ResourceData, epsId string) map[string]interface{} { + return map[string]interface{}{ + "name": d.Get("name"), + "product_id": d.Get("flavor_id"), + "vpc_id": d.Get("vpc_id"), + "subnet_id": d.Get("subnet_id"), + "root_volume": buildAppServerRootVolume(d.Get("root_volume").([]interface{})), + "authorize_accounts": buildImageServerAccounts(d.Get("authorize_accounts").(*schema.Set).List()), + "image_ref": map[string]interface{}{ + "id": d.Get("image_id"), + "image_type": d.Get("image_type"), + "spce_code": utils.ValueIgnoreEmpty(d.Get("spec_code")), + "product_id": utils.ValueIgnoreEmpty(d.Get("image_product_id")), + }, + "is_vdi": d.Get("is_vdi"), + "availability_zone": utils.ValueIgnoreEmpty(d.Get("availability_zone")), + "description": utils.ValueIgnoreEmpty(d.Get("description")), + "ou_name": utils.ValueIgnoreEmpty(d.Get("ou_name")), + "extra_session_type": utils.ValueIgnoreEmpty(d.Get("extra_session_type")), + "extra_session_size": utils.ValueIgnoreEmpty(d.Get("extra_session_size")), + "route_policy": buildAppServerGroupRoutePolicy(d.Get("route_policy").([]interface{})), + "tags": utils.ExpandResourceTags(d.Get("tags").(map[string]interface{})), + "enterprise_project_id": utils.ValueIgnoreEmpty(epsId), + "scheduler_hints": buildAppServerSchedulerHints(d.Get("scheduler_hints").([]interface{})), + "attach_apps": utils.ValueIgnoreEmpty(utils.ExpandToStringList(d.Get("attach_apps").([]interface{}))), + } +} + +func buildImageServerAccounts(accounts []interface{}) []map[string]interface{} { + if len(accounts) == 0 { + return nil + } + + res := make([]map[string]interface{}, len(accounts)) + for i, v := range accounts { + res[i] = map[string]interface{}{ + "account": utils.PathSearch("account", v, ""), + "account_type": utils.PathSearch("type", v, ""), + "domain": utils.PathSearch("domain", v, ""), + } + } + return res +} + +func resourceAppImageServerCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var ( + cfg = meta.(*config.Config) + region = cfg.GetRegion(d) + httpUrl = "v1/{project_id}/image-servers" + ) + + client, err := cfg.NewServiceClient("appstream", region) + if err != nil { + return diag.Errorf("error creating Workspace APP client: %s", err) + } + + createPath := client.Endpoint + httpUrl + createPath = strings.ReplaceAll(createPath, "{project_id}", client.ProjectID) + createOpt := golangsdk.RequestOpts{ + KeepResponseBody: true, + JSONBody: utils.RemoveNil(buildCreateAppImageServerOpts(d, cfg.GetEnterpriseProjectID(d))), + } + requestResp, err := client.Request("POST", createPath, &createOpt) + if err != nil { + return diag.Errorf("error creating image server of Workspace APP: %s", err) + } + respBody, err := utils.FlattenResponse(requestResp) + if err != nil { + return diag.FromErr(err) + } + + jobId := utils.PathSearch("job_id", respBody, "").(string) + if jobId == "" { + return diag.Errorf("unable to find job ID from API response") + } + + serverResp, err := waitForImageServerJobCompleted(ctx, client, d.Timeout(schema.TimeoutCreate), jobId) + if err != nil { + return diag.Errorf("error waiting for creating image server job (%s) completed: %s", jobId, err) + } + + imageServerId := utils.PathSearch("sub_jobs|[0].job_resource_info.resource_id", serverResp, "").(string) + if imageServerId == "" { + return diag.Errorf("unable to find image server ID from API response") + } + + d.SetId(imageServerId) + + return resourceAppImageServerRead(ctx, d, meta) +} + +func waitForImageServerJobCompleted(ctx context.Context, client *golangsdk.ServiceClient, timeout time.Duration, jobId string) (interface{}, + error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{"RUNNING"}, + Target: []string{"SUCCESS"}, + Refresh: refreshImageServerJobStatusFunc(client, jobId), + Timeout: timeout, + Delay: 10 * time.Second, + PollInterval: 30 * time.Second, + } + + serverResp, err := stateConf.WaitForStateContext(ctx) + return serverResp, err +} + +func refreshImageServerJobStatusFunc(client *golangsdk.ServiceClient, jobId string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + httpUrl := "v1/{project_id}/image-server-jobs/{job_id}" + getJobPath := client.Endpoint + httpUrl + getJobPath = strings.ReplaceAll(getJobPath, "{project_id}", client.ProjectID) + getJobPath = strings.ReplaceAll(getJobPath, "{job_id}", jobId) + getOpt := golangsdk.RequestOpts{ + KeepResponseBody: true, + } + + resp, err := client.Request("GET", getJobPath, &getOpt) + if err != nil { + return resp, "ERROR", err + } + + respBody, err := utils.FlattenResponse(resp) + if err != nil { + return resp, "ERROR", err + } + + return respBody, utils.PathSearch("status", respBody, nil).(string), nil + } +} + +func resourceAppImageServerRead(_ context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var ( + cfg = meta.(*config.Config) + region = cfg.GetRegion(d) + imageServerId = d.Id() + ) + + client, err := cfg.NewServiceClient("appstream", region) + if err != nil { + return diag.Errorf("error creating Workspace APP client: %s", err) + } + + imageServer, err := GetAppImageServerById(client, imageServerId) + if err != nil { + return common.CheckDeletedDiag(d, err, "Workspace APP image server") + } + + mErr := multierror.Append(nil, + d.Set("region", region), + d.Set("name", utils.PathSearch("name", imageServer, nil)), + d.Set("authorize_accounts", + flattenAuthorizAccounts(utils.PathSearch("authorize_accounts", imageServer, make([]interface{}, 0)).([]interface{}))), + d.Set("image_id", utils.PathSearch("image_ref.id", imageServer, nil)), + d.Set("image_type", utils.PathSearch("image_ref.image_type", imageServer, nil)), + d.Set("spec_code", utils.PathSearch("image_ref.spce_code", imageServer, nil)), + d.Set("description", utils.PathSearch("description", imageServer, nil)), + d.Set("enterprise_project_id", utils.PathSearch("enterprise_project_id", imageServer, nil)), + d.Set("created_at", utils.FormatTimeStampRFC3339(utils.ConvertTimeStrToNanoTimestamp(utils.PathSearch("create_time", + imageServer, "").(string))/1000, false)), + ) + if err = mErr.ErrorOrNil(); err != nil { + return diag.FromErr(err) + } + return nil +} + +func flattenAuthorizAccounts(accounts []interface{}) []map[string]interface{} { + if len(accounts) == 0 { + return nil + } + + rest := make([]map[string]interface{}, len(accounts)) + for i, v := range accounts { + rest[i] = map[string]interface{}{ + "account": utils.PathSearch("account", v, nil), + "type": utils.PathSearch("account_type", v, nil), + "domain": utils.PathSearch("domain", v, nil), + } + } + + return rest +} + +func GetAppImageServerById(client *golangsdk.ServiceClient, imageServerId string) (interface{}, error) { + httpUrl := "v1/{project_id}/image-servers" + getPath := client.Endpoint + httpUrl + getPath = strings.ReplaceAll(getPath, "{project_id}", client.ProjectID) + getPath = fmt.Sprintf("%s?server_id=%s", getPath, imageServerId) + getOpt := golangsdk.RequestOpts{ + KeepResponseBody: true, + } + // In any case, the response status code is 200. + requestResp, err := client.Request("GET", getPath, &getOpt) + if err != nil { + return nil, fmt.Errorf("error retrieving image server (%s): %s", imageServerId, err) + } + + respBody, err := utils.FlattenResponse(requestResp) + if err != nil { + return nil, err + } + + imageServer := utils.PathSearch("items|[0]", respBody, nil) + if imageServer == nil { + return nil, golangsdk.ErrDefault404{} + } + return imageServer, nil +} + +func resourceAppImageServerUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + // To ensure that the update logic is skipped when the `is_delete_associated_resources` parameters is updated. + if d.HasChangesExcept("name", "description") { + return nil + } + + var ( + cfg = meta.(*config.Config) + httpUrl = "v1/{project_id}/image-servers/{server_id}" + imageServerId = d.Id() + ) + + client, err := cfg.NewServiceClient("appstream", cfg.GetRegion(d)) + if err != nil { + return diag.Errorf("error creating Workspace APP client: %s", err) + } + + updatePath := client.Endpoint + httpUrl + updatePath = strings.ReplaceAll(updatePath, "{project_id}", client.ProjectID) + updatePath = strings.ReplaceAll(updatePath, "{server_id}", imageServerId) + updateOpt := golangsdk.RequestOpts{ + KeepResponseBody: true, + JSONBody: map[string]interface{}{ + "name": d.Get("name"), + "description": d.Get("description"), + }, + } + + _, err = client.Request("PATCH", updatePath, &updateOpt) + if err != nil { + return diag.Errorf("error updating image server (%s) of Workspace APP: %s", imageServerId, err) + } + + return resourceAppImageServerRead(ctx, d, meta) +} + +func resourceAppImageServerDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var ( + cfg = meta.(*config.Config) + region = cfg.GetRegion(d) + httpUrl = "v1/{project_id}/image-servers/actions/batch-delete" + imageServerId = d.Id() + ) + + client, err := cfg.NewServiceClient("appstream", region) + if err != nil { + return diag.Errorf("error creating Workspace APP client: %s", err) + } + + deletePath := client.Endpoint + httpUrl + deletePath = strings.ReplaceAll(deletePath, "{project_id}", client.ProjectID) + deleteOpt := golangsdk.RequestOpts{ + KeepResponseBody: true, + JSONBody: map[string]interface{}{ + "items": []string{imageServerId}, + "recursive": d.Get("is_delete_associated_resources"), + }, + } + + resp, err := client.Request("PATCH", deletePath, &deleteOpt) + if err != nil { + // Although the deletion result of the main region shows that the interface returns a 200 status code when + // deleting a non-existent image server, in order to avoid the possible return of a 404 status code in the + // future, the CheckDeleted design is retained here. + return common.CheckDeletedDiag(d, err, fmt.Sprintf("error deleting image server (%s)", imageServerId)) + } + + respBody, err := utils.FlattenResponse(resp) + if err != nil { + return diag.FromErr(err) + } + + jobId := utils.PathSearch("job_id", respBody, "").(string) + if jobId == "" { + return diag.Errorf("unable to find job ID from API response") + } + + _, err = waitForImageServerJobCompleted(ctx, client, d.Timeout(schema.TimeoutDelete), jobId) + if err != nil { + return diag.Errorf("error waiting for deleting image server job (%s) completed: %s", jobId, err) + } + return nil +}