From 0c30dde9b581facfcd686714c19fca6fc2f74bc5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 30 Jan 2024 17:11:37 -0600 Subject: [PATCH] feat: add customizable upgrade message on client/server version mismatch (#11587) --- cli/login.go | 3 +- cli/root.go | 45 +++++---- cli/root_internal_test.go | 94 +++++++++++++++++++ cli/testdata/coder_server_--help.golden | 5 + cli/testdata/server-config.yaml.golden | 5 + coderd/apidoc/docs.go | 7 ++ coderd/apidoc/swagger.json | 7 ++ coderd/coderd.go | 2 +- coderd/deployment.go | 3 +- codersdk/deployment.go | 15 +++ docs/api/general.md | 2 + docs/api/schemas.md | 5 + docs/cli/server.md | 10 ++ .../cli/testdata/coder_server_--help.golden | 5 + site/src/api/typesGenerated.ts | 2 + site/src/testHelpers/entities.ts | 1 + 16 files changed, 191 insertions(+), 20 deletions(-) diff --git a/cli/login.go b/cli/login.go index 1bf8d97297..17cb206e1e 100644 --- a/cli/login.go +++ b/cli/login.go @@ -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" @@ -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. diff --git a/cli/root.go b/cli/root.go index 56ee8dba94..2bf0109557 100644 --- a/cli/root.go +++ b/cli/root.go @@ -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) }() @@ -812,7 +812,12 @@ 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 } @@ -820,30 +825,26 @@ func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client) 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 + } - 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")) + fmtWarningText := "version mismatch: client %s, server %s\n%s" + fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText) + warning := fmt.Sprintf(fmtWarn, clientVersion, serverInfo.Version, upgradeMessage) + + _, _ = fmt.Fprint(i.Stderr, warning) _, _ = fmt.Fprintln(i.Stderr) } @@ -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) +} diff --git a/cli/root_internal_test.go b/cli/root_internal_test.go index 2d99ab8247..6d108ee554 100644 --- a/cli/root_internal_test.go +++ b/cli/root_internal_test.go @@ -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) { @@ -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()) + }) +} diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 23f7bba488..8d420da29b 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -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 diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 653f3bb335..1c9372fad6 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -433,6 +433,11 @@ client: # incorrectly can break SSH to your deployment, use cautiously. # (default: , 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: , type: string) + cliUpgradeMessage: "" # The renderer to use when opening a web terminal. Valid values are 'canvas', # 'webgl', or 'dom'. # (default: canvas, type: string) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0a3199d6f9..4f7a7d3ebc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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.", "type": "string" }, + "upgrade_message": { + "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", + "type": "string" + }, "version": { "description": "Version returns the semantic version of the build.", "type": "string" @@ -9043,6 +9047,9 @@ const docTemplate = `{ "cache_directory": { "type": "string" }, + "cli_upgrade_message": { + "type": "string" + }, "config": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 08ed1c781b..c7696a4157 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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.", "type": "string" }, + "upgrade_message": { + "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", + "type": "string" + }, "version": { "description": "Version returns the semantic version of the build.", "type": "string" @@ -8079,6 +8083,9 @@ "cache_directory": { "type": "string" }, + "cli_upgrade_message": { + "type": "string" + }, "config": { "type": "string" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index 4ab7f040ab..9d640e4b01 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/deployment.go b/coderd/deployment.go index af6955cef0..22bd555b28 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -68,7 +68,7 @@ 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(), @@ -76,6 +76,7 @@ func buildInfo(accessURL *url.URL) http.HandlerFunc { AgentAPIVersion: AgentAPIVersionREST, DashboardURL: accessURL.String(), WorkspaceProxy: false, + UpgradeMessage: upgradeMessage, }) } } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 191a1cb93d..c02eea5959 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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"` @@ -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: ` @@ -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 { diff --git a/docs/api/general.md b/docs/api/general.md index 39e7372c3b..1069a7d332 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -56,6 +56,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ "agent_api_version": "string", "dashboard_url": "string", "external_url": "string", + "upgrade_message": "string", "version": "string", "workspace_proxy": true } @@ -157,6 +158,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "autobuild_poll_interval": 0, "browser_only": true, "cache_directory": "string", + "cli_upgrade_message": "string", "config": "string", "config_ssh": { "deploymentName": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3f2ebc9788..909158f2eb 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1445,6 +1445,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "agent_api_version": "string", "dashboard_url": "string", "external_url": "string", + "upgrade_message": "string", "version": "string", "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). | | `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. | +| `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. | | `workspace_proxy` | boolean | false | | | @@ -2131,6 +2133,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "autobuild_poll_interval": 0, "browser_only": true, "cache_directory": "string", + "cli_upgrade_message": "string", "config": "string", "config_ssh": { "deploymentName": "string", @@ -2497,6 +2500,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "autobuild_poll_interval": 0, "browser_only": true, "cache_directory": "string", + "cli_upgrade_message": "string", "config": "string", "config_ssh": { "deploymentName": "string", @@ -2758,6 +2762,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `autobuild_poll_interval` | integer | false | | | | `browser_only` | boolean | false | | | | `cache_directory` | string | false | | | +| `cli_upgrade_message` | string | false | | | | `config` | string | false | | | | `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | | `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | diff --git a/docs/cli/server.md b/docs/cli/server.md index 77f6d600e3..2b649aa321 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -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. +### --cli-upgrade-message + +| | | +| ----------- | --------------------------------------- | +| Type | string | +| Environment | $CODER_CLI_UPGRADE_MESSAGE | +| YAML | client.cliUpgradeMessage | + +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 | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index e2b27dc6d9..5129aaed52 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -66,6 +66,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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 501bb2eabc..6858d7a890 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -163,6 +163,7 @@ export interface BuildInfoResponse { readonly dashboard_url: string; readonly workspace_proxy: boolean; readonly agent_api_version: string; + readonly upgrade_message: string; } // From codersdk/insights.go @@ -438,6 +439,7 @@ export interface DeploymentValues { readonly web_terminal_renderer?: string; readonly allow_workspace_renames?: boolean; readonly healthcheck?: HealthcheckConfig; + readonly cli_upgrade_message?: string; readonly config?: string; readonly write_config?: boolean; readonly address?: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6f72d80d3d..11d2ba5a9c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -199,6 +199,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = { version: "v99.999.9999+c9cdf14", dashboard_url: "https:///mock-url", workspace_proxy: false, + upgrade_message: "My custom upgrade message", }; export const MockSupportLinks: TypesGen.LinkConfig[] = [