This is an automated email from the ASF dual-hosted git repository. kiranchavala pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/cloudstack-terraform-provider.git
The following commit(s) were added to refs/heads/main by this push: new afa7a18 Add `cloudstack_project` resource (#167) afa7a18 is described below commit afa7a18188519a08fee753432a82f1c50844d2c4 Author: Ian <ian.cro...@telus.com> AuthorDate: Tue Sep 16 06:41:16 2025 -0700 Add `cloudstack_project` resource (#167) * Add CloudStack project resource * Add test for empty display_text defaulting to name value * Uncomment and implement tests for accountid and userid in project resource * Minor README Fix * Update display_text to required for API compatibility and adjust documentation * Clean up tests for 4.20.1.0 * fix: include domain ID when looking up projects by ID Fix issue where getProjectByID() would always return "id not found" while getProjectByName() could find the same project. CloudStack projects are only unique within a domain context, so we now include domain ID in lookups. - Modified getProjectByID() to accept optional domain parameter - Updated all calls to include domain when available - Updated test functions accordingly - Updated documentation to clarify domain requirement for project imports * feat: add cloudstack_project data source and corresponding tests * remove rogue testing script * Update cloudstack/resource_cloudstack_project.go Co-authored-by: Copilot <175728472+copi...@users.noreply.github.com> * adding domain validation to ensure projects are only reused within the intended scope Co-authored-by: Copilot <175728472+copi...@users.noreply.github.com> * Updated cloudstack go sdk to v2.17.1 (#193) * Fix creation of firewall & Egress firewall rules when created in a project * chore(deps): bump github.com/cloudflare/circl from 1.3.7 to 1.6.1 Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.3.7 to 1.6.1. - [Release notes](https://github.com/cloudflare/circl/releases) - [Commits](https://github.com/cloudflare/circl/compare/v1.3.7...v1.6.1) --- updated-dependencies: - dependency-name: github.com/cloudflare/circl dependency-version: 1.6.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] <supp...@github.com> * resolve retrieveError issue * Update cloudstack/resource_cloudstack_project.go Co-authored-by: Copilot <175728472+copi...@users.noreply.github.com> * Update cloudstack/resource_cloudstack_project.go Co-authored-by: Copilot <175728472+copi...@users.noreply.github.com> * Change display_text field from required to optional in resourceCloudStackProject * Pin github actions version for opentofu * rat + excludes and add licenses to other files (#200) * readme: add specific test instruction in readme (#211) Add instructions for specific test execution * data: get vpc in project by project name (#209) * Support additional parameters for cloudstack_nic resource (#210) * serviceoffering: add params for custom offering, storage tags, encryptroot (#212) * Support desc and ruleId in create_network_acl_rule * fix review comment * change rule_id -> rule_number and add doc * add params in unit tests * verify description and rule_number in unit test * use fields defined in schema * fix test verification sequence * handle review comments * Add support for additional optional parameters for creating network offerings (#205) * Add disk_offering & override_disk_offering to instance resource * Update website/docs/r/instance.html.markdown Co-authored-by: Copilot <175728472+copi...@users.noreply.github.com> * Allow specifying private end port & public end port for port forward rules * Update tests * Add `cloudstack_physicalnetwork` and some underlying additional resources (#201) * feat: add cidrlist parameter to loadbalancer rule (#147) * feat: add cloudstack_project resource to provider * fix: update display_text to displaytext in project resource and tests. fix: update lookup to use getAccountNameByID helper function * fix: rename display_text to displaytext in project resource and tests --------- Signed-off-by: dependabot[bot] <supp...@github.com> Co-authored-by: Copilot <175728472+copi...@users.noreply.github.com> Co-authored-by: Suresh Kumar Anaparti <sureshkumar.anapa...@gmail.com> Co-authored-by: Pearl Dsilva <pearl1...@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: vishesh92 <vishes...@gmail.com> Co-authored-by: dahn <d...@onecht.net> Co-authored-by: Manoj Kumar <manojkr.it...@gmail.com> Co-authored-by: Wei Zhou <weiz...@apache.org> Co-authored-by: Abhishek Kumar <abhishek.mr...@gmail.com> Co-authored-by: ABW <49398549+chrxmv...@users.noreply.github.com> --- README.md | 4 +- cloudstack/data_source_cloudstack_project.go | 215 ++++++++ cloudstack/data_source_cloudstack_project_test.go | 115 +++++ cloudstack/provider.go | 2 + cloudstack/resource_cloudstack_project.go | 572 +++++++++++++++++++++ cloudstack/resource_cloudstack_project_test.go | 577 ++++++++++++++++++++++ cloudstack/resources.go | 2 + website/cloudstack.erb | 4 + website/docs/d/project.html.markdown | 59 +++ website/docs/r/project.html.markdown | 61 +++ 10 files changed, 1609 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a51798..4f74d01 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ When Docker started the container you can go to http://localhost:8080/client and Once the login page is shown and you can login, you need to provision a simulated data-center: ```sh -docker exec -it cloudstack-simulator python /root/tools/marvin/marvin/deployDataCenter.py -i /root/setup/dev/advanced.cfg +docker exec -it simulator python /root/tools/marvin/marvin/deployDataCenter.py -i /root/setup/dev/advanced.cfg ``` If you refresh the client or login again, you will now get passed the initial welcome screen and be able to go to your account details and retrieve the API key and secret. Export those together with the URL: @@ -206,7 +206,7 @@ Check and ensure TF provider passes builds, GA and run this for local checks: goreleaser release --snapshot --clean ``` -Next, create a personalised Github token: https://github.com/settings/tokens/new?scopes=repo,write:packages +Next, create a personalised Github token: https://github.com/settings/tokens/new?scopes=repo,write:packages ``` export GITHUB_TOKEN="YOUR_GH_TOKEN" diff --git a/cloudstack/data_source_cloudstack_project.go b/cloudstack/data_source_cloudstack_project.go new file mode 100644 index 0000000..27e57f0 --- /dev/null +++ b/cloudstack/data_source_cloudstack_project.go @@ -0,0 +1,215 @@ +// +// 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 ( + "encoding/json" + "fmt" + "log" + "regexp" + "strings" + "time" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceCloudstackProject() *schema.Resource { + return &schema.Resource{ + Read: datasourceCloudStackProjectRead, + Schema: map[string]*schema.Schema{ + "filter": dataSourceFiltersSchema(), + + // Computed values + "name": { + Type: schema.TypeString, + Computed: true, + }, + + "displaytext": { + Type: schema.TypeString, + Computed: true, + }, + + "domain": { + Type: schema.TypeString, + Computed: true, + }, + + "account": { + Type: schema.TypeString, + Computed: true, + }, + + "state": { + Type: schema.TypeString, + Computed: true, + }, + + "tags": tagsSchema(), + }, + } +} + +func datasourceCloudStackProjectRead(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + p := cs.Project.NewListProjectsParams() + csProjects, err := cs.Project.ListProjects(p) + + if err != nil { + return fmt.Errorf("failed to list projects: %s", err) + } + + filters := d.Get("filter") + var projects []*cloudstack.Project + + for _, v := range csProjects.Projects { + match, err := applyProjectFilters(v, filters.(*schema.Set)) + if err != nil { + return err + } + if match { + projects = append(projects, v) + } + } + + if len(projects) == 0 { + return fmt.Errorf("no project matches the specified filters") + } + + // Return the latest project from the list of filtered projects according + // to its creation date + project, err := latestProject(projects) + if err != nil { + return err + } + log.Printf("[DEBUG] Selected project: %s\n", project.Name) + + return projectDescriptionAttributes(d, project) +} + +func projectDescriptionAttributes(d *schema.ResourceData, project *cloudstack.Project) error { + d.SetId(project.Id) + d.Set("name", project.Name) + d.Set("displaytext", project.Displaytext) + d.Set("domain", project.Domain) + d.Set("state", project.State) + + // Handle account information safely + if len(project.Owner) > 0 { + for _, owner := range project.Owner { + if account, ok := owner["account"]; ok { + d.Set("account", account) + break + } + } + } + + d.Set("tags", tagsToMap(project.Tags)) + + return nil +} + +func latestProject(projects []*cloudstack.Project) (*cloudstack.Project, error) { + var latest time.Time + var project *cloudstack.Project + + for _, v := range projects { + created, err := time.Parse("2006-01-02T15:04:05-0700", v.Created) + if err != nil { + return nil, fmt.Errorf("failed to parse creation date of a project: %s", err) + } + + if created.After(latest) { + latest = created + project = v + } + } + + return project, nil +} + +func applyProjectFilters(project *cloudstack.Project, filters *schema.Set) (bool, error) { + var projectJSON map[string]interface{} + k, _ := json.Marshal(project) + err := json.Unmarshal(k, &projectJSON) + if err != nil { + return false, err + } + + for _, f := range filters.List() { + m := f.(map[string]interface{}) + r, err := regexp.Compile(m["value"].(string)) + if err != nil { + return false, fmt.Errorf("invalid regex: %s", err) + } + + // Handle special case for owner/account + if m["name"].(string) == "account" { + if len(project.Owner) == 0 { + return false, nil + } + + found := false + for _, owner := range project.Owner { + if account, ok := owner["account"]; ok { + if r.MatchString(fmt.Sprintf("%v", account)) { + found = true + break + } + } + } + + if !found { + return false, nil + } + continue + } + + updatedName := strings.ReplaceAll(m["name"].(string), "_", "") + + // Handle fields that might not exist in the JSON + fieldValue, exists := projectJSON[updatedName] + if !exists { + return false, nil + } + + // Handle different types of fields + switch v := fieldValue.(type) { + case string: + if !r.MatchString(v) { + return false, nil + } + case float64: + if !r.MatchString(fmt.Sprintf("%v", v)) { + return false, nil + } + case bool: + if !r.MatchString(fmt.Sprintf("%v", v)) { + return false, nil + } + default: + // Skip fields that aren't simple types + continue + } + } + + return true, nil +} diff --git a/cloudstack/data_source_cloudstack_project_test.go b/cloudstack/data_source_cloudstack_project_test.go new file mode 100644 index 0000000..2aebc1d --- /dev/null +++ b/cloudstack/data_source_cloudstack_project_test.go @@ -0,0 +1,115 @@ +// +// 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 ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccProjectDataSource_basic(t *testing.T) { + resourceName := "cloudstack_project.project-resource" + datasourceName := "data.cloudstack_project.project-data-source" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testProjectDataSourceConfig_basic, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair(datasourceName, "name", resourceName, "name"), + resource.TestCheckResourceAttrPair(datasourceName, "displaytext", resourceName, "displaytext"), + resource.TestCheckResourceAttrPair(datasourceName, "domain", resourceName, "domain"), + ), + }, + }, + }) +} + +func TestAccProjectDataSource_withAccount(t *testing.T) { + resourceName := "cloudstack_project.project-account-resource" + datasourceName := "data.cloudstack_project.project-account-data-source" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testProjectDataSourceConfig_withAccount, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair(datasourceName, "name", resourceName, "name"), + resource.TestCheckResourceAttrPair(datasourceName, "displaytext", resourceName, "displaytext"), + resource.TestCheckResourceAttrPair(datasourceName, "domain", resourceName, "domain"), + resource.TestCheckResourceAttrPair(datasourceName, "account", resourceName, "account"), + ), + }, + }, + }) +} + +const testProjectDataSourceConfig_basic = ` +resource "cloudstack_project" "project-resource" { + name = "test-project-datasource" + displaytext = "Test Project for Data Source" +} + +data "cloudstack_project" "project-data-source" { + filter { + name = "name" + value = "test-project-datasource" + } + depends_on = [ + cloudstack_project.project-resource + ] +} + +output "project-output" { + value = data.cloudstack_project.project-data-source +} +` + +const testProjectDataSourceConfig_withAccount = ` +resource "cloudstack_project" "project-account-resource" { + name = "test-project-account-datasource" + displaytext = "Test Project with Account for Data Source" + account = "admin" + domain = "ROOT" +} + +data "cloudstack_project" "project-account-data-source" { + filter { + name = "name" + value = "test-project-account-datasource" + } + filter { + name = "account" + value = "admin" + } + depends_on = [ + cloudstack_project.project-account-resource + ] +} + +output "project-account-output" { + value = data.cloudstack_project.project-account-data-source +} +` diff --git a/cloudstack/provider.go b/cloudstack/provider.go index f4eedc1..58c7fa5 100644 --- a/cloudstack/provider.go +++ b/cloudstack/provider.go @@ -91,6 +91,7 @@ func Provider() *schema.Provider { "cloudstack_vpn_connection": dataSourceCloudstackVPNConnection(), "cloudstack_pod": dataSourceCloudstackPod(), "cloudstack_domain": dataSourceCloudstackDomain(), + "cloudstack_project": dataSourceCloudstackProject(), "cloudstack_physical_network": dataSourceCloudStackPhysicalNetwork(), "cloudstack_role": dataSourceCloudstackRole(), "cloudstack_cluster": dataSourceCloudstackCluster(), @@ -141,6 +142,7 @@ func Provider() *schema.Provider { "cloudstack_zone": resourceCloudStackZone(), "cloudstack_service_offering": resourceCloudStackServiceOffering(), "cloudstack_account": resourceCloudStackAccount(), + "cloudstack_project": resourceCloudStackProject(), "cloudstack_user": resourceCloudStackUser(), "cloudstack_domain": resourceCloudStackDomain(), "cloudstack_network_service_provider": resourceCloudStackNetworkServiceProvider(), diff --git a/cloudstack/resource_cloudstack_project.go b/cloudstack/resource_cloudstack_project.go new file mode 100644 index 0000000..ce113e4 --- /dev/null +++ b/cloudstack/resource_cloudstack_project.go @@ -0,0 +1,572 @@ +// 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 ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "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, + }, + + "displaytext": { + Type: schema.TypeString, + Optional: true, + }, + + "domain": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + + "account": { + Type: schema.TypeString, + Optional: true, + }, + + "accountid": { + Type: schema.TypeString, + Optional: true, + }, + + "userid": { + Type: schema.TypeString, + Optional: true, + }, + }, + } +} + +func resourceCloudStackProjectCreate(d *schema.ResourceData, meta any) error { + cs := meta.(*cloudstack.CloudStackClient) + + // Get the name and displaytext + name := d.Get("name").(string) + displaytext := d.Get("displaytext").(string) + + // Get domain if provided + var domain string + domainSet := false + if domainParam, ok := d.GetOk("domain"); ok { + domain = domainParam.(string) + domainSet = true + } + + // Only check for an existing project if domain is set + if domainSet { + existingProject, err := getProjectByName(cs, name, domain) + if err == nil { + // Project with this name and domain already exists + log.Printf("[DEBUG] Project with name %s and domain %s already exists, using existing project with ID: %s", name, domain, existingProject.Id) + d.SetId(existingProject.Id) + + // Set the basic attributes to match the existing project + d.Set("name", existingProject.Name) + d.Set("displaytext", existingProject.Displaytext) + d.Set("domain", existingProject.Domain) + + return resourceCloudStackProjectRead(d, meta) + } else if !strings.Contains(err.Error(), "not found") { + // If we got an error other than "not found", return it + return fmt.Errorf("error checking for existing project: %s", err) + } + } + + // Project doesn't exist, create a new one + + // The CloudStack Go SDK expects parameters in the API 4.18 order: displaytext, name. + p := cs.Project.NewCreateProjectParams(displaytext, name) + + // Set the domain if provided + if domain != "" { + domainid, e := retrieveID(cs, "domain", domain) + if e != nil { + return fmt.Errorf("error retrieving domain ID: %v", e) + } + 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) + log.Printf("[DEBUG] Project created with ID: %s", r.Id) + + // Wait for the project to be available + // Use a longer timeout to ensure project creation completes + ctx := context.Background() + + err = retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + project, err := getProjectByID(cs, d.Id(), domain) + if err != nil { + if strings.Contains(err.Error(), "not found") { + log.Printf("[DEBUG] Project %s not found yet, retrying...", d.Id()) + return retry.RetryableError(fmt.Errorf("project not yet created: %s", err)) + } + return retry.NonRetryableError(fmt.Errorf("Error retrieving project: %s", err)) + } + + log.Printf("[DEBUG] Project %s found with name %s", d.Id(), project.Name) + return nil + }) + + // Even if the retry times out, we should still try to read the resource + // since it might have been created successfully + if err != nil { + log.Printf("[WARN] Timeout waiting for project %s to be available: %s", d.Id(), err) + } + + // Read the resource state + return resourceCloudStackProjectRead(d, meta) +} + +// Helper function to get a project by ID +func getProjectByID(cs *cloudstack.CloudStackClient, id string, domain ...string) (*cloudstack.Project, error) { + p := cs.Project.NewListProjectsParams() + p.SetId(id) + + // If domain is provided, use it to narrow the search + if len(domain) > 0 && domain[0] != "" { + log.Printf("[DEBUG] Looking up project with ID: %s in domain: %s", id, domain[0]) + domainID, err := retrieveID(cs, "domain", domain[0]) + if err != nil { + log.Printf("[WARN] Error retrieving domain ID for domain %s: %v", domain[0], err) + // Continue without domain ID, but log the warning + } else { + p.SetDomainid(domainID) + } + } else { + log.Printf("[DEBUG] Looking up project with ID: %s (no domain specified)", id) + } + + l, err := cs.Project.ListProjects(p) + if err != nil { + log.Printf("[ERROR] Error calling ListProjects with ID %s: %v", id, err) + return nil, err + } + + log.Printf("[DEBUG] ListProjects returned Count: %d for ID: %s", l.Count, id) + + if l.Count == 0 { + return nil, fmt.Errorf("project with id %s not found", id) + } + + // Add validation to ensure the returned project ID matches the requested ID + if l.Projects[0].Id != id { + log.Printf("[WARN] Project ID mismatch - requested: %s, got: %s", id, l.Projects[0].Id) + // Continue anyway to see if this is the issue + } + + log.Printf("[DEBUG] Found project with ID: %s, Name: %s", l.Projects[0].Id, l.Projects[0].Name) + return l.Projects[0], nil +} + +// Helper function to get an account name by account ID +func getAccountNameByID(cs *cloudstack.CloudStackClient, accountID string) (string, error) { + // Create parameters for listing accounts + p := cs.Account.NewListAccountsParams() + p.SetId(accountID) + + // Call the API to list accounts with the specified ID + accounts, err := cs.Account.ListAccounts(p) + if err != nil { + return "", fmt.Errorf("error retrieving account with ID %s: %s", accountID, err) + } + + // Check if we found the account + if accounts.Count == 0 { + return "", fmt.Errorf("account with ID %s not found", accountID) + } + + // Return the account name + account := accounts.Accounts[0] + if account.Name == "" { + return "", fmt.Errorf("account with ID %s has no name", accountID) + } + + return account.Name, nil +} + +// Helper function to get a project by name +func getProjectByName(cs *cloudstack.CloudStackClient, name string, domain string) (*cloudstack.Project, error) { + p := cs.Project.NewListProjectsParams() + p.SetName(name) + + // If domain is provided, use it to narrow the search + if domain != "" { + domainID, err := retrieveID(cs, "domain", domain) + if err != nil { + return nil, fmt.Errorf("error retrieving domain ID: %v", err) + } + p.SetDomainid(domainID) + } + + log.Printf("[DEBUG] Looking up project with name: %s", name) + l, err := cs.Project.ListProjects(p) + if err != nil { + return nil, err + } + + if l.Count == 0 { + return nil, fmt.Errorf("project with name %s not found", name) + } + + // If multiple projects with the same name exist, log a warning and return the first one + if l.Count > 1 { + log.Printf("[WARN] Multiple projects found with name %s, using the first one", name) + } + + log.Printf("[DEBUG] Found project %s with ID: %s", name, l.Projects[0].Id) + return l.Projects[0], nil +} + +func resourceCloudStackProjectRead(d *schema.ResourceData, meta any) error { + cs := meta.(*cloudstack.CloudStackClient) + + log.Printf("[DEBUG] Retrieving project %s", d.Id()) + + // Get project name and domain for potential fallback lookup + name := d.Get("name").(string) + var domain string + if domainParam, ok := d.GetOk("domain"); ok { + domain = domainParam.(string) + } + + // Get the project details by ID + project, err := getProjectByID(cs, d.Id(), domain) + + // If project not found by ID and we have a name, try to find it by name + if err != nil && name != "" && (strings.Contains(err.Error(), "not found") || + strings.Contains(err.Error(), "does not exist") || + strings.Contains(err.Error(), "could not be found") || + 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 not found by ID, trying to find by name: %s", d.Id(), name) + project, err = getProjectByName(cs, name, domain) + + // If project not found by name either, resource doesn't exist + if err != nil { + if strings.Contains(err.Error(), "not found") { + log.Printf("[DEBUG] Project with name %s not found either, marking as gone", name) + d.SetId("") + return nil + } + // For other errors during name lookup, return them + return fmt.Errorf("error looking up project by name: %s", err) + } + + // Found by name, update the ID + log.Printf("[DEBUG] Found project by name %s with ID: %s", name, project.Id) + d.SetId(project.Id) + } else if err != nil { + // For other errors during ID lookup, return them + return fmt.Errorf("error retrieving project %s: %s", d.Id(), err) + } + + log.Printf("[DEBUG] Found project %s: %s", d.Id(), project.Name) + + // Set the basic attributes + d.Set("name", project.Name) + d.Set("displaytext", project.Displaytext) + d.Set("domain", project.Domain) + + // Handle owner information more safely + // Only set the account, accountid, and userid if they were explicitly set in the configuration + // and if the owner information is available + if _, ok := d.GetOk("account"); ok { + // Safely handle the case where project.Owner might be nil or empty + if len(project.Owner) > 0 { + foundAccount := false + for _, owner := range project.Owner { + if account, ok := owner["account"]; ok { + d.Set("account", account) + foundAccount = true + break + } + } + if !foundAccount { + log.Printf("[DEBUG] Project %s owner information doesn't contain account, keeping original value", d.Id()) + } + } else { + // Keep the original account value from the configuration + // This prevents Terraform from thinking the resource has disappeared + log.Printf("[DEBUG] Project %s owner information not available yet, keeping original account value", d.Id()) + } + } + + if _, ok := d.GetOk("accountid"); ok { + if len(project.Owner) > 0 { + foundAccountID := false + for _, owner := range project.Owner { + if accountid, ok := owner["accountid"]; ok { + d.Set("accountid", accountid) + foundAccountID = true + break + } + } + if !foundAccountID { + log.Printf("[DEBUG] Project %s owner information doesn't contain accountid, keeping original value", d.Id()) + } + } else { + log.Printf("[DEBUG] Project %s owner information not available yet, keeping original accountid value", d.Id()) + } + } + + if _, ok := d.GetOk("userid"); ok { + if len(project.Owner) > 0 { + foundUserID := false + for _, owner := range project.Owner { + if userid, ok := owner["userid"]; ok { + d.Set("userid", userid) + foundUserID = true + break + } + } + if !foundUserID { + log.Printf("[DEBUG] Project %s owner information doesn't contain userid, keeping original value", d.Id()) + } + } else { + log.Printf("[DEBUG] Project %s owner information not available yet, keeping original userid value", d.Id()) + } + } + + return nil +} + +func resourceCloudStackProjectUpdate(d *schema.ResourceData, meta any) error { + cs := meta.(*cloudstack.CloudStackClient) + + // Check if the name or displaytext is changed + if d.HasChange("name") || d.HasChange("displaytext") { + // Create a new parameter struct + p := cs.Project.NewUpdateProjectParams(d.Id()) + + // Set the name and displaytext if they have changed + // Note: The 'name' parameter is only available in CloudStack API 4.19+ and in cloudstack-go SDK v2.11.0+. + // If you're using API 4.18 or lower, or an older SDK, the SetName method might not work. + // In that case, you might need to update the displaytext only. + if d.HasChange("name") { + p.SetName(d.Get("name").(string)) + } + + if d.HasChange("displaytext") { + p.SetDisplaytext(d.Get("displaytext").(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) + } + } + + // Check if the account, accountid, or userid is changed + if d.HasChange("account") || d.HasChange("accountid") || d.HasChange("userid") { + // Create a new parameter struct + p := cs.Project.NewUpdateProjectParams(d.Id()) + + // Set swapowner to true to swap ownership with the account/user provided + p.SetSwapowner(true) + + // Set the account if it has changed + if d.HasChange("account") { + p.SetAccount(d.Get("account").(string)) + } + + // Set the userid if it has changed + if d.HasChange("userid") { + p.SetUserid(d.Get("userid").(string)) + } + + // Note: The UpdateProject API does not accept 'accountid' directly. + // If 'accountid' has changed but 'account' has not, we perform a lookup to get the account name + // corresponding to the new 'accountid' and set it using the 'account' parameter instead. + // This is necessary because the API only allows updating the owner via the account name, not the account ID. + // Only perform the lookup if "account" itself hasn't changed, to avoid conflicting updates. + if d.HasChange("accountid") && !d.HasChange("account") { + // If accountid has changed but account hasn't, we need to look up the account name + // from the accountid and use it in the account parameter + accountid := d.Get("accountid").(string) + if accountid != "" { + accountName, err := getAccountNameByID(cs, accountid) + if err != nil { + log.Printf("[WARN] Failed to look up account name for accountid %s: %s. Skipping account update as account name could not be determined.", accountid, err) + } else { + log.Printf("[DEBUG] Found account name '%s' for accountid %s, using account parameter", accountName, accountid) + p.SetAccount(accountName) + } + } + } + + log.Printf("[DEBUG] Updating project owner %s", d.Id()) + _, err := cs.Project.UpdateProject(p) + if err != nil { + return fmt.Errorf("Error updating project owner %s: %s", d.Id(), err) + } + } + + // Wait for the project to be updated + ctx := context.Background() + + // Get domain if provided + var domain string + if domainParam, ok := d.GetOk("domain"); ok { + domain = domainParam.(string) + } + + err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + project, err := getProjectByID(cs, d.Id(), domain) + if err != nil { + if strings.Contains(err.Error(), "not found") { + log.Printf("[DEBUG] Project %s not found after update, retrying...", d.Id()) + return retry.RetryableError(fmt.Errorf("project not found after update: %s", err)) + } + return retry.NonRetryableError(fmt.Errorf("Error retrieving project after update: %s", err)) + } + + // Check if the project has the expected values + if d.HasChange("name") && project.Name != d.Get("name").(string) { + log.Printf("[DEBUG] Project %s name not updated yet, retrying...", d.Id()) + return retry.RetryableError(fmt.Errorf("project name not updated yet")) + } + + if d.HasChange("displaytext") && project.Displaytext != d.Get("displaytext").(string) { + log.Printf("[DEBUG] Project %s displaytext not updated yet, retrying...", d.Id()) + return retry.RetryableError(fmt.Errorf("project displaytext not updated yet")) + } + + log.Printf("[DEBUG] Project %s updated successfully", d.Id()) + return nil + }) + + // Even if the retry times out, we should still try to read the resource + // since it might have been updated successfully + if err != nil { + log.Printf("[WARN] Timeout waiting for project %s to be updated: %s", d.Id(), err) + } + + // Read the resource state + return resourceCloudStackProjectRead(d, meta) +} + +func resourceCloudStackProjectDelete(d *schema.ResourceData, meta any) error { + cs := meta.(*cloudstack.CloudStackClient) + + // Get project name and domain for potential fallback lookup + name := d.Get("name").(string) + var domain string + if domainParam, ok := d.GetOk("domain"); ok { + domain = domainParam.(string) + } + + // First check if the project still exists by ID + log.Printf("[DEBUG] Checking if project %s exists before deleting", d.Id()) + project, err := getProjectByID(cs, d.Id(), domain) + + // If project not found by ID, try to find it by name + if err != nil && strings.Contains(err.Error(), "not found") { + log.Printf("[DEBUG] Project %s not found by ID, trying to find by name: %s", d.Id(), name) + project, err = getProjectByName(cs, name, domain) + + // If project not found by name either, we're done + if err != nil { + if strings.Contains(err.Error(), "not found") { + log.Printf("[DEBUG] Project with name %s not found either, nothing to delete", name) + return nil + } + // For other errors during name lookup, return them + return fmt.Errorf("error looking up project by name: %s", err) + } + + // Found by name, update the ID + log.Printf("[DEBUG] Found project by name %s with ID: %s", name, project.Id) + d.SetId(project.Id) + } else if err != nil { + // For other errors during ID lookup, return them + return fmt.Errorf("error checking project existence before delete: %s", err) + } + + log.Printf("[DEBUG] Found project %s (%s), proceeding with delete", d.Id(), project.Name) + + // Create a new parameter struct + p := cs.Project.NewDeleteProjectParams(d.Id()) + result, err := cs.Project.DeleteProject(p) + if err != nil { + // Check for various "not found" or "does not exist" error patterns + if strings.Contains(err.Error(), "not found") || + strings.Contains(err.Error(), "does not exist") || + 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 no longer exists after delete attempt", d.Id()) + return nil + } + + return fmt.Errorf("error deleting project %s: %s", d.Id(), err) + } + if result == nil { + log.Printf("[WARN] DeleteProject returned nil result for project: %s (%s)", d.Id(), project.Name) + } + + log.Printf("[DEBUG] Successfully deleted project: %s (%s)", d.Id(), project.Name) + return nil +} diff --git a/cloudstack/resource_cloudstack_project_test.go b/cloudstack/resource_cloudstack_project_test.go new file mode 100644 index 0000000..b4d60c8 --- /dev/null +++ b/cloudstack/resource_cloudstack_project_test.go @@ -0,0 +1,577 @@ +// 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" + "strings" + "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", "displaytext", "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", "displaytext", "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", "displaytext", "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", "displaytext", "Terraform Test Project with Account"), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "account", "admin"), + ), + }, + }, + }) +} + +func TestAccCloudStackProject_updateAccount(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", "displaytext", "Terraform Test Project with Account"), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "account", "admin"), + ), + }, + { + Config: testAccCloudStackProject_updateAccount, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.bar", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "name", "terraform-test-project-account"), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "displaytext", "Terraform Test Project with Account"), + resource.TestCheckResourceAttr( + "cloudstack_project.bar", "account", "admin"), + ), + }, + }, + }) +} + +func TestAccCloudStackProject_emptyDisplayText(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_emptyDisplayText, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.empty", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.empty", "name", "terraform-test-project-empty-display"), + resource.TestCheckResourceAttr( + "cloudstack_project.empty", "displaytext", "terraform-test-project-empty-display"), + ), + }, + }, + }) +} + +func TestAccCloudStackProject_updateUserid(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_userid, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.baz", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.baz", "name", "terraform-test-project-userid"), + resource.TestCheckResourceAttr( + "cloudstack_project.baz", "displaytext", "Terraform Test Project with Userid"), + ), + }, + { + Config: testAccCloudStackProject_updateUserid, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.baz", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.baz", "name", "terraform-test-project-userid-updated"), + resource.TestCheckResourceAttr( + "cloudstack_project.baz", "displaytext", "Terraform Test Project with Userid Updated"), + ), + }, + }, + }) +} + +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) + + // Get domain if available + var domain string + if domainAttr, ok := rs.Primary.Attributes["domain"]; ok && domainAttr != "" { + domain = domainAttr + } + + // First try to find the project by ID with domain if available + p := cs.Project.NewListProjectsParams() + p.SetId(rs.Primary.ID) + + // Add domain if available + if domain != "" { + domainID, err := retrieveID(cs, "domain", domain) + if err == nil { + p.SetDomainid(domainID) + } + } + + list, err := cs.Project.ListProjects(p) + if err == nil && list.Count > 0 && list.Projects[0].Id == rs.Primary.ID { + // Found by ID, set the project and return + *project = *list.Projects[0] + return nil + } + + // If not found by ID or there was an error, try by name + if err != nil || list.Count == 0 || list.Projects[0].Id != rs.Primary.ID { + name := rs.Primary.Attributes["name"] + if name == "" { + return fmt.Errorf("Project not found by ID and name attribute is empty") + } + + // Try to find by name + p := cs.Project.NewListProjectsParams() + p.SetName(name) + + // Add domain if available + if domain, ok := rs.Primary.Attributes["domain"]; ok && domain != "" { + domainID, err := retrieveID(cs, "domain", domain) + if err != nil { + return fmt.Errorf("Error retrieving domain ID: %v", err) + } + p.SetDomainid(domainID) + } + + list, err := cs.Project.ListProjects(p) + if err != nil { + return fmt.Errorf("Error retrieving project by name: %s", err) + } + + if list.Count == 0 { + return fmt.Errorf("Project with name %s not found", name) + } + + // Find the project with the matching ID if possible + found := false + for _, proj := range list.Projects { + if proj.Id == rs.Primary.ID { + *project = *proj + found = true + break + } + } + + // If we didn't find a project with matching ID, use the first one + if !found { + *project = *list.Projects[0] + // Update the resource ID to match the found project + rs.Primary.ID = list.Projects[0].Id + } + + return nil + } + + return fmt.Errorf("Project not found by ID or name") + } +} + +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") + } + + // Get domain if available + var domain string + if domainAttr, ok := rs.Primary.Attributes["domain"]; ok && domainAttr != "" { + domain = domainAttr + } + + // Try to find the project by ID + p := cs.Project.NewListProjectsParams() + p.SetId(rs.Primary.ID) + + // Add domain if available + if domain != "" { + domainID, err := retrieveID(cs, "domain", domain) + if err == nil { + p.SetDomainid(domainID) + } + } + + list, err := cs.Project.ListProjects(p) + + // If we get an error, check if it's a "not found" error + if err != nil { + if strings.Contains(err.Error(), "not found") || + strings.Contains(err.Error(), "does not exist") || + strings.Contains(err.Error(), "could not be found") || + strings.Contains(err.Error(), fmt.Sprintf( + "Invalid parameter id value=%s due to incorrect long value format, "+ + "or entity does not exist", rs.Primary.ID)) { + // Project doesn't exist, which is what we want + continue + } + // For other errors, return them + return fmt.Errorf("error checking if project %s exists: %s", rs.Primary.ID, err) + } + + // If we found the project, it still exists + if list.Count != 0 { + return fmt.Errorf("project %s still exists (found by ID)", rs.Primary.ID) + } + + // Also check by name to be thorough + name := rs.Primary.Attributes["name"] + if name != "" { + // Try to find the project by name + p := cs.Project.NewListProjectsParams() + p.SetName(name) + + // Add domain if available + if domain, ok := rs.Primary.Attributes["domain"]; ok && domain != "" { + domainID, err := retrieveID(cs, "domain", domain) + if err == nil { + p.SetDomainid(domainID) + } + } + + list, err := cs.Project.ListProjects(p) + if err != nil { + // Ignore errors for name lookup + continue + } + + // Check if any of the returned projects match our ID + for _, proj := range list.Projects { + if proj.Id == rs.Primary.ID { + return fmt.Errorf("project %s still exists (found by name %s)", rs.Primary.ID, name) + } + } + } + } + + return nil +} + +const testAccCloudStackProject_basic = ` +resource "cloudstack_project" "foo" { + name = "terraform-test-project" + displaytext = "Terraform Test Project" +}` + +const testAccCloudStackProject_update = ` +resource "cloudstack_project" "foo" { + name = "terraform-test-project-updated" + displaytext = "Terraform Test Project Updated" +}` + +const testAccCloudStackProject_account = ` +resource "cloudstack_project" "bar" { + name = "terraform-test-project-account" + displaytext = "Terraform Test Project with Account" + account = "admin" + domain = "ROOT" +}` + +const testAccCloudStackProject_updateAccount = ` +resource "cloudstack_project" "bar" { + name = "terraform-test-project-account" + displaytext = "Terraform Test Project with Account" + account = "admin" + domain = "ROOT" +}` + +const testAccCloudStackProject_userid = ` +resource "cloudstack_project" "baz" { + name = "terraform-test-project-userid" + displaytext = "Terraform Test Project with Userid" + domain = "ROOT" +}` + +const testAccCloudStackProject_updateUserid = ` +resource "cloudstack_project" "baz" { + name = "terraform-test-project-userid-updated" + displaytext = "Terraform Test Project with Userid Updated" + domain = "ROOT" +}` + +const testAccCloudStackProject_emptyDisplayText = ` +resource "cloudstack_project" "empty" { + name = "terraform-test-project-empty-display" + displaytext = "terraform-test-project-empty-display" +}` + +func TestAccCloudStackProject_updateAccountid(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_accountid, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.accountid_test", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.accountid_test", "name", "terraform-test-project-accountid"), + resource.TestCheckResourceAttr( + "cloudstack_project.accountid_test", "displaytext", "Terraform Test Project with Accountid"), + resource.TestCheckResourceAttrSet( + "cloudstack_project.accountid_test", "accountid"), + ), + }, + { + Config: testAccCloudStackProject_updateAccountid, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectExists( + "cloudstack_project.accountid_test", &project), + resource.TestCheckResourceAttr( + "cloudstack_project.accountid_test", "name", "terraform-test-project-accountid"), + resource.TestCheckResourceAttr( + "cloudstack_project.accountid_test", "displaytext", "Terraform Test Project with Accountid"), + resource.TestCheckResourceAttrSet( + "cloudstack_project.accountid_test", "accountid"), + ), + }, + }, + }) +} + +func TestAccCloudStackProject_list(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudStackProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudStackProject_list, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackProjectsExist("cloudstack_project.project1", "cloudstack_project.project2"), + ), + }, + }, + }) +} + +func testAccCheckCloudStackProjectsExist(projectNames ...string) resource.TestCheckFunc { + return func(s *terraform.State) error { + // Get CloudStack client + cs := testAccProvider.Meta().(*cloudstack.CloudStackClient) + + // Create a map to track which projects we've found + foundProjects := make(map[string]bool) + for _, name := range projectNames { + // Get the project resource from state + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("Not found: %s", name) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No project ID is set for %s", name) + } + + // Add the project ID to our tracking map + foundProjects[rs.Primary.ID] = false + } + + // List all projects + p := cs.Project.NewListProjectsParams() + list, err := cs.Project.ListProjects(p) + if err != nil { + return err + } + + // Check if all our projects are in the list + for _, project := range list.Projects { + if _, exists := foundProjects[project.Id]; exists { + foundProjects[project.Id] = true + } + } + + // Verify all projects were found + for id, found := range foundProjects { + if !found { + return fmt.Errorf("Project with ID %s was not found in the list", id) + } + } + + return nil + } +} + +const testAccCloudStackProject_accountid = ` +resource "cloudstack_project" "accountid_test" { + name = "terraform-test-project-accountid" + displaytext = "Terraform Test Project with Accountid" + accountid = "1" + domain = "ROOT" +}` + +const testAccCloudStackProject_updateAccountid = ` +resource "cloudstack_project" "accountid_test" { + name = "terraform-test-project-accountid" + displaytext = "Terraform Test Project with Accountid" + accountid = "2" + domain = "ROOT" +}` + +const testAccCloudStackProject_list = ` +resource "cloudstack_project" "project1" { + name = "terraform-test-project-list-1" + displaytext = "Terraform Test Project List 1" +} + +resource "cloudstack_project" "project2" { + name = "terraform-test-project-list-2" + displaytext = "Terraform Test Project List 2" +}` diff --git a/cloudstack/resources.go b/cloudstack/resources.go index 22b2adc..5a75b77 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 fbc70b4..1ea7f7e 100644 --- a/website/cloudstack.erb +++ b/website/cloudstack.erb @@ -81,6 +81,10 @@ <a href="/docs/providers/cloudstack/r/private_gateway.html">cloudstack_private_gateway</a> </li> + <li<%= sidebar_current("docs-cloudstack-resource-project") %>> + <a href="/docs/providers/cloudstack/r/project.html">cloudstack_project</a> + </li> + <li<%= sidebar_current("docs-cloudstack-resource-secondary-ipaddress") %>> <a href="/docs/providers/cloudstack/r/secondary_ipaddress.html">cloudstack_secondary_ipaddress</a> </li> diff --git a/website/docs/d/project.html.markdown b/website/docs/d/project.html.markdown new file mode 100644 index 0000000..a1d4656 --- /dev/null +++ b/website/docs/d/project.html.markdown @@ -0,0 +1,59 @@ +--- +layout: "cloudstack" +page_title: "CloudStack: cloudstack_project" +sidebar_current: "docs-cloudstack-cloudstack_project" +description: |- + Gets information about CloudStack projects. +--- + +# cloudstack_project + +Use this datasource to get information about a CloudStack project for use in other resources. + +## Example Usage + +### Basic Usage + +```hcl +data "cloudstack_project" "my_project" { + filter { + name = "name" + value = "my-project" + } +} +``` + +### With Multiple Filters + +```hcl +data "cloudstack_project" "admin_project" { + filter { + name = "name" + value = "admin-project" + } + filter { + name = "domain" + value = "ROOT" + } + filter { + name = "account" + value = "admin" + } +} +``` + +## Argument Reference + +* `filter` - (Required) One or more name/value pairs to filter off of. You can apply filters on any exported attributes. + +## 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 belongs. +* `account` - The account who is the admin for the project. +* `state` - The current state of the project. +* `tags` - A map of tags assigned to the project. \ No newline at end of file diff --git a/website/docs/r/project.html.markdown b/website/docs/r/project.html.markdown new file mode 100644 index 0000000..fbf9669 --- /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` - (Required) The display text of the project. Required for API version 4.18 and lower compatibility. This requirement will be removed when support for API versions older than 4.18 is dropped. +* `domain` - (Optional) The domain where the project will be created. This cannot be changed after the project is created. +* `account` - (Optional) The account who will be Admin for the project. Requires `domain` to be set. This can be updated after the project is created. +* `accountid` - (Optional) The ID of the account owning the project. This can be updated after the project is created. +* `userid` - (Optional) The user ID of the account to be assigned as owner of the project (Project Admin). This can be updated after the project is created. + +## 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 +```