Thanks to visit codestin.com
Credit goes to github.com

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
22 changes: 21 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,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.
Expand Down
67 changes: 67 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,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),
)
})
}
})
}
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
67 changes: 64 additions & 3 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -40,7 +42,13 @@ const (
varForceTty = "force-tty"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."

envSessionToken = "CODER_SESSION_TOKEN"
noVersionCheckFlag = "no-version-warning"
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
)

var (
errUnauthenticated = xerrors.New(notLoggedInMessage)
envSessionToken = "CODER_SESSION_TOKEN"
)

func init() {
Expand All @@ -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") + `

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
12 changes: 12 additions & 0 deletions codersdk/buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
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 @@ -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
Expand Down