mirror of https://github.com/coder/coder.git
feat: Add support for update checks and notifications (#4810)
Co-authored-by: Kira Pilot <kira@coder.com>
This commit is contained in:
parent
4f1cf6c9d8
commit
d9f2aaf3b4
|
@ -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.
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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".
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -9,5 +9,6 @@ export interface AlertBannerProps {
|
|||
error?: ApiError | Error | unknown
|
||||
actions?: ReactElement[]
|
||||
dismissible?: boolean
|
||||
onDismiss?: () => void
|
||||
retry?: () => void
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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."),
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
)
|
Loading…
Reference in New Issue