diff --git a/docs/resources/image.md b/docs/resources/image.md index b44e75d..a45850d 100644 --- a/docs/resources/image.md +++ b/docs/resources/image.md @@ -21,17 +21,27 @@ resource "incus_instance" "test1" { ## Argument Reference +* `source_file` - *Optional* - The image file from the local file system from which the image will be created. See reference below. + * `source_image` - *Optional* - The source image from which the image will be created. See reference below. * `source_instance` - *Optional* - The source instance from which the image will be created. See reference below. * `aliases` - *Optional* - A list of aliases to assign to the image after - pulling. + pulling. * `project` - *Optional* - Name of the project where the image will be stored. * `remote` - *Optional* - The remote in which the resource will be created. If - not provided, the provider's default remote will be used. + not provided, the provider's default remote will be used. + +The `source_file` block supports: + +* `data_path` - **Required** - Either the path of an [unified image](https://linuxcontainers.org/incus/docs/main/reference/image_format/#image-format-unified) + or the rootfs tarball of a [split image](https://linuxcontainers.org/incus/docs/main/reference/image_format/#image-format-split), depending on + `metadata_path` being provided or not. + +* `metadata_path` - *Optional* - Path to the metadata tarball of a [split image](https://linuxcontainers.org/incus/docs/main/reference/image_format/#image-format-split). The `source_image` block supports: diff --git a/internal/image/resource_image.go b/internal/image/resource_image.go index 282ab6b..50040b4 100644 --- a/internal/image/resource_image.go +++ b/internal/image/resource_image.go @@ -3,6 +3,9 @@ package image import ( "context" "fmt" + "io" + "os" + "path/filepath" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" @@ -25,6 +28,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" incus "github.com/lxc/incus/v6/client" "github.com/lxc/incus/v6/shared/api" + "github.com/lxc/incus/v6/shared/archive" "github.com/lxc/terraform-provider-incus/internal/errors" provider_config "github.com/lxc/terraform-provider-incus/internal/provider-config" @@ -33,6 +37,7 @@ import ( // ImageModel resource data model that matches the schema. type ImageModel struct { + SourceFile types.Object `tfsdk:"source_file"` SourceImage types.Object `tfsdk:"source_image"` SourceInstance types.Object `tfsdk:"source_instance"` Aliases types.Set `tfsdk:"aliases"` @@ -46,6 +51,11 @@ type ImageModel struct { CopiedAliases types.Set `tfsdk:"copied_aliases"` } +type SourceFileModel struct { + DataPath types.String `tfsdk:"data_path"` + MetadataPath types.String `tfsdk:"metadata_path"` +} + type SourceImageModel struct { Remote types.String `tfsdk:"remote"` Name types.String `tfsdk:"name"` @@ -76,6 +86,30 @@ func (r ImageResource) Metadata(_ context.Context, req resource.MetadataRequest, func (r ImageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ + "source_file": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "data_path": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "metadata_path": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + }, + }, + "source_image": schema.SingleNestedAttribute{ Optional: true, Attributes: map[string]schema.Attribute{ @@ -222,18 +256,10 @@ func (r ImageResource) ValidateConfig(ctx context.Context, req resource.Validate return } - if config.SourceImage.IsNull() && config.SourceInstance.IsNull() { + if !exactlyOne(!config.SourceFile.IsNull(), !config.SourceImage.IsNull(), !config.SourceInstance.IsNull()) { resp.Diagnostics.AddError( "Invalid Configuration", - "Either source_image or source_instance must be set.", - ) - return - } - - if !config.SourceImage.IsNull() && !config.SourceInstance.IsNull() { - resp.Diagnostics.AddError( - "Invalid Configuration", - "Only source_image or source_instance can be set.", + "Exactly one of source_file, source_image or source_instance must be set.", ) return } @@ -248,7 +274,10 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r return } - if !plan.SourceImage.IsNull() { + if !plan.SourceFile.IsNull() { + r.createImageFromSourceFile(ctx, resp, &plan) + return + } else if !plan.SourceImage.IsNull() { r.createImageFromSourceImage(ctx, resp, &plan) return } else if !plan.SourceInstance.IsNull() { @@ -444,6 +473,144 @@ func (r ImageResource) SyncState(ctx context.Context, tfState *tfsdk.State, serv return tfState.Set(ctx, &m) } +func (r ImageResource) createImageFromSourceFile(ctx context.Context, resp *resource.CreateResponse, plan *ImageModel) { + var sourceFileModel SourceFileModel + + diags := plan.SourceFile.As(ctx, &sourceFileModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + remote := plan.Remote.ValueString() + project := plan.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + var dataPath, metadataPath string + if sourceFileModel.MetadataPath.IsNull() { + // Unified image + metadataPath = sourceFileModel.DataPath.ValueString() + } else { + // Split image + dataPath = sourceFileModel.DataPath.ValueString() + metadataPath = sourceFileModel.MetadataPath.ValueString() + } + + var image api.ImagesPost + var createArgs *incus.ImageCreateArgs + + imageType := "container" + if strings.HasPrefix(dataPath, "https://") { + image.Source = &api.ImagesPostSource{} + image.Source.Type = "url" + image.Source.Mode = "pull" + image.Source.Protocol = "direct" + image.Source.URL = dataPath + createArgs = nil + } else { + var meta io.ReadCloser + var rootfs io.ReadCloser + + meta, err = os.Open(metadataPath) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to open metadata_path: %s", metadataPath), err.Error()) + return + } + + defer func() { _ = meta.Close() }() + + // Open rootfs + if dataPath != "" { + rootfs, err = os.Open(dataPath) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("failed to open data_path: %s", dataPath), err.Error()) + return + } + + defer func() { _ = rootfs.Close() }() + + _, ext, _, err := archive.DetectCompressionFile(rootfs) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to detect compression of rootfs in data_path: %s", dataPath), err.Error()) + return + } + + _, err = rootfs.(*os.File).Seek(0, io.SeekStart) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to seek start for rootfas in data_path: %s", dataPath), err.Error()) + return + } + + if ext == ".qcow2" { + imageType = "virtual-machine" + } + } + + createArgs = &incus.ImageCreateArgs{ + MetaFile: meta, + MetaName: filepath.Base(metadataPath), + RootfsFile: rootfs, + RootfsName: filepath.Base(dataPath), + Type: imageType, + } + + image.Filename = createArgs.MetaName + } + + aliases, diags := ToAliasList(ctx, plan.Aliases) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + imageAliases := make([]api.ImageAlias, 0, len(aliases)) + for _, alias := range aliases { + // Ensure image alias does not already exist. + aliasTarget, _, _ := server.GetImageAlias(alias) + if aliasTarget != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Image alias %q already exists", alias), "") + return + } + + ia := api.ImageAlias{ + Name: alias, + } + + imageAliases = append(imageAliases, ia) + } + image.Aliases = imageAliases + + op, err := server.CreateImage(image, createArgs) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to create image from file %q", dataPath), err.Error()) + return + } + + // Wait for image create operation to finish. + err = op.Wait() + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to create image from file %q", dataPath), err.Error()) + return + } + + fingerprint, ok := op.Get().Metadata["fingerprint"].(string) + if !ok { + resp.Diagnostics.AddError("Failed to get fingerprint of created image", "no fingerprint returned in metadata") + return + } + imageID := createImageResourceID(remote, fingerprint) + plan.ResourceID = types.StringValue(imageID) + + plan.CopiedAliases = basetypes.NewSetNull(basetypes.StringType{}) + + diags = r.SyncState(ctx, &resp.State, server, *plan) + resp.Diagnostics.Append(diags...) +} + func (r ImageResource) createImageFromSourceImage(ctx context.Context, resp *resource.CreateResponse, plan *ImageModel) { var sourceImageModel SourceImageModel @@ -728,3 +895,13 @@ func splitImageResourceID(id string) (string, string) { pieces := strings.SplitN(id, ":", 2) return pieces[0], pieces[1] } + +func exactlyOne(in ...bool) bool { + var count int + for _, b := range in { + if b { + count++ + } + } + return count == 1 +} diff --git a/internal/image/resource_image_test.go b/internal/image/resource_image_test.go index 70eceb5..5ec2d39 100644 --- a/internal/image/resource_image_test.go +++ b/internal/image/resource_image_test.go @@ -2,6 +2,7 @@ package image_test import ( "fmt" + "path/filepath" "regexp" "strings" "testing" @@ -324,6 +325,99 @@ func TestAccImage_sourceInstanceWithSnapshot(t *testing.T) { }) } +func TestAccImage_sourceFileSplitImage(t *testing.T) { + tmpDir := t.TempDir() + targetMetadata := filepath.Join(tmpDir, `alpine-edge.img`) + targetData := targetMetadata + ".root" + + alias1 := petname.Generate(2, "-") + alias2 := petname.Generate(2, "-") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckAPIExtensions(t, "image_create_aliases") + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + ExternalProviders: map[string]resource.ExternalProvider{ + "null": { + Source: "null", + VersionConstraint: ">= 3.0.0", + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccSourceFileSplitImage_exportImage(targetMetadata), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("incus_image.img1", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img1", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img1", "source_image.copy_aliases", "true"), + resource.TestCheckResourceAttr("incus_image.img1", "copied_aliases.#", "4"), + resource.TestCheckResourceAttrSet("null_resource.export_img1", "id"), + ), + }, + { + Config: `#`, // Empty config to remove image. Comment is required, since empty string is seen as zero value. + }, + { + Config: testAccSourceFileSplitImage_fromFile(targetData, targetMetadata, alias1, alias2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("incus_image.from_file", "source_file.data_path", targetData), + resource.TestCheckResourceAttr("incus_image.from_file", "source_file.metadata_path", targetMetadata), + resource.TestCheckResourceAttr("incus_image.from_file", "aliases.#", "2"), + resource.TestCheckTypeSetElemAttr("incus_image.from_file", "aliases.*", alias1), + resource.TestCheckTypeSetElemAttr("incus_image.from_file", "aliases.*", alias2), + resource.TestCheckResourceAttr("incus_image.from_file", "copied_aliases.#", "0"), + ), + }, + }, + }) +} + +func TestAccImage_sourceFileUnifiedImage(t *testing.T) { + name := petname.Generate(2, "-") + tmpDir := t.TempDir() + targetData := filepath.Join(tmpDir, name) + + alias1 := petname.Generate(2, "-") + alias2 := petname.Generate(2, "-") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + ExternalProviders: map[string]resource.ExternalProvider{ + "null": { + Source: "null", + VersionConstraint: ">= 3.0.0", + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccSourceFileUnifiedImage_exportImage(name, targetData), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("incus_instance.instance1", "name", name), + resource.TestCheckResourceAttr("incus_instance.instance1", "image", "images:alpine/edge"), + resource.TestCheckResourceAttr("incus_instance.instance1", "type", "container"), + resource.TestCheckResourceAttr("incus_instance.instance1", "status", "Stopped"), + resource.TestCheckResourceAttrSet("null_resource.publish_instance1", "id"), + resource.TestCheckResourceAttrSet("null_resource.export_instance1_image", "id"), + resource.TestCheckResourceAttrSet("null_resource.delete_instance1_image", "id"), + ), + }, + { + Config: testAccSourceFileUnifiedImage_fromFile(targetData, alias1, alias2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("incus_image.from_file", "source_file.data_path", targetData+".tar.gz"), + resource.TestCheckResourceAttr("incus_image.from_file", "aliases.#", "2"), + resource.TestCheckTypeSetElemAttr("incus_image.from_file", "aliases.*", alias1), + resource.TestCheckTypeSetElemAttr("incus_image.from_file", "aliases.*", alias2), + resource.TestCheckResourceAttr("incus_image.from_file", "copied_aliases.#", "0"), + ), + }, + }, + }) +} + func testAccImage_basic() string { return ` resource "incus_image" "img1" { @@ -572,3 +666,83 @@ resource "incus_image" "img1" { } `, projectName, instanceName, acctest.TestImage) } + +func testAccSourceFileSplitImage_exportImage(target string) string { + return fmt.Sprintf(` +resource "incus_image" "img1" { + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = true + } +} + +resource "null_resource" "export_img1" { + provisioner "local-exec" { + command = "incus image export ${incus_image.img1.fingerprint} %[1]s" + } +} +`, target) +} + +func testAccSourceFileSplitImage_fromFile(targetData, targetMetadata string, aliases ...string) string { + return fmt.Sprintf(` +resource "incus_image" "from_file" { + source_file = { + data_path = "%[1]s" + metadata_path = "%[2]s" + } + aliases = ["%[3]s"] +} +`, targetData, targetMetadata, strings.Join(aliases, `","`)) +} + +func testAccSourceFileUnifiedImage_exportImage(name, targetData string) string { + return fmt.Sprintf(` +resource "incus_instance" "instance1" { + name = "%[1]s" + image = "images:alpine/edge" + type = "container" + running = false +} + +resource "null_resource" "publish_instance1" { + depends_on = [ + incus_instance.instance1 + ] + provisioner "local-exec" { + command = "incus publish --alias %[1]s %[1]s" + } +} + +resource "null_resource" "export_instance1_image" { + depends_on = [ + null_resource.publish_instance1 + ] + provisioner "local-exec" { + command = "incus image export %[1]s %[2]s" + } +} + +resource "null_resource" "delete_instance1_image" { + depends_on = [ + null_resource.export_instance1_image + ] + provisioner "local-exec" { + command = "incus image delete %[1]s" + } +} +`, name, targetData) +} + +func testAccSourceFileUnifiedImage_fromFile(targetData string, aliases ...string) string { + return fmt.Sprintf(` +resource "incus_image" "from_file" { + source_file = { + data_path = "%[1]s.tar.gz" + } + aliases = ["%[2]s"] +} + +`, targetData, strings.Join(aliases, `","`)) +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 0346098..8add59d 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "sort" "strings" "time" @@ -111,3 +112,13 @@ func ToPrettyJSON(v any) string { bytes, _ := json.MarshalIndent(v, "", " ") return string(bytes) } + +// IsDir returns true if the given path is a directory. +func IsDir(name string) bool { + stat, err := os.Stat(name) + if err != nil { + return false + } + + return stat.IsDir() +}