// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package cliconfig import ( "path/filepath" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/opentofu/svchost" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/getproviders" ) func TestLoadConfig_providerInstallation(t *testing.T) { for _, configFile := range []string{"provider-installation", "provider-installation.json"} { t.Run(configFile, func(t *testing.T) { got, diags := loadConfigFile(filepath.Join(fixtureDir, configFile)) if diags.HasErrors() { t.Errorf("unexpected diagnostics: %s", diags.Err().Error()) } want := &Config{ ProviderInstallation: []*ProviderInstallation{ { Methods: []*ProviderInstallationMethod{ { Location: ProviderInstallationFilesystemMirror("/tmp/example1"), Include: []string{"example.com/*/*"}, }, { Location: ProviderInstallationNetworkMirror("https://tf-Mirror.example.com/"), Include: []string{"registry.opentofu.org/*/*"}, Exclude: []string{"registry.OpenTofu.org/foobar/*"}, Retries: func() (int, bool) { return 2, true }, }, { Location: ProviderInstallationFilesystemMirror("/tmp/example2"), }, { Location: ProviderInstallationDirect, Exclude: []string{"example.com/*/*"}, Retries: func() (int, bool) { return 3, true }, }, }, DevOverrides: map[addrs.Provider]getproviders.PackageLocalDir{ addrs.MustParseProviderSourceString("hashicorp/boop"): getproviders.PackageLocalDir(filepath.FromSlash("/tmp/boop")), addrs.MustParseProviderSourceString("hashicorp/blorp"): getproviders.PackageLocalDir(filepath.FromSlash("/tmp/blorp")), }, }, }, } if diff := cmp.Diff(want, got, cmp.Comparer(func(a, b ProviderInstallationMethodRetries) bool { if (a == nil && b != nil) || (a != nil && b == nil) { return false } if a == nil && b == nil { return true } ar, aok := a() br, bok := b() return ar == br && aok == bok })); diff != "" { t.Errorf("wrong result\n%s", diff) } }) } } func TestLoadConfig_providerInstallationErrors(t *testing.T) { _, diags := loadConfigFile(filepath.Join(fixtureDir, "provider-installation-errors")) want := `7 problems: - Invalid provider_installation method block: Unknown provider installation method "not_a_thing" at 2:3. - Invalid provider_installation method block: Invalid filesystem_mirror block at 1:1: "path" argument is required. - Invalid provider_installation method block: Invalid network_mirror block at 1:1: "url" argument is required. - Invalid provider_installation method block: The items inside the provider_installation block at 1:1 must all be blocks. - Invalid provider_installation method block: The blocks inside the provider_installation block at 1:1 may not have any labels. - Invalid provider_installation block: The provider_installation block at 9:1 must not have any labels. - Invalid provider_installation block: The provider_installation block at 11:1 must not be introduced with an equals sign.` // The above error messages include only line/column location information // and not file location information because HCL 1 does not store // information about the filename a location belongs to. (There is a field // for it in token.Pos but it's always an empty string in practice.) if got := diags.Err().Error(); got != want { 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) } }) }