diff --git a/stackit/internal/services/iaas/image/resource.go b/stackit/internal/services/iaas/image/resource.go index 2be96d4df..0212cafae 100644 --- a/stackit/internal/services/iaas/image/resource.go +++ b/stackit/internal/services/iaas/image/resource.go @@ -3,10 +3,13 @@ package image import ( "bufio" "context" + "crypto/md5" "errors" "fmt" + "io" "net/http" "os" + "path/filepath" "strings" "time" @@ -14,8 +17,10 @@ import ( 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" @@ -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 { @@ -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 @@ -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" @@ -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) { @@ -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(), }, @@ -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...) @@ -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 { @@ -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 @@ -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) } @@ -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 +} diff --git a/stackit/internal/services/iaas/image/resource_test.go b/stackit/internal/services/iaas/image/resource_test.go index e3e157f87..aee1a1592 100644 --- a/stackit/internal/services/iaas/image/resource_test.go +++ b/stackit/internal/services/iaas/image/resource_test.go @@ -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" @@ -350,6 +352,43 @@ func TestToUpdatePayload(t *testing.T) { } } +func Test_LoadFileFromDisk(t *testing.T) { + tests := []struct { + name string + filePath string + wantErr bool + }{ + { + name: "ok", + filePath: "testdata/mock-image.txt", + wantErr: false, + }, + { + name: "empty_file_path", + filePath: "", + wantErr: true, + }, + { + name: "file_not_found", + filePath: "testdata/non-existing-file.txt", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + // Call the function + file, err := loadFileFromDisk(context.Background(), tt.filePath) + if file != nil { + t.Cleanup(func() { _ = file.Close() }) + } + if (err != nil) != tt.wantErr { + t.Errorf("uploadImage() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func Test_UploadImage(t *testing.T) { tests := []struct { name string @@ -396,12 +435,107 @@ func Test_UploadImage(t *testing.T) { t.Error(err) return } - + file, _ := os.Open(tt.filePath) + if file != nil { + t.Cleanup(func() { _ = file.Close() }) + } // Call the function - err = uploadImage(context.Background(), &diag.Diagnostics{}, tt.filePath, uploadURL.String()) + err = uploadImage(context.Background(), &diag.Diagnostics{}, file, uploadURL.String()) if (err != nil) != tt.wantErr { t.Errorf("uploadImage() error = %v, wantErr %v", err, tt.wantErr) } }) } } + +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(context.Background(), 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)) + } + } + }) + } +} diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/validate.go index 3ebf658da..e3e37d6cf 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/validate.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "net/url" "os" "regexp" "strings" @@ -424,3 +425,46 @@ func NoLeadingOrTrailingWhitespace() *Validator { }, } } + +// URL returns a validator that checks if the string is a valid URL. +// If allowedSchemes are provided, the URL's scheme must match one of them. +func URL(allowedSchemes ...string) *Validator { + var description string + if len(allowedSchemes) > 0 { + description = fmt.Sprintf("value must be a valid URL with scheme %s", strings.Join(allowedSchemes, " or ")) + } else { + description = "value must be a valid URL" + } + + return &Validator{ + description: description, + validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + u, err := url.ParseRequestURI(req.ConfigValue.ValueString()) + if err != nil || u.Host == "" || u.Scheme == "" { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + description, + req.ConfigValue.ValueString(), + )) + return + } + + if len(allowedSchemes) > 0 { + schemeValid := false + for _, scheme := range allowedSchemes { + if strings.EqualFold(u.Scheme, scheme) { + schemeValid = true + break + } + } + if !schemeValid { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + description, + req.ConfigValue.ValueString(), + )) + } + } + }, + } +} diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/validate_test.go index 1f0cdced9..dec936eb7 100644 --- a/stackit/internal/validate/validate_test.go +++ b/stackit/internal/validate/validate_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" @@ -1085,3 +1086,54 @@ func TestNoLeadingOrtTrailingWhitespace(t *testing.T) { }) } } + +func TestURL(t *testing.T) { + tests := []struct { + name string + allowedSchemes []string + value string + wantErr bool + }{ + { + name: "valid_http_matching_scheme", + allowedSchemes: []string{"http", "https"}, + value: "http://example.com/file.iso", + wantErr: false, + }, + { + name: "valid_url_no_scheme_restriction", + allowedSchemes: nil, + value: "s3://mybucket/file.iso", + wantErr: false, + }, + { + name: "invalid_disallowed_scheme", + allowedSchemes: []string{"http", "https"}, + value: "ftp://example.com/file.iso", + wantErr: true, + }, + { + name: "invalid_malformed_url", + allowedSchemes: nil, + value: "://bad-url", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := URL(tt.allowedSchemes...) + resp := &validator.StringResponse{} + req := validator.StringRequest{ + ConfigValue: types.StringValue(tt.value), + Path: path.Root("url"), + } + + v.ValidateString(context.Background(), req, resp) + + if resp.Diagnostics.HasError() != tt.wantErr { + t.Errorf("URL() error = %v, wantErr %v", resp.Diagnostics.HasError(), tt.wantErr) + } + }) + } +}