0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-07-01 11:02:59 +00:00
terraform-provider-proxmox/fwprovider/nodes/resource_download_file.go
Pavel Boldyrev 37bdeccf9b
file(file): handle remote file size check error in download_file resource (#1940)
Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
2025-05-01 23:17:17 -04:00

649 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package nodes
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
"github.com/bpg/terraform-provider-proxmox/fwprovider/config"
"github.com/bpg/terraform-provider-proxmox/proxmox/api"
"github.com/bpg/terraform-provider-proxmox/proxmox"
"github.com/bpg/terraform-provider-proxmox/proxmox/nodes"
"github.com/bpg/terraform-provider-proxmox/proxmox/nodes/storage"
proxmoxtypes "github.com/bpg/terraform-provider-proxmox/proxmox/types"
)
var (
_ resource.Resource = &downloadFileResource{}
_ resource.ResourceWithConfigure = &downloadFileResource{}
httpRegex = regexp.MustCompile(`https?://.*`)
)
type sizeRequiresReplaceModifier struct{}
func (r sizeRequiresReplaceModifier) PlanModifyInt64(
ctx context.Context,
req planmodifier.Int64Request,
resp *planmodifier.Int64Response,
) {
// Do not replace on resource creation.
if req.State.Raw.IsNull() {
return
}
// Do not replace on resource destroy.
if req.Plan.Raw.IsNull() {
return
}
var plan, state downloadFileModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
originalStateSizeBytes, diags := req.Private.GetKey(ctx, "original_state_size")
resp.Diagnostics.Append(diags...)
if originalStateSizeBytes != nil {
originalStateSize, err := strconv.ParseInt(string(originalStateSizeBytes), 10, 64)
if err != nil {
resp.Diagnostics.AddError(
"Unexpected error when reading originalStateSize from Private",
fmt.Sprintf(
"Unexpected error in ParseInt: %s",
err.Error(),
),
)
return
}
if state.Size.ValueInt64() != originalStateSize && plan.Overwrite.ValueBool() {
resp.RequiresReplace = true
resp.PlanValue = types.Int64Value(originalStateSize)
resp.Diagnostics.AddWarning(
"The file size in datastore has changed.",
fmt.Sprintf(
"Previous size %d does not match size from datastore: %d",
originalStateSize,
state.Size.ValueInt64(),
),
)
return
}
}
urlSizeBytes, diags := req.Private.GetKey(ctx, "url_size")
resp.Diagnostics.Append(diags...)
if (urlSizeBytes != nil) && (plan.URL.ValueString() == state.URL.ValueString()) {
urlSize, err := strconv.ParseInt(string(urlSizeBytes), 10, 64)
if err != nil {
resp.Diagnostics.AddError(
"Unexpected error when reading urlSize from Private",
fmt.Sprintf(
"Unexpected error in ParseInt: %s",
err.Error(),
),
)
return
}
if state.Size.ValueInt64() != urlSize {
if urlSize < 0 {
resp.Diagnostics.AddWarning(
"Could not read the file metadata from URL.",
fmt.Sprintf(
"The remote file at URL %q most likely doesnt exist or cant be accessed.\n"+
"To skip the remote file check, set `overwrite` to `false`.",
plan.URL.ValueString(),
),
)
} else {
resp.RequiresReplace = true
resp.PlanValue = types.Int64Value(urlSize)
resp.Diagnostics.AddWarning(
"The file size from url has changed.",
fmt.Sprintf(
"Size %d from url %q does not match size from datastore: %d",
urlSize,
plan.URL.ValueString(),
state.Size.ValueInt64(),
),
)
}
return
}
}
}
func (r sizeRequiresReplaceModifier) Description(_ context.Context) string {
return "Triggers resource force replacement if `size` in state does not match remote value."
}
func (r sizeRequiresReplaceModifier) MarkdownDescription(_ context.Context) string {
return "Triggers resource force replacement if `size` in state does not match remote value."
}
type downloadFileModel struct {
ID types.String `tfsdk:"id"`
Content types.String `tfsdk:"content_type"`
FileName types.String `tfsdk:"file_name"`
Storage types.String `tfsdk:"datastore_id"`
Node types.String `tfsdk:"node_name"`
Size types.Int64 `tfsdk:"size"`
URL types.String `tfsdk:"url"`
Checksum types.String `tfsdk:"checksum"`
DecompressionAlgorithm types.String `tfsdk:"decompression_algorithm"`
UploadTimeout types.Int64 `tfsdk:"upload_timeout"`
ChecksumAlgorithm types.String `tfsdk:"checksum_algorithm"`
Verify types.Bool `tfsdk:"verify"`
Overwrite types.Bool `tfsdk:"overwrite"`
OverwriteUnmanaged types.Bool `tfsdk:"overwrite_unmanaged"`
}
// NewDownloadFileResource manages files downloaded using Proxmox API.
func NewDownloadFileResource() resource.Resource {
return &downloadFileResource{}
}
type downloadFileResource struct {
client proxmox.Client
}
func (r *downloadFileResource) Metadata(
_ context.Context,
req resource.MetadataRequest,
resp *resource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_download_file"
}
// Schema defines the schema for the resource.
func (r *downloadFileResource) Schema(
_ context.Context,
_ resource.SchemaRequest,
resp *resource.SchemaResponse,
) {
resp.Schema = schema.Schema{
Description: "Manages files upload using PVE download-url API. ",
MarkdownDescription: "Manages files upload using PVE download-url API. " +
"It can be fully compatible and faster replacement for image files created using " +
"`proxmox_virtual_environment_file`. Supports images for VMs (ISO images) and LXC (CT Templates).",
Attributes: map[string]schema.Attribute{
"id": attribute.ResourceID(),
"content_type": schema.StringAttribute{
Description: "The file content type. Must be `iso` for VM images or `vztmpl` for LXC images.",
Required: true,
Validators: []validator.String{stringvalidator.OneOf([]string{
"iso",
"vztmpl",
}...)},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"file_name": schema.StringAttribute{
Description: "The file name. If not provided, it is calculated " +
"using `url`. PVE will raise 'wrong file extension' error for some popular " +
"extensions file `.raw` or `.qcow2`. Workaround is to use e.g. `.img` instead.",
Computed: true,
Required: false,
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"datastore_id": schema.StringAttribute{
Description: "The identifier for the target datastore.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"node_name": schema.StringAttribute{
Description: "The node name.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"size": schema.Int64Attribute{
Description: "The file size.",
Optional: false,
Required: false,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
int64planmodifier.RequiresReplace(),
sizeRequiresReplaceModifier{},
},
},
"upload_timeout": schema.Int64Attribute{
Description: "The file download timeout seconds. Default is 600 (10min).",
Optional: true,
Computed: true,
Default: int64default.StaticInt64(600),
},
"url": schema.StringAttribute{
Description: "The URL to download the file from. Must match regex: `" + httpRegex.String() + "`.",
Required: true,
Validators: []validator.String{
stringvalidator.RegexMatches(httpRegex, "must match HTTP URL regex `"+httpRegex.String()+"`"),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"checksum": schema.StringAttribute{
Description: "The expected checksum of the file.",
Optional: true,
Default: nil,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.AlsoRequires(path.MatchRoot("checksum_algorithm")),
},
},
"decompression_algorithm": schema.StringAttribute{
Description: "Decompress the downloaded file using the " +
"specified compression algorithm. Must be one of `gz` | `lzo` | `zst` | `bz2`.",
Optional: true,
Default: nil,
Validators: []validator.String{
stringvalidator.OneOf([]string{
"gz",
"lzo",
"zst",
"bz2",
}...),
},
},
"checksum_algorithm": schema.StringAttribute{
Description: "The algorithm to calculate the checksum of the file. " +
"Must be `md5` | `sha1` | `sha224` | `sha256` | `sha384` | `sha512`.",
Optional: true,
Validators: []validator.String{
stringvalidator.OneOf([]string{
"md5",
"sha1",
"sha224",
"sha256",
"sha384",
"sha512",
}...),
stringvalidator.AlsoRequires(path.MatchRoot("checksum")),
},
Default: nil,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"verify": schema.BoolAttribute{
Description: "By default `true`. If `false`, no SSL/TLS certificates will be verified.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"overwrite": schema.BoolAttribute{
Description: "If `true` and size of uploaded file is different, " +
"than size from `url` Content-Length header, file will be downloaded again. " +
"If `false`, there will be no checks.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"overwrite_unmanaged": schema.BoolAttribute{
Description: "If `true` and a file with the same name already exists in the datastore, " +
"it will be deleted and the new file will be downloaded. If `false` and the file already exists, " +
"an error will be returned.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
},
}
}
func (r *downloadFileResource) Configure(
_ context.Context,
req resource.ConfigureRequest,
resp *resource.ConfigureResponse,
) {
if req.ProviderData == nil {
return
}
cfg, ok := req.ProviderData.(config.Resource)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *proxmox.Client, got: %T", req.ProviderData),
)
return
}
r.client = cfg.Client
}
func (r *downloadFileResource) Create(
ctx context.Context,
req resource.CreateRequest,
resp *resource.CreateResponse,
) {
var plan downloadFileModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
timeout := time.Duration(plan.UploadTimeout.ValueInt64()) * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
fileMetadata, err := r.getURLMetadata(
ctx,
&plan,
)
if err != nil {
resp.Diagnostics.AddError(
"Error initiating file download",
"Could not get file metadata, unexpected error: "+err.Error(),
)
return
}
if plan.FileName.IsUnknown() {
plan.FileName = types.StringValue(*fileMetadata.Filename)
}
nodesClient := r.client.Node(plan.Node.ValueString())
verify := proxmoxtypes.CustomBool(plan.Verify.ValueBool())
downloadFileReq := storage.DownloadURLPostRequestBody{
Node: plan.Node.ValueStringPointer(),
Storage: plan.Storage.ValueStringPointer(),
Content: plan.Content.ValueStringPointer(),
Checksum: plan.Checksum.ValueStringPointer(),
ChecksumAlgorithm: plan.ChecksumAlgorithm.ValueStringPointer(),
Compression: plan.DecompressionAlgorithm.ValueStringPointer(),
FileName: plan.FileName.ValueStringPointer(),
URL: plan.URL.ValueStringPointer(),
Verify: &verify,
}
storageClient := nodesClient.Storage(plan.Storage.ValueString())
err = storageClient.DownloadFileByURL(ctx, &downloadFileReq)
if isErrFileAlreadyExists(err) && plan.OverwriteUnmanaged.ValueBool() {
fileID := plan.Content.ValueString() + "/" + plan.FileName.ValueString()
err = storageClient.DeleteDatastoreFile(ctx, fileID)
if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
resp.Diagnostics.AddError("Error deleting file from datastore",
fmt.Sprintf("Could not delete file '%s', unexpected error: %s", fileID, err.Error()),
)
}
err = storageClient.DownloadFileByURL(ctx, &downloadFileReq)
}
if err != nil {
if isErrFileAlreadyExists(err) {
resp.Diagnostics.AddError(
"File already exists in the datastore, it was created outside of Terraform "+
"or is managed by another resource.",
fmt.Sprintf("File already exists in the datastore: '%s', error: %s",
plan.FileName.ValueString(), err.Error(),
),
)
} else {
resp.Diagnostics.AddError(
"Error downloading file from url",
fmt.Sprintf("Could not download file '%s', unexpected error: %s",
plan.FileName.ValueString(),
err.Error(),
),
)
}
return
}
plan.ID = types.StringValue(plan.Storage.ValueString() + ":" +
plan.Content.ValueString() + "/" + plan.FileName.ValueString())
err = r.read(ctx, &plan)
if err != nil {
resp.Diagnostics.AddError(
"Error when reading file from datastore", err.Error(),
)
}
resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
}
func (r *downloadFileResource) getURLMetadata(
ctx context.Context,
model *downloadFileModel,
) (*nodes.QueryURLMetadataGetResponseData, error) {
nodesClient := r.client.Node(model.Node.ValueString())
verify := proxmoxtypes.CustomBool(model.Verify.ValueBool())
queryURLMetadataReq := nodes.QueryURLMetadataGetRequestBody{
URL: model.URL.ValueString(),
Verify: &verify,
}
fileMetadata, err := nodesClient.GetQueryURLMetadata(
ctx,
&queryURLMetadataReq,
)
if err != nil {
return nil, fmt.Errorf(
"error fetching metadata from download url, "+
"unexpected error in GetQueryURLMetadata: %w",
err,
)
}
return fileMetadata, nil
}
func (r *downloadFileResource) read(
ctx context.Context,
model *downloadFileModel,
) error {
nodesClient := r.client.Node(model.Node.ValueString())
storageClient := nodesClient.Storage(model.Storage.ValueString())
datastoresFiles, err := storageClient.ListDatastoreFiles(ctx)
if err != nil {
return fmt.Errorf("unexpected error when listing datastore files: %w", err)
}
for _, file := range datastoresFiles {
if file != nil {
if file.VolumeID != model.ID.ValueString() {
continue
}
model.Size = types.Int64Value(file.FileSize)
return nil
}
}
return fmt.Errorf("file does not exists in datastore")
}
// Read reads file from datastore.
func (r *downloadFileResource) Read(
ctx context.Context,
req resource.ReadRequest,
resp *resource.ReadResponse,
) {
var state downloadFileModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
setOriginalValue := []byte(strconv.FormatInt(state.Size.ValueInt64(), 10))
resp.Private.SetKey(ctx, "original_state_size", setOriginalValue)
err := r.read(ctx, &state)
if err != nil {
if strings.Contains(err.Error(), "failed to authenticate") {
resp.Diagnostics.AddError("Failed to authenticate", err.Error())
return
}
resp.Diagnostics.AddWarning(
"The file does not exist in datastore and resource must be recreated.",
err.Error(),
)
resp.State.RemoveResource(ctx)
return
}
if state.Overwrite.ValueBool() {
// with overwrite, use url to get proper target size
urlMetadata, err := r.getURLMetadata(
ctx,
&state,
)
if err != nil {
tflog.Error(ctx, "Could not get file metadata from url", map[string]interface{}{
"error": err,
"url": state.URL.ValueString(),
})
// force size to -1, which is a special value used in sizeRequiresReplaceModifier
resp.Private.SetKey(ctx, "url_size", []byte("-1"))
} else if urlMetadata.Size != nil {
setValue := []byte(strconv.FormatInt(*urlMetadata.Size, 10))
resp.Private.SetKey(ctx, "url_size", setValue)
}
}
resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
}
// Update file resource.
func (r *downloadFileResource) Update(
ctx context.Context,
req resource.UpdateRequest,
resp *resource.UpdateResponse,
) {
var plan, state downloadFileModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
err := r.read(ctx, &plan)
if err != nil {
resp.Diagnostics.AddError(
"Error when reading file from datastore", err.Error(),
)
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
// Delete removes file resource.
func (r *downloadFileResource) Delete(
ctx context.Context,
req resource.DeleteRequest,
resp *resource.DeleteResponse,
) {
var state downloadFileModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nodesClient := r.client.Node(state.Node.ValueString())
storageClient := nodesClient.Storage(state.Storage.ValueString())
err := storageClient.DeleteDatastoreFile(
ctx,
state.ID.ValueString(),
)
if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
if strings.Contains(err.Error(), "unable to parse") {
resp.Diagnostics.AddWarning(
"Datastore file does not exists",
fmt.Sprintf(
"Could not delete datastore file '%s', it does not exist or has been deleted outside of Terraform.",
state.ID.ValueString(),
),
)
} else {
resp.Diagnostics.AddError(
"Error deleting datastore file",
fmt.Sprintf("Could not delete datastore file '%s', unexpected error: %s",
state.ID.ValueString(), err.Error()),
)
}
}
}
func isErrFileAlreadyExists(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "refusing to override existing file")
}