@@ -3,6 +3,9 @@ package image
3
3
import (
4
4
"context"
5
5
"fmt"
6
+ "io"
7
+ "os"
8
+ "path/filepath"
6
9
"strings"
7
10
8
11
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
@@ -25,6 +28,7 @@ import (
25
28
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
26
29
incus "github.com/lxc/incus/v6/client"
27
30
"github.com/lxc/incus/v6/shared/api"
31
+ "github.com/lxc/incus/v6/shared/archive"
28
32
29
33
"github.com/lxc/terraform-provider-incus/internal/errors"
30
34
provider_config "github.com/lxc/terraform-provider-incus/internal/provider-config"
@@ -33,6 +37,7 @@ import (
33
37
34
38
// ImageModel resource data model that matches the schema.
35
39
type ImageModel struct {
40
+ SourceFile types.Object `tfsdk:"source_file"`
36
41
SourceImage types.Object `tfsdk:"source_image"`
37
42
SourceInstance types.Object `tfsdk:"source_instance"`
38
43
Aliases types.Set `tfsdk:"aliases"`
@@ -46,6 +51,11 @@ type ImageModel struct {
46
51
CopiedAliases types.Set `tfsdk:"copied_aliases"`
47
52
}
48
53
54
+ type SourceFileModel struct {
55
+ DataPath types.String `tfsdk:"data_path"`
56
+ MetadataPath types.String `tfsdk:"metadata_path"`
57
+ }
58
+
49
59
type SourceImageModel struct {
50
60
Remote types.String `tfsdk:"remote"`
51
61
Name types.String `tfsdk:"name"`
@@ -76,6 +86,30 @@ func (r ImageResource) Metadata(_ context.Context, req resource.MetadataRequest,
76
86
func (r ImageResource ) Schema (_ context.Context , _ resource.SchemaRequest , resp * resource.SchemaResponse ) {
77
87
resp .Schema = schema.Schema {
78
88
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
+
79
113
"source_image" : schema.SingleNestedAttribute {
80
114
Optional : true ,
81
115
Attributes : map [string ]schema.Attribute {
@@ -222,18 +256,10 @@ func (r ImageResource) ValidateConfig(ctx context.Context, req resource.Validate
222
256
return
223
257
}
224
258
225
- if config .SourceImage .IsNull () && config .SourceInstance .IsNull () {
259
+ if ! exactlyOne ( ! config .SourceFile . IsNull (), ! config . SourceImage .IsNull (), ! config .SourceInstance .IsNull () ) {
226
260
resp .Diagnostics .AddError (
227
261
"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." ,
237
263
)
238
264
return
239
265
}
@@ -248,7 +274,10 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r
248
274
return
249
275
}
250
276
251
- if ! plan .SourceImage .IsNull () {
277
+ if ! plan .SourceFile .IsNull () {
278
+ r .createImageFromSourceFile (ctx , resp , & plan )
279
+ return
280
+ } else if ! plan .SourceImage .IsNull () {
252
281
r .createImageFromSourceImage (ctx , resp , & plan )
253
282
return
254
283
} else if ! plan .SourceInstance .IsNull () {
@@ -444,6 +473,144 @@ func (r ImageResource) SyncState(ctx context.Context, tfState *tfsdk.State, serv
444
473
return tfState .Set (ctx , & m )
445
474
}
446
475
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
+
447
614
func (r ImageResource ) createImageFromSourceImage (ctx context.Context , resp * resource.CreateResponse , plan * ImageModel ) {
448
615
var sourceImageModel SourceImageModel
449
616
@@ -728,3 +895,13 @@ func splitImageResourceID(id string) (string, string) {
728
895
pieces := strings .SplitN (id , ":" , 2 )
729
896
return pieces [0 ], pieces [1 ]
730
897
}
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