// Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package cmd import ( "fmt" "os" "path/filepath" "reflect" "strings" "testing" homedir "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/spf13/viper" "gopkg.in/yaml.v3" "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/events/vcs/testdata" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // passedConfig is set to whatever config ended up being passed to NewServer. // Used for testing. var passedConfig server.UserConfig type ServerCreatorMock struct{} func (s *ServerCreatorMock) NewServer(userConfig server.UserConfig, _ server.Config) (ServerStarter, error) { passedConfig = userConfig return &ServerStarterMock{}, nil } type ServerStarterMock struct{} func (s *ServerStarterMock) Start() error { return nil } // Adding a new flag? Add it to this slice for testing in alphabetical // order. var testFlags = map[string]interface{}{ ADHostnameFlag: "dev.azure.com", ADTokenFlag: "ad-token", ADUserFlag: "ad-user", ADWebhookPasswordFlag: "ad-wh-pass", ADWebhookUserFlag: "ad-wh-user", AtlantisURLFlag: "url", AutoplanModules: false, AutoplanModulesFromProjects: "", AllowCommandsFlag: "version,plan,apply,unlock,import,approve_policies", AllowForkPRsFlag: true, APISecretFlag: "", AutoDiscoverModeFlag: "auto", AutomergeFlag: true, AutoplanFileListFlag: "**/*.tf,**/*.yml", BitbucketBaseURLFlag: "https://bitbucket-base-url.com", BitbucketTokenFlag: "bitbucket-token", BitbucketUserFlag: "bitbucket-user", BitbucketWebhookSecretFlag: "bitbucket-secret", CheckoutStrategyFlag: CheckoutStrategyMerge, CheckoutDepthFlag: 0, DataDirFlag: "/path", DefaultTFVersionFlag: "v0.11.0", DisableApplyAllFlag: true, DisableMarkdownFoldingFlag: true, DisableRepoLockingFlag: true, DisableGlobalApplyLockFlag: false, DiscardApprovalOnPlanFlag: true, EmojiReaction: "eyes", ExecutableName: "atlantis", FailOnPreWorkflowHookError: false, GHAllowMergeableBypassApply: false, GHHostnameFlag: "ghhostname", GHTeamAllowlistFlag: "", GHTokenFlag: "token", GHUserFlag: "user", GHAppIDFlag: int64(0), GHAppKeyFlag: "", GHAppKeyFileFlag: "", GHAppSlugFlag: "atlantis", GHOrganizationFlag: "", GHWebhookSecretFlag: "secret", GitlabHostnameFlag: "gitlab-hostname", GitlabTokenFlag: "gitlab-token", GitlabUserFlag: "gitlab-user", GitlabWebhookSecretFlag: "gitlab-secret", HideUnchangedPlanComments: false, HidePrevPlanComments: false, IncludeGitUntrackedFiles: false, LockingDBType: "boltdb", LogLevelFlag: "debug", MarkdownTemplateOverridesDirFlag: "/path2", StatsNamespace: "atlantis", AllowDraftPRs: true, PortFlag: 8181, ParallelPoolSize: 100, ParallelPlanFlag: true, ParallelApplyFlag: true, QuietPolicyChecks: false, RedisHost: "", RedisInsecureSkipVerify: false, RedisPassword: "", RedisPort: 6379, RedisTLSEnabled: false, RedisDB: 0, RepoAllowlistFlag: "github.com/runatlantis/atlantis", RepoConfigFlag: "", RepoConfigJSONFlag: "", SilenceNoProjectsFlag: false, SilenceVCSStatusNoProjectsFlag: false, SilenceForkPRErrorsFlag: true, SilenceAllowlistErrorsFlag: true, SilenceVCSStatusNoPlans: true, SkipCloneNoChanges: true, SlackTokenFlag: "slack-token", SSLCertFileFlag: "cert-file", SSLKeyFileFlag: "key-file", RestrictFileList: false, TFDownloadFlag: true, TFDownloadURLFlag: "https://my-hostname.com", TFEHostnameFlag: "my-hostname", TFELocalExecutionModeFlag: true, TFETokenFlag: "my-token", UseTFPluginCache: true, VarFileAllowlistFlag: "/path", VCSStatusName: "my-status", WebBasicAuthFlag: false, WebPasswordFlag: "atlantis", WebUsernameFlag: "atlantis", WebsocketCheckOrigin: false, WriteGitCredsFlag: true, DisableAutoplanFlag: true, DisableAutoplanLabelFlag: "no-auto-plan", DisableUnlockLabelFlag: "do-not-unlock", EnablePolicyChecksFlag: false, EnableRegExpCmdFlag: false, EnableDiffMarkdownFormat: false, } func TestExecute_Defaults(t *testing.T) { t.Log("Should set the defaults for all unspecified flags.") c := setup(map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) // Get our hostname since that's what atlantis-url gets defaulted to. hostname, err := os.Hostname() Ok(t, err) // Get our home dir since that's what data-dir and markdown-template-overrides-dir defaulted to. dataDir, err := homedir.Expand("~/.atlantis") Ok(t, err) markdownTemplateOverridesDir, err := homedir.Expand("~/.markdown_templates") Ok(t, err) strExceptions := map[string]string{ GHUserFlag: "user", GHTokenFlag: "token", DataDirFlag: dataDir, MarkdownTemplateOverridesDirFlag: markdownTemplateOverridesDir, AtlantisURLFlag: "http://" + hostname + ":4141", RepoAllowlistFlag: "*", VarFileAllowlistFlag: dataDir, } strIgnore := map[string]bool{ "config": true, } for flag, cfg := range stringFlags { t.Log(flag) if _, ok := strIgnore[flag]; ok { continue } else if excep, ok := strExceptions[flag]; ok { Equals(t, excep, configVal(t, passedConfig, flag)) } else { Equals(t, cfg.defaultValue, configVal(t, passedConfig, flag)) } } for flag, cfg := range boolFlags { t.Log(flag) Equals(t, cfg.defaultValue, configVal(t, passedConfig, flag)) } for flag, cfg := range intFlags { t.Log(flag) Equals(t, cfg.defaultValue, configVal(t, passedConfig, flag)) } } func TestExecute_Flags(t *testing.T) { t.Log("Should use all flags that are set.") c := setup(testFlags, t) err := c.Execute() Ok(t, err) for flag, exp := range testFlags { Equals(t, exp, configVal(t, passedConfig, flag)) } } func TestExecute_ConfigFile(t *testing.T) { t.Log("Should use all the values from the config file.") // Use yaml package to quote values that need quoting cfgContents, yamlErr := yaml.Marshal(&testFlags) Ok(t, yamlErr) tmpFile := tempFile(t, string(cfgContents)) defer os.Remove(tmpFile) // nolint: errcheck c := setup(map[string]interface{}{ ConfigFlag: tmpFile, }, t) err := c.Execute() Ok(t, err) for flag, exp := range testFlags { Equals(t, exp, configVal(t, passedConfig, flag)) } } func TestExecute_EnvironmentVariables(t *testing.T) { t.Log("Environment variables should work.") for flag, value := range testFlags { envKey := "ATLANTIS_" + strings.ToUpper(strings.ReplaceAll(flag, "-", "_")) os.Setenv(envKey, fmt.Sprintf("%v", value)) // nolint: errcheck defer func(key string) { os.Unsetenv(key) }(envKey) } c := setup(nil, t) err := c.Execute() Ok(t, err) for flag, exp := range testFlags { Equals(t, exp, configVal(t, passedConfig, flag)) } } func TestExecute_NoConfigFlag(t *testing.T) { t.Log("If there is no config flag specified Execute should return nil.") c := setupWithDefaults(map[string]interface{}{ ConfigFlag: "", }, t) err := c.Execute() Ok(t, err) } func TestExecute_ConfigFileExtension(t *testing.T) { t.Log("If the config file doesn't have an extension then error.") c := setupWithDefaults(map[string]interface{}{ ConfigFlag: "does-not-exist", }, t) err := c.Execute() Equals(t, "invalid config: reading does-not-exist: Unsupported Config Type \"\"", err.Error()) } func TestExecute_ConfigFileMissing(t *testing.T) { t.Log("If the config file doesn't exist then error.") c := setupWithDefaults(map[string]interface{}{ ConfigFlag: "does-not-exist.yaml", }, t) err := c.Execute() Equals(t, "invalid config: reading does-not-exist.yaml: open does-not-exist.yaml: no such file or directory", err.Error()) } func TestExecute_ConfigFileExists(t *testing.T) { t.Log("If the config file exists then there should be no error.") tmpFile := tempFile(t, "") defer os.Remove(tmpFile) // nolint: errcheck c := setupWithDefaults(map[string]interface{}{ ConfigFlag: tmpFile, }, t) err := c.Execute() Ok(t, err) } func TestExecute_InvalidConfig(t *testing.T) { t.Log("If the config file contains invalid yaml there should be an error.") tmpFile := tempFile(t, "invalidyaml") defer os.Remove(tmpFile) // nolint: errcheck c := setupWithDefaults(map[string]interface{}{ ConfigFlag: tmpFile, }, t) err := c.Execute() Assert(t, strings.Contains(err.Error(), "unmarshal errors"), "should be an unmarshal error") } // Should error if the repo allowlist contained a scheme. func TestExecute_RepoAllowlistScheme(t *testing.T) { c := setup(map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "http://github.com/*", }, t) err := c.Execute() Assert(t, err != nil, "should be an error") Equals(t, "--repo-allowlist cannot contain ://, should be hostnames only", err.Error()) } func TestExecute_ValidateLogLevel(t *testing.T) { cases := []struct { description string flags map[string]interface{} expectError bool }{ { "log level is invalid", map[string]interface{}{ LogLevelFlag: "invalid", }, true, }, { "log level is valid uppercase", map[string]interface{}{ LogLevelFlag: "DEBUG", }, false, }, } for _, testCase := range cases { t.Log("Should validate log level when " + testCase.description) c := setupWithDefaults(testCase.flags, t) err := c.Execute() if testCase.expectError { Assert(t, err != nil, "should be an error") } else { Ok(t, err) } } } func TestExecute_ValidateCheckoutStrategy(t *testing.T) { c := setupWithDefaults(map[string]interface{}{ CheckoutStrategyFlag: "invalid", }, t) err := c.Execute() ErrEquals(t, "invalid checkout strategy: not one of branch or merge", err) } func TestExecute_ValidateSSLConfig(t *testing.T) { expErr := "--ssl-key-file and --ssl-cert-file are both required for ssl" cases := []struct { description string flags map[string]interface{} expectError bool }{ { "neither option set", make(map[string]interface{}), false, }, { "just ssl-key-file set", map[string]interface{}{ SSLKeyFileFlag: "file", }, true, }, { "just ssl-cert-file set", map[string]interface{}{ SSLCertFileFlag: "flag", }, true, }, { "both flags set", map[string]interface{}{ SSLCertFileFlag: "cert", SSLKeyFileFlag: "key", }, false, }, } for _, testCase := range cases { t.Log("Should validate ssl config when " + testCase.description) c := setupWithDefaults(testCase.flags, t) err := c.Execute() if testCase.expectError { Assert(t, err != nil, "should be an error") Equals(t, expErr, err.Error()) } else { Ok(t, err) } } } func TestExecute_ValidateVCSConfig(t *testing.T) { expErr := "--gh-user/--gh-token or --gh-app-id/--gh-app-key-file or --gh-app-id/--gh-app-key or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set" cases := []struct { description string flags map[string]interface{} expectError bool }{ { "no config set", make(map[string]interface{}), true, }, { "just github token set", map[string]interface{}{ GHTokenFlag: "token", }, true, }, { "just gitlab token set", map[string]interface{}{ GitlabTokenFlag: "token", }, true, }, { "just bitbucket token set", map[string]interface{}{ BitbucketTokenFlag: "token", }, true, }, { "just azuredevops token set", map[string]interface{}{ ADTokenFlag: "token", }, true, }, { "just github user set", map[string]interface{}{ GHUserFlag: "user", }, true, }, { "just github app set", map[string]interface{}{ GHAppIDFlag: "1", }, true, }, { "just github app key file set", map[string]interface{}{ GHAppKeyFileFlag: "key.pem", }, true, }, { "just github app key set", map[string]interface{}{ GHAppKeyFlag: testdata.GithubPrivateKey, }, true, }, { "just gitlab user set", map[string]interface{}{ GitlabUserFlag: "user", }, true, }, { "just bitbucket user set", map[string]interface{}{ BitbucketUserFlag: "user", }, true, }, { "just azuredevops user set", map[string]interface{}{ ADUserFlag: "user", }, true, }, { "github user and gitlab token set", map[string]interface{}{ GHUserFlag: "user", GitlabTokenFlag: "token", }, true, }, { "gitlab user and github token set", map[string]interface{}{ GitlabUserFlag: "user", GHTokenFlag: "token", }, true, }, { "github user and bitbucket token set", map[string]interface{}{ GHUserFlag: "user", BitbucketTokenFlag: "token", }, true, }, { "github user and github token set and should be successful", map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", }, false, }, { "github app and key file set and should be successful", map[string]interface{}{ GHAppIDFlag: "1", GHAppKeyFileFlag: "key.pem", }, false, }, { "github app and key set and should be successful", map[string]interface{}{ GHAppIDFlag: "1", GHAppKeyFlag: testdata.GithubPrivateKey, }, false, }, { "gitlab user and gitlab token set and should be successful", map[string]interface{}{ GitlabUserFlag: "user", GitlabTokenFlag: "token", }, false, }, { "bitbucket user and bitbucket token set and should be successful", map[string]interface{}{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", }, false, }, { "azuredevops user and azuredevops token set and should be successful", map[string]interface{}{ ADUserFlag: "user", ADTokenFlag: "token", }, false, }, { "all set should be successful", map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", GitlabUserFlag: "user", GitlabTokenFlag: "token", BitbucketUserFlag: "user", BitbucketTokenFlag: "token", ADUserFlag: "user", ADTokenFlag: "token", }, false, }, } for _, testCase := range cases { t.Log("Should validate vcs config when " + testCase.description) testCase.flags[RepoAllowlistFlag] = "*" c := setup(testCase.flags, t) err := c.Execute() if testCase.expectError { Assert(t, err != nil, "should be an error") Equals(t, expErr, err.Error()) } else { Ok(t, err) } } } func TestExecute_ValidateAllowCommands(t *testing.T) { cases := []struct { name string allowCommandsFlag string expErr string }{ { name: "invalid allow commands", allowCommandsFlag: "noallow", expErr: "invalid --allow-commands: unknown command name: noallow", }, { name: "success with empty allow commands", allowCommandsFlag: "", expErr: "", }, } for _, testCase := range cases { c := setupWithDefaults(map[string]interface{}{ AllowCommandsFlag: testCase.allowCommandsFlag, }, t) err := c.Execute() if testCase.expErr != "" { ErrEquals(t, testCase.expErr, err) } else { Ok(t, err) } } } func TestExecute_ExpandHomeInDataDir(t *testing.T) { t.Log("If ~ is used as a data-dir path, should expand to absolute home path") c := setup(map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "*", DataDirFlag: "~/this/is/a/path", }, t) err := c.Execute() Ok(t, err) home, err := homedir.Dir() Ok(t, err) Equals(t, home+"/this/is/a/path", passedConfig.DataDir) } func TestExecute_RelativeDataDir(t *testing.T) { t.Log("Should convert relative dir to absolute.") c := setupWithDefaults(map[string]interface{}{ DataDirFlag: "../", }, t) // Figure out what ../ should be as an absolute path. expectedAbsolutePath, err := filepath.Abs("../") Ok(t, err) err = c.Execute() Ok(t, err) Equals(t, expectedAbsolutePath, passedConfig.DataDir) } func TestExecute_GithubUser(t *testing.T) { t.Log("Should remove the @ from the github username if it's passed.") c := setup(map[string]interface{}{ GHUserFlag: "@user", GHTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.GithubUser) } func TestExecute_GithubApp(t *testing.T) { t.Log("Should remove the @ from the github username if it's passed.") c := setup(map[string]interface{}{ GHAppKeyFlag: testdata.GithubPrivateKey, GHAppIDFlag: "1", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, int64(1), passedConfig.GithubAppID) } func TestExecute_GitlabUser(t *testing.T) { t.Log("Should remove the @ from the gitlab username if it's passed.") c := setup(map[string]interface{}{ GitlabUserFlag: "@user", GitlabTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.GitlabUser) } func TestExecute_BitbucketUser(t *testing.T) { t.Log("Should remove the @ from the bitbucket username if it's passed.") c := setup(map[string]interface{}{ BitbucketUserFlag: "@user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.BitbucketUser) } func TestExecute_ADUser(t *testing.T) { t.Log("Should remove the @ from the azure devops username if it's passed.") c := setup(map[string]interface{}{ ADUserFlag: "@user", ADTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.AzureDevopsUser) } // If using bitbucket cloud, webhook secrets are not supported. func TestExecute_BitbucketCloudWithWebhookSecret(t *testing.T) { c := setup(map[string]interface{}{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", BitbucketWebhookSecretFlag: "my secret", }, t) err := c.Execute() ErrEquals(t, "--bitbucket-webhook-secret cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", err) } // Base URL must have a scheme. func TestExecute_BitbucketServerBaseURLScheme(t *testing.T) { c := setup(map[string]interface{}{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", BitbucketBaseURLFlag: "mydomain.com", }, t) ErrEquals(t, "--bitbucket-base-url must have http:// or https://, got \"mydomain.com\"", c.Execute()) c = setup(map[string]interface{}{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", BitbucketBaseURLFlag: "://mydomain.com", }, t) ErrEquals(t, "error parsing --bitbucket-webhook-secret flag value \"://mydomain.com\": parse \"://mydomain.com\": missing protocol scheme", c.Execute()) } // Port should be retained on base url. func TestExecute_BitbucketServerBaseURLPort(t *testing.T) { c := setup(map[string]interface{}{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", BitbucketBaseURLFlag: "http://mydomain.com:7990", }, t) Ok(t, c.Execute()) Equals(t, "http://mydomain.com:7990", passedConfig.BitbucketBaseURL) } // Can't use both --repo-config and --repo-config-json. func TestExecute_RepoCfgFlags(t *testing.T) { c := setup(map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "github.com", RepoConfigFlag: "repos.yaml", RepoConfigJSONFlag: "{}", }, t) err := c.Execute() ErrEquals(t, "cannot use --repo-config and --repo-config-json at the same time", err) } // Can't use both --tfe-hostname flag without --tfe-token. func TestExecute_TFEHostnameOnly(t *testing.T) { c := setup(map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "github.com", TFEHostnameFlag: "not-app.terraform.io", }, t) err := c.Execute() ErrEquals(t, "if setting --tfe-hostname, must set --tfe-token", err) } // Must set allow or whitelist. func TestExecute_AllowAndWhitelist(t *testing.T) { c := setup(map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", }, t) err := c.Execute() ErrEquals(t, "--repo-allowlist must be set for security purposes", err) } func TestExecute_AutoDetectModulesFromProjects_Env(t *testing.T) { t.Setenv("ATLANTIS_AUTOPLAN_MODULES_FROM_PROJECTS", "**/init.tf") c := setupWithDefaults(map[string]interface{}{}, t) err := c.Execute() Ok(t, err) Equals(t, "**/init.tf", passedConfig.AutoplanModulesFromProjects) } func TestExecute_AutoDetectModulesFromProjects(t *testing.T) { c := setupWithDefaults(map[string]interface{}{ AutoplanModulesFromProjects: "**/*.tf", }, t) err := c.Execute() Ok(t, err) Equals(t, "**/*.tf", passedConfig.AutoplanModulesFromProjects) } func TestExecute_AutoplanFileList(t *testing.T) { cases := []struct { description string flags map[string]interface{} expectErr string }{ { "default value", map[string]interface{}{ AutoplanFileListFlag: DefaultAutoplanFileList, }, "", }, { "valid value", map[string]interface{}{ AutoplanFileListFlag: "**/*.tf", }, "", }, { "invalid exclusion pattern", map[string]interface{}{ AutoplanFileListFlag: "**/*.yml,!", }, "invalid pattern in --autoplan-file-list, **/*.yml,!: illegal exclusion pattern: \"!\"", }, { "invalid pattern", map[string]interface{}{ AutoplanFileListFlag: "[^]", }, "invalid pattern in --autoplan-file-list, [^]: syntax error in pattern", }, } for _, testCase := range cases { t.Log("Should validate autoplan file list when " + testCase.description) c := setupWithDefaults(testCase.flags, t) err := c.Execute() if testCase.expectErr != "" { ErrEquals(t, testCase.expectErr, err) } else { Ok(t, err) } } } func setup(flags map[string]interface{}, t *testing.T) *cobra.Command { vipr := viper.New() for k, v := range flags { vipr.Set(k, v) } c := &ServerCmd{ ServerCreator: &ServerCreatorMock{}, Viper: vipr, SilenceOutput: true, Logger: logging.NewNoopLogger(t), } return c.Init() } func setupWithDefaults(flags map[string]interface{}, t *testing.T) *cobra.Command { vipr := viper.New() flags[GHUserFlag] = "user" flags[GHTokenFlag] = "token" flags[RepoAllowlistFlag] = "*" for k, v := range flags { vipr.Set(k, v) } c := &ServerCmd{ ServerCreator: &ServerCreatorMock{}, Viper: vipr, SilenceOutput: true, Logger: logging.NewNoopLogger(t), } return c.Init() } func tempFile(t *testing.T, contents string) string { f, err := os.CreateTemp("", "") Ok(t, err) newName := f.Name() + ".yaml" err = os.Rename(f.Name(), newName) Ok(t, err) os.WriteFile(newName, []byte(contents), 0600) // nolint: errcheck return newName } func configVal(t *testing.T, u server.UserConfig, tag string) interface{} { t.Helper() v := reflect.ValueOf(u) typeOfS := v.Type() for i := 0; i < v.NumField(); i++ { if typeOfS.Field(i).Tag.Get("mapstructure") == tag { return v.Field(i).Interface() } } t.Fatalf("no field with tag %q found", tag) return nil }