diff --git a/docs/resources/remote_alpine.md b/docs/resources/remote_alpine.md index 2f6833f..5eb4c3d 100644 --- a/docs/resources/remote_alpine.md +++ b/docs/resources/remote_alpine.md @@ -21,6 +21,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | `quarantine_enabled` | No | `false` | Enable quarantine for new artifacts | | `quarantine_days` | No | `3` | Days to quarantine new artifacts | | `stale_on_error` | No | `true` | Serve stale cache when upstream is unreachable | +| `serve_stale` | No | `false` | Let rpm/deb/alpine virtuals serve this member's last good index while it refreshes | | `upstream_dial_timeout` | No | `0` | Upstream TCP connect timeout in seconds (0 = server default) | | `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) | diff --git a/docs/resources/remote_deb.md b/docs/resources/remote_deb.md index f197e96..8908eda 100644 --- a/docs/resources/remote_deb.md +++ b/docs/resources/remote_deb.md @@ -21,6 +21,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | `quarantine_enabled` | No | `false` | Enable quarantine for new artifacts | | `quarantine_days` | No | `3` | Days to quarantine new artifacts | | `stale_on_error` | No | `true` | Serve stale cache when upstream is unreachable | +| `serve_stale` | No | `false` | Let rpm/deb/alpine virtuals serve this member's last good index while it refreshes | | `upstream_dial_timeout` | No | `0` | Upstream TCP connect timeout in seconds (0 = server default) | | `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) | diff --git a/docs/resources/remote_github_alpine.md b/docs/resources/remote_github_alpine.md index 281cf5c..66d096b 100644 --- a/docs/resources/remote_github_alpine.md +++ b/docs/resources/remote_github_alpine.md @@ -21,6 +21,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | `quarantine_enabled` | No | `false` | Enable quarantine for new artifacts | | `quarantine_days` | No | `3` | Days to quarantine new artifacts | | `stale_on_error` | No | `true` | Serve stale cache when upstream is unreachable | +| `serve_stale` | No | `false` | Let rpm/deb/alpine virtuals serve this member's last good index while it refreshes | | `upstream_dial_timeout` | No | `0` | Upstream TCP connect timeout in seconds (0 = server default) | | `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) | @@ -30,6 +31,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | Attribute | Default | Description | |-------------------|---------|----------------------------------------------------------| | `releases_remote` | `""` | Name of a backend remote that serves the download bytes. Terraform uses it for download URL rewriting; `github_rpm`/`github_deb`/`github_alpine` 302-redirect `.rpm`/`.deb`/`.apk` downloads to it. | +| `sync_interval` | `0` | Release re-scan interval in seconds, `0`..`2592000` (`0` = `mutable_ttl`). `github_rpm`/`github_deb`/`github_alpine` only; rejected at plan time elsewhere. | ## Notes diff --git a/docs/resources/remote_github_deb.md b/docs/resources/remote_github_deb.md index 8c4f51b..42008ee 100644 --- a/docs/resources/remote_github_deb.md +++ b/docs/resources/remote_github_deb.md @@ -21,6 +21,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | `quarantine_enabled` | No | `false` | Enable quarantine for new artifacts | | `quarantine_days` | No | `3` | Days to quarantine new artifacts | | `stale_on_error` | No | `true` | Serve stale cache when upstream is unreachable | +| `serve_stale` | No | `false` | Let rpm/deb/alpine virtuals serve this member's last good index while it refreshes | | `upstream_dial_timeout` | No | `0` | Upstream TCP connect timeout in seconds (0 = server default) | | `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) | @@ -30,6 +31,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | Attribute | Default | Description | |-------------------|---------|----------------------------------------------------------| | `releases_remote` | `""` | Name of a backend remote that serves the download bytes. Terraform uses it for download URL rewriting; `github_rpm`/`github_deb`/`github_alpine` 302-redirect `.rpm`/`.deb`/`.apk` downloads to it. | +| `sync_interval` | `0` | Release re-scan interval in seconds, `0`..`2592000` (`0` = `mutable_ttl`). `github_rpm`/`github_deb`/`github_alpine` only; rejected at plan time elsewhere. | ## Notes diff --git a/docs/resources/remote_github_rpm.md b/docs/resources/remote_github_rpm.md index 39eae13..462e7a1 100644 --- a/docs/resources/remote_github_rpm.md +++ b/docs/resources/remote_github_rpm.md @@ -21,6 +21,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | `quarantine_enabled` | No | `false` | Enable quarantine for new artifacts | | `quarantine_days` | No | `3` | Days to quarantine new artifacts | | `stale_on_error` | No | `true` | Serve stale cache when upstream is unreachable | +| `serve_stale` | No | `false` | Let rpm/deb/alpine virtuals serve this member's last good index while it refreshes | | `upstream_dial_timeout` | No | `0` | Upstream TCP connect timeout in seconds (0 = server default) | | `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) | @@ -30,6 +31,8 @@ Per-type remote resources manage upstream repository proxies. Each type applies | Attribute | Default | Description | |-------------------|---------|----------------------------------------------------------| | `releases_remote` | `""` | Name of a backend remote that serves the download bytes. Terraform uses it for download URL rewriting; `github_rpm`/`github_deb`/`github_alpine` 302-redirect `.rpm`/`.deb`/`.apk` downloads to it. | +| `cache_repodata` | `false` | Cache generated repodata (`github_rpm` only). | +| `sync_interval` | `0` | Release re-scan interval in seconds, `0`..`2592000` (`0` = `mutable_ttl`). `github_rpm`/`github_deb`/`github_alpine` only; rejected at plan time elsewhere. | ## Notes diff --git a/docs/resources/remote_rpm.md b/docs/resources/remote_rpm.md index 309d946..fcdcc46 100644 --- a/docs/resources/remote_rpm.md +++ b/docs/resources/remote_rpm.md @@ -21,6 +21,7 @@ Per-type remote resources manage upstream repository proxies. Each type applies | `quarantine_enabled` | No | `false` | Enable quarantine for new artifacts | | `quarantine_days` | No | `3` | Days to quarantine new artifacts | | `stale_on_error` | No | `true` | Serve stale cache when upstream is unreachable | +| `serve_stale` | No | `false` | Let rpm/deb/alpine virtuals serve this member's last good index while it refreshes | | `upstream_dial_timeout` | No | `0` | Upstream TCP connect timeout in seconds (0 = server default) | | `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) | diff --git a/docs/resources/virtual.md b/docs/resources/virtual.md index b2654df..f1c0c41 100644 --- a/docs/resources/virtual.md +++ b/docs/resources/virtual.md @@ -21,3 +21,4 @@ resource "artifactapi_virtual" "helm" { | `package_type` | Yes | Package type of member remotes | | `description` | No | Human-readable description | | `members` | Yes | List of remote names to include | +| `parallel_fetch` | No | Fetch each member's index files concurrently (rpm/deb); default `false` | diff --git a/examples/resources/artifactapi_remote_github_rpm/main.tf b/examples/resources/artifactapi_remote_github_rpm/main.tf index 9aef7c4..8a7f761 100644 --- a/examples/resources/artifactapi_remote_github_rpm/main.tf +++ b/examples/resources/artifactapi_remote_github_rpm/main.tf @@ -33,6 +33,12 @@ resource "artifactapi_remote_github_rpm" "acme_tools" { mutable_ttl = 3600 + # Optional: re-scan releases every 15 minutes, cache the generated repodata, + # and let virtuals serve the last good index while this remote refreshes. + sync_interval = 900 + cache_repodata = true + serve_stale = true + # Optional: restrict which release assets become packages. patterns = [ ".*\\.x86_64\\.rpm$", diff --git a/internal/provider/datasource_remote.go b/internal/provider/datasource_remote.go index d4a8663..717462f 100644 --- a/internal/provider/datasource_remote.go +++ b/internal/provider/datasource_remote.go @@ -47,6 +47,9 @@ func (d *remoteDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, "quarantine_enabled": schema.BoolAttribute{Computed: true}, "quarantine_days": schema.Int64Attribute{Computed: true}, "stale_on_error": schema.BoolAttribute{Computed: true}, + "serve_stale": schema.BoolAttribute{Computed: true}, + "cache_repodata": schema.BoolAttribute{Computed: true}, + "sync_interval": schema.Int64Attribute{Computed: true}, "releases_remote": schema.StringAttribute{Computed: true}, "managed_by": schema.StringAttribute{Computed: true}, @@ -76,6 +79,9 @@ type remoteDataSourceModel struct { QuarantineEnabled types.Bool `tfsdk:"quarantine_enabled"` QuarantineDays types.Int64 `tfsdk:"quarantine_days"` StaleOnError types.Bool `tfsdk:"stale_on_error"` + ServeStale types.Bool `tfsdk:"serve_stale"` + CacheRepodata types.Bool `tfsdk:"cache_repodata"` + SyncInterval types.Int64 `tfsdk:"sync_interval"` ReleasesRemote types.String `tfsdk:"releases_remote"` ManagedBy types.String `tfsdk:"managed_by"` @@ -128,6 +134,9 @@ func (d *remoteDataSource) Read(ctx context.Context, req datasource.ReadRequest, QuarantineEnabled: types.BoolValue(remote.QuarantineEnabled), QuarantineDays: types.Int64Value(remote.QuarantineDays), StaleOnError: types.BoolValue(remote.StaleOnError), + ServeStale: types.BoolValue(remote.ServeStale), + CacheRepodata: types.BoolValue(remote.CacheRepodata), + SyncInterval: types.Int64Value(remote.SyncInterval), ReleasesRemote: types.StringValue(remote.ReleasesRemote), ManagedBy: types.StringValue(remote.ManagedBy), diff --git a/internal/provider/datasource_virtual.go b/internal/provider/datasource_virtual.go index 9f47f2a..7514566 100644 --- a/internal/provider/datasource_virtual.go +++ b/internal/provider/datasource_virtual.go @@ -29,21 +29,23 @@ func (d *virtualDataSource) Schema(_ context.Context, _ datasource.SchemaRequest resp.Schema = schema.Schema{ Description: "Read an existing ArtifactAPI virtual repository.", Attributes: map[string]schema.Attribute{ - "name": schema.StringAttribute{Required: true, Description: "Virtual repository name."}, - "package_type": schema.StringAttribute{Computed: true}, - "description": schema.StringAttribute{Computed: true}, - "members": schema.ListAttribute{Computed: true, ElementType: types.StringType}, - "managed_by": schema.StringAttribute{Computed: true}, + "name": schema.StringAttribute{Required: true, Description: "Virtual repository name."}, + "package_type": schema.StringAttribute{Computed: true}, + "description": schema.StringAttribute{Computed: true}, + "members": schema.ListAttribute{Computed: true, ElementType: types.StringType}, + "managed_by": schema.StringAttribute{Computed: true}, + "parallel_fetch": schema.BoolAttribute{Computed: true}, }, } } type virtualDataSourceModel struct { - Name types.String `tfsdk:"name"` - PackageType types.String `tfsdk:"package_type"` - Description types.String `tfsdk:"description"` - Members types.List `tfsdk:"members"` - ManagedBy types.String `tfsdk:"managed_by"` + Name types.String `tfsdk:"name"` + PackageType types.String `tfsdk:"package_type"` + Description types.String `tfsdk:"description"` + Members types.List `tfsdk:"members"` + ManagedBy types.String `tfsdk:"managed_by"` + ParallelFetch types.Bool `tfsdk:"parallel_fetch"` } func (d *virtualDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { @@ -72,11 +74,12 @@ func (d *virtualDataSource) Read(ctx context.Context, req datasource.ReadRequest } state := virtualDataSourceModel{ - Name: types.StringValue(virt.Name), - PackageType: types.StringValue(virt.PackageType), - Description: types.StringValue(virt.Description), - Members: stringsToList(ctx, virt.Members), - ManagedBy: types.StringValue(virt.ManagedBy), + Name: types.StringValue(virt.Name), + PackageType: types.StringValue(virt.PackageType), + Description: types.StringValue(virt.Description), + Members: stringsToList(ctx, virt.Members), + ManagedBy: types.StringValue(virt.ManagedBy), + ParallelFetch: types.BoolValue(virt.ParallelFetch), } resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } diff --git a/internal/provider/models.go b/internal/provider/models.go index cf67a78..643a3d8 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -22,6 +22,9 @@ type remoteAPI struct { QuarantineEnabled bool `json:"quarantine_enabled"` QuarantineDays int64 `json:"quarantine_days"` StaleOnError bool `json:"stale_on_error"` + ServeStale bool `json:"serve_stale"` + CacheRepodata bool `json:"cache_repodata"` + SyncInterval int64 `json:"sync_interval"` ReleasesRemote string `json:"releases_remote,omitempty"` ManagedBy string `json:"managed_by,omitempty"` @@ -31,9 +34,10 @@ type remoteAPI struct { } type virtualAPI struct { - Name string `json:"name"` - PackageType string `json:"package_type"` - Description string `json:"description,omitempty"` - Members []string `json:"members"` - ManagedBy string `json:"managed_by,omitempty"` + Name string `json:"name"` + PackageType string `json:"package_type"` + Description string `json:"description,omitempty"` + Members []string `json:"members"` + ManagedBy string `json:"managed_by,omitempty"` + ParallelFetch bool `json:"parallel_fetch"` } diff --git a/internal/provider/resource_remote.go b/internal/provider/resource_remote.go index bd4807b..b4ce467 100644 --- a/internal/provider/resource_remote.go +++ b/internal/provider/resource_remote.go @@ -33,6 +33,13 @@ const defaultMirrorStrategy = "round_robin" // validMirrorStrategies are the accepted mirror_strategy values. var validMirrorStrategies = map[string]bool{"round_robin": true, "least_conn": true} +// syncIntervalPackageTypes are the remote types with a background release +// syncer, the only ones accepting sync_interval. +var syncIntervalPackageTypes = map[string]bool{"github_rpm": true, "github_deb": true, "github_alpine": true} + +// maxSyncInterval is the API's 30-day cap on sync_interval, in seconds. +const maxSyncInterval = 30 * 24 * 60 * 60 + type remoteResource struct { client *apiClient packageType string @@ -58,6 +65,9 @@ type remoteResourceModel struct { QuarantineEnabled types.Bool `tfsdk:"quarantine_enabled"` QuarantineDays types.Int64 `tfsdk:"quarantine_days"` StaleOnError types.Bool `tfsdk:"stale_on_error"` + ServeStale types.Bool `tfsdk:"serve_stale"` + CacheRepodata types.Bool `tfsdk:"cache_repodata"` + SyncInterval types.Int64 `tfsdk:"sync_interval"` ReleasesRemote types.String `tfsdk:"releases_remote"` UpstreamDialTimeout types.Int64 `tfsdk:"upstream_dial_timeout"` @@ -148,6 +158,18 @@ func (r *remoteResource) Schema(_ context.Context, _ resource.SchemaRequest, res Description: "Serve stale cached content when upstream is unreachable.", Optional: true, Computed: true, Default: booldefault.StaticBool(true), }, + "serve_stale": schema.BoolAttribute{ + Description: "Let rpm/deb/alpine virtuals serve this member's last good index while it refreshes.", + Optional: true, Computed: true, Default: booldefault.StaticBool(false), + }, + "cache_repodata": schema.BoolAttribute{ + Description: "Cache generated repodata (github_rpm only).", + Optional: true, Computed: true, Default: booldefault.StaticBool(false), + }, + "sync_interval": schema.Int64Attribute{ + Description: "Release re-scan interval in seconds, 0..2592000 (0 = mutable_ttl); github_rpm/github_deb/github_alpine only.", + Optional: true, Computed: true, Default: int64default.StaticInt64(0), + }, "ban_tags_enabled": schema.BoolAttribute{ Description: "Enable tag banning (docker only).", Optional: true, Computed: true, Default: booldefault.StaticBool(false), @@ -304,6 +326,30 @@ func (r *remoteResource) ValidateConfig(ctx context.Context, req resource.Valida ) } } + + if !config.CacheRepodata.IsNull() && !config.CacheRepodata.IsUnknown() && r.packageType != "github_rpm" { + resp.Diagnostics.AddAttributeError( + path.Root("cache_repodata"), + "cache_repodata not supported for this remote type", + fmt.Sprintf("cache_repodata is only valid on github_rpm remotes, not %q remotes.", r.packageType), + ) + } + + if !config.SyncInterval.IsNull() && !config.SyncInterval.IsUnknown() { + if !syncIntervalPackageTypes[r.packageType] { + resp.Diagnostics.AddAttributeError( + path.Root("sync_interval"), + "sync_interval not supported for this remote type", + fmt.Sprintf("sync_interval is only valid on github_rpm/github_deb/github_alpine remotes, not %q remotes.", r.packageType), + ) + } else if v := config.SyncInterval.ValueInt64(); v < 0 || v > maxSyncInterval { + resp.Diagnostics.AddAttributeError( + path.Root("sync_interval"), + "invalid sync_interval", + fmt.Sprintf("sync_interval must be between 0 and %d seconds, got %d.", maxSyncInterval, v), + ) + } + } } func reconcileOptionalLists(prior, current *remoteResourceModel) { @@ -329,6 +375,9 @@ func (r *remoteResource) modelToAPI(ctx context.Context, m remoteResourceModel) QuarantineEnabled: m.QuarantineEnabled.ValueBool(), QuarantineDays: m.QuarantineDays.ValueInt64(), StaleOnError: m.StaleOnError.ValueBool(), + ServeStale: m.ServeStale.ValueBool(), + CacheRepodata: m.CacheRepodata.ValueBool(), + SyncInterval: m.SyncInterval.ValueInt64(), UpstreamDialTimeout: m.UpstreamDialTimeout.ValueInt64(), UpstreamTLSTimeout: m.UpstreamTLSTimeout.ValueInt64(), @@ -375,6 +424,9 @@ func (r *remoteResource) apiToModel(ctx context.Context, api remoteAPI) remoteRe QuarantineEnabled: types.BoolValue(api.QuarantineEnabled), QuarantineDays: types.Int64Value(api.QuarantineDays), StaleOnError: types.BoolValue(api.StaleOnError), + ServeStale: types.BoolValue(api.ServeStale), + CacheRepodata: types.BoolValue(api.CacheRepodata), + SyncInterval: types.Int64Value(api.SyncInterval), BanTagsEnabled: types.BoolValue(api.BanTagsEnabled), BanTags: stringsToList(ctx, api.BanTags), ReleasesRemote: types.StringValue(api.ReleasesRemote), diff --git a/internal/provider/resource_remote_test.go b/internal/provider/resource_remote_test.go index 3eb23b4..3eff455 100644 --- a/internal/provider/resource_remote_test.go +++ b/internal/provider/resource_remote_test.go @@ -2,9 +2,14 @@ package provider import ( "context" + "encoding/json" + "fmt" + "strings" "testing" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -770,3 +775,136 @@ func TestValidateConfig_MirrorStrategy(t *testing.T) { } }) } + +func TestRemoteResource_SchemaCacheSyncFields(t *testing.T) { + r := &remoteResource{packageType: "github_rpm"} + var resp resource.SchemaResponse + r.Schema(context.Background(), resource.SchemaRequest{}, &resp) + + for _, name := range []string{"serve_stale", "cache_repodata", "sync_interval"} { + attr, ok := resp.Schema.Attributes[name] + if !ok { + t.Fatalf("missing %s attribute in schema", name) + } + if !attr.IsOptional() || !attr.IsComputed() { + t.Errorf("%s must be Optional+Computed so the API default never churns", name) + } + } + + ctx := context.Background() + for _, name := range []string{"serve_stale", "cache_repodata"} { + var d defaults.BoolResponse + resp.Schema.Attributes[name].(schema.BoolAttribute).Default.DefaultBool(ctx, defaults.BoolRequest{}, &d) + if d.PlanValue.ValueBool() { + t.Errorf("%s: expected default false", name) + } + } + var d defaults.Int64Response + resp.Schema.Attributes["sync_interval"].(schema.Int64Attribute).Default.DefaultInt64(ctx, defaults.Int64Request{}, &d) + if d.PlanValue.ValueInt64() != 0 { + t.Errorf("sync_interval: expected default 0, got %d", d.PlanValue.ValueInt64()) + } +} + +func TestRemoteCacheSyncFields_RoundTrip(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "github_rpm"} + + model := r.apiToModel(ctx, remoteAPI{ + Name: "gh", PackageType: "github_rpm", BaseURL: "https://api.github.com/repos/acme/tools", + ServeStale: true, CacheRepodata: true, SyncInterval: 900, + }) + if !model.ServeStale.ValueBool() || !model.CacheRepodata.ValueBool() || model.SyncInterval.ValueInt64() != 900 { + t.Errorf("apiToModel: got serve_stale=%v cache_repodata=%v sync_interval=%d", + model.ServeStale, model.CacheRepodata, model.SyncInterval) + } + + api := r.modelToAPI(ctx, model) + if !api.ServeStale || !api.CacheRepodata || api.SyncInterval != 900 { + t.Errorf("modelToAPI: got serve_stale=%v cache_repodata=%v sync_interval=%d", + api.ServeStale, api.CacheRepodata, api.SyncInterval) + } + + // The PUT is a full replace, so false/0 must be sent explicitly, not omitted. + body, err := json.Marshal(r.modelToAPI(ctx, r.apiToModel(ctx, remoteAPI{Name: "z", PackageType: "github_rpm"}))) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{`"serve_stale":false`, `"cache_repodata":false`, `"sync_interval":0`} { + if !strings.Contains(string(body), key) { + t.Errorf("request body missing %s: %s", key, body) + } + } +} + +// remoteConfig builds a tfsdk.Config for the remote schema with every +// attribute null except those in set. +func remoteConfig(ctx context.Context, t *testing.T, r *remoteResource, set map[string]tftypes.Value) 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) + } + for name, v := range set { + attrs[name] = v + } + return tfsdk.Config{Schema: resp.Schema, Raw: tftypes.NewValue(objType, attrs)} +} + +func TestValidateConfig_CacheRepodata(t *testing.T) { + ctx := context.Background() + set := map[string]tftypes.Value{"cache_repodata": tftypes.NewValue(tftypes.Bool, true)} + + cases := map[string]bool{"github_rpm": false, "github_deb": true, "github_alpine": true, "rpm": true, "docker": true} + for pkgType, wantErr := range cases { + t.Run(pkgType, func(t *testing.T) { + r := &remoteResource{packageType: pkgType} + var resp resource.ValidateConfigResponse + r.ValidateConfig(ctx, resource.ValidateConfigRequest{Config: remoteConfig(ctx, t, r, set)}, &resp) + if resp.Diagnostics.HasError() != wantErr { + t.Errorf("cache_repodata on %s: wantErr=%v, got %v", pkgType, wantErr, resp.Diagnostics.Errors()) + } + }) + } +} + +func TestValidateConfig_SyncInterval(t *testing.T) { + ctx := context.Background() + cases := []struct { + pkgType string + value int64 + wantErr bool + }{ + {"github_rpm", 0, false}, + {"github_deb", 3600, false}, + {"github_alpine", maxSyncInterval, false}, + {"github_rpm", -1, true}, + {"github_rpm", maxSyncInterval + 1, true}, + {"rpm", 3600, true}, + {"docker", 0, true}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%s/%d", tc.pkgType, tc.value), func(t *testing.T) { + r := &remoteResource{packageType: tc.pkgType} + set := map[string]tftypes.Value{"sync_interval": tftypes.NewValue(tftypes.Number, tc.value)} + var resp resource.ValidateConfigResponse + r.ValidateConfig(ctx, resource.ValidateConfigRequest{Config: remoteConfig(ctx, t, r, set)}, &resp) + if resp.Diagnostics.HasError() != tc.wantErr { + t.Errorf("wantErr=%v, got %v", tc.wantErr, resp.Diagnostics.Errors()) + } + }) + } + + t.Run("null-on-docker", func(t *testing.T) { + r := &remoteResource{packageType: "docker"} + var resp resource.ValidateConfigResponse + r.ValidateConfig(ctx, resource.ValidateConfigRequest{Config: remoteConfig(ctx, t, r, nil)}, &resp) + if resp.Diagnostics.HasError() { + t.Errorf("unexpected error for unset cache/sync fields on docker: %v", resp.Diagnostics.Errors()) + } + }) +} diff --git a/internal/provider/resource_virtual.go b/internal/provider/resource_virtual.go index c973764..e5b1854 100644 --- a/internal/provider/resource_virtual.go +++ b/internal/provider/resource_virtual.go @@ -7,6 +7,7 @@ import ( "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/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" @@ -23,10 +24,11 @@ type virtualResource struct { } type virtualResourceModel struct { - Name types.String `tfsdk:"name"` - PackageType types.String `tfsdk:"package_type"` - Description types.String `tfsdk:"description"` - Members types.List `tfsdk:"members"` + Name types.String `tfsdk:"name"` + PackageType types.String `tfsdk:"package_type"` + Description types.String `tfsdk:"description"` + Members types.List `tfsdk:"members"` + ParallelFetch types.Bool `tfsdk:"parallel_fetch"` } func init() { registerResource(NewVirtualResource) } @@ -65,6 +67,12 @@ func (r *virtualResource) Schema(_ context.Context, _ resource.SchemaRequest, re Required: true, ElementType: types.StringType, }, + "parallel_fetch": schema.BoolAttribute{ + Description: "Fetch each member's index files concurrently.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, }, } } @@ -162,18 +170,20 @@ func (r *virtualResource) ImportState(ctx context.Context, req resource.ImportSt func virtualModelToAPI(ctx context.Context, m virtualResourceModel) virtualAPI { return virtualAPI{ - Name: m.Name.ValueString(), - PackageType: m.PackageType.ValueString(), - Description: m.Description.ValueString(), - Members: listToStrings(ctx, m.Members), + Name: m.Name.ValueString(), + PackageType: m.PackageType.ValueString(), + Description: m.Description.ValueString(), + Members: listToStrings(ctx, m.Members), + ParallelFetch: m.ParallelFetch.ValueBool(), } } func virtualAPIToModel(ctx context.Context, api virtualAPI) virtualResourceModel { return virtualResourceModel{ - Name: types.StringValue(api.Name), - PackageType: types.StringValue(api.PackageType), - Description: types.StringValue(api.Description), - Members: stringsToList(ctx, api.Members), + Name: types.StringValue(api.Name), + PackageType: types.StringValue(api.PackageType), + Description: types.StringValue(api.Description), + Members: stringsToList(ctx, api.Members), + ParallelFetch: types.BoolValue(api.ParallelFetch), } } diff --git a/internal/provider/resource_virtual_test.go b/internal/provider/resource_virtual_test.go index efb72c2..b7cb8c2 100644 --- a/internal/provider/resource_virtual_test.go +++ b/internal/provider/resource_virtual_test.go @@ -2,9 +2,13 @@ package provider import ( "context" + "encoding/json" + "strings" "testing" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -110,11 +114,17 @@ func TestVirtualRoundTrip(t *testing.T) { PackageType: "helm", Description: "Round trip test", Members: []string{"a", "b", "c"}, + + ParallelFetch: true, } model := virtualAPIToModel(ctx, original) result := virtualModelToAPI(ctx, model) + if !model.ParallelFetch.ValueBool() || !result.ParallelFetch { + t.Errorf("ParallelFetch: expected true through the round trip, got model=%v api=%v", model.ParallelFetch, result.ParallelFetch) + } + if result.Name != original.Name { t.Errorf("Name: expected %s, got %s", original.Name, result.Name) } @@ -150,12 +160,33 @@ func TestVirtualResource_Schema(t *testing.T) { var resp resource.SchemaResponse r.Schema(context.Background(), req, &resp) - expectedAttrs := []string{"name", "package_type", "description", "members"} + expectedAttrs := []string{"name", "package_type", "description", "members", "parallel_fetch"} for _, attr := range expectedAttrs { if _, ok := resp.Schema.Attributes[attr]; !ok { t.Errorf("missing expected attribute: %s", attr) } } + + pf := resp.Schema.Attributes["parallel_fetch"].(schema.BoolAttribute) + if !pf.IsOptional() || !pf.IsComputed() { + t.Error("parallel_fetch must be Optional+Computed") + } + var d defaults.BoolResponse + pf.Default.DefaultBool(context.Background(), defaults.BoolRequest{}, &d) + if d.PlanValue.ValueBool() { + t.Error("parallel_fetch: expected default false") + } +} + +func TestVirtualModelToAPI_SendsParallelFetchFalse(t *testing.T) { + ctx := context.Background() + body, err := json.Marshal(virtualModelToAPI(ctx, virtualResourceModel{ParallelFetch: types.BoolValue(false)})) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"parallel_fetch":false`) { + t.Errorf("request body must carry parallel_fetch=false for the full-replace PUT: %s", body) + } } func TestNewVirtualResource_Type(t *testing.T) {