diff --git a/docs/resources/image.md b/docs/resources/image.md index f0990ac..cf321d4 100644 --- a/docs/resources/image.md +++ b/docs/resources/image.md @@ -5,40 +5,53 @@ Manages a locally-stored Incus image. ## Example Usage ```hcl -resource "incus_image" "xenial" { - source_remote = "ubuntu" - source_image = "xenial/amd64" +resource "incus_image" "alpine" { + source_image = { + remote = "images" + name = "alpine/edge" + } } resource "incus_instance" "test1" { name = "test1" - image = incus_image.xenial.fingerprint + image = incus_image.alpine.fingerprint ephemeral = false } ``` ## Argument Reference -* `source_image` - **Required** - Fingerprint or alias of image to pull. +* `source_instance` - *Optional* - The source image from which the image will be created. See reference below. -* `source_remote` - **Required** - Name of the Incus remote from where image will - be pulled. - -* `type` - *Optional* - Type of image to cache. Must be one of `container` or - `virtual-machine`. Defaults to `container`. +* `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. -* `copy_aliases` - *Optional* - Whether to copy the aliases of the image from - the remote. Valid values are `true` and `false`. Defaults to `true`. - * `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. -* `architecture` - The image architecture (e.g. x86_64, aarch64). See [Architectures](https://linuxcontainers.org/incus/docs/main/architectures/) for all possible values. +The `source_image` block supports: + +* `remote` - **Required** - The remote where the image will be pulled from. + +* `name` - **Required** - Name of the image. + +* `type` - *Optional* - Type of image to cache. Must be one of `container` or + `virtual-machine`. Defaults to `container`. + +* `architecture` - *Optional* - The image architecture (e.g. x86_64, aarch64). See [Architectures](https://linuxcontainers.org/incus/docs/main/architectures/) for all possible values. + +* `copy_aliases` - *Optional* - Whether to copy the aliases of the image from + the remote. Valid values are `true` and `false`. Defaults to `true`. + +The `source_instance` block supports: + +* `name` - **Required** - Name of the source instance. + +* `snapshot`- *Optional* - Name of the snapshot of the source instance ## Attribute Reference diff --git a/docs/resources/image_publish.md b/docs/resources/image_publish.md deleted file mode 100644 index 9a9e2f7..0000000 --- a/docs/resources/image_publish.md +++ /dev/null @@ -1,60 +0,0 @@ -# incus_image_publish - -Create an Incus image from an instance - -## Example Usage - -```hcl -resource "incus_image" "xenial" { - source_remote = "ubuntu" - source_image = "xenial/amd64" -} - -resource "incus_instance" "test1" { - name = "test1" - image = incus_image.xenial.fingerprint - running = false -} - -resource "incus_image_publish" "test1" { - instance = incus_instance.test1 - aliases = ["test1_img"] -} -``` - -## Argument Reference - -* `instance` - **Required** - The name of the instance. - -* `aliases` - *Optional* - A list of aliases to assign to the image. - -* `properties` - *Optional* - A map of properties to assign to the image. - -* `public` - *Optional* - Whether the image can be downloaded by untrusted users. - Valid values are `true` and `false`. Defaults to `false`. - -* `filename` - *Optional* - Used for export. - -* `compression_algorithm` - *Optional* - Override the compression algorithm for the image. - Valid values are (`bzip2`, `gzip`, `lzma`, `xz` or `none`). Defaults to `gzip`. - -* `triggers` - *Optional* - A list of arbitrary strings that, when changed, will force the resource to be replaced. - -* `project` - *Optional* - Name of the project where the published 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. - -## Attribute Reference - -The following attributes are exported: - -* `fingerprint` - The fingerprint of the published image. - -* `architecture` - The architecture of the published image. - -* `created_at` - The creation timestamp of the published image. - -## Notes - -* Image can only be published if the instance is stopped. diff --git a/internal/image/resource_image.go b/internal/image/resource_image.go index 69b046e..c65c413 100644 --- a/internal/image/resource_image.go +++ b/internal/image/resource_image.go @@ -7,12 +7,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" @@ -20,6 +22,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/lxc/incus/v6/client" "github.com/lxc/incus/v6/shared/api" @@ -30,22 +33,32 @@ import ( // ImageModel resource data model that matches the schema. type ImageModel struct { - SourceImage types.String `tfsdk:"source_image"` - SourceRemote types.String `tfsdk:"source_remote"` - Aliases types.Set `tfsdk:"aliases"` - CopyAliases types.Bool `tfsdk:"copy_aliases"` - Type types.String `tfsdk:"type"` - Project types.String `tfsdk:"project"` - Remote types.String `tfsdk:"remote"` + SourceImage types.Object `tfsdk:"source_image"` + SourceInstance types.Object `tfsdk:"source_instance"` + Aliases types.Set `tfsdk:"aliases"` + Project types.String `tfsdk:"project"` + Remote types.String `tfsdk:"remote"` // Computed. ResourceID types.String `tfsdk:"resource_id"` - Architecture types.String `tfsdk:"architecture"` CreatedAt types.Int64 `tfsdk:"created_at"` Fingerprint types.String `tfsdk:"fingerprint"` CopiedAliases types.Set `tfsdk:"copied_aliases"` } +type SourceImageModel struct { + Remote types.String `tfsdk:"remote"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + Architecture types.String `tfsdk:"architecture"` + CopyAliases types.Bool `tfsdk:"copy_aliases"` +} + +type SourceInstanceModel struct { + Name types.String `tfsdk:"name"` + Snapshot types.String `tfsdk:"snapshot"` +} + // ImageResource represent Incus cached image resource. type ImageResource struct { provider *provider_config.IncusProviderConfig @@ -63,17 +76,61 @@ 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_image": schema.StringAttribute{ - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), + "source_image": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "remote": schema.StringAttribute{ + Required: true, + }, + "name": schema.StringAttribute{ + Required: true, + }, + "type": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("container"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("container", "virtual-machine"), + }, + }, + "architecture": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + architectureValidator{}, + }, + }, + "copy_aliases": schema.BoolAttribute{ + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + }, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), }, }, - "source_remote": schema.StringAttribute{ - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), + "source_instance": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + }, + "snapshot": schema.StringAttribute{ + Optional: true, + }, + }, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), }, }, @@ -89,25 +146,6 @@ func (r ImageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp }, }, - "copy_aliases": schema.BoolAttribute{ - Optional: true, - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.RequiresReplace(), - }, - }, - - "type": schema.StringAttribute{ - Optional: true, - Computed: true, - Default: stringdefault.StaticString("container"), - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - Validators: []validator.String{ - stringvalidator.OneOf("container", "virtual-machine"), - }, - }, - "project": schema.StringAttribute{ Optional: true, PlanModifiers: []planmodifier.String{ @@ -125,18 +163,6 @@ func (r ImageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp }, }, - "architecture": schema.StringAttribute{ - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - stringplanmodifier.RequiresReplace(), - }, - Validators: []validator.String{ - architectureValidator{}, - }, - }, - // Computed attributes. "resource_id": schema.StringAttribute{ @@ -183,16 +209,250 @@ func (r *ImageResource) Configure(_ context.Context, req resource.ConfigureReque r.provider = provider } +func (r ImageResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + if req.Config.Raw.IsNull() { + return + } + + var config ImageModel + + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if 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.", + ) + return + } +} + func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan ImageModel - // Fetch resource model from Terraform plan. diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } + if !plan.SourceImage.IsNull() { + r.createImageFromSourceImage(ctx, resp, &plan) + return + } else if !plan.SourceInstance.IsNull() { + r.createImageFromSourceInstance(ctx, resp, &plan) + return + } +} + +func (r ImageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ImageModel + + // Fetch resource model from Terraform state. + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := state.Remote.ValueString() + project := state.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + // Update Terraform state. + diags = r.SyncState(ctx, &resp.State, server, state) + resp.Diagnostics.Append(diags...) +} + +func (r ImageResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ImageModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + 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 + } + + // Extract image metadata. + _, imageFingerprint := splitImageResourceID(plan.ResourceID.ValueString()) + + // Extract removed and added image aliases. + oldAliases, diags := ToAliasList(ctx, plan.Aliases) + resp.Diagnostics.Append(diags...) + + newAliases := make([]string, 0, len(plan.Aliases.Elements())) + diags = req.State.GetAttribute(ctx, path.Root("aliases"), &newAliases) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + removed, added := utils.DiffSlices(oldAliases, newAliases) + + // Delete removed aliases. + for _, alias := range removed { + err := server.DeleteImageAlias(alias) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to delete alias %q for cached image with fingerprint %q", alias, imageFingerprint), err.Error()) + return + } + } + + // Add new aliases. + for _, alias := range added { + req := api.ImageAliasesPost{} + req.Name = alias + req.Target = imageFingerprint + + err := server.CreateImageAlias(req) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to create alias %q for cached image with fingerprint %q", alias, imageFingerprint), err.Error()) + return + } + } + + // Update Terraform state. + diags = r.SyncState(ctx, &resp.State, server, plan) + resp.Diagnostics.Append(diags...) +} + +func (r ImageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ImageModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := state.Remote.ValueString() + project := state.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + _, imageFingerprint := splitImageResourceID(state.ResourceID.ValueString()) + + opDelete, err := server.DeleteImage(imageFingerprint) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove cached image with fingerprint %q", imageFingerprint), err.Error()) + return + } + + err = opDelete.Wait() + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove cached image with fingerprint %q", imageFingerprint), err.Error()) + return + } +} + +// SyncState fetches the server's current state for a cached image and +// updates the provided model. It then applies this updated model as the +// new state in Terraform. +func (r ImageResource) SyncState(ctx context.Context, tfState *tfsdk.State, server incus.InstanceServer, m ImageModel) diag.Diagnostics { + var respDiags diag.Diagnostics + + _, imageFingerprint := splitImageResourceID(m.ResourceID.ValueString()) + + image, _, err := server.GetImage(imageFingerprint) + if err != nil { + if errors.IsNotFoundError(err) { + tfState.RemoveResource(ctx) + return nil + } + + respDiags.AddError(fmt.Sprintf("Failed to retrieve cached image with fingerprint %q", imageFingerprint), err.Error()) + return respDiags + } + + if !m.SourceImage.IsNull() { + var sourceImageModel SourceImageModel + respDiags = m.SourceImage.As(ctx, &sourceImageModel, basetypes.ObjectAsOptions{}) + if respDiags.HasError() { + return respDiags + } + + // Store architecture if computed + if sourceImageModel.Architecture.IsNull() || sourceImageModel.Architecture.IsUnknown() { + sourceImageModel.Architecture = types.StringValue(image.Architecture) + m.SourceImage, respDiags = types.ObjectValue(m.SourceImage.AttributeTypes(ctx), map[string]attr.Value{ + "remote": sourceImageModel.Remote, + "name": sourceImageModel.Name, + "type": sourceImageModel.Type, + "architecture": sourceImageModel.Architecture, + "copy_aliases": sourceImageModel.CopyAliases, + }) + if respDiags.HasError() { + return respDiags + } + } + } + + configAliases, diags := ToAliasList(ctx, m.Aliases) + respDiags.Append(diags...) + + copiedAliases, diags := ToAliasList(ctx, m.CopiedAliases) + respDiags.Append(diags...) + + // Copy aliases from image state that are present in user defined + // config or are not copied. + var aliases []string + for _, a := range image.Aliases { + if utils.ValueInSlice(a.Name, configAliases) || !utils.ValueInSlice(a.Name, copiedAliases) { + aliases = append(aliases, a.Name) + } + } + + aliasSet, diags := ToAliasSetType(ctx, aliases) + respDiags.Append(diags...) + + m.Fingerprint = types.StringValue(image.Fingerprint) + m.CreatedAt = types.Int64Value(image.CreatedAt.Unix()) + m.Aliases = aliasSet + + if respDiags.HasError() { + return respDiags + } + + return tfState.Set(ctx, &m) +} + +func (r ImageResource) createImageFromSourceImage(ctx context.Context, resp *resource.CreateResponse, plan *ImageModel) { + var sourceImageModel SourceImageModel + + diags := plan.SourceImage.As(ctx, &sourceImageModel, 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, "") @@ -201,9 +461,9 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r return } - image := plan.SourceImage.ValueString() - imageType := plan.Type.ValueString() - imageRemote := plan.SourceRemote.ValueString() + image := sourceImageModel.Name.ValueString() + imageType := sourceImageModel.Type.ValueString() + imageRemote := sourceImageModel.Remote.ValueString() imageServer, err := r.provider.ImageServer(imageRemote) if err != nil { resp.Diagnostics.Append(errors.NewImageServerError(err)) @@ -217,7 +477,7 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r } // Determine the correct image for the specified architecture. - architecture := plan.Architecture.ValueString() + architecture := sourceImageModel.Architecture.ValueString() if architecture != "" { availableArchitectures, err := imageServer.GetImageAliasArchitectures(imageType, image) if err != nil { @@ -257,8 +517,8 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r } aliases, diags := ToAliasList(ctx, plan.Aliases) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } @@ -296,7 +556,7 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r // For OCI images, we need to use the actual image name to pull the image from the current OCI images registry. // Nevertheless, we need to restore the actual fingerprint after copying the image by name. realFingerprint := imageInfo.Fingerprint - imageInfo.Fingerprint = plan.SourceImage.ValueString() + imageInfo.Fingerprint = sourceImageModel.Name.ValueString() opCopy, err = server.CopyImage(imageServer, *imageInfo, &args) imageInfo.Fingerprint = realFingerprint } else { @@ -318,191 +578,127 @@ func (r ImageResource) Create(ctx context.Context, req resource.CreateRequest, r // Store remote aliases that we've copied, so we can filter them // out later. copied := make([]string, 0) - if plan.CopyAliases.ValueBool() { + if sourceImageModel.CopyAliases.ValueBool() { for _, a := range imageInfo.Aliases { copied = append(copied, a.Name) } } copiedAliases, diags := types.SetValueFrom(ctx, types.StringType, copied) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } imageID := createImageResourceID(remote, imageInfo.Fingerprint) plan.ResourceID = types.StringValue(imageID) + plan.CopiedAliases = copiedAliases // Update Terraform state. - diags = r.SyncState(ctx, &resp.State, server, plan) + diags = r.SyncState(ctx, &resp.State, server, *plan) resp.Diagnostics.Append(diags...) } -func (r ImageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state ImageModel +func (r ImageResource) createImageFromSourceInstance(ctx context.Context, resp *resource.CreateResponse, plan *ImageModel) { + var sourceInstanceModel SourceInstanceModel - // Fetch resource model from Terraform state. - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { + diags := plan.SourceInstance.As(ctx, &sourceInstanceModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + resp.Diagnostics.Append(diags...) return } - remote := state.Remote.ValueString() - project := state.Project.ValueString() + 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 } - // Update Terraform state. - diags = r.SyncState(ctx, &resp.State, server, state) - resp.Diagnostics.Append(diags...) -} - -func (r ImageResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan ImageModel - - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { + instanceName := sourceInstanceModel.Name.ValueString() + instanceState, _, err := server.GetInstanceState(instanceName) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to retrieve state of instance %q", instanceName), err.Error()) 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)) + if sourceInstanceModel.Snapshot.IsNull() && instanceState.StatusCode != api.Stopped { + resp.Diagnostics.AddError(fmt.Sprintf("Cannot publish image because instance %q is running", instanceName), "") return } - // Extract image metadata. - image := plan.SourceImage.ValueString() - _, imageFingerprint := splitImageResourceID(plan.ResourceID.ValueString()) - - // Extract removed and added image aliases. - oldAliases, diags := ToAliasList(ctx, plan.Aliases) - resp.Diagnostics.Append(diags...) - - newAliases := make([]string, 0, len(plan.Aliases.Elements())) - diags = req.State.GetAttribute(ctx, path.Root("aliases"), &newAliases) + aliases, diags := ToAliasList(ctx, plan.Aliases) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - removed, added := utils.DiffSlices(oldAliases, newAliases) - - // Delete removed aliases. - for _, alias := range removed { - err := server.DeleteImageAlias(alias) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to delete alias %q for cached image %q", alias, image), err.Error()) + 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 } - } - // Add new aliases. - for _, alias := range added { - req := api.ImageAliasesPost{} - req.Name = alias - req.Target = imageFingerprint - - err := server.CreateImageAlias(req) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to create alias %q for cached image %q", alias, image), err.Error()) - return + ia := api.ImageAlias{ + Name: alias, } - } - - // Update Terraform state. - diags = r.SyncState(ctx, &resp.State, server, plan) - resp.Diagnostics.Append(diags...) -} - -func (r ImageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state ImageModel - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return + imageAliases = append(imageAliases, ia) } - remote := state.Remote.ValueString() - project := state.Project.ValueString() - server, err := r.provider.InstanceServer(remote, project, "") - if err != nil { - resp.Diagnostics.Append(errors.NewInstanceServerError(err)) - return + var source *api.ImagesPostSource + if !sourceInstanceModel.Snapshot.IsNull() { + snapsnotName := sourceInstanceModel.Snapshot.ValueString() + source = &api.ImagesPostSource{ + Name: fmt.Sprintf("%s/%s", instanceName, snapsnotName), + Type: "snapshot", + } + } else { + source = &api.ImagesPostSource{ + Name: instanceName, + Type: "instance", + } } - _, imageFingerprint := splitImageResourceID(state.ResourceID.ValueString()) - opDelete, err := server.DeleteImage(imageFingerprint) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove cached image %q", state.SourceImage.ValueString()), err.Error()) - return + imageReq := api.ImagesPost{ + Aliases: imageAliases, + ImagePut: api.ImagePut{}, + Source: source, } - err = opDelete.Wait() + // Publish image. + op, err := server.CreateImage(imageReq, nil) if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove cached image %q", state.SourceImage.ValueString()), err.Error()) + resp.Diagnostics.AddError(fmt.Sprintf("Failed to publish instance %q image", instanceName), err.Error()) return } -} - -// SyncState fetches the server's current state for a cached image and -// updates the provided model. It then applies this updated model as the -// new state in Terraform. -func (r ImageResource) SyncState(ctx context.Context, tfState *tfsdk.State, server incus.InstanceServer, m ImageModel) diag.Diagnostics { - var respDiags diag.Diagnostics - _, imageFingerprint := splitImageResourceID(m.ResourceID.ValueString()) - - imageName := m.SourceImage.ValueString() - image, _, err := server.GetImage(imageFingerprint) + // Wait for create operation to finish. + err = op.Wait() if err != nil { - if errors.IsNotFoundError(err) { - tfState.RemoveResource(ctx) - return nil - } - - respDiags.AddError(fmt.Sprintf("Failed to retrieve cached image %q", imageName), err.Error()) - return respDiags - } - - configAliases, diags := ToAliasList(ctx, m.Aliases) - respDiags.Append(diags...) - - copiedAliases, diags := ToAliasList(ctx, m.CopiedAliases) - respDiags.Append(diags...) - - // Copy aliases from image state that are present in user defined - // config or are not copied. - var aliases []string - for _, a := range image.Aliases { - if utils.ValueInSlice(a.Name, configAliases) || !utils.ValueInSlice(a.Name, copiedAliases) { - aliases = append(aliases, a.Name) - } + resp.Diagnostics.AddError(fmt.Sprintf("Failed to publish instance %q image", instanceName), err.Error()) + return } - aliasSet, diags := ToAliasSetType(ctx, aliases) - respDiags.Append(diags...) + // Extract fingerprint from operation response. + opResp := op.Get() + imageFingerprint := opResp.Metadata["fingerprint"].(string) + plan.Fingerprint = types.StringValue(imageFingerprint) - m.Fingerprint = types.StringValue(image.Fingerprint) - m.Architecture = types.StringValue(image.Architecture) - m.CreatedAt = types.Int64Value(image.CreatedAt.Unix()) - m.Aliases = aliasSet + imageID := createImageResourceID(remote, imageFingerprint) + plan.ResourceID = types.StringValue(imageID) - if respDiags.HasError() { - return respDiags - } + plan.CopiedAliases = types.SetNull(types.StringType) - return tfState.Set(ctx, &m) + // Update Terraform state. + diags = r.SyncState(ctx, &resp.State, server, *plan) + resp.Diagnostics.Append(diags...) } // ToAliasList converts aliases of type types.Set into a slice of strings. diff --git a/internal/image/resource_image_publish.go b/internal/image/resource_image_publish.go deleted file mode 100644 index feae83d..0000000 --- a/internal/image/resource_image_publish.go +++ /dev/null @@ -1,470 +0,0 @@ -package image - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/lxc/incus/v6/client" - "github.com/lxc/incus/v6/shared/api" - - "github.com/lxc/terraform-provider-incus/internal/common" - "github.com/lxc/terraform-provider-incus/internal/errors" - provider_config "github.com/lxc/terraform-provider-incus/internal/provider-config" - "github.com/lxc/terraform-provider-incus/internal/utils" -) - -// ImagePublishModel resource data model that matches the schema. -type ImagePublishModel struct { - Instance types.String `tfsdk:"instance"` - Aliases types.Set `tfsdk:"aliases"` - Properties types.Map `tfsdk:"properties"` - Public types.Bool `tfsdk:"public"` - Filename types.String `tfsdk:"filename"` - CompressionAlg types.String `tfsdk:"compression_algorithm"` - Triggers types.List `tfsdk:"triggers"` - Project types.String `tfsdk:"project"` - Remote types.String `tfsdk:"remote"` - - // Computed. - ResourceID types.String `tfsdk:"resource_id"` - Architecture types.String `tfsdk:"architecture"` - Fingerprint types.String `tfsdk:"fingerprint"` - CreatedAt types.Int64 `tfsdk:"created_at"` -} - -// ImagePublishResource represent Incus publish image resource. -type ImagePublishResource struct { - provider *provider_config.IncusProviderConfig -} - -// NewImagePublishResource return new publish image resource. -func NewImagePublishResource() resource.Resource { - return &ImagePublishResource{} -} - -func (r ImagePublishResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = fmt.Sprintf("%s_image_publish", req.ProviderTypeName) -} - -func (r ImagePublishResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Attributes: map[string]schema.Attribute{ - "instance": schema.StringAttribute{ - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - - "aliases": schema.SetAttribute{ - Optional: true, - ElementType: types.StringType, - Validators: []validator.Set{ - // Prevent empty values. - setvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(1)), - }, - PlanModifiers: []planmodifier.Set{ - setplanmodifier.RequiresReplace(), - }, - }, - - "properties": schema.MapAttribute{ - Optional: true, - ElementType: types.StringType, - PlanModifiers: []planmodifier.Map{ - mapplanmodifier.RequiresReplace(), - }, - Validators: []validator.Map{ - mapvalidator.KeysAre(stringvalidator.LengthAtLeast(1)), - mapvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(1)), - }, - }, - - "public": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.RequiresReplace(), - }, - }, - - "filename": schema.StringAttribute{ - Optional: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - - "compression_algorithm": schema.StringAttribute{ - Optional: true, - Computed: true, - Default: stringdefault.StaticString("gzip"), - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - Validators: []validator.String{ - stringvalidator.OneOf("bzip2", "gzip", "lzma", "xz", "none"), - }, - }, - - "triggers": schema.ListAttribute{ - Optional: true, - ElementType: types.StringType, - PlanModifiers: []planmodifier.List{ - listplanmodifier.RequiresReplace(), - }, - }, - - "project": schema.StringAttribute{ - Optional: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - Validators: []validator.String{ - stringvalidator.LengthAtLeast(1), - }, - }, - - "remote": schema.StringAttribute{ - Optional: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - - // Computed. - - "resource_id": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - - "architecture": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - - "fingerprint": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - - "created_at": schema.Int64Attribute{ - Computed: true, - PlanModifiers: []planmodifier.Int64{ - int64planmodifier.UseStateForUnknown(), - }, - }, - }, - } -} - -func (r *ImagePublishResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - data := req.ProviderData - if data == nil { - return - } - - provider, ok := data.(*provider_config.IncusProviderConfig) - if !ok { - resp.Diagnostics.Append(errors.NewProviderDataTypeError(req.ProviderData)) - return - } - - r.provider = provider -} - -func (r ImagePublishResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan ImagePublishModel - - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - 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 - } - - instanceName := plan.Instance.ValueString() - ct, _, err := server.GetInstanceState(instanceName) - if err != nil { // && errors.IsNotFoundError(err) - resp.Diagnostics.AddError(fmt.Sprintf("Failed to retrieve state of instance %q", instanceName), err.Error()) - return - } - - if ct.StatusCode != api.Stopped { - resp.Diagnostics.AddError(fmt.Sprintf("Cannot publish image because instance %q is running", instanceName), "") - return - } - - imageProps, diags := common.ToConfigMap(ctx, plan.Properties) - resp.Diagnostics.Append(diags...) - - aliases, diags := ToAliasList(ctx, plan.Aliases) - resp.Diagnostics.Append(diags...) - - if resp.Diagnostics.HasError() { - 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) - } - - imageReq := api.ImagesPost{ - Aliases: imageAliases, - Filename: plan.Filename.ValueString(), - CompressionAlgorithm: plan.CompressionAlg.ValueString(), - ImagePut: api.ImagePut{ - Public: plan.Public.ValueBool(), - Properties: imageProps, - }, - Source: &api.ImagesPostSource{ - Name: plan.Instance.ValueString(), - Type: "instance", - }, - } - - // Publish image. - op, err := server.CreateImage(imageReq, nil) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to publish instance %q image", instanceName), err.Error()) - return - } - - // Wait for create operation to finish. - err = op.Wait() - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to publish instance %q image", instanceName), err.Error()) - return - } - - // Extract fingerprint from operation response. - opResp := op.Get() - imageFingerprint := opResp.Metadata["fingerprint"].(string) - plan.Fingerprint = types.StringValue(imageFingerprint) - - imageID := createImageResourceID(remote, imageFingerprint) - plan.ResourceID = types.StringValue(imageID) - - // Update Terraform state. - diags = r.SyncState(ctx, &resp.State, server, plan) - resp.Diagnostics.Append(diags...) -} - -func (r ImagePublishResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state ImagePublishModel - - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - remote := state.Remote.ValueString() - project := state.Project.ValueString() - server, err := r.provider.InstanceServer(remote, project, "") - if err != nil { - resp.Diagnostics.Append(errors.NewInstanceServerError(err)) - return - } - - // Update Terraform state. - diags = r.SyncState(ctx, &resp.State, server, state) - resp.Diagnostics.Append(diags...) -} - -func (r ImagePublishResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan ImagePublishModel - - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - 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 - } - - _, imageFingerprint := splitImageResourceID(plan.ResourceID.ValueString()) - - imageProps, diags := common.ToConfigMap(ctx, plan.Properties) - resp.Diagnostics.Append(diags...) - - oldAliases, diags := ToAliasList(ctx, plan.Aliases) - resp.Diagnostics.Append(diags...) - - newAliases := make([]string, 0, len(plan.Aliases.Elements())) - diags = req.State.GetAttribute(ctx, path.Root("aliases"), &newAliases) - resp.Diagnostics.Append(diags...) - - if resp.Diagnostics.HasError() { - return - } - - // Extract removed and added image aliases. - removed, added := utils.DiffSlices(oldAliases, newAliases) - - // Delete removed aliases. - for _, alias := range removed { - err := server.DeleteImageAlias(alias) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to delete alias %q for published image", alias), err.Error()) - return - } - } - - // Add new aliases. - for _, alias := range added { - req := api.ImageAliasesPost{} - req.Name = alias - req.Target = imageFingerprint - - err := server.CreateImageAlias(req) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to create alias %q for published image", alias), err.Error()) - return - } - } - - imageReq := api.ImagePut{ - Properties: imageProps, - } - - err = server.UpdateImage(imageFingerprint, imageReq, "") - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to update publihsed image properties"), err.Error()) - return - } - - // Update Terraform state. - diags = r.SyncState(ctx, &resp.State, server, plan) - resp.Diagnostics.Append(diags...) -} - -func (r ImagePublishResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state ImagePublishModel - - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - remote := state.Remote.ValueString() - project := state.Project.ValueString() - server, err := r.provider.InstanceServer(remote, project, "") - if err != nil { - resp.Diagnostics.Append(errors.NewInstanceServerError(err)) - return - } - - _, imageFingerprint := splitImageResourceID(state.ResourceID.ValueString()) - opDelete, err := server.DeleteImage(imageFingerprint) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove published image"), err.Error()) - return - } - - err = opDelete.Wait() - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove published image"), err.Error()) - return - } -} - -// SyncState fetches the server's current state for a published image and -// updates the provided model. It then applies this updated model as the -// new state in Terraform. -func (_ ImagePublishResource) SyncState(ctx context.Context, tfState *tfsdk.State, server incus.InstanceServer, m ImagePublishModel) diag.Diagnostics { - var respDiags diag.Diagnostics - - _, imageFingerprint := splitImageResourceID(m.ResourceID.ValueString()) - - image, _, err := server.GetImage(imageFingerprint) - if err != nil { - if errors.IsNotFoundError(err) { - tfState.RemoveResource(ctx) - return nil - } - - respDiags.AddError(fmt.Sprintf("Failed to retrieve published image"), err.Error()) - return respDiags - } - - configAliases, diags := ToAliasList(ctx, m.Aliases) - respDiags.Append(diags...) - - // Copy aliases from image state that are present in user defined - // config. - var aliases []string - for _, a := range image.Aliases { - if utils.ValueInSlice(a.Name, configAliases) { - aliases = append(aliases, a.Name) - } - } - - aliasSet, diags := ToAliasSetType(ctx, aliases) - respDiags.Append(diags...) - - m.Fingerprint = types.StringValue(image.Fingerprint) - m.Architecture = types.StringValue(image.Architecture) - m.CreatedAt = types.Int64Value(image.CreatedAt.Unix()) - m.Public = types.BoolValue(image.Public) - m.Aliases = aliasSet - - if respDiags.HasError() { - return respDiags - } - - return tfState.Set(ctx, m) -} diff --git a/internal/image/resource_image_publish_test.go b/internal/image/resource_image_publish_test.go deleted file mode 100644 index 519315e..0000000 --- a/internal/image/resource_image_publish_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package image_test - -import ( - "fmt" - "strings" - "testing" - - "github.com/dustinkirkland/golang-petname" - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - - "github.com/lxc/terraform-provider-incus/internal/acctest" -) - -func TestAccImagePublish_basic(t *testing.T) { - instanceName := petname.Generate(2, "-") - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: testAccImagePublish_basic(instanceName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_instance.instance1", "name", instanceName), - resource.TestCheckResourceAttr("incus_instance.instance1", "status", "Stopped"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "instance", instanceName), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "aliases.#", "1"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "aliases.0", "test_basic"), - resource.TestCheckResourceAttrSet("incus_image_publish.pimg", "resource_id"), - ), - }, - }, - }) -} - -func TestAccImagePublish_aliases(t *testing.T) { - instanceName := petname.Generate(2, "-") - aliases := []string{"alias1", "alias2"} - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: testAccImagePublish_aliases(instanceName, aliases...), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_instance.instance1", "name", instanceName), - resource.TestCheckResourceAttr("incus_instance.instance1", "status", "Stopped"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "instance", instanceName), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "aliases.#", "2"), - resource.TestCheckTypeSetElemAttr("incus_image_publish.pimg", "aliases.*", aliases[0]), - resource.TestCheckTypeSetElemAttr("incus_image_publish.pimg", "aliases.*", aliases[1]), - resource.TestCheckResourceAttrSet("incus_image_publish.pimg", "resource_id"), - ), - }, - }, - }) -} - -func TestAccImagePublish_properties(t *testing.T) { - instanceName := petname.Generate(2, "-") - properties := map[string]string{"os": "Alpine", "version": "4"} - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: testAccImagePublish_properties(instanceName, properties), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_instance.instance1", "name", instanceName), - resource.TestCheckResourceAttr("incus_instance.instance1", "status", "Stopped"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "instance", instanceName), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "aliases.#", "0"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "properties.%", "2"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "properties.os", "Alpine"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "properties.version", "4"), - resource.TestCheckResourceAttrSet("incus_image_publish.pimg", "resource_id"), - ), - }, - }, - }) -} - -func TestAccImagePublish_project(t *testing.T) { - projectName := petname.Name() - instanceName := petname.Generate(2, "-") - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: testAccImagePublish_project(projectName, instanceName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_project.project1", "name", projectName), - resource.TestCheckResourceAttr("incus_instance.instance1", "name", instanceName), - resource.TestCheckResourceAttr("incus_instance.instance1", "status", "Stopped"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "instance", instanceName), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "aliases.#", "0"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "aliases.#", "0"), - resource.TestCheckResourceAttr("incus_image_publish.pimg", "project", projectName), - resource.TestCheckResourceAttrSet("incus_image_publish.pimg", "resource_id"), - ), - }, - }, - }) -} - -func testAccImagePublish_basic(name string) string { - return fmt.Sprintf(` -resource "incus_instance" "instance1" { - name = "%s" - image = "%s" - running = false -} - -resource "incus_image_publish" "pimg" { - instance = incus_instance.instance1.name - aliases = ["test_basic"] -} - `, name, acctest.TestImage) -} - -func testAccImagePublish_aliases(name string, aliases ...string) string { - return fmt.Sprintf(` -resource "incus_instance" "instance1" { - name = "%s" - image = "%s" - running = false -} - -resource "incus_image_publish" "pimg" { - instance = incus_instance.instance1.name - aliases = ["%s"] -} - `, name, acctest.TestImage, strings.Join(toStringSlice(aliases), "\",\"")) -} - -func testAccImagePublish_properties(name string, properties map[string]string) string { - return fmt.Sprintf(` -resource "incus_instance" "instance1" { - name = "%s" - image = "%s" - running = false -} - -resource "incus_image_publish" "pimg" { - instance = incus_instance.instance1.name - properties = { - %s - } -} - `, name, acctest.TestImage, strings.Join(formatProperties(properties), "\n")) -} - -func testAccImagePublish_project(project, instance string) string { - return fmt.Sprintf(` -resource "incus_project" "project1" { - name = "%s" - config = { - "features.storage.volumes" = false - "features.images" = false - "features.profiles" = false - } -} - -resource "incus_instance" "instance1" { - name = "%s" - image = "%s" - project = incus_project.project1.name - running = false -} - -resource "incus_image_publish" "pimg" { - instance = incus_instance.instance1.name - project = incus_project.project1.name -} - `, project, instance, acctest.TestImage) -} - -func toStringSlice(slice []string) []string { - new := make([]string, 0, len(slice)) - for _, v := range slice { - new = append(new, v) - } - return new -} - -func formatProperties(properties map[string]string) []string { - r := make([]string, 0, len(properties)) - for k, v := range properties { - r = append(r, fmt.Sprintf(`"%s":"%s"`, k, v)) - } - return r -} diff --git a/internal/image/resource_image_test.go b/internal/image/resource_image_test.go index 9ef95fa..5459338 100644 --- a/internal/image/resource_image_test.go +++ b/internal/image/resource_image_test.go @@ -20,9 +20,9 @@ func TestAccImage_basic(t *testing.T) { { Config: testAccImage_basic(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img1", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img1", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img1", "copy_aliases", "true"), + 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"), ), }, @@ -38,10 +38,10 @@ func TestAccImage_basicVM(t *testing.T) { { Config: testAccImage_basicVM(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img1vm", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img1vm", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img1vm", "copy_aliases", "true"), - resource.TestCheckResourceAttr("incus_image.img1vm", "type", "virtual-machine"), + resource.TestCheckResourceAttr("incus_image.img1vm", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img1vm", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img1vm", "source_image.copy_aliases", "true"), + resource.TestCheckResourceAttr("incus_image.img1vm", "source_image.type", "virtual-machine"), resource.TestCheckResourceAttr("incus_image.img1vm", "copied_aliases.#", "4"), ), }, @@ -60,9 +60,9 @@ func TestAccImage_alias(t *testing.T) { { Config: testAccImage_aliases(alias1, alias2), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img2", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img2", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img2", "copy_aliases", "false"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.copy_aliases", "false"), resource.TestCheckResourceAttr("incus_image.img2", "aliases.#", "2"), resource.TestCheckTypeSetElemAttr("incus_image.img2", "aliases.*", alias1), resource.TestCheckTypeSetElemAttr("incus_image.img2", "aliases.*", alias2), @@ -84,9 +84,9 @@ func TestAccImage_copiedAliases(t *testing.T) { { Config: testAccImage_copiedAliases(alias1, alias2), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img3", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img3", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img3", "copy_aliases", "true"), + resource.TestCheckResourceAttr("incus_image.img3", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img3", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img3", "source_image.copy_aliases", "true"), resource.TestCheckResourceAttr("incus_image.img3", "aliases.#", "3"), resource.TestCheckTypeSetElemAttr("incus_image.img3", "aliases.*", "alpine/edge"), resource.TestCheckTypeSetElemAttr("incus_image.img3", "aliases.*", alias1), @@ -106,9 +106,9 @@ func TestAccImage_aliasCollision(t *testing.T) { { Config: testAccImage_aliasCollision(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img4", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img4", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img4", "copy_aliases", "true"), + resource.TestCheckResourceAttr("incus_image.img4", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img4", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img4", "source_image.copy_aliases", "true"), resource.TestCheckResourceAttr("incus_image.img4", "aliases.#", "1"), resource.TestCheckResourceAttr("incus_image.img4", "aliases.0", "alpine/edge/amd64"), resource.TestCheckResourceAttr("incus_image.img4", "copied_aliases.#", "4"), @@ -128,9 +128,9 @@ func TestAccImage_aliasExists(t *testing.T) { { Config: testAccImage_aliasExists1(alias), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.exists1", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.exists1", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.exists1", "copy_aliases", "false"), + resource.TestCheckResourceAttr("incus_image.exists1", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.exists1", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.exists1", "source_image.copy_aliases", "false"), resource.TestCheckResourceAttr("incus_image.exists1", "aliases.#", "1"), resource.TestCheckResourceAttr("incus_image.exists1", "aliases.0", alias), resource.TestCheckResourceAttr("incus_image.exists1", "copied_aliases.#", "0"), @@ -140,8 +140,8 @@ func TestAccImage_aliasExists(t *testing.T) { Config: testAccImage_aliasExists2(alias), ExpectError: regexp.MustCompile(fmt.Sprintf(`Image alias %q already exists`, alias)), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.exists1", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.exists1", "source_image", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.exists1", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.exists1", "source_image.name", "alpine/edge"), resource.TestCheckResourceAttr("incus_image.exists1", "aliases.#", "1"), resource.TestCheckResourceAttr("incus_image.exists1", "aliases.0", alias), ), @@ -161,9 +161,9 @@ func TestAccImage_addRemoveAlias(t *testing.T) { { Config: testAccImage_aliases(alias1), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img2", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img2", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img2", "copy_aliases", "false"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.copy_aliases", "false"), resource.TestCheckResourceAttr("incus_image.img2", "aliases.#", "1"), resource.TestCheckResourceAttr("incus_image.img2", "aliases.0", alias1), resource.TestCheckResourceAttr("incus_image.img2", "copied_aliases.#", "0"), @@ -172,9 +172,9 @@ func TestAccImage_addRemoveAlias(t *testing.T) { { Config: testAccImage_aliases(alias1, alias2), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img2", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img2", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img2", "copy_aliases", "false"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.copy_aliases", "false"), resource.TestCheckResourceAttr("incus_image.img2", "aliases.#", "2"), resource.TestCheckTypeSetElemAttr("incus_image.img2", "aliases.*", alias1), resource.TestCheckTypeSetElemAttr("incus_image.img2", "aliases.*", alias2), @@ -184,9 +184,9 @@ func TestAccImage_addRemoveAlias(t *testing.T) { { Config: testAccImage_aliases(alias2), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.img2", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img2", "source_image", "alpine/edge"), - resource.TestCheckResourceAttr("incus_image.img2", "copy_aliases", "false"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.name", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img2", "source_image.copy_aliases", "false"), resource.TestCheckResourceAttr("incus_image.img2", "aliases.#", "1"), resource.TestCheckResourceAttr("incus_image.img2", "aliases.0", alias2), resource.TestCheckResourceAttr("incus_image.img2", "copied_aliases.#", "0"), @@ -207,8 +207,8 @@ func TestAccImage_project(t *testing.T) { Config: testAccImage_project(projectName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("incus_project.project1", "name", projectName), - resource.TestCheckResourceAttr("incus_image.img1", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img1", "source_image", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img1", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img1", "source_image.name", "alpine/edge"), resource.TestCheckResourceAttr("incus_image.img1", "project", projectName), resource.TestCheckNoResourceAttr("incus_image.img1", "aliases"), resource.TestCheckResourceAttr("incus_image.img1", "copied_aliases.#", "0"), @@ -231,8 +231,8 @@ func TestAccImage_instanceFromImageFingerprint(t *testing.T) { Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("incus_project.project1", "name", projectName), resource.TestCheckResourceAttr("incus_image.img1", "project", projectName), - resource.TestCheckResourceAttr("incus_image.img1", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img1", "source_image", "alpine/edge"), + resource.TestCheckResourceAttr("incus_image.img1", "source_image.remote", "images"), + resource.TestCheckResourceAttr("incus_image.img1", "source_image.name", "alpine/edge"), resource.TestCheckResourceAttr("incus_instance.inst", "name", instanceName), resource.TestCheckResourceAttr("incus_instance.inst", "project", projectName), ), @@ -253,12 +253,12 @@ func TestAccImage_architecture(t *testing.T) { Config: testAccImage_architecture(projectName, architecture), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("incus_project.project1", "name", projectName), - resource.TestCheckResourceAttr("incus_image.img1", "source_remote", "images"), - resource.TestCheckResourceAttr("incus_image.img1", "source_image", "alpine/edge"), + 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.architecture", architecture), resource.TestCheckResourceAttr("incus_image.img1", "project", projectName), resource.TestCheckNoResourceAttr("incus_image.img1", "aliases"), resource.TestCheckResourceAttr("incus_image.img1", "copied_aliases.#", "0"), - resource.TestCheckResourceAttr("incus_image.img1", "architecture", architecture), ), }, }, @@ -275,8 +275,49 @@ func TestAccImage_oci(t *testing.T) { { Config: testAccImage_oci(imageName), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("incus_image.oci_img1", "source_remote", "docker"), - resource.TestCheckResourceAttr("incus_image.oci_img1", "source_image", imageName), + resource.TestCheckResourceAttr("incus_image.oci_img1", "source_image.remote", "docker"), + resource.TestCheckResourceAttr("incus_image.oci_img1", "source_image.name", imageName), + ), + }, + }, + }) +} + +func TestAccImage_sourceInstance(t *testing.T) { + projectName := petname.Name() + instanceName := petname.Generate(2, "-") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccImage_sourceInstance(projectName, instanceName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("incus_image.img1", "source_instance.name", instanceName), + resource.TestCheckResourceAttr("incus_image.img1", "aliases.#", "1"), + resource.TestCheckResourceAttr("incus_image.img1", "aliases.0", instanceName), + ), + }, + }, + }) +} + +func TestAccImage_sourceInstanceWithSnapshot(t *testing.T) { + projectName := petname.Name() + instanceName := petname.Generate(2, "-") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccImage_sourceInstanceWithSnapshot(projectName, instanceName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("incus_image.img1", "source_instance.name", instanceName), + resource.TestCheckResourceAttr("incus_image.img1", "source_instance.snapshot", "snap0"), + resource.TestCheckResourceAttr("incus_image.img1", "aliases.#", "1"), + resource.TestCheckResourceAttr("incus_image.img1", "aliases.0", instanceName), ), }, }, @@ -286,9 +327,11 @@ func TestAccImage_oci(t *testing.T) { func testAccImage_basic() string { return ` resource "incus_image" "img1" { - source_remote = "images" - source_image = "alpine/edge" - copy_aliases = true + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = true + } } ` } @@ -296,10 +339,12 @@ resource "incus_image" "img1" { func testAccImage_basicVM() string { return ` resource "incus_image" "img1vm" { - source_remote = "images" - source_image = "alpine/edge" + source_image = { + remote = "images" + name = "alpine/edge" type = "virtual-machine" - copy_aliases = true + copy_aliases = true + } } ` } @@ -307,10 +352,13 @@ resource "incus_image" "img1vm" { func testAccImage_aliases(aliases ...string) string { return fmt.Sprintf(` resource "incus_image" "img2" { - source_remote = "images" - source_image = "alpine/edge" - aliases = ["%s"] - copy_aliases = false + aliases = ["%s"] + + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = false + } } `, strings.Join(aliases, `","`)) } @@ -318,10 +366,13 @@ resource "incus_image" "img2" { func testAccImage_aliasExists1(alias string) string { return fmt.Sprintf(` resource "incus_image" "exists1" { - source_remote = "images" - source_image = "alpine/edge" - aliases = ["%s"] - copy_aliases = false + aliases = ["%s"] + + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = false + } } `, alias) } @@ -329,17 +380,23 @@ resource "incus_image" "exists1" { func testAccImage_aliasExists2(alias string) string { return fmt.Sprintf(` resource "incus_image" "exists1" { - source_remote = "images" - source_image = "alpine/edge" aliases = ["%s"] - copy_aliases = false + + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = false + } } resource "incus_image" "exists2" { - source_remote = "images" - source_image = "alpine/edge" - aliases = ["%s"] - copy_aliases = false + aliases = ["%s"] + + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = false + } } `, alias, alias) } @@ -347,10 +404,13 @@ resource "incus_image" "exists2" { func testAccImage_copiedAliases(aliases ...string) string { return fmt.Sprintf(` resource "incus_image" "img3" { - source_remote = "images" - source_image = "alpine/edge" - aliases = ["alpine/edge","%s"] - copy_aliases = true + aliases = ["alpine/edge","%s"] + + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = true + } } `, strings.Join(aliases, `","`)) } @@ -358,10 +418,13 @@ resource "incus_image" "img3" { func testAccImage_aliasCollision() string { return ` resource "incus_image" "img4" { - source_remote = "images" - source_image = "alpine/edge" - aliases = ["alpine/edge/amd64"] - copy_aliases = true + aliases = ["alpine/edge/amd64"] + + source_image = { + remote = "images" + name = "alpine/edge" + copy_aliases = true + } } ` } @@ -371,10 +434,14 @@ func testAccImage_project(project string) string { resource "incus_project" "project1" { name = "%s" } + resource "incus_image" "img1" { - source_remote = "images" - source_image = "alpine/edge" - project = incus_project.project1.name + project = incus_project.project1.name + + source_image = { + remote = "images" + name = "alpine/edge" + } } `, project) } @@ -386,9 +453,12 @@ resource "incus_project" "project1" { } resource "incus_image" "img1" { - source_remote = "images" - source_image = "alpine/edge" - project = incus_project.project1.name + project = incus_project.project1.name + + source_image = { + remote = "images" + name = "alpine/edge" + } } resource "incus_instance" "inst" { @@ -414,11 +484,15 @@ func testAccImage_architecture(project string, architecture string) string { resource "incus_project" "project1" { name = "%s" } + resource "incus_image" "img1" { - source_remote = "images" - source_image = "alpine/edge" - project = incus_project.project1.name - architecture = "%s" + project = incus_project.project1.name + + source_image = { + remote = "images" + name = "alpine/edge" + architecture = "%s" + } } `, project, architecture) } @@ -426,8 +500,75 @@ resource "incus_image" "img1" { func testAccImage_oci(image string) string { return fmt.Sprintf(` resource "incus_image" "oci_img1" { - source_remote = "docker" - source_image = "%s" + source_image = { + remote = "docker" + name = "%s" + } } `, image) } + +func testAccImage_sourceInstance(projectName, instanceName string) string { + return fmt.Sprintf(` +resource "incus_project" "project1" { + name = "%[1]s" + config = { + "features.images" = false + "features.profiles" = false + } +} + +resource "incus_instance" "instance1" { + project = incus_project.project1.name + name = "%[2]s" + image = "%[3]s" + running = false +} + +resource "incus_image" "img1" { + project = incus_project.project1.name + + aliases = [incus_instance.instance1.name] + + source_instance = { + name = incus_instance.instance1.name + } +} + `, projectName, instanceName, acctest.TestImage) +} + +func testAccImage_sourceInstanceWithSnapshot(projectName, instanceName string) string { + return fmt.Sprintf(` +resource "incus_project" "project1" { + name = "%[1]s" + config = { + "features.images" = false + "features.profiles" = false + } +} + +resource "incus_instance" "instance1" { + project = incus_project.project1.name + name = "%[2]s" + image = "%[3]s" +} + +resource "incus_instance_snapshot" "snapshot1" { + project = incus_project.project1.name + name = "snap0" + instance = incus_instance.instance1.name + stateful = false +} + +resource "incus_image" "img1" { + project = incus_project.project1.name + + aliases = [incus_instance.instance1.name] + + source_instance = { + name = incus_instance.instance1.name + snapshot = incus_instance_snapshot.snapshot1.name + } +} + `, projectName, instanceName, acctest.TestImage) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 436666b..3c5e680 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -263,7 +263,6 @@ func (p *IncusProvider) Configure(ctx context.Context, req provider.ConfigureReq func (p *IncusProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ image.NewImageResource, - image.NewImagePublishResource, instance.NewInstanceResource, instance.NewInstanceSnapshotResource, network.NewNetworkResource,