Skip to content

Add source_file to incus_image #169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading