diff --git a/examples/resources/hypercore_iso/resource.tf b/examples/resources/hypercore_iso/resource.tf new file mode 100644 index 0000000..76635bf --- /dev/null +++ b/examples/resources/hypercore_iso/resource.tf @@ -0,0 +1,27 @@ +locals { + vm_name = "myvm" +} + +data "hypercore_vm" "isovm" { + name = local.vm_name +} + +resource "hypercore_iso" "iso_upload_local" { + name = "testiso-local.iso" + source_url = "file:////home/bla/Downloads/mytestiso.iso" +} + +resource "hypercore_iso" "iso_upload_from_url" { + name = "testiso-remote.iso" + source_url = "https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/aarch64/alpine-virt-3.21.3-aarch64.iso" +} + + +output "uploaded_iso_LOCAL" { + value = hypercore_iso.iso_upload_local +} + +output "uploaded_iso_EXTERNAL" { + value = hypercore_iso.iso_upload_from_url +} + diff --git a/internal/provider/hypercore_iso_resource.go b/internal/provider/hypercore_iso_resource.go new file mode 100644 index 0000000..614aa3b --- /dev/null +++ b/internal/provider/hypercore_iso_resource.go @@ -0,0 +1,301 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +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" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-provider-hypercore/internal/utils" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &HypercoreISOResource{} +var _ resource.ResourceWithImportState = &HypercoreISOResource{} + +func NewHypercoreISOResource() resource.Resource { + return &HypercoreISOResource{} +} + +// HypercoreNicResource defines the resource implementation. +type HypercoreISOResource struct { + client *utils.RestClient +} + +// HypercoreNicResourceModel describes the resource data model. +type HypercoreISOResourceModel struct { + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + SourceURL types.String `tfsdk:"source_url"` +} + +func (r *HypercoreISOResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_iso" +} + +func (r *HypercoreISOResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "" + + "Hypercore ISO resource to manage ISO images.

