Skip to content

feat: add customizable upgrade message on client/server version mismatch #11587

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 19 commits into from
Jan 30, 2024
3 changes: 2 additions & 1 deletion cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/coder/pretty"

"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/userpassword"
Expand Down Expand Up @@ -175,7 +176,7 @@ func (r *RootCmd) login() *clibase.Cmd {
// 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 = r.checkVersions(inv, client)
err = r.checkVersions(inv, client, buildinfo.Version())
if err != nil {
// Checking versions isn't a fatal error so we print a warning
// and proceed.
Expand Down
45 changes: 28 additions & 17 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ func (r *RootCmd) PrintWarnings(client *codersdk.Client) clibase.MiddlewareFunc
warningErr = make(chan error)
)
go func() {
versionErr <- r.checkVersions(inv, client)
versionErr <- r.checkVersions(inv, client, buildinfo.Version())
close(versionErr)
}()

Expand Down Expand Up @@ -812,38 +812,39 @@ func formatExamples(examples ...example) string {
return sb.String()
}

func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client) error {
// checkVersions checks to see if there's a version mismatch between the client
// and server and prints a message nudging the user to upgrade if a mismatch
// is detected. forceCheck is a test flag and should always be false in production.
//
//nolint:revive
func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client, clientVersion string) error {
if r.noVersionCheck {
return nil
}

ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second)
defer cancel()

clientVersion := buildinfo.Version()
info, err := client.BuildInfo(ctx)
serverInfo, err := client.BuildInfo(ctx)
// Avoid printing errors that are connection-related.
if isConnectionError(err) {
return nil
}

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

fmtWarningText := `version mismatch: client %s, server %s
`
// Our installation script doesn't work on Windows, so instead we direct the user
// to the GitHub release page to download the latest installer.
if runtime.GOOS == "windows" {
fmtWarningText += `download the server version from: https://github.com/coder/coder/releases/v%s`
} else {
fmtWarningText += `download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'`
}
if !buildinfo.VersionsMatch(clientVersion, serverInfo.Version) {
upgradeMessage := defaultUpgradeMessage(serverInfo.CanonicalVersion())
if serverInfo.UpgradeMessage != "" {
upgradeMessage = serverInfo.UpgradeMessage
}

fmtWarningText := "version mismatch: client %s, server %s\n%s"
fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText)
warning := fmt.Sprintf(fmtWarn, clientVersion, serverInfo.Version, upgradeMessage)

if !buildinfo.VersionsMatch(clientVersion, info.Version) {
warn := cliui.DefaultStyles.Warn
_, _ = fmt.Fprintf(i.Stderr, pretty.Sprint(warn, fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))
_, _ = fmt.Fprint(i.Stderr, warning)
_, _ = fmt.Fprintln(i.Stderr)
}

Expand Down Expand Up @@ -1216,3 +1217,13 @@ func SlimUnsupported(w io.Writer, cmd string) {
//nolint:revive
os.Exit(1)
}

func defaultUpgradeMessage(version string) string {
// Our installation script doesn't work on Windows, so instead we direct the user
// to the GitHub release page to download the latest installer.
version = strings.TrimPrefix(version, "v")
if runtime.GOOS == "windows" {
return fmt.Sprintf("download the server version from: https://github.com/coder/coder/releases/v%s", version)
}
return fmt.Sprintf("download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'", version)
}
94 changes: 94 additions & 0 deletions cli/root_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
package cli

import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"runtime"
"testing"

"github.com/stretchr/testify/require"
"go.uber.org/goleak"

"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
)

func Test_formatExamples(t *testing.T) {
Expand Down Expand Up @@ -84,3 +96,85 @@ func TestMain(m *testing.M) {
goleak.IgnoreTopFunction("github.com/lib/pq.NewDialListener"),
)
}

func Test_checkVersions(t *testing.T) {
t.Parallel()

t.Run("CustomUpgradeMessage", func(t *testing.T) {
t.Parallel()

expectedUpgradeMessage := "My custom upgrade message"

srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
// Provide a version that will not match
Version: "v1.0.0",
AgentAPIVersion: coderd.AgentAPIVersionREST,
// does not matter what the url is
DashboardURL: "https://example.com",
WorkspaceProxy: false,
UpgradeMessage: expectedUpgradeMessage,
})
}))
defer srv.Close()
surl, err := url.Parse(srv.URL)
require.NoError(t, err)

client := codersdk.New(surl)

r := &RootCmd{}

cmd, err := r.Command(nil)
require.NoError(t, err)

var buf bytes.Buffer
inv := cmd.Invoke()
inv.Stderr = &buf

