From aaf620cfaa8a173fa00537da0b7067728224ccdb Mon Sep 17 00:00:00 2001 From: Din Music Date: Wed, 7 Aug 2024 12:27:26 +0000 Subject: [PATCH 1/3] internal/profile: Allow managing default profile in non-default project Signed-off-by: Din Music --- internal/profile/resource_profile.go | 119 +++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/internal/profile/resource_profile.go b/internal/profile/resource_profile.go index 2675d2c8..cff46231 100644 --- a/internal/profile/resource_profile.go +++ b/internal/profile/resource_profile.go @@ -5,6 +5,7 @@ import ( "fmt" lxd "github.com/canonical/lxd/client" + "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -172,8 +173,10 @@ func (r ProfileResource) Create(ctx context.Context, req resource.CreateRequest, return } + profileName := plan.Name.ValueString() + profile := api.ProfilesPost{ - Name: plan.Name.ValueString(), + Name: profileName, ProfilePut: api.ProfilePut{ Description: plan.Description.ValueString(), Config: config, @@ -181,10 +184,39 @@ func (r ProfileResource) Create(ctx context.Context, req resource.CreateRequest, }, } - err = server.CreateProfile(profile) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to create profile %q", profile.Name), err.Error()) - return + if profileName == "default" { + // Resolve empty project name. + projectName := project + if projectName == "" { + projectName = "default" + } + + err := checkDefaultProject(server, projectName, profileName) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Cannot import existing profile %q from project %q", profileName, projectName), err.Error()) + return + } + + // Default profile is automatically created in each project and we cannot remove it. + // However, if default profile is added, instead of creating it, we need fetch the + // existing one and update it. + _, etag, err := server.GetProfile(profileName) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to retrieve existing profile %q", profileName), err.Error()) + return + } + + err = server.UpdateProfile(profileName, profile.ProfilePut, etag) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to update profile %q", profile.Name), err.Error()) + return + } + } else { + err = server.CreateProfile(profile) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to create profile %q", profile.Name), err.Error()) + return + } } // Update Terraform state. @@ -255,6 +287,23 @@ func (r ProfileResource) Update(ctx context.Context, req resource.UpdateRequest, Devices: devices, } + if profileName == "default" { + // Resolve empty project name. + projectName := project + if projectName == "" { + projectName = "default" + } + + // Ensure default profile is not located within the default project. This can + // occur if the profiles's project feature `feature.profiles` was manually + // changed after the default profile was made managed. + err := checkDefaultProject(server, projectName, profileName) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf(`Cannot update profile "default" in project "%q"`, projectName), err.Error()) + return + } + } + err = server.UpdateProfile(profileName, profile, etag) if err != nil { resp.Diagnostics.AddError(fmt.Sprintf("Failed to update profile %q", profileName), err.Error()) @@ -284,9 +333,34 @@ func (r ProfileResource) Delete(ctx context.Context, req resource.DeleteRequest, } profileName := state.Name.ValueString() - err = server.DeleteProfile(profileName) - if err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove profile %q", profileName), err.Error()) + + // Default profile cannot be removed. + if profileName == "default" { + // If default profile is located in the default project, simply remove it + // from the state. + if checkDefaultProject(server, project, profileName) != nil { + return + } + + // Otherwise, try to empty the profile's configuration to ensure the profile + // is not being used by any resource. + profile := api.ProfilePut{ + Description: "", + Config: nil, + Devices: nil, + } + + // Also ignore the not found error, which may occur if a project where + // the profile is located is already removed. + err = server.UpdateProfile(profileName, profile, "") + if err != nil && !errors.IsNotFoundError(err) { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to empty configuration of the profile %q", profileName), err.Error()) + } + } else { + err = server.DeleteProfile(profileName) + if err != nil && !errors.IsNotFoundError(err) { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove profile %q", profileName), err.Error()) + } } } @@ -343,3 +417,32 @@ func (r ProfileResource) SyncState(ctx context.Context, tfState *tfsdk.State, se return tfState.Set(ctx, &m) } + +// checkDefaultProject returns an error if default profile is located within the default +// project or if the project does not exist. +func checkDefaultProject(server lxd.InstanceServer, projectName string, profileName string) error { + if profileName != "default" { + // Nothing to check for. + return nil + } + + // Default profile in default project cannot be managed as there may be resources + // linked to that profile that cannot be managed by Terraform. + if projectName == "" || projectName == "default" { + return fmt.Errorf(`Profile "default" cannot be managed in project "default"`) + } + + project, _, err := server.GetProject(projectName) + if err != nil { + return err + } + + // Ensure project has "features.profiles" disabled. If this feature is enabled, + // project's profiles are located in default project, which we cannot manage. + feature, ok := project.Config["features.profiles"] + if !ok || shared.IsFalse(feature) { + return fmt.Errorf(`Project %q has "features.profiles" disabled which means the profile "default" is located in project "default". This profile cannot be managed.`, projectName) + } + + return nil +} From e3ed9070dd570d36e123f7e28acd4a31720ca7a6 Mon Sep 17 00:00:00 2001 From: Din Music Date: Wed, 7 Aug 2024 12:27:49 +0000 Subject: [PATCH 2/3] internal/profile: Add test for default profile Signed-off-by: Din Music --- internal/profile/resource_profile_test.go | 63 ++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/profile/resource_profile_test.go b/internal/profile/resource_profile_test.go index 993f8a16..41314234 100644 --- a/internal/profile/resource_profile_test.go +++ b/internal/profile/resource_profile_test.go @@ -2,6 +2,7 @@ package profile_test import ( "fmt" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -233,6 +234,39 @@ func TestAccProfile_project(t *testing.T) { }) } +func TestAccProfile_defaultProfile(t *testing.T) { + projectName := acctest.GenerateName(2, "-") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Ensure default profile cannot be managed in default project. + Config: testAccProfile_basic("default"), + ExpectError: regexp.MustCompile("Cannot import existing profile"), + }, + { + // Ensure default profile cannot be managed in project with "features.profiles". + Config: testAccProfile_defaultProfile(projectName, false), + ExpectError: regexp.MustCompile("Cannot import existing profile"), + }, + { + // Ensure default profile can be managed in non-default project that + // have "features.profiles" enabled. + Config: testAccProfile_defaultProfile(projectName, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_project.project1", "name", projectName), + resource.TestCheckResourceAttr("lxd_profile.default", "name", "default"), + resource.TestCheckResourceAttr("lxd_profile.default", "project", projectName), + resource.TestCheckResourceAttr("lxd_profile.default", "device.#", "1"), + resource.TestCheckResourceAttr("lxd_profile.default", "device.0.name", "foo"), + ), + }, + }, + }) +} + func TestAccProfile_importBasic(t *testing.T) { profileName := acctest.GenerateName(2, "-") resourceName := "lxd_profile.profile1" @@ -468,7 +502,7 @@ resource "lxd_instance" "instance1" { `, profileName, instanceName, acctest.TestImage) } -func testAccProfile_project(projectName, profileName string) string { +func testAccProfile_project(projectName string, profileName string) string { return fmt.Sprintf(` resource "lxd_project" "project1" { name = "%s" @@ -494,3 +528,30 @@ resource "lxd_profile" "profile1" { } `, projectName, profileName) } + +func testAccProfile_defaultProfile(projectName string, enableProfileFeature bool) string { + return fmt.Sprintf(` +resource "lxd_project" "project1" { + name = "%s" + config = { + "features.images" = false + "features.profiles" = %v + } +} + +resource "lxd_profile" "default" { + name = "default" + project = lxd_project.project1.name + + device { + name = "foo" + type = "nic" + properties = { + name = "bar" + nictype = "bridged" + parent = "lxdbr0" + } + } +} + `, projectName, enableProfileFeature) +} From e4ca7e2bf59506b0c539c4b3d122764f45b2575a Mon Sep 17 00:00:00 2001 From: Din Music Date: Wed, 7 Aug 2024 12:45:41 +0000 Subject: [PATCH 3/3] docs: Add example on how to manage default profiles Signed-off-by: Din Music --- docs/resources/profile.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/resources/profile.md b/docs/resources/profile.md index 76b4e6e0..fe079183 100644 --- a/docs/resources/profile.md +++ b/docs/resources/profile.md @@ -41,6 +41,25 @@ resource "lxd_instance" "test1" { } ``` +Default profiles in LXD are special because they are created automatically and cannot be removed. +The provider will attempt to update these profiles and, if successful, import them into the Terraform state. +```hcl +resource "lxd_project" "myproject" { + name = "myproject" + config = { + "features.profiles": true + } +} + +resource "lxd_profile" "default" { + name = "default" + project = lxd_project.myproject.name + description = "Modified default profile description" +} +``` + +~> **Note:** Default profiles can be managed only in non-default projects that have `features.profiles` set to `true`. + ## Argument Reference * `name` - **Required** - Name of the profile.