diff --git a/docs/resources/repository_pypi_group.md b/docs/resources/repository_pypi_group.md index dfe1be3..1b0b335 100644 --- a/docs/resources/repository_pypi_group.md +++ b/docs/resources/repository_pypi_group.md @@ -55,3 +55,16 @@ Required: Optional: - `policy_names` (Set of String) Set of Cleanup Policies that will apply to this Repository + +## Import + +Import is supported using the following syntax: + +```shell +# Existing group pypi repository configuration can be imported as follows. +# +# NOTE: The Identifier REPOSITORY_NAME needs to match repository name in your sonatype nexus repository instance. + +# Example +terraform import sonatyperepo_repository_pypi_group.example REPOSITORY_NAME +``` diff --git a/docs/resources/repository_pypi_hosted.md b/docs/resources/repository_pypi_hosted.md index e4e3dbe..cbf12db 100644 --- a/docs/resources/repository_pypi_hosted.md +++ b/docs/resources/repository_pypi_hosted.md @@ -55,3 +55,16 @@ Optional: Optional: - `proprietary_components` (Boolean) Components in this repository count as proprietary for namespace conflict attacks (requires Sonatype Nexus Repository Firewall) + +## Import + +Import is supported using the following syntax: + +```shell +# Existing hosted pypi repository configuration can be imported as follows. +# +# NOTE: The Identifier REPOSITORY_NAME needs to match repository name in your sonatype nexus repository instance. + +# Example +terraform import sonatyperepo_repository_pypi_hosted.example REPOSITORY_NAME +``` diff --git a/docs/resources/repository_pypi_proxy.md b/docs/resources/repository_pypi_proxy.md index a196a09..280a12c 100644 --- a/docs/resources/repository_pypi_proxy.md +++ b/docs/resources/repository_pypi_proxy.md @@ -131,3 +131,16 @@ Required: Optional: - `asset_path_regex` (String) Regular Expression of Asset Paths to pull pre-emptively pull + +## Import + +Import is supported using the following syntax: + +```shell +# Existing proxy pypi repository configuration can be imported as follows. +# +# NOTE: The Identifier REPOSITORY_NAME needs to match repository name in your sonatype nexus repository instance. + +# Example +terraform import sonatyperepo_repository_pypi_proxy.example REPOSITORY_NAME +``` diff --git a/examples/resources/sonatyperepo_repository_pypi_group/import.sh b/examples/resources/sonatyperepo_repository_pypi_group/import.sh new file mode 100644 index 0000000..951e31a --- /dev/null +++ b/examples/resources/sonatyperepo_repository_pypi_group/import.sh @@ -0,0 +1,6 @@ +# Existing group pypi repository configuration can be imported as follows. +# +# NOTE: The Identifier REPOSITORY_NAME needs to match repository name in your sonatype nexus repository instance. + +# Example +terraform import sonatyperepo_repository_pypi_group.example REPOSITORY_NAME diff --git a/examples/resources/sonatyperepo_repository_pypi_hosted/import.sh b/examples/resources/sonatyperepo_repository_pypi_hosted/import.sh new file mode 100644 index 0000000..d8930a3 --- /dev/null +++ b/examples/resources/sonatyperepo_repository_pypi_hosted/import.sh @@ -0,0 +1,6 @@ +# Existing hosted pypi repository configuration can be imported as follows. +# +# NOTE: The Identifier REPOSITORY_NAME needs to match repository name in your sonatype nexus repository instance. + +# Example +terraform import sonatyperepo_repository_pypi_hosted.example REPOSITORY_NAME diff --git a/examples/resources/sonatyperepo_repository_pypi_proxy/import.sh b/examples/resources/sonatyperepo_repository_pypi_proxy/import.sh new file mode 100644 index 0000000..302e1ff --- /dev/null +++ b/examples/resources/sonatyperepo_repository_pypi_proxy/import.sh @@ -0,0 +1,6 @@ +# Existing proxy pypi repository configuration can be imported as follows. +# +# NOTE: The Identifier REPOSITORY_NAME needs to match repository name in your sonatype nexus repository instance. + +# Example +terraform import sonatyperepo_repository_pypi_proxy.example REPOSITORY_NAME diff --git a/internal/provider/model/repository_pypi.go b/internal/provider/model/repository_pypi.go index fc1a9cc..59fd383 100644 --- a/internal/provider/model/repository_pypi.go +++ b/internal/provider/model/repository_pypi.go @@ -30,18 +30,7 @@ type RepositoryPyPiHostedModel struct { } func (m *RepositoryPyPiHostedModel) FromApiModel(api sonatyperepo.SimpleApiHostedRepository) { - m.Name = types.StringPointerValue(api.Name) - m.Online = types.BoolValue(api.Online) - m.Url = types.StringPointerValue(api.Url) - - // Cleanup - if api.Cleanup != nil && len(api.Cleanup.PolicyNames) > 0 { - m.Cleanup = NewRepositoryCleanupModel() - mapCleanupFromApi(api.Cleanup, m.Cleanup) - } - - // Storage - m.Storage.MapFromApi(&api.Storage) + m.mapSimpleApiHostedRepository(api) } func (m *RepositoryPyPiHostedModel) ToApiCreateModel() sonatyperepo.PypiHostedRepositoryApiRequest { @@ -94,10 +83,21 @@ func (m *RepositoryPyPiProxyModel) FromApiModel(api sonatyperepo.PyPiProxyApiRep m.HttpClient.MapFromApiHttpClientAttributes(&api.HttpClient) m.RoutingRule = types.StringPointerValue(api.RoutingRuleName) if api.Replication != nil { + m.Replication = &RepositoryReplicationModel{} m.Replication.MapFromApi(api.Replication) + } else { + // Set default values when API doesn't provide replication data + m.Replication = &RepositoryReplicationModel{ + PreemptivePullEnabled: types.BoolValue(common.DEFAULT_PROXY_PREEMPTIVE_PULL), + AssetPathRegex: types.StringNull(), + } } // PyPi + // pypi is required for PyPi proxy repositories, so always populate it + if m.PyPi == nil { + m.PyPi = &ProxyRemoveQuarrantiedModel{} + } m.PyPi.MapFromPyPiApi(&api.Pypi) } diff --git a/internal/provider/repository/format/common.go b/internal/provider/repository/format/common.go index 347dac7..3d8ac08 100644 --- a/internal/provider/repository/format/common.go +++ b/internal/provider/repository/format/common.go @@ -92,32 +92,50 @@ func (f *BaseRepositoryFormat) ValidateRepositoryForImport(repositoryData any, e // Get Format field formatField := v.FieldByName("Format") - if !formatField.IsValid() || formatField.IsNil() { + if !formatField.IsValid() { return fmt.Errorf(errRepositoryFormatNil, expectedFormat) } - // Extract the string value from the *string - formatPtr := formatField.Interface().(*string) - actualFormat := strings.ToLower(*formatPtr) - expectedFormatLower := strings.ToLower(expectedFormat) + // Handle both *string and string types + var actualFormat string + if formatField.Kind() == reflect.Ptr { + if formatField.IsNil() { + return fmt.Errorf(errRepositoryFormatNil, expectedFormat) + } + formatPtr := formatField.Interface().(*string) + actualFormat = strings.ToLower(*formatPtr) + } else { + actualFormat = strings.ToLower(formatField.Interface().(string)) + } + expectedFormatLower := strings.ToLower(expectedFormat) if actualFormat != expectedFormatLower { - return fmt.Errorf(errRepositoryFormatMismatch, *formatPtr, expectedFormat) + return fmt.Errorf(errRepositoryFormatMismatch, actualFormat, expectedFormat) } // Get Type field typeField := v.FieldByName("Type") - if !typeField.IsValid() || typeField.IsNil() { + if !typeField.IsValid() { expectedTypeStr := expectedType.String() return fmt.Errorf(errRepositoryTypeNil, expectedTypeStr) } - // Extract the string value from the *string - typePtr := typeField.Interface().(*string) - expectedTypeStr := expectedType.String() + // Handle both *string and string types + var actualType string + if typeField.Kind() == reflect.Ptr { + if typeField.IsNil() { + expectedTypeStr := expectedType.String() + return fmt.Errorf(errRepositoryTypeNil, expectedTypeStr) + } + typePtr := typeField.Interface().(*string) + actualType = *typePtr + } else { + actualType = typeField.Interface().(string) + } - if *typePtr != expectedTypeStr { - return fmt.Errorf(errRepositoryTypeMismatch, *typePtr, expectedTypeStr) + expectedTypeStr := expectedType.String() + if actualType != expectedTypeStr { + return fmt.Errorf(errRepositoryTypeMismatch, actualType, expectedTypeStr) } return nil diff --git a/internal/provider/repository/format/pypi.go b/internal/provider/repository/format/pypi.go index 4150aee..57ccc9b 100644 --- a/internal/provider/repository/format/pypi.go +++ b/internal/provider/repository/format/pypi.go @@ -76,6 +76,9 @@ func (f *PyPiRepositoryFormatHosted) DoReadRequest(state any, apiClient *sonatyp // Call to API to Read apiResponse, httpResponse, err := apiClient.RepositoryManagementAPI.GetPypiHostedRepository(ctx, stateModel.Name.ValueString()).Execute() + if err != nil { + return nil, httpResponse, err + } return *apiResponse, httpResponse, err } @@ -111,11 +114,25 @@ func (f *PyPiRepositoryFormatHosted) UpdatePlanForState(plan any) any { } func (f *PyPiRepositoryFormatHosted) UpdateStateFromApi(state any, api any) any { - stateModel := (state).(model.RepositoryPyPiHostedModel) + var stateModel model.RepositoryPyPiHostedModel + // During import, state might be nil, so we create a new model + if state != nil { + stateModel = (state).(model.RepositoryPyPiHostedModel) + } stateModel.FromApiModel((api).(sonatyperepo.SimpleApiHostedRepository)) return stateModel } +// DoImportRequest implements the import functionality for PyPI Hosted repositories +func (f *PyPiRepositoryFormatHosted) DoImportRequest(repositoryName string, apiClient *sonatyperepo.APIClient, ctx context.Context) (any, *http.Response, error) { + // Call to API to Read repository for import + apiResponse, httpResponse, err := apiClient.RepositoryManagementAPI.GetPypiHostedRepository(ctx, repositoryName).Execute() + if err != nil { + return nil, httpResponse, err + } + return *apiResponse, httpResponse, nil +} + // -------------------------------------------- // PROXY PyPi Format Functions // -------------------------------------------- @@ -133,6 +150,9 @@ func (f *PyPiRepositoryFormatProxy) DoReadRequest(state any, apiClient *sonatype // Call to API to Read apiResponse, httpResponse, err := apiClient.RepositoryManagementAPI.GetPypiProxyRepository(ctx, stateModel.Name.ValueString()).Execute() + if err != nil { + return nil, httpResponse, err + } return *apiResponse, httpResponse, err } @@ -170,11 +190,25 @@ func (f *PyPiRepositoryFormatProxy) UpdatePlanForState(plan any) any { } func (f *PyPiRepositoryFormatProxy) UpdateStateFromApi(state any, api any) any { - stateModel := (state).(model.RepositoryPyPiProxyModel) + var stateModel model.RepositoryPyPiProxyModel + // During import, state might be nil, so we create a new model + if state != nil { + stateModel = (state).(model.RepositoryPyPiProxyModel) + } stateModel.FromApiModel((api).(sonatyperepo.PyPiProxyApiRepository)) return stateModel } +// DoImportRequest implements the import functionality for PyPI Proxy repositories +func (f *PyPiRepositoryFormatProxy) DoImportRequest(repositoryName string, apiClient *sonatyperepo.APIClient, ctx context.Context) (any, *http.Response, error) { + // Call to API to Read repository for import + apiResponse, httpResponse, err := apiClient.RepositoryManagementAPI.GetPypiProxyRepository(ctx, repositoryName).Execute() + if err != nil { + return nil, httpResponse, err + } + return *apiResponse, httpResponse, nil +} + // -------------------------------------------- // GORUP PyPi Format Functions // -------------------------------------------- @@ -192,6 +226,9 @@ func (f *PyPiRepositoryFormatGroup) DoReadRequest(state any, apiClient *sonatype // Call to API to Read apiResponse, httpResponse, err := apiClient.RepositoryManagementAPI.GetPypiGroupRepository(ctx, stateModel.Name.ValueString()).Execute() + if err != nil { + return nil, httpResponse, err + } return *apiResponse, httpResponse, err } @@ -227,11 +264,25 @@ func (f *PyPiRepositoryFormatGroup) UpdatePlanForState(plan any) any { } func (f *PyPiRepositoryFormatGroup) UpdateStateFromApi(state any, api any) any { - stateModel := (state).(model.RepositoryPyPiGroupModel) + var stateModel model.RepositoryPyPiGroupModel + // During import, state might be nil, so we create a new model + if state != nil { + stateModel = (state).(model.RepositoryPyPiGroupModel) + } stateModel.FromApiModel((api).(sonatyperepo.SimpleApiGroupDeployRepository)) return stateModel } +// DoImportRequest implements the import functionality for PyPI Group repositories +func (f *PyPiRepositoryFormatGroup) DoImportRequest(repositoryName string, apiClient *sonatyperepo.APIClient, ctx context.Context) (any, *http.Response, error) { + // Call to API to Read repository for import + apiResponse, httpResponse, err := apiClient.RepositoryManagementAPI.GetPypiGroupRepository(ctx, repositoryName).Execute() + if err != nil { + return nil, httpResponse, err + } + return *apiResponse, httpResponse, nil +} + // -------------------------------------------- // Common Functions // -------------------------------------------- diff --git a/internal/provider/repository/repository_common.go b/internal/provider/repository/repository_common.go index c5dde46..6236ac5 100644 --- a/internal/provider/repository/repository_common.go +++ b/internal/provider/repository/repository_common.go @@ -381,7 +381,7 @@ func (r *repositoryResource) ImportState(ctx context.Context, req resource.Impor if httpResponse != nil && httpResponse.StatusCode == http.StatusNotFound { resp.Diagnostics.AddError( fmt.Sprintf("Repository '%s' not found", repositoryName), - fmt.Sprintf("The %s %s repository '%s' does not exist or you do not have permission to access it.", + fmt.Sprintf("The %s %s repository '%s' does not exist or you do not have permission to access it.", r.RepositoryFormat.GetKey(), r.RepositoryType.String(), repositoryName), ) } else { @@ -408,7 +408,7 @@ func (r *repositoryResource) ImportState(ctx context.Context, req resource.Impor // UpdateStateFromApi expects an empty instance of the proper model type and returns a populated one // Pass nil as the first parameter - UpdateStateFromApi will create the proper model type stateModel := r.RepositoryFormat.UpdateStateFromApi(nil, apiResponse) - + // Update plan for state (sets last_updated timestamp) stateModel = r.RepositoryFormat.UpdatePlanForState(stateModel) @@ -504,4 +504,4 @@ func getHostedStandardSchema(repoFormat string, repoType format.RepositoryType) }, }, } -} \ No newline at end of file +} diff --git a/internal/provider/repository/repository_pypi_x_resources_test.go b/internal/provider/repository/repository_pypi_x_resources_test.go index ea66bc0..40048b7 100644 --- a/internal/provider/repository/repository_pypi_x_resources_test.go +++ b/internal/provider/repository/repository_pypi_x_resources_test.go @@ -172,3 +172,153 @@ resource "%s" "repo" { }, }) } + +func TestAccRepositoryPyPiHostedImport(t *testing.T) { + randomString := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceType := "sonatyperepo_repository_pypi_hosted" + resourceName := fmt.Sprintf(utils_test.RES_NAME_FORMAT, resourceType) + repoName := fmt.Sprintf("pypi-hosted-import-%s", randomString) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: utils_test.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with minimal configuration + { + Config: fmt.Sprintf(utils_test.ProviderConfig+` +resource "%s" "repo" { + name = "%s" + online = true + storage = { + blob_store_name = "default" + strict_content_type_validation = true + write_policy = "ALLOW_ONCE" + } +} +`, resourceType, repoName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", repoName), + resource.TestCheckResourceAttr(resourceName, "online", "true"), + ), + }, + // Import and verify no changes + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateId: repoName, + ImportStateVerifyIdentifierAttribute: "name", + ImportStateVerifyIgnore: []string{"last_updated"}, + }, + }, + }) +} + +func TestAccRepositoryPyPiProxyImport(t *testing.T) { + randomString := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceType := "sonatyperepo_repository_pypi_proxy" + resourceName := fmt.Sprintf(utils_test.RES_NAME_FORMAT, resourceType) + repoName := fmt.Sprintf("pypi-proxy-import-%s", randomString) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: utils_test.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with minimal configuration + { + Config: fmt.Sprintf(utils_test.ProviderConfig+` +resource "%s" "repo" { + name = "%s" + online = true + storage = { + blob_store_name = "default" + strict_content_type_validation = true + } + proxy = { + remote_url = "https://pypi.org/" + content_max_age = 1440 + metadata_max_age = 1440 + } + negative_cache = { + enabled = true + time_to_live = 1440 + } + http_client = { + blocked = false + auto_block = true + } + pypi = { + remove_quarrantined = true + } +} +`, resourceType, repoName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", repoName), + resource.TestCheckResourceAttr(resourceName, "online", "true"), + ), + }, + // Import and verify no changes + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateId: repoName, + ImportStateVerifyIdentifierAttribute: "name", + ImportStateVerifyIgnore: []string{"last_updated"}, + }, + }, + }) +} + +func TestAccRepositoryPyPiGroupImport(t *testing.T) { + randomString := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceType := "sonatyperepo_repository_pypi_group" + resourceTypeHosted := "sonatyperepo_repository_pypi_hosted" + resourceName := fmt.Sprintf(utils_test.RES_NAME_FORMAT, resourceType) + repoName := fmt.Sprintf("pypi-group-import-%s", randomString) + memberName := fmt.Sprintf("pypi-hosted-member-%s", randomString) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: utils_test.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with minimal configuration + { + Config: fmt.Sprintf(utils_test.ProviderConfig+` +resource "%s" "member" { + name = "%s" + online = true + storage = { + blob_store_name = "default" + strict_content_type_validation = true + write_policy = "ALLOW" + } +} + +resource "%s" "repo" { + name = "%s" + online = true + storage = { + blob_store_name = "default" + strict_content_type_validation = true + } + group = { + member_names = ["%s"] + } + depends_on = [%s.member] +} +`, resourceTypeHosted, memberName, resourceType, repoName, memberName, resourceTypeHosted), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", repoName), + resource.TestCheckResourceAttr(resourceName, "online", "true"), + ), + }, + // Import and verify no changes + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateId: repoName, + ImportStateVerifyIdentifierAttribute: "name", + ImportStateVerifyIgnore: []string{"last_updated"}, + }, + }, + }) +}