Skip to content
241 changes: 224 additions & 17 deletions stackit/internal/services/iaas/image/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@ package image
import (
"bufio"
"context"
"crypto/md5"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"

iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"

"github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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/boolplanmodifier"
Expand All @@ -38,10 +43,11 @@ import (

// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &imageResource{}
_ resource.ResourceWithConfigure = &imageResource{}
_ resource.ResourceWithImportState = &imageResource{}
_ resource.ResourceWithModifyPlan = &imageResource{}
_ resource.Resource = &imageResource{}
_ resource.ResourceWithConfigure = &imageResource{}
_ resource.ResourceWithImportState = &imageResource{}
_ resource.ResourceWithModifyPlan = &imageResource{}
_ resource.ResourceWithConfigValidators = &imageResource{}
)

type Model struct {
Expand All @@ -59,6 +65,7 @@ type Model struct {
Checksum types.Object `tfsdk:"checksum"`
Labels types.Map `tfsdk:"labels"`
LocalFilePath types.String `tfsdk:"local_file_path"`
ImageFile types.Object `tfsdk:"image_file"`
}

// Struct corresponding to Model.Config
Expand Down Expand Up @@ -118,6 +125,23 @@ type imageResource struct {
providerData core.ProviderData
}

// Struct corresponding to Model.ImageFile
type imageFileModel struct {
Local types.Object `tfsdk:"local"`
Download types.Object `tfsdk:"download"`
}

// Struct corresponding to Model.ImageFile.Download
type downloadModel struct {
URL types.String `tfsdk:"url"`
}

// Struct corresponding to Model.ImageFile.Local
type localModel struct {
Path types.String `tfsdk:"file_path"`
DisablePlanValidation types.Bool `tfsdk:"disable_plan_validation"`
}

// Metadata returns the resource type name.
func (r *imageResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_image"
Expand Down Expand Up @@ -168,6 +192,15 @@ func (r *imageResource) Configure(ctx context.Context, req resource.ConfigureReq
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
func (r *imageResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator {
return []resource.ConfigValidator{
resourcevalidator.ExactlyOneOf(
path.MatchRoot("local_file_path"),
path.MatchRoot("image_file").AtName("local"),
path.MatchRoot("image_file").AtName("download"),
),
}
}

// Schema defines the schema for the resource.
func (r *imageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
Expand Down Expand Up @@ -223,9 +256,9 @@ func (r *imageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp
stringplanmodifier.RequiresReplace(),
},
},
"local_file_path": schema.StringAttribute{
Description: "The filepath of the raw image file to be uploaded.",
Required: true,
"local_file_path": schema.StringAttribute{ // Deprecated: image_file is deprecated and will be removed after February 2027.
Description: "The filepath of the raw image file to be uploaded. (Deprecated: image_file is deprecated and will be removed after February 2027. Use local.file_path instead.)",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Expand Down Expand Up @@ -407,13 +440,58 @@ func (r *imageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp
ElementType: types.StringType,
Optional: true,
},
"image_file": schema.SingleNestedAttribute{
Description: "Representation of an image file.",
Computed: false,
Optional: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"local": schema.SingleNestedAttribute{
Description: "Representation of a local image file.",
Optional: true,
Attributes: map[string]schema.Attribute{
"file_path": schema.StringAttribute{
Description: "Path to the local file.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.FileExists(), // will only be validated if parent is present since parent is optional
},
},
"disable_plan_validation": schema.BoolAttribute{ // TODO: clarify what this is for? (when would I provide a local file path without it being present besides current hacky solutions or maybe sophisticated CI?)
Description: "Whether to disable plan-time validation.",
Optional: true,
},
},
},
"download": schema.SingleNestedAttribute{
Description: "Remote file download settings.",
Optional: true,
Attributes: map[string]schema.Attribute{
"url": schema.StringAttribute{
Description: "URL to downlioad the image from.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.URL("http", "https"), // will only be validated if parent is present since parent is optional
},
},
},
},
},
},
},
}
}

// Create creates the resource and sets the initial Terraform state.
func (r *imageResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
Expand All @@ -428,6 +506,60 @@ func (r *imageResource) Create(ctx context.Context, req resource.CreateRequest,

ctx = core.InitProviderContext(ctx)

var file *os.File
var err error

if !model.LocalFilePath.IsNull() && !model.LocalFilePath.IsUnknown() { // is deprecated
file, err = loadFileFromDisk(ctx, model.LocalFilePath.ValueString())
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error loading image from disk", fmt.Sprintf("Loading file: %v", err))
return
}
} else if !model.ImageFile.IsNull() && !model.ImageFile.IsUnknown() {
var imageFile imageFileModel
diags = model.ImageFile.As(ctx, &imageFile, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

if !imageFile.Download.IsNull() && !imageFile.Download.IsUnknown() { // is download
var downloadModel downloadModel
diags = imageFile.Download.As(ctx, &downloadModel, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

file, err = downloadImage(ctx, downloadModel.URL.ValueString())
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error downloading image", fmt.Sprintf("Downloading Image: %v", err))
return
}
defer os.RemoveAll(filepath.Dir(file.Name()))

} else if !imageFile.Local.IsNull() && !imageFile.Local.IsUnknown() { // is local
var localModel localModel
diags = imageFile.Local.As(ctx, &localModel, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

file, err = loadFileFromDisk(ctx, localModel.Path.ValueString())
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error loading image from disk", fmt.Sprintf("Loading file: %v", err))
return
}
}
}

if file == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", "No valid image source path or URL was resolved from configuration.")
return
}
defer file.Close()

// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
Expand Down Expand Up @@ -468,7 +600,7 @@ func (r *imageResource) Create(ctx context.Context, req resource.CreateRequest,
}

// Upload image
err = uploadImage(ctx, &resp.Diagnostics, model.LocalFilePath.ValueString(), imageCreateResp.UploadUrl)
err = uploadImage(ctx, &resp.Diagnostics, file, imageCreateResp.UploadUrl)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Uploading image: %v", err))
return
Expand Down Expand Up @@ -863,24 +995,29 @@ func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map)
}, nil
}

