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

This commit is contained in:
Jon Ayers 2024-01-30 17:11:37 -06:00 committed by GitHub
parent adbb025e74
commit 0c30dde9b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 191 additions and 20 deletions

View File

@ -18,6 +18,7 @@ import (
"github.com/coder/pretty" "github.com/coder/pretty"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/coderd/userpassword"
@ -175,7 +176,7 @@ func (r *RootCmd) login() *clibase.Cmd {
// Try to check the version of the server prior to logging in. // 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 // It may be useful to warn the user if they are trying to login
// on a very old client. // on a very old client.
err = r.checkVersions(inv, client) err = r.checkVersions(inv, client, buildinfo.Version())
if err != nil { if err != nil {
// Checking versions isn't a fatal error so we print a warning // Checking versions isn't a fatal error so we print a warning
// and proceed. // and proceed.

View File

@ -602,7 +602,7 @@ func (r *RootCmd) PrintWarnings(client *codersdk.Client) clibase.MiddlewareFunc
warningErr = make(chan error) warningErr = make(chan error)
) )
go func() { go func() {
versionErr <- r.checkVersions(inv, client) versionErr <- r.checkVersions(inv, client, buildinfo.Version())
close(versionErr) close(versionErr)
}() }()
@ -812,7 +812,12 @@ func formatExamples(examples ...example) string {
return sb.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 { if r.noVersionCheck {
return nil return nil
} }
@ -820,30 +825,26 @@ func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client)
ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second)
defer cancel() defer cancel()
clientVersion := buildinfo.Version() serverInfo, err := client.BuildInfo(ctx)
info, err := client.BuildInfo(ctx)
// Avoid printing errors that are connection-related. // Avoid printing errors that are connection-related.
if isConnectionError(err) { if isConnectionError(err) {
return nil return nil
} }
if err != nil { if err != nil {
return xerrors.Errorf("build info: %w", err) return xerrors.Errorf("build info: %w", err)
} }
fmtWarningText := `version mismatch: client %s, server %s if !buildinfo.VersionsMatch(clientVersion, serverInfo.Version) {
` upgradeMessage := defaultUpgradeMessage(serverInfo.CanonicalVersion())
// Our installation script doesn't work on Windows, so instead we direct the user if serverInfo.UpgradeMessage != "" {
// to the GitHub release page to download the latest installer. upgradeMessage = serverInfo.UpgradeMessage
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, info.Version) { fmtWarningText := "version mismatch: client %s, server %s\n%s"
warn := cliui.DefaultStyles.Warn fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText)
_, _ = fmt.Fprintf(i.Stderr, pretty.Sprint(warn, fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) warning := fmt.Sprintf(fmtWarn, clientVersion, serverInfo.Version, upgradeMessage)
_, _ = fmt.Fprint(i.Stderr, warning)
_, _ = fmt.Fprintln(i.Stderr) _, _ = fmt.Fprintln(i.Stderr)
} }
@ -1216,3 +1217,13 @@ func SlimUnsupported(w io.Writer, cmd string) {
//nolint:revive //nolint:revive
os.Exit(1) 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)
}

View File

@ -1,12 +1,24 @@
package cli package cli
import ( import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os" "os"
"runtime" "runtime"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/goleak" "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) { func Test_formatExamples(t *testing.T) {
@ -84,3 +96,85 @@ func TestMain(m *testing.M) {
goleak.IgnoreTopFunction("github.com/lib/pq.NewDialListener"), 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())
})
}

View File

@ -65,6 +65,11 @@ CLIENT OPTIONS:
These options change the behavior of how clients interact with the Coder. These options change the behavior of how clients interact with the Coder.
Clients include the coder cli, vs code extension, and the web UI. 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 --ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
These SSH config options will override the default 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 Provide options in "key=value" or "key value" format separated by

View File

@ -433,6 +433,11 @@ client:
# incorrectly can break SSH to your deployment, use cautiously. # incorrectly can break SSH to your deployment, use cautiously.
# (default: <unset>, type: string-array) # (default: <unset>, type: string-array)
sshConfigOptions: [] 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', # The renderer to use when opening a web terminal. Valid values are 'canvas',
# 'webgl', or 'dom'. # 'webgl', or 'dom'.
# (default: canvas, type: string) # (default: canvas, type: string)

