Skip to content

Commit 28f59a5

Browse files
authored
Merge pull request #169 from breml/issue-125
Add source_file to incus_image
2 parents edbcdfc + 1b233c7 commit 28f59a5

File tree

4 files changed

+385
-13
lines changed

4 files changed

+385
-13
lines changed

docs/resources/image.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,27 @@ resource "incus_instance" "test1" {
2121

2222
## Argument Reference
2323

24+
* `source_file` - *Optional* - The image file from the local file system from which the image will be created. See reference below.
25+
2426
* `source_image` - *Optional* - The source image from which the image will be created. See reference below.
2527

2628
* `source_instance` - *Optional* - The source instance from which the image will be created. See reference below.
2729

2830
* `aliases` - *Optional* - A list of aliases to assign to the image after
29-
pulling.
31+
pulling.
3032

3133
* `project` - *Optional* - Name of the project where the image will be stored.
3234

3335
* `remote` - *Optional* - The remote in which the resource will be created. If
34-
not provided, the provider's default remote will be used.
36+
not provided, the provider's default remote will be used.
37+
38+
The `source_file` block supports:
39+
40+
* `data_path` - **Required** - Either the path of an [unified image](https://linuxcontainers.org/incus/docs/main/reference/image_format/#image-format-unified)
41+
or the rootfs tarball of a [split image](https://linuxcontainers.org/incus/docs/main/reference/image_format/#image-format-split), depending on
42+
`metadata_path` being provided or not.
43+
44+
* `metadata_path` - *Optional* - Path to the metadata tarball of a [split image](https://linuxcontainers.org/incus/docs/main/reference/image_format/#image-format-split).
3545

3646
The `source_image` block supports:
3747

internal/image/resource_image.go

Lines changed: 188 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package image
33
import (
44
"context"
55
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
69
"strings"
710

811
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
@@ -25,6 +28,7 @@ import (
2528
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
2629
incus "github.com/lxc/incus/v6/client"
2730
"github.com/lxc/incus/v6/shared/api"
31+
"github.com/lxc/incus/v6/shared/archive"
2832

2933
"github.com/lxc/terraform-provider-incus/internal/errors"
3034
provider_config "github.com/lxc/terraform-provider-incus/internal/provider-config"
@@ -33,6 +37,7 @@ import (
3337

3438
// ImageModel resource data model that matches the schema.
3539
type ImageModel struct {
40+
SourceFile types.Object `tfsdk:"source_file"`
3641
SourceImage types.Object `tfsdk:"source_image"`
3742
SourceInstance types.Object `tfsdk:"source_instance"`
3843
Aliases types.Set `tfsdk:"aliases"`
@@ -46,6 +51,11 @@ type ImageModel struct {
4651
CopiedAliases types.Set `tfsdk:"copied_aliases"`
4752
}
4853

54+
type SourceFileModel struct {
55+
DataPath types.String `tfsdk:"data_path"`
56+
MetadataPath types.String `tfsdk:"metadata_path"`
57+
}
58+
4959
type SourceImageModel struct {
5060
Remote types.String `tfsdk:"remote"`
5161
Name types.String `tfsdk:"name"`
@@ -76,6 +86,30 @@ func (r ImageResource) Metadata(_ context.Context, req resource.MetadataRequest,
7686
func (r ImageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
7787
resp.Schema = schema.Schema{
7888
Attributes: map[string]schema.Attribute{
89+
"source_file": schema.SingleNestedAttribute{
90+
Optional: true,
91+
Attributes: map[string]schema.Attribute{
92+
"data_path": schema.StringAttribute{
93+
Required: true,
94+
PlanModifiers: []planmodifier.String{
95+
stringplanmodifier.RequiresReplace(),
96+
},
97+
Validators: []validator.String{
98+
stringvalidator.LengthAtLeast(1),
99+
},
100+
},
101+
"metadata_path": schema.StringAttribute{
102+
Optional: true,
103+
PlanModifiers: []planmodifier.String{
104+
stringplanmodifier.RequiresReplace(),
105+
},
106+
Validators: []validator.String{
107+
stringvalidator.LengthAtLeast(1),
108+
},
109+
},
110+
},
111+
},
112+
79113
"source_image": schema.SingleNestedAttribute{
80114
Optional: true,
81115
Attributes: map[string]schema.Attribute{
@@ -222,18 +256,10 @@ func (r ImageResource) ValidateConfig(ctx context.Context, req resource.Validate
222256
return
223257
}
224258

225-
if config.SourceImage.IsNull() && config.SourceInstance.IsNull() {
259+
if !exactlyOne(!config.SourceFile.IsNull(), !config.SourceImage.IsNull(), !config.SourceInstance.IsNull()) {
226260
resp.Diagnostics.AddError(
227261
"Invalid Configuration",
228-
"Either source_image or source_instance must be set.",
229-
)
230-
return
231-
}
232-
233-
if !config.SourceImage.IsNull() && !config.SourceInstance.IsNull() {
234-
resp.Diagnostics.AddError(
235-
"Invalid Configuration",
236-
"Only source_image or source_instance can be set.",
262+
"Exactly one of source_file, source_image or source_instance must be set.",
237263
)
238264
return
239265
}
@@ -248,7 +274,10 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r
248274
return
249275
}
250276

251-
if !plan.SourceImage.IsNull() {
277+
if !plan.SourceFile.IsNull() {
278+
r.createImageFromSourceFile(ctx, resp, &plan)
279+
return
280+
} else if !plan.SourceImage.IsNull() {
252281
r.createImageFromSourceImage(ctx, resp, &plan)
253282
return
254283
} else if !plan.SourceInstance.IsNull() {
@@ -444,6 +473,144 @@ func (r ImageResource) SyncState(ctx context.Context, tfState *tfsdk.State, serv
444473
return tfState.Set(ctx, &m)
445474
}
446475

476+
func (r ImageResource) createImageFromSourceFile(ctx context.Context, resp *resource.CreateResponse, plan *ImageModel) {
477+
var sourceFileModel SourceFileModel
478+
479+
diags := plan.SourceFile.As(ctx, &sourceFileModel, basetypes.ObjectAsOptions{})
480+
if diags.HasError() {
481+
resp.Diagnostics.Append(diags...)
482+
return
483+
}
484+
485+
remote := plan.Remote.ValueString()
486+
project := plan.Project.ValueString()
487+
server, err := r.provider.InstanceServer(remote, project, "")
488+
if err != nil {
489+
resp.Diagnostics.Append(errors.NewInstanceServerError(err))
490+
return
491+
}
492+
493+
var dataPath, metadataPath string
494+
if sourceFileModel.MetadataPath.IsNull() {
495+
// Unified image
496+
metadataPath = sourceFileModel.DataPath.ValueString()
497+
} else {
498+
// Split image
499+
dataPath = sourceFileModel.DataPath.ValueString()
500+
metadataPath = sourceFileModel.MetadataPath.ValueString()
501+
}
502+
503+
var image api.ImagesPost
504+
var createArgs *incus.ImageCreateArgs
505+
506+
imageType := "container"
507+
if strings.HasPrefix(dataPath, "https://") {
508+
image.Source = &api.ImagesPostSource{}
509+
image.Source.Type = "url"
510+
image.Source.Mode = "pull"
511+
image.Source.Protocol = "direct"
512+
image.Source.URL = dataPath
513+
createArgs = nil
514+
} else {
515+
var meta io.ReadCloser
516+
var rootfs io.ReadCloser
517+
518+
meta, err = os.Open(metadataPath)
519+
if err != nil {
520+
resp.Diagnostics.AddError(fmt.Sprintf("Failed to open metadata_path: %s", metadataPath), err.Error())
521+
return
522+
}
523+
524+
defer func() { _ = meta.Close() }()
525+
526+
// Open rootfs
527+
if dataPath != "" {
528+
rootfs, err = os.Open(dataPath)
529+
if err != nil {
530+
resp.Diagnostics.AddError(fmt.Sprintf("failed to open data_path: %s", dataPath), err.Error())
531+
return
532+
}
533+
534+
defer func() { _ = rootfs.Close() }()
535+
536+
_, ext, _, err := archive.DetectCompressionFile(rootfs)
537+
if err != nil {
538+
resp.Diagnostics.AddError(fmt.Sprintf("Failed to detect compression of rootfs in data_path: %s", dataPath), err.Error())
539+
return
540+
}
541+
542+
_, err = rootfs.(*os.File).Seek(0, io.SeekStart)
543+
if err != nil {
544+
resp.Diagnostics.AddError(fmt.Sprintf("Failed to seek start for rootfas in data_path: %s", dataPath), err.Error())
545+
return
546+
}
547+
548+
if ext == ".qcow2" {
549+
imageType = "virtual-machine"
550+
}
551+
}
552+
553+
createArgs = &incus.ImageCreateArgs{
554+
MetaFile: meta,
555+
MetaName: filepath.Base(metadataPath),
556+
RootfsFile: rootfs,
557+
RootfsName: filepath.Base(dataPath),
558+
Type: imageType,
559+
}
560+
561+
image.Filename = createArgs.MetaName
562+
}
563+
564+
aliases, diags := ToAliasList(ctx, plan.Aliases)
565+
if diags.HasError() {
566+
resp.Diagnostics.Append(diags...)
567+
return
568+
}
569+
570+
imageAliases := make([]api.ImageAlias, 0, len(aliases))
571+
for _, alias := range aliases {
572+
// Ensure image alias does not already exist.
573+
aliasTarget, _, _ := server.GetImageAlias(alias)
574+
if aliasTarget != nil {
575+
resp.Diagnostics.AddError(fmt.Sprintf("Image alias %q already exists", alias), "")
576+
return
577+
}
578+
579+
ia := api.ImageAlias{
580+
Name: alias,
581+
}
582+
583+
imageAliases = append(imageAliases, ia)
584+
}
585+
image.Aliases = imageAliases
586+
587+
op, err := server.CreateImage(image, createArgs)
588+
if err != nil {
589+
resp.Diagnostics.AddError(fmt.Sprintf("Failed to create image from file %q", dataPath), err.Error())
590+
return
591+
}
592+
593+
// Wait for image create operation to finish.
594+
err = op.Wait()
595+
if err != nil {
596+
resp.Diagnostics.AddError(fmt.Sprintf("Failed to create image from file %q", dataPath), err.Error())
597+
return
598+
}
599+
600+
fingerprint, ok := op.Get().Metadata["fingerprint"].(string)
601+
if !ok {
602+
resp.Diagnostics.AddError("Failed to get fingerprint of created image", "no fingerprint returned in metadata")
603+
return
604+
}
605+
imageID := createImageResourceID(remote, fingerprint)
606+
plan.ResourceID = types.StringValue(imageID)
607+
608+
plan.CopiedAliases = basetypes.NewSetNull(basetypes.StringType{})
609+
610+
diags = r.SyncState(ctx, &resp.State, server, *plan)
611+
resp.Diagnostics.Append(diags...)
612+
}
613+
447614
func (r ImageResource) createImageFromSourceImage(ctx context.Context, resp *resource.CreateResponse, plan *ImageModel) {
448615
var sourceImageModel SourceImageModel
449616

@@ -728,3 +895,13 @@ func splitImageResourceID(id string) (string, string) {
728895
pieces := strings.SplitN(id, ":", 2)
729896
return pieces[0], pieces[1]
730897
}
898+
899+
func exactlyOne(in ...bool) bool {
900+
var count int
901+
for _, b := range in {
902+
if b {
903+
count++
904+
}
905+
}
906+
return count == 1
907+
}

0 commit comments

Comments
 (0)