diff --git a/cmd/tofu/provider_source.go b/cmd/tofu/provider_source.go index f3ef7d1eaf..7db68c52df 100644 --- a/cmd/tofu/provider_source.go +++ b/cmd/tofu/provider_source.go @@ -15,11 +15,14 @@ import ( "github.com/apparentlymart/go-userdirs/userdirs" "github.com/hashicorp/terraform-svchost/disco" + orasRemote "oras.land/oras-go/v2/registry/remote" + orasAuth "oras.land/oras-go/v2/registry/remote/auth" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/command/cliconfig" "github.com/opentofu/opentofu/internal/command/cliconfig/ociauthconfig" "github.com/opentofu/opentofu/internal/getproviders" + "github.com/opentofu/opentofu/internal/httpclient" "github.com/opentofu/opentofu/internal/tfdiags" ) @@ -203,7 +206,7 @@ func implicitProviderSource(services *disco.Disco) getproviders.Source { return getproviders.MultiSource(searchRules) } -func providerSourceForCLIConfigLocation(loc cliconfig.ProviderInstallationLocation, services *disco.Disco, _ ociCredsPolicyBuilder) (getproviders.Source, tfdiags.Diagnostics) { +func providerSourceForCLIConfigLocation(loc cliconfig.ProviderInstallationLocation, services *disco.Disco, makeOCICredsPolicy ociCredsPolicyBuilder) (getproviders.Source, tfdiags.Diagnostics) { if loc == cliconfig.ProviderInstallationDirect { return getproviders.NewMemoizeSource( getproviders.NewRegistrySource(services), @@ -237,11 +240,24 @@ func providerSourceForCLIConfigLocation(loc cliconfig.ProviderInstallationLocati } return getproviders.NewHTTPMirrorSource(url, services.CredentialsSource()), nil - // TODO: Once we implement an OCI-Distribution-based mirror source in a - // future commit, we'll use the ociCredsPolicyBuilder callback as part of - // initializing it so that it can find any credentials it needs to do its work. - // For now this is just a stub to illustrate where future work should - // continue, to help split this OCI integration work across multiple changes. + case cliconfig.ProviderInstallationOCIMirror: + mappingFunc := loc.RepositoryMapping + return getproviders.NewOCIRegistryMirrorSource( + mappingFunc, + func(ctx context.Context, registryDomain, repositoryName string) (getproviders.OCIRepositoryStore, error) { + // We intentionally delay the finalization of the credentials policy until + // just before we need it because most OpenTofu commands don't install + // providers at all, and even those that do only need to do this if + // actually interacting with an OCI mirror, so we can avoid doing + // this work at all most of the time. + credsPolicy, err := makeOCICredsPolicy(ctx) + if err != nil { + // This deals with only a small number of errors that we can't catch during CLI config validation + return nil, fmt.Errorf("invalid credentials configuration for OCI registries: %w", err) + } + return getOCIRepositoryStore(ctx, registryDomain, repositoryName, credsPolicy) + }, + ), nil default: // We should not get here because the set of cases above should @@ -261,3 +277,77 @@ func providerDevOverrides(configs []*cliconfig.ProviderInstallation) map[addrs.P // ignore any additional configurations in here. return configs[0].DevOverrides } + +// getOCIRepositoryStore instantiates a [getproviders.OCIRepositoryStore] implementation to use +// when accessing the given repository on the given registry, using the given OCI credentials +// policy to decide which credentials to use. +func getOCIRepositoryStore(ctx context.Context, registryDomain, repositoryName string, credsPolicy ociauthconfig.CredentialsConfigs) (getproviders.OCIRepositoryStore, error) { + // We currently use the ORAS-Go library to satisfy the [getproviders.OCIRepositoryStore] + // interface, which is easy because that interface was designed to match a subset of + // the ORAS-Go API since we had no particular need to diverge from it. However, we consider + // ORAS-Go to be an implementation detail here and so we should avoid any ORAS-Go + // types becoming part of the direct public API between packages. + + // ORAS-Go has a bit of an impedence mismatch with us in that it thinks of credentials + // as being a per-registry thing rather than a per-repository thing, so we deal with + // the credSource resolution ourselves here and then just return whatever we found to + // ORAS when it asks through its callback. In practice we only interact with one + // repository per client so this is just a little inconvenient and not a practical problem. + credSource, err := credsPolicy.CredentialsSourceForRepository(ctx, registryDomain, repositoryName) + if ociauthconfig.IsCredentialsNotFoundError(err) { + credSource = nil // we'll just try without any credentials, then + } else if err != nil { + return nil, fmt.Errorf("finding credentials for %q: %w", registryDomain, err) + } + client := &orasAuth.Client{ + Client: httpclient.New(), // the underlying HTTP client to use, preconfigured with OpenTofu's User-Agent string + Credential: func(ctx context.Context, hostport string) (orasAuth.Credential, error) { + if hostport != registryDomain { + // We should not send the credentials we selected to any registry + // other than the one they were configured for. + return orasAuth.EmptyCredential, nil + } + if credSource == nil { + return orasAuth.EmptyCredential, nil + } + creds, err := credSource.Credentials(ctx, ociCredentialsLookupEnv{}) + if ociauthconfig.IsCredentialsNotFoundError(err) { + return orasAuth.EmptyCredential, nil + } + if err != nil { + return orasAuth.Credential{}, err + } + return creds.ToORASCredential(), nil + }, + Cache: orasAuth.NewCache(), + } + reg, err := orasRemote.NewRegistry(registryDomain) + if err != nil { + return nil, err // This is only for registryDomain validation errors, and we should've caught those much earlier than here + } + reg.Client = client + err = reg.Ping(ctx) // tests whether the given domain refers to a valid OCI repository and will accept the credentials + if err != nil { + return nil, fmt.Errorf("failed to contact OCI registry at %q: %w", registryDomain, err) + } + repo, err := reg.Repository(ctx, repositoryName) + if err != nil { + return nil, err // This is only for repositoryName validation errors, and we should've caught those much earlier than here + } + // NOTE: At this point we don't yet know if the named repository actually exists + // in the registry. The caller will find that out when they try to interact + // with the methods of [getproviders.OCIRepositoryStore]. + return repo, nil +} + +// ociCredentialsLookupEnv is our implementation of ociauthconfig.CredentialsLookupEnvironment +// used when resolving the selected credentials for a particular OCI repository. +type ociCredentialsLookupEnv struct{} + +var _ ociauthconfig.CredentialsLookupEnvironment = ociCredentialsLookupEnv{} + +// QueryDockerCredentialHelper implements ociauthconfig.CredentialsLookupEnvironment. +func (o ociCredentialsLookupEnv) QueryDockerCredentialHelper(ctx context.Context, helperName string, serverURL string) (ociauthconfig.DockerCredentialHelperGetResult, error) { + // TODO: Implement this + return ociauthconfig.DockerCredentialHelperGetResult{}, fmt.Errorf("support for Docker-style credential helpers is not yet available") +} diff --git a/internal/command/cliconfig/provider_installation.go b/internal/command/cliconfig/provider_installation.go index 35a541ad6d..f38d1034b5 100644 --- a/internal/command/cliconfig/provider_installation.go +++ b/internal/command/cliconfig/provider_installation.go @@ -11,8 +11,15 @@ import ( "github.com/hashicorp/hcl" hclast "github.com/hashicorp/hcl/hcl/ast" + hcltoken "github.com/hashicorp/hcl/hcl/token" + hcl2 "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/command/cliconfig/ociauthconfig" "github.com/opentofu/opentofu/internal/getproviders" "github.com/opentofu/opentofu/internal/tfdiags" ) @@ -215,6 +222,13 @@ func decodeProviderInstallationFromConfig(hclFile *hclast.File) ([]*ProviderInst location = ProviderInstallationNetworkMirror(bodyContent.URL) include = bodyContent.Include exclude = bodyContent.Exclude + case "oci_mirror": + var moreDiags tfdiags.Diagnostics + location, include, exclude, moreDiags = decodeOCIMirrorInstallationMethodBlock(methodBody) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + continue + } case "dev_overrides": if len(pi.Methods) > 0 { // We require dev_overrides to appear first if it's present, @@ -288,6 +302,262 @@ func decodeProviderInstallationFromConfig(hclFile *hclast.File) ([]*ProviderInst return ret, diags } +// decodeOCIMirrorInstallationMethodBlock decodes the content of an oci_mirror block +// from inside a provider_installation block. +func decodeOCIMirrorInstallationMethodBlock(methodBody *hclast.ObjectType) (location ProviderInstallationLocation, include, exclude []string, diags tfdiags.Diagnostics) { + type BodyContent struct { + RepositoryTemplate string `hcl:"repository_template"` + Include []string `hcl:"include"` + Exclude []string `hcl:"exclude"` + } + var bodyContent BodyContent + err := hcl.DecodeObject(&bodyContent, methodBody) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider_installation method block", + fmt.Sprintf("Invalid oci_mirror block at %s: %s.", methodBody.Pos(), err), + )) + return nil, nil, nil, diags + } + if bodyContent.RepositoryTemplate == "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider_installation method block", + fmt.Sprintf("Invalid oci_mirror block at %s: \"repository_template\" argument is required.", methodBody.Pos()), + )) + return nil, nil, nil, diags + } + + // If the given template is not valid at all then we'd prefer to give immediate + // feedback on that rather than the user discovering it only once they try to + // install a provider from this source, so we do as much of the work of + // parsing and checking the template here as possible. There are still a few + // situations we cannot detect until we know exactly which provider source + // address we're trying to map, but our aim is to detect here any situation + // that would make this template invalid for _any_ given source address. + + // CLI configuration still mainly uses legacy HCL 1, but we'll use HCL 2's + // template engine for this argument because otherwise we'd need to bring in + // "HIL", which is another legacy codebase that was historically used as the + // template engine with HCL 1. HCL 1 generates low-quality source location + // information, so for now we'll just accept that any diagnostics from this + // will not include source snippets. + templateExpr, hclDiags := hclsyntax.ParseTemplate([]byte(bodyContent.RepositoryTemplate), "", hcl2.InitialPos) + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + return nil, nil, nil, diags + } + + // The fact that we use HCL templates for this is not exposed outside of this + // package, and instead we encapsulate the mapping in a plain callback function. + // This helper also performs validation of the template, returning error diagnostics + // if it has any problems that would make it invalid regardless of specific provider + // source address. + repoMapping, mappingDiags := prepareOCIMirrorRepositoryMapping(templateExpr, bodyContent.Include, methodBody.Pos()) + diags = diags.Append(mappingDiags) + if mappingDiags.HasErrors() { + return nil, nil, nil, diags + } + + location = ProviderInstallationOCIMirror{ + RepositoryMapping: repoMapping, + } + include = bodyContent.Include + exclude = bodyContent.Exclude + return location, include, exclude, diags +} + +func prepareOCIMirrorRepositoryMapping(templateExpr hclsyntax.Expression, include []string, pos hcltoken.Pos) (func(addrs.Provider) (registryDomain, repositoryName string, err error), tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + var templateHasHostname, templateHasNamespace, templateHasType bool + for _, traversal := range templateExpr.Variables() { + switch name := traversal.RootName(); name { + case "hostname": + templateHasHostname = true + case "namespace": + templateHasNamespace = true + case "type": + templateHasType = true + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid oci_mirror repository template", + fmt.Sprintf( + "Invalid oci_mirror block at %s: the symbol %q is not available for an OCI mirror repository address template. Only \"hostname\", \"namespace\", and \"type\" are available.", + pos, name, + ), + )) + // We continue anyway, because we might be able to collect other errors + // if the template is invalid in multiple ways. + } + } + + // The template must include at least one reference to any source address + // component that isn't isn't exactly matched by all of the "include" patterns, + // because otherwise the mapping would be ambiguous. + includePatterns, err := getproviders.ParseMultiSourceMatchingPatterns(include) + if err != nil { + // Invalid patterns get caught later when we finally assemble the provider + // sources, so we intentionally don't produce an error here to avoid + // reporting the same problem twice. Instead, we just skip the + // template checking altogether by returning early. + return func(p addrs.Provider) (registryDomain string, repositoryName string, err error) { + // We should not actually get here because overall config validation will + // detect this problem and report it anyway, but this is here just for + // robustness in case this accidentally becomes reachable in future. + return "", "", fmt.Errorf("oci_mirror installation source has invalid 'include' patterns: %w", err) + }, diags + } + hostnames := map[svchost.Hostname]struct{}{} + namespaces := map[string]struct{}{} + types := map[string]struct{}{} + for _, pattern := range includePatterns { + if pattern.Hostname != svchost.Hostname(getproviders.Wildcard) { + hostnames[pattern.Hostname] = struct{}{} + } + if pattern.Namespace != getproviders.Wildcard { + namespaces[pattern.Namespace] = struct{}{} + } + if pattern.Type != getproviders.Wildcard { + types[pattern.Type] = struct{}{} + } + } + if len(hostnames) != 1 && !templateHasHostname { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid oci_mirror repository template", + fmt.Sprintf("Invalid oci_mirror block at %s: template must refer to the \"hostname\" symbol unless the \"include\" argument selects exactly one registry hostname.", pos), + )) + } + if len(namespaces) != 1 && !templateHasNamespace { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid oci_mirror repository template", + fmt.Sprintf("Invalid oci_mirror block at %s: template must refer to the \"namespace\" symbol unless the \"include\" argument selects exactly one provider namespace.", pos), + )) + } + if len(types) != 1 && !templateHasType { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid oci_mirror repository template", + fmt.Sprintf("Invalid oci_mirror block at %s: template must refer to the \"type\" symbol unless the \"include\" argument selects exactly one provider.", pos), + )) + } + if diags.HasErrors() { + return nil, diags + } + + // The above dealt with some likely problems that we can return tailored diagnoses + // for. We'll also catch some other potential problems, such as type errors returned + // by HCL expression operators, by actually trying to evaluate the template. We'll + // do this in two passes: the first using unknown values of type string so that + // we can achieve some moderately-high-quality diagnostics for type-related problems, + // and then the second using known placeholder values that we can use to check whether + // the resulting syntax seems sensible but for which we can't really generate good + // error messages because we can't really know why the result turned out to be invalid. + hclCtx := &hcl2.EvalContext{ + Variables: map[string]cty.Value{ + "hostname": cty.UnknownVal(cty.String), + "namespace": cty.UnknownVal(cty.String), + "type": cty.UnknownVal(cty.String), + }, + } + _, hclDiags := templateExpr.Value(hclCtx) // HCL itself can catch any type-related errors + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + // Since these diagnostics are coming from HCLv2 itself, they will + // describe the source location as "" + // rather than an actual file location. This is just another + // unfortunate consequence of continuing to use legacy HCL + // for the CLI configuration. :( + return nil, diags + } + exampleAddr, exampleDiags := evalOCIProviderMirrorRepositoryTemplate(templateExpr, addrs.Provider{ + Hostname: svchost.Hostname("example.com"), + Namespace: "example-namespace", + Type: "example-type", + }, pos) + diags = diags.Append(exampleDiags) + if exampleDiags.HasErrors() { + return nil, diags // This should not catch anything that the previous call didn't, but we'll handle it anyway to make sure + } + // If we've got this far without finding a problem then exampleVal + // should be a string containing some sort of valid OCI repository + // address, although we can't assume anything about what exactly it + // refers to, only validate its syntax. + _, _, err = ociauthconfig.ParseRepositoryAddressPrefix(exampleAddr) + if err != nil { + // We can't really say anything specific here because we know nothing + // about what the author's intention was in writing this template and + // it would be confusing to reveal the fixed placeholder provider address + // we used for this test, so we'll keep this generic. Not ideal, but + // we've put in a bunch of effort above to minimize the chances of + // reaching this last-resort error message. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid oci_mirror repository template", + fmt.Sprintf("Invalid oci_mirror block at %s: template must generate a valid OCI repository address, including a registry hostname followed by a slash and then a repository name.", pos), + )) + return nil, diags + } + + // If everything we did above succeeded then we've eliminated many of the + // ways that the template could be invalid. There are still a few left + // but we'll need to handle those ones dynamically on request instead. + return func(p addrs.Provider) (registryDomain string, repositoryName string, err error) { + repoAddrRaw, diags := evalOCIProviderMirrorRepositoryTemplate(templateExpr, p, pos) + if diags.HasErrors() { + // The provider installer returns normal error values rather than full + // diagnostics, so this function is defined similarly and will do its + // best to transform diagnostics into reasonable naked errors. + // + // Due to the checks we did above before returning this function, + // it should be very unlikely to return errors here but possible + // if the user wrote something really weird/complex in the template, + // such as a conditional expression that only performs an invalid + // operation when given specific input that doesn't match the + // example input we tried above. + return "", "", diags.Err() + } + return ociauthconfig.ParseRepositoryAddressPrefix(repoAddrRaw) + }, diags +} + +func evalOCIProviderMirrorRepositoryTemplate(templateExpr hclsyntax.Expression, providerAddr addrs.Provider, pos hcltoken.Pos) (string, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + hclCtx := &hcl2.EvalContext{ + Variables: map[string]cty.Value{ + "hostname": cty.StringVal(providerAddr.Hostname.String()), + "namespace": cty.StringVal(providerAddr.Namespace), + "type": cty.StringVal(providerAddr.Type), + }, + } + val, hclDiags := templateExpr.Value(hclCtx) + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + return "", diags + } + val, err := convert.Convert(val, cty.String) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid oci_mirror repository template", + fmt.Sprintf("Invalid oci_mirror block at %s: template must produce a string value.", pos), + )) + return "", diags + } + if val.IsNull() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid oci_mirror repository template", + fmt.Sprintf("Invalid oci_mirror block at %s: template must not produce a null value.", pos), + )) + return "", diags + } + return val.AsString(), diags +} + // ProviderInstallationMethod represents an installation method block inside // a provider_installation block. type ProviderInstallationMethod struct { @@ -303,6 +573,7 @@ type ProviderInstallationMethod struct { // - [ProviderInstallationDirect]: install from the provider's origin registry // - [ProviderInstallationFilesystemMirror] (dir): install from a local filesystem mirror // - [ProviderInstallationNetworkMirror] (host): install from a network mirror +// - [ProviderInstallationOCIMirror]: use OCI registries as if they were a network mirror type ProviderInstallationLocation interface { providerInstallationLocation() } @@ -341,3 +612,30 @@ func (i ProviderInstallationNetworkMirror) providerInstallationLocation() {} func (i ProviderInstallationNetworkMirror) GoString() string { return fmt.Sprintf("cliconfig.ProviderInstallationNetworkMirror(%q)", i) } + +// ProviderInstallationOCIMirror is a ProviderInstallationSourceLocation +// representing a rule for using repositories in OCI registries as a +// provider network mirror. +// +// This is conceptualy similar to [ProviderInstallationNetworkMirror], but +// this on uses the OCI Distribution protocol instead of the OpenTofu-specific +// Provider Mirror Protocol. +type ProviderInstallationOCIMirror struct { + // RepositoryMapping is a function that translates from an OpenTofu-style + // logical provider source address to a physical OCI repository address. + // + // For a valid OCI mirror source this function encapsulates the details + // of evaluating the user-provided mapping template from the configuration, + // so that callers of this function don't need to be aware of the + // implementation detail that this uses HCL templates. + RepositoryMapping func(addrs.Provider) (registryDomain, repositoryName string, err error) +} + +func (i ProviderInstallationOCIMirror) providerInstallationLocation() {} + +func (i ProviderInstallationOCIMirror) GoString() string { + // There isn't really any useful string representation of the content + // of this type, but this is only used for internal stuff like describing + // mismatches in tests, so just naming the type is good enough for now. + return "cliconfig.ProviderInstallationNetworkMirror{/*...*/}" +} diff --git a/internal/command/cliconfig/provider_installation_test.go b/internal/command/cliconfig/provider_installation_test.go index 52cd927971..0036c64dbe 100644 --- a/internal/command/cliconfig/provider_installation_test.go +++ b/internal/command/cliconfig/provider_installation_test.go @@ -7,9 +7,11 @@ package cliconfig import ( "path/filepath" + "strings" "testing" "github.com/google/go-cmp/cmp" + svchost "github.com/hashicorp/terraform-svchost" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/getproviders" ) @@ -80,3 +82,196 @@ func TestLoadConfig_providerInstallationErrors(t *testing.T) { t.Errorf("wrong diagnostics\ngot:\n%s\nwant:\n%s", got, want) } } + +func TestLoadConfig_providerInstallationOCIMirror(t *testing.T) { + for _, configFile := range []string{"provider-installation-oci", "provider-installation-oci.json"} { + t.Run(configFile, func(t *testing.T) { + gotConfig, diags := loadConfigFile(filepath.Join(fixtureDir, configFile)) + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags.Err().Error()) + } + + gotInstBlocks := gotConfig.ProviderInstallation + if got, want := len(gotInstBlocks), 1; got != want { + t.Fatalf("wrong number of provider_installation blocks %d; want %d", got, want) + } + gotMethods := gotInstBlocks[0].Methods + if got, want := len(gotMethods), 4; got != want { + t.Fatalf("wrong number of provider installation methods %d; want %d", got, want) + } + + providerAddr := addrs.Provider{ + Hostname: svchost.Hostname("registry.opentofu.org"), + Namespace: "opentofu", + Type: "foo", + } + t.Run("all segments in template", func(t *testing.T) { + method := gotMethods[0] + loc, ok := method.Location.(ProviderInstallationOCIMirror) + if !ok { + t.Fatalf("wrong location type %T; want %T", method.Location, loc) + } + gotRegistry, gotRepository, err := loc.RepositoryMapping(providerAddr) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got, want := gotRegistry, "example.com"; got != want { + t.Errorf("wrong registry domain %q; want %q", got, want) + } + if got, want := gotRepository, "registry.opentofu.org/opentofu/foo"; got != want { + t.Errorf("wrong repository name %q; want %q", got, want) + } + }) + t.Run("hostname chosen by include", func(t *testing.T) { + method := gotMethods[1] + loc, ok := method.Location.(ProviderInstallationOCIMirror) + if !ok { + t.Fatalf("wrong location type %T; want %T", method.Location, loc) + } + gotRegistry, gotRepository, err := loc.RepositoryMapping(providerAddr) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got, want := gotRegistry, "example.net"; got != want { + t.Errorf("wrong registry domain %q; want %q", got, want) + } + if got, want := gotRepository, "opentofu-registry/opentofu/foo"; got != want { + t.Errorf("wrong repository name %q; want %q", got, want) + } + }) + t.Run("hostname and namespace chosen by include", func(t *testing.T) { + method := gotMethods[2] + loc, ok := method.Location.(ProviderInstallationOCIMirror) + if !ok { + t.Fatalf("wrong location type %T; want %T", method.Location, loc) + } + gotRegistry, gotRepository, err := loc.RepositoryMapping(providerAddr) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got, want := gotRegistry, "example.net"; got != want { + t.Errorf("wrong registry domain %q; want %q", got, want) + } + if got, want := gotRepository, "opentofu-registry/opentofu-namespace/foo"; got != want { + t.Errorf("wrong repository name %q; want %q", got, want) + } + }) + t.Run("all components chosen by include", func(t *testing.T) { + method := gotMethods[3] + loc, ok := method.Location.(ProviderInstallationOCIMirror) + if !ok { + t.Fatalf("wrong location type %T; want %T", method.Location, loc) + } + gotRegistry, gotRepository, err := loc.RepositoryMapping(providerAddr) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got, want := gotRegistry, "example.net"; got != want { + t.Errorf("wrong registry domain %q; want %q", got, want) + } + if got, want := gotRepository, "opentofu-registry/opentofu-namespace/foo-type"; got != want { + t.Errorf("wrong repository name %q; want %q", got, want) + } + }) + }) + } +} + +func TestLoadConfig_providerInstallationOCIMirrorErrors(t *testing.T) { + t.Run("missing hostname reference", func(t *testing.T) { + _, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-oci-missinghostname")) + if !diags.HasErrors() { + t.Fatalf("unexpected success; want error") + } + if got, want := diags.Err().Error(), `template must refer to the "hostname" symbol`; !strings.Contains(got, want) { + t.Errorf("missing expected error\ngot: %s\nwant substring: %s", got, want) + } + }) + t.Run("missing namespace reference", func(t *testing.T) { + _, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-oci-missingnamespace")) + if !diags.HasErrors() { + t.Fatalf("unexpected success; want error") + } + if got, want := diags.Err().Error(), `template must refer to the "namespace" symbol`; !strings.Contains(got, want) { + t.Errorf("missing expected error\ngot: %s\nwant substring: %s", got, want) + } + }) + t.Run("missing type reference", func(t *testing.T) { + _, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-oci-missingtype")) + if !diags.HasErrors() { + t.Fatalf("unexpected success; want error") + } + if got, want := diags.Err().Error(), `template must refer to the "type" symbol`; !strings.Contains(got, want) { + t.Errorf("missing expected error\ngot: %s\nwant substring: %s", got, want) + } + }) + t.Run("type error in template", func(t *testing.T) { + _, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-oci-typeerror")) + if !diags.HasErrors() { + t.Fatalf("unexpected success; want error") + } + if got, want := diags.Err().Error(), `This value does not have any indices`; !strings.Contains(got, want) { + t.Errorf("missing expected error\ngot: %s\nwant substring: %s", got, want) + } + }) + t.Run("value error in template", func(t *testing.T) { + _, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-oci-valueerror")) + if !diags.HasErrors() { + t.Fatalf("unexpected success; want error") + } + if got, want := diags.Err().Error(), `a number is required`; !strings.Contains(got, want) { + t.Errorf("missing expected error\ngot: %s\nwant substring: %s", got, want) + } + }) + t.Run("dynamic error in template", func(t *testing.T) { + cfg, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-oci-dynerror")) + if diags.HasErrors() { + t.Fatalf("unexpected error for configuration load (error should be only during template evaluation)") + } + method, ok := cfg.ProviderInstallation[0].Methods[0].Location.(ProviderInstallationOCIMirror) + if !ok { + t.Fatalf("wrong installation method location type") + } + _, _, err := method.RepositoryMapping(addrs.Provider{ + Hostname: svchost.Hostname("example.net"), // This template fails for anything other than "example.com" + Namespace: "whatever", + Type: "anything", + }) + if err == nil { + t.Fatalf("unexpected success; want error") + } + if got, want := err.Error(), `The given key does not identify an element in this collection value`; !strings.Contains(got, want) { + t.Errorf("missing expected error\ngot: %s\nwant substring: %s", got, want) + } + }) + t.Run("unmappable characters in provider source address", func(t *testing.T) { + // This deals with a particularly-annoying case: OpenTofu provider source addresses + // support a wide range of unicode characters with the intent that folks can name + // their private providers using the alphabet of their native language, but OCI Distribution + // only allows ASCII characters in repository names, so for now the OCI mirror + // installation method can only work with providers whose namespace and type + // are ASCII-only. Non-ASCII characters are pretty rare in practice for public + // providers, but we can't tell whether they are more common in private provider + // registries. For now we treat this as an error but we might try to find a better + // answer for this in a future release if it proves to be a problem in practice. + cfg, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-oci-passthru")) + if diags.HasErrors() { + t.Fatalf("unexpected error for configuration load (error should be only during template evaluation)") + } + method, ok := cfg.ProviderInstallation[0].Methods[0].Location.(ProviderInstallationOCIMirror) + if !ok { + t.Fatalf("wrong installation method location type") + } + _, _, err := method.RepositoryMapping(addrs.Provider{ + Hostname: svchost.Hostname("example.com"), + Namespace: "ほげ", + Type: "ふが", + }) + if err == nil { + t.Fatalf("unexpected success; want error") + } + if got, want := err.Error(), `invalid repository "example.com/ほげ/ふが"`; !strings.Contains(got, want) { + t.Errorf("missing expected error\ngot: %s\nwant substring: %s", got, want) + } + }) +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci b/internal/command/cliconfig/testdata/provider-installation-oci new file mode 100644 index 0000000000..60587c8be7 --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci @@ -0,0 +1,17 @@ +provider_installation { + oci_mirror { + repository_template = "example.com/${hostname}/${namespace}/${type}" + } + oci_mirror { + repository_template = "example.net/opentofu-registry/${namespace}/${type}" + include = ["registry.opentofu.org/*/*"] + } + oci_mirror { + repository_template = "example.net/opentofu-registry/opentofu-namespace/${type}" + include = ["registry.opentofu.org/opentofu/*"] + } + oci_mirror { + repository_template = "example.net/opentofu-registry/opentofu-namespace/foo-type" + include = ["registry.opentofu.org/opentofu/foo"] + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci-dynerror b/internal/command/cliconfig/testdata/provider-installation-oci-dynerror new file mode 100644 index 0000000000..e3a8b540e1 --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci-dynerror @@ -0,0 +1,13 @@ +provider_installation { + oci_mirror { + # This is a particularly-annoying case where the template is + # invalid only for a specific provider source address and so + # we can't detect it until that specific source address is + # used to attempt installation. + # This is a contrived example that is highly unlikely to be + # written by anyone in practice, but this test is covering + # just the general idea of templates that fail only for + # some of their inputs. + repository_template = "example.com/${ {"example.com": "okay"}[hostname] }/${namespace}/${type}" + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci-missinghostname b/internal/command/cliconfig/testdata/provider-installation-oci-missinghostname new file mode 100644 index 0000000000..2467083995 --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci-missinghostname @@ -0,0 +1,5 @@ +provider_installation { + oci_mirror { + repository_template = "example.com/${namespace}/${type}" + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci-missingnamespace b/internal/command/cliconfig/testdata/provider-installation-oci-missingnamespace new file mode 100644 index 0000000000..b34c2a2dcc --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci-missingnamespace @@ -0,0 +1,5 @@ +provider_installation { + oci_mirror { + repository_template = "example.com/${hostname}/${type}" + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci-missingtype b/internal/command/cliconfig/testdata/provider-installation-oci-missingtype new file mode 100644 index 0000000000..7bb28f6d63 --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci-missingtype @@ -0,0 +1,5 @@ +provider_installation { + oci_mirror { + repository_template = "example.com/${hostname}/${namespace}" + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci-passthru b/internal/command/cliconfig/testdata/provider-installation-oci-passthru new file mode 100644 index 0000000000..6f7eabe5d7 --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci-passthru @@ -0,0 +1,8 @@ +provider_installation { + oci_mirror { + # This one is an intentionally-simple template intended to be used + # as a general way to test arbitrary mappings, whether successful + # or not. + repository_template = "example.org/${hostname}/${namespace}/${type}" + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci-typeerror b/internal/command/cliconfig/testdata/provider-installation-oci-typeerror new file mode 100644 index 0000000000..0c18a9f346 --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci-typeerror @@ -0,0 +1,8 @@ +provider_installation { + oci_mirror { + # The following is invalid in a way that we should be able to detect + # in the first round of template type checking because hostname is + # a string and the index operator is never valid for a string. + repository_template = "example.com/${hostname[1]}/${namespace}/${type}" + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci-valueerror b/internal/command/cliconfig/testdata/provider-installation-oci-valueerror new file mode 100644 index 0000000000..596c7800a9 --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci-valueerror @@ -0,0 +1,10 @@ +provider_installation { + oci_mirror { + # The following is invalid in a way that we can only detect + # with known values, because strings containing only digits + # can convert to numbers automatically for addition, but + # the real hostname we use for detecting this does not + # meet that criteria. + repository_template = "example.com/${hostname + 1}/${namespace}/${type}" + } +} diff --git a/internal/command/cliconfig/testdata/provider-installation-oci.json b/internal/command/cliconfig/testdata/provider-installation-oci.json new file mode 100644 index 0000000000..153606a8cf --- /dev/null +++ b/internal/command/cliconfig/testdata/provider-installation-oci.json @@ -0,0 +1,21 @@ +{ + "provider_installation": { + "oci_mirror": [ + { + "repository_template": "example.com/${hostname}/${namespace}/${type}" + }, + { + "repository_template": "example.net/opentofu-registry/${namespace}/${type}", + "include": ["registry.opentofu.org/*/*"] + }, + { + "repository_template": "example.net/opentofu-registry/opentofu-namespace/${type}", + "include": ["registry.opentofu.org/opentofu/*"] + }, + { + "repository_template": "example.net/opentofu-registry/opentofu-namespace/foo-type", + "include": ["registry.opentofu.org/opentofu/foo"] + } + ] + } +} \ No newline at end of file diff --git a/internal/getproviders/oci_registry_mirror_source.go b/internal/getproviders/oci_registry_mirror_source.go index 96f158b982..920e9801cb 100644 --- a/internal/getproviders/oci_registry_mirror_source.go +++ b/internal/getproviders/oci_registry_mirror_source.go @@ -109,7 +109,7 @@ type OCIRegistryMirrorSource struct { // _at all_ MUST return an instance of [ErrProviderNotFound] so // that a [MultiSource] can successfully blend the results from // this and other sources. - resolveOCIRepositoryAddr func(ctx context.Context, addr addrs.Provider) (registryDomain, repositoryName string, err error) + resolveOCIRepositoryAddr func(addr addrs.Provider) (registryDomain, repositoryName string, err error) // getOCIRepositoryStore is the dependency inversion adapter for // obtaining a suitably-configured client for the given repository @@ -143,6 +143,16 @@ type OCIRegistryMirrorSource struct { var _ Source = (*OCIRegistryMirrorSource)(nil) +func NewOCIRegistryMirrorSource( + resolveRepositoryAddr func(addr addrs.Provider) (registryDomain, repositoryName string, err error), + getRepositoryStore func(ctx context.Context, registryDomain, repositoryName string) (OCIRepositoryStore, error), +) *OCIRegistryMirrorSource { + return &OCIRegistryMirrorSource{ + resolveOCIRepositoryAddr: resolveRepositoryAddr, + getOCIRepositoryStore: getRepositoryStore, + } +} + // AvailableVersions implements Source. func (o *OCIRegistryMirrorSource) AvailableVersions(ctx context.Context, provider addrs.Provider) (VersionList, Warnings, error) { store, _, _, err := o.getRepositoryStore(ctx, provider) @@ -309,7 +319,7 @@ func (o *OCIRegistryMirrorSource) getRepositoryStore(ctx context.Context, provid } // Otherwise we'll instantiate a new one and overwrite our cache with it. - registryDomain, repositoryName, err = o.resolveOCIRepositoryAddr(ctx, provider) + registryDomain, repositoryName, err = o.resolveOCIRepositoryAddr(provider) if err != nil { if notFoundErr, ok := err.(ErrProviderNotFound); ok { // [MultiSource] relies on this particular error type being returned diff --git a/internal/getproviders/oci_registry_mirror_source_test.go b/internal/getproviders/oci_registry_mirror_source_test.go index 25fa9ff76a..2084a61c6d 100644 --- a/internal/getproviders/oci_registry_mirror_source_test.go +++ b/internal/getproviders/oci_registry_mirror_source_test.go @@ -101,7 +101,7 @@ func TestOCIRegistryMirrorSource(t *testing.T) { // Each tag refers to manifests representing a provider that supports the platforms amigaos_m68k and tos_m68k. // We'll set up our source to interact with the fake local repository we just set up. source := &OCIRegistryMirrorSource{ - resolveOCIRepositoryAddr: func(ctx context.Context, addr addrs.Provider) (registryDomain string, repositoryName string, err error) { + resolveOCIRepositoryAddr: func(addr addrs.Provider) (registryDomain string, repositoryName string, err error) { if addr.Hostname != svchost.Hostname("example.com") { // We'll return [ErrProviderNotFound] here to satisfy the documented contract // that the source will return that error type in particular when asked for