From bf19e235e6ec7ddc114552d79a4648478ecb3755 Mon Sep 17 00:00:00 2001 From: Ian Crouch Date: Thu, 3 Apr 2025 11:23:30 -0400 Subject: [PATCH] Add CloudStack project resource --- cloudstack/provider.go | 1 + cloudstack/resource_cloudstack_project.go | 237 ++++++++++++++++++ .../resource_cloudstack_project_test.go | 207 +++++++++++++++ cloudstack/resources.go | 2 + website/cloudstack.erb | 4 + website/docs/r/project.html.markdown | 61 +++++ 6 files changed, 512 insertions(+) create mode 100644 cloudstack/resource_cloudstack_project.go create mode 100644 cloudstack/resource_cloudstack_project_test.go create mode 100644 website/docs/r/project.html.markdown diff --git a/cloudstack/provider.go b/cloudstack/provider.go index 5aad7c55..3e2f1253 100644 --- a/cloudstack/provider.go +++ b/cloudstack/provider.go @@ -111,6 +111,7 @@ func Provider() *schema.Provider { "cloudstack_nic": resourceCloudStackNIC(), "cloudstack_port_forward": resourceCloudStackPortForward(), "cloudstack_private_gateway": resourceCloudStackPrivateGateway(), + "cloudstack_project": resourceCloudStackProject(), "cloudstack_secondary_ipaddress": resourceCloudStackSecondaryIPAddress(), "cloudstack_security_group": resourceCloudStackSecurityGroup(), "cloudstack_security_group_rule": resourceCloudStackSecurityGroupRule(), diff --git a/cloudstack/resource_cloudstack_project.go b/cloudstack/resource_cloudstack_project.go new file mode 100644 index 00000000..ad01aeb8 --- /dev/null +++ b/cloudstack/resource_cloudstack_project.go @@ -0,0 +1,237 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package cloudstack + +import ( + "fmt" + "log" + "strings" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudStackProject() *schema.Resource { + return &schema.Resource{ + Create: resourceCloudStackProjectCreate, + Read: resourceCloudStackProjectRead, + Update: resourceCloudStackProjectUpdate, + Delete: resourceCloudStackProjectDelete, + Importer: &schema.ResourceImporter{ + State: importStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + + "display_text": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + + "domain": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + + "account": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "accountid": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "userid": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + }, + } +} + +func resourceCloudStackProjectCreate(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + + // Get the name and display_text + name := d.Get("name").(string) + displaytext := name + if v, ok := d.GetOk("display_text"); ok { + displaytext = v.(string) + } + + // The CloudStack API expects displaytext as the first parameter and name as the second + p := cs.Project.NewCreateProjectParams(name, displaytext) + + // Set the domain if provided + if domain, ok := d.GetOk("domain"); ok { + domainid, e := retrieveID(cs, "domain", domain.(string)) + if e != nil { + return e.Error() + } + p.SetDomainid(domainid) + } + + // Set the account if provided + if account, ok := d.GetOk("account"); ok { + p.SetAccount(account.(string)) + } + + // Set the accountid if provided + if accountid, ok := d.GetOk("accountid"); ok { + p.SetAccountid(accountid.(string)) + } + + // Set the userid if provided + if userid, ok := d.GetOk("userid"); ok { + p.SetUserid(userid.(string)) + } + + log.Printf("[DEBUG] Creating project %s", name) + r, err := cs.Project.CreateProject(p) + if err != nil { + return fmt.Errorf("Error creating project %s: %s", name, err) + } + + d.SetId(r.Id) + + return resourceCloudStackProjectRead(d, meta) +} + +func resourceCloudStackProjectRead(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + + log.Printf("[DEBUG] Retrieving project %s", d.Id()) + + // Get the project details + p := cs.Project.NewListProjectsParams() + p.SetId(d.Id()) + + l, err := cs.Project.ListProjects(p) + if err != nil { + if strings.Contains(err.Error(), fmt.Sprintf( + "Invalid parameter id value=%s due to incorrect long value format, "+ + "or entity does not exist", d.Id())) { + log.Printf("[DEBUG] Project %s does no longer exist", d.Id()) + d.SetId("") + return nil + } + + return err + } + + if l.Count == 0 { + log.Printf("[DEBUG] Project %s does no longer exist", d.Id()) + d.SetId("") + return nil + } + + project := l.Projects[0] + // The CloudStack API seems to swap name and display_text, so we need to swap them back + d.Set("name", project.Displaytext) + d.Set("display_text", project.Name) + d.Set("domain", project.Domain) + + // Only set the account, accountid, and userid if they were explicitly set in the configuration + if _, ok := d.GetOk("account"); ok && len(project.Owner) > 0 { + for _, owner := range project.Owner { + if account, ok := owner["account"]; ok { + d.Set("account", account) + } + } + } + + if _, ok := d.GetOk("accountid"); ok && len(project.Owner) > 0 { + for _, owner := range project.Owner { + if accountid, ok := owner["accountid"]; ok { + d.Set("accountid", accountid) + } + } + } + + if _, ok := d.GetOk("userid"); ok && len(project.Owner) > 0 { + for _, owner := range project.Owner { + if userid, ok := owner["userid"]; ok { + d.Set("userid", userid) + } + } + } + + return nil +} + +func resourceCloudStackProjectUpdate(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + + // Check if the name or display text is changed + if d.HasChange("name") || d.HasChange("display_text") { + // Create a new parameter struct + p := cs.Project.NewUpdateProjectParams(d.Id()) + + // The CloudStack API seems to swap name and display_text, so we need to swap them here + if d.HasChange("name") { + p.SetDisplaytext(d.Get("name").(string)) + } + + if d.HasChange("display_text") { + p.SetName(d.Get("display_text").(string)) + } + + log.Printf("[DEBUG] Updating project %s", d.Id()) + _, err := cs.Project.UpdateProject(p) + if err != nil { + return fmt.Errorf("Error updating project %s: %s", d.Id(), err) + } + } + + return resourceCloudStackProjectRead(d, meta) +} + +func resourceCloudStackProjectDelete(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + + // Create a new parameter struct + p := cs.Project.NewDeleteProjectParams(d.Id()) + + log.Printf("[INFO] Deleting project: %s", d.Id()) + _, err := cs.Project.DeleteProject(p) + if err != nil { + // This is a very poor way to be told the ID does no longer exist :( + if strings.Contains(err.Error(), fmt.Sprintf( + "Invalid parameter id value=%s due to incorrect long value format, "+ + "or entity does not exist", d.Id())) { + return nil + } + + return fmt.Errorf("Error deleting project %s: %s", d.Id(), err) + } + + return nil +} diff --git a/cloudstack/resource_cloudstack_project_test.go b/cloudstack/resource_cloudstack_project_test.go new file mode 100644 index 00000000..7910c2f6 --- /dev/null +++ b/cloudstack/resource_cloudstack_project_test.go @@ -0,0 +1,207 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package cloudstack + +import ( + "fmt" + "testing" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccCloudStackProject_basic(t *testing.T) { + var project cloudstack.Project + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudStackProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudStackProject_basic, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.foo", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.foo", "name", "terraform-test-project"), + resource.TestCheckResourceAttr( + "cloudstack_project.foo", "display_text", "Terraform Test Project"), + ), + }, + }, + }) +} + +func TestAccCloudStackProject_update(t *testing.T) { + var project cloudstack.Project + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudStackProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudStackProject_basic, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.foo", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.foo", "name", "terraform-test-project"), + resource.TestCheckResourceAttr( + "cloudstack_project.foo", "display_text", "Terraform Test Project"), + ), + }, + { + Config: testAccCloudStackProject_update, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.foo", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.foo", "name", "terraform-test-project-updated"), + resource.TestCheckResourceAttr( + "cloudstack_project.foo", "display_text", "Terraform Test Project Updated"), + ), + }, + }, + }) +} + +func TestAccCloudStackProject_import(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudStackProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudStackProject_basic, + }, + { + ResourceName: "cloudstack_project.foo", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccCloudStackProject_account(t *testing.T) { + var project cloudstack.Project + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudStackProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudStackProject_account, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.bar", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "name", "terraform-test-project-account"), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "display_text", "Terraform Test Project with Account"), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "account", "admin"), + ), + }, + }, + }) +} + +func testAccCheckCloudStackProjectExists( + n string, project *cloudstack.Project) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No project ID is set") + } + + cs := testAccProvider.Meta().(*cloudstack.CloudStackClient) + p := cs.Project.NewListProjectsParams() + p.SetId(rs.Primary.ID) + + list, err := cs.Project.ListProjects(p) + if err != nil { + return err + } + + if list.Count != 1 || list.Projects[0].Id != rs.Primary.ID { + return fmt.Errorf("Project not found") + } + + *project = *list.Projects[0] + + return nil + } +} + +func testAccCheckCloudStackProjectDestroy(s *terraform.State) error { + cs := testAccProvider.Meta().(*cloudstack.CloudStackClient) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudstack_project" { + continue + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No project ID is set") + } + + p := cs.Project.NewListProjectsParams() + p.SetId(rs.Primary.ID) + + list, err := cs.Project.ListProjects(p) + if err != nil { + return err + } + + if list.Count != 0 { + return fmt.Errorf("Project %s still exists", rs.Primary.ID) + } + } + + return nil +} + +const testAccCloudStackProject_basic = ` +resource "cloudstack_project" "foo" { + name = "terraform-test-project" + display_text = "Terraform Test Project" +}` + +const testAccCloudStackProject_update = ` +resource "cloudstack_project" "foo" { + name = "terraform-test-project-updated" + display_text = "Terraform Test Project Updated" +}` + +const testAccCloudStackProject_account = ` +resource "cloudstack_project" "bar" { + name = "terraform-test-project-account" + display_text = "Terraform Test Project with Account" + account = "admin" + domain = "ROOT" +}` diff --git a/cloudstack/resources.go b/cloudstack/resources.go index 22b2adcc..5a75b77d 100644 --- a/cloudstack/resources.go +++ b/cloudstack/resources.go @@ -72,6 +72,8 @@ func retrieveID(cs *cloudstack.CloudStackClient, name string, value string, opts switch name { case "disk_offering": id, _, err = cs.DiskOffering.GetDiskOfferingID(value) + case "domain": + id, _, err = cs.Domain.GetDomainID(value) case "kubernetes_version": id, _, err = cs.Kubernetes.GetKubernetesSupportedVersionID(value) case "network_offering": diff --git a/website/cloudstack.erb b/website/cloudstack.erb index f900196a..fff2c8e2 100644 --- a/website/cloudstack.erb +++ b/website/cloudstack.erb @@ -78,6 +78,10 @@ cloudstack_private_gateway + > + cloudstack_project + + > cloudstack_secondary_ipaddress diff --git a/website/docs/r/project.html.markdown b/website/docs/r/project.html.markdown new file mode 100644 index 00000000..215a3932 --- /dev/null +++ b/website/docs/r/project.html.markdown @@ -0,0 +1,61 @@ +--- +subcategory: "CloudStack" +layout: "cloudstack" +page_title: "CloudStack: cloudstack_project" +description: |- + Creates a project. +--- + +# cloudstack_project + +Creates a project. + +## Example Usage + +```hcl +resource "cloudstack_project" "myproject" { + name = "terraform-project" + display_text = "Terraform Managed Project" + domain = "root" +} +``` + +### With Account and User ID + +```hcl +resource "cloudstack_project" "myproject" { + name = "terraform-project" + display_text = "Terraform Managed Project" + domain = "root" + account = "admin" + userid = "b0afc3ca-a99c-4fb4-98ad-8564acab10a4" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the project. +* `display_text` - (Optional) The display text of the project. Defaults to `name` if not specified. +* `domain` - (Optional) The domain where the project will be created. +* `account` - (Optional) The account who will be Admin for the project. Requires `domain` to be set. +* `accountid` - (Optional) The ID of the account owning the project. +* `userid` - (Optional) The user ID of the account to be assigned as owner of the project (Project Admin). + +## Attributes Reference + +The following attributes are exported: + +* `id` - The ID of the project. +* `name` - The name of the project. +* `display_text` - The display text of the project. +* `domain` - The domain where the project was created. + +## Import + +Projects can be imported using the project ID, e.g. + +```sh +terraform import cloudstack_project.myproject 5cf69677-7e4b-4bf4-b868-f0b02bb72ee0 +```