diff --git a/buildinfo/buildinfo.go b/buildinfo/buildinfo.go index 65026d4681..d55385eae7 100644 --- a/buildinfo/buildinfo.go +++ b/buildinfo/buildinfo.go @@ -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. diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 9e1c921631..a28918b562 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -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(), + }, } } diff --git a/cli/deployment/config_test.go b/cli/deployment/config_test.go index ca1ad0eeab..1994da924f 100644 --- a/cli/deployment/config_test.go +++ b/cli/deployment/config_test.go @@ -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", diff --git a/cli/server.go b/cli/server.go index ea231758ab..70e31eaa15 100644 --- a/cli/server.go +++ b/cli/server.go @@ -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, diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 1351da7c89..80627b157f 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -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". diff --git a/coderd/coderd.go b/coderd/coderd.go index 60103ea8a7..4cf2762fe9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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() diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 6e22df4b24..56afc6e103 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -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}, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index e2f67e14a8..65f115ae9c 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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, } } diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index a91e2d1962..5d489d602c 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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) { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2f03e2533e..219c210188 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e7b5459c9c..200c80c1f2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index b975d2f68c..734b631a21 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -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'; diff --git a/coderd/updatecheck.go b/coderd/updatecheck.go new file mode 100644 index 0000000000..a2c08ad879 --- /dev/null +++ b/coderd/updatecheck.go @@ -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, + }) +} diff --git a/coderd/updatecheck/updatecheck.go b/coderd/updatecheck/updatecheck.go new file mode 100644 index 0000000000..132405c6bb --- /dev/null +++ b/coderd/updatecheck/updatecheck.go @@ -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 +} diff --git a/coderd/updatecheck/updatecheck_test.go b/coderd/updatecheck/updatecheck_test.go new file mode 100644 index 0000000000..8e1802b27c --- /dev/null +++ b/coderd/updatecheck/updatecheck_test.go @@ -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) +} diff --git a/coderd/updatecheck_test.go b/coderd/updatecheck_test.go new file mode 100644 index 0000000000..378c40cb0d --- /dev/null +++ b/coderd/updatecheck_test.go @@ -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) + }) + } +} diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index 74e0b58b91..c274eeee90 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -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 { diff --git a/codersdk/updatecheck.go b/codersdk/updatecheck.go new file mode 100644 index 0000000000..21da7ee309 --- /dev/null +++ b/codersdk/updatecheck.go @@ -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) +} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 1ee53a929d..09c8826482 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -376,6 +376,12 @@ export const getBuildInfo = async (): Promise => { return response.data } +export const getUpdateCheck = + async (): Promise => { + const response = await axios.get("/api/v2/updatecheck") + return response.data + } + export const putWorkspaceAutostart = async ( workspaceID: string, autostart: TypesGen.UpdateWorkspaceAutostartRequest, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index df75f2080b..6142d03a36 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -299,6 +299,7 @@ export interface DeploymentConfig { readonly provisioner: ProvisionerConfig readonly api_rate_limit: DeploymentConfigField readonly experimental: DeploymentConfigField + readonly update_check: DeploymentConfigField } // 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[] diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index 10eebd8ed8..b46b60a429 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -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 = (args) => ( + +
+ This is a message with a link +
+
+) + +export const InfoWithChildContent = WithChildren.bind({}) +InfoWithChildContent.args = { + severity: "info", +} diff --git a/site/src/components/AlertBanner/AlertBanner.tsx b/site/src/components/AlertBanner/AlertBanner.tsx index 1f79f2e8d1..d2d1c06bee 100644 --- a/site/src/components/AlertBanner/AlertBanner.tsx +++ b/site/src/components/AlertBanner/AlertBanner.tsx @@ -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 = ({ +export const AlertBanner: FC> = ({ + 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 = ({ const [showDetails, setShowDetails] = useState(false) return ( - + onDismiss && onDismiss()}> = ({ {severityConstants[severity].icon} + {children} {alertMessage} {detail && ( diff --git a/site/src/components/AlertBanner/alertTypes.ts b/site/src/components/AlertBanner/alertTypes.ts index 638f73748d..f400483091 100644 --- a/site/src/components/AlertBanner/alertTypes.ts +++ b/site/src/components/AlertBanner/alertTypes.ts @@ -9,5 +9,6 @@ export interface AlertBannerProps { error?: ApiError | Error | unknown actions?: ReactElement[] dismissible?: boolean + onDismiss?: () => void retry?: () => void } diff --git a/site/src/components/AuthAndFrame/AuthAndFrame.tsx b/site/src/components/AuthAndFrame/AuthAndFrame.tsx index b8b4cbdcd8..e0256915aa 100644 --- a/site/src/components/AuthAndFrame/AuthAndFrame.tsx +++ b/site/src/components/AuthAndFrame/AuthAndFrame.tsx @@ -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 = ({ 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 (
+ {updateCheckState.context.show && ( +
+ + updateCheckSend("DISMISS")} + /> + +
+ )}
}>{children}
@@ -32,12 +57,20 @@ export const AuthAndFrame: FC = ({ 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, }, diff --git a/site/src/components/UpdateCheckBanner/UpdateCheckBanner.stories.tsx b/site/src/components/UpdateCheckBanner/UpdateCheckBanner.stories.tsx new file mode 100644 index 0000000000..dc963c8e11 --- /dev/null +++ b/site/src/components/UpdateCheckBanner/UpdateCheckBanner.stories.tsx @@ -0,0 +1,25 @@ +import { ComponentMeta, Story } from "@storybook/react" +import { UpdateCheckBanner, UpdateCheckBannerProps } from "./UpdateCheckBanner" + +export default { + title: "components/UpdateCheckBanner", + component: UpdateCheckBanner, +} as ComponentMeta + +const Template: Story = (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."), +} diff --git a/site/src/components/UpdateCheckBanner/UpdateCheckBanner.test.tsx b/site/src/components/UpdateCheckBanner/UpdateCheckBanner.test.tsx new file mode 100644 index 0000000000..d376cda381 --- /dev/null +++ b/site/src/components/UpdateCheckBanner/UpdateCheckBanner.test.tsx @@ -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( + , + ) + + 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( + , + ) + + 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( + , + ) + expect(container.firstChild).toBeNull() + }) +}) diff --git a/site/src/components/UpdateCheckBanner/UpdateCheckBanner.tsx b/site/src/components/UpdateCheckBanner/UpdateCheckBanner.tsx new file mode 100644 index 0000000000..88ecce5a15 --- /dev/null +++ b/site/src/components/UpdateCheckBanner/UpdateCheckBanner.tsx @@ -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 +> = ({ 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 && ( + + <> + {error && <>{t("updateCheck.error")} } + {isOutdated && ( +
+ + Coder {"{{version}}"} is now available. View the{" "} + release notes and{" "} + + upgrade instructions + {" "} + for more information. + +
+ )} + +
+ )} + + ) +} diff --git a/site/src/i18n/en/common.json b/site/src/i18n/en/common.json index e2dc165b58..f1b4116b24 100644 --- a/site/src/i18n/en/common.json +++ b/site/src/i18n/en/common.json @@ -35,5 +35,9 @@ }, "emojiPicker": { "select": "Select emoji" + }, + "updateCheck": { + "message": "Coder {{version}} is now available. View the <4>release notes and <7>upgrade instructions for more information.", + "error": "Coder update check failed." } } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index abf89a9982..3ee8993068 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 32382402d6..3c44d2ba94 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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)) diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index 8df5003aff..21a35b3c4d 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -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 // Since the info here is used by multiple deployment settings page and we don't want to refetch them every time deploymentConfigXService: ActorRefFrom + updateCheckXService: ActorRefFrom } /** @@ -35,6 +37,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => { entitlementsXService: useInterpret(entitlementsMachine), siteRolesXService: useInterpret(siteRolesMachine), deploymentConfigXService: useInterpret(deploymentConfigMachine), + updateCheckXService: useInterpret(updateCheckMachine), }} > {children} diff --git a/site/src/xServices/updateCheck/updateCheckXService.ts b/site/src/xServices/updateCheck/updateCheckXService.ts new file mode 100644 index 0000000000..ef5ca67eab --- /dev/null +++ b/site/src/xServices/updateCheck/updateCheckXService.ts @@ -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 + +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], + }, + }, +)