Skip to content

Commit 7df5827

Browse files
authored
feat: add version checking to CLI (#2725)
1 parent 45328ec commit 7df5827

File tree

6 files changed

+184
-5
lines changed

6 files changed

+184
-5
lines changed

buildinfo/buildinfo.go

+21-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package buildinfo
33
import (
44
"fmt"
55
"runtime/debug"
6+
"strings"
67
"sync"
78
"time"
89

@@ -24,6 +25,11 @@ var (
2425
tag string
2526
)
2627

28+
const (
29+
// develPrefix is prefixed to developer versions of the application.
30+
develPrefix = "v0.0.0-devel"
31+
)
32+
2733
// Version returns the semantic version of the build.
2834
// Use golang.org/x/mod/semver to compare versions.
2935
func Version() string {
@@ -35,7 +41,7 @@ func Version() string {
3541
if tag == "" {
3642
// This occurs when the tag hasn't been injected,
3743
// like when using "go run".
38-
version = "v0.0.0-devel" + revision
44+
version = develPrefix + revision
3945
return
4046
}
4147
version = "v" + tag
@@ -48,6 +54,20 @@ func Version() string {
4854
return version
4955
}
5056

57+
// VersionsMatch compares the two versions. It assumes the versions match if
58+
// the major and the minor versions are equivalent. Patch versions are
59+
// disregarded. If it detects that either version is a developer build it
60+
// returns true.
61+
func VersionsMatch(v1, v2 string) bool {
62+
// Developer versions are disregarded...hopefully they know what they are
63+
// doing.
64+
if strings.HasPrefix(v1, develPrefix) || strings.HasPrefix(v2, develPrefix) {
65+
return true
66+
}
67+
68+
return semver.MajorMinor(v1) == semver.MajorMinor(v2)
69+
}
70+
5171
// ExternalURL returns a URL referencing the current Coder version.
5272
// For production builds, this will link directly to a release.
5373
// For development builds, this will link to a commit.

buildinfo/buildinfo_test.go

+67
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package buildinfo_test
22

33
import (
4+
"fmt"
45
"testing"
56

67
"github.com/stretchr/testify/require"
@@ -29,4 +30,70 @@ func TestBuildInfo(t *testing.T) {
2930
_, valid := buildinfo.Time()
3031
require.False(t, valid)
3132
})
33+
34+
t.Run("VersionsMatch", func(t *testing.T) {
35+
t.Parallel()
36+
37+
type testcase struct {
38+
name string
39+
v1 string
40+
v2 string
41+
expectMatch bool
42+
}
43+
44+
cases := []testcase{
45+
{
46+
name: "OK",
47+
v1: "v1.2.3",
48+
v2: "v1.2.3",
49+
expectMatch: true,
50+
},
51+
// Test that we return true if a developer version is detected.
52+
// Developers do not need to be warned of mismatched versions.
53+
{
54+
name: "DevelIgnored",
55+
v1: "v0.0.0-devel+123abac",
56+
v2: "v1.2.3",
57+
expectMatch: true,
58+
},
59+
// Our CI instance uses a "-devel" prerelease
60+
// flag. This is not the same as a developer WIP build.
61+
{
62+
name: "DevelPreleaseNotIgnored",
63+
v1: "v1.1.1-devel+123abac",
64+
v2: "v1.2.3",
65+
expectMatch: false,
66+
},
67+
{
68+
name: "MajorMismatch",
69+
v1: "v1.2.3",
70+
v2: "v0.1.2",
71+
expectMatch: false,
72+
},
73+
{
74+
name: "MinorMismatch",
75+
v1: "v1.2.3",
76+
v2: "v1.3.2",
77+
expectMatch: false,
78+
},
79+
// Different patches are ok, breaking changes are not allowed
80+
// in patches.
81+
{
82+
name: "PatchMismatch",
83+
v1: "v1.2.3+hash.whocares",
84+
v2: "v1.2.4+somestuff.hm.ok",
85+
expectMatch: true,
86+
},
87+
}
88+
89+
for _, c := range cases {
90+
c := c
91+
t.Run(c.name, func(t *testing.T) {
92+
t.Parallel()
93+
require.Equal(t, c.expectMatch, buildinfo.VersionsMatch(c.v1, c.v2),
94+
fmt.Sprintf("expected match=%v for version %s and %s", c.expectMatch, c.v1, c.v2),
95+
)
96+
})
97+
}
98+
})
3299
}

cli/login.go

+9
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ func login() *cobra.Command {
6767
}
6868

6969
client := codersdk.New(serverURL)
70+
71+
// Try to check the version of the server prior to logging in.
72+
// It may be useful to warn the user if they are trying to login
73+
// on a very old client.
74+
err = checkVersions(cmd, client)
75+
if err != nil {
76+
return xerrors.Errorf("check versions: %w", err)
77+
}
78+
7079
hasInitialUser, err := client.HasFirstUser(cmd.Context())
7180
if err != nil {
7281
return xerrors.Errorf("has initial user: %w", err)

cli/root.go

+74-3
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"fmt"
55
"net/url"
66
"os"
7+
"strconv"
78
"strings"
89
"time"
910

1011
"golang.org/x/xerrors"
1112

13+
"github.com/charmbracelet/lipgloss"
1214
"github.com/kirsle/configdir"
1315
"github.com/mattn/go-isatty"
1416
"github.com/spf13/cobra"
@@ -40,7 +42,13 @@ const (
4042
varForceTty = "force-tty"
4143
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
4244

43-
envSessionToken = "CODER_SESSION_TOKEN"
45+
noVersionCheckFlag = "no-version-warning"
46+
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
47+
)
48+
49+
var (
50+
errUnauthenticated = xerrors.New(notLoggedInMessage)
51+
envSessionToken = "CODER_SESSION_TOKEN"
4452
)
4553

4654
func init() {
@@ -53,12 +61,47 @@ func init() {
5361
}
5462

5563
func Root() *cobra.Command {
64+
var varSuppressVersion bool
65+
5666
cmd := &cobra.Command{
5767
Use: "coder",
5868
SilenceErrors: true,
5969
SilenceUsage: true,
6070
Long: `Coder — A tool for provisioning self-hosted development environments.
6171
`,
72+
PersistentPreRun: func(cmd *cobra.Command, args []string) {
73+
err := func() error {
74+
if varSuppressVersion {
75+
return nil
76+
}
77+
78+
// Login handles checking the versions itself since it
79+
// has a handle to an unauthenticated client.
80+
// Server is skipped for obvious reasons.
81+
if cmd.Name() == "login" || cmd.Name() == "server" {
82+
return nil
83+
}
84+
85+
client, err := createClient(cmd)
86+
// If the client is unauthenticated we can ignore the check.
87+
// The child commands should handle an unauthenticated client.
88+
if xerrors.Is(err, errUnauthenticated) {
89+
return nil
90+
}
91+
if err != nil {
92+
return xerrors.Errorf("create client: %w", err)
93+
}
94+
return checkVersions(cmd, client)
95+
}()
96+
if err != nil {
97+
// Just log the error here. We never want to fail a command
98+
// due to a pre-run.
99+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
100+
cliui.Styles.Warn.Render("check versions error: %s"), err)
101+
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
102+
}
103+
},
104+
62105
Example: ` Start a Coder server.
63106
` + cliui.Styles.Code.Render("$ coder server") + `
64107
@@ -97,6 +140,7 @@ func Root() *cobra.Command {
97140
cmd.SetUsageTemplate(usageTemplate())
98141

99142
cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.")
143+
cliflag.BoolVarP(cmd.PersistentFlags(), &varSuppressVersion, noVersionCheckFlag, "", envNoVersionCheck, false, "Suppress warning when client and server versions do not match.")
100144
cliflag.String(cmd.PersistentFlags(), varToken, "", envSessionToken, "", fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken))
101145
cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.")
102146
_ = cmd.PersistentFlags().MarkHidden(varAgentToken)
@@ -142,7 +186,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
142186
if err != nil {
143187
// If the configuration files are absent, the user is logged out
144188
if os.IsNotExist(err) {
145-
return nil, xerrors.New(notLoggedInMessage)
189+
return nil, errUnauthenticated
146190
}
147191
return nil, err
148192
}
@@ -157,7 +201,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
157201
if err != nil {
158202
// If the configuration files are absent, the user is logged out
159203
if os.IsNotExist(err) {
160-
return nil, xerrors.New(notLoggedInMessage)
204+
return nil, errUnauthenticated
161205
}
162206
return nil, err
163207
}
@@ -331,3 +375,30 @@ func FormatCobraError(err error, cmd *cobra.Command) string {
331375
helpErrMsg := fmt.Sprintf("Run '%s --help' for usage.", cmd.CommandPath())
332376
return cliui.Styles.Error.Render(err.Error() + "\n" + helpErrMsg)
333377
}
378+
379+
func checkVersions(cmd *cobra.Command, client *codersdk.Client) error {
380+
flag := cmd.Flag("no-version-warning")
381+
if suppress, _ := strconv.ParseBool(flag.Value.String()); suppress {
382+
return nil
383+
}
384+
385+
clientVersion := buildinfo.Version()
386+
387+
info, err := client.BuildInfo(cmd.Context())
388+
if err != nil {
389+
return xerrors.Errorf("build info: %w", err)
390+
}
391+
392+
fmtWarningText := `version mismatch: client %s, server %s
393+
download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'
394+
`
395+
396+
if !buildinfo.VersionsMatch(clientVersion, info.Version) {
397+
warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left)
398+
// Trim the leading 'v', our install.sh script does not handle this case well.
399+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))
400+
_, _ = fmt.Fprintln(cmd.OutOrStdout())
401+
}
402+
403+
return nil
404+
}

codersdk/buildinfo.go

+12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"context"
55
"encoding/json"
66
"net/http"
7+
"strings"
8+
9+
"golang.org/x/mod/semver"
710
)
811

912
// BuildInfoResponse contains build information for this instance of Coder.
@@ -16,6 +19,15 @@ type BuildInfoResponse struct {
1619
Version string `json:"version"`
1720
}
1821

22+
// CanonicalVersion trims build information from the version.
23+
// E.g. 'v0.7.4-devel+11573034' -> 'v0.7.4'.
24+
func (b BuildInfoResponse) CanonicalVersion() string {
25+
// We do a little hack here to massage the string into a form
26+
// that works well with semver.
27+
trimmed := strings.ReplaceAll(b.Version, "-devel+", "+devel-")
28+
return semver.Canonical(trimmed)
29+
}
30+
1931
// BuildInfo returns build information for this instance of Coder.
2032
func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
2133
res, err := c.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)

site/src/api/typesGenerated.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface AzureInstanceIdentityToken {
3636
readonly encoding: string
3737
}
3838

39-
// From codersdk/buildinfo.go:10:6
39+
// From codersdk/buildinfo.go:13:6
4040
export interface BuildInfoResponse {
4141
readonly external_url: string
4242
readonly version: string

0 commit comments

Comments
 (0)