Skip to content

Commit

Permalink
Merge pull request #169 from breml/issue-125
Browse files Browse the repository at this point in the history
Add source_file to incus_image
  • Loading branch information
stgraber authored Dec 17, 2024
2 parents edbcdfc + 1b233c7 commit 28f59a5
Show file tree
Hide file tree
Showing 4 changed files with 385 additions and 13 deletions.
14 changes: 12 additions & 2 deletions docs/resources/image.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
199 changes: 188 additions & 11 deletions internal/image/resource_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package image
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
Expand All @@ -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"
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Loading

0 comments on commit 28f59a5

Please sign in to comment.