diff --git a/cmd/confluent/main.go b/cmd/confluent/main.go index ea4c2fb391..a5fb7a815e 100644 --- a/cmd/confluent/main.go +++ b/cmd/confluent/main.go @@ -22,6 +22,10 @@ var ( ) func main() { + // Must precede both the config load and command construction, since each resolves paths that + // depend on the channel. + pversion.SetProcessChannel(pversion.ChannelOf(version)) + cfg := config.New() err := cfg.Load() diff --git a/cmd/docs/main.go b/cmd/docs/main.go index 8770f32463..5afaebc1cf 100644 --- a/cmd/docs/main.go +++ b/cmd/docs/main.go @@ -17,6 +17,11 @@ import ( // This code is adapted from https://github.com/spf13/cobra/blob/master/doc/rest_docs.md func main() { + // Docs describe the released CLI, so paths in them must be the stable channel's; this binary + // carries no version stamp and would otherwise resolve to a dev build. Set before anything + // else, since the channel is an unsynchronized global and the test server below is concurrent. + pversion.SetProcessChannel(pversion.Stable) + // Set up test server for feature flags called by the code testBackend := testserver.StartTestCloudServer(&testing.T{}, true) defer testBackend.Close() diff --git a/cmd/lint/main.go b/cmd/lint/main.go index 80e1acfbff..5edd296920 100644 --- a/cmd/lint/main.go +++ b/cmd/lint/main.go @@ -409,6 +409,10 @@ func init() { } func main() { + // Lint the released CLI's surface, so paths baked into flag defaults are the stable channel's. + // Must precede command construction; this binary carries no version stamp of its own. + pversion.SetProcessChannel(pversion.Stable) + // Set up test server for feature flags called by the code testBackend := testserver.StartTestCloudServer(&testing.T{}, true) defer testBackend.Close() diff --git a/cmd/whitelist/main.go b/cmd/whitelist/main.go index b83369cc11..610d1c268a 100644 --- a/cmd/whitelist/main.go +++ b/cmd/whitelist/main.go @@ -26,6 +26,10 @@ var version = "v0.0.0" */ func main() { + // Whitelist the released CLI's surface, so paths baked into flag defaults are the stable + // channel's. Must precede command construction; this binary carries no version stamp. + pversion.SetProcessChannel(pversion.Stable) + fmt.Println("BEGIN;") fmt.Println("") fmt.Println("INSERT INTO whitelist(version, keyword) VALUES") diff --git a/internal/configuration/command_list.go b/internal/configuration/command_list.go index c5acb044c3..a49046a7dc 100644 --- a/internal/configuration/command_list.go +++ b/internal/configuration/command_list.go @@ -10,7 +10,7 @@ import ( func (c *command) newListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List user-configurable fields in ~/.confluent/config.json.", + Short: "List user-configurable fields in the CLI configuration file.", Args: cobra.NoArgs, RunE: c.list, } diff --git a/internal/plugin/command_install.go b/internal/plugin/command_install.go index 27a6d781b0..a482302c3c 100644 --- a/internal/plugin/command_install.go +++ b/internal/plugin/command_install.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "github.com/confluentinc/cli/v4/pkg/config" "github.com/confluentinc/cli/v4/pkg/errors" "github.com/confluentinc/cli/v4/pkg/output" "github.com/confluentinc/cli/v4/pkg/plugin" @@ -27,11 +28,11 @@ func (c *command) newInstallCommand() *cobra.Command { } func (c *command) install(_ *cobra.Command, args []string) error { - home, err := os.UserHomeDir() + confluentDir, err := config.StateDir() if err != nil { return err } - confluentDir := filepath.Join(home, ".confluent") + dir, err := os.MkdirTemp(confluentDir, "cli-plugins") if err != nil { return err diff --git a/internal/plugin/command_search.go b/internal/plugin/command_search.go index 5fc0a2d601..1fa62122d4 100644 --- a/internal/plugin/command_search.go +++ b/internal/plugin/command_search.go @@ -11,6 +11,7 @@ import ( "gopkg.in/yaml.v3" pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/config" "github.com/confluentinc/cli/v4/pkg/output" "github.com/confluentinc/cli/v4/pkg/plugin" "github.com/confluentinc/cli/v4/pkg/utils" @@ -50,12 +51,12 @@ func (c *command) newSearchCommand() *cobra.Command { } func (c *command) search(cmd *cobra.Command, _ []string) error { - home, err := os.UserHomeDir() + stateDir, err := config.StateDir() if err != nil { return err } - dir, err := os.MkdirTemp(filepath.Join(home, ".confluent"), "cli-plugins") + dir, err := os.MkdirTemp(stateDir, "cli-plugins") if err != nil { return err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 6b2be4b527..a7cb80c6c0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -648,9 +648,30 @@ func (c *Config) GetFilename() string { return c.Filename } +// StateDirName is the name of the CLI's state directory within the user's home directory. It is +// scoped to the build's release channel so a production install, a prerelease, and a local build +// cannot read or overwrite each other's state. A stable build returns ".confluent", unchanged. +func StateDirName() string { + return ".confluent" + pversion.ProcessChannel().StateDirSuffix() +} + +// StateDir is the absolute path of the CLI's state directory. +func StateDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", errors.NewErrorWithSuggestions( + fmt.Sprintf("unable to determine the home directory holding the CLI's state: %v", err), + "Set the `HOME` environment variable (`USERPROFILE` on Windows) to a writable directory.", + ) + } + return filepath.Join(home, StateDirName()), nil +} + +// GetDefaultFilename swallows a missing home directory because it backs a flag default built at +// command-construction time, where there is no error to return. Prefer StateDir where you can. func GetDefaultFilename() string { home, _ := os.UserHomeDir() - return filepath.Join(home, ".confluent", "config.json") + return filepath.Join(home, StateDirName(), "config.json") } func (c *Config) CheckIsOnPremLogin() error { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 579d6535c2..3c65803da8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -565,10 +565,87 @@ func TestConfig_OverwrittenEnvironment(t *testing.T) { func TestConfig_getFilename(t *testing.T) { home, err := os.UserHomeDir() require.NoError(t, err) - path := filepath.Join(home, ".confluent", "config.json") + + // Set explicitly rather than relying on the package default, so this cannot start passing or + // failing because of a channel another test left behind. + t.Cleanup(func() { pversion.SetProcessChannel(pversion.Dev) }) + pversion.SetProcessChannel(pversion.Dev) + + path := filepath.Join(home, ".confluent-dev", "config.json") require.Equal(t, path, New().GetFilename()) } +func TestConfig_getFilename_perChannel(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + t.Cleanup(func() { pversion.SetProcessChannel(pversion.Dev) }) + + // Driven by the version strings the build system actually produces, so this fails if either the + // classifier or the directory naming drifts. + tests := []struct { + name string + version string + dir string + }{ + {"GA release", "4.72.0", ".confluent"}, + {"release candidate", "5.0.0-rc1", ".confluent-prerelease"}, + {"local goreleaser snapshot", "4.72.0-SNAPSHOT-d962911bb", ".confluent-dev"}, + {"nothing stamped", "0.0.0", ".confluent-dev"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pversion.SetProcessChannel(pversion.ChannelOf(test.version)) + + require.Equal(t, filepath.Join(home, test.dir, "config.json"), New().GetFilename()) + }) + } +} + +func TestStateDir_ErrorWhenHomeUnresolvable(t *testing.T) { + // Clearing the home-directory env vars is what makes os.UserHomeDir fail: HOME on Unix, + // USERPROFILE on Windows. Both are cleared so the test is platform-agnostic. + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + if runtime.GOOS == "windows" { + // os.UserHomeDir consults HOMEDRIVE+HOMEPATH before erroring on Windows. + t.Setenv("HOMEDRIVE", "") + t.Setenv("HOMEPATH", "") + } + + _, err := StateDir() + + require.Error(t, err, "StateDir must fail rather than fall back to the working directory") +} + +func TestStateDir_ByChannel(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Cleanup(func() { pversion.SetProcessChannel(pversion.Dev) }) + + tests := []struct { + name string + channel pversion.Channel + dir string + }{ + {"stable keeps the historical path", pversion.Stable, ".confluent"}, + {"prerelease is isolated", pversion.Prerelease, ".confluent-prerelease"}, + {"dev is isolated", pversion.Dev, ".confluent-dev"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pversion.SetProcessChannel(test.channel) + + dir, err := StateDir() + + require.NoError(t, err) + require.Equal(t, filepath.Join(home, test.dir), dir) + }) + } +} + func TestConfig_AddContext(t *testing.T) { filename := "/tmp/TestConfig_AddContext.json" conf := AuthenticatedOnPremConfigMock() diff --git a/pkg/flink/config/env_variables.go b/pkg/flink/config/env_variables.go index 8a13b71bac..bd7c631aab 100644 --- a/pkg/flink/config/env_variables.go +++ b/pkg/flink/config/env_variables.go @@ -1,5 +1,11 @@ package config -// Keys/values for environment variables in pairs followed by default values +// Overrides the directory under $HOME that Flink statement history is written to. The default is +// no longer a constant here: it follows the build's release channel, via config.StateDirName. const HomeConfluentPathEnvVar = "HOME_CONFLUENT_PATH" + +// HomeConfluentPathDefault is the legacy stable-channel state directory name. +// +// Deprecated: the state directory now follows the build's release channel; use +// config.StateDirName instead. Retained as an exported alias for source compatibility. const HomeConfluentPathDefault = ".confluent" diff --git a/pkg/flink/internal/history/history.go b/pkg/flink/internal/history/history.go index 4d9da8681a..826bbc89d7 100644 --- a/pkg/flink/internal/history/history.go +++ b/pkg/flink/internal/history/history.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + pconfig "github.com/confluentinc/cli/v4/pkg/config" "github.com/confluentinc/cli/v4/pkg/flink/config" "github.com/confluentinc/cli/v4/pkg/log" ) @@ -58,9 +59,11 @@ func initPath(file string) *History { return nil } + // HOME_CONFLUENT_PATH predates channel-scoped state directories and still wins, so anyone + // relying on it keeps the layout they have. confluentDir := os.Getenv(config.HomeConfluentPathEnvVar) if confluentDir == "" { - confluentDir = config.HomeConfluentPathDefault + confluentDir = pconfig.StateDirName() } confluentPath := filepath.Join(home, confluentDir) historyPath := filepath.Join(confluentPath, file) diff --git a/pkg/flink/internal/history/history_test.go b/pkg/flink/internal/history/history_test.go index d06241f558..e4797a84ab 100644 --- a/pkg/flink/internal/history/history_test.go +++ b/pkg/flink/internal/history/history_test.go @@ -6,6 +6,10 @@ import ( "testing" "github.com/stretchr/testify/require" + + pconfig "github.com/confluentinc/cli/v4/pkg/config" + "github.com/confluentinc/cli/v4/pkg/flink/config" + pversion "github.com/confluentinc/cli/v4/pkg/version" ) func TestLoadHistory(t *testing.T) { @@ -47,6 +51,49 @@ func TestLoadHistory(t *testing.T) { history.historyPath = prevPath } +func TestInitPath_ScopesByChannel(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + t.Setenv(config.HomeConfluentPathEnvVar, "") + t.Cleanup(func() { pversion.SetProcessChannel(pversion.Dev) }) + + tests := []struct { + name string + channel pversion.Channel + dir string + }{ + {"stable keeps the historical path", pversion.Stable, ".confluent"}, + {"dev is isolated", pversion.Dev, ".confluent-dev"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pversion.SetProcessChannel(test.channel) + + history := initPath(filename) + + require.NotNil(t, history) + require.Equal(t, filepath.Join(home, test.dir), history.confluentPath) + }) + } +} + +func TestInitPath_HomeConfluentPathOverridesChannel(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + t.Setenv(config.HomeConfluentPathEnvVar, ".confluent-custom") + t.Cleanup(func() { pversion.SetProcessChannel(pversion.Dev) }) + pversion.SetProcessChannel(pversion.Stable) + + history := initPath(filename) + + require.NotNil(t, history) + require.Equal(t, filepath.Join(home, ".confluent-custom"), history.confluentPath, + "HOME_CONFLUENT_PATH must win over the channel default") + require.NotEqual(t, filepath.Join(home, pconfig.StateDirName()), history.confluentPath, + "the override must actually diverge from the channel default it is overriding") +} + func TestHistorySave(t *testing.T) { // Create a temp directory tmpDir, _ := os.MkdirTemp("", "confluent-test") diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 9b6d0f22a8..5392bfde8d 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -27,17 +27,19 @@ type pluginInfo struct { // SearchPath goes through the files in the user's $PATH and checks if they are plugins func SearchPath(cfg *config.Config) map[string][]string { - if runtime.GOOS == "windows" { - log.CliLogger.Debugf(`Searching $PATH and %%USERPROFILE%%\.confluent\plugins for plugins. Plugins can be disabled in %s.`, cfg.GetFilename()) - } else { - log.CliLogger.Debugf("Searching $PATH and ~/.confluent/plugins for plugins. Plugins can be disabled in %s.", cfg.GetFilename()) - } - pathDirList := filepath.SplitList(os.Getenv("PATH")) - home, _ := os.UserHomeDir() - pluginDir := filepath.Join(home, ".confluent", "plugins") - if !slices.Contains(pathDirList, pluginDir) { - pathDirList = append(pathDirList, pluginDir) + + // Degrading to $PATH alone is deliberate: a relative plugin directory would pick up executables + // from whatever directory the CLI happens to be run in. + stateDir, err := config.StateDir() + if err != nil { + log.CliLogger.Debugf("Searching only $PATH for plugins: %v. Plugins can be disabled in %s.", err, cfg.GetFilename()) + } else { + pluginDir := filepath.Join(stateDir, "plugins") + log.CliLogger.Debugf("Searching $PATH and %s for plugins. Plugins can be disabled in %s.", pluginDir, cfg.GetFilename()) + if !slices.Contains(pathDirList, pluginDir) { + pathDirList = append(pathDirList, pluginDir) + } } plugins := make(map[string][]string) diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go index 9c09314fae..aedde22def 100644 --- a/pkg/plugin/plugin_test.go +++ b/pkg/plugin/plugin_test.go @@ -13,6 +13,7 @@ import ( "github.com/confluentinc/cli/v4/pkg/config" "github.com/confluentinc/cli/v4/pkg/mock" + pversion "github.com/confluentinc/cli/v4/pkg/version" ) func TestIsExec_Dir(t *testing.T) { @@ -101,6 +102,69 @@ func TestSearchPath(t *testing.T) { require.Equal(t, fileName, filepath.Base(pluginPaths[0])) } +// SearchPath also scans the channel-scoped state directory ($HOME//plugins), not just +// $PATH; a regression there would make installed plugins undiscoverable while TestSearchPath (which +// only exercises $PATH) still passed. +func TestSearchPath_ChannelStateDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Cleanup(func() { pversion.SetProcessChannel(pversion.Dev) }) + pversion.SetProcessChannel(pversion.Dev) + + // An empty $PATH forces discovery through the state directory alone. + t.Setenv("PATH", t.TempDir()) + + pluginDir := filepath.Join(home, ".confluent-dev", "plugins") + require.NoError(t, os.MkdirAll(pluginDir, 0700)) + name := writeFakePlugin(t, pluginDir, "confluent-foo") + + pluginPaths, ok := SearchPath(&config.Config{})["confluent-foo"] + + require.True(t, ok, "a plugin under the channel state directory must be discovered") + require.Equal(t, name, filepath.Base(pluginPaths[0])) +} + +// When the home directory is unresolvable, config.StateDir fails and SearchPath degrades to $PATH +// alone. The failure mode this guards against is the old behavior that joined a relative +// ".confluent/plugins" (empty home + Join), which resolved against the working directory and would +// run whatever confluent-* executables happened to sit there. +func TestSearchPath_StateDirError(t *testing.T) { + // os.UserHomeDir reads HOME on Unix and USERPROFILE on Windows; clearing both makes it error, + // which is what drives config.StateDir to fail. + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + + // Plant a plugin in a working-directory-relative ".confluent/plugins": a regressed degrade path + // would scan it, the correct one must not. + t.Chdir(t.TempDir()) + relativePluginDir := filepath.Join(".confluent", "plugins") + require.NoError(t, os.MkdirAll(relativePluginDir, 0700)) + writeFakePlugin(t, relativePluginDir, "confluent-cwd") + + // A plugin on $PATH must still be discovered with no reachable state directory. + pathDir := t.TempDir() + name := writeFakePlugin(t, pathDir, "confluent-foo") + t.Setenv("PATH", pathDir) + + plugins := SearchPath(&config.Config{}) + + pluginPaths, ok := plugins["confluent-foo"] + require.True(t, ok, "plugins on $PATH must still be discovered when the state directory is unavailable") + require.Equal(t, name, filepath.Base(pluginPaths[0])) + require.NotContains(t, plugins, "confluent-cwd", "a working-directory-relative plugins directory must not be scanned") +} + +// writeFakePlugin creates an empty executable for a plugin named base (with the Windows .exe suffix +// where applicable) in dir, and returns the file name written. +func writeFakePlugin(t *testing.T, dir, base string) string { + if runtime.GOOS == "windows" { + base += ".exe" + } + require.NoError(t, os.WriteFile(filepath.Join(dir, base), nil, fs.ModePerm)) + return base +} + func TestVersionRegex(t *testing.T) { // Go goInstaller := &GoPluginInstaller{} diff --git a/pkg/version/channel.go b/pkg/version/channel.go new file mode 100644 index 0000000000..f03691193b --- /dev/null +++ b/pkg/version/channel.go @@ -0,0 +1,112 @@ +package version + +import ( + "strings" + + "github.com/hashicorp/go-version" +) + +// Channel is the release stream a binary was built from. It selects the directory the CLI keeps its +// state in, so a production install, a prerelease under evaluation, and a local build never share +// contexts or credentials. +type Channel int + +const ( + // Stable is a published GA release, and the only channel that uses the historical path. + Stable Channel = iota + + // Prerelease is a published release candidate or preview, tagged with a semver prerelease + // segment such as v5.0.0-rc1. + Prerelease + + // Dev is anything built outside the release pipeline. + Dev +) + +func (c Channel) String() string { + switch c { + case Stable: + return "stable" + case Prerelease: + return "prerelease" + default: + return "dev" + } +} + +// StateDirSuffix is appended to ".confluent" to name the channel's state directory. Only Stable +// returns an empty string, which is what keeps existing installs on the path they already use; any +// unrecognized channel falls through to the dev suffix so an unfamiliar build isolates itself rather +// than sharing production state. +func (c Channel) StateDirSuffix() string { + switch c { + case Stable: + return "" + case Prerelease: + return "-prerelease" + default: + return "-dev" + } +} + +// snapshotMarker is what goreleaser puts in a local build's version. Its default template is +// "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}" and it does not strip an existing prerelease segment, +// so during a release-candidate cycle `make build` stamps 5.0.0-rc1-SNAPSHOT-. +const snapshotMarker = "SNAPSHOT" + +// ChannelOf classifies the version string the linker stamps into main.version. +// +// A prerelease segment alone cannot mean "published prerelease", since `make build` puts one on +// every developer's binary; the snapshot marker is what separates them. Anything else carrying one +// is treated as published, which errs toward isolating an unfamiliar build. That includes a +// Confluent Platform suffix like 4.72.0-cp1, which no tag in this repo's history uses. +// +// Not folded into Version.IsReleased on purpose: it answers a different question, and treats 0.0.1 +// as released. +func ChannelOf(s string) Channel { + semver, err := version.NewSemver(s) + if err != nil || semver.Segments()[0] == 0 { + return Dev + } + + prerelease := semver.Prerelease() + switch { + case prerelease == "": + return Stable + case hasSnapshotToken(prerelease): + return Dev + default: + return Prerelease + } +} + +// hasSnapshotToken reports whether the snapshot marker appears as a whole segment of the prerelease, +// not merely as a substring, so a published label like 5.0.0-presnapshot stays a prerelease. The +// segment is the goreleaser template's own `-SNAPSHOT-`, joined with hyphens onto any existing +// prerelease dot-identifiers, so both delimiters bound a token. +func hasSnapshotToken(prerelease string) bool { + for _, token := range strings.FieldsFunc(prerelease, func(r rune) bool { return r == '-' || r == '.' }) { + if strings.EqualFold(token, snapshotMarker) { + return true + } + } + return false +} + +// processChannel is the channel of the running binary. The version is fixed at link time, so there +// is one answer per process, and command construction needs it before any Config exists. +// +// Dev is the default because an unstamped binary did not come from the release pipeline, which also +// keeps a `go test` process aligned with the test binaries it drives. +var processChannel = Dev + +// SetProcessChannel records the running binary's channel. Call it once from main, before loading +// configuration or constructing commands. +func SetProcessChannel(channel Channel) { + processChannel = channel +} + +// ProcessChannel reports the running binary's channel. +func ProcessChannel() Channel { + return processChannel +} diff --git a/pkg/version/channel_test.go b/pkg/version/channel_test.go new file mode 100644 index 0000000000..0aa490e3a2 --- /dev/null +++ b/pkg/version/channel_test.go @@ -0,0 +1,78 @@ +package version + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelOf(t *testing.T) { + tests := []struct { + name string + version string + want Channel + }{ + {"GA release", "4.72.0", Stable}, + {"GA release with a v prefix", "v4.72.0", Stable}, + {"release candidate", "5.0.0-rc1", Prerelease}, + {"release candidate with a v prefix", "v5.0.0-rc1", Prerelease}, + {"preview", "5.0.0-preview.2", Prerelease}, + {"beta", "5.0.0-beta3", Prerelease}, + {"unrecognized prerelease marker", "5.0.0-nightly.4", Prerelease}, + {"build metadata on a GA tag", "4.72.0+dirty", Stable}, + // What `make build` actually stamps. It carries a prerelease segment but is a local build, + // so misreading it as Prerelease would drop developers into the testers' state directory. + {"goreleaser snapshot", "4.72.0-SNAPSHOT-d962911bb", Dev}, + {"goreleaser snapshot, lowercased", "4.72.0-snapshot-d962911bb", Dev}, + // The marker is matched as a whole segment, not a substring, so a published label that merely + // contains the letters stays a prerelease. + {"snapshot only as a substring", "5.0.0-snapshotx", Prerelease}, + {"snapshot only as a substring, prefixed", "5.0.0-presnapshot.1", Prerelease}, + // goreleaser does not strip the tag's prerelease segment, so during an RC cycle a local + // build carries both. The snapshot marker has to win, or every developer lands in the + // prerelease directory precisely when real testers are using it. + {"snapshot built during an RC cycle", "5.0.0-rc1-SNAPSHOT-d962911bb", Dev}, + {"snapshot built during a beta cycle", "5.0.0-beta.1-SNAPSHOT-d962911bb", Dev}, + {"bare go build, nothing stamped", "0.0.0", Dev}, + {"explicit dev stamp", "0.0.0-dev-a1b2c3d", Dev}, + {"any 0.x is a local build", "0.9.1", Dev}, + {"unparsable", "not-a-version", Dev}, + {"empty", "", Dev}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, ChannelOf(test.version)) + }) + } +} + +func TestChannel_StateDirSuffix(t *testing.T) { + req := require.New(t) + + req.Empty(Stable.StateDirSuffix(), "stable must keep using the existing ~/.confluent path") + req.Equal("-prerelease", Prerelease.StateDirSuffix()) + req.Equal("-dev", Dev.StateDirSuffix()) + req.Equal("-dev", Channel(99).StateDirSuffix(), "an unrecognized channel must isolate itself, not share production state") +} + +func TestChannel_String(t *testing.T) { + req := require.New(t) + + req.Equal("stable", Stable.String()) + req.Equal("prerelease", Prerelease.String()) + req.Equal("dev", Dev.String()) + req.Equal("dev", Channel(99).String(), "an unrecognized channel must not panic") +} + +func TestProcessChannel_DefaultsToDev(t *testing.T) { + require.Equal(t, Dev, ProcessChannel(), "a binary with no version stamped in must not touch production state") +} + +func TestSetProcessChannel(t *testing.T) { + t.Cleanup(func() { SetProcessChannel(Dev) }) + + SetProcessChannel(Stable) + + require.Equal(t, Stable, ProcessChannel()) +} diff --git a/test/channel_state_test.go b/test/channel_state_test.go new file mode 100644 index 0000000000..0d75b0e7fd --- /dev/null +++ b/test/channel_state_test.go @@ -0,0 +1,114 @@ +package test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// The channel a build resolves its state directory from is decided in cmd/confluent/main.go, before +// any command runs. Unit tests cover the classifier and the path it produces, but neither can see +// whether main actually calls it: the integration binary carries no version stamp, so "wired +// correctly" and "not wired at all" both come out as the dev channel. +// +// These build stamped binaries and run them, which is the only level at which that wiring is +// observable. A regression here means every existing customer's login moves on upgrade. + +func TestChannelState_StampedBuildsUseSeparateDirectories(t *testing.T) { + tests := []struct { + name string + version string + want string + absent string + }{ + {"GA release keeps the historical path", "9.9.9", ".confluent", ".confluent-dev"}, + {"release candidate is isolated", "9.9.9-rc1", ".confluent-prerelease", ".confluent"}, + {"unstamped build is a local build", "", ".confluent-dev", ".confluent"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + home := t.TempDir() + + runStampedCli(t, buildStampedCli(t, test.version), home) + + require.DirExists(t, filepath.Join(home, test.want)) + require.NoDirExists(t, filepath.Join(home, test.absent)) + }) + } +} + +// The isolation the feature promises, rather than the paths it happens to pick: state written by +// one channel must be invisible to another sharing the same home directory. +func TestChannelState_ReleaseConfigIsInvisibleToLocalBuild(t *testing.T) { + home := t.TempDir() + release := buildStampedCli(t, "9.9.9") + local := buildStampedCli(t, "") + + runStampedCli(t, release, home, "configuration", "update", "disable_update_check", "true") + before := readFile(t, filepath.Join(home, ".confluent", "config.json")) + runStampedCli(t, local, home, "configuration", "update", "disable_update_check", "true") + + require.Equal(t, before, readFile(t, filepath.Join(home, ".confluent", "config.json")), + "a local build must not modify the release build's configuration") + require.FileExists(t, filepath.Join(home, ".confluent-dev", "config.json")) +} + +// buildStampedCli compiles the CLI with the given main.version, or with none when version is empty. +func buildStampedCli(t *testing.T, version string) string { + t.Helper() + + binary := filepath.Join(t.TempDir(), "confluent") + if runtime.GOOS == "windows" { + binary += ".exe" + } + + // isTest keeps the stamped binary off the real update service, which `version` and + // `configuration update` below would otherwise hit; the normal integration build stamps it too. + ldflags := "-X main.isTest=true" + if version != "" { + ldflags += " -X main.version=" + version + } + + args := []string{"build", "-ldflags=" + ldflags, "-o", binary, mainPackagePath()} + + output, err := exec.Command("go", args...).CombinedOutput() + require.NoError(t, err, "go build failed: %s", output) + + return binary +} + +// mainPackagePath resolves cmd/confluent from this file's own location rather than the working +// directory, which a sibling suite (TestCLI) changes to the repo root without restoring. +func mainPackagePath() string { + _, thisFile, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(thisFile), "..", "cmd", "confluent") +} + +func runStampedCli(t *testing.T, binary, home string, args ...string) { + t.Helper() + + if len(args) == 0 { + args = []string{"version"} + } + + cmd := exec.Command(binary, args...) + // USERPROFILE covers Windows, where os.UserHomeDir ignores HOME. + cmd.Env = append(os.Environ(), "HOME="+home, "USERPROFILE="+home) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "%s %v failed: %s", binary, args, output) +} + +func readFile(t *testing.T, path string) []byte { + t.Helper() + + contents, err := os.ReadFile(path) + require.NoError(t, err) + + return contents +} diff --git a/test/fixtures/output/configuration/help-onprem.golden b/test/fixtures/output/configuration/help-onprem.golden index c88df485ee..25ecfb435c 100644 --- a/test/fixtures/output/configuration/help-onprem.golden +++ b/test/fixtures/output/configuration/help-onprem.golden @@ -8,7 +8,7 @@ Aliases: Available Commands: describe Describe a user-configurable field. - list List user-configurable fields in ~/.confluent/config.json. + list List user-configurable fields in the CLI configuration file. update Update a user-configurable field's value. Global Flags: diff --git a/test/fixtures/output/configuration/help.golden b/test/fixtures/output/configuration/help.golden index c88df485ee..25ecfb435c 100644 --- a/test/fixtures/output/configuration/help.golden +++ b/test/fixtures/output/configuration/help.golden @@ -8,7 +8,7 @@ Aliases: Available Commands: describe Describe a user-configurable field. - list List user-configurable fields in ~/.confluent/config.json. + list List user-configurable fields in the CLI configuration file. update Update a user-configurable field's value. Global Flags: diff --git a/test/fixtures/output/configuration/list-help-onprem.golden b/test/fixtures/output/configuration/list-help-onprem.golden index 4b585f9a06..a964811011 100644 --- a/test/fixtures/output/configuration/list-help-onprem.golden +++ b/test/fixtures/output/configuration/list-help-onprem.golden @@ -1,4 +1,4 @@ -List user-configurable fields in ~/.confluent/config.json. +List user-configurable fields in the CLI configuration file. Usage: confluent configuration list [flags] diff --git a/test/fixtures/output/configuration/list-help.golden b/test/fixtures/output/configuration/list-help.golden index 4b585f9a06..a964811011 100644 --- a/test/fixtures/output/configuration/list-help.golden +++ b/test/fixtures/output/configuration/list-help.golden @@ -1,4 +1,4 @@ -List user-configurable fields in ~/.confluent/config.json. +List user-configurable fields in the CLI configuration file. Usage: confluent configuration list [flags] diff --git a/test/live/live_test.go b/test/live/live_test.go index e067c6993e..7125d8d5b6 100644 --- a/test/live/live_test.go +++ b/test/live/live_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + + "github.com/confluentinc/cli/v4/pkg/config" ) var liveBin = "test/live/bin/confluent" @@ -142,12 +144,17 @@ func (s *CLILiveTestSuite) setupTestContext(t *testing.T) *LiveTestState { state.homeDir = homeDir if configDir := os.Getenv("CLI_LIVE_TEST_CONFIG_DIR"); configDir != "" { - // Pre-authenticated mode: copy existing CLI config into isolated HOME + // Pre-authenticated mode: copy an existing CLI config into an isolated HOME. The source is + // usually a stable install's ~/.confluent; the destination must be where the binary under + // test will look. + // + // StateDirName() answers for this process, not that binary. They agree only because neither + // is version-stamped, so stamping build-for-live-test would break this. srcDir := configDir if filepath.Base(srcDir) != ".confluent" { srcDir = filepath.Join(srcDir, ".confluent") } - dstDir := filepath.Join(homeDir, ".confluent") + dstDir := filepath.Join(homeDir, config.StateDirName()) require.NoError(t, copyDir(srcDir, dstDir), "failed to copy config from %s", srcDir) // Validate the copied config is authenticated