diff --git a/buildinfo/buildinfo.go b/buildinfo/buildinfo.go index b23c489095..3a0a9ebb1a 100644 --- a/buildinfo/buildinfo.go +++ b/buildinfo/buildinfo.go @@ -87,6 +87,12 @@ func IsDevVersion(v string) bool { return strings.Contains(v, "-"+develPreRelease) } +// IsRCVersion returns true if the version has a release candidate +// pre-release tag, e.g. "v2.31.0-rc.0". +func IsRCVersion(v string) bool { + return strings.Contains(v, "-rc.") +} + // IsDev returns true if this is a development build. // CI builds are also considered development builds. func IsDev() bool { diff --git a/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go index ac9f5cd4de..a632926930 100644 --- a/buildinfo/buildinfo_test.go +++ b/buildinfo/buildinfo_test.go @@ -102,3 +102,29 @@ func TestBuildInfo(t *testing.T) { } }) } + +func TestIsRCVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + version string + expected bool + }{ + {"RC0", "v2.31.0-rc.0", true}, + {"RC1WithBuild", "v2.31.0-rc.1+abc123", true}, + {"RC10", "v2.31.0-rc.10", true}, + {"RCDevel", "v2.33.0-rc.1-devel+727ec00f7", true}, + {"DevelVersion", "v2.31.0-devel+abc123", false}, + {"StableVersion", "v2.31.0", false}, + {"DevNoVersion", "v0.0.0-devel+abc123", false}, + {"BetaVersion", "v2.31.0-beta.1", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, c.expected, buildinfo.IsRCVersion(c.version)) + }) + } +} diff --git a/cli/root.go b/cli/root.go index 0af41238f3..830f70ba76 100644 --- a/cli/root.go +++ b/cli/root.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "flag" "fmt" "io" "net/http" @@ -711,7 +712,7 @@ func (r *RootCmd) createHTTPClient(ctx context.Context, serverURL *url.URL, inv transport = wrapTransportWithTelemetryHeader(transport, inv) transport = wrapTransportWithUserAgentHeader(transport, inv) if !r.noVersionCheck { - transport = wrapTransportWithVersionMismatchCheck(transport, inv, buildinfo.Version(), func(ctx context.Context) (codersdk.BuildInfoResponse, error) { + transport = wrapTransportWithVersionCheck(transport, inv, buildinfo.Version(), func(ctx context.Context) (codersdk.BuildInfoResponse, error) { // Create a new client without any wrapped transport // otherwise it creates an infinite loop! basicClient := codersdk.New(serverURL) @@ -1435,6 +1436,21 @@ func defaultUpgradeMessage(version string) string { return fmt.Sprintf("download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'", version) } +// serverVersionMessage returns a warning message if the server version +// is a release candidate or development build. Returns empty string +// for stable versions. RC is checked before devel because RC dev +// builds (e.g. v2.33.0-rc.1-devel+hash) contain both tags. +func serverVersionMessage(serverVersion string) string { + switch { + case buildinfo.IsRCVersion(serverVersion): + return fmt.Sprintf("the server is running a release candidate of Coder (%s)", serverVersion) + case buildinfo.IsDevVersion(serverVersion): + return fmt.Sprintf("the server is running a development version of Coder (%s)", serverVersion) + default: + return "" + } +} + // wrapTransportWithEntitlementsCheck adds a middleware to the HTTP transport // that checks for entitlement warnings and prints them to the user. func wrapTransportWithEntitlementsCheck(rt http.RoundTripper, w io.Writer) http.RoundTripper { @@ -1453,10 +1469,10 @@ func wrapTransportWithEntitlementsCheck(rt http.RoundTripper, w io.Writer) http. }) } -// wrapTransportWithVersionMismatchCheck adds a middleware to the HTTP transport -// that checks for version mismatches between the client and server. If a mismatch -// is detected, a warning is printed to the user. -func wrapTransportWithVersionMismatchCheck(rt http.RoundTripper, inv *serpent.Invocation, clientVersion string, getBuildInfo func(ctx context.Context) (codersdk.BuildInfoResponse, error)) http.RoundTripper { +// wrapTransportWithVersionCheck adds a middleware to the HTTP transport +// that checks the server version and warns about development builds, +// release candidates, and client/server version mismatches. +func wrapTransportWithVersionCheck(rt http.RoundTripper, inv *serpent.Invocation, clientVersion string, getBuildInfo func(ctx context.Context) (codersdk.BuildInfoResponse, error)) http.RoundTripper { var once sync.Once return roundTripper(func(req *http.Request) (*http.Response, error) { res, err := rt.RoundTrip(req) @@ -1468,9 +1484,16 @@ func wrapTransportWithVersionMismatchCheck(rt http.RoundTripper, inv *serpent.In if serverVersion == "" { return } + // Warn about non-stable server versions. Skip + // during tests to avoid polluting golden files. + if msg := serverVersionMessage(serverVersion); msg != "" && flag.Lookup("test.v") == nil { + warning := pretty.Sprint(cliui.DefaultStyles.Warn, msg) + _, _ = fmt.Fprintln(inv.Stderr, warning) + } if buildinfo.VersionsMatch(clientVersion, serverVersion) { return } + upgradeMessage := defaultUpgradeMessage(semver.Canonical(serverVersion)) if serverInfo, err := getBuildInfo(inv.Context()); err == nil { switch { diff --git a/cli/root_internal_test.go b/cli/root_internal_test.go index 9eb3fe7609..f5353ed658 100644 --- a/cli/root_internal_test.go +++ b/cli/root_internal_test.go @@ -91,7 +91,7 @@ func Test_formatExamples(t *testing.T) { } } -func Test_wrapTransportWithVersionMismatchCheck(t *testing.T) { +func Test_wrapTransportWithVersionCheck(t *testing.T) { t.Parallel() t.Run("NoOutput", func(t *testing.T) { @@ -102,7 +102,7 @@ func Test_wrapTransportWithVersionMismatchCheck(t *testing.T) { var buf bytes.Buffer inv := cmd.Invoke() inv.Stderr = &buf - rt := wrapTransportWithVersionMismatchCheck(roundTripper(func(req *http.Request) (*http.Response, error) { + rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ @@ -131,7 +131,7 @@ func Test_wrapTransportWithVersionMismatchCheck(t *testing.T) { inv := cmd.Invoke() inv.Stderr = &buf expectedUpgradeMessage := "My custom upgrade message" - rt := wrapTransportWithVersionMismatchCheck(roundTripper(func(req *http.Request) (*http.Response, error) { + rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ @@ -159,6 +159,53 @@ func Test_wrapTransportWithVersionMismatchCheck(t *testing.T) { expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput)) require.Equal(t, expectedOutput, buf.String()) }) + + t.Run("ServerStableVersion", func(t *testing.T) { + t.Parallel() + r := &RootCmd{} + cmd, err := r.Command(nil) + require.NoError(t, err) + var buf bytes.Buffer + inv := cmd.Invoke() + inv.Stderr = &buf + rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + codersdk.BuildVersionHeader: []string{"v2.31.0"}, + }, + Body: io.NopCloser(nil), + }, nil + }), inv, "v2.31.0", nil) + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + res, err := rt.RoundTrip(req) + require.NoError(t, err) + defer res.Body.Close() + require.Empty(t, buf.String()) + }) +} + +func Test_serverVersionMessage(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + version string + expected string + }{ + {"Stable", "v2.31.0", ""}, + {"Dev", "v0.0.0-devel+abc123", "the server is running a development version of Coder (v0.0.0-devel+abc123)"}, + {"RC", "v2.31.0-rc.1", "the server is running a release candidate of Coder (v2.31.0-rc.1)"}, + {"RCDevel", "v2.33.0-rc.1-devel+727ec00f7", "the server is running a release candidate of Coder (v2.33.0-rc.1-devel+727ec00f7)"}, + {"Empty", "", ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, c.expected, serverVersionMessage(c.version)) + }) + } } func Test_wrapTransportWithTelemetryHeader(t *testing.T) {