diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml index f1064217e..24cb2dc91 100644 --- a/.github/workflows/acceptance-tests.yml +++ b/.github/workflows/acceptance-tests.yml @@ -148,7 +148,9 @@ jobs: update-changelog: runs-on: ubuntu-latest needs: acceptance-tests-matrix - if: ${{ github.event_name == 'pull_request' }} && ${{ needs.acceptance-tests-matrix.result == 'success' }} + if: | + always() && + (github.event_name == 'pull_request' && needs.acceptance-tests-matrix.result == 'success') permissions: contents: write steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2004673ee..f3c22551b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -## 11.5.0 (August 2, 2024). Tested on Artifactory 7.90.5 with Terraform 1.9.3 and OpenTofu 1.8.0 +## 11.5.0 (August 6, 2024). Tested on Artifactory 7.90.6 with Terraform 1.9.3 and OpenTofu 1.8.0 + +FEATURES: + +**New Resource:** `artifactory_item_properties` Issue: [#1041](https://github.com/jfrog/terraform-provider-artifactory/issues/1041) PR: [#1046](https://github.com/jfrog/terraform-provider-artifactory/pull/1046) IMPROVEMENTS: diff --git a/docs/resources/artifact.md b/docs/resources/artifact.md index d185fb3f4..938889b28 100644 --- a/docs/resources/artifact.md +++ b/docs/resources/artifact.md @@ -1,7 +1,7 @@ --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "artifactory_artifact Resource - terraform-provider-artifactory" -subcategory: "" +subcategory: "Artifact" description: |- Provides a resource for deploying artifact to Artifactory repository. Support deploying a single artifact only. Changes to repository or path attributes will trigger a recreation of the resource (i.e. delete then create). See JFrog documentation https://jfrog.com/help/r/jfrog-artifactory-documentation/deploy-a-single-artifact for more details. --- diff --git a/docs/resources/item_properties.md b/docs/resources/item_properties.md new file mode 100644 index 000000000..f79e2fb41 --- /dev/null +++ b/docs/resources/item_properties.md @@ -0,0 +1,61 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "artifactory_item_properties Resource - terraform-provider-artifactory" +subcategory: "Artifact" +description: |- + Provides a resource for managaing item (file, folder, or repository) properties. When a folder is used property attachment is recursive by default. See JFrog documentation https://jfrog.com/help/r/jfrog-artifactory-documentation/working-with-jfrog-properties for more details. +--- + +# artifactory_item_properties (Resource) + +Provides a resource for managaing item (file, folder, or repository) properties. When a folder is used property attachment is recursive by default. See [JFrog documentation](https://jfrog.com/help/r/jfrog-artifactory-documentation/working-with-jfrog-properties) for more details. + +## Example Usage + +```terraform +resource "artifactory_item_properties" "my-repo-properties" { + repo_key = "my-generic-local" + properties = { + "key1": ["value1"], + "key2": ["value2", "value3"] + } + is_recursive = true +} + +resource "artifactory_item_properties" "my-folder-properties" { + repo_key = "my-generic-local" + item_path = "folder/subfolder" + properties = { + "key1": ["value1"], + "key2": ["value2", "value3"] + } + is_recursive = true +} +``` + + +## Schema + +### Required + +- `properties` (Map of Set of String) Map of key and list of values. + +~>Keys are limited up to 255 characters and values are limited up to 2,400 characters. Using properties with values over this limit might cause backend issues. + +~>The following special characters are forbidden in the key field: `)(}{][*+^$/~``!@#%&<>;=,±§` and the space character. +- `repo_key` (String) Respository key. + +### Optional + +- `is_recursive` (Boolean) Add this property to the selected folder and to all of artifacts and folders under this folder. Default to `false` +- `item_path` (String) The relative path of the item (file/folder/repository). Leave unset for repository. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import artifactory_item_properties.my-repo-properties repo_key + +terraform import artifactory_item_properties.my-folder-properties repo_key:folder/subfolder +``` diff --git a/examples/resources/artifactory_item_properties/import.sh b/examples/resources/artifactory_item_properties/import.sh new file mode 100644 index 000000000..9ae95ad24 --- /dev/null +++ b/examples/resources/artifactory_item_properties/import.sh @@ -0,0 +1,3 @@ +terraform import artifactory_item_properties.my-repo-properties repo_key + +terraform import artifactory_item_properties.my-folder-properties repo_key:folder/subfolder \ No newline at end of file diff --git a/examples/resources/artifactory_item_properties/resource.tf b/examples/resources/artifactory_item_properties/resource.tf new file mode 100644 index 000000000..7754f93de --- /dev/null +++ b/examples/resources/artifactory_item_properties/resource.tf @@ -0,0 +1,18 @@ +resource "artifactory_item_properties" "my-repo-properties" { + repo_key = "my-generic-local" + properties = { + "key1": ["value1"], + "key2": ["value2", "value3"] + } + is_recursive = true +} + +resource "artifactory_item_properties" "my-folder-properties" { + repo_key = "my-generic-local" + item_path = "folder/subfolder" + properties = { + "key1": ["value1"], + "key2": ["value2", "value3"] + } + is_recursive = true +} \ No newline at end of file diff --git a/pkg/artifactory/provider/framework.go b/pkg/artifactory/provider/framework.go index 596d4a7fc..671888331 100644 --- a/pkg/artifactory/provider/framework.go +++ b/pkg/artifactory/provider/framework.go @@ -16,7 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" datasource_artifact "github.com/jfrog/terraform-provider-artifactory/v11/pkg/artifactory/datasource/artifact" datasource_repository "github.com/jfrog/terraform-provider-artifactory/v11/pkg/artifactory/datasource/repository" - rs "github.com/jfrog/terraform-provider-artifactory/v11/pkg/artifactory/resource" + "github.com/jfrog/terraform-provider-artifactory/v11/pkg/artifactory/resource/artifact" "github.com/jfrog/terraform-provider-artifactory/v11/pkg/artifactory/resource/configuration" "github.com/jfrog/terraform-provider-artifactory/v11/pkg/artifactory/resource/lifecycle" "github.com/jfrog/terraform-provider-artifactory/v11/pkg/artifactory/resource/security" @@ -197,7 +197,8 @@ func (p *ArtifactoryProvider) Configure(ctx context.Context, req provider.Config // Resources satisfies the provider.Provider interface for ArtifactoryProvider. func (p *ArtifactoryProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ - rs.NewArtifactResource, + artifact.NewArtifactResource, + artifact.NewItemPropertiesResource, user.NewAnonymousUserResource, user.NewManagedUserResource, user.NewUnmanagedUserResource, diff --git a/pkg/artifactory/resource/resource_artifactory_artifact.go b/pkg/artifactory/resource/artifact/resource_artifactory_artifact.go similarity index 99% rename from pkg/artifactory/resource/resource_artifactory_artifact.go rename to pkg/artifactory/resource/artifact/resource_artifactory_artifact.go index 51adb889a..ff3abd178 100644 --- a/pkg/artifactory/resource/resource_artifactory_artifact.go +++ b/pkg/artifactory/resource/artifact/resource_artifactory_artifact.go @@ -1,4 +1,4 @@ -package resource +package artifact import ( "context" diff --git a/pkg/artifactory/resource/resource_artifactory_artifact_test.go b/pkg/artifactory/resource/artifact/resource_artifactory_artifact_test.go similarity index 96% rename from pkg/artifactory/resource/resource_artifactory_artifact_test.go rename to pkg/artifactory/resource/artifact/resource_artifactory_artifact_test.go index a74a3164f..fff61fa36 100644 --- a/pkg/artifactory/resource/resource_artifactory_artifact_test.go +++ b/pkg/artifactory/resource/artifact/resource_artifactory_artifact_test.go @@ -1,4 +1,4 @@ -package resource_test +package artifact_test import ( "fmt" @@ -33,7 +33,7 @@ func TestAccArtifact_full(t *testing.T) { "name": name, "repoName": repoName, "path": "/foo/bar/multi1-3.7-20220310.233748-1.jar", - "filePath": "../../../samples/multi1-3.7-20220310.233748-1.jar", + "filePath": "../../../../samples/multi1-3.7-20220310.233748-1.jar", } config := util.ExecuteTemplate(name, temp, testData) @@ -74,7 +74,7 @@ func TestAccArtifact_invalid_path(t *testing.T) { testData := map[string]string{ "name": name, "path": "foo/bar/multi1-3.7-20220310.233748-1.jar", - "filePath": "../../../samples/multi1-3.7-20220310.233748-1.jar", + "filePath": "../../../../samples/multi1-3.7-20220310.233748-1.jar", } config := util.ExecuteTemplate(name, temp, testData) diff --git a/pkg/artifactory/resource/artifact/resource_artifactory_item_properties.go b/pkg/artifactory/resource/artifact/resource_artifactory_item_properties.go new file mode 100644 index 000000000..502a63db8 --- /dev/null +++ b/pkg/artifactory/resource/artifact/resource_artifactory_item_properties.go @@ -0,0 +1,408 @@ +package artifact + +import ( + "context" + "fmt" + "path" + "regexp" + "strings" + + "github.com/go-resty/resty/v2" + "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/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + fw_path "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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/jfrog/terraform-provider-shared/util" + utilfw "github.com/jfrog/terraform-provider-shared/util/fw" + validatorfw_string "github.com/jfrog/terraform-provider-shared/validator/fw/string" + "github.com/samber/lo" +) + +const itemPropertiesEndpoint = "/artifactory/api/storage/{repo_key}" + +func NewItemPropertiesResource() resource.Resource { + return &ItemPropertiesResource{ + TypeName: "artifactory_item_properties", + } +} + +type ItemPropertiesResource struct { + ProviderData util.ProviderMetadata + TypeName string +} + +type ItemPropertiesResourceModel struct { + RepoKey types.String `tfsdk:"repo_key"` + ItemPath types.String `tfsdk:"item_path"` + Properties types.Map `tfsdk:"properties"` + IsRecursive types.Bool `tfsdk:"is_recursive"` +} + +func (r *ItemPropertiesResourceModel) toPropertiesQueryParamsString(ctx context.Context, params *string) diag.Diagnostics { + // Convert from Terraform resource model into API model + var properties map[string][]string + diags := r.Properties.ElementsAs(ctx, &properties, false) + if diags.HasError() { + return diags + } + + *params = lo.Reduce( + lo.Keys(properties), + func(val, key string, _ int) string { + values := strings.Join(properties[key], ",") + + if val == "" { + return fmt.Sprintf("%s=%s", key, values) + } + + return fmt.Sprintf("%s;%s=%s", val, key, values) + }, + "", + ) + + return nil +} + +func (r *ItemPropertiesResourceModel) fromAPIModel(ctx context.Context, apiModel ItemPropertiesGetAPIModel) (ds diag.Diagnostics) { + attrValues := lo.MapEntries( + apiModel.Properties, + func(k string, v []string) (string, attr.Value) { + valueSet, d := types.SetValueFrom(ctx, types.StringType, v) + if d.HasError() { + ds.Append(d...) + } + + return k, valueSet + }, + ) + + propertiesSet, d := types.MapValue( + types.SetType{ElemType: types.StringType}, + attrValues, + ) + if d.HasError() { + ds.Append(d...) + } + + r.Properties = propertiesSet + + return nil +} + +func (r ItemPropertiesResourceModel) GetAPIRequestAndURL(req *resty.Request, baseURL string) (request *resty.Request, url string) { + request = req.SetPathParam("repo_key", r.RepoKey.ValueString()) + + url = baseURL + + if !r.ItemPath.IsNull() { + url = path.Join(url, "{item_path}") + + request = req.SetRawPathParam("item_path", r.ItemPath.ValueString()) + } + + return +} + +type ItemPropertiesGetAPIModel struct { + URI string `json:"uri"` + Properties map[string][]string `json:"properties"` +} + +type ItemPropertiesPatchAPIModel struct { + Props map[string]*string `json:"props"` +} + +func (r *ItemPropertiesResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = r.TypeName +} + +func (r *ItemPropertiesResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "repo_key": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + validatorfw_string.RepoKey(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + MarkdownDescription: "Respository key.", + }, + "item_path": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + MarkdownDescription: "The relative path of the item (file/folder/repository). Leave unset for repository.", + }, + "properties": schema.MapAttribute{ + ElementType: types.SetType{ElemType: types.StringType}, + Required: true, + Validators: []validator.Map{ + mapvalidator.KeysAre( + stringvalidator.LengthBetween(1, 255), + stringvalidator.RegexMatches(regexp.MustCompile(`^[a-zA-Z].*`), "must begin with a letter"), + validatorfw_string.RegexNotMatches(regexp.MustCompile(`[)(}{\]\[\-*+^$/~\x60!@#%&<>;=,±§\s]+`), "must not contain the following special characters: )(}{][-*+^$\\/~`!@#%&<>;=,±§ and the space character"), + ), + mapvalidator.ValueSetsAre( + setvalidator.SizeAtLeast(1), + setvalidator.ValueStringsAre( + stringvalidator.LengthBetween(1, 2400), + ), + ), + }, + MarkdownDescription: "Map of key and list of values.\n\n~>Keys are limited up to 255 characters and values are limited up to 2,400 characters. Using properties with values over this limit might cause backend issues.\n\n" + + "~>The following special characters are forbidden in the key field: `)(}{][*+^$/~``!@#%&<>;=,±§` and the space character.", + }, + "is_recursive": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Add this property to the selected folder and to all of artifacts and folders under this folder. Default to `false`", + }, + }, + MarkdownDescription: "Provides a resource for managaing item (file, folder, or repository) properties. When a folder is used property attachment is recursive by default. See [JFrog documentation](https://jfrog.com/help/r/jfrog-artifactory-documentation/working-with-jfrog-properties) for more details.", + } +} + +func (r *ItemPropertiesResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + r.ProviderData = req.ProviderData.(util.ProviderMetadata) +} + +func (r *ItemPropertiesResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + go util.SendUsageResourceCreate(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var plan ItemPropertiesResourceModel + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var propertiesString string + resp.Diagnostics.Append(plan.toPropertiesQueryParamsString(ctx, &propertiesString)...) + if resp.Diagnostics.HasError() { + return + } + + isRecursive := 0 + if plan.IsRecursive.ValueBool() { + isRecursive = 1 + } + + request, url := plan.GetAPIRequestAndURL(r.ProviderData.Client.R(), itemPropertiesEndpoint) + + response, err := request. + SetQueryParams(map[string]string{ + "properties": propertiesString, + "recursive": fmt.Sprintf("%d", isRecursive), + }). + Put(url) + + if err != nil { + utilfw.UnableToCreateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToCreateResourceError(resp, response.String()) + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *ItemPropertiesResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + go util.SendUsageResourceRead(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var state ItemPropertiesResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + request, url := state.GetAPIRequestAndURL(r.ProviderData.Client.R(), itemPropertiesEndpoint) + + // Convert from Terraform data model into API data model + var properties ItemPropertiesGetAPIModel + response, err := request. + SetQueryParam("properties", ""). + SetResult(&properties). + Get(url) + + if err != nil { + utilfw.UnableToRefreshResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToRefreshResourceError(resp, response.String()) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(state.fromAPIModel(ctx, properties)...) + if resp.Diagnostics.HasError() { + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *ItemPropertiesResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + go util.SendUsageResourceUpdate(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var plan ItemPropertiesResourceModel + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state ItemPropertiesResourceModel + // Read Terraform state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + var planProperties map[string][]string + resp.Diagnostics.Append(plan.Properties.ElementsAs(ctx, &planProperties, false)...) + if resp.Diagnostics.HasError() { + return + } + + var stateProperties map[string][]string + resp.Diagnostics.Append(state.Properties.ElementsAs(ctx, &stateProperties, false)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "Update", map[string]interface{}{ + "planProperties": planProperties, + "stateProperties": stateProperties, + }) + + _, propKeysToRemove := lo.Difference( + lo.Keys(planProperties), + lo.Keys(stateProperties), + ) + + props := lo.MapEntries( + planProperties, + func(k string, v []string) (string, *string) { + str := strings.Join(v, ",") + return k, &str + }, + ) + + for _, key := range propKeysToRemove { + props[key] = nil + } + + updateProps := ItemPropertiesPatchAPIModel{ + Props: props, + } + + isRecursive := 0 + if plan.IsRecursive.ValueBool() { + isRecursive = 1 + } + + request, url := plan.GetAPIRequestAndURL(r.ProviderData.Client.R(), "artifactory/api/metadata/{repo_key}") + + response, err := request. + SetQueryParam("recursiveProperties", fmt.Sprintf("%d", isRecursive)). + SetBody(updateProps). + Patch(url) + + if err != nil { + utilfw.UnableToUpdateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToUpdateResourceError(resp, response.String()) + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *ItemPropertiesResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + go util.SendUsageResourceDelete(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var state ItemPropertiesResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + var propertiesString string + resp.Diagnostics.Append(state.toPropertiesQueryParamsString(ctx, &propertiesString)...) + if resp.Diagnostics.HasError() { + return + } + + isRecursive := 0 + if state.IsRecursive.ValueBool() { + isRecursive = 1 + } + + request, url := state.GetAPIRequestAndURL(r.ProviderData.Client.R(), itemPropertiesEndpoint) + + response, err := request. + SetQueryParams(map[string]string{ + "properties": propertiesString, + "recursive": fmt.Sprintf("%d", isRecursive), + }). + Delete(url) + + if err != nil { + utilfw.UnableToDeleteResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToDeleteResourceError(resp, response.String()) + return + } + + // If the logic reaches here, it implicitly succeeded and will remove + // the resource from state if there are no other errors. +} + +// ImportState imports the resource into the Terraform state. +func (r *ItemPropertiesResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + parts := strings.SplitN(req.ID, ":", 2) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, fw_path.Root("repo_key"), parts[0])...) + + if len(parts) == 2 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, fw_path.Root("item_path"), parts[1])...) + } +} diff --git a/pkg/artifactory/resource/artifact/resource_artifactory_item_properties_test.go b/pkg/artifactory/resource/artifact/resource_artifactory_item_properties_test.go new file mode 100644 index 000000000..add6a5844 --- /dev/null +++ b/pkg/artifactory/resource/artifact/resource_artifactory_item_properties_test.go @@ -0,0 +1,242 @@ +package artifact_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/jfrog/terraform-provider-artifactory/v11/pkg/acctest" + "github.com/jfrog/terraform-provider-shared/testutil" + "github.com/jfrog/terraform-provider-shared/util" +) + +func TestAccItemProperties_repo_only(t *testing.T) { + _, fqrn, name := testutil.MkNames("test-item-properties-", "artifactory_item_properties") + _, _, repoName := testutil.MkNames("test-generic-local", "artifactory_local_generic_repository") + + temp := ` + resource "artifactory_local_generic_repository" "{{ .repoName }}" { + key = "{{ .repoName }}" + } + + resource "artifactory_item_properties" "{{ .name }}" { + repo_key = artifactory_local_generic_repository.{{ .repoName }}.key + properties = { + "key1" = ["value1", "value2"], + "key2" = ["value3", "value4"] + } + is_recursive = true + }` + + testData := map[string]string{ + "name": name, + "repoName": repoName, + } + config := util.ExecuteTemplate(name, temp, testData) + + updatedTemp := ` + resource "artifactory_local_generic_repository" "{{ .repoName }}" { + key = "{{ .repoName }}" + } + + resource "artifactory_item_properties" "{{ .name }}" { + repo_key = artifactory_local_generic_repository.{{ .repoName }}.key + properties = { + "key1" = ["value1"] + "key3" = ["value5"] + } + is_recursive = false + }` + updatedConfig := util.ExecuteTemplate(name, updatedTemp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6MuxProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "repo_key", repoName), + resource.TestCheckNoResourceAttr(fqrn, "item_path"), + resource.TestCheckResourceAttr(fqrn, "properties.%", "2"), + resource.TestCheckResourceAttr(fqrn, "properties.key1.#", "2"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key1.*", "value1"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key1.*", "value2"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key2.*", "value3"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key2.*", "value4"), + resource.TestCheckResourceAttr(fqrn, "is_recursive", "true"), + ), + }, + { + Config: updatedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "repo_key", repoName), + resource.TestCheckNoResourceAttr(fqrn, "item_path"), + resource.TestCheckResourceAttr(fqrn, "properties.%", "2"), + resource.TestCheckResourceAttr(fqrn, "properties.key1.#", "1"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key1.*", "value1"), + resource.TestCheckResourceAttr(fqrn, "properties.key3.#", "1"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key3.*", "value5"), + resource.TestCheckResourceAttr(fqrn, "is_recursive", "false"), + ), + }, + { + ResourceName: fqrn, + ImportState: true, + ImportStateId: testData["repoName"], + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "repo_key", + ImportStateVerifyIgnore: []string{"is_recursive"}, + }, + }, + }) +} + +func createRepoPath(t *testing.T, repoKey, path string) { + restyClient := acctest.GetTestResty(t) + response, err := restyClient.R(). + SetRawPathParams(map[string]string{ + "repo_key": repoKey, + "path": path, + }). + Put("artifactory/{repo_key}/{path}") + + if err != nil { + t.Error(err) + } + + if response.IsError() { + t.Error(response.String()) + } +} + +func TestAccItemProperties_repo_with_path(t *testing.T) { + _, fqrn, name := testutil.MkNames("test-item-properties-", "artifactory_item_properties") + _, _, repoName := testutil.MkNames("test-generic-local", "artifactory_local_generic_repository") + + temp := ` + resource "artifactory_item_properties" "{{ .name }}" { + repo_key = "{{ .repoName }}" + item_path = "foo/bar" + properties = { + "key1" = ["value1", "value2"], + "key2" = ["value3", "value4"] + } + is_recursive = true + }` + + testData := map[string]string{ + "name": name, + "repoName": repoName, + } + config := util.ExecuteTemplate(name, temp, testData) + + updatedTemp := ` + resource "artifactory_item_properties" "{{ .name }}" { + repo_key = "{{ .repoName }}" + item_path = "foo/bar" + properties = { + "key1" = ["value1"] + "key3" = ["value5"] + } + is_recursive = false + }` + updatedConfig := util.ExecuteTemplate(name, updatedTemp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + + acctest.CreateRepo(t, repoName, "local", "generic", false, false) + createRepoPath(t, repoName, "foo/bar") + }, + ProtoV6ProviderFactories: acctest.ProtoV6MuxProviderFactories, + CheckDestroy: func(_ *terraform.State) error { + acctest.DeleteRepo(t, repoName) + return nil + }, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "repo_key", repoName), + resource.TestCheckResourceAttr(fqrn, "item_path", "foo/bar"), + resource.TestCheckResourceAttr(fqrn, "properties.%", "2"), + resource.TestCheckResourceAttr(fqrn, "properties.key1.#", "2"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key1.*", "value1"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key1.*", "value2"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key2.*", "value3"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key2.*", "value4"), + resource.TestCheckResourceAttr(fqrn, "is_recursive", "true"), + ), + }, + { + Config: updatedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "repo_key", repoName), + resource.TestCheckResourceAttr(fqrn, "item_path", "foo/bar"), + resource.TestCheckResourceAttr(fqrn, "properties.%", "2"), + resource.TestCheckResourceAttr(fqrn, "properties.key1.#", "1"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key1.*", "value1"), + resource.TestCheckResourceAttr(fqrn, "properties.key3.#", "1"), + resource.TestCheckTypeSetElemAttr(fqrn, "properties.key3.*", "value5"), + resource.TestCheckResourceAttr(fqrn, "is_recursive", "false"), + ), + }, + { + ResourceName: fqrn, + ImportState: true, + ImportStateId: fmt.Sprintf("%s:foo/bar", testData["repoName"]), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "repo_key", + ImportStateVerifyIgnore: []string{"is_recursive"}, + }, + }, + }) +} + +func TestAccItemProperties_invalid_property_key_character(t *testing.T) { + invalidChars := []string{")", "(", "}", "{", "]", "[", "*", "+", "^", "$", "/", "~", "`", "!", "@", "#", "%", "&", "<", ">", ";", "=", ",", "±", "§", " "} + for _, invalidChar := range invalidChars { + t.Run(invalidChar, func(t *testing.T) { + resource.Test(testInvalidKey(invalidChar, t)) + }) + } +} + +func testInvalidKey(invalidChar string, t *testing.T) (*testing.T, resource.TestCase) { + _, _, name := testutil.MkNames("test-item-properties-", "artifactory_item_properties") + _, _, repoName := testutil.MkNames("test-generic-local", "artifactory_local_generic_repository") + + temp := ` + resource "artifactory_local_generic_repository" "{{ .repoName }}" { + key = "{{ .repoName }}" + } + + resource "artifactory_item_properties" "{{ .name }}" { + repo_key = artifactory_local_generic_repository.{{ .repoName }}.key + properties = { + "invalid_{{ .invalid_char }}_key" = ["value1", "value2"], + } + }` + + testData := map[string]string{ + "name": name, + "repoName": repoName, + "invalid_char": invalidChar, + } + config := util.ExecuteTemplate(name, temp, testData) + + return t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6MuxProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`.*must not contain the following special\n.*characters.*`), + }, + }, + } +}