err = r.checkVersions(inv, client, "v2.0.0")
require.NoError(t, err)

fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", expectedUpgradeMessage)
expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput))
require.Equal(t, expectedOutput, buf.String())
})

t.Run("DefaultUpgradeMessage", func(t *testing.T) {
t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
// Provide a version that will not match
Version: "v1.0.0",
AgentAPIVersion: coderd.AgentAPIVersionREST,
// does not matter what the url is
DashboardURL: "https://example.com",
WorkspaceProxy: false,
UpgradeMessage: "",
})
}))
defer srv.Close()
surl, err := url.Parse(srv.URL)
require.NoError(t, err)

client := codersdk.New(surl)

r := &RootCmd{}

cmd, err := r.Command(nil)
require.NoError(t, err)

var buf bytes.Buffer
inv := cmd.Invoke()
inv.Stderr = &buf

err = r.checkVersions(inv, client, "v2.0.0")
require.NoError(t, err)

fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", defaultUpgradeMessage("v1.0.0"))
expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput))
require.Equal(t, expectedOutput, buf.String())
})
}
5 changes: 5 additions & 0 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ CLIENT OPTIONS:
These options change the behavior of how clients interact with the Coder.
Clients include the coder cli, vs code extension, and the web UI.

--cli-upgrade-message string, $CODER_CLI_UPGRADE_MESSAGE
The upgrade message to display to users when a client/server mismatch
is detected. By default it instructs users to update using 'curl -L
https://coder.com/install.sh | sh'.

--ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
These SSH config options will override the default SSH config options.
Provide options in "key=value" or "key value" format separated by
Expand Down
5 changes: 5 additions & 0 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,11 @@ client:
# incorrectly can break SSH to your deployment, use cautiously.
# (default: <unset>, type: string-array)
sshConfigOptions: []
# The upgrade message to display to users when a client/server mismatch is
# detected. By default it instructs users to update using 'curl -L
# https://coder.com/install.sh | sh'.
# (default: <unset>, type: string)
cliUpgradeMessage: ""
# The renderer to use when opening a web terminal. Valid values are 'canvas',
# 'webgl', or 'dom'.
# (default: canvas, type: string)
Expand Down
7 changes: 7 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ func New(options *Options) *API {
// All CSP errors will be logged
r.Post("/csp/reports", api.logReportCSPViolations)

r.Get("/buildinfo", buildInfo(api.AccessURL))
r.Get("/buildinfo", buildInfo(api.AccessURL, api.DeploymentValues.CLIUpgradeMessage.String()))
// /regions is overridden in the enterprise version
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
Expand Down
3 changes: 2 additions & 1 deletion coderd/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) {
// @Tags General
// @Success 200 {object} codersdk.BuildInfoResponse
// @Router /buildinfo [get]
func buildInfo(accessURL *url.URL) http.HandlerFunc {
func buildInfo(accessURL *url.URL, upgradeMessage string) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
AgentAPIVersion: AgentAPIVersionREST,
DashboardURL: accessURL.String(),
WorkspaceProxy: false,
UpgradeMessage: upgradeMessage,
})
}
}
Expand Down
15 changes: 15 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ type DeploymentValues struct {
WebTerminalRenderer clibase.String `json:"web_terminal_renderer,omitempty" typescript:",notnull"`
AllowWorkspaceRenames clibase.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"`
Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"`
CLIUpgradeMessage clibase.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"`

Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"`
Expand Down Expand Up @@ -1780,6 +1781,16 @@ when required by your organization's security policy.`,
Value: &c.SSHConfig.SSHConfigOptions,
Hidden: false,
},
{
Name: "CLI Upgrade Message",
Description: "The upgrade message to display to users when a client/server mismatch is detected. By default it instructs users to update using 'curl -L https://coder.com/install.sh | sh'.",
Flag: "cli-upgrade-message",
Env: "CODER_CLI_UPGRADE_MESSAGE",
YAML: "cliUpgradeMessage",
Group: &deploymentGroupClient,
Value: &c.CLIUpgradeMessage,
Hidden: false,
},
{
Name: "Write Config",
Description: `
Expand Down Expand Up @@ -2052,6 +2063,10 @@ type BuildInfoResponse struct {
// AgentAPIVersion is the current version of the Agent API (back versions
// MAY still be supported).
AgentAPIVersion string `json:"agent_api_version"`

// UpgradeMessage is the message displayed to users when an outdated client
// is detected.
UpgradeMessage string `json:"upgrade_message"`
}

type WorkspaceProxyBuildInfo struct {
Expand Down
2 changes: 2 additions & 0 deletions docs/api/general.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/api/schemas.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading