8000 feat: add version checking to CLI by sreya · Pull Request #2643 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat: add version checking to CLI #2643

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 29, 2022
36 changes: 35 additions & 1 deletion buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package buildinfo
import (
"fmt"
"runtime/debug"
"strings"
"sync"
"time"

Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -48,6 +54,34 @@ 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
}

v1Toks := strings.Split(v1, ".")
v2Toks := strings.Split(v2, ".")

// Versions should be formatted as "<major>.<minor>.<patch>".
// We assume malformed versions are evidence of a bug and return false.
if len(v1Toks) < 3 || len(v2Toks) < 3 {
return false
}

// Slice off the patch suffix. Patch versions should be non-breaking
// changes.
v1MajorMinor := strings.Join(v1Toks[:2], ".")
v2MajorMinor := strings.Join(v2Toks[:2], ".")

return v1MajorMinor == v2MajorMinor
}

// 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.
Expand Down
70 changes: 70 additions & 0 deletions buildinfo/buildinfo_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package buildinfo_test

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -29,4 +30,73 @@ 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 false if a version is malformed.
{
name: "MalformedIgnored",
v1: "v1.2.3",
v2: "v1.2",
expectMatch: false,
},
// 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,
},
{
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 {
// It's very important to do this since we're running the tests
// in parallel. Otherwise you will likely get the last element
// in the list since the goroutines will likely start executing
// after the for loop has completed.
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),
)
})
}
})
}
9 changes: 9 additions & 0 deletions cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 61 additions & 2 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"golang.org/x/xerrors"

"github.com/charmbracelet/lipgloss"
"github.com/kirsle/configdir"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -39,6 +40,13 @@ const (
varNoOpen = "no-open"
varForceTty = "force-tty"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."

envNoVersionCheck = "CODER_NO_VERSION_WARNING"
)

var (
errUnauthenticated = xerrors.New(notLoggedInMessage)
varSuppressVersion = false
)

func init() {
Expand All @@ -57,6 +65,29 @@ func Root() *cobra.Command {
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 F438 a Coder server.
` + cliui.Styles.Code.Render("$ coder server") + `

Expand Down Expand Up @@ -96,6 +127,7 @@ func Root() *cobra.Command {

cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.")
cmd.PersistentFlags().String(varToken, "", "Specify an authentication token.")
cliflag.BoolVarP(cmd.PersistentFlags(), &varSuppressVersion, "no-version-warning", "", envNoVersionCheck, false, "Suppress warning when client and server versions do not match.")
cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.")
_ = cmd.PersistentFlags().MarkHidden(varAgentToken)
cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "Specify the URL for an agent to access your deployment.")
Expand Down Expand Up @@ -140,7 +172,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
}
Expand All @@ -155,7 +187,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
}
Expand Down Expand Up @@ -329,3 +361,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 {
if varSuppressVersion {
return nil
}

clientVersion := buildinfo.Version()

info, err := client.BuildInfo(cmd.Context())
if err != nil {
return xerrors.Errorf("build info: %w", err)
}

fmtWarningText := `client/server versions do not match
client version: %s
server version: %s
download the appropriate version from https://github.com/coder/coder/releases/tag/%s
`

if !buildinfo.VersionsMatch(clientVersion, info.Version) {
warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), warn.Render(fmtWarningText), clientVersion, info.Version, info.TrimmedVersion())
_, _ = fmt.Fprintln(cmd.OutOrStdout())
}

return nil
}
13 changes: 13 additions & 0 deletions codersdk/buildinfo.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package codersdk

import (
"bytes"
"context"
"encoding/json"
"net/http"
Expand All @@ -16,6 +17,18 @@ type BuildInfoResponse struct {
Version string `json:"version"`
}

// TrimmedVersion trims build information from the version.
// E.g. 'v0.7.4-devel+11573034' -> 'v0.7.4'.
func (b BuildInfoResponse) TrimmedVersion() string {
// Linter doesn't like strings.Index...
idx := bytes.Index([]byte(b.Version), []byte("-devel"))
if idx < 0 {
return b.Version
}

return b.Version[:idx]
}

// 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)
Expand Down
2 changes: 1 addition & 1 deletion site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface AzureInstanceIdentityToken {
readonly encoding: string
}

// From codersdk/buildinfo.go:10:6
// From codersdk/buildinfo.go:11:6
export interface BuildInfoResponse {
readonly external_url: string
readonly version: string
Expand Down
0