diff --git a/buildinfo/buildinfo.go b/buildinfo/buildinfo.go index 224a248a8fb5f..65026d4681f77 100644 --- a/buildinfo/buildinfo.go +++ b/buildinfo/buildinfo.go @@ -3,6 +3,7 @@ package buildinfo import ( "fmt" "runtime/debug" + "strings" "sync" "time" @@ -24,6 +25,11 @@ var ( tag string ) +const ( + // develPrefix is prefixed to developer versions of the application. + develPrefix = "v0.0.0-devel" +) + // Version returns the semantic version of the build. // Use golang.org/x/mod/semver to compare versions. func Version() string { @@ -35,7 +41,7 @@ func Version() string { if tag == "" { // This occurs when the tag hasn't been injected, // like when using "go run". - version = "v0.0.0-devel" + revision + version = develPrefix + revision return } version = "v" + tag @@ -48,6 +54,20 @@ func Version() string { return version } +// VersionsMatch compares the two versions. It assumes the versions match if +// the major and the minor versions are equivalent. Patch versions are +// disregarded. If it detects that either version is a developer build it +// returns true. +func VersionsMatch(v1, v2 string) bool { + // Developer versions are disregarded...hopefully they know what they are + // doing. + if strings.HasPrefix(v1, develPrefix) || strings.HasPrefix(v2, develPrefix) { + return true + } + + return semver.MajorMinor(v1) == semver.MajorMinor(v2) +} + // ExternalURL returns a URL referencing the current Coder version. // For production builds, this will link directly to a release. // For development builds, this will link to a commit. diff --git a/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go index 9a92927a5b420..12cc8c99a3ee7 100644 --- a/buildinfo/buildinfo_test.go +++ b/buildinfo/buildinfo_test.go @@ -1,6 +1,7 @@ package buildinfo_test import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -29,4 +30,70 @@ func TestBuildInfo(t *testing.T) { _, valid := buildinfo.Time() require.False(t, valid) }) + + t.Run("VersionsMatch", func(t *testing.T) { + t.Parallel() + + type testcase struct { + name string + v1 string + v2 string + expectMatch bool + } + + cases := []testcase{ + { + name: "OK", + v1: "v1.2.3", + v2: "v1.2.3", + expectMatch: true, + }, + // Test that we return true if a developer version is detected. + // Developers do not need to be warned of mismatched versions. + { + name: "DevelIgnored", + v1: "v0.0.0-devel+123abac", + v2: "v1.2.3", + expectMatch: true, + }, + // Our CI instance uses a "-devel" prerelease + // flag. This is not the same as a developer WIP build. + { + name: "DevelPreleaseNotIgnored", + v1: "v1.1.1-devel+123abac", + v2: "v1.2.3", + expectMatch: false, + }, + { + name: "MajorMismatch", + v1: "v1.2.3", + v2: "v0.1.2", + expectMatch: false, + }, + { + name: "MinorMismatch", + v1: "v1.2.3", + v2: "v1.3.2", + expectMatch: false, + }, + // Different patches are ok, breaking changes are not allowed + // in patches. + { + name: "PatchMismatch", + v1: "v1.2.3+hash.whocares", + v2: "v1.2.4+somestuff.hm.ok", + expectMatch: true, + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, c.expectMatch, buildinfo.VersionsMatch(c.v1, c.v2), + fmt.Sprintf("expected match=%v for version %s and %s", c.expectMatch, c.v1, c.v2), + ) + }) + } + }) } diff --git a/cli/login.go b/cli/login.go index 9ae7f8130cd50..6ff76992596cb 100644 --- a/cli/login.go +++ b/cli/login.go @@ -67,6 +67,15 @@ func login() *cobra.Command { } client := codersdk.New(serverURL) + + // Try to check the version of the server prior to logging in. + // It may be useful to warn the user if they are trying to login + // on a very old client. + err = checkVersions(cmd, client) + if err != nil { + return xerrors.Errorf("check versions: %w", err) + } + hasInitialUser, err := client.HasFirstUser(cmd.Context()) if err != nil { return xerrors.Errorf("has initial user: %w", err) diff --git a/cli/root.go b/cli/root.go index aed8f93204861..9dc8baf8914ba 100644 --- a/cli/root.go +++ b/cli/root.go @@ -4,11 +4,13 @@ import ( "fmt" "net/url" "os" + "strconv" "strings" "time" "golang.org/x/xerrors" + "github.com/charmbracelet/lipgloss" "github.com/kirsle/configdir" "github.com/mattn/go-isatty" "github.com/spf13/cobra" @@ -40,7 +42,13 @@ const ( varForceTty = "force-tty" notLoggedInMessage = "You are not logged in. Try logging in using 'coder login '." - envSessionToken = "CODER_SESSION_TOKEN" + noVersionCheckFlag = "no-version-warning" + envNoVersionCheck = "CODER_NO_VERSION_WARNING" +) + +var ( + errUnauthenticated = xerrors.New(notLoggedInMessage) + envSessionToken = "CODER_SESSION_TOKEN" ) func init() { @@ -53,12 +61,37 @@ func init() { } func Root() *cobra.Command { + var varSuppressVersion bool + cmd := &cobra.Command{ Use: "coder", SilenceErrors: true, SilenceUsage: true, Long: `Coder — A tool for provisioning self-hosted development environments. `, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if varSuppressVersion { + return nil + } + + // Login handles checking the versions itself since it + // has a handle to an unauthenticated client. + if cmd.Name() == "login" { + return nil + } + + client, err := createClient(cmd) + // If the client is unauthenticated we can ignore the check. + // The child commands should handle an unauthenticated client. + if xerrors.Is(err, errUnauthenticated) { + return nil + } + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + return checkVersions(cmd, client) + }, + Example: ` Start a Coder server. ` + cliui.Styles.Code.Render("$ coder server") + ` @@ -97,6 +130,7 @@ func Root() *cobra.Command { cmd.SetUsageTemplate(usageTemplate()) cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.") + cliflag.BoolVarP(cmd.PersistentFlags(), &varSuppressVersion, noVersionCheckFlag, "", envNoVersionCheck, false, "Suppress warning when client and server versions do not match.") cliflag.String(cmd.PersistentFlags(), varToken, "", envSessionToken, "", fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken)) cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.") _ = cmd.PersistentFlags().MarkHidden(varAgentToken) @@ -142,7 +176,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) { if err != nil { // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { - return nil, xerrors.New(notLoggedInMessage) + return nil, errUnauthenticated } return nil, err } @@ -157,7 +191,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) { if err != nil { // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { - return nil, xerrors.New(notLoggedInMessage) + return nil, errUnauthenticated } return nil, err } @@ -331,3 +365,30 @@ func FormatCobraError(err error, cmd *cobra.Command) string { helpErrMsg := fmt.Sprintf("Run '%s --help' for usage.", cmd.CommandPath()) return cliui.Styles.Error.Render(err.Error() + "\n" + helpErrMsg) } + +func checkVersions(cmd *cobra.Command, client *codersdk.Client) error { + flag := cmd.Flag("no-version-warning") + if suppress, _ := strconv.ParseBool(flag.Value.String()); suppress { + return nil + } + + clientVersion := buildinfo.Version() + + info, err := client.BuildInfo(cmd.Context()) + if err != nil { + return xerrors.Errorf("build info: %w", err) + } + + fmtWarningText := `version mismatch: client %s, server %s +download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s' +` + + if !buildinfo.VersionsMatch(clientVersion, info.Version) { + warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left) + // Trim the leading 'v', our install.sh script does not handle this case well. + _, _ = fmt.Fprintf(cmd.OutOrStdout(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + } + + return nil +} diff --git a/codersdk/buildinfo.go b/codersdk/buildinfo.go index 0233047caf98c..a8ba01e3716cb 100644 --- a/codersdk/buildinfo.go +++ b/codersdk/buildinfo.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "net/http" + "strings" + + "golang.org/x/mod/semver" ) // BuildInfoResponse contains build information for this instance of Coder. @@ -16,6 +19,15 @@ type BuildInfoResponse struct { Version string `json:"version"` } +// CanonicalVersion trims build information from the version. +// E.g. 'v0.7.4-devel+11573034' -> 'v0.7.4'. +func (b BuildInfoResponse) CanonicalVersion() string { + // We do a little hack here to massage the string into a form + // that works well with semver. + trimmed := strings.ReplaceAll(b.Version, "-devel+", "+devel-") + return semver.Canonical(trimmed) +} + // BuildInfo returns build information for this instance of Coder. func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 96afeb66799f7..57f7cdba81800 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -36,7 +36,7 @@ export interface AzureInstanceIdentityToken { readonly encoding: string } -// From codersdk/buildinfo.go:10:6 +// From codersdk/buildinfo.go:13:6 export interface BuildInfoResponse { readonly external_url: string readonly version: string