func uploadImage(ctx context.Context, diags *diag.Diagnostics, filePath, uploadURL string) error {
func loadFileFromDisk(ctx context.Context, filePath string) (*os.File, error) {
if filePath == "" {
return fmt.Errorf("file path is empty")
}
if uploadURL == "" {
return fmt.Errorf("upload URL is empty")
return nil, fmt.Errorf("file path is empty")
}

file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
return nil, fmt.Errorf("open file: %w", err)
}

return file, nil
}

func uploadImage(ctx context.Context, diags *diag.Diagnostics, file *os.File, uploadURL string) error {
if file == nil {
return fmt.Errorf("file is nil")
}
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("stat file: %w", err)
}

req, err := http.NewRequest(http.MethodPut, uploadURL, bufio.NewReader(file))
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bufio.NewReader(file))
if err != nil {
return fmt.Errorf("create upload request: %w", err)
}
Expand All @@ -902,6 +1039,76 @@ func uploadImage(ctx context.Context, diags *diag.Diagnostics, filePath, uploadU
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upload image: %s", resp.Status)
}

return nil
}

func downloadImage(ctx context.Context, downloadURL string) (*os.File, error) { //TODO: Ask what the benefit of using os.File over file paths with reopening is
if downloadURL == "" {
return nil, fmt.Errorf("download URL is empty")
}

md5sum := fmt.Sprintf("%x", md5.Sum([]byte(downloadURL)))

tmpDir, err := os.MkdirTemp("", "tf-provider-download-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}

filename := filepath.Join(tmpDir, md5sum+".img")

cleanupOnErr := func() {
if err := os.RemoveAll(tmpDir); err != nil {
tflog.Warn(ctx, "failed to cleanup temp directory", map[string]interface{}{
"dir": tmpDir,
"error": err.Error(),
})
}
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
cleanupOnErr()
return nil, fmt.Errorf("create download request: %w", err)
}

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
cleanupOnErr()
return nil, fmt.Errorf("download image: %w", err)
}

defer func() {
if err := resp.Body.Close(); err != nil {
tflog.Debug(ctx, "failed to close HTTP response body", map[string]interface{}{
"error": err.Error(),
})
}
}()
if resp.StatusCode != http.StatusOK {
cleanupOnErr()
return nil, fmt.Errorf("download image unexpected status: %s", resp.Status)
}

file, err := os.Create(filename)
if err != nil {
cleanupOnErr()
return nil, fmt.Errorf("creating file: %w", err)
}

_, err = io.Copy(file, resp.Body)
if err != nil {
file.Close()
cleanupOnErr()
return nil, fmt.Errorf("writing to file: %w", err)
}

// rewind for next consumer
if _, err := file.Seek(0, io.SeekStart); err != nil {
file.Close()
cleanupOnErr()
return nil, fmt.Errorf("seeking file: %w", err)
}

return file, nil
}
Loading