Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 189 additions & 3 deletions stackit/internal/services/iaas/image/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package image
import (
"bufio"
"context"
"crypto/md5"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -59,6 +62,22 @@ 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"`
}

type localModel struct {
Path types.String `tfsdk:"file_path"`
DisablePlanValidation types.Bool `tfsdk:"disable_plan_validation"`
}

type downloadModel struct {
URL types.String `tfsdk:"url"`
CachePath types.String `tfsdk:"cache_path"`
}

type imageFileModel struct {
Local types.Object `tfsdk:"local"`
Download types.Object `tfsdk:"download"`
}

// Struct corresponding to Model.Config
Expand Down Expand Up @@ -225,7 +244,7 @@ func (r *imageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp
},
"local_file_path": schema.StringAttribute{
Description: "The filepath of the raw image file to be uploaded.",
Required: true,
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Expand Down Expand Up @@ -407,6 +426,49 @@ 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,
Validators: []validator.String{
// Validating that the file exists in the plan is useful to avoid
// creating an image resource where the local image upload will fail
validate.FileExists(),
},
},
"disable_plan_validation": schema.BoolAttribute{
Description: "Wheter to disable plan-time validation.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Description: "Wheter to disable plan-time validation.",
Description: "Whether to disable plan-time validation.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And add the default value to the description

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,
},
"cache_path": schema.StringAttribute{
Description: "Local path to cache the downloaded image.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add default value to description

Required: true,
},
},
},
},
},
},
}
}
Expand All @@ -428,6 +490,53 @@ func (r *imageResource) Create(ctx context.Context, req resource.CreateRequest,

ctx = core.InitProviderContext(ctx)

if model.ImageFile.IsNull() || model.ImageFile.IsUnknown() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", "Error in config")
}

var imageFile imageFileModel
diags = model.ImageFile.As(ctx, &imageFile, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", "Error in config")
}

isLocal := imageFile.Download.IsNull() || imageFile.Download.IsUnknown()
isDownload := imageFile.Local.IsNull() || imageFile.Local.IsUnknown()
if isDownload && isLocal || !isDownload && !isLocal {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", "Error in config")
return
}

var filename string
var err error
var downloadModel downloadModel
var localModel localModel
if isDownload {
diags = imageFile.Download.As(ctx, &downloadModel, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", "Error in config")
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 file.Close()
defer os.RemoveAll(filepath.Dir(file.Name()))
filename = file.Name()
} else {
diags = imageFile.Download.As(ctx, &localModel, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", "Error in config")
return
}
filename = localModel.Path.ValueString()
}

// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
Expand Down Expand Up @@ -468,7 +577,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, filename, imageCreateResp.UploadUrl)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Uploading image: %v", err))
return
Expand Down Expand Up @@ -875,6 +984,13 @@ func uploadImage(ctx context.Context, diags *diag.Diagnostics, filePath, uploadU
if err != nil {
return fmt.Errorf("open file: %w", err)
}

defer func() {
if closeErr := file.Close(); closeErr != nil {
core.LogAndAddError(ctx, diags, "Error closing file", closeErr.Error())
}
}()

stat, err := file.Stat()
if err != nil {
return fmt.Errorf("stat file: %w", err)
Expand Down Expand Up @@ -902,6 +1018,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) {
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)
}

if _, err := file.Seek(0, 0); err != nil {
file.Close()
cleanupOnErr()
return nil, fmt.Errorf("seeking file: %w", err)
}

return file, nil
}
94 changes: 94 additions & 0 deletions stackit/internal/services/iaas/image/resource_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package image

import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -405,3 +407,95 @@ func Test_UploadImage(t *testing.T) {
})
}
}

func Test_DownloadImage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/404":
w.WriteHeader(http.StatusNotFound)
case "/empty":
w.WriteHeader(http.StatusOK)
case "/large":
w.WriteHeader(http.StatusOK)
_, _ = w.Write(bytes.Repeat([]byte("A"), 1024*1024))
case "/drop-conn":
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "webserver doesn't support hijacking", http.StatusInternalServerError)
return
}
conn, _, _ := hj.Hijack()
_ = conn.Close()
default:
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("dummy content"))
}
}))
t.Cleanup(server.Close)

tests := []struct {
name string
ctx context.Context
downloadURL string
wantBytes []byte
wantErr bool
}{{
name: "ok",
downloadURL: server.URL,
wantBytes: []byte("dummy content"),
wantErr: false,
},
{
name: "invalid_url_format",
downloadURL: "http://127.0.0.1:0/invalid",
wantErr: true,
},
{
name: "status_404_not_found",
downloadURL: server.URL + "/404",
wantErr: true,
},
{
name: "empty_body_200_ok",
downloadURL: server.URL + "/empty",
wantBytes: []byte(""),
wantErr: false,
},
{
name: "large_file_stream",
downloadURL: server.URL + "/large",
wantBytes: bytes.Repeat([]byte("A"), 1024*1024),
wantErr: false,
},
{
name: "connection_dropped_mid_stream",
downloadURL: server.URL + "/drop-conn",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file, err := downloadImage(tt.ctx, tt.downloadURL)
if (err != nil) != tt.wantErr {
t.Fatalf("downloadImage() error = %v, wantErr %v", err, tt.wantErr)
}

if file != nil {
t.Cleanup(func() {
_ = file.Close()
_ = os.Remove(file.Name())
})

gotBytes, err := os.ReadFile(file.Name())
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}

if !bytes.Equal(gotBytes, tt.wantBytes) {
t.Errorf("byte mismatch: got length %d, want length %d", len(gotBytes), len(tt.wantBytes))
}
}
})
}
}
Loading