diff --git a/README.md b/README.md index 900395c..9049d39 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,27 @@ Available resource types: | `upstream_tls_timeout` | No | `0` | Upstream TLS handshake timeout in seconds (0 = server default) | | `upstream_response_header_timeout` | No | `0` | Upstream response-header timeout in seconds (0 = server default) | +#### rpm / deb / alpine-specific Attributes + +| Attribute | Default | Description | +|--------------|---------|-------------------------------------------------------------------------------------------------| +| `mirrorlist` | | Extra upstream mirror base URLs. Requests are load-balanced with failover across `base_url` + `mirrorlist`. | + +Only valid on the `artifactapi_remote_rpm`, `artifactapi_remote_deb`, and +`artifactapi_remote_alpine` resources. Setting it on any other remote type is +rejected at plan time. + +```hcl +resource "artifactapi_remote_rpm" "epel" { + name = "epel-9" + base_url = "https://download.example.com/pub/epel/9/Everything/x86_64" + mirrorlist = [ + "https://mirror-a.example.net/epel/9/Everything/x86_64", + "https://mirror-b.example.org/epel/9/Everything/x86_64", + ] +} +``` + #### Docker-specific Attributes | Attribute | Default | Description | diff --git a/examples/resources/artifactapi_remote_rpm/main.tf b/examples/resources/artifactapi_remote_rpm/main.tf index 8df1c2d..ebba1e8 100644 --- a/examples/resources/artifactapi_remote_rpm/main.tf +++ b/examples/resources/artifactapi_remote_rpm/main.tf @@ -18,6 +18,13 @@ resource "artifactapi_remote_rpm" "almalinux" { base_url = "https://gsl-syd.mm.fcix.net/almalinux" description = "AlmaLinux RPM package repository" + # Extra mirrors; requests are load-balanced with failover across + # base_url + mirrorlist (rpm/deb/alpine remotes only). + mirrorlist = [ + "https://mirror.aarnet.edu.au/pub/almalinux", + "https://mirror.realcompute.io/almalinux", + ] + immutable_ttl = 0 mutable_ttl = 7200 } diff --git a/go.mod b/go.mod index 26d5e80..87837c0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module git.unkin.net/unkin/terraform-provider-artifactapi go 1.25.9 -require github.com/hashicorp/terraform-plugin-framework v1.15.0 +require ( + github.com/hashicorp/terraform-plugin-framework v1.15.0 + github.com/hashicorp/terraform-plugin-go v0.28.0 +) require ( github.com/fatih/color v1.13.0 // indirect @@ -10,7 +13,6 @@ require ( github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/terraform-plugin-go v0.28.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.5 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect diff --git a/internal/provider/datasource_remote.go b/internal/provider/datasource_remote.go index 0fc2ff9..f695a68 100644 --- a/internal/provider/datasource_remote.go +++ b/internal/provider/datasource_remote.go @@ -30,6 +30,7 @@ func (d *remoteDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, "name": schema.StringAttribute{Required: true}, "package_type": schema.StringAttribute{Computed: true}, "base_url": schema.StringAttribute{Computed: true}, + "mirrorlist": schema.ListAttribute{Computed: true, ElementType: types.StringType}, "description": schema.StringAttribute{Computed: true}, "immutable_ttl": schema.Int64Attribute{Computed: true}, "mutable_ttl": schema.Int64Attribute{Computed: true}, @@ -57,6 +58,7 @@ type remoteDataSourceModel struct { Name types.String `tfsdk:"name"` PackageType types.String `tfsdk:"package_type"` BaseURL types.String `tfsdk:"base_url"` + Mirrorlist types.List `tfsdk:"mirrorlist"` Description types.String `tfsdk:"description"` ImmutableTTL types.Int64 `tfsdk:"immutable_ttl"` MutableTTL types.Int64 `tfsdk:"mutable_ttl"` @@ -107,6 +109,7 @@ func (d *remoteDataSource) Read(ctx context.Context, req datasource.ReadRequest, Name: types.StringValue(remote.Name), PackageType: types.StringValue(remote.PackageType), BaseURL: types.StringValue(remote.BaseURL), + Mirrorlist: stringsToList(ctx, remote.Mirrorlist), Description: types.StringValue(remote.Description), ImmutableTTL: types.Int64Value(remote.ImmutableTTL), MutableTTL: types.Int64Value(remote.MutableTTL), diff --git a/internal/provider/models.go b/internal/provider/models.go index fd6cc7f..d7ffcaf 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -5,6 +5,7 @@ type remoteAPI struct { PackageType string `json:"package_type"` RepoType string `json:"repo_type,omitempty"` BaseURL string `json:"base_url"` + Mirrorlist []string `json:"mirrorlist,omitempty"` Description string `json:"description,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` diff --git a/internal/provider/resource_remote.go b/internal/provider/resource_remote.go index 6ec6ea5..877fd85 100644 --- a/internal/provider/resource_remote.go +++ b/internal/provider/resource_remote.go @@ -16,10 +16,15 @@ import ( ) var ( - _ resource.Resource = &remoteResource{} - _ resource.ResourceWithImportState = &remoteResource{} + _ resource.Resource = &remoteResource{} + _ resource.ResourceWithImportState = &remoteResource{} + _ resource.ResourceWithValidateConfig = &remoteResource{} ) +// mirrorlistPackageTypes are the remote package types for which the mirrorlist +// attribute is valid; the API load-balances base_url + mirrorlist for these. +var mirrorlistPackageTypes = map[string]bool{"rpm": true, "deb": true, "alpine": true} + type remoteResource struct { client *apiClient packageType string @@ -28,6 +33,7 @@ type remoteResource struct { type remoteResourceModel struct { Name types.String `tfsdk:"name"` BaseURL types.String `tfsdk:"base_url"` + Mirrorlist types.List `tfsdk:"mirrorlist"` Description types.String `tfsdk:"description"` Username types.String `tfsdk:"username"` Password types.String `tfsdk:"password"` @@ -90,6 +96,11 @@ func (r *remoteResource) Schema(_ context.Context, _ resource.SchemaRequest, res Description: "Upstream repository base URL.", Required: true, }, + "mirrorlist": schema.ListAttribute{ + Description: "Extra upstream mirror base URLs; only valid on remote rpm/deb/apk (alpine) repos. Requests are load-balanced with failover across base_url + mirrorlist.", + Optional: true, + ElementType: types.StringType, + }, "description": schema.StringAttribute{ Optional: true, Computed: true, Default: stringdefault.StaticString(""), }, @@ -268,7 +279,26 @@ func (r *remoteResource) ImportState(ctx context.Context, req resource.ImportSta resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) } +func (r *remoteResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var config remoteResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + if config.Mirrorlist.IsNull() || config.Mirrorlist.IsUnknown() { + return + } + if !mirrorlistPackageTypes[r.packageType] { + resp.Diagnostics.AddAttributeError( + path.Root("mirrorlist"), + "mirrorlist not supported for this remote type", + fmt.Sprintf("mirrorlist is only valid on remote rpm/deb/apk (alpine) repos, not %q remotes.", r.packageType), + ) + } +} + func reconcileOptionalLists(prior, current *remoteResourceModel) { + current.Mirrorlist = preserveListNullEmptySemantics(prior.Mirrorlist, current.Mirrorlist) current.Patterns = preserveListNullEmptySemantics(prior.Patterns, current.Patterns) current.Blocklist = preserveListNullEmptySemantics(prior.Blocklist, current.Blocklist) current.MutablePatterns = preserveListNullEmptySemantics(prior.MutablePatterns, current.MutablePatterns) @@ -295,6 +325,7 @@ func (r *remoteResource) modelToAPI(ctx context.Context, m remoteResourceModel) UpstreamTLSTimeout: m.UpstreamTLSTimeout.ValueInt64(), UpstreamResponseHeaderTimeout: m.UpstreamResponseHeaderTimeout.ValueInt64(), } + api.Mirrorlist = listToStrings(ctx, m.Mirrorlist) api.Patterns = listToStrings(ctx, m.Patterns) api.Blocklist = listToStrings(ctx, m.Blocklist) api.MutablePatterns = listToStrings(ctx, m.MutablePatterns) @@ -309,6 +340,7 @@ func (r *remoteResource) apiToModel(ctx context.Context, api remoteAPI) remoteRe m := remoteResourceModel{ Name: types.StringValue(api.Name), BaseURL: types.StringValue(api.BaseURL), + Mirrorlist: stringsToList(ctx, api.Mirrorlist), Description: types.StringValue(api.Description), Username: types.StringValue(api.Username), Password: types.StringValue(api.Password), diff --git a/internal/provider/resource_remote_test.go b/internal/provider/resource_remote_test.go index 151e316..77de3d6 100644 --- a/internal/provider/resource_remote_test.go +++ b/internal/provider/resource_remote_test.go @@ -5,7 +5,9 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestModelToAPI_FullFields(t *testing.T) { @@ -540,3 +542,128 @@ func TestNewRemoteResource_Constructors(t *testing.T) { }) } } + +func TestModelToAPI_Mirrorlist(t *testing.T) { + ctx := context.Background() + mirrors := []string{"https://mirror-a.example.com/rpm", "https://mirror-b.example.com/rpm"} + + for _, pkgType := range []string{"rpm", "deb", "alpine"} { + t.Run(pkgType, func(t *testing.T) { + r := &remoteResource{packageType: pkgType} + model := remoteResourceModel{ + Name: types.StringValue("mirror-remote"), + BaseURL: types.StringValue("https://primary.example.com"), + Mirrorlist: stringsToList(ctx, mirrors), + } + api := r.modelToAPI(ctx, model) + if len(api.Mirrorlist) != 2 || api.Mirrorlist[0] != mirrors[0] || api.Mirrorlist[1] != mirrors[1] { + t.Errorf("Mirrorlist: expected %v, got %v", mirrors, api.Mirrorlist) + } + }) + } +} + +func TestModelToAPI_MirrorlistNull(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "rpm"} + model := remoteResourceModel{ + Name: types.StringValue("no-mirror"), + BaseURL: types.StringValue("https://primary.example.com"), + Mirrorlist: types.ListNull(types.StringType), + } + api := r.modelToAPI(ctx, model) + if api.Mirrorlist != nil { + t.Errorf("Mirrorlist: expected nil (omitted) for null list, got %v", api.Mirrorlist) + } +} + +func TestAPIToModel_Mirrorlist(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "rpm"} + mirrors := []string{"https://mirror-a.example.com/rpm", "https://mirror-b.example.com/rpm"} + + // Round-trip: mirrorlist survives API -> Model -> API without churn. + api := remoteAPI{Name: "rt", PackageType: "rpm", BaseURL: "https://primary.example.com", Mirrorlist: mirrors} + model := r.apiToModel(ctx, api) + got := listToStrings(ctx, model.Mirrorlist) + if len(got) != 2 || got[0] != mirrors[0] || got[1] != mirrors[1] { + t.Errorf("Mirrorlist round-trip: expected %v, got %v", mirrors, got) + } + + // nil mirrorlist maps to a null list (not empty) to avoid perpetual diff. + empty := r.apiToModel(ctx, remoteAPI{Name: "e", PackageType: "rpm", BaseURL: "https://x"}) + if !empty.Mirrorlist.IsNull() { + t.Errorf("Mirrorlist: expected null for nil input, got %v", empty.Mirrorlist) + } +} + +func TestRemoteResource_SchemaHasMirrorlist(t *testing.T) { + r := &remoteResource{packageType: "rpm"} + var resp resource.SchemaResponse + r.Schema(context.Background(), resource.SchemaRequest{}, &resp) + if _, ok := resp.Schema.Attributes["mirrorlist"]; !ok { + t.Fatal("missing mirrorlist attribute in schema") + } +} + +// mirrorlistConfig builds a tfsdk.Config for the remote schema with every +// attribute null except mirrorlist, which is set to the given values (or null +// when values is nil). +func mirrorlistConfig(ctx context.Context, t *testing.T, r *remoteResource, values []string) tfsdk.Config { + t.Helper() + var resp resource.SchemaResponse + r.Schema(ctx, resource.SchemaRequest{}, &resp) + objType := resp.Schema.Type().TerraformType(ctx).(tftypes.Object) + + attrs := make(map[string]tftypes.Value, len(objType.AttributeTypes)) + for name, at := range objType.AttributeTypes { + attrs[name] = tftypes.NewValue(at, nil) + } + if values != nil { + elems := make([]tftypes.Value, len(values)) + for i, v := range values { + elems[i] = tftypes.NewValue(tftypes.String, v) + } + attrs["mirrorlist"] = tftypes.NewValue(objType.AttributeTypes["mirrorlist"], elems) + } + return tfsdk.Config{Schema: resp.Schema, Raw: tftypes.NewValue(objType, attrs)} +} + +func TestValidateConfig_Mirrorlist(t *testing.T) { + ctx := context.Background() + mirrors := []string{"https://mirror.example.com"} + + // Supported types accept mirrorlist. + for _, pkgType := range []string{"rpm", "deb", "alpine"} { + t.Run("allowed/"+pkgType, func(t *testing.T) { + r := &remoteResource{packageType: pkgType} + var resp resource.ValidateConfigResponse + r.ValidateConfig(ctx, resource.ValidateConfigRequest{Config: mirrorlistConfig(ctx, t, r, mirrors)}, &resp) + if resp.Diagnostics.HasError() { + t.Errorf("unexpected error for %s: %v", pkgType, resp.Diagnostics.Errors()) + } + }) + } + + // Unsupported types reject a set mirrorlist. + for _, pkgType := range []string{"docker", "pypi", "helm", "generic"} { + t.Run("rejected/"+pkgType, func(t *testing.T) { + r := &remoteResource{packageType: pkgType} + var resp resource.ValidateConfigResponse + r.ValidateConfig(ctx, resource.ValidateConfigRequest{Config: mirrorlistConfig(ctx, t, r, mirrors)}, &resp) + if !resp.Diagnostics.HasError() { + t.Errorf("expected error setting mirrorlist on %s remote", pkgType) + } + }) + } + + // A null mirrorlist is fine on any type. + t.Run("null-on-docker", func(t *testing.T) { + r := &remoteResource{packageType: "docker"} + var resp resource.ValidateConfigResponse + r.ValidateConfig(ctx, resource.ValidateConfigRequest{Config: mirrorlistConfig(ctx, t, r, nil)}, &resp) + if resp.Diagnostics.HasError() { + t.Errorf("unexpected error for null mirrorlist on docker: %v", resp.Diagnostics.Errors()) + } + }) +}