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
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,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, false)
if err != nil {
// Checking versions isn't a fatal error so we print a warning
// and proceed.
Expand Down
28 changes: 22 additions & 6 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,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, false)
close(versionErr)
}()

Expand Down Expand Up @@ -810,7 +810,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, forceCheck bool) error {
if r.noVersionCheck {
return nil
}
Expand All @@ -824,11 +829,15 @@ func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client)
if isConnectionError(err) {
return nil
}

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

dconfig, err := client.UnprivilegedDeploymentConfig(ctx)
if err != nil {
return xerrors.Errorf("deployment config: %w", err)
}

fmtWarningText := `version mismatch: client %s, server %s
`
// Our installation script doesn't work on Windows, so instead we direct the user
Expand All @@ -838,10 +847,17 @@ func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client)
} else {
fmtWarningText += `download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'`
}
warn := cliui.DefaultStyles.Warn
warning := fmt.Sprintf(pretty.Sprint(warn, fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))

// If a custom upgrade message has been set, override the default that we
// display.
if msg := dconfig.CLIUpgradeMessage; msg != "" {
warning = fmt.Sprint(pretty.Sprint(warn, msg))
}

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"))
if !buildinfo.VersionsMatch(clientVersion, info.Version) || forceCheck {
_, _ = fmt.Fprint(i.Stderr, warning)
_, _ = fmt.Fprintln(i.Stderr)
}

Expand Down
45 changes: 45 additions & 0 deletions cli/root_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package cli

import (
"bytes"
"fmt"
"os"
"runtime"
"testing"

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

"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/pretty"
)

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

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

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

var (
expectedUpgradeMessage = "My custom upgrade message"
dv = coderdtest.DeploymentValues(t)
)
dv.CLIUpgradeMessage = clibase.String(expectedUpgradeMessage)

ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: dv,
})
owner := coderdtest.CreateFirstUser(t, ownerClient)

// Create an unprivileged user to ensure the message can be printed
// to any Coder user.
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

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, memberClient, true)
require.NoError(t, err)

expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, expectedUpgradeMessage))
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 @@ -62,6 +62,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
39 changes: 39 additions & 0 deletions coderd/apidoc/docs.go

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

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

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

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ func New(options *Options) *API {
r.Get("/config", api.deploymentValues)
r.Get("/stats", api.deploymentStats)
r.Get("/ssh", api.sshConfig)
r.Get("/unprivileged", api.unprivilegedConfig)
})
r.Route("/experiments", func(r chi.Router) {
r.Use(apiKeyMiddleware)
Expand Down
14 changes: 14 additions & 0 deletions coderd/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,17 @@ func buildInfo(accessURL *url.URL) http.HandlerFunc {
func (api *API) sshConfig(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, api.SSHConfig)
}

// @Summary Unprivileged deployment config.
// @ID unprivileged-deployment-config
// @Security CoderSessionToken
// @Produce json
// @Tags General
// @Success 200 {object} codersdk.UnprivilegedDeploymentConfig
// @Router /deployment/unprivileged [get]
func (api *API) unprivilegedConfig(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.UnprivilegedDeploymentConfig{
SSHConfig: api.SSHConfig,
CLIUpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(),
})
}
2 changes: 1 addition & 1 deletion coderd/externalauth/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAut
// return a better error.
switch resp.StatusCode {
case http.StatusTooManyRequests:
return nil, fmt.Errorf("rate limit hit, unable to authorize device. please try again later")
return nil, xerrors.New("rate limit hit, unable to authorize device. please try again later")
default:
return nil, err
}
Expand Down
3 changes: 2 additions & 1 deletion coderd/httpapi/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import (
"context"
"time"

"cdr.dev/slog"
"nhooyr.io/websocket"

"cdr.dev/slog"
)

// Heartbeat loops to ping a WebSocket to keep it alive.
Expand Down
5 changes: 3 additions & 2 deletions coderd/promoauth/github.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package promoauth

import (
"fmt"
"net/http"
"strconv"
"time"

"golang.org/x/xerrors"
)

type rateLimits struct {
Expand Down Expand Up @@ -81,7 +82,7 @@ func (p *headerParser) string(key string) string {

v := p.header.Get(key)
if v == "" {
p.errors[key] = fmt.Errorf("missing header %q", key)
p.errors[key] = xerrors.Errorf("missing header %q", key)
}
return v
}
Expand Down
33 changes: 33 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,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 @@ -1767,6 +1768,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 @@ -2073,6 +2084,28 @@ func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
return buildInfo, json.NewDecoder(res.Body).Decode(&buildInfo)
}

type UnprivilegedDeploymentConfig struct {
SSHConfig SSHConfigResponse `json:"ssh_config"`
CLIUpgradeMessage string `json:"cli_upgrade_message"`
}

// UnprivilegedDeploymentConfig returns unsensitive config values
// accessible by an ordinary, unprivileged user.
func (c *Client) UnprivilegedDeploymentConfig(ctx context.Context) (UnprivilegedDeploymentConfig, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/unprivileged", nil)
if err != nil {
return UnprivilegedDeploymentConfig{}, err
}
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
return UnprivilegedDeploymentConfig{}, ReadBodyAsError(res)
}

var config UnprivilegedDeploymentConfig
return config, json.NewDecoder(res.Body).Decode(&config)
}

type Experiment string

const (
Expand Down
Loading