diff --git a/crusoe/provider.go b/crusoe/provider.go index fb8d568..783cc9e 100644 --- a/crusoe/provider.go +++ b/crusoe/provider.go @@ -16,6 +16,7 @@ import ( "github.com/crusoecloud/terraform-provider-crusoe/internal/firewall_rule" "github.com/crusoecloud/terraform-provider-crusoe/internal/ib_network" "github.com/crusoecloud/terraform-provider-crusoe/internal/ib_partition" + "github.com/crusoecloud/terraform-provider-crusoe/internal/project" "github.com/crusoecloud/terraform-provider-crusoe/internal/vm" ) @@ -51,6 +52,7 @@ func (p *crusoeProvider) DataSources(_ context.Context) []func() datasource.Data vm.NewVMDataSource, disk.NewDisksDataSource, ib_network.NewIBNetworkDataSource, + project.NewProjectsDataSource, } } @@ -61,6 +63,7 @@ func (p *crusoeProvider) Resources(_ context.Context) []func() resource.Resource disk.NewDiskResource, firewall_rule.NewFirewallRuleResource, ib_partition.NewIBPartitionResource, + project.NewProjectResource, } } @@ -102,6 +105,16 @@ func (p *crusoeProvider) Configure(ctx context.Context, req provider.ConfigureRe ) } + if clientConfig.DefaultProject == "" { + resp.Diagnostics.AddAttributeWarning( + path.Root("default_project"), + "Missing Crusoe Default Project", + "The provider did not find a default project specified in the configuration file and will attempt to infer the project to use if not specified. "+ + "Set the value in ~/.crusoe/config. "+ + "If either is already set, ensure the value is not empty.", + ) + } + if resp.Diagnostics.HasError() { return } diff --git a/examples/infiniband/main.tf b/examples/infiniband/main.tf index 409aa33..b50b47c 100644 --- a/examples/infiniband/main.tf +++ b/examples/infiniband/main.tf @@ -6,6 +6,7 @@ terraform { } } + locals { my_ssh_key = file("~/.ssh/id_ed25519.pub") } @@ -24,22 +25,32 @@ resource "crusoe_ib_partition" "my_partition" { # above. alternatively, they can be obtain with the CLI by # crusoe networking ib-network list ib_network_id = "" + project_id = crusoe_project.my_project.id } -# create two VMs, both in the same Infiniband partition +# create multiple VMs, all in the same Infiniband partition resource "crusoe_compute_instance" "my_vm1" { count = 8 name = "ib-vm-${count.index}" - type = "a100-80gb-sxm-ib.8x" # IB enabled VM type, `a100-80gb-sxm-ib.8x` or h100-80gb-sxm-ib.8x` - location = "us-east1-a" # IB currently only supported in `us-east1-a` - image = "ubuntu20.04-nvidia-sxm-docker:latest" # IB image, see full list at https://docs.crusoecloud.com/compute/images/overview/index.html#list-of-curated-images - ib_partition_id = crusoe_ib_partition.my_partition.id + type = "a100-80gb-sxm-ib.8x" # IB enabled VM type + location = "us-east1-a" # IB currently only supported at us-east1-a + image = "ubuntu22.04-nvidia-sxm-docker:latest" # IB image + ssh_key = local.my_ssh_key + host_channel_adapters = [ + { + ib_partition_id = crusoe_ib_partition.my_partition.id + } + ] disks = [ // disk attached at startup - crusoe_storage_disk.data_disk + { + id = crusoe_storage_disk.data_disk.id + attachment_type = "data" + mode = "read-only" + } ] } diff --git a/examples/project-variable/main.tf b/examples/project-variable/main.tf new file mode 100644 index 0000000..60668ad --- /dev/null +++ b/examples/project-variable/main.tf @@ -0,0 +1,58 @@ +terraform { + required_providers { + crusoe = { + source = "registry.terraform.io/crusoecloud/crusoe" + } + } +} + +locals { + my_ssh_key = file("~/.ssh/id_ed25519.pub") +} + +variable "project_id" { + type = string + default = "" +} + +// new VM +resource "crusoe_compute_instance" "my_vm" { + name = "my-new-vm" + type = "a40.1x" + location = "us-northcentral1-a" + + disks = [ + // disk attached at startup + { + id = crusoe_storage_disk.data_disk.id + mode = "read-write" + attachment_type = "data" + } + ] + + ssh_key = local.my_ssh_key + project_id = var.project_id + +} + +resource "crusoe_storage_disk" "data_disk" { + name = "data-disk" + size = "200GiB" + project_id = var.project_id + location = "us-northcentral1-a" +} + +// firewall rule +// note: this allows all ingress over TCP to our VM +resource "crusoe_vpc_firewall_rule" "open_fw_rule" { + network = crusoe_compute_instance.my_vm.network_interfaces[0].network + name = "example-terraform-rule" + action = "allow" + direction = "ingress" + protocols = "tcp" + source = "0.0.0.0/0" + source_ports = "1-65535" + destination = crusoe_compute_instance.my_vm.network_interfaces[0].public_ipv4.address + destination_ports = "1-65535" + project_id = var.project_id +} diff --git a/examples/vms/main.tf b/examples/vms/main.tf index 82b6213..04575f5 100644 --- a/examples/vms/main.tf +++ b/examples/vms/main.tf @@ -16,19 +16,22 @@ resource "crusoe_compute_instance" "my_vm" { type = "a40.1x" location = "us-northcentral1-a" - # optionally specify a different base image - #image = "nvidia-docker" - - ssh_key = local.my_ssh_key - startup_script = file("startup.sh") + # specify the base image + image = "ubuntu20.04:latest" disks = [ - // attached at startup - crusoe_storage_disk.data_disk - ] + // disk attached at startup + { + id = crusoe_storage_disk.data_disk.id + mode = "read-only" + attachment_type = "data" + } + ] + + ssh_key = local.my_ssh_key + } -// attached disk resource "crusoe_storage_disk" "data_disk" { name = "data-disk" size = "200GiB" diff --git a/go.mod b/go.mod index a5a1463..750b354 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( github.com/BurntSushi/toml v0.3.1 github.com/antihax/optional v1.0.0 - github.com/crusoecloud/client-go v0.1.22 + github.com/crusoecloud/client-go v0.1.32 github.com/hashicorp/terraform-plugin-framework v1.3.5 ) diff --git a/go.sum b/go.sum index 72aa9a4..0d87329 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/crusoecloud/client-go v0.1.22 h1:SE077syXicRyq9/1B9V1+ocYJdGZZRr6pdkimKhUPQM= -github.com/crusoecloud/client-go v0.1.22/go.mod h1:k1FgpUllEJtE53osEwsF+JfbFKILn5t3UuBdHYBVpdY= +github.com/crusoecloud/client-go v0.1.32 h1:c1f0lo8/T/3dkdF5KbxjWSjP1fnG+DE0luOo6q1jKWY= +github.com/crusoecloud/client-go v0.1.32/go.mod h1:k1FgpUllEJtE53osEwsF+JfbFKILn5t3UuBdHYBVpdY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/common/config.go b/internal/common/config.go index 4f9445a..24e9042 100644 --- a/internal/common/config.go +++ b/internal/common/config.go @@ -10,7 +10,7 @@ import ( const ( configFilePath = "/.crusoe/config" // full path is this appended to the user's home path - defaultApiEndpoint = "https://api.crusoecloud.com/v1alpha4" + defaultApiEndpoint = "https://api.crusoecloud.com/v1alpha5" ) // Config holds options that can be set via ~/.crusoe/config and env variables. @@ -19,6 +19,7 @@ type Config struct { SecretKey string `toml:"secret_key"` SSHPublicKeyFile string `toml:"ssh_public_key_file"` ApiEndpoint string `toml:"api_endpoint"` + DefaultProject string `toml:"default_project"` } // ConfigFile reflects the structure of a valid Crusoe config, which should have a default profile at the root level. @@ -48,6 +49,7 @@ func GetConfig() (*Config, error) { config.AccessKeyID = configFile.Default.AccessKeyID config.SecretKey = configFile.Default.SecretKey config.SSHPublicKeyFile = configFile.Default.SSHPublicKeyFile + config.DefaultProject = configFile.Default.DefaultProject if configFile.Default.ApiEndpoint != "" { config.ApiEndpoint = configFile.Default.ApiEndpoint diff --git a/internal/common/util.go b/internal/common/util.go index 3d1cae6..5ac60b2 100644 --- a/internal/common/util.go +++ b/internal/common/util.go @@ -5,18 +5,18 @@ import ( "encoding/json" "errors" "fmt" + "github.com/antihax/optional" + "github.com/hashicorp/terraform-plugin-framework/diag" "net/http" "strings" "time" - "github.com/antihax/optional" - - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" ) const ( // TODO: pull from config set during build - version = "v0.4.1" + version = "v0.5.0" pollInterval = 2 * time.Second @@ -36,14 +36,11 @@ var ( OpInProgress opStatus = "IN_PROGRESS" OpFailed opStatus = "FAILED" - errNoOperations = errors.New("no operation with id found") errUnableToGetOpRes = errors.New("failed to get result of operation") - errAmbiguousRole = errors.New("user is associated with multiple roles - please contact support@crusoecloud.com") - errNoRoleAssociation = errors.New("user is not associated with any role") - // fallback error presented to the user in unexpected situations - errUnexpected = errors.New("An unexpected error occurred. Please try again, and if the problem persists, contact support@crusoecloud.com.") + errMultipleProjects = errors.New("User has multiple projects. Please specify a project to be used.") + errUnexpected = errors.New("An unexpected error occurred. Please try again, and if the problem persists, contact support@crusoecloud.com.") ) // NewAPIClient initializes a new Crusoe API client with the given configuration. @@ -60,46 +57,19 @@ func NewAPIClient(host, key, secret string) *swagger.APIClient { return swagger.NewAPIClient(cfg) } -// GetRole creates a get Role request and calls the API. -// This function returns a role id if the user's role can be determined -// (i.e. user only has one role, which is the case for v0). -func GetRole(ctx context.Context, api *swagger.APIClient) (string, error) { - opts := swagger.RolesApiGetRolesOpts{ - OrgId: optional.EmptyString(), - } - - resp, httpResp, err := api.RolesApi.GetRoles(ctx, &opts) - if err != nil { - return "", fmt.Errorf("could not get roles: %w", err) - } - defer httpResp.Body.Close() - - switch len(resp.Roles) { - case 0: - return "", errNoRoleAssociation - case 1: - return resp.Roles[0].Id, nil - default: - // user has multiple roles: unable to disambiguate - return "", errAmbiguousRole - } -} - // AwaitOperation polls an async API operation until it resolves into a success or failure state. -func AwaitOperation(ctx context.Context, op *swagger.Operation, - getFunc func(context.Context, string) (swagger.ListOperationsResponseV1Alpha4, *http.Response, error)) ( +func AwaitOperation(ctx context.Context, op *swagger.Operation, projectID string, + getFunc func(context.Context, string, string) (swagger.Operation, *http.Response, error)) ( *swagger.Operation, error, ) { for op.State == string(OpInProgress) { - updatedOps, httpResp, err := getFunc(ctx, op.OperationId) + updatedOps, httpResp, err := getFunc(ctx, projectID, op.OperationId) if err != nil { return nil, fmt.Errorf("error getting operation with id %s: %w", op.OperationId, err) } httpResp.Body.Close() - if len(updatedOps.Operations) == 0 { - return nil, errNoOperations - } - op = &updatedOps.Operations[0] + + op = &updatedOps time.Sleep(pollInterval) } @@ -122,10 +92,10 @@ func AwaitOperation(ctx context.Context, op *swagger.Operation, // AwaitOperationAndResolve awaits an async API operation and attempts to parse the response as an instance of T, // if the operation was successful. -func AwaitOperationAndResolve[T any](ctx context.Context, op *swagger.Operation, - getFunc func(context.Context, string) (swagger.ListOperationsResponseV1Alpha4, *http.Response, error), +func AwaitOperationAndResolve[T any](ctx context.Context, op *swagger.Operation, projectID string, + getFunc func(context.Context, string, string) (swagger.Operation, *http.Response, error), ) (*T, *swagger.Operation, error) { - op, err := AwaitOperation(ctx, op, getFunc) + op, err := AwaitOperation(ctx, op, projectID, getFunc) if err != nil { return nil, op, err } @@ -138,6 +108,54 @@ func AwaitOperationAndResolve[T any](ctx context.Context, op *swagger.Operation, return result, op, nil } +// GetFallbackProject queries the API to get the list of projects belonging to the +// logged in user. If there is one project belonging to the user, it returns that project +// else it adds an error to the diagnostics and returns. +func GetFallbackProject(ctx context.Context, client *swagger.APIClient, diag *diag.Diagnostics) (string, error) { + + config, err := GetConfig() + if err != nil { + return "", fmt.Errorf("failed to get config: %v", err) + } + + var opts = &swagger.ProjectsApiListProjectsOpts{ + OrgId: optional.EmptyString(), + } + + if config.DefaultProject != "" { + opts.ProjectName = optional.NewString(config.DefaultProject) + } + + dataResp, httpResp, err := client.ProjectsApi.ListProjects(ctx, opts) + + defer httpResp.Body.Close() + + if err != nil { + diag.AddError("Failed to retrieve project ID", + "Failed to retrieve project ID for the authenticated user.") + + return "", err + } + + if len(dataResp.Items) != 1 { + diag.AddError("Multiple projects found.", + "Multiple projects found for the authenticated user. Unable to determine which project to use.") + + return "", errMultipleProjects + } + + projectID := dataResp.Items[0].Id + + if config.DefaultProject == "" { + diag.AddWarning("Default project not specified", + fmt.Sprintf("A project_id was not specified in the configuration file. "+ + "Please specify a project in the terraform file or set a 'default_project' in your configuration file. "+ + "Falling back to project: %s.", dataResp.Items[0].Name)) + } + + return projectID, nil +} + func parseOpResult[T any](opResult interface{}) (*T, error) { b, err := json.Marshal(opResult) if err != nil { diff --git a/internal/disk/disk_resource.go b/internal/disk/disk_resource.go index 536ea90..c67e28c 100644 --- a/internal/disk/disk_resource.go +++ b/internal/disk/disk_resource.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" validators "github.com/crusoecloud/terraform-provider-crusoe/internal/validators" ) @@ -27,6 +27,7 @@ type diskResource struct { type diskResourceModel struct { ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` Location types.String `tfsdk:"location"` Name types.String `tfsdk:"name"` Type types.String `tfsdk:"type"` @@ -67,6 +68,12 @@ func (r *diskResource) Schema(ctx context.Context, req resource.SchemaRequest, r Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, // maintain across updates }, + "project_id": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace()}, + }, "location": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, // cannot be updated in place @@ -113,29 +120,35 @@ func (r *diskResource) Create(ctx context.Context, req resource.CreateRequest, r diskType = defaultDiskType } - roleID, err := common.GetRole(ctx, r.client) - if err != nil { - resp.Diagnostics.AddError("Failed to get Role ID", err.Error()) + projectID := "" + if plan.ProjectID.ValueString() == "" { + project, err := common.GetFallbackProject(ctx, r.client, &resp.Diagnostics) + if err != nil { - return + resp.Diagnostics.AddError("Failed to create disk", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) + return + } + projectID = project + } else { + projectID = plan.ProjectID.ValueString() } - dataResp, httpResp, err := r.client.DisksApi.CreateDisk(ctx, swagger.DisksPostRequest{ - RoleId: roleID, + dataResp, httpResp, err := r.client.DisksApi.CreateDisk(ctx, swagger.DisksPostRequestV1Alpha5{ Name: plan.Name.ValueString(), Location: plan.Location.ValueString(), Type_: diskType, Size: plan.Size.ValueString(), - }) + }, projectID) if err != nil { resp.Diagnostics.AddError("Failed to create disk", - fmt.Sprintf("There was an error starting a create disk operation: %s", common.UnpackAPIError(err))) + fmt.Sprintf("There was an error starting a create disk operation (%s): %s", projectID, common.UnpackAPIError(err))) return } defer httpResp.Body.Close() - disk, _, err := common.AwaitOperationAndResolve[swagger.Disk](ctx, dataResp.Operation, r.client.DiskOperationsApi.GetStorageDisksOperation) + disk, _, err := common.AwaitOperationAndResolve[swagger.DiskV1Alpha5](ctx, dataResp.Operation, projectID, r.client.DiskOperationsApi.GetStorageDisksOperation) if err != nil { resp.Diagnostics.AddError("Failed to create disk", fmt.Sprintf("There was an error creating a disk: %s", common.UnpackAPIError(err))) @@ -146,18 +159,8 @@ func (r *diskResource) Create(ctx context.Context, req resource.CreateRequest, r plan.ID = types.StringValue(disk.Id) plan.Type = types.StringValue(disk.Type_) plan.Location = types.StringValue(disk.Location) - - // The Serial Number is not populated in the creation response, but we can reliably fetch it immediately after - // disk creation. TODO: this request can be dropped with if the creation response is updated to include serial number - disk2, err := getDisk(ctx, r.client, disk.Id) - if err != nil { - // log a warning and not an error, because creation still worked but the serial number won't be populated - // until the next time the resource is read. - resp.Diagnostics.AddWarning("Unable to get Serial Number", - "The serial number of one of your created disks was not populated; it should be populated during the next Terraform run.") - } else { - plan.SerialNumber = types.StringValue(disk2.SerialNumber) - } + plan.SerialNumber = types.StringValue(disk.SerialNumber) + plan.ProjectID = types.StringValue(projectID) diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) @@ -172,19 +175,35 @@ func (r *diskResource) Read(ctx context.Context, req resource.ReadRequest, resp return } - dataResp, httpResp, err := r.client.DisksApi.GetDisks(ctx) + // We only have this parsing for transitioning from v1alpha4 to v1alpha5 because old tf state files will not + // have project ID stored. So we will try to get a fallback project to pass to the API. + projectID := "" + if state.ProjectID.ValueString() == "" { + project, err := common.GetFallbackProject(ctx, r.client, &resp.Diagnostics) + if err != nil { + + resp.Diagnostics.AddError("Failed to create disk", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) + return + } + projectID = project + } else { + projectID = state.ProjectID.ValueString() + } + + dataResp, httpResp, err := r.client.DisksApi.ListDisks(ctx, projectID) if err != nil { resp.Diagnostics.AddError("Failed to get disks", - fmt.Sprintf("Fetching Crusoe disks failed: %s\n\nIf the problem persists, contact support@crusoecloud.com", err.Error())) + fmt.Sprintf("Fetching Crusoe disks failed: %s\n\nIf the problem persists, contact support@crusoecloud.com", common.UnpackAPIError(err))) return } defer httpResp.Body.Close() - var disk *swagger.Disk - for i := range dataResp.Disks { - if dataResp.Disks[i].Id == state.ID.ValueString() { - disk = &dataResp.Disks[i] + var disk *swagger.DiskV1Alpha5 + for i := range dataResp.Items { + if dataResp.Items[i].Id == state.ID.ValueString() { + disk = &dataResp.Items[i] } } @@ -222,24 +241,25 @@ func (r *diskResource) Update(ctx context.Context, req resource.UpdateRequest, r dataResp, httpResp, err := r.client.DisksApi.ResizeDisk(ctx, swagger.DisksPatchRequest{Size: plan.Size.ValueString()}, + plan.ProjectID.ValueString(), plan.ID.ValueString(), ) if err != nil { resp.Diagnostics.AddError("Failed to resize disk", fmt.Sprintf("There was an error starting a resize operation: %s.\n\n"+ "Make sure the disk still exists, you are englarging the disk,"+ - " and if the disk is attached to a VM, the VM is powered off.", err.Error())) + " and if the disk is attached to a VM, the VM is powered off.", common.UnpackAPIError(err))) return } defer httpResp.Body.Close() - _, _, err = common.AwaitOperationAndResolve[swagger.Disk](ctx, dataResp.Operation, r.client.DiskOperationsApi.GetStorageDisksOperation) + _, _, err = common.AwaitOperationAndResolve[swagger.DiskV1Alpha5](ctx, dataResp.Operation, plan.ProjectID.ValueString(), r.client.DiskOperationsApi.GetStorageDisksOperation) if err != nil { resp.Diagnostics.AddError("Failed to resize disk", fmt.Sprintf("There was an error resizing a disk: %s.\n\n"+ "Make sure the disk still exists, you are englarging the disk,"+ - " and if the disk is attached to a VM, the VM is powered off.", err.Error())) + " and if the disk is attached to a VM, the VM is powered off.", common.UnpackAPIError(err))) return } @@ -257,7 +277,7 @@ func (r *diskResource) Delete(ctx context.Context, req resource.DeleteRequest, r return } - dataResp, httpResp, err := r.client.DisksApi.DeleteDisk(ctx, state.ID.ValueString()) + dataResp, httpResp, err := r.client.DisksApi.DeleteDisk(ctx, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to delete disk", fmt.Sprintf("There was an error starting a delete disk operation: %s", common.UnpackAPIError(err))) @@ -266,7 +286,7 @@ func (r *diskResource) Delete(ctx context.Context, req resource.DeleteRequest, r } defer httpResp.Body.Close() - _, err = common.AwaitOperation(ctx, dataResp.Operation, r.client.DiskOperationsApi.GetStorageDisksOperation) + _, err = common.AwaitOperation(ctx, dataResp.Operation, state.ProjectID.ValueString(), r.client.DiskOperationsApi.GetStorageDisksOperation) if err != nil { resp.Diagnostics.AddError("Failed to delete disk", fmt.Sprintf("There was an error deleting a disk: %s", common.UnpackAPIError(err))) diff --git a/internal/disk/disks_data_source.go b/internal/disk/disks_data_source.go index 218cc20..fe15ffc 100644 --- a/internal/disk/disks_data_source.go +++ b/internal/disk/disks_data_source.go @@ -2,11 +2,12 @@ package disk import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" ) @@ -18,6 +19,10 @@ type disksDataSourceModel struct { Disks []diskModel `tfsdk:"disks"` } +type disksDataSourceFilter struct { + ProjectID *string `tfsdk:"project_id"` +} + type diskModel struct { ID string `tfsdk:"id"` Name string `tfsdk:"name"` @@ -86,7 +91,15 @@ func (ds *disksDataSource) Schema(ctx context.Context, request datasource.Schema //nolint:gocritic // Implements Terraform defined interface func (ds *disksDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - dataResp, httpResp, err := ds.client.DisksApi.GetDisks(ctx) + projectID, err := common.GetFallbackProject(ctx, ds.client, &resp.Diagnostics) + if err != nil { + resp.Diagnostics.AddError("Failed to fetch disks", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) + + return + } + + dataResp, httpResp, err := ds.client.DisksApi.ListDisks(ctx, projectID) if err != nil { resp.Diagnostics.AddError("Failed to Fetch Disks", "Could not fetch Disk data at this time.") @@ -95,14 +108,14 @@ func (ds *disksDataSource) Read(ctx context.Context, req datasource.ReadRequest, defer httpResp.Body.Close() var state disksDataSourceModel - for i := range dataResp.Disks { + for i := range dataResp.Items { state.Disks = append(state.Disks, diskModel{ - ID: dataResp.Disks[i].Id, - Name: dataResp.Disks[i].Name, - Location: dataResp.Disks[i].Location, - Type: dataResp.Disks[i].Type_, - Size: dataResp.Disks[i].Size, - SerialNumber: dataResp.Disks[i].SerialNumber, + ID: dataResp.Items[i].Id, + Name: dataResp.Items[i].Name, + Location: dataResp.Items[i].Location, + Type: dataResp.Items[i].Type_, + Size: dataResp.Items[i].Size, + SerialNumber: dataResp.Items[i].SerialNumber, }) } diff --git a/internal/disk/util.go b/internal/disk/util.go deleted file mode 100644 index 8bad96e..0000000 --- a/internal/disk/util.go +++ /dev/null @@ -1,25 +0,0 @@ -package disk - -import ( - "context" - "errors" - - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" -) - -// getDisk fetches a storage disk by ID. -func getDisk(ctx context.Context, apiClient *swagger.APIClient, diskID string) (*swagger.Disk, error) { - dataResp, httpResp, err := apiClient.DisksApi.GetDisk(ctx, diskID) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - for i := range dataResp.Disks { - if dataResp.Disks[i].Id == diskID { - return &dataResp.Disks[i], nil - } - } - - return nil, errors.New("failed to fetch disk with matching ID") -} diff --git a/internal/firewall_rule/firewall_rule_resource.go b/internal/firewall_rule/firewall_rule_resource.go index db59805..d63c40c 100644 --- a/internal/firewall_rule/firewall_rule_resource.go +++ b/internal/firewall_rule/firewall_rule_resource.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" validators "github.com/crusoecloud/terraform-provider-crusoe/internal/validators" ) @@ -23,6 +23,7 @@ type firewallRuleResource struct { type firewallRuleResourceModel struct { ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` Name types.String `tfsdk:"name"` Network types.String `tfsdk:"network"` Action types.String `tfsdk:"action"` @@ -67,6 +68,11 @@ func (r *firewallRuleResource) Schema(ctx context.Context, req resource.SchemaRe Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, // maintain across updates }, + "project_id": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, "network": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, @@ -118,18 +124,24 @@ func (r *firewallRuleResource) Create(ctx context.Context, req resource.CreateRe return } - roleID, err := common.GetRole(ctx, r.client) - if err != nil { - resp.Diagnostics.AddError("Failed to get Role ID", err.Error()) + projectID := "" + if plan.ProjectID.ValueString() == ""{ + project, err := common.GetFallbackProject(ctx, r.client, &resp.Diagnostics) + if err != nil { + resp.Diagnostics.AddError("Failed to create firewall rule", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) - return + return + } + projectID = project + } else { + projectID = plan.ProjectID.ValueString() } sourcePortsStr := strings.ReplaceAll(plan.SourcePorts.ValueString(), "*", "1-65535") destPortsStr := strings.ReplaceAll(plan.DestinationPorts.ValueString(), "*", "1-65535") - dataResp, httpResp, err := r.client.VPCFirewallRulesApi.CreateVPCFirewallRule(ctx, swagger.VpcFirewallRulesPostRequestV1Alpha4{ - RoleId: roleID, + dataResp, httpResp, err := r.client.VPCFirewallRulesApi.CreateVPCFirewallRule(ctx, swagger.VpcFirewallRulesPostRequestV1Alpha5{ VpcNetworkId: plan.Network.ValueString(), Name: plan.Name.ValueString(), Action: plan.Action.ValueString(), @@ -139,7 +151,7 @@ func (r *firewallRuleResource) Create(ctx context.Context, req resource.CreateRe SourcePorts: stringToSlice(sourcePortsStr, ","), Destinations: []swagger.FirewallRuleObject{toFirewallRuleObject(plan.Destination.ValueString())}, DestinationPorts: stringToSlice(destPortsStr, ","), - }) + }, projectID) if err != nil { resp.Diagnostics.AddError("Failed to create firewall rule", fmt.Sprintf("There was an error starting a create firewall rule operation: %s", common.UnpackAPIError(err))) @@ -149,7 +161,7 @@ func (r *firewallRuleResource) Create(ctx context.Context, req resource.CreateRe defer httpResp.Body.Close() firewallRule, _, err := common.AwaitOperationAndResolve[swagger.VpcFirewallRule]( - ctx, dataResp.Operation, + ctx, dataResp.Operation, projectID, r.client.VPCFirewallRuleOperationsApi.GetNetworkingVPCFirewallRulesOperation) if err != nil { resp.Diagnostics.AddError("Failed to create firewall rule", @@ -159,6 +171,7 @@ func (r *firewallRuleResource) Create(ctx context.Context, req resource.CreateRe } plan.ID = types.StringValue(firewallRule.Id) + plan.ProjectID = types.StringValue(projectID) diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) @@ -173,8 +186,8 @@ func (r *firewallRuleResource) Read(ctx context.Context, req resource.ReadReques return } - dataResp, httpResp, err := r.client.VPCFirewallRulesApi.GetVPCFirewallRule(ctx, state.ID.ValueString()) - if err != nil || len(dataResp.FirewallRules) == 0 { + rule, httpResp, err := r.client.VPCFirewallRulesApi.GetVPCFirewallRule(ctx, state.ProjectID.ValueString(), state.ID.ValueString()) + if err != nil { // fw rule has most likely been deleted out of band, so we update Terraform state to match resp.State.RemoveResource(ctx) @@ -185,14 +198,6 @@ func (r *firewallRuleResource) Read(ctx context.Context, req resource.ReadReques return } - if len(dataResp.FirewallRules) > 0 { - // should never happen - resp.Diagnostics.AddWarning("Found multiple matching firewall rules", - "An unexpected number of matching firewall rules was found. If you're seeing this error message, "+ - "please report an issue to support@crusoecloud.com") - } - - rule := dataResp.FirewallRules[0] state.ID = types.StringValue(rule.Id) state.Name = types.StringValue(rule.Name) state.Network = types.StringValue(rule.VpcNetworkId) @@ -245,6 +250,7 @@ func (r *firewallRuleResource) Update(ctx context.Context, req resource.UpdateRe dataResp, httpResp, err := r.client.VPCFirewallRulesApi.PatchVPCFirewallRule(ctx, patchReq, + plan.ProjectID.ValueString(), plan.ID.ValueString(), ) if err != nil { @@ -256,7 +262,7 @@ func (r *firewallRuleResource) Update(ctx context.Context, req resource.UpdateRe defer httpResp.Body.Close() - _, _, err = common.AwaitOperationAndResolve[swagger.VpcFirewallRule](ctx, dataResp.Operation, r.client.VPCFirewallRuleOperationsApi.GetNetworkingVPCFirewallRulesOperation) + _, _, err = common.AwaitOperationAndResolve[swagger.VpcFirewallRule](ctx, dataResp.Operation, plan.ProjectID.ValueString(), r.client.VPCFirewallRuleOperationsApi.GetNetworkingVPCFirewallRulesOperation) if err != nil { resp.Diagnostics.AddError("Failed to patch firewall rule", fmt.Sprintf("There was an error updating the firewall rule: %s.", common.UnpackAPIError(err))) @@ -277,7 +283,7 @@ func (r *firewallRuleResource) Delete(ctx context.Context, req resource.DeleteRe return } - dataResp, httpResp, err := r.client.VPCFirewallRulesApi.DeleteVPCFirewallRule(ctx, state.ID.ValueString()) + dataResp, httpResp, err := r.client.VPCFirewallRulesApi.DeleteVPCFirewallRule(ctx, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to delete firewall rule", fmt.Sprintf("There was an error starting a delete firewall rule operation: %s", common.UnpackAPIError(err))) @@ -286,7 +292,7 @@ func (r *firewallRuleResource) Delete(ctx context.Context, req resource.DeleteRe } defer httpResp.Body.Close() - _, err = common.AwaitOperation(ctx, dataResp.Operation, r.client.VPCFirewallRuleOperationsApi.GetNetworkingVPCFirewallRulesOperation) + _, err = common.AwaitOperation(ctx, dataResp.Operation, state.ProjectID.ValueString(), r.client.VPCFirewallRuleOperationsApi.GetNetworkingVPCFirewallRulesOperation) if err != nil { resp.Diagnostics.AddError("Failed to delete firewall rule", fmt.Sprintf("There was an error deleting a firewall rule: %s", common.UnpackAPIError(err))) diff --git a/internal/firewall_rule/util.go b/internal/firewall_rule/util.go index 723dc8c..48becfc 100644 --- a/internal/firewall_rule/util.go +++ b/internal/firewall_rule/util.go @@ -4,7 +4,7 @@ import ( "regexp" "strings" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" ) var whitespaceRegex = regexp.MustCompile(`\s*`) diff --git a/internal/firewall_rule/util_test.go b/internal/firewall_rule/util_test.go index 23b1892..11e250a 100644 --- a/internal/firewall_rule/util_test.go +++ b/internal/firewall_rule/util_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" ) func Test_cidrListToString(t *testing.T) { diff --git a/internal/ib_network/ib_network_data_source.go b/internal/ib_network/ib_network_data_source.go index 7355c2f..79dcd1e 100644 --- a/internal/ib_network/ib_network_data_source.go +++ b/internal/ib_network/ib_network_data_source.go @@ -4,11 +4,10 @@ package ib_network import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" ) @@ -72,7 +71,15 @@ func (ds *ibNetworksDataSource) Schema(ctx context.Context, request datasource.S } func (ds *ibNetworksDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - dataResp, httpResp, err := ds.client.IBNetworksApi.GetIBNetworks(ctx) + projectID, err := common.GetFallbackProject(ctx, ds.client, &resp.Diagnostics) + if err != nil { + resp.Diagnostics.AddError("Failed to fetch IB networks", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) + + return + } + + dataResp, httpResp, err := ds.client.IBNetworksApi.ListIBNetworks(ctx, projectID) if err != nil { resp.Diagnostics.AddError("Failed to Fetch IB Networks", fmt.Sprintf("Could not fetch Infiniband network data at this time: %s", common.UnpackAPIError(err))) @@ -82,11 +89,11 @@ func (ds *ibNetworksDataSource) Read(ctx context.Context, req datasource.ReadReq defer httpResp.Body.Close() var state ibNetworksDataSourceModel - for i := range dataResp.IbNetworks { + for i := range dataResp.Items { state.IBNetworks = append(state.IBNetworks, ibNetworkModel{ - ID: dataResp.IbNetworks[i].Id, - Name: dataResp.IbNetworks[i].Name, - Location: dataResp.IbNetworks[i].Location, + ID: dataResp.Items[i].Id, + Name: dataResp.Items[i].Name, + Location: dataResp.Items[i].Location, }) } diff --git a/internal/ib_partition/ib_partition_resource.go b/internal/ib_partition/ib_partition_resource.go index 3b1f487..a629f48 100644 --- a/internal/ib_partition/ib_partition_resource.go +++ b/internal/ib_partition/ib_partition_resource.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" ) @@ -24,6 +24,7 @@ type ibPartitionResource struct { type ibPartitionResourceModel struct { ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` Name types.String `tfsdk:"name"` IBNetworkID types.String `tfsdk:"ib_network_id"` } @@ -66,6 +67,12 @@ func (r *ibPartitionResource) Schema(ctx context.Context, req resource.SchemaReq Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, // cannot be updated in place }, + "project_id": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace()}, // cannot be updated in place + }, }, } } @@ -82,18 +89,24 @@ func (r *ibPartitionResource) Create(ctx context.Context, req resource.CreateReq return } - roleID, err := common.GetRole(ctx, r.client) - if err != nil { - resp.Diagnostics.AddError("Failed to get Role ID", err.Error()) + projectID := "" + if plan.ProjectID.ValueString() == "" { + project, err := common.GetFallbackProject(ctx, r.client, &resp.Diagnostics) + if err != nil { + resp.Diagnostics.AddError("Failed to create partition", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) - return + return + } + projectID = project + } else { + projectID = plan.ProjectID.ValueString() } - dataResp, httpResp, err := r.client.IBPartitionsApi.CreateIBPartition(ctx, swagger.IbPartitionsPostRequestV1Alpha4{ - RoleId: roleID, + dataResp, httpResp, err := r.client.IBPartitionsApi.CreateIBPartition(ctx, swagger.IbPartitionsPostRequestV1Alpha5{ Name: plan.Name.ValueString(), IbNetworkId: plan.IBNetworkID.ValueString(), - }) + }, projectID) if err != nil { resp.Diagnostics.AddError("Failed to create partition", fmt.Sprintf("There was an error creating an Infiniband partition: %s", common.UnpackAPIError(err))) @@ -103,6 +116,7 @@ func (r *ibPartitionResource) Create(ctx context.Context, req resource.CreateReq defer httpResp.Body.Close() plan.ID = types.StringValue(dataResp.Id) + plan.ProjectID = types.StringValue(projectID) diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) @@ -116,7 +130,7 @@ func (r *ibPartitionResource) Read(ctx context.Context, req resource.ReadRequest return } - partition, httpResp, err := r.client.IBPartitionsApi.GetIBPartition(ctx, state.ID.ValueString()) + partition, httpResp, err := r.client.IBPartitionsApi.GetIBPartition(ctx, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { if err.Error() == notFoundMessage { // partition has most likely been deleted out of band, so we update Terraform state to match @@ -126,7 +140,7 @@ func (r *ibPartitionResource) Read(ctx context.Context, req resource.ReadRequest } resp.Diagnostics.AddError("Failed to get IB partition", - fmt.Sprintf("Fetching Crusoe Infiniband partition failed: %s\n\nIf the problem persists, contact support@crusoecloud.com", err.Error())) + fmt.Sprintf("Fetching Crusoe Infiniband partition failed: %s\n\nIf the problem persists, contact support@crusoecloud.com", common.UnpackAPIError(err))) return } @@ -156,7 +170,7 @@ func (r *ibPartitionResource) Delete(ctx context.Context, req resource.DeleteReq return } - httpResp, err := r.client.IBPartitionsApi.DeleteIBPartition(ctx, state.ID.ValueString()) + httpResp, err := r.client.IBPartitionsApi.DeleteIBPartition(ctx, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to delete partition", fmt.Sprintf("There was an error deleting an Infiniband partition: %s", common.UnpackAPIError(err))) diff --git a/internal/project/project_resource.go b/internal/project/project_resource.go new file mode 100644 index 0000000..0c77095 --- /dev/null +++ b/internal/project/project_resource.go @@ -0,0 +1,172 @@ +package project + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" + "github.com/crusoecloud/terraform-provider-crusoe/internal/common" +) + +type projectResource struct { + client *swagger.APIClient +} + +type projectResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` +} + +func NewProjectResource() resource.Resource { + return &projectResource{} +} + +//nolint:gocritic // Implements Terraform defined interface +func (r *projectResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*swagger.APIClient) + if !ok { + resp.Diagnostics.AddError("Failed to initialize provider", common.ErrorMsgProviderInitFailed) + + return + } + + r.client = client +} + +//nolint:gocritic // Implements Terraform defined interface +func (r *projectResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project" +} + +//nolint:gocritic // Implements Terraform defined interface +func (r *projectResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, // maintain across updates + }, + "name": schema.StringAttribute{ + Required: true, + }, + }, + } +} + +func (r *projectResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +//nolint:gocritic // Implements Terraform defined interface +func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan projectResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + orgID, err := getUserOrg(ctx, r.client) + if err != nil { + resp.Diagnostics.AddError("Failed to create project", + fmt.Sprintf("There was an error fetching the user's organization: %s", err)) + + return + } + dataResp, httpResp, err := r.client.ProjectsApi.CreateProject(ctx, swagger.ProjectsPostRequest{ + Name: plan.Name.ValueString(), + OrganizationId: orgID, + }) + if err != nil { + resp.Diagnostics.AddError("Failed to create project", + fmt.Sprintf("There was an error starting a create project operation: %s.", common.UnpackAPIError(err))) + + return + } + defer httpResp.Body.Close() + + project := dataResp.Project + + plan.ID = types.StringValue(project.Id) + plan.Name = types.StringValue(project.Name) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +//nolint:gocritic // Implements Terraform defined interface +func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state projectResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + + project, httpResp, err := r.client.ProjectsApi.GetProject(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to get projects", + fmt.Sprintf("Fetching Crusoe projects failed: %s\n\nIf the problem persists, contact support@crusoecloud.com", common.UnpackAPIError(err))) + + return + } + defer httpResp.Body.Close() + + state.Name = types.StringValue(project.Name) + state.ID = types.StringValue(project.Id) + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +//nolint:gocritic // Implements Terraform defined interface +func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var state projectResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var plan projectResourceModel + diags = req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + _, httpResp, err := r.client.ProjectsApi.UpdateProject(ctx, + swagger.ProjectsPutRequest{Name: plan.Name.ValueString()}, + plan.ID.ValueString(), + ) + if err != nil { + resp.Diagnostics.AddError("Failed to update project", + fmt.Sprintf("There was an error starting an update project operation: %s.", common.UnpackAPIError(err))) + + return + } + defer httpResp.Body.Close() + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +//nolint:gocritic // Implements Terraform defined interface +func (r *projectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + + resp.Diagnostics.AddWarning("Delete not supported", + "Deleting projects is not currently supported. If you're seeing this message, please reach"+ + " out to support@crusoecloud.com and let us know.") +} diff --git a/internal/project/projects_data_source.go b/internal/project/projects_data_source.go new file mode 100644 index 0000000..4baf723 --- /dev/null +++ b/internal/project/projects_data_source.go @@ -0,0 +1,97 @@ +package project + +import ( + "context" + "fmt" + "github.com/antihax/optional" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" + "github.com/crusoecloud/terraform-provider-crusoe/internal/common" +) + +type projectsDataSource struct { + client *swagger.APIClient +} + +type projectsDataSourceModel struct { + Projects []projectModel `tfsdk:"projects"` +} + +type projectModel struct { + ID string `tfsdk:"id"` + Name string `tfsdk:"name"` +} + +func NewProjectsDataSource() datasource.DataSource { + return &projectsDataSource{} +} + +// Configure adds the provider configured client to the data source. +func (ds *projectsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*swagger.APIClient) + if !ok { + resp.Diagnostics.AddError("Failed to initialize provider", common.ErrorMsgProviderInitFailed) + + return + } + + ds.client = client +} + +//nolint:gocritic // Implements Terraform defined interface +func (ds *projectsDataSource) Metadata(ctx context.Context, request datasource.MetadataRequest, response *datasource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_projects" +} + +//nolint:gocritic // Implements Terraform defined interface +func (ds *projectsDataSource) Schema(ctx context.Context, request datasource.SchemaRequest, response *datasource.SchemaResponse) { + response.Schema = schema.Schema{Attributes: map[string]schema.Attribute{ + "projects": schema.ListNestedAttribute{ + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "name": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + }} +} + +//nolint:gocritic // Implements Terraform defined interface +func (ds *projectsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + opts := &swagger.ProjectsApiListProjectsOpts{ + OrgId: optional.EmptyString(), + } + + dataResp, httpResp, err := ds.client.ProjectsApi.ListProjects(ctx, opts) + if err != nil { + resp.Diagnostics.AddError("Failed to Fetch Projects", + fmt.Sprintf("Could not fetch Project data at this time: %v.", common.UnpackAPIError(err))) + + return + } + defer httpResp.Body.Close() + + var state projectsDataSourceModel + for i := range dataResp.Items { + state.Projects = append(state.Projects, projectModel{ + ID: dataResp.Items[i].Id, + Name: dataResp.Items[i].Name, + }) + } + + diags := resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/project/util.go b/internal/project/util.go new file mode 100644 index 0000000..7fe1ea4 --- /dev/null +++ b/internal/project/util.go @@ -0,0 +1,27 @@ +package project + +import ( + "context" + "errors" + + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" +) + +// getUserOrg returns the organization id for the authenticated user. +func getUserOrg(ctx context.Context, apiClient *swagger.APIClient) (string, error) { + dataResp, httpResp, err := apiClient.EntitiesApi.GetOrganizations(ctx) + if err != nil { + return "", err + } + defer httpResp.Body.Close() + + entities := dataResp.Items + switch len(entities) { + case 0: + return "", errors.New("user does not belong to any organizations") + case 1: + return entities[0].Id, nil + default: + return "", errors.New("user belongs to multiple organizations") + } +} diff --git a/internal/validators/storage_attachment_type_validator.go b/internal/validators/storage_attachment_type_validator.go new file mode 100644 index 0000000..ddca94f --- /dev/null +++ b/internal/validators/storage_attachment_type_validator.go @@ -0,0 +1,43 @@ +package internal + +import ( + "context" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type DiskModeType string + +const ( + DiskReadOnly DiskModeType = "read-only" + DiskReadWrite DiskModeType = "read-write" +) + +// StorageModeValidator validates that a given data storage size is accepted by the storage API. +type StorageModeValidator struct{} + +func (v StorageModeValidator) Description(ctx context.Context) string { + return "Disk attachment type must be either 'disk-readonly' or 'disk-readwrite'" +} + +func (v StorageModeValidator) MarkdownDescription(ctx context.Context) string { + return "Disk attachment type must be either 'disk-readonly' or 'disk-readwrite'" +} + +//nolint:gocritic // Implements Terraform defined interface +func (v StorageModeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + input := req.ConfigValue.ValueString() + input = strings.ToLower(input) + + if input != string(DiskReadOnly) && input != string(DiskReadWrite){ + resp.Diagnostics.AddAttributeError(req.Path, "Unsupported Disk Mode Type", + "Disk mode type must be either 'read-only' or 'read-write'") + } + + return +} diff --git a/internal/vm/util.go b/internal/vm/util.go index ed5a7f4..0572c5c 100644 --- a/internal/vm/util.go +++ b/internal/vm/util.go @@ -3,11 +3,11 @@ package vm import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" ) @@ -35,27 +35,45 @@ var vmNetworkInterfaceSchema = types.ObjectType{ }, } +var vmDiskAttachmentSchema = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "attachment_type": types.StringType, + "mode": types.StringType, + }, +} + +var vmHostChannelAdapterSchema = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "ib_partition_id": types.StringType, + }, +} + // getDisksDiff compares the disks attached to two VM resource models and returns // a diff of disks defined by disk ID. -func getDisksDiff(origDisks, newDisks []vmDiskResourceModel) (disksAdded, disksRemoved []string) { +func getDisksDiff(origDisks, newDisks []vmDiskResourceModel) (disksAdded []swagger.DiskAttachment, disksRemoved []string) { for _, newDisk := range newDisks { matched := false for _, origDisk := range origDisks { - if newDisk.ID == origDisk.ID { + if newDisk.ID == origDisk.ID && newDisk.Mode == origDisk.Mode { matched = true break } } if !matched { - disksAdded = append(disksAdded, newDisk.ID) + disksAdded = append(disksAdded, swagger.DiskAttachment{ + DiskId: newDisk.ID, + AttachmentType: newDisk.AttachmentType, + Mode: newDisk.Mode, + }) } } for _, origDisk := range origDisks { matched := false for _, newDisk := range newDisks { - if newDisk.ID == origDisk.ID { + if newDisk.ID == origDisk.ID && newDisk.Mode == origDisk.Mode { matched = true break @@ -65,22 +83,17 @@ func getDisksDiff(origDisks, newDisks []vmDiskResourceModel) (disksAdded, disksR disksRemoved = append(disksRemoved, origDisk.ID) } } - return disksAdded, disksRemoved } -func getVM(ctx context.Context, apiClient *swagger.APIClient, vmID string) (*swagger.InstanceV1Alpha4, error) { - dataResp, httpResp, err := apiClient.VMsApi.GetInstance(ctx, vmID) +func getVM(ctx context.Context, apiClient *swagger.APIClient, projectID, vmID string) (*swagger.InstanceV1Alpha5, error) { + dataResp, httpResp, err := apiClient.VMsApi.GetInstance(ctx, projectID, vmID) if err != nil { return nil, fmt.Errorf("failed to find VM: %w", common.UnpackAPIError(err)) } defer httpResp.Body.Close() - if dataResp.Instance != nil { - return dataResp.Instance, nil - } - - return nil, fmt.Errorf("failed to find VM with matching ID: %w", err) + return &dataResp, nil } // vmNetworkInterfacesToTerraformDataModel creates a slice of Terraform-compatible network @@ -151,3 +164,31 @@ func vmNetworkInterfacesToTerraformResourceModel(networkInterfaces []swagger.Net return values, warning } + +func vmPartialHostChannelAdaptersToTerraformResourceModel(hostChannelAdapters []swagger.HostChannelAdapter) (hostChannelAdaptersList types.List, warning string) { + hcas := make([]vmHostChannelAdapterResourceModel, 0, len(hostChannelAdapters)) + for _, hca := range hostChannelAdapters { + + hcas = append(hcas, vmHostChannelAdapterResourceModel{ + IBPartitionID: hca.IbPartitionId, + }) + } + + values, _ := types.ListValueFrom(context.Background(), vmHostChannelAdapterSchema, hcas) + + return values, warning +} + +func vmDiskAttachmentToTerraformResourceModel(diskAttachments []swagger.DiskAttachment) (diskAttachmentsList types.List, diags diag.Diagnostics) { + attachments := make([]vmDiskResourceModel, 0, len(diskAttachments)) + for _, diskAttachment := range diskAttachments { + attachments = append(attachments, vmDiskResourceModel{ + ID: diskAttachment.DiskId, + AttachmentType: diskAttachment.AttachmentType, + Mode: diskAttachment.Mode, + }) + } + + diskAttachmentsList, diags = types.ListValueFrom(context.Background(), vmDiskAttachmentSchema, attachments) + return diskAttachmentsList, diags +} diff --git a/internal/vm/vm_data_source.go b/internal/vm/vm_data_source.go index 43f8352..75769fb 100644 --- a/internal/vm/vm_data_source.go +++ b/internal/vm/vm_data_source.go @@ -2,13 +2,14 @@ package vm import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" ) @@ -20,6 +21,7 @@ type vmDataSource struct { type vmDataSourceFilter struct { ID *string `tfsdk:"id"` + ProjectID *string `tfsdk:"project_id"` Name *string `tfsdk:"name"` Type *string `tfsdk:"type"` Disks []vmDiskResourceModel `tfsdk:"disks"` @@ -73,6 +75,9 @@ func (ds *vmDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, re "name": schema.StringAttribute{ Optional: true, }, + "project_id": schema.StringAttribute{ + Optional: true, + }, "type": schema.StringAttribute{ Computed: true, }, @@ -83,6 +88,12 @@ func (ds *vmDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, re "id": schema.StringAttribute{ Required: true, }, + "attachment_type": schema.StringAttribute{ + Required: true, + }, + "mode": schema.StringAttribute{ + Required: true, + }, }, }, }, @@ -141,16 +152,26 @@ func (ds *vmDataSource) Read(ctx context.Context, req datasource.ReadRequest, re } if config.ID != nil { - vm, err := getVM(ctx, ds.client, *config.ID) + vm, err := getVM(ctx, ds.client, *config.ProjectID, *config.ID) if err != nil { - resp.Diagnostics.AddError("Failed to get Instance", err.Error()) + resp.Diagnostics.AddError("Failed to get Instance", fmt.Sprintf("Failed to get instance: %s.", + common.UnpackAPIError(err))) return } state.ID = &vm.Id + state.ProjectID = &vm.ProjectId state.Name = &vm.Name - state.Type = &vm.ProductName + state.Type = &vm.Type_ + attachedDisks := make([]vmDiskResourceModel, 0, len(vm.Disks)) + for _, disk := range vm.Disks { + attachedDisks = append(attachedDisks, vmDiskResourceModel{ + ID: disk.Id, + AttachmentType: disk.AttachmentType, + Mode: disk.Mode, + }) + } networkInterfaces, _ := vmNetworkInterfacesToTerraformDataModel(vm.NetworkInterfaces) state.NetworkInterfaces = networkInterfaces diff --git a/internal/vm/vm_resource.go b/internal/vm/vm_resource.go index a5fadf9..55548b4 100644 --- a/internal/vm/vm_resource.go +++ b/internal/vm/vm_resource.go @@ -3,7 +3,6 @@ package vm import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -14,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - swagger "github.com/crusoecloud/client-go/swagger/v1alpha4" + swagger "github.com/crusoecloud/client-go/swagger/v1alpha5" "github.com/crusoecloud/terraform-provider-crusoe/internal/common" validators "github.com/crusoecloud/terraform-provider-crusoe/internal/validators" ) @@ -24,17 +23,18 @@ type vmResource struct { } type vmResourceModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Type types.String `tfsdk:"type"` - SSHKey types.String `tfsdk:"ssh_key"` - Location types.String `tfsdk:"location"` - Image types.String `tfsdk:"image"` - StartupScript types.String `tfsdk:"startup_script"` - ShutdownScript types.String `tfsdk:"shutdown_script"` - IBPartitionID types.String `tfsdk:"ib_partition_id"` - Disks []vmDiskResourceModel `tfsdk:"disks"` - NetworkInterfaces types.List `tfsdk:"network_interfaces"` + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + SSHKey types.String `tfsdk:"ssh_key"` + Location types.String `tfsdk:"location"` + Image types.String `tfsdk:"image"` + StartupScript types.String `tfsdk:"startup_script"` + ShutdownScript types.String `tfsdk:"shutdown_script"` + Disks types.List `tfsdk:"disks"` + NetworkInterfaces types.List `tfsdk:"network_interfaces"` + HostChannelAdapters types.List `tfsdk:"host_channel_adapters"` } type vmNetworkInterfaceResourceModel struct { @@ -54,7 +54,13 @@ type vmPublicIPv4ResourceModel struct { } type vmDiskResourceModel struct { - ID string `tfsdk:"id"` + ID string `tfsdk:"id"` + AttachmentType string `tfsdk:"attachment_type"` + Mode string `tfsdk:"mode"` +} + +type vmHostChannelAdapterResourceModel struct { + IBPartitionID string `tfsdk:"ib_partition_id"` } func NewVMResource() resource.Resource { @@ -91,6 +97,11 @@ func (r *vmResource) Schema(ctx context.Context, req resource.SchemaRequest, res Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, // cannot be updated in place }, + "project_id": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}, // cannot be updated in place + }, "type": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, // cannot be updated in place @@ -111,7 +122,7 @@ func (r *vmResource) Schema(ctx context.Context, req resource.SchemaRequest, res }, "image": schema.StringAttribute{ Optional: true, - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, // cannot be updated in place + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, // cannot be updated in place }, "startup_script": schema.StringAttribute{ Optional: true, @@ -126,7 +137,14 @@ func (r *vmResource) Schema(ctx context.Context, req resource.SchemaRequest, res NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - Optional: true, + Required: true, + }, + "attachment_type": schema.StringAttribute{ + Required: true, + }, + "mode": schema.StringAttribute{ + Required: true, + Validators: []validator.String{validators.StorageModeValidator{}}, }, }, }, @@ -189,10 +207,20 @@ func (r *vmResource) Schema(ctx context.Context, req resource.SchemaRequest, res }, }, }, - "ib_partition_id": schema.StringAttribute{ + "host_channel_adapters": schema.ListNestedAttribute{ + Computed: true, Optional: true, - Description: "Infiniband Partition ID", - PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, // maintain across updates + PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()}, // maintain across updates + NestedObject: schema.NestedAttributeObject{ + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, // maintain across updates + Attributes: map[string]schema.Attribute{ + "ib_partition_id": schema.StringAttribute{ + Optional: true, + Description: "Infiniband Partition ID", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, // maintain across updates + }, + }, + }, }, }, } @@ -211,16 +239,34 @@ func (r *vmResource) Create(ctx context.Context, req resource.CreateRequest, res return } - roleID, err := common.GetRole(ctx, r.client) - if err != nil { - resp.Diagnostics.AddError("Failed to get Role ID", err.Error()) - + tDisks := make([]vmDiskResourceModel, 0, len(plan.Disks.Elements())) + diags = plan.Disks.ElementsAs(ctx, &tDisks, true) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { return } - diskIds := make([]string, 0, len(plan.Disks)) - for _, d := range plan.Disks { - diskIds = append(diskIds, d.ID) + projectID := "" + if plan.ProjectID.ValueString() == "" { + project, err := common.GetFallbackProject(ctx, r.client, &resp.Diagnostics) + if err != nil { + resp.Diagnostics.AddError("Failed to create instance", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) + + return + } + projectID = project + } else { + projectID = plan.ProjectID.ValueString() + } + + diskIds := make([]swagger.DiskAttachment, 0, len(tDisks)) + for _, d := range tDisks { + diskIds = append(diskIds, swagger.DiskAttachment{ + AttachmentType: d.AttachmentType, + DiskId: d.ID, + Mode: d.Mode, + }) } // public static IPs @@ -241,19 +287,33 @@ func (r *vmResource) Create(ctx context.Context, req resource.CreateRequest, res } } - dataResp, httpResp, err := r.client.VMsApi.CreateInstance(ctx, swagger.InstancesPostRequestV1Alpha4{ - RoleId: roleID, - Name: plan.Name.ValueString(), - ProductName: plan.Type.ValueString(), - Location: plan.Location.ValueString(), - Image: plan.Image.ValueString(), - SshPublicKey: plan.SSHKey.ValueString(), - StartupScript: plan.StartupScript.ValueString(), - ShutdownScript: plan.ShutdownScript.ValueString(), - IbPartitionId: plan.IBPartitionID.ValueString(), - NetworkInterfaces: newNetworkInterfaces, - Disks: diskIds, - }) + var hostChannelAdapters []swagger.PartialHostChannelAdapter + if !plan.HostChannelAdapters.IsUnknown() && !plan.HostChannelAdapters.IsNull() { + tHostChannelAdapters := make([]vmHostChannelAdapterResourceModel, 0, len(plan.HostChannelAdapters.Elements())) + diags = plan.HostChannelAdapters.ElementsAs(ctx, &tHostChannelAdapters, true) + resp.Diagnostics.Append(diags...) + + for _, hca := range tHostChannelAdapters { + hostChannelAdapters = []swagger.PartialHostChannelAdapter{ + { + IbPartitionId: hca.IBPartitionID, + }, + } + } + } + + dataResp, httpResp, err := r.client.VMsApi.CreateInstance(ctx, swagger.InstancesPostRequestV1Alpha5{ + Name: plan.Name.ValueString(), + Type_: plan.Type.ValueString(), + Location: plan.Location.ValueString(), + Image: plan.Image.ValueString(), + SshPublicKey: plan.SSHKey.ValueString(), + StartupScript: plan.StartupScript.ValueString(), + ShutdownScript: plan.ShutdownScript.ValueString(), + NetworkInterfaces: newNetworkInterfaces, + Disks: diskIds, + HostChannelAdapters: hostChannelAdapters, + }, projectID) if err != nil { resp.Diagnostics.AddError("Failed to create instance", fmt.Sprintf("There was an error starting a create instance operation: %s", common.UnpackAPIError(err))) @@ -262,8 +322,8 @@ func (r *vmResource) Create(ctx context.Context, req resource.CreateRequest, res } defer httpResp.Body.Close() - instance, _, err := common.AwaitOperationAndResolve[swagger.InstanceV1Alpha4]( - ctx, dataResp.Operation, r.client.VMOperationsApi.GetComputeVMsInstancesOperation) + instance, _, err := common.AwaitOperationAndResolve[swagger.InstanceV1Alpha5]( + ctx, dataResp.Operation, projectID, r.client.VMOperationsApi.GetComputeVMsInstancesOperation) if err != nil { resp.Diagnostics.AddError("Failed to create instance", fmt.Sprintf("There was an error creating a instance: %s", common.UnpackAPIError(err))) @@ -275,6 +335,18 @@ func (r *vmResource) Create(ctx context.Context, req resource.CreateRequest, res networkInterfaces, _ := vmNetworkInterfacesToTerraformResourceModel(instance.NetworkInterfaces) plan.NetworkInterfaces = networkInterfaces + if len(diskIds) > 0 { + disks, diags := vmDiskAttachmentToTerraformResourceModel(diskIds) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + plan.Disks = disks + } + + plan.ProjectID = types.StringValue(projectID) + hcas, _ := vmPartialHostChannelAdaptersToTerraformResourceModel(instance.HostChannelAdapters) + plan.HostChannelAdapters = hcas diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) @@ -289,7 +361,23 @@ func (r *vmResource) Read(ctx context.Context, req resource.ReadRequest, resp *r return } - instance, err := getVM(ctx, r.client, state.ID.ValueString()) + // We only have this parsing for transitioning from v1alpha4 to v1alpha5 because old tf state files will not + // have project ID stored. So we will try to get a fallback project to pass to the API. + projectID := "" + if state.ProjectID.ValueString() == "" { + project, err := common.GetFallbackProject(ctx, r.client, &resp.Diagnostics) + if err != nil { + + resp.Diagnostics.AddError("Failed to create disk", + fmt.Sprintf("No project was specified and it was not possible to determine which project to use: %v", err)) + return + } + projectID = project + } else { + projectID = state.ProjectID.ValueString() + } + + instance, err := getVM(ctx, r.client, projectID, state.ID.ValueString()) if err != nil || instance == nil { // instance has most likely been deleted out of band, so we update Terraform state to match resp.State.RemoveResource(ctx) @@ -299,20 +387,26 @@ func (r *vmResource) Read(ctx context.Context, req resource.ReadRequest, resp *r state.ID = types.StringValue(instance.Id) state.Name = types.StringValue(instance.Name) - state.Type = types.StringValue(instance.ProductName) + state.Type = types.StringValue(instance.Type_) + state.ProjectID = types.StringValue(instance.ProjectId) networkInterfaces, _ := vmNetworkInterfacesToTerraformResourceModel(instance.NetworkInterfaces) state.NetworkInterfaces = networkInterfaces disks := make([]vmDiskResourceModel, 0, len(instance.Disks)) for _, disk := range instance.Disks { - if !disk.IsBootDisk { - disks = append(disks, vmDiskResourceModel{ID: disk.Id}) + if disk.AttachmentType != "os" { + disks = append(disks, vmDiskResourceModel{ + ID: disk.Id, + AttachmentType: disk.AttachmentType, + Mode: disk.Mode, + }) } } if len(disks) > 0 { // only assign if disks is not empty. otherwise, intentionally keep this nil, for future comparisons - state.Disks = disks + tDisks, _ := types.ListValueFrom(context.Background(), vmDiskAttachmentSchema, disks) + state.Disks = tDisks } diags = resp.State.Set(ctx, &state) @@ -339,37 +433,26 @@ func (r *vmResource) Update(ctx context.Context, req resource.UpdateRequest, res } // attach/detach disks if requested - addedDisks, removedDisks := getDisksDiff(state.Disks, plan.Disks) - if len(addedDisks) > 0 { - attachResp, httpResp, err := r.client.VMsApi.UpdateInstanceAttachDisks(ctx, swagger.InstancesAttachDiskPostRequestV1Alpha4{ - AttachDisks: addedDisks, - }, state.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to attach disk", - fmt.Sprintf("There was an error starting an attach disk operation: %s", common.UnpackAPIError(err))) + tPlanDisks := make([]vmDiskResourceModel, 0, len(plan.Disks.Elements())) + diags = plan.Disks.ElementsAs(ctx, &tPlanDisks, true) - return - } - defer httpResp.Body.Close() + tStateDisks := make([]vmDiskResourceModel, 0, len(state.Disks.Elements())) + diags = state.Disks.ElementsAs(ctx, &tStateDisks, true) - _, err = common.AwaitOperation(ctx, attachResp.Operation, r.client.VMOperationsApi.GetComputeVMsInstancesOperation) - if err != nil { - resp.Diagnostics.AddError("Failed to attach disk", - fmt.Sprintf("There was an error attaching a disk: %s", common.UnpackAPIError(err))) - } - } + addedDisks, removedDisks := getDisksDiff(tStateDisks, tPlanDisks) if len(removedDisks) > 0 { + detachResp, httpResp, err := r.client.VMsApi.UpdateInstanceDetachDisks(ctx, swagger.InstancesDetachDiskPostRequest{ DetachDisks: removedDisks, - }, state.ID.ValueString()) + }, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to detach disk", fmt.Sprintf("There was an error starting a detach disk operation: %s", common.UnpackAPIError(err))) } defer httpResp.Body.Close() - _, err = common.AwaitOperation(ctx, detachResp.Operation, r.client.VMOperationsApi.GetComputeVMsInstancesOperation) + _, err = common.AwaitOperation(ctx, detachResp.Operation, plan.ProjectID.ValueString(), r.client.VMOperationsApi.GetComputeVMsInstancesOperation) if err != nil { resp.Diagnostics.AddError("Failed to detach disk", fmt.Sprintf("There was an error detaching a disk: %s", common.UnpackAPIError(err))) @@ -378,17 +461,39 @@ func (r *vmResource) Update(ctx context.Context, req resource.UpdateRequest, res } } + if len(addedDisks) > 0 { + attachResp, httpResp, err := r.client.VMsApi.UpdateInstanceAttachDisks(ctx, swagger.InstancesAttachDiskPostRequestV1Alpha5{ + AttachDisks: addedDisks, + }, state.ProjectID.ValueString(), state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to attach disk", + fmt.Sprintf("There was an error starting an attach disk operation: %s", common.UnpackAPIError(err))) + + return + } + defer httpResp.Body.Close() + + _, err = common.AwaitOperation(ctx, attachResp.Operation, plan.ProjectID.ValueString(), r.client.VMOperationsApi.GetComputeVMsInstancesOperation) + if err != nil { + resp.Diagnostics.AddError("Failed to attach disk", + fmt.Sprintf("There was an error attaching a disk: %s", common.UnpackAPIError(err))) + } + } + // save intermediate results if len(addedDisks) > 0 || len(removedDisks) > 0 { state.Disks = plan.Disks diags = resp.State.Set(ctx, &state) resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } } // handle toggling static/dynamic public IPs if !plan.NetworkInterfaces.IsUnknown() && len(plan.NetworkInterfaces.Elements()) == 1 { // instances must be running to toggle static public IP - instance, httpResp, err := r.client.VMsApi.GetInstance(ctx, state.ID.ValueString()) + instance, httpResp, err := r.client.VMsApi.GetInstance(ctx, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to update instance network interface", fmt.Sprintf("There was an error fetching the instance's current state: %v", err)) @@ -396,17 +501,32 @@ func (r *vmResource) Update(ctx context.Context, req resource.UpdateRequest, res return } defer httpResp.Body.Close() - if instance.Instance.State != StateRunning { + if instance.State != StateRunning { resp.Diagnostics.AddError("Cannot update instance network interface", "The instance needs to be running before updating its public IP address.") return } + var hostChannelAdapters []swagger.PartialHostChannelAdapter + if !plan.HostChannelAdapters.IsUnknown() && !plan.HostChannelAdapters.IsNull() { + tHostChannelAdapters := make([]vmHostChannelAdapterResourceModel, 0, len(plan.HostChannelAdapters.Elements())) + diags = plan.HostChannelAdapters.ElementsAs(ctx, &tHostChannelAdapters, true) + resp.Diagnostics.Append(diags...) + + for _, hca := range tHostChannelAdapters { + hostChannelAdapters = []swagger.PartialHostChannelAdapter{ + { + IbPartitionId: hca.IBPartitionID, + }, + } + } + } + var tNetworkInterfaces []vmNetworkInterfaceResourceModel diags = plan.NetworkInterfaces.ElementsAs(ctx, &tNetworkInterfaces, true) resp.Diagnostics.Append(diags...) - patchResp, httpResp, err := r.client.VMsApi.UpdateInstance(ctx, swagger.InstancesPatchRequestV1Alpha4{ + patchResp, httpResp, err := r.client.VMsApi.UpdateInstance(ctx, swagger.InstancesPatchRequestV1Alpha5{ Action: "UPDATE", NetworkInterfaces: []swagger.NetworkInterface{{ Ips: []swagger.IpAddresses{{ @@ -416,7 +536,8 @@ func (r *vmResource) Update(ctx context.Context, req resource.UpdateRequest, res }, }}, }}, - }, state.ID.ValueString()) + HostChannelAdapters: hostChannelAdapters, + }, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to update instance network interface", fmt.Sprintf("There was an error requesting to update the instance's network interface: %v", err)) @@ -425,7 +546,7 @@ func (r *vmResource) Update(ctx context.Context, req resource.UpdateRequest, res } defer httpResp.Body.Close() - _, err = common.AwaitOperation(ctx, patchResp.Operation, r.client.VMOperationsApi.GetComputeVMsInstancesOperation) + _, err = common.AwaitOperation(ctx, patchResp.Operation, state.ProjectID.ValueString(), r.client.VMOperationsApi.GetComputeVMsInstancesOperation) if err != nil { resp.Diagnostics.AddError("Failed to update instance network interface", fmt.Sprintf("There was an error updating the instance's network interfaces: %s", common.UnpackAPIError(err))) @@ -448,14 +569,14 @@ func (r *vmResource) Delete(ctx context.Context, req resource.DeleteRequest, res return } - _, err := getVM(ctx, r.client, state.ID.ValueString()) + _, err := getVM(ctx, r.client, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to find instance", "Could not find a matching VM instance.") return } - delDataResp, delHttpResp, err := r.client.VMsApi.DeleteInstance(ctx, state.ID.ValueString()) + delDataResp, delHttpResp, err := r.client.VMsApi.DeleteInstance(ctx, state.ProjectID.ValueString(), state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to delete instance", fmt.Sprintf("There was an error starting a delete instance operation: %s", common.UnpackAPIError(err))) @@ -464,7 +585,8 @@ func (r *vmResource) Delete(ctx context.Context, req resource.DeleteRequest, res } defer delHttpResp.Body.Close() - _, _, err = common.AwaitOperationAndResolve[interface{}](ctx, delDataResp.Operation, r.client.VMOperationsApi.GetComputeVMsInstancesOperation) + _, _, err = common.AwaitOperationAndResolve[interface{}](ctx, delDataResp.Operation, state.ProjectID.ValueString(), + r.client.VMOperationsApi.GetComputeVMsInstancesOperation) if err != nil { resp.Diagnostics.AddError("Failed to delete instance", fmt.Sprintf("There was an error deleting an instance: %s", common.UnpackAPIError(err)))