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 {