Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/kosli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ func newConfigCmd(out io.Writer) *cobra.Command {

func (o *configOptions) run() error {
path := defaultConfigFilePathFunc()
// An empty path means no home directory could be resolved. Continuing would
// write the config into the current working directory, which is never where
// the default config file belongs.
if path == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good guard — this is the one place that would otherwise have written a .kosli.yml into whatever directory the user happened to be in.

Two nits: the format string has no arguments, so errors.New says the same thing without the fmt.Errorf indirection (errors is already imported in this package's sibling files, though not here — fmt is fine if you'd rather not add the import). And since config already rejects --config-file in its PreRunE, "use --config-file on each command instead" reads for a moment as advice for this command; "…or pass --config-file to the commands you run" removes the ambiguity.

return fmt.Errorf("setting default config failed. Could not determine your home directory. Set HOME, or use --config-file on each command instead")
}
home := filepath.Dir(path)
configFileName := filepath.Base(path)
permissions := os.FileMode(0600)
Expand Down
174 changes: 174 additions & 0 deletions cmd/kosli/configWorkingDir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/suite"
)

// WorkingDirConfigTestSuite covers the config file the CLI must NOT load: one
// sitting in the current working directory that the user never named. Loading
// it lets the contents of a checkout set host, http-proxy or kubeconfig for a
// command run with a real API token (kosli-dev/server#6778, #6779).
type WorkingDirConfigTestSuite struct {
suite.Suite
}

func (suite *WorkingDirConfigTestSuite) TearDownTest() {
defaultConfigFilePathFunc = (&RealConfigGetter{}).defaultConfigFilePath
global = new(GlobalOpts)
}

// stubHomeConfig points the default config path at a file that does not exist,
// which is the state that used to trigger the working-directory fallback.
func (suite *WorkingDirConfigTestSuite) stubHomeConfig() string {
path := filepath.Join(suite.T().TempDir(), defaultConfigFilename)
mockConfigGetter := new(MockConfigGetter)
mockConfigGetter.Mock.On("defaultConfigFilePath").Return(path)
defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath
return path
}

// chdirWithConfig writes content to a named config file in a temp directory and
// makes that directory the working directory for the test.
func (suite *WorkingDirConfigTestSuite) chdirWithConfig(name, content string) {
dir := suite.T().TempDir()
suite.Require().NoError(os.WriteFile(filepath.Join(dir, name), []byte(content), 0600))
suite.T().Chdir(dir)
}

func (suite *WorkingDirConfigTestSuite) TestDefaultIsHomePathWhenHomeConfigIsAbsent() {
path := suite.stubHomeConfig()

_, _, _, _, err := executeCommandC("version")

suite.Require().NoError(err)
suite.Equal(path, global.ConfigFile,
"the default config file must be the home path, not a bare name that resolves to the working directory")
}

func (suite *WorkingDirConfigTestSuite) TestWorkingDirConfigIsNotLoaded() {
for _, name := range []string{"kosli.yml", "kosli.yaml", "kosli.json", "kosli.toml"} {
suite.Run(name, func() {
defer func() { global = new(GlobalOpts) }()
suite.stubHomeConfig()
content := "host: https://attacker.example\n"
switch filepath.Ext(name) {
case ".json":
content = `{"host": "https://attacker.example"}`
case ".toml":
content = `host = "https://attacker.example"`
}
suite.chdirWithConfig(name, content)

_, _, _, _, err := executeCommandC("version")

suite.Require().NoError(err)
suite.Equal(defaultHost, global.Host,
"a config file in the working directory must not set the host")
})
}
}

func (suite *WorkingDirConfigTestSuite) TestExplicitConfigFileFlagStillLoadsWorkingDirConfig() {
suite.stubHomeConfig()
suite.chdirWithConfig("kosli.yml", "host: https://named.example\n")

_, _, _, stderr, err := executeCommandC("version --config-file kosli.yml")

suite.Require().NoError(err)
suite.Equal("https://named.example", global.Host,
"a config file the user names must still be loaded")
suite.NotContains(stderr, "no longer loaded automatically",
"naming the file is the supported way to load it, so there is nothing to warn about")
}

func (suite *WorkingDirConfigTestSuite) TestConfigFileEnvVarStillLoadsWorkingDirConfig() {
suite.stubHomeConfig()
suite.chdirWithConfig("kosli.yml", "host: https://named.example\n")
suite.T().Setenv("KOSLI_CONFIG_FILE", "kosli.yml")

_, _, _, stderr, err := executeCommandC("version")

suite.Require().NoError(err)
suite.Equal("https://named.example", global.Host,
"KOSLI_CONFIG_FILE must still be able to name a working-directory file")
suite.NotContains(stderr, "no longer loaded automatically")
}

