mirror of https://github.com/coder/coder.git
feat: add version checking to CLI (#2725)
This commit is contained in:
parent
45328ec0f1
commit
7df5827767
|
@ -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.
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
77
cli/root.go
77
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 <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() {
|
||||
|
@ -53,12 +61,47 @@ 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.
|
||||
`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
err := func() error {
|
||||
if varSuppressVersion {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login handles checking the versions itself since it
|
||||
// has a handle to an unauthenticated client.
|
||||
// Server is skipped for obvious reasons.
|
||||
if cmd.Name() == "login" || cmd.Name() == "server" {
|
||||
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)
|
||||
}()
|
||||
if err != nil {
|
||||
// Just log the error here. We never want to fail a command
|
||||
// due to a pre-run.
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
|
||||
cliui.Styles.Warn.Render("check versions error: %s"), err)
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
}
|
||||
},
|
||||
|
||||
Example: ` Start a Coder server.
|
||||
` + cliui.Styles.Code.Render("$ coder server") + `
|
||||
|
||||
|
@ -97,6 +140,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 +186,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 +201,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 +375,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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue