| // 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 |
| } |