Skip to content

[WIP] Add CloudStack project resource #167

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -200,7 +200,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"
Expand Down
1 change: 1 addition & 0 deletions cloudstack/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
373 changes: 373 additions & 0 deletions cloudstack/resource_cloudstack_project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,373 @@
// 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"
"time"

"github.com/apache/cloudstack-go/v2/cloudstack"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)

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,
Required: true, // Required for API version 4.18 and lower. TODO: Make this optional when support for API versions older than 4.18 is dropped.
},

"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 interface{}) error {
cs := meta.(*cloudstack.CloudStackClient)

// Get the name and display_text
name := d.Get("name").(string)
displaytext := d.Get("display_text").(string)

// The CloudStack API parameter order differs between versions:
// - In API 4.18 and lower: displaytext is the first parameter and name is the second
// - In API 4.19 and higher: name is the first parameter and displaytext is optional
// The CloudStack Go SDK uses the API 4.18 parameter order
p := cs.Project.NewCreateProjectParams(displaytext, name)

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

// Wait for the project to be available, but with a shorter timeout
// to prevent getting stuck indefinitely
err = resource.Retry(30*time.Second, func() *resource.RetryError {
project, err := getProjectByID(cs, d.Id())
if err != nil {
if strings.Contains(err.Error(), "not found") {
log.Printf("[DEBUG] Project %s not found yet, retrying...", d.Id())
return resource.RetryableError(fmt.Errorf("Project not yet created: %s", err))
}
return resource.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) (*cloudstack.Project, error) {
p := cs.Project.NewListProjectsParams()
p.SetId(id)

l, err := cs.Project.ListProjects(p)
if err != nil {
return nil, err
}

if l.Count == 0 {
return nil, fmt.Errorf("Project with ID %s not found", id)
}

return l.Projects[0], nil
}

func resourceCloudStackProjectRead(d *schema.ResourceData, meta interface{}) error {
cs := meta.(*cloudstack.CloudStackClient)

log.Printf("[DEBUG] Retrieving project %s", d.Id())

// Get the project details
project, err := getProjectByID(cs, d.Id())
if err != nil {
if strings.Contains(err.Error(), "not 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 does no longer exist", d.Id())
d.SetId("")
return nil
}

return err
}

log.Printf("[DEBUG] Found project %s: %s", d.Id(), project.Name)

// Set the basic attributes
d.Set("name", project.Name)
d.Set("display_text", 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 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())

// Set the name and display_text if they have changed
// Note: The 'name' parameter is only available in API 4.19 and higher
// If you're using API 4.18 or lower, the SetName method might not work
// In that case, you might need to update the display_text only
if d.HasChange("name") {
p.SetName(d.Get("name").(string))
}

if d.HasChange("display_text") {
p.SetDisplaytext(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)
}
}

// 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: accountid is not directly supported by the UpdateProject API,
// but we can use the account parameter instead if accountid has changed
if d.HasChange("accountid") && !d.HasChange("account") {
// If accountid has changed but account hasn't, we need to look up the account name
// This is a placeholder - in a real implementation, you would need to look up
// the account name from the accountid
log.Printf("[WARN] Updating accountid is not directly supported by the API. Please use account instead.")
}

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, but with a shorter timeout
err := resource.Retry(30*time.Second, func() *resource.RetryError {
project, err := getProjectByID(cs, d.Id())
if err != nil {
if strings.Contains(err.Error(), "not found") {
log.Printf("[DEBUG] Project %s not found after update, retrying...", d.Id())
return resource.RetryableError(fmt.Errorf("Project not found after update: %s", err))
}
return resource.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 resource.RetryableError(fmt.Errorf("Project name not updated yet"))
}

if d.HasChange("display_text") && project.Displaytext != d.Get("display_text").(string) {
log.Printf("[DEBUG] Project %s display_text not updated yet, retrying...", d.Id())
return resource.RetryableError(fmt.Errorf("Project display_text 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 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
}
Loading
Loading