Skip to content

Commit 0c30dde

Browse files
authored
feat: add customizable upgrade message on client/server version mismatch (#11587)
1 parent adbb025 commit 0c30dde

16 files changed

+191
-20
lines changed

cli/login.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"github.com/coder/pretty"
2020

21+
"github.com/coder/coder/v2/buildinfo"
2122
"github.com/coder/coder/v2/cli/clibase"
2223
"github.com/coder/coder/v2/cli/cliui"
2324
"github.com/coder/coder/v2/coderd/userpassword"
@@ -175,7 +176,7 @@ func (r *RootCmd) login() *clibase.Cmd {
175176
// Try to check the version of the server prior to logging in.
176177
// It may be useful to warn the user if they are trying to login
177178
// on a very old client.
178-
err = r.checkVersions(inv, client)
179+
err = r.checkVersions(inv, client, buildinfo.Version())
179180
if err != nil {
180181
// Checking versions isn't a fatal error so we print a warning
181182
// and proceed.

cli/root.go

+28-17
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ func (r *RootCmd) PrintWarnings(client *codersdk.Client) clibase.MiddlewareFunc
602602
warningErr = make(chan error)
603603
)
604604
go func() {
605-
versionErr <- r.checkVersions(inv, client)
605+
versionErr <- r.checkVersions(inv, client, buildinfo.Version())
606606
close(versionErr)
607607
}()
608608

@@ -812,38 +812,39 @@ func formatExamples(examples ...example) string {
812812
return sb.String()
813813
}
814814

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

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

823-
clientVersion := buildinfo.Version()
824-
info, err := client.BuildInfo(ctx)
828+
serverInfo, err := client.BuildInfo(ctx)
825829
// Avoid printing errors that are connection-related.
826830
if isConnectionError(err) {
827831
return nil
828832
}
829-
830833
if err != nil {
831834
return xerrors.Errorf("build info: %w", err)
832835
}
833836

834-
fmtWarningText := `version mismatch: client %s, server %s
835-
`
836-
// Our installation script doesn't work on Windows, so instead we direct the user
837-
// to the GitHub release page to download the latest installer.
838-
if runtime.GOOS == "windows" {
839-
fmtWarningText += `download the server version from: https://github.com/coder/coder/releases/v%s`
840-
} else {
841-
fmtWarningText += `download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'`
842-
}
837+
if !buildinfo.VersionsMatch(clientVersion, serverInfo.Version) {
838+
upgradeMessage := defaultUpgradeMessage(serverInfo.CanonicalVersion())
839+
if serverInfo.UpgradeMessage != "" {
840+
upgradeMessage = serverInfo.UpgradeMessage
841+
}
842+
843+
fmtWarningText := "version mismatch: client %s, server %s\n%s"
844+
fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText)
845+
warning := fmt.Sprintf(fmtWarn, clientVersion, serverInfo.Version, upgradeMessage)
843846

844-
if !buildinfo.VersionsMatch(clientVersion, info.Version) {
845-
warn := cliui.DefaultStyles.Warn
846-
_, _ = fmt.Fprintf(i.Stderr, pretty.Sprint(warn, fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))
847+
_, _ = fmt.Fprint(i.Stderr, warning)
847848
_, _ = fmt.Fprintln(i.Stderr)
848849
}
849850

@@ -1216,3 +1217,13 @@ func SlimUnsupported(w io.Writer, cmd string) {
12161217
//nolint:revive
12171218
os.Exit(1)
12181219
}
1220+
1221+
func defaultUpgradeMessage(version string) string {
1222+
// Our installation script doesn't work on Windows, so instead we direct the user
1223+
// to the GitHub release page to download the latest installer.
1224+
version = strings.TrimPrefix(version, "v")
1225+
if runtime.GOOS == "windows" {
1226+
return fmt.Sprintf("download the server version from: https://github.com/coder/coder/releases/v%s", version)
1227+
}
1228+
return fmt.Sprintf("download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'", version)
1229+
}

cli/root_internal_test.go

+94
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
package cli
22

33
import (
4+
"bytes"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
49
"os"
510
"runtime"
611
"testing"
712

813
"github.com/stretchr/testify/require"
914
"go.uber.org/goleak"
15+
16+
"github.com/coder/coder/v2/buildinfo"
17+
"github.com/coder/coder/v2/cli/cliui"
18+
"github.com/coder/coder/v2/coderd"
19+
"github.com/coder/coder/v2/coderd/httpapi"
20+
"github.com/coder/coder/v2/codersdk"
21+
"github.com/coder/pretty"
1022
)
1123

1224
func Test_formatExamples(t *testing.T) {
@@ -84,3 +96,85 @@ func TestMain(m *testing.M) {
8496
goleak.IgnoreTopFunction("github.com/lib/pq.NewDialListener"),
8597
)
8698
}
99+
100+
func Test_checkVersions(t *testing.T) {
101+
t.Parallel()
102+
103+
t.Run("CustomUpgradeMessage", func(t *testing.T) {
104+
t.Parallel()
105+
106+
expectedUpgradeMessage := "My custom upgrade message"
107+
108+
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
109+
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
110+
ExternalURL: buildinfo.ExternalURL(),
111+
// Provide a version that will not match
112+
Version: "v1.0.0",
113+
AgentAPIVersion: coderd.AgentAPIVersionREST,
114+
// does not matter what the url is
115+
DashboardURL: "https://example.com",
116+
WorkspaceProxy: false,
117+
UpgradeMessage: expectedUpgradeMessage,
118+
})
119+
}))
120+
defer srv.Close()
121+
surl, err := url.Parse(srv.URL)
122+
require.NoError(t, err)
123+
124+
client := codersdk.New(surl)
125+
126+
r := &RootCmd{}
127+
128+
cmd, err := r.Command(nil)
129+
require.NoError(t, err)
130+
131+
var buf bytes.Buffer
132+
inv := cmd.Invoke()
133+
inv.Stderr = &buf
134+
135+
err = r.checkVersions(inv, client, "v2.0.0")
136+
require.NoError(t, err)
137+
138+
fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", expectedUpgradeMessage)
139+
expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput))
140+
require.Equal(t, expectedOutput, buf.String())
141+
})
142+
143+
t.Run("DefaultUpgradeMessage", func(t *testing.T) {
144+
t.Parallel()
145+
146+
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
147+
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
148+
ExternalURL: buildinfo.ExternalURL(),
149+
// Provide a version that will not match
150+
Version: "v1.0.0",
151+
AgentAPIVersion: coderd.AgentAPIVersionREST,
152+
// does not matter what the url is
153+
DashboardURL: "https://example.com",
154+
WorkspaceProxy: false,
155+
UpgradeMessage: "",
156+
})
157+
}))
158+
defer srv.Close()
159+
surl, err := url.Parse(srv.URL)
160+
require.NoError(t, err)
161+
162+
client := codersdk.New(surl)
163+
164+
r := &RootCmd{}
165+
166+
cmd, err := r.Command(nil)
167+
require.NoError(t, err)
168+
169+
var buf bytes.Buffer
170+
inv := cmd.Invoke()
171+
inv.Stderr = &buf
172+
173+
err = r.checkVersions(inv, client, "v2.0.0")
174+
require.NoError(t, err)
175+
176+
fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", defaultUpgradeMessage("v1.0.0"))
177+
expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput))
178+
require.Equal(t, expectedOutput, buf.String())
179+
})
180+
}