// TestEmptyDefaultConfigPathLoadsNothing covers the case where no home
// directory can be resolved, so defaultConfigFilePath returns no path at all.
// The working directory must not become the fallback search location. The empty
// path is injected because homedir.Dir cannot be made to fail portably: on
// macOS it falls back to dscl even with HOME unset.
func (suite *WorkingDirConfigTestSuite) TestEmptyDefaultConfigPathLoadsNothing() {
mockConfigGetter := new(MockConfigGetter)
mockConfigGetter.Mock.On("defaultConfigFilePath").Return("")
defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath
suite.chdirWithConfig("kosli.yml", "host: https://attacker.example\n")

_, _, _, _, err := executeCommandC("version")

suite.Require().NoError(err)
suite.Equal(defaultHost, global.Host,
"with no default config path the working directory must not be searched instead")
}

func (suite *WorkingDirConfigTestSuite) TestWarnsAboutIgnoredWorkingDirConfig() {
cases := []struct {
name string
content string
wantWarn bool
}{
{name: "host", content: "host: https://attacker.example\n", wantWarn: true},
{name: "org", content: "org: some-org\n", wantWarn: true},
{name: "api token", content: "api-token: abc123\n", wantWarn: true},
{name: "documented uppercase keys", content: "ORG: some-org\nAPI-TOKEN: abc123\n", wantWarn: true},
// A kosli.yml in a repository root is far more often a flow template,
// which was never loaded as CLI config, so it must stay silent.
{name: "flow template shape", content: "trail:\n artifacts:\n - name: nginx\n", wantWarn: false},
{name: "unparsable file", content: "\tnot: [valid\n", wantWarn: false},
}
for _, tc := range cases {
suite.Run(tc.name, func() {
defer func() { global = new(GlobalOpts) }()
suite.stubHomeConfig()
suite.chdirWithConfig("kosli.yml", tc.content)

_, _, _, stderr, err := executeCommandC("version")

suite.Require().NoError(err)
if tc.wantWarn {
suite.Contains(stderr, "kosli.yml")
suite.Contains(stderr, "no longer loaded automatically")
suite.Contains(stderr, "--config-file kosli.yml",
"the warning must name the fix, not just the problem")
} else {
suite.NotContains(stderr, "no longer loaded automatically")
}
})
}
}

func TestWorkingDirConfigTestSuite(t *testing.T) {
suite.Run(t, new(WorkingDirConfigTestSuite))
}

// TestConfigCommandFailsWithoutHomeDirectory pins the other side of an empty
// default config path: `kosli config` must say so rather than silently writing
// a config file into the current working directory.
func (suite *WorkingDirConfigTestSuite) TestConfigCommandFailsWithoutHomeDirectory() {
mockConfigGetter := new(MockConfigGetter)
mockConfigGetter.Mock.On("defaultConfigFilePath").Return("")
defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath
suite.T().Chdir(suite.T().TempDir())

_, _, _, _, err := executeCommandC("config --org some-org")

suite.Require().Error(err)
suite.Contains(err.Error(), "Could not determine your home directory")
_, statErr := os.Stat(defaultConfigFilename)
suite.Require().Error(statErr, "no config file may be written into the working directory")
}
Comment on lines +155 to +174

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestConfigCommandFailsWithoutHomeDirectory sits below the suite.Run entry point, so the file reads as if the suite ended at line 157. It still runs, but move it above TestWorkingDirConfigTestSuite to match the rest of the suite and the convention in the other test files here.

Also note this test parses an untrusted, repository-controlled file on every command run in that directory (warnAboutIgnoredWorkingDirConfigv.ReadInConfig()). That is a deliberate and much smaller surface than applying the values, and the unparsable file case pins that a parse error stays quiet — but it does mean an arbitrarily large or pathological kosli.yml in a checkout is still fed to a parser. A cheap os.Stat size ceiling (skip files over, say, 1 MB) before ReadInConfig would close that without changing any behaviour a real config file has.

105 changes: 78 additions & 27 deletions cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,18 +369,57 @@ func (r *RealConfigGetter) defaultConfigFilePath() string {
return filepath.Join(home, defaultConfigFilename)

}
return "kosli" // for backward compatibility with old default config location
// With no resolvable home directory there is no default config file. A bare
// name here would make viper search the current working directory, which is
// the whole problem getConfigFileFlagDefault exists to avoid.
return ""
}

// defaultConfigFilePathFunc is a variable holding the implementation of defaultConfigFilePath
var defaultConfigFilePathFunc = (&RealConfigGetter{}).defaultConfigFilePath

func getConfigFileFlagDefault() string {
defaultPath := defaultConfigFilePathFunc()
if _, err := os.Stat(defaultPath); err == nil {
return defaultPath
// workingDirConfigNames are the config file names the CLI used to load
// implicitly from the current working directory, before that became
// kosli-dev/server#6778 and #6779.
var workingDirConfigNames = []string{"kosli.yaml", "kosli.yml", "kosli.json", "kosli.toml"}
Comment on lines +381 to +384

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning covers 4 of the 12 names that used to be loaded, and orders them differently from viper.

The old code path was v.SetConfigName("kosli") + v.AddConfigPath("."), so viper searched kosli.<ext> for every entry in viper.SupportedExts — in viper 1.21 that is json, toml, yaml, yml, properties, props, prop, hcl, tfvars, dotenv, env, ini — and returned the first match in that order.

Two consequences:

  1. A pipeline relying on kosli.properties, kosli.env, kosli.ini, kosli.hcl or kosli.tfvars loses its config with no warning at all — precisely the silent breakage this function exists to prevent.
  2. If both kosli.json and kosli.yml exist, the loop reports kosli.yml first, but the file viper actually loaded before was kosli.json. A user who follows the advice (--config-file kosli.yml) gets a different config than they had, still silently.

Deriving the list from viper keeps both the coverage and the precedence honest, and it can't drift when viper adds a format:

Suggested change
// workingDirConfigNames are the config file names the CLI used to load
// implicitly from the current working directory, before that became
// kosli-dev/server#6778 and #6779.
var workingDirConfigNames = []string{"kosli.yaml", "kosli.yml", "kosli.json", "kosli.toml"}
// workingDirConfigNames are the config file names the CLI used to load
// implicitly from the current working directory, before that became
// kosli-dev/server#6778 and #6779. viper searched a "kosli" config name in
// every extension it supports and took the first match, so the list is
// derived from viper in viper's own order.
var workingDirConfigNames = func() []string {
names := make([]string, 0, len(viper.SupportedExts))
for _, ext := range viper.SupportedExts {
names = append(names, "kosli."+ext)
}
return names
}()

TestWorkingDirConfigIsNotLoaded would then be worth extending with one non-YAML/JSON case (kosli.env or kosli.properties) to pin the wider set.


// warnAboutIgnoredWorkingDirConfig reports a config file in the current working
// directory that an earlier CLI would have loaded, so that the change does not
// break a pipeline silently.
//
// Only a file that sets a global setting is reported. A kosli.yml in a
// repository root is far more often a flow template, which was never loaded as
// CLI config, and warning about those would be pure noise.
func warnAboutIgnoredWorkingDirConfig() {
for _, name := range workingDirConfigNames {
if _, err := os.Stat(name); err != nil {
continue
}

v := viper.New()
v.SetConfigFile(name)
if err := v.ReadInConfig(); err != nil {
continue
}
if !v.IsSet("org") && !v.IsSet("api-token") && !v.IsSet("host") {
continue
}
Comment on lines +404 to +406

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key set omits the two settings the advisories were actually about.

http-proxy and kubeconfig are the vectors named in the PR description (#6778, #6779), yet a working-directory config that sets only those — or only max-api-retries — is now ignored and silent. Someone whose kosli.yml held http-proxy for a corporate proxy loses it with no signal, which is the exact failure mode the warning was added to avoid.

The flow-template exclusion still holds if the added keys are ones no template uses. Note that trail, artifacts, template and version must stay out of the set for that reason — trail is both a CLI flag and a template key, so adding it would break the flow template shape case in TestWarnsAboutIgnoredWorkingDirConfig.

Suggested change
if !v.IsSet("org") && !v.IsSet("api-token") && !v.IsSet("host") {
continue
}
// http-proxy, kubeconfig and max-api-retries are global settings a
// working-directory config plausibly held. trail, artifacts, template
// and version stay out: they are flow template keys, and a template
// was never loaded as CLI config, so it must not warn.
if !v.IsSet("org") && !v.IsSet("api-token") && !v.IsSet("host") &&
!v.IsSet("http-proxy") && !v.IsSet("kubeconfig") && !v.IsSet("max-api-retries") {
continue
}


logger.Warn("config file [%s] in the current directory is no longer loaded automatically. To keep using it, pass --config-file %s or set KOSLI_CONFIG_FILE=%s. To apply its settings to every command, move them to your home config file with 'kosli config'.", name, name, name)
return
}
return "kosli" // for backward compatibility with old default config location
}

// getConfigFileFlagDefault returns the default --config-file value, which is
// always the home config file whether or not that file exists. It used to fall
// back to the bare name "kosli", which viper resolves against the current
// working directory: a repository could then set host, http-proxy or kubeconfig
// for a command run with a real API token, redirecting the bearer token or
// executing a kubeconfig credential plugin (kosli-dev/server#6778, #6779).
// A config file in the working directory is now loaded only when the user names
// it with --config-file or KOSLI_CONFIG_FILE.
func getConfigFileFlagDefault() string {
return defaultConfigFilePathFunc()
}

func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) {
Expand Down Expand Up @@ -530,38 +569,50 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error {
// we load the config file before we bind env vars to flags,
// so we check for the config file env var separately here
configFlag := cmd.Flags().Lookup("config-file")
namedByUser := configFlag.Changed
if !configFlag.Changed {
// A variable set to the empty string reports as present, but it names no
// file. Overriding the default with it loads no config file at all,
// silently dropping org, api-token and every other configured default.
if path, exists := os.LookupEnv("KOSLI_CONFIG_FILE"); exists && path != "" {
global.ConfigFile = path
namedByUser = true
}
}
dir, file := filepath.Split(global.ConfigFile)
file = strings.TrimSuffix(file, filepath.Ext(file))

// Set the base name of the config file, without the file extension.
v.SetConfigName(file)
if global.ConfigFile != "" {
dir, file := filepath.Split(global.ConfigFile)
file = strings.TrimSuffix(file, filepath.Ext(file))

// Set as many paths as you like where viper should look for the
// config file. By default, we are looking in the current working directory.
if dir == "" {
dir = "."
}
v.AddConfigPath(dir)

// Attempt to read the config file, gracefully ignoring errors
// caused by a config file not being found. Return an error
// if we cannot parse the config file.
logger.Debug("processing config file [%s]", global.ConfigFile)
if err := v.ReadInConfig(); err != nil {
// It's okay if there isn't a config file
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("failed to parse config file [%s] : %v", global.ConfigFile, err)
} else {
logger.Debug("config file [%s] not found. Skipping.", global.ConfigFile)
// Set the base name of the config file, without the file extension.
v.SetConfigName(file)

// A relative path is resolved against the current working directory,
// which is what a user asks for by naming one. The default is absolute,
// so it never reaches that case.
if dir == "" {
dir = "."
}
v.AddConfigPath(dir)

// Attempt to read the config file, gracefully ignoring errors
// caused by a config file not being found. Return an error
// if we cannot parse the config file.
logger.Debug("processing config file [%s]", global.ConfigFile)
if err := v.ReadInConfig(); err != nil {
// It's okay if there isn't a config file
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("failed to parse config file [%s] : %v", global.ConfigFile, err)
} else {
logger.Debug("config file [%s] not found. Skipping.", global.ConfigFile)
}
}
} else {
logger.Debug("no default config file location could be determined. Skipping.")
}

Comment on lines 581 to +613

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing pins that the primary path still works. The if global.ConfigFile != "" wrapper is the load path for $HOME/.kosli.yml, but every test in the new suite stubs the default at a path that does not exist, and RootCommandTestSuite only exercises an explicit --config-file. A future edit that broke reading the home config (e.g. inverting this condition) would leave the whole suite green.

Worth one more case in WorkingDirConfigTestSuite: stub the default at a temp file containing host: https://home.example, run version, assert global.Host. It reuses stubHomeConfig almost verbatim and closes the one gap in an otherwise well-targeted suite.

Two smaller notes on this block:

  • warnAboutIgnoredWorkingDirConfig runs before bindFlags, so logger.QuietEnabled still reflects only the --quiet command-line flag. KOSLI_QUIET=true (or quiet in the home config) therefore does not suppress this warning, while --quiet does. Minor, but it makes --quiet and its env var behave differently for one message.
  • With DOCS set, the default is the literal $HOME/.kosli.yml, dir becomes $HOME/, and viper's absPathify expands it — so docs generation now reads the real home config where it previously searched ./kosli.*. Harmless for docgen output, but it is a behaviour change that no test covers.

if !namedByUser {
warnAboutIgnoredWorkingDirConfig()
}
// When we bind flags to environment variables expect that the
// environment variables are prefixed, e.g. a flag like --namespace
Expand Down
Loading