7
coderd/apidoc/docs.go generated
View File

@ -8329,6 +8329,10 @@ const docTemplate = `{
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
"type": "string" "type": "string"
}, },
"upgrade_message": {
"description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.",
"type": "string"
},
"version": { "version": {
"description": "Version returns the semantic version of the build.", "description": "Version returns the semantic version of the build.",
"type": "string" "type": "string"
@ -9043,6 +9047,9 @@ const docTemplate = `{
"cache_directory": { "cache_directory": {
"type": "string" "type": "string"
}, },
"cli_upgrade_message": {
"type": "string"
},
"config": { "config": {
"type": "string" "type": "string"
}, },

View File

@ -7418,6 +7418,10 @@
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
"type": "string" "type": "string"
}, },
"upgrade_message": {
"description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.",
"type": "string"
},
"version": { "version": {
"description": "Version returns the semantic version of the build.", "description": "Version returns the semantic version of the build.",
"type": "string" "type": "string"
@ -8079,6 +8083,9 @@
"cache_directory": { "cache_directory": {
"type": "string" "type": "string"
}, },
"cli_upgrade_message": {
"type": "string"
},
"config": { "config": {
"type": "string" "type": "string"
}, },

View File

@ -645,7 +645,7 @@ func New(options *Options) *API {
// All CSP errors will be logged // All CSP errors will be logged
r.Post("/csp/reports", api.logReportCSPViolations) 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 // /regions is overridden in the enterprise version
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware) r.Use(apiKeyMiddleware)

View File

@ -68,7 +68,7 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) {
// @Tags General // @Tags General
// @Success 200 {object} codersdk.BuildInfoResponse // @Success 200 {object} codersdk.BuildInfoResponse
// @Router /buildinfo [get] // @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) { return func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{ httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(), ExternalURL: buildinfo.ExternalURL(),
@ -76,6 +76,7 @@ func buildInfo(accessURL *url.URL) http.HandlerFunc {
AgentAPIVersion: AgentAPIVersionREST, AgentAPIVersion: AgentAPIVersionREST,
DashboardURL: accessURL.String(), DashboardURL: accessURL.String(),
WorkspaceProxy: false, WorkspaceProxy: false,
UpgradeMessage: upgradeMessage,
}) })
} }
} }

View File