cli/testdata/coder_server_--help.golden

+5
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ CLIENT OPTIONS:
6565
These options change the behavior of how clients interact with the Coder.
6666
Clients include the coder cli, vs code extension, and the web UI.
6767

68+
--cli-upgrade-message string, $CODER_CLI_UPGRADE_MESSAGE
69+
The upgrade message to display to users when a client/server mismatch
70+
is detected. By default it instructs users to update using 'curl -L
71+
https://coder.com/install.sh | sh'.
72+
6873
--ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
6974
These SSH config options will override the default SSH config options.
7075
Provide options in "key=value" or "key value" format separated by

cli/testdata/server-config.yaml.golden

+5
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,11 @@ client:
433433
# incorrectly can break SSH to your deployment, use cautiously.
434434
# (default: <unset>, type: string-array)
435435
sshConfigOptions: []
436+
# The upgrade message to display to users when a client/server mismatch is
437+
# detected. By default it instructs users to update using 'curl -L
438+
# https://coder.com/install.sh | sh'.
439+
# (default: <unset>, type: string)
440+
cliUpgradeMessage: ""
436441
# The renderer to use when opening a web terminal. Valid values are 'canvas',
437442
# 'webgl', or 'dom'.
438443
# (default: canvas, type: string)

coderd/apidoc/docs.go

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ func New(options *Options) *API {
645645
// All CSP errors will be logged
646646
r.Post("/csp/reports", api.logReportCSPViolations)
647647

648-
r.Get("/buildinfo", buildInfo(api.AccessURL))
648+
r.Get("/buildinfo", buildInfo(api.AccessURL, api.DeploymentValues.CLIUpgradeMessage.String()))
649649
// /regions is overridden in the enterprise version
650650
r.Group(func(r chi.Router) {
651651
r.Use(apiKeyMiddleware)

coderd/deployment.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,15 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) {
6868
// @Tags General
6969
// @Success 200 {object} codersdk.BuildInfoResponse
7070
// @Router /buildinfo [get]
71-
func buildInfo(accessURL *url.URL) http.HandlerFunc {
71+
func buildInfo(accessURL *url.URL, upgradeMessage string) http.HandlerFunc {
7272
return func(rw http.ResponseWriter, r *http.Request) {
7373
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
7474
ExternalURL: buildinfo.ExternalURL(),
7575
Version: buildinfo.Version(),
7676
AgentAPIVersion: AgentAPIVersionREST,
7777
DashboardURL: accessURL.String(),
7878
WorkspaceProxy: false,
79+
UpgradeMessage: upgradeMessage,
7980
})
8081
}
8182
}

codersdk/deployment.go

+15
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ type DeploymentValues struct {
188188
WebTerminalRenderer clibase.String `json:"web_terminal_renderer,omitempty" typescript:",notnull"`
189189
AllowWorkspaceRenames clibase.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"`
190190
Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"`
191+
CLIUpgradeMessage clibase.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"`
191192

192193
Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
193194
WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"`
@@ -1780,6 +1781,16 @@ when required by your organization's security policy.`,
17801781
Value: &c.SSHConfig.SSHConfigOptions,
17811782
Hidden: false,
17821783
},
1784+
{
1785+
Name: "CLI Upgrade Message",
1786+
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'.",
1787+
Flag: "cli-upgrade-message",
1788+
Env: "CODER_CLI_UPGRADE_MESSAGE",
1789+
YAML: "cliUpgradeMessage",
1790+
Group: &deploymentGroupClient,
1791+
Value: &c.CLIUpgradeMessage,
1792+
Hidden: false,
1793+
},
17831794
{
17841795
Name: "Write Config",
17851796
Description: `
@@ -2052,6 +2063,10 @@ type BuildInfoResponse struct {
20522063
// AgentAPIVersion is the current version of the Agent API (back versions
20532064
// MAY still be supported).
20542065
AgentAPIVersion string `json:"agent_api_version"`
2066+
2067+
// UpgradeMessage is the message displayed to users when an outdated client
2068+
// is detected.
2069+
UpgradeMessage string `json:"upgrade_message"`
20552070
}
20562071

20572072
type WorkspaceProxyBuildInfo struct {

docs/api/general.md

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api/schemas.md

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)