" + + "To use this resource, it's recommended to set the environment variable `TF_CLI_ARGS_apply=\"-parallelism=1\"` or pass the `-parallelism` parameter to the `terraform apply`.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "ISO identifier", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Desired name of the ISO to upload. ISO name must end with '.iso'.", + Required: true, + }, + "source_url": schema.StringAttribute{ + MarkdownDescription: "Source URL from where to fetch that disk from. URL can start with: `http://`, `https://`, `file:///`", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *HypercoreISOResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + tflog.Info(ctx, "TTRT HypercoreISOResource CONFIGURE") + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + restClient, ok := req.ProviderData.(*utils.RestClient) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = restClient +} + +func (r *HypercoreISOResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + tflog.Info(ctx, "TTRT HypercoreNicResource CREATE") + var data HypercoreISOResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if r.client == nil { + resp.Diagnostics.AddError( + "Unconfigured HTTP Client", + "Expected configured HTTP client. Please report this issue to the provider developers.", + ) + return + } + if resp.Diagnostics.HasError() { + return + } + + isoName := data.Name.ValueString() + isoSourceURL := data.SourceURL.ValueString() + + // STEPS: + // 1. Create ISO resource (with readForInsert = False) + + // Validate ISO name + nameDiag := utils.ValidateISOName(isoName) + if nameDiag != nil { + resp.Diagnostics.AddError(nameDiag.Summary(), nameDiag.Detail()) + return + } + + // Validate ISO SourceURL + sourceURLDiag := utils.ValidateISOSourceURL(isoSourceURL) + if sourceURLDiag != nil { + resp.Diagnostics.AddError(sourceURLDiag.Summary(), sourceURLDiag.Detail()) + return + } + + // Read binary + isoBinaryData, binDiag := utils.ReadISOBinary(isoSourceURL) + if binDiag != nil { + resp.Diagnostics.AddError(binDiag.Summary(), binDiag.Detail()) + return + } + + // Create + tflog.Info(ctx, fmt.Sprintf("TTRT Create: name=%s", data.Name.ValueString())) + isoUUID, iso := utils.CreateISO(*r.client, isoName, false, isoBinaryData, ctx) + tflog.Info(ctx, fmt.Sprintf("TTRT Created: name=%s, iso_uuid=%s, iso=%v", data.Name.ValueString(), isoUUID, iso)) + + // 2. Upload ISO file + fileSize := len(isoBinaryData) + tflog.Debug(ctx, fmt.Sprintf("TTRT ISO Upload: source_url=%s, file_size=%d (Bytes)", isoSourceURL, fileSize)) + _, uploadDiag := utils.UploadISO(*r.client, isoUUID, isoBinaryData, ctx) + if uploadDiag != nil { + resp.Diagnostics.AddWarning(uploadDiag.Summary(), uploadDiag.Detail()) + } + + // 3. Update ISO resource (change readForInsert = True) + payload := map[string]any{ + "name": data.Name.ValueString(), + "size": len(isoBinaryData), + "readyForInsert": true, + } + updateDiag := utils.UpdateISO(*r.client, isoUUID, payload, ctx) + if updateDiag != nil { + resp.Diagnostics.AddWarning(updateDiag.Summary(), updateDiag.Detail()) + } + + // TODO: Check if HC3 matches TF + // save into the Terraform state. + data.Id = types.StringValue(isoUUID) + // TODO MAC, IP address etc + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Trace(ctx, "created a resource ISO") + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *HypercoreISOResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + tflog.Info(ctx, "TTRT HypercoreISOResource READ") + var data HypercoreISOResourceModel + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // ISO read ====================================================================== + restClient := *r.client + name := data.Name.ValueString() + isoUUID := data.Id.ValueString() + tflog.Debug(ctx, fmt.Sprintf("TTRT HypercoreISOResource Read oldState name=%s and id=%s\n", name, isoUUID)) + + pISO := utils.GetISOByUUID(restClient, isoUUID) + if pISO == nil { + msg := fmt.Sprintf("ISO not found - isoUUID=%s, name=%s.\n", isoUUID, name) + resp.Diagnostics.AddError("ISO not found\n", msg) + return + } + iso := *pISO + // + tflog.Info(ctx, fmt.Sprintf("TTRT HypercoreISOResource: name=%s, iso_uuid=%s, iso=%v\n", name, isoUUID, iso)) + // save into the Terraform state. + data.Id = types.StringValue(isoUUID) + data.Name = types.StringValue(utils.AnyToString(iso["name"])) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *HypercoreISOResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + tflog.Info(ctx, "TTRT HypercoreISOResource UPDATE") + var data_state HypercoreISOResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data_state)...) + var data HypercoreISOResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + restClient := *r.client + isoUUID := data.Id.ValueString() + name := data.Name.ValueString() + tflog.Debug(ctx, fmt.Sprintf("TTRT HypercoreISOResource Update name=%s iso_uuid=%s REQUESTED", name, isoUUID)) + tflog.Debug(ctx, fmt.Sprintf("TTRT HypercoreISOResource Update name=%s iso_uuid=%s STATE", name, isoUUID)) + + updatePayload := map[string]any{ + "name": name, + } + diag := utils.UpdateISO(restClient, isoUUID, updatePayload, ctx) + if diag != nil { + resp.Diagnostics.AddWarning(diag.Summary(), diag.Detail()) + } + + // TODO: Check if HC3 matches TF + // Do not trust UpdateNic made what we asked for. Read new NIC state from HC3. + pISO := utils.GetISOByUUID(restClient, isoUUID) + if pISO == nil { + msg := fmt.Sprintf("ISO not found - isoUUID=%s, name=%s.", isoUUID, name) + resp.Diagnostics.AddError("ISO not found", msg) + return + } + iso := *pISO + + tflog.Info(ctx, fmt.Sprintf("TTRT HypercoreISOResource: name=%s, iso_uuid=%s, iso=%v", name, isoUUID, iso)) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *HypercoreISOResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + tflog.Info(ctx, "TTRT HypercoreISOResource DELETE") + var data HypercoreISOResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // If applicable, this is a great opportunity to initialize any necessary + // provider client data and make a call using it. + // httpResp, err := r.client.Do(httpReq) + // if err != nil { + // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete example, got error: %s", err)) + // return + // } + + restClient := *r.client + isoUUID := data.Id.ValueString() + taskTag := restClient.DeleteRecord( + fmt.Sprintf("/rest/v1/ISO/%s", isoUUID), + -1, + ctx, + ) + taskTag.WaitTask(restClient, ctx) +} + +func (r *HypercoreISOResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + tflog.Info(ctx, "TTRT HypercoreISOResource IMPORT_STATE") + + vdUUID := req.ID + tflog.Info(ctx, fmt.Sprintf("TTRT HypercoreISOResource: iso_uuid=%s", vdUUID)) + + restClient := *r.client + hc3ISO := utils.GetISOByUUID(restClient, vdUUID) + + if hc3ISO == nil { + msg := fmt.Sprintf("ISO import, ISO not found - 'iso_uuid'='%s'.", req.ID) + resp.Diagnostics.AddError("ISO import error, ISO not found", msg) + return + } + + name := utils.AnyToString((*hc3ISO)["name"]) + tflog.Info(ctx, fmt.Sprintf("TTRT uuid=%v, name=%v\n", vdUUID, name)) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), vdUUID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b5fee5e..8e5a92b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -168,6 +168,7 @@ func (p *HypercoreProvider) Resources(ctx context.Context) []func() resource.Res NewHypercoreNicResource, NewHypercoreDiskResource, NewHypercoreVirtualDiskResource, + NewHypercoreISOResource, NewHypercoreVMPowerStateResource, NewHypercoreVMBootOrderResource, } diff --git a/internal/utils/helper.go b/internal/utils/helper.go index 84a8835..d938267 100644 --- a/internal/utils/helper.go +++ b/internal/utils/helper.go @@ -240,3 +240,14 @@ func FetchFileBinaryFromURL(url string) ([]byte, error) { return binaryData, nil } + +func GetFileSize(sourceFilePath string) int64 { + fileInfo, err := os.Stat(sourceFilePath) + if err != nil { + if os.IsNotExist(err) { + panic(fmt.Errorf("ISO file %s not found", sourceFilePath)) + } + panic(fmt.Errorf("unable to get file info for %s: %v", sourceFilePath, err)) + } + return fileInfo.Size() +} diff --git a/internal/utils/iso.go b/internal/utils/iso.go new file mode 100644 index 0000000..43cae18 --- /dev/null +++ b/internal/utils/iso.go @@ -0,0 +1,148 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package utils + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +func ValidateISOName(name string) diag.Diagnostic { + if strings.HasSuffix(name, ".iso") { + return nil + } + + return diag.NewErrorDiagnostic( + "Invalid ISO name for the ISO used", + fmt.Sprintf("ISO name '%s' is invalid. ISO name must end with '.iso'", name), + ) +} + +func ValidateISOSourceURL(url string) diag.Diagnostic { + if strings.HasPrefix(url, "http://") || + strings.HasPrefix(url, "https://") || + strings.HasPrefix(url, "file:///") { + return nil + } + + return diag.NewErrorDiagnostic( + "Invalid source URL for the ISO used", + fmt.Sprintf("Source URL '%s' is invalid. ISO source URL must start with either 'http://', 'https://' or 'file:///'", url), + ) +} + +func ReadISOBinary(sourceURL string) ([]byte, diag.Diagnostic) { + var binaryData []byte + var err error + + if strings.Contains(sourceURL, "http") { + binaryData, err = FetchFileBinaryFromURL(sourceURL) + } else if strings.Contains(sourceURL, "file:///") { + sourceURLParts := strings.Split(sourceURL, "file:///") + localFilePath := fmt.Sprintf("/%s", sourceURLParts[1]) // Add another 'slash' so it's an absolute path - that's because SMB has 3 slashes + binaryData, err = ReadLocalFileBinary(localFilePath) + } + + if err != nil { + return nil, diag.NewErrorDiagnostic( + "Couldn't fetch ISO from source", + fmt.Sprintf("Couldn't fetch ISO from source '%s': %s", sourceURL, err.Error()), + ) + } + + return binaryData, nil +} + +func CreateISO( + restClient RestClient, + name string, + readyForInsert bool, + binaryData []byte, + ctx context.Context, +) (string, map[string]any) { + payload := map[string]any{ + "name": name, + "size": len(binaryData), + "readyForInsert": readyForInsert, + } + taskTag, _, _ := restClient.CreateRecord( + "/rest/v1/ISO", + payload, + -1, + ) + taskTag.WaitTask(restClient, ctx) + isoUUID := taskTag.CreatedUUID + iso := GetISOByUUID(restClient, isoUUID) + return isoUUID, *iso +} + +func GetISOByUUID( + restClient RestClient, + isoUUID string, +) *map[string]any { + iso := restClient.GetRecord( + fmt.Sprintf("/rest/v1/ISO/%s", isoUUID), + nil, + false, + -1, + ) + return iso +} + +func UpdateISO( + restClient RestClient, + isoUUID string, + payload map[string]any, + ctx context.Context, +) diag.Diagnostic { + taskTag, err := restClient.UpdateRecord( + fmt.Sprintf("/rest/v1/ISO/%s", isoUUID), + payload, + -1, + ctx, + ) + + if err != nil { + return diag.NewWarningDiagnostic( + "HC3 is receiving too many requests at the same time.", + fmt.Sprintf("Please retry apply after Terraform finishes it's current operation. HC3 response message: %v", err.Error()), + ) + } + + taskTag.WaitTask(restClient, ctx) + tflog.Debug(ctx, fmt.Sprintf("TTRT Task Tag: %v\n", taskTag)) + + return nil +} + +func UploadISO( + restClient RestClient, + isoUUID string, + binaryData []byte, + ctx context.Context, +) (*map[string]any, diag.Diagnostic) { + fileSize := len(binaryData) + + _, err := restClient.PutBinaryRecordWithoutTaskTag( + fmt.Sprintf("/rest/v1/ISO/%s/data/", isoUUID), + binaryData, + int64(fileSize), + -1, + ctx, + ) + + if err != nil { + return nil, diag.NewWarningDiagnostic( + "HC3 is receiving too many requests at the same time.", + fmt.Sprintf("Please retry apply after Terraform finishes it's current operation or consider using the `-parallelism=1` terraform option. HC3 response message: %v", err.Error()), + ) + } + + iso := GetISOByUUID(restClient, isoUUID) + return iso, nil +} diff --git a/internal/utils/rest_client.go b/internal/utils/rest_client.go index 1ba5dde..8ac7fed 100644 --- a/internal/utils/rest_client.go +++ b/internal/utils/rest_client.go @@ -456,6 +456,35 @@ func (rc *RestClient) PutBinaryRecord(endpoint string, binaryData []byte, conten return jsonObjectToTaskTag(respJson), nil } +func (rc *RestClient) PutBinaryRecordWithoutTaskTag(endpoint string, binaryData []byte, contentLength int64, timeout float64, ctx context.Context) (int, error) { + useTimeout := timeout + if timeout == -1 { + useTimeout = rc.Timeout + } + client := rc.HttpClient + client.Timeout = time.Duration(useTimeout * float64(time.Second)) + + req := rc.RequestBinary( + "PUT", + endpoint, + binaryData, + contentLength, + rc.AuthHeader, + ) + + resp, err := client.Do(req) + if err != nil { + panic(fmt.Errorf("Error making a request: %s", err.Error())) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + panic(fmt.Errorf("Error making a request: got response status code %v", resp.StatusCode)) + } + + return resp.StatusCode, nil +} + func (rc *RestClient) DeleteRecord(endpoint string, timeout float64, ctx context.Context) *TaskTag { useTimeout := timeout if timeout == -1 { diff --git a/internal/utils/vm_disk.go b/internal/utils/vm_disk.go index 6b253c0..1b0f695 100644 --- a/internal/utils/vm_disk.go +++ b/internal/utils/vm_disk.go @@ -18,7 +18,7 @@ var ALLOWED_DISK_TYPES = map[string]bool{ "IDE_FLOPPY": true, "NVRAM": true, "VTPM": true, - "IDE_CDROM": false, + "IDE_CDROM": true, } type VMDisk struct {