feat: Add support for update checks and notifications (#4810)

Co-authored-by: Kira Pilot <kira@coder.com>
This commit is contained in:
Mathias Fredriksson 2022-12-01 19:43:28 +02:00 committed by GitHub
parent 4f1cf6c9d8
commit d9f2aaf3b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1088 additions and 22 deletions

View File

@ -68,6 +68,11 @@ func VersionsMatch(v1, v2 string) bool {
return semver.MajorMinor(v1) == semver.MajorMinor(v2)
}
// IsDev returns true if this is a development build.
func IsDev() bool {
return strings.HasPrefix(Version(), develPrefix)
}
// ExternalURL returns a URL referencing the current Coder version.
// For production builds, this will link directly to a release.
// For development builds, this will link to a commit.

View File

@ -14,6 +14,7 @@ import (
"github.com/spf13/viper"
"golang.org/x/xerrors"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/codersdk"
@ -405,6 +406,12 @@ func newConfig() *codersdk.DeploymentConfig {
Usage: "Enable experimental features. Experimental features are not ready for production.",
Flag: "experimental",
},
UpdateCheck: &codersdk.DeploymentConfigField[bool]{
Name: "Update Check",
Usage: "Periodically check for new releases of Coder and inform the owner. The check is performed once per day.",
Flag: "update-check",
Default: flag.Lookup("test.v") == nil && !buildinfo.IsDev(),
},
}
}

View File

