mirror of
https://github.com/opentffoundation/opentf.git
synced 2025-12-19 17:59:05 -05:00
cliconfig+main: Allow oci_mirror as a new provider installation method
It's now valid to include an oci_mirror block in the provider_installation block in the CLI configuration, specifying that OpenTofu should try to install providers from OCI repositories based on a template that maps from OpenTofu-style provider source addresses into OCI repository addresses. The getproviders.Source implementation for this was added in a previous commit, so this is mainly just wiring it up to the cliconfig layer and the dependency wiring code in package main. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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), "<oci_mirror repository_template>", 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 "<oci_mirror repository_template>"
|
||||
// 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{/*...*/}"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
17
internal/command/cliconfig/testdata/provider-installation-oci
vendored
Normal file
17
internal/command/cliconfig/testdata/provider-installation-oci
vendored
Normal file
@@ -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"]
|
||||
}
|
||||
}
|
||||
13
internal/command/cliconfig/testdata/provider-installation-oci-dynerror
vendored
Normal file
13
internal/command/cliconfig/testdata/provider-installation-oci-dynerror
vendored
Normal file
@@ -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}"
|
||||
}
|
||||
}
|
||||
5
internal/command/cliconfig/testdata/provider-installation-oci-missinghostname
vendored
Normal file
5
internal/command/cliconfig/testdata/provider-installation-oci-missinghostname
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
provider_installation {
|
||||
oci_mirror {
|
||||
repository_template = "example.com/${namespace}/${type}"
|
||||
}
|
||||
}
|
||||
5
internal/command/cliconfig/testdata/provider-installation-oci-missingnamespace
vendored
Normal file
5
internal/command/cliconfig/testdata/provider-installation-oci-missingnamespace
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
provider_installation {
|
||||
oci_mirror {
|
||||
repository_template = "example.com/${hostname}/${type}"
|
||||
}
|
||||
}
|
||||
5
internal/command/cliconfig/testdata/provider-installation-oci-missingtype
vendored
Normal file
5
internal/command/cliconfig/testdata/provider-installation-oci-missingtype
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
provider_installation {
|
||||
oci_mirror {
|
||||
repository_template = "example.com/${hostname}/${namespace}"
|
||||
}
|
||||
}
|
||||
8
internal/command/cliconfig/testdata/provider-installation-oci-passthru
vendored
Normal file
8
internal/command/cliconfig/testdata/provider-installation-oci-passthru
vendored
Normal file
@@ -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}"
|
||||
}
|
||||
}
|
||||
8
internal/command/cliconfig/testdata/provider-installation-oci-typeerror
vendored
Normal file
8
internal/command/cliconfig/testdata/provider-installation-oci-typeerror
vendored
Normal file
@@ -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}"
|
||||
}
|
||||
}
|
||||
10
internal/command/cliconfig/testdata/provider-installation-oci-valueerror
vendored
Normal file
10
internal/command/cliconfig/testdata/provider-installation-oci-valueerror
vendored
Normal file
@@ -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}"
|
||||
}
|
||||
}
|
||||
21
internal/command/cliconfig/testdata/provider-installation-oci.json
vendored
Normal file
21
internal/command/cliconfig/testdata/provider-installation-oci.json
vendored
Normal file
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user