0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-06-30 18:42:58 +00:00
terraform-provider-proxmox/fwprovider/resource_download_file.go
Rafał Safin 58347c09fe
feat(storage): add new resource proxmox_virtual_environment_download_file (#837)
* feat(core): add query url metadata endpoint to nodes client api

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(core): move storage api code to nodestorage folder

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(core): add download url api

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): add  resource_download_file

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): finish new resource_download_file

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): generate docs for new download file resource, update other docs and examples

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): add basic acc tests for resource download_file

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(test): lint new test file

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): warning instead of error when file already exists on resource download file

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): better validation in resource download file, delete upload task on error

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): better err message in resource download file

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(core): removed unnecessary toint in custom type bool

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(storage): typo in resource download file error

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): download file resource review - 1

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* feat(storage): finish resource download file after review

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(storage): error instead of warnings in parse int errors in sizeRequiresReplaceModifier

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(docs): remove unwanted changes in virtual_environment_file.md

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(test): fix download file base acceptance tests

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(test): fix resource download file acc tests

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix(test): last fix resource download file acc test

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>

* fix: use PVE-compatible jammy LXC image, fix few typos

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>

---------

Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>
Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
Co-authored-by: Rafał Safin <rafal.safin@rafsaf.pl>
Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
2024-01-02 21:29:13 -05:00

610 lines
17 KiB
Go

/*
* 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 fwprovider
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/bpg/terraform-provider-proxmox/fwprovider/structure"
"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/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{}
httpRe = regexp.MustCompile(`https?://.*`)
)
func sizeRequiresReplace() planmodifier.Int64 {
return sizeRequiresReplaceModifier{}
}
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 {
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 {
resp.RequiresReplace = true
resp.PlanValue = types.Int64Value(urlSize)
resp.Diagnostics.AddWarning(
"The file size from url has changed.",
fmt.Sprintf(
"Size from url %d does not match size from datastore: %d",
urlSize,
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"`
}
// NewDownloadFileResource manages files downloaded using proxmomx 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": structure.IDAttribute(),
"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(),
sizeRequiresReplace(),
},
},
"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. Format `https?://.*`.",
Required: true,
Validators: []validator.String{
stringvalidator.RegexMatches(httpRe, "Must match http url regex"),
},
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`.",
Optional: true,
Default: nil,
Validators: []validator.String{
stringvalidator.OneOf([]string{
"gz",
"lzo",
"zst",
}...),
},
},
"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),
},
},
}
}
func (r *downloadFileResource) Configure(
_ context.Context,
req resource.ConfigureRequest,
resp *resource.ConfigureResponse,
) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(proxmox.Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *proxmox.Client, got: %T", req.ProviderData),
)
return
}
r.client = 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
}
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,
plan.UploadTimeout.ValueInt64(),
)
if err != nil {
if strings.Contains(err.Error(), "refusing to override existing file") {
resp.Diagnostics.AddError(
"File already exists in a datastore, it was created outside of Terraform "+
"or is managed by another resource.",
fmt.Sprintf(
"File already exists in a datastore: `%s`, "+
"error: %s",
plan.FileName.ValueString(),
err.Error(),
),
)
} else {
resp.Diagnostics.AddError(
"Error creating Download File interface",
fmt.Sprintf(
"Could not DownloadFileByURL: `%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.ValueStringPointer(),
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 {
resp.Diagnostics.AddError(
"Could not get file metadata from url.",
err.Error(),
)
return
}
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 {
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()),
)
}
}
}