@ -38,6 +38,7 @@ func TestConfig(t *testing.T) {
"CODER_TELEMETRY": "false",
"CODER_TELEMETRY_TRACE": "false",
"CODER_WILDCARD_ACCESS_URL": "something-wildcard.com",
"CODER_UPDATE_CHECK": "false",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.Address.Value, "0.0.0.0:8443")
@ -53,6 +54,7 @@ func TestConfig(t *testing.T) {
require.Equal(t, config.Telemetry.Enable.Value, false)
require.Equal(t, config.Telemetry.Trace.Value, false)
require.Equal(t, config.WildcardAccessURL.Value, "something-wildcard.com")
require.Equal(t, config.UpdateCheck.Value, false)
},
}, {
Name: "DERP",

View File

@ -63,6 +63,7 @@ import (
"github.com/coder/coder/coderd/prometheusmetrics"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/provisioner/echo"
@ -373,6 +374,25 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
options.TLSCertificates = tlsConfig.Certificates
}
if cfg.UpdateCheck.Value {
options.UpdateCheckOptions = &updatecheck.Options{
// Avoid spamming GitHub API checking for updates.
Interval: 24 * time.Hour,
// Inform server admins of new versions.
Notify: func(r updatecheck.Result) {
if semver.Compare(r.Version, buildinfo.Version()) > 0 {
options.Logger.Info(
context.Background(),
"new version of coder available",
slog.F("new_version", r.Version),
slog.F("url", r.URL),
slog.F("upgrade_instructions", "https://coder.com/docs/coder-oss/latest/admin/upgrade"),
)
}
},
}
}
if cfg.OAuth2.Github.ClientSecret.Value != "" {
options.GithubOAuth2Config, err = configureGithubOAuth2(accessURLParsed,
cfg.OAuth2.Github.ClientID.Value,

View File

@ -219,6 +219,10 @@ Flags:
verbose flag was supplied, debug-level
logs will be included.
Consumes $CODER_TRACE_CAPTURE_LOGS
--update-check Periodically check for new releases of
Coder and inform the owner. The check is
performed once per day.
Consumes $CODER_UPDATE_CHECK
--wildcard-access-url string Specifies the wildcard hostname to use
for workspace applications in the form
"*.example.com".

View File

@ -47,6 +47,7 @@ import (
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/coderd/wsconncache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisionerd/proto"
@ -105,6 +106,7 @@ type Options struct {
AgentStatsRefreshInterval time.Duration
Experimental bool
DeploymentConfig *codersdk.DeploymentConfig
UpdateCheckOptions *updatecheck.Options // Set non-nil to enable update checking.
}
// New constructs a Coder API handler.
@ -123,7 +125,7 @@ func New(options *Options) *API {
options.AgentInactiveDisconnectTimeout = options.AgentConnectionUpdateFrequency * 2
}
if options.AgentStatsRefreshInterval == 0 {
options.AgentStatsRefreshInterval = 10 * time.Minute
options.AgentStatsRefreshInterval = 5 * time.Minute
}
if options.MetricsCacheRefreshInterval == 0 {
options.MetricsCacheRefreshInterval = time.Hour
@ -131,12 +133,6 @@ func New(options *Options) *API {
if options.APIRateLimit == 0 {
options.APIRateLimit = 512
}
if options.AgentStatsRefreshInterval == 0 {
options.AgentStatsRefreshInterval = 5 * time.Minute
}
if options.MetricsCacheRefreshInterval == 0 {
options.MetricsCacheRefreshInterval = time.Hour
}
if options.Authorizer == nil {
options.Authorizer = rbac.NewAuthorizer()
}
@ -181,6 +177,13 @@ func New(options *Options) *API {
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
}
if options.UpdateCheckOptions != nil {
api.updateChecker = updatecheck.New(
options.Database,
options.Logger.Named("update_checker"),
*options.UpdateCheckOptions,
)
}
api.Auditor.Store(&options.Auditor)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
@ -308,6 +311,9 @@ func New(options *Options) *API {
})
})
})
r.Route("/updatecheck", func(r chi.Router) {
r.Get("/", api.updateCheck)
})
r.Route("/config", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/deployment", api.deploymentConfig)
@ -590,13 +596,14 @@ type API struct {
// RootHandler serves "/"
RootHandler chi.Router
metricsCache *metricscache.Cache
siteHandler http.Handler
siteHandler http.Handler
WebsocketWaitMutex sync.Mutex
WebsocketWaitGroup sync.WaitGroup
metricsCache *metricscache.Cache
workspaceAgentCache *wsconncache.Cache
updateChecker *updatecheck.Checker
}
// Close waits for all WebSocket connections to drain before returning.
@ -606,6 +613,9 @@ func (api *API) Close() error {
api.WebsocketWaitMutex.Unlock()
api.metricsCache.Close()
if api.updateChecker != nil {
api.updateChecker.Close()
}
coordinator := api.TailnetCoordinator.Load()
if coordinator != nil {
_ = (*coordinator).Close()

View File

@ -48,6 +48,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"GET:/healthz": {NoAuthorize: true},
"GET:/api/v2": {NoAuthorize: true},
"GET:/api/v2/buildinfo": {NoAuthorize: true},
"GET:/api/v2/updatecheck": {NoAuthorize: true},
"GET:/api/v2/users/first": {NoAuthorize: true},
"POST:/api/v2/users/first": {NoAuthorize: true},
"POST:/api/v2/users/login": {NoAuthorize: true},

View File

@ -64,6 +64,7 @@ import (
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
@ -102,6 +103,9 @@ type Options struct {
AgentStatsRefreshInterval time.Duration
DeploymentConfig *codersdk.DeploymentConfig
// Set update check options to enable update check.
UpdateCheckOptions *updatecheck.Options
// Overriding the database is heavily discouraged.
// It should only be used in cases where multiple Coder
// test instances are running against the same database.
@ -283,6 +287,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval,
AgentStatsRefreshInterval: options.AgentStatsRefreshInterval,
DeploymentConfig: options.DeploymentConfig,
UpdateCheckOptions: options.UpdateCheckOptions,
}
}

View File

@ -119,9 +119,10 @@ type data struct {
workspaceResources []database.WorkspaceResource
workspaces []database.Workspace
deploymentID string
derpMeshKey string
lastLicenseID int32
deploymentID string
derpMeshKey string
lastUpdateCheck []byte
lastLicenseID int32
}
func (fakeQuerier) IsFakeDB() {}
@ -3272,6 +3273,24 @@ func (q *fakeQuerier) GetDERPMeshKey(_ context.Context) (string, error) {
return q.derpMeshKey, nil
}
func (q *fakeQuerier) InsertOrUpdateLastUpdateCheck(_ context.Context, data string) error {
q.mutex.RLock()
defer q.mutex.RUnlock()
q.lastUpdateCheck = []byte(data)
return nil
}
func (q *fakeQuerier) GetLastUpdateCheck(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
if q.lastUpdateCheck == nil {
return "", sql.ErrNoRows
}
return string(q.lastUpdateCheck), nil
}
func (q *fakeQuerier) InsertLicense(
_ context.Context, arg database.InsertLicenseParams,
) (database.License, error) {

View File

@ -50,6 +50,7 @@ type sqlcQuerier interface {
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error)
GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error)
GetLastUpdateCheck(ctx context.Context) (string, error)
GetLatestAgentStat(ctx context.Context, agentID uuid.UUID) (AgentStat, error)
GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error)
GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error)
@ -141,6 +142,7 @@ type sqlcQuerier interface {
InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error)
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
InsertOrUpdateLastUpdateCheck(ctx context.Context, value string) error
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
InsertParameterSchema(ctx context.Context, arg InsertParameterSchemaParams) (ParameterSchema, error)

View File

@ -2970,6 +2970,17 @@ func (q *sqlQuerier) GetDeploymentID(ctx context.Context) (string, error) {
return value, err
}
const getLastUpdateCheck = `-- name: GetLastUpdateCheck :one
SELECT value FROM site_configs WHERE key = 'last_update_check'
`
func (q *sqlQuerier) GetLastUpdateCheck(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getLastUpdateCheck)
var value string
err := row.Scan(&value)
return value, err
}
const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec
INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1)
`
@ -2988,6 +2999,16 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error
return err
}
const insertOrUpdateLastUpdateCheck = `-- name: InsertOrUpdateLastUpdateCheck :exec
INSERT INTO site_configs (key, value) VALUES ('last_update_check', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'last_update_check'
`
func (q *sqlQuerier) InsertOrUpdateLastUpdateCheck(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, insertOrUpdateLastUpdateCheck, value)
return err
}
const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one
WITH build_times AS (
SELECT

View File

@ -9,3 +9,10 @@ INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1);
-- name: GetDERPMeshKey :one
SELECT value FROM site_configs WHERE key = 'derp_mesh_key';
-- name: InsertOrUpdateLastUpdateCheck :exec
INSERT INTO site_configs (key, value) VALUES ('last_update_check', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'last_update_check';
-- name: GetLastUpdateCheck :one
SELECT value FROM site_configs WHERE key = 'last_update_check';

54
coderd/updatecheck.go Normal file
View File

@ -0,0 +1,54 @@
package coderd
import (
"database/sql"
"net/http"
"strings"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
func (api *API) updateCheck(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
currentVersion := codersdk.UpdateCheckResponse{
Current: true,
Version: buildinfo.Version(),
URL: buildinfo.ExternalURL(),
}
if api.updateChecker == nil {
// If update checking is disabled, echo the current
// version.
httpapi.Write(ctx, rw, http.StatusOK, currentVersion)
return
}
uc, err := api.updateChecker.Latest(ctx)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
// Update checking is enabled, but has never
// succeeded, reproduce behavior as if disabled.
httpapi.Write(ctx, rw, http.StatusOK, currentVersion)
return
}
httpapi.InternalServerError(rw, err)
return
}
// Since our dev version (v0.12.9-devel+f7246386) is not semver compatible,
// ignore everything after "-"."
versionWithoutDevel := strings.SplitN(buildinfo.Version(), "-", 2)[0]
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateCheckResponse{
Current: semver.Compare(versionWithoutDevel, uc.Version) >= 0,
Version: uc.Version,
URL: uc.URL,
})
}

View File

@ -0,0 +1,238 @@
// Package updatecheck provides a mechanism for periodically checking
// for updates to Coder.
//
// The update check is performed by querying the GitHub API for the
// latest release of Coder.
package updatecheck
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"time"
"github.com/google/go-github/v43/github"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
)
const (
// defaultURL defines the URL to check for the latest version of Coder.
defaultURL = "https://api.github.com/repos/coder/coder/releases/latest"
)
// Checker is responsible for periodically checking for updates.
type Checker struct {
ctx context.Context
cancel context.CancelFunc
db database.Store
log slog.Logger
opts Options
firstCheck chan struct{}
closed chan struct{}
}
// Options set optional parameters for the update check.
type Options struct {
// Client is the HTTP client to use for the update check,
// if omitted, http.DefaultClient will be used.
Client *http.Client
// URL is the URL to check for the latest version of Coder,
// if omitted, the default URL will be used.
URL string
// Interval is the interval at which to check for updates,
// default 24h.
Interval time.Duration
// UpdateTimeout sets the timeout for the update check,
// default 30s.
UpdateTimeout time.Duration
// Notify is called when a newer version of Coder (than the
// last update check) is available.
Notify func(r Result)
}
// New returns a new Checker that periodically checks for Coder updates.
func New(db database.Store, log slog.Logger, opts Options) *Checker {
if opts.Client == nil {
opts.Client = http.DefaultClient
}
if opts.URL == "" {
opts.URL = defaultURL
}
if opts.Interval == 0 {
opts.Interval = 24 * time.Hour
}
if opts.UpdateTimeout == 0 {
opts.UpdateTimeout = 30 * time.Second
}
if opts.Notify == nil {
opts.Notify = func(r Result) {}
}
ctx, cancel := context.WithCancel(context.Background())
c := &Checker{
ctx: ctx,
cancel: cancel,
db: db,
log: log,
opts: opts,
firstCheck: make(chan struct{}),
closed: make(chan struct{}),
}
go c.start()
return c
}
// Result is the result from the last update check.
type Result struct {
Checked time.Time `json:"checked,omitempty"`
Version string `json:"version,omitempty"`
URL string `json:"url,omitempty"`
}
// Latest returns the latest version of Coder.
func (c *Checker) Latest(ctx context.Context) (r Result, err error) {
select {
case <-c.ctx.Done():
return r, c.ctx.Err()
case <-ctx.Done():
return r, ctx.Err()
case <-c.firstCheck:
}
return c.lastUpdateCheck(ctx)
}
func (c *Checker) init() (Result, error) {
defer close(c.firstCheck)
r, err := c.lastUpdateCheck(c.ctx)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return Result{}, xerrors.Errorf("last update check: %w", err)
}
if r.Checked.IsZero() || time.Since(r.Checked) > c.opts.Interval {
r, err = c.update()
if err != nil {
return Result{}, xerrors.Errorf("update check failed: %w", err)
}
}
return r, nil
}
func (c *Checker) start() {
defer close(c.closed)
r, err := c.init()
if err != nil {
if xerrors.Is(err, context.Canceled) {
return
}
c.log.Error(c.ctx, "init failed", slog.Error(err))
} else {
c.opts.Notify(r)
}
t := time.NewTicker(c.opts.Interval)
defer t.Stop()
diff := time.Until(r.Checked.Add(c.opts.Interval))
if diff > 0 {
c.log.Info(c.ctx, "time until next update check", slog.F("duration", diff))
t.Reset(diff)
} else {
c.log.Info(c.ctx, "time until next update check", slog.F("duration", c.opts.Interval))
}
for {
select {
case <-t.C:
rr, err := c.update()
if err != nil {
if xerrors.Is(err, context.Canceled) {
return
}
c.log.Error(c.ctx, "update check failed", slog.Error(err))
} else {
c.notifyIfNewer(r, rr)
r = rr
}
c.log.Info(c.ctx, "time until next update check", slog.F("duration", c.opts.Interval))
t.Reset(c.opts.Interval)
case <-c.ctx.Done():
return
}
}
}
func (c *Checker) update() (r Result, err error) {
ctx, cancel := context.WithTimeout(c.ctx, c.opts.UpdateTimeout)
defer cancel()
c.log.Info(c.ctx, "checking for update")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.opts.URL, nil)
if err != nil {
return r, xerrors.Errorf("new request: %w", err)
}
resp, err := c.opts.Client.Do(req)
if err != nil {
return r, xerrors.Errorf("client do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return r, xerrors.Errorf("unexpected status code %d: %s", resp.StatusCode, b)
}
var rr github.RepositoryRelease
err = json.NewDecoder(resp.Body).Decode(&rr)
if err != nil {
return r, xerrors.Errorf("json decode: %w", err)
}
r = Result{
Checked: time.Now(),
Version: rr.GetTagName(),
URL: rr.GetHTMLURL(),
}
c.log.Info(ctx, "update check result", slog.F("latest_version", r.Version))
b, err := json.Marshal(r)
if err != nil {
return r, xerrors.Errorf("json marshal result: %w", err)
}
err = c.db.InsertOrUpdateLastUpdateCheck(ctx, string(b))
if err != nil {
return r, err
}
return r, nil
}
func (c *Checker) notifyIfNewer(prev, next Result) {
if (prev.Version == "" && next.Version != "") || semver.Compare(next.Version, prev.Version) > 0 {
c.opts.Notify(next)
}
}
func (c *Checker) lastUpdateCheck(ctx context.Context) (r Result, err error) {
s, err := c.db.GetLastUpdateCheck(ctx)
if err != nil {
return r, err
}
return r, json.Unmarshal([]byte(s), &r)
}
func (c *Checker) Close() error {
c.cancel()
<-c.closed
return nil
}

View File

@ -0,0 +1,158 @@
package updatecheck_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/go-github/v43/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd/database/databasefake"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/testutil"
)
func TestChecker_Notify(t *testing.T) {
t.Parallel()
responses := []github.RepositoryRelease{
{TagName: github.String("v1.2.3"), HTMLURL: github.String("https://someurl.com")},
{TagName: github.String("v1.2.4"), HTMLURL: github.String("https://someurl.com")},
{TagName: github.String("v1.2.4"), HTMLURL: github.String("https://someurl.com")},
{TagName: github.String("v1.2.5"), HTMLURL: github.String("https://someurl.com")},
}
responseC := make(chan github.RepositoryRelease, len(responses))
for _, r := range responses {
responseC <- r
}
wantVersion := []string{"v1.2.3", "v1.2.4", "v1.2.5"}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case resp := <-responseC:
b, err := json.Marshal(resp)
assert.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
}
}))
defer srv.Close()
db := databasefake.New()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named(t.Name())
notify := make(chan updatecheck.Result, len(wantVersion))
c := updatecheck.New(db, logger, updatecheck.Options{
Interval: 1 * time.Nanosecond, // Zero means unset.
URL: srv.URL,
Notify: func(r updatecheck.Result) {
select {
case notify <- r:
default:
t.Error("unexpected notification")
}
},
})
defer c.Close()
ctx, _ := testutil.Context(t)
for i := 0; i < len(wantVersion); i++ {
select {
case <-ctx.Done():
t.Error("timed out waiting for notification")
case r := <-notify:
assert.Equal(t, wantVersion[i], r.Version)
}
}
}
func TestChecker_Latest(t *testing.T) {
t.Parallel()
rr := github.RepositoryRelease{
TagName: github.String("v1.2.3"),
HTMLURL: github.String("https://someurl.com"),
}
tests := []struct {
name string
release github.RepositoryRelease
wantR updatecheck.Result
wantErr bool
}{
{
name: "check latest",
release: rr,
wantR: updatecheck.Result{
Version: "v1.2.3",
URL: "https://someurl.com",
},
wantErr: false,
},
{
name: "missing release data",
release: github.RepositoryRelease{},
wantErr: true,
},
{
name: "error",
release: rr,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if tt.wantErr {
w.WriteHeader(http.StatusInternalServerError)
return
}
rrJSON, err := json.Marshal(rr)
assert.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(rrJSON)
}))
defer srv.Close()
db := databasefake.New()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named(t.Name())
c := updatecheck.New(db, logger, updatecheck.Options{
URL: srv.URL,
})
defer c.Close()
ctx, _ := testutil.Context(t)
_ = ctx
gotR, err := c.Latest(ctx)
if tt.wantErr {
require.Error(t, err)
return
}
// Zero out the time so we can compare the rest of the struct.
gotR.Checked = time.Time{}
require.Equal(t, tt.wantR, gotR, "wrong version")
})
}
}
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}

View File

@ -0,0 +1,79 @@
package coderd_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/go-github/v43/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
func TestUpdateCheck_NewVersion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resp github.RepositoryRelease
want codersdk.UpdateCheckResponse
}{
{
name: "New version",
resp: github.RepositoryRelease{
TagName: github.String("v99.999.999"),
HTMLURL: github.String("https://someurl.com"),
},
want: codersdk.UpdateCheckResponse{
Current: false,
Version: "v99.999.999",
URL: "https://someurl.com",
},
},
{
name: "Same version",
resp: github.RepositoryRelease{
TagName: github.String(buildinfo.Version()),
HTMLURL: github.String("https://someurl.com"),
},
want: codersdk.UpdateCheckResponse{
Current: true,
Version: buildinfo.Version(),
URL: "https://someurl.com",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
b, err := json.Marshal(tt.resp)
assert.NoError(t, err)
w.Write(b)
}))
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
UpdateCheckOptions: &updatecheck.Options{
URL: srv.URL,
},
})
ctx, _ := testutil.Context(t)
got, err := client.UpdateCheck(ctx)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}

View File

@ -41,6 +41,7 @@ type DeploymentConfig struct {
Provisioner *ProvisionerConfig `json:"provisioner" typescript:",notnull"`
APIRateLimit *DeploymentConfigField[int] `json:"api_rate_limit" typescript:",notnull"`
Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"`
UpdateCheck *DeploymentConfigField[bool] `json:"update_check" typescript:",notnull"`
}
type DERP struct {

37
codersdk/updatecheck.go Normal file
View File

@ -0,0 +1,37 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
)
// UpdateCheckResponse contains information
// on the latest release of Coder.
type UpdateCheckResponse struct {
// Current is a boolean indicating whether the
// server version is the same as the latest.
Current bool `json:"current"`
// Version is the semantic version for the latest
// release of Coder.
Version string `json:"version"`
// URL to download the latest release of Coder.
URL string `json:"url"`
}
// UpdateCheck returns information about the latest release version of
// Coder and whether or not the server is running the latest release.
func (c *Client) UpdateCheck(ctx context.Context) (UpdateCheckResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/updatecheck", nil)
if err != nil {
return UpdateCheckResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UpdateCheckResponse{}, readBodyAsError(res)
}
var buildInfo UpdateCheckResponse
return buildInfo, json.NewDecoder(res.Body).Decode(&buildInfo)
}