@ -188,6 +188,7 @@ type DeploymentValues struct {
WebTerminalRenderer clibase.String `json:"web_terminal_renderer,omitempty" typescript:",notnull"` WebTerminalRenderer clibase.String `json:"web_terminal_renderer,omitempty" typescript:",notnull"`
AllowWorkspaceRenames clibase.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"` AllowWorkspaceRenames clibase.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"`
Healthcheck HealthcheckConfig `json:"healthcheck,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"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"`
@ -1780,6 +1781,16 @@ when required by your organization's security policy.`,
Value: &c.SSHConfig.SSHConfigOptions, Value: &c.SSHConfig.SSHConfigOptions,
Hidden: false, 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", Name: "Write Config",
Description: ` Description: `
@ -2052,6 +2063,10 @@ type BuildInfoResponse struct {
// AgentAPIVersion is the current version of the Agent API (back versions // AgentAPIVersion is the current version of the Agent API (back versions
// MAY still be supported). // MAY still be supported).
AgentAPIVersion string `json:"agent_api_version"` 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 { type WorkspaceProxyBuildInfo struct {

2
docs/api/general.md generated
View File

@ -56,6 +56,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \
"agent_api_version": "string", "agent_api_version": "string",
"dashboard_url": "string", "dashboard_url": "string",
"external_url": "string", "external_url": "string",
"upgrade_message": "string",
"version": "string", "version": "string",
"workspace_proxy": true "workspace_proxy": true
} }
@ -157,6 +158,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"autobuild_poll_interval": 0, "autobuild_poll_interval": 0,
"browser_only": true, "browser_only": true,
"cache_directory": "string", "cache_directory": "string",
"cli_upgrade_message": "string",
"config": "string", "config": "string",
"config_ssh": { "config_ssh": {
"deploymentName": "string", "deploymentName": "string",

5
docs/api/schemas.md generated
View File

@ -1445,6 +1445,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"agent_api_version": "string", "agent_api_version": "string",
"dashboard_url": "string", "dashboard_url": "string",
"external_url": "string", "external_url": "string",
"upgrade_message": "string",
"version": "string", "version": "string",
"workspace_proxy": true "workspace_proxy": true
} }
@ -1457,6 +1458,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). | | `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). |
| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | | `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. |
| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | | `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. |
| `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. |
| `version` | string | false | | Version returns the semantic version of the build. | | `version` | string | false | | Version returns the semantic version of the build. |
| `workspace_proxy` | boolean | false | | | | `workspace_proxy` | boolean | false | | |
@ -2131,6 +2133,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"autobuild_poll_interval": 0, "autobuild_poll_interval": 0,
"browser_only": true, "browser_only": true,
"cache_directory": "string", "cache_directory": "string",
"cli_upgrade_message": "string",
"config": "string", "config": "string",
"config_ssh": { "config_ssh": {
"deploymentName": "string", "deploymentName": "string",
@ -2497,6 +2500,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"autobuild_poll_interval": 0, "autobuild_poll_interval": 0,
"browser_only": true, "browser_only": true,
"cache_directory": "string", "cache_directory": "string",
"cli_upgrade_message": "string",
"config": "string", "config": "string",
"config_ssh": { "config_ssh": {
"deploymentName": "string", "deploymentName": "string",
@ -2758,6 +2762,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `autobuild_poll_interval` | integer | false | | | | `autobuild_poll_interval` | integer | false | | |
| `browser_only` | boolean | false | | | | `browser_only` | boolean | false | | |
| `cache_directory` | string | false | | | | `cache_directory` | string | false | | |
| `cli_upgrade_message` | string | false | | |
| `config` | string | false | | | | `config` | string | false | | |
| `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | | `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | |
| `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | | `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | |

10
docs/cli/server.md generated
View File

@ -73,6 +73,16 @@ Block peer-to-peer (aka. direct) workspace connections. All workspace connection
Whether Coder only allows connections to workspaces via the browser. Whether Coder only allows connections to workspaces via the browser.
### --cli-upgrade-message
| | |
| ----------- | --------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_CLI_UPGRADE_MESSAGE</code> |
| YAML | <code>client.cliUpgradeMessage</code> |
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'.
### --cache-dir ### --cache-dir
| | | | | |

View File

@ -66,6 +66,11 @@ CLIENT OPTIONS:
These options change the behavior of how clients interact with the Coder. These options change the behavior of how clients interact with the Coder.
Clients include the coder cli, vs code extension, and the web UI. 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 --ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
These SSH config options will override the default 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 Provide options in "key=value" or "key value" format separated by

View File

@ -163,6 +163,7 @@ export interface BuildInfoResponse {
readonly dashboard_url: string; readonly dashboard_url: string;
readonly workspace_proxy: boolean; readonly workspace_proxy: boolean;
readonly agent_api_version: string; readonly agent_api_version: string;
readonly upgrade_message: string;
} }
// From codersdk/insights.go // From codersdk/insights.go
@ -438,6 +439,7 @@ export interface DeploymentValues {
readonly web_terminal_renderer?: string; readonly web_terminal_renderer?: string;
readonly allow_workspace_renames?: boolean; readonly allow_workspace_renames?: boolean;
readonly healthcheck?: HealthcheckConfig; readonly healthcheck?: HealthcheckConfig;
readonly cli_upgrade_message?: string;
readonly config?: string; readonly config?: string;
readonly write_config?: boolean; readonly write_config?: boolean;
readonly address?: string; readonly address?: string;

View File

@ -199,6 +199,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = {
version: "v99.999.9999+c9cdf14", version: "v99.999.9999+c9cdf14",
dashboard_url: "https:///mock-url", dashboard_url: "https:///mock-url",
workspace_proxy: false, workspace_proxy: false,
upgrade_message: "My custom upgrade message",
}; };
export const MockSupportLinks: TypesGen.LinkConfig[] = [ export const MockSupportLinks: TypesGen.LinkConfig[] = [