From fe05afd4b940575a25e7290ff57245ec097c0ef9 Mon Sep 17 00:00:00 2001 From: Lucas Bremgartner Date: Thu, 21 Nov 2024 17:18:26 +0100 Subject: [PATCH] config: Add source_file to incus_instance Signed-off-by: Lucas Bremgartner --- internal/instance/resource_instance.go | 160 ++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/internal/instance/resource_instance.go b/internal/instance/resource_instance.go index fccde71..de0b40f 100644 --- a/internal/instance/resource_instance.go +++ b/internal/instance/resource_instance.go @@ -3,6 +3,7 @@ package instance import ( "context" "fmt" + "os" "sort" "strings" "time" @@ -29,6 +30,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" incus "github.com/lxc/incus/v6/client" "github.com/lxc/incus/v6/shared/api" + "github.com/mitchellh/go-homedir" "github.com/lxc/terraform-provider-incus/internal/common" "github.com/lxc/terraform-provider-incus/internal/errors" @@ -52,6 +54,7 @@ type InstanceModel struct { Remote types.String `tfsdk:"remote"` Target types.String `tfsdk:"target"` SourceInstance types.Object `tfsdk:"source_instance"` + SourceFile types.String `tfsdk:"source_file"` // Computed. IPv4 types.String `tfsdk:"ipv4_address"` @@ -204,6 +207,16 @@ func (r InstanceResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, + "source_file": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + // Computed. "ipv4_address": schema.StringAttribute{ @@ -370,13 +383,76 @@ func (r InstanceResource) ValidateConfig(ctx context.Context, req resource.Valid ) } - if !config.Image.IsNull() && !config.SourceInstance.IsNull() { + if !atMostOneOf(config.Image, config.SourceFile, config.SourceInstance) { resp.Diagnostics.AddError( "Invalid Configuration", - "Only image or source_instance can be set.", + "At most one of image, source_file and source_instance can be set.", ) return } + + if !config.SourceFile.IsNull() { + // Instances from source_file are mutually exclusive with a series of other attributes. + if !config.Description.IsNull() || + !config.Type.IsNull() || + !config.Ephemeral.IsNull() || + !config.Profiles.IsNull() || + !config.Files.IsNull() || + !config.Config.IsNull() { + resp.Diagnostics.AddError( + "Invalid Configuration", + "Attribute source_file is mutually exclusive with description, type, ephemeral, profiles, file and config.", + ) + return + } + + // With `incus import`, a storage pool can be provided optionally. + // In order to support the same behavior with source_file, + // a single device entry of type `disk` is allowed with exactly two properties + // `path` and `pool` being set. For `path`, the only accepted value is `/`. + if len(config.Devices.Elements()) > 0 { + if len(config.Devices.Elements()) > 1 { + resp.Diagnostics.AddError( + "Invalid Configuration", + "Only one device entry is supported with source_file.", + ) + return + } + + deviceList := make([]common.DeviceModel, 0, 1) + diags = config.Devices.ElementsAs(ctx, &deviceList, false) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + if len(deviceList[0].Properties.Elements()) != 2 { + resp.Diagnostics.AddError( + "Invalid Configuration", + `Exactly two device properties named "path" and "pool" need to be provided with source_file.`, + ) + return + } + + properties := make(map[string]string, 2) + diags = deviceList[0].Properties.ElementsAs(ctx, &properties, false) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + _, poolOK := properties["pool"] + path, pathOK := properties["path"] + + if !poolOK || !pathOK || path != "/" { + resp.Diagnostics.AddError( + "Invalid Configuration", + `Exactly two device properties named "path" and "pool" need to be provided with source_file. For "path", the only accepted value is "/".`, + ) + return + } + } + } } func (r InstanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { @@ -400,6 +476,9 @@ func (r InstanceResource) Create(ctx context.Context, req resource.CreateRequest if !plan.Image.IsNull() { diags = r.createInstanceFromImage(ctx, server, plan) resp.Diagnostics.Append(diags...) + } else if !plan.SourceFile.IsNull() { + diags = r.createInstanceFromSourceFile(ctx, server, plan) + resp.Diagnostics.Append(diags...) } else if !plan.SourceInstance.IsNull() { diags = r.createInstanceFromSourceInstance(ctx, server, plan) resp.Diagnostics.Append(diags...) @@ -794,6 +873,13 @@ func (r InstanceResource) SyncState(ctx context.Context, tfState *tfsdk.State, s return respDiags } + if !m.SourceFile.IsNull() && !m.Devices.IsNull() { + // Using device to signal the storage pool is a special case, which is not + // reflected on the instance state and therefore we need to compensate here + // in order to prevent inconsistent provider results. + devices = m.Devices + } + m.Name = types.StringValue(instance.Name) m.Type = types.StringValue(instance.Type) m.Description = types.StringValue(instance.Description) @@ -888,6 +974,66 @@ func (r InstanceResource) createInstanceFromImage(ctx context.Context, server in return diags } +func (r InstanceResource) createInstanceFromSourceFile(ctx context.Context, server incus.InstanceServer, plan InstanceModel) diag.Diagnostics { + var diags diag.Diagnostics + + name := plan.Name.ValueString() + + var poolName string + + if len(plan.Devices.Elements()) > 0 { + // Only one device is expected, this is ensured by ValidateConfig. + deviceList := make([]common.DeviceModel, 0, 1) + diags = plan.Devices.ElementsAs(ctx, &deviceList, false) + if diags.HasError() { + return diags + } + + // Exactly two properties named "path" and "pool" are expected, this is ensured by ValidateConfig. + properties := make(map[string]string, 2) + diags = deviceList[0].Properties.ElementsAs(ctx, &properties, false) + if diags.HasError() { + return diags + } + + poolName = properties["pool"] + } + + srcFile := plan.SourceFile.ValueString() + + path, err := homedir.Expand(srcFile) + if err != nil { + diags.AddError(fmt.Sprintf("Failed to determine source_file: %q", srcFile), err.Error()) + return diags + } + + file, err := os.Open(path) + if err != nil { + diags.AddError(fmt.Sprintf("Failed to open source_file: %q", path), err.Error()) + return diags + } + + defer func() { _ = file.Close() }() + + createArgs := incus.InstanceBackupArgs{ + BackupFile: file, + PoolName: poolName, + Name: name, + } + + op, err := server.CreateInstanceFromBackup(createArgs) + if err == nil { + err = op.Wait() + } + + if err != nil { + diags.AddError(fmt.Sprintf("Failed to create instance: %q", name), err.Error()) + return diags + } + + return diags +} + func (r InstanceResource) createInstanceFromSourceInstance(ctx context.Context, destServer incus.InstanceServer, plan InstanceModel) diag.Diagnostics { var diags diag.Diagnostics var sourceInstanceModel SourceInstanceModel @@ -1426,3 +1572,13 @@ func getAddresses(name string, entry api.InstanceStateNetwork) (string, string, return ipv4, ipv6, entry.Hwaddr, name, true } + +func atMostOneOf(in ...interface{ IsNull() bool }) bool { + var count int + for _, v := range in { + if !v.IsNull() { + count++ + } + } + return count <= 1 +}