View File

@ -376,6 +376,12 @@ export const getBuildInfo = async (): Promise<TypesGen.BuildInfoResponse> => {
return response.data
}
export const getUpdateCheck =
async (): Promise<TypesGen.UpdateCheckResponse> => {
const response = await axios.get("/api/v2/updatecheck")
return response.data
}
export const putWorkspaceAutostart = async (
workspaceID: string,
autostart: TypesGen.UpdateWorkspaceAutostartRequest,

View File

@ -299,6 +299,7 @@ export interface DeploymentConfig {
readonly provisioner: ProvisionerConfig
readonly api_rate_limit: DeploymentConfigField<number>
readonly experimental: DeploymentConfigField<boolean>
readonly update_check: DeploymentConfigField<boolean>
}
// From codersdk/deploymentconfig.go
@ -702,6 +703,13 @@ export interface UpdateActiveTemplateVersion {
readonly id: string
}
// From codersdk/updatecheck.go
export interface UpdateCheckResponse {
readonly current: boolean
readonly version: string
readonly url: string
}
// From codersdk/users.go
export interface UpdateRoles {
readonly roles: string[]

View File

@ -3,6 +3,7 @@ import { AlertBanner } from "./AlertBanner"
import Button from "@material-ui/core/Button"
import { makeMockApiError } from "testHelpers/entities"
import { AlertBannerProps } from "./alertTypes"
import Link from "@material-ui/core/Link"
export default {
title: "components/AlertBanner",
@ -106,3 +107,16 @@ ErrorAsWarning.args = {
error: mockError,
severity: "warning",
}
const WithChildren: Story<AlertBannerProps> = (args) => (
<AlertBanner {...args}>
<div>
This is a message with a <Link href="#">link</Link>
</div>
</AlertBanner>
)
export const InfoWithChildContent = WithChildren.bind({})
InfoWithChildContent.args = {
severity: "info",
}

View File

@ -1,4 +1,4 @@
import { useState, FC } from "react"
import { useState, FC, Children } from "react"
import Collapse from "@material-ui/core/Collapse"
import { Stack } from "components/Stack/Stack"
import { makeStyles, Theme } from "@material-ui/core/styles"
@ -11,31 +11,39 @@ import { severityConstants } from "./severityConstants"
import { AlertBannerCtas } from "./AlertBannerCtas"
/**
* @param children: the children to be displayed in the alert
* @param severity: the level of alert severity (see ./severityTypes.ts)
* @param text: default text to be displayed to the user; useful for warnings or as a fallback error message
* @param error: should be passed in if the severity is 'Error'; warnings can use 'text' instead
* @param actions: an array of CTAs passed in by the consumer
* @param dismissible: determines whether or not the banner should have a `Dismiss` CTA
* @param retry: a handler to retry the action that spawned the error
* @param dismissible: determines whether or not the banner should have a `Dismiss` CTA
* @param onDismiss: a handler that is called when the `Dismiss` CTA is clicked, after the animation has finished
*/
export const AlertBanner: FC<AlertBannerProps> = ({
export const AlertBanner: FC<React.PropsWithChildren<AlertBannerProps>> = ({
children,
severity,
text,
error,
actions = [],
retry,
dismissible = false,
onDismiss,
}) => {
const { t } = useTranslation("common")
const [open, setOpen] = useState(true)
// Set a fallback message if no text or children are provided.
const defaultMessage =
text ??
(Children.count(children) === 0
? t("warningsAndErrors.somethingWentWrong")
: "")
// if an error is passed in, display that error, otherwise
// display the text passed in, e.g. warning text
const alertMessage = getErrorMessage(
error,
text ?? t("warningsAndErrors.somethingWentWrong"),
)
const alertMessage = getErrorMessage(error, defaultMessage)
// if we have an error, check if there's detail to display
const detail = error ? getErrorDetail(error) : undefined
@ -44,7 +52,7 @@ export const AlertBanner: FC<AlertBannerProps> = ({
const [showDetails, setShowDetails] = useState(false)
return (
<Collapse in={open}>
<Collapse in={open} onExited={() => onDismiss && onDismiss()}>
<Stack
className={classes.alertContainer}
direction="row"
@ -55,6 +63,7 @@ export const AlertBanner: FC<AlertBannerProps> = ({
<Stack direction="row" alignItems="center" spacing={1}>
{severityConstants[severity].icon}
<Stack spacing={0}>
{children}
{alertMessage}
{detail && (
<Expander expanded={showDetails} setExpanded={setShowDetails}>

View File

@ -9,5 +9,6 @@ export interface AlertBannerProps {
error?: ApiError | Error | unknown
actions?: ReactElement[]
dismissible?: boolean
onDismiss?: () => void
retry?: () => void
}

View File

@ -1,11 +1,13 @@
import { makeStyles } from "@material-ui/core/styles"
import { useActor } from "@xstate/react"
import { Loader } from "components/Loader/Loader"
import { FC, Suspense, useContext } from "react"
import { FC, Suspense, useContext, useEffect } from "react"
import { XServiceContext } from "../../xServices/StateContext"
import { Footer } from "../Footer/Footer"
import { Navbar } from "../Navbar/Navbar"
import { RequireAuth } from "../RequireAuth/RequireAuth"
import { UpdateCheckBanner } from "components/UpdateCheckBanner/UpdateCheckBanner"
import { Margins } from "components/Margins/Margins"
interface AuthAndFrameProps {
children: JSX.Element
@ -17,12 +19,35 @@ interface AuthAndFrameProps {
export const AuthAndFrame: FC<AuthAndFrameProps> = ({ children }) => {
const styles = useStyles()
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const [updateCheckState, updateCheckSend] = useActor(
xServices.updateCheckXService,
)
useEffect(() => {
if (authState.matches("signedIn")) {
updateCheckSend("CHECK")
} else {
updateCheckSend("CLEAR")
}
}, [authState, updateCheckSend])
return (
<RequireAuth>
<div className={styles.site}>
<Navbar />
{updateCheckState.context.show && (
<div className={styles.updateCheckBanner}>
<Margins>
<UpdateCheckBanner
updateCheck={updateCheckState.context.updateCheck}
error={updateCheckState.context.error}
onDismiss={() => updateCheckSend("DISMISS")}
/>
</Margins>
</div>
)}
<div className={styles.siteContent}>
<Suspense fallback={<Loader />}>{children}</Suspense>
</div>
@ -32,12 +57,20 @@ export const AuthAndFrame: FC<AuthAndFrameProps> = ({ children }) => {
)
}
const useStyles = makeStyles(() => ({
const useStyles = makeStyles((theme) => ({
site: {
display: "flex",
minHeight: "100vh",
flexDirection: "column",
},
updateCheckBanner: {
// Add spacing at the top and remove some from the bottom. Removal
// is necessary to avoid a visual jerk when the banner is dismissed.
// It also give a more pleasant distance to the site content when
// the banner is visible.
marginTop: theme.spacing(2),
marginBottom: -theme.spacing(2),
},
siteContent: {
flex: 1,
},

View File

@ -0,0 +1,25 @@
import { ComponentMeta, Story } from "@storybook/react"
import { UpdateCheckBanner, UpdateCheckBannerProps } from "./UpdateCheckBanner"
export default {
title: "components/UpdateCheckBanner",
component: UpdateCheckBanner,
} as ComponentMeta<typeof UpdateCheckBanner>
const Template: Story<UpdateCheckBannerProps> = (args) => (
<UpdateCheckBanner {...args} />
)
export const UpdateAvailable = Template.bind({})
UpdateAvailable.args = {
updateCheck: {
current: false,
version: "v0.12.9",
url: "https://github.com/coder/coder/releases/tag/v0.12.9",
},
}
export const UpdateCheckError = Template.bind({})
UpdateCheckError.args = {
error: new Error("Something went wrong."),
}

View File

@ -0,0 +1,51 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import i18next from "i18next"
import { MockUpdateCheck, render } from "testHelpers/renderHelpers"
import { UpdateCheckBanner } from "./UpdateCheckBanner"
describe("UpdateCheckBanner", () => {
it("shows an update notification when one is available", () => {
const { t } = i18next
render(
<UpdateCheckBanner
updateCheck={{ ...MockUpdateCheck, current: false }}
/>,
)
const updateText = t("updateCheck.message", {
ns: "common",
version: MockUpdateCheck.version,
})
// Message contatins HTML elements so we check it in parts.
for (const text of updateText.split(/<\/?[0-9]+>/)) {
expect(screen.getByText(text, { exact: false })).toBeInTheDocument()
}
expect(screen.getAllByRole("link")[0]).toHaveAttribute(
"href",
MockUpdateCheck.url,
)
})
it("is hidden when dismissed", async () => {
const dismiss = jest.fn()
const { container } = render(
<UpdateCheckBanner
onDismiss={dismiss}
updateCheck={{ ...MockUpdateCheck, current: false }}
/>,
)
fireEvent.click(screen.getByRole("button"))
await waitFor(() => expect(dismiss).toBeCalledTimes(1), { timeout: 2000 })
expect(container.firstChild).toBeNull()
})
it("does not show when up-to-date", async () => {
const { container } = render(
<UpdateCheckBanner updateCheck={{ ...MockUpdateCheck, current: true }} />,
)
expect(container.firstChild).toBeNull()
})
})

View File

@ -0,0 +1,59 @@
import Link from "@material-ui/core/Link"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Trans, useTranslation } from "react-i18next"
import * as TypesGen from "api/typesGenerated"
import { FC, useState } from "react"
export interface UpdateCheckBannerProps {
updateCheck?: TypesGen.UpdateCheckResponse
error?: Error | unknown
onDismiss?: () => void
}
export const UpdateCheckBanner: FC<
React.PropsWithChildren<UpdateCheckBannerProps>
> = ({ updateCheck, error, onDismiss }) => {
const { t } = useTranslation("common")
const isOutdated = updateCheck && !updateCheck.current
const [show, setShow] = useState(error || isOutdated)
const dismiss = () => {
onDismiss && onDismiss()
setShow(false)
}
return (
<>
{show && (
<AlertBanner
severity={error ? "error" : "info"}
error={error}
onDismiss={dismiss}
dismissible
>
<>
{error && <>{t("updateCheck.error")} </>}
{isOutdated && (
<div>
<Trans
t={t}
i18nKey="updateCheck.message"
values={{ version: updateCheck.version }}
>
Coder {"{{version}}"} is now available. View the{" "}
<Link href={updateCheck.url}>release notes</Link> and{" "}
<Link href="https://coder.com/docs/coder-oss/latest/admin/upgrade">
upgrade instructions
</Link>{" "}
for more information.
</Trans>
</div>
)}
</>
</AlertBanner>
)}
</>
)
}

View File

@ -35,5 +35,9 @@
},
"emojiPicker": {
"select": "Select emoji"
},
"updateCheck": {
"message": "Coder {{version}} is now available. View the <4>release notes</4> and <7>upgrade instructions</7> for more information.",
"error": "Coder update check failed."
}
}

View File

@ -24,6 +24,12 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = {
version: "v99.999.9999+c9cdf14",
}
export const MockUpdateCheck: TypesGen.UpdateCheckResponse = {
current: true,
url: "file:///mock-url",
version: "v99.999.9999+c9cdf14",
}
export const MockOwnerRole: TypesGen.Role = {
name: "owner",
display_name: "Owner",

View File

@ -15,6 +15,11 @@ export const handlers = [
return res(ctx.status(200), ctx.json(M.MockBuildInfo))
}),
// update check
rest.get("/api/v2/updatecheck", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockUpdateCheck))
}),
// organizations
rest.get("/api/v2/organizations/:organizationId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockOrganization))

View File

@ -3,6 +3,7 @@ import { createContext, FC, ReactNode } from "react"
import { ActorRefFrom } from "xstate"
import { authMachine } from "./auth/authXService"
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
import { updateCheckMachine } from "./updateCheck/updateCheckXService"
import { deploymentConfigMachine } from "./deploymentConfig/deploymentConfigMachine"
import { entitlementsMachine } from "./entitlements/entitlementsXService"
import { siteRolesMachine } from "./roles/siteRolesXService"
@ -14,6 +15,7 @@ interface XServiceContextType {
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
// Since the info here is used by multiple deployment settings page and we don't want to refetch them every time
deploymentConfigXService: ActorRefFrom<typeof deploymentConfigMachine>
updateCheckXService: ActorRefFrom<typeof updateCheckMachine>
}
/**
@ -35,6 +37,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
entitlementsXService: useInterpret(entitlementsMachine),
siteRolesXService: useInterpret(siteRolesMachine),
deploymentConfigXService: useInterpret(deploymentConfigMachine),
updateCheckXService: useInterpret(updateCheckMachine),
}}
>
{children}

View File

@ -0,0 +1,172 @@
import { assign, createMachine } from "xstate"
import { checkAuthorization, getUpdateCheck } from "api/api"
import { AuthorizationResponse, UpdateCheckResponse } from "api/typesGenerated"
export const checks = {
viewUpdateCheck: "viewUpdateCheck",
}
export const permissionsToCheck = {
[checks.viewUpdateCheck]: {
object: {
resource_type: "update_check",
},
action: "read",
},
}
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>
export interface UpdateCheckContext {
show: boolean
updateCheck?: UpdateCheckResponse
permissions?: Permissions
error?: Error | unknown
}
export type UpdateCheckEvent =
| { type: "CHECK" }
| { type: "CLEAR" }
| { type: "DISMISS" }
export const updateCheckMachine = createMachine(
{
id: "updateCheckState",
predictableActionArguments: true,
tsTypes: {} as import("./updateCheckXService.typegen").Typegen0,
schema: {
context: {} as UpdateCheckContext,
events: {} as UpdateCheckEvent,
services: {} as {
checkPermissions: {
data: AuthorizationResponse
}
getUpdateCheck: {
data: UpdateCheckResponse
}
},
},
context: {
show: false,
},
initial: "idle",
states: {
idle: {
on: {
CHECK: {
target: "fetchingPermissions",
},
},
},
fetchingPermissions: {
invoke: {
src: "checkPermissions",
id: "checkPermissions",
onDone: [
{
actions: ["assignPermissions"],
target: "checkingPermissions",
},
],
onError: [
{
actions: ["assignError"],
target: "show",
},
],
},
},
checkingPermissions: {
always: [
{
target: "fetchingUpdateCheck",
cond: "canViewUpdateCheck",
},
{
target: "dismissOrClear",
cond: "canNotViewUpdateCheck",
},
],
},
fetchingUpdateCheck: {
invoke: {
src: "getUpdateCheck",
id: "getUpdateCheck",
onDone: [
{
actions: ["assignUpdateCheck", "clearError"],
target: "show",
},
],
onError: [
{
actions: ["assignError", "clearUpdateCheck"],
target: "show",
},
],
},
},
show: {
entry: "assignShow",
always: [
{
target: "dismissOrClear",
},
],
},
dismissOrClear: {
on: {
DISMISS: {
actions: ["assignHide"],
target: "dismissed",
},
CLEAR: {
actions: ["clearUpdateCheck", "clearError", "assignHide"],
target: "idle",
},
},
},
dismissed: {
type: "final",
},
},
},
{
services: {
checkPermissions: async () =>
checkAuthorization({ checks: permissionsToCheck }),
getUpdateCheck: getUpdateCheck,
},
actions: {
assignPermissions: assign({
permissions: (_, event) => event.data as Permissions,
}),
assignShow: assign({
show: true,
}),
assignHide: assign({
show: false,
}),
assignUpdateCheck: assign({
updateCheck: (_, event) => event.data,
}),
clearUpdateCheck: assign((context) => ({
...context,
updateCheck: undefined,
})),
assignError: assign({
error: (_, event) => event.data,
}),
clearError: assign((context) => ({
...context,
error: undefined,
})),
},
guards: {
canViewUpdateCheck: (context) =>
context.permissions?.[checks.viewUpdateCheck] || false,
canNotViewUpdateCheck: (context) =>
!context.permissions?.[checks.viewUpdateCheck],
},
},
)