diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a1f2553e25..80da058186 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9408,14 +9408,16 @@ const docTemplate = `{ "AccessURL", "Websocket", "Database", - "WorkspaceProxy" + "WorkspaceProxy", + "ProvisionerDaemons" ], "x-enum-varnames": [ "HealthSectionDERP", "HealthSectionAccessURL", "HealthSectionWebsocket", "HealthSectionDatabase", - "HealthSectionWorkspaceProxy" + "HealthSectionWorkspaceProxy", + "HealthSectionProvisionerDaemons" ] }, "codersdk.HealthSettings": { @@ -12957,7 +12959,10 @@ const docTemplate = `{ "EACS03", "EACS04", "EDERP01", - "EDERP02" + "EDERP02", + "EPD01", + "EPD02", + "EPD03" ], "x-enum-varnames": [ "CodeUnknown", @@ -12975,7 +12980,10 @@ const docTemplate = `{ "CodeAccessURLFetch", "CodeAccessURLNotOK", "CodeDERPNodeUsesWebsocket", - "CodeDERPOneNodeUnhealthy" + "CodeDERPOneNodeUnhealthy", + "CodeProvisionerDaemonsNoProvisionerDaemons", + "CodeProvisionerDaemonVersionMismatch", + "CodeProvisionerDaemonAPIMajorVersionDeprecated" ] }, "health.Message": { @@ -13092,6 +13100,32 @@ const docTemplate = `{ } } }, + "healthcheck.ProvisionerDaemonsReport": { + "type": "object", + "properties": { + "dismissed": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "provisioner_daemons": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerDaemon" + } + }, + "severity": { + "$ref": "#/definitions/health.Severity" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/health.Message" + } + } + } + }, "healthcheck.Report": { "type": "object", "properties": { @@ -13119,6 +13153,9 @@ const docTemplate = `{ "description": "Healthy is true if the report returns no errors.\nDeprecated: use ` + "`" + `Severity` + "`" + ` instead", "type": "boolean" }, + "provisioner_daemons": { + "$ref": "#/definitions/healthcheck.ProvisionerDaemonsReport" + }, "severity": { "description": "Severity indicates the status of Coder health.", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d4fe6ffd55..d0d440c152 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8440,13 +8440,21 @@ }, "codersdk.HealthSection": { "type": "string", - "enum": ["DERP", "AccessURL", "Websocket", "Database", "WorkspaceProxy"], + "enum": [ + "DERP", + "AccessURL", + "Websocket", + "Database", + "WorkspaceProxy", + "ProvisionerDaemons" + ], "x-enum-varnames": [ "HealthSectionDERP", "HealthSectionAccessURL", "HealthSectionWebsocket", "HealthSectionDatabase", - "HealthSectionWorkspaceProxy" + "HealthSectionWorkspaceProxy", + "HealthSectionProvisionerDaemons" ] }, "codersdk.HealthSettings": { @@ -11791,7 +11799,10 @@ "EACS03", "EACS04", "EDERP01", - "EDERP02" + "EDERP02", + "EPD01", + "EPD02", + "EPD03" ], "x-enum-varnames": [ "CodeUnknown", @@ -11809,7 +11820,10 @@ "CodeAccessURLFetch", "CodeAccessURLNotOK", "CodeDERPNodeUsesWebsocket", - "CodeDERPOneNodeUnhealthy" + "CodeDERPOneNodeUnhealthy", + "CodeProvisionerDaemonsNoProvisionerDaemons", + "CodeProvisionerDaemonVersionMismatch", + "CodeProvisionerDaemonAPIMajorVersionDeprecated" ] }, "health.Message": { @@ -11910,6 +11924,32 @@ } } }, + "healthcheck.ProvisionerDaemonsReport": { + "type": "object", + "properties": { + "dismissed": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "provisioner_daemons": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerDaemon" + } + }, + "severity": { + "$ref": "#/definitions/health.Severity" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/health.Message" + } + } + } + }, "healthcheck.Report": { "type": "object", "properties": { @@ -11937,6 +11977,9 @@ "description": "Healthy is true if the report returns no errors.\nDeprecated: use `Severity` instead", "type": "boolean" }, + "provisioner_daemons": { + "$ref": "#/definitions/healthcheck.ProvisionerDaemonsReport" + }, "severity": { "description": "Severity indicates the status of Coder health.", "enum": ["ok", "warning", "error"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 7de4e32071..3f16c89cb0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -440,6 +440,12 @@ func New(options *Options) *API { CurrentVersion: buildinfo.Version(), WorkspaceProxiesFetchUpdater: *(options.WorkspaceProxiesFetchUpdater).Load(), }, + ProvisionerDaemons: healthcheck.ProvisionerDaemonsReportDeps{ + CurrentVersion: buildinfo.Version(), + CurrentAPIMajorVersion: provisionersdk.CurrentMajor, + Store: options.Database, + // TimeNow and StaleInterval set to defaults, see healthcheck/provisioner.go + }, }) } } @@ -1188,7 +1194,7 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, name string Tags: provisionersdk.MutateTags(uuid.Nil, nil), LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, Version: buildinfo.Version(), - APIVersion: provisionersdk.APIVersionCurrent, + APIVersion: provisionersdk.VersionCurrent.String(), }) if err != nil { return nil, xerrors.Errorf("failed to create in-memory provisioner daemon: %w", err) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 329f593ba9..6707b72a89 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -416,3 +416,19 @@ func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerNa } return apps } + +func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon { + result := codersdk.ProvisionerDaemon{ + ID: dbDaemon.ID, + CreatedAt: dbDaemon.CreatedAt, + LastSeenAt: codersdk.NullTime{NullTime: dbDaemon.LastSeenAt}, + Name: dbDaemon.Name, + Tags: dbDaemon.Tags, + Version: dbDaemon.Version, + APIVersion: dbDaemon.APIVersion, + } + for _, provisionerType := range dbDaemon.Provisioners { + result.Provisioners = append(result.Provisioners, codersdk.ProvisionerType(provisionerType)) + } + return result +} diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 8fe9953d6a..c244bca5d4 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -218,7 +218,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { CreatedAt: now.Add(-14 * 24 * time.Hour), LastSeenAt: sql.NullTime{Valid: true, Time: now.Add(-7 * 24 * time.Hour).Add(time.Minute)}, Version: "1.0.0", - APIVersion: provisionersdk.APIVersionCurrent, + APIVersion: provisionersdk.VersionCurrent.String(), }) require.NoError(t, err) _, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ @@ -229,7 +229,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { CreatedAt: now.Add(-8 * 24 * time.Hour), LastSeenAt: sql.NullTime{Valid: true, Time: now.Add(-8 * 24 * time.Hour).Add(time.Hour)}, Version: "1.0.0", - APIVersion: provisionersdk.APIVersionCurrent, + APIVersion: provisionersdk.VersionCurrent.String(), }) require.NoError(t, err) _, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ @@ -242,7 +242,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { }, CreatedAt: now.Add(-9 * 24 * time.Hour), Version: "1.0.0", - APIVersion: provisionersdk.APIVersionCurrent, + APIVersion: provisionersdk.VersionCurrent.String(), }) require.NoError(t, err) _, err = db.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ @@ -256,7 +256,7 @@ func TestDeleteOldProvisionerDaemons(t *testing.T) { CreatedAt: now.Add(-6 * 24 * time.Hour), LastSeenAt: sql.NullTime{Valid: true, Time: now.Add(-6 * 24 * time.Hour)}, Version: "1.0.0", - APIVersion: provisionersdk.APIVersionCurrent, + APIVersion: provisionersdk.VersionCurrent.String(), }) require.NoError(t, err) diff --git a/coderd/healthcheck/health/model.go b/coderd/healthcheck/health/model.go index 707969e404..9eae390aa0 100644 --- a/coderd/healthcheck/health/model.go +++ b/coderd/healthcheck/health/model.go @@ -34,6 +34,10 @@ const ( CodeDERPNodeUsesWebsocket Code = `EDERP01` CodeDERPOneNodeUnhealthy Code = `EDERP02` + + CodeProvisionerDaemonsNoProvisionerDaemons Code = `EPD01` + CodeProvisionerDaemonVersionMismatch Code = `EPD02` + CodeProvisionerDaemonAPIMajorVersionDeprecated Code = `EPD03` ) // @typescript-generate Severity diff --git a/coderd/healthcheck/healthcheck.go b/coderd/healthcheck/healthcheck.go index 7c63420123..1d1890ba23 100644 --- a/coderd/healthcheck/healthcheck.go +++ b/coderd/healthcheck/healthcheck.go @@ -18,6 +18,7 @@ type Checker interface { Websocket(ctx context.Context, opts *WebsocketReportOptions) WebsocketReport Database(ctx context.Context, opts *DatabaseReportOptions) DatabaseReport WorkspaceProxy(ctx context.Context, opts *WorkspaceProxyReportOptions) WorkspaceProxyReport + ProvisionerDaemons(ctx context.Context, opts *ProvisionerDaemonsReportDeps) ProvisionerDaemonsReport } // @typescript-generate Report @@ -32,49 +33,62 @@ type Report struct { // FailingSections is a list of sections that have failed their healthcheck. FailingSections []codersdk.HealthSection `json:"failing_sections"` - DERP derphealth.Report `json:"derp"` - AccessURL AccessURLReport `json:"access_url"` - Websocket WebsocketReport `json:"websocket"` - Database DatabaseReport `json:"database"` - WorkspaceProxy WorkspaceProxyReport `json:"workspace_proxy"` + DERP derphealth.Report `json:"derp"` + AccessURL AccessURLReport `json:"access_url"` + Websocket WebsocketReport `json:"websocket"` + Database DatabaseReport `json:"database"` + WorkspaceProxy WorkspaceProxyReport `json:"workspace_proxy"` + ProvisionerDaemons ProvisionerDaemonsReport `json:"provisioner_daemons"` // The Coder version of the server that the report was generated on. CoderVersion string `json:"coder_version"` } type ReportOptions struct { - AccessURL AccessURLReportOptions - Database DatabaseReportOptions - DerpHealth derphealth.ReportOptions - Websocket WebsocketReportOptions - WorkspaceProxy WorkspaceProxyReportOptions + AccessURL AccessURLReportOptions + Database DatabaseReportOptions + DerpHealth derphealth.ReportOptions + Websocket WebsocketReportOptions + WorkspaceProxy WorkspaceProxyReportOptions + ProvisionerDaemons ProvisionerDaemonsReportDeps Checker Checker } type defaultChecker struct{} -func (defaultChecker) DERP(ctx context.Context, opts *derphealth.ReportOptions) (report derphealth.Report) { +func (defaultChecker) DERP(ctx context.Context, opts *derphealth.ReportOptions) derphealth.Report { + var report derphealth.Report report.Run(ctx, opts) return report } -func (defaultChecker) AccessURL(ctx context.Context, opts *AccessURLReportOptions) (report AccessURLReport) { +func (defaultChecker) AccessURL(ctx context.Context, opts *AccessURLReportOptions) AccessURLReport { + var report AccessURLReport report.Run(ctx, opts) return report } -func (defaultChecker) Websocket(ctx context.Context, opts *WebsocketReportOptions) (report WebsocketReport) { +func (defaultChecker) Websocket(ctx context.Context, opts *WebsocketReportOptions) WebsocketReport { + var report WebsocketReport report.Run(ctx, opts) return report } -func (defaultChecker) Database(ctx context.Context, opts *DatabaseReportOptions) (report DatabaseReport) { +func (defaultChecker) Database(ctx context.Context, opts *DatabaseReportOptions) DatabaseReport { + var report DatabaseReport report.Run(ctx, opts) return report } -func (defaultChecker) WorkspaceProxy(ctx context.Context, opts *WorkspaceProxyReportOptions) (report WorkspaceProxyReport) { +func (defaultChecker) WorkspaceProxy(ctx context.Context, opts *WorkspaceProxyReportOptions) WorkspaceProxyReport { + var report WorkspaceProxyReport + report.Run(ctx, opts) + return report +} + +func (defaultChecker) ProvisionerDaemons(ctx context.Context, opts *ProvisionerDaemonsReportDeps) ProvisionerDaemonsReport { + var report ProvisionerDaemonsReport report.Run(ctx, opts) return report } @@ -149,26 +163,41 @@ func Run(ctx context.Context, opts *ReportOptions) *Report { report.WorkspaceProxy = opts.Checker.WorkspaceProxy(ctx, &opts.WorkspaceProxy) }() + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + report.ProvisionerDaemons.Error = health.Errorf(health.CodeUnknown, "provisioner daemon report panic: %s", err) + } + }() + + report.ProvisionerDaemons = opts.Checker.ProvisionerDaemons(ctx, &opts.ProvisionerDaemons) + }() + report.CoderVersion = buildinfo.Version() wg.Wait() report.Time = time.Now() report.FailingSections = []codersdk.HealthSection{} - if !report.DERP.Healthy { + if report.DERP.Severity.Value() > health.SeverityWarning.Value() { report.FailingSections = append(report.FailingSections, codersdk.HealthSectionDERP) } - if !report.AccessURL.Healthy { + if report.AccessURL.Severity.Value() > health.SeverityOK.Value() { report.FailingSections = append(report.FailingSections, codersdk.HealthSectionAccessURL) } - if !report.Websocket.Healthy { + if report.Websocket.Severity.Value() > health.SeverityWarning.Value() { report.FailingSections = append(report.FailingSections, codersdk.HealthSectionWebsocket) } - if !report.Database.Healthy { + if report.Database.Severity.Value() > health.SeverityWarning.Value() { report.FailingSections = append(report.FailingSections, codersdk.HealthSectionDatabase) } - if !report.WorkspaceProxy.Healthy { + if report.WorkspaceProxy.Severity.Value() > health.SeverityWarning.Value() { report.FailingSections = append(report.FailingSections, codersdk.HealthSectionWorkspaceProxy) } + if report.ProvisionerDaemons.Severity.Value() > health.SeverityWarning.Value() { + report.FailingSections = append(report.FailingSections, codersdk.HealthSectionProvisionerDaemons) + } report.Healthy = len(report.FailingSections) == 0 @@ -190,6 +219,9 @@ func Run(ctx context.Context, opts *ReportOptions) *Report { if report.WorkspaceProxy.Severity.Value() > report.Severity.Value() { report.Severity = report.WorkspaceProxy.Severity } + if report.ProvisionerDaemons.Severity.Value() > report.Severity.Value() { + report.Severity = report.ProvisionerDaemons.Severity + } return &report } diff --git a/coderd/healthcheck/healthcheck_test.go b/coderd/healthcheck/healthcheck_test.go index e8089f36eb..1dc155623a 100644 --- a/coderd/healthcheck/healthcheck_test.go +++ b/coderd/healthcheck/healthcheck_test.go @@ -13,11 +13,12 @@ import ( ) type testChecker struct { - DERPReport derphealth.Report - AccessURLReport healthcheck.AccessURLReport - WebsocketReport healthcheck.WebsocketReport - DatabaseReport healthcheck.DatabaseReport - WorkspaceProxyReport healthcheck.WorkspaceProxyReport + DERPReport derphealth.Report + AccessURLReport healthcheck.AccessURLReport + WebsocketReport healthcheck.WebsocketReport + DatabaseReport healthcheck.DatabaseReport + WorkspaceProxyReport healthcheck.WorkspaceProxyReport + ProvisionerDaemonsReport healthcheck.ProvisionerDaemonsReport } func (c *testChecker) DERP(context.Context, *derphealth.ReportOptions) derphealth.Report { @@ -40,6 +41,10 @@ func (c *testChecker) WorkspaceProxy(context.Context, *healthcheck.WorkspaceProx return c.WorkspaceProxyReport } +func (c *testChecker) ProvisionerDaemons(context.Context, *healthcheck.ProvisionerDaemonsReportDeps) healthcheck.ProvisionerDaemonsReport { + return c.ProvisionerDaemonsReport +} + func TestHealthcheck(t *testing.T) { t.Parallel() @@ -72,6 +77,9 @@ func TestHealthcheck(t *testing.T) { Healthy: true, Severity: health.SeverityOK, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, }, healthy: true, severity: health.SeverityOK, @@ -99,6 +107,9 @@ func TestHealthcheck(t *testing.T) { Healthy: true, Severity: health.SeverityOK, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, }, healthy: false, severity: health.SeverityError, @@ -127,6 +138,9 @@ func TestHealthcheck(t *testing.T) { Healthy: true, Severity: health.SeverityOK, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, }, healthy: true, severity: health.SeverityWarning, @@ -154,6 +168,9 @@ func TestHealthcheck(t *testing.T) { Healthy: true, Severity: health.SeverityOK, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, }, healthy: false, severity: health.SeverityWarning, @@ -181,6 +198,9 @@ func TestHealthcheck(t *testing.T) { Healthy: true, Severity: health.SeverityOK, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, }, healthy: false, severity: health.SeverityError, @@ -208,6 +228,9 @@ func TestHealthcheck(t *testing.T) { Healthy: true, Severity: health.SeverityOK, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, }, healthy: false, severity: health.SeverityError, @@ -235,6 +258,9 @@ func TestHealthcheck(t *testing.T) { Healthy: false, Severity: health.SeverityError, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, }, severity: health.SeverityError, healthy: false, @@ -263,6 +289,70 @@ func TestHealthcheck(t *testing.T) { Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}}, Severity: health.SeverityWarning, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityOK, + }, + }, + severity: health.SeverityWarning, + healthy: true, + failingSections: []codersdk.HealthSection{}, + }, { + name: "ProvisionerDaemonsFail", + checker: &testChecker{ + DERPReport: derphealth.Report{ + Healthy: true, + Severity: health.SeverityOK, + }, + AccessURLReport: healthcheck.AccessURLReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + WebsocketReport: healthcheck.WebsocketReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + DatabaseReport: healthcheck.DatabaseReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + WorkspaceProxyReport: healthcheck.WorkspaceProxyReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityError, + }, + }, + severity: health.SeverityError, + healthy: false, + failingSections: []codersdk.HealthSection{codersdk.HealthSectionProvisionerDaemons}, + }, { + name: "ProvisionerDaemonsWarn", + checker: &testChecker{ + DERPReport: derphealth.Report{ + Healthy: true, + Severity: health.SeverityOK, + }, + AccessURLReport: healthcheck.AccessURLReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + WebsocketReport: healthcheck.WebsocketReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + DatabaseReport: healthcheck.DatabaseReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + WorkspaceProxyReport: healthcheck.WorkspaceProxyReport{ + Healthy: true, + Severity: health.SeverityOK, + }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityWarning, + Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}}, + }, }, severity: health.SeverityWarning, healthy: true, @@ -291,6 +381,9 @@ func TestHealthcheck(t *testing.T) { Healthy: false, Severity: health.SeverityError, }, + ProvisionerDaemonsReport: healthcheck.ProvisionerDaemonsReport{ + Severity: health.SeverityError, + }, }, severity: health.SeverityError, failingSections: []codersdk.HealthSection{ @@ -299,6 +392,7 @@ func TestHealthcheck(t *testing.T) { codersdk.HealthSectionWebsocket, codersdk.HealthSectionDatabase, codersdk.HealthSectionWorkspaceProxy, + codersdk.HealthSectionProvisionerDaemons, }, }} { c := c diff --git a/coderd/healthcheck/provisioner.go b/coderd/healthcheck/provisioner.go new file mode 100644 index 0000000000..bbbd9d2bed --- /dev/null +++ b/coderd/healthcheck/provisioner.go @@ -0,0 +1,136 @@ +package healthcheck + +import ( + "context" + "time" + + "golang.org/x/mod/semver" + + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/healthcheck/health" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/util/apiversion" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" +) + +// @typescript-generate ProvisionerDaemonsReport +type ProvisionerDaemonsReport struct { + Severity health.Severity `json:"severity"` + Warnings []health.Message `json:"warnings"` + Dismissed bool `json:"dismissed"` + Error *string `json:"error"` + + ProvisionerDaemons []codersdk.ProvisionerDaemon `json:"provisioner_daemons"` +} + +type ProvisionerDaemonsReportDeps struct { + // Required + CurrentVersion string + CurrentAPIMajorVersion int + Store ProvisionerDaemonsStore + + // Optional + TimeNow func() time.Time // Defaults to dbtime.Now + StaleInterval time.Duration // Defaults to 3 heartbeats + + Dismissed bool +} + +type ProvisionerDaemonsStore interface { + GetProvisionerDaemons(ctx context.Context) ([]database.ProvisionerDaemon, error) +} + +func (r *ProvisionerDaemonsReport) Run(ctx context.Context, opts *ProvisionerDaemonsReportDeps) { + r.ProvisionerDaemons = make([]codersdk.ProvisionerDaemon, 0) + r.Severity = health.SeverityOK + r.Warnings = make([]health.Message, 0) + r.Dismissed = opts.Dismissed + + if opts.TimeNow == nil { + opts.TimeNow = dbtime.Now + } + now := opts.TimeNow() + + if opts.StaleInterval == 0 { + opts.StaleInterval = provisionerdserver.DefaultHeartbeatInterval * 3 + } + + if opts.CurrentVersion == "" { + r.Severity = health.SeverityError + r.Error = ptr.Ref("Developer error: CurrentVersion is empty!") + return + } + + if opts.CurrentAPIMajorVersion == 0 { + r.Severity = health.SeverityError + r.Error = ptr.Ref("Developer error: CurrentAPIMajorVersion must be non-zero!") + return + } + + if opts.Store == nil { + r.Severity = health.SeverityError + r.Error = ptr.Ref("Developer error: Store is nil!") + return + } + + // nolint: gocritic // need an actor to fetch provisioner daemons + daemons, err := opts.Store.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + if err != nil { + r.Severity = health.SeverityError + r.Error = ptr.Ref("error fetching provisioner daemons: " + err.Error()) + return + } + for _, daemon := range daemons { + // Daemon never connected, skip. + if !daemon.LastSeenAt.Valid { + continue + } + // Daemon has gone away, skip. + if now.Sub(daemon.LastSeenAt.Time) > (opts.StaleInterval) { + continue + } + + r.ProvisionerDaemons = append(r.ProvisionerDaemons, db2sdk.ProvisionerDaemon(daemon)) + + // For release versions, just check MAJOR.MINOR and ignore patch. + if !semver.IsValid(daemon.Version) { + if r.Severity.Value() < health.SeverityError.Value() { + r.Severity = health.SeverityError + } + r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Provisioner daemon %q reports invalid version %q", opts.CurrentVersion, daemon.Version)) + } else if !buildinfo.VersionsMatch(opts.CurrentVersion, daemon.Version) { + if r.Severity.Value() < health.SeverityWarning.Value() { + r.Severity = health.SeverityWarning + } + r.Warnings = append(r.Warnings, health.Messagef(health.CodeProvisionerDaemonVersionMismatch, "Provisioner daemon %q has outdated version %q", daemon.Name, daemon.Version)) + } + + // Provisioner daemon API version follows different rules; we just want to check the major API version and + // warn about potential later deprecations. + // When we check API versions of connecting provisioner daemons, all active provisioner daemons + // will, by necessity, have a compatible API version. + if maj, _, err := apiversion.Parse(daemon.APIVersion); err != nil { + if r.Severity.Value() < health.SeverityError.Value() { + r.Severity = health.SeverityError + } + r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Provisioner daemon %q reports invalid API version: %s", daemon.Name, err.Error())) + } else if maj != opts.CurrentAPIMajorVersion { + if r.Severity.Value() < health.SeverityWarning.Value() { + r.Severity = health.SeverityWarning + } + r.Warnings = append(r.Warnings, health.Messagef(health.CodeProvisionerDaemonAPIMajorVersionDeprecated, "Provisioner daemon %q reports deprecated major API version %d. Consider upgrading!", daemon.Name, provisionersdk.CurrentMajor)) + } + } + + if len(r.ProvisionerDaemons) == 0 { + r.Severity = health.SeverityError + r.Error = ptr.Ref("No active provisioner daemons found!") + return + } +} diff --git a/coderd/healthcheck/provisioner_test.go b/coderd/healthcheck/provisioner_test.go new file mode 100644 index 0000000000..27c5293d70 --- /dev/null +++ b/coderd/healthcheck/provisioner_test.go @@ -0,0 +1,191 @@ +package healthcheck_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/coderd/healthcheck/health" + "github.com/coder/coder/v2/provisionersdk" + + gomock "go.uber.org/mock/gomock" +) + +func TestProvisionerDaemonReport(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + currentVersion string + currentAPIMajorVersion int + provisionerDaemons []database.ProvisionerDaemon + provisionerDaemonsErr error + expectedSeverity health.Severity + expectedWarningCode health.Code + expectedError string + }{ + { + name: "current version empty", + currentVersion: "", + expectedSeverity: health.SeverityError, + expectedError: "Developer error: CurrentVersion is empty", + }, + { + name: "no daemons", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityError, + expectedError: "No active provisioner daemons found!", + }, + { + name: "error fetching daemons", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + provisionerDaemonsErr: assert.AnError, + expectedSeverity: health.SeverityError, + expectedError: assert.AnError.Error(), + }, + { + name: "one daemon up to date", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityOK, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "1.0")}, + }, + { + name: "one daemon out of date", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityWarning, + expectedWarningCode: health.CodeProvisionerDaemonVersionMismatch, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-old", "v1.1.2", "1.0")}, + }, + { + name: "invalid daemon version", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityError, + expectedWarningCode: health.CodeUnknown, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-invalid-version", "invalid", "1.0")}, + }, + { + name: "invalid daemon api version", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityError, + expectedWarningCode: health.CodeUnknown, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-new-minor", "v1.2.3", "invalid")}, + }, + { + name: "api version backward compat", + currentVersion: "v2.3.4", + currentAPIMajorVersion: 2, + expectedSeverity: health.SeverityWarning, + expectedWarningCode: health.CodeProvisionerDaemonAPIMajorVersionDeprecated, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-old-api", "v2.3.4", "1.0")}, + }, + { + name: "one up to date, one out of date", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityWarning, + expectedWarningCode: health.CodeProvisionerDaemonVersionMismatch, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "1.0"), fakeProvisionerDaemon(t, "pd-old", "v1.1.2", "1.0")}, + }, + { + name: "one up to date, one newer", + currentVersion: "v1.2.3", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityWarning, + expectedWarningCode: health.CodeProvisionerDaemonVersionMismatch, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "1.0"), fakeProvisionerDaemon(t, "pd-new", "v2.3.4", "1.0")}, + }, + { + name: "one up to date, one stale older", + currentVersion: "v2.3.4", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityOK, + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemonStale(t, "pd-ok", "v1.2.3", "0.9", dbtime.Now().Add(-5*time.Minute)), fakeProvisionerDaemon(t, "pd-new", "v2.3.4", "1.0")}, + }, + { + name: "one stale", + currentVersion: "v2.3.4", + currentAPIMajorVersion: provisionersdk.CurrentMajor, + expectedSeverity: health.SeverityError, + expectedError: "No active provisioner daemons found!", + provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemonStale(t, "pd-ok", "v1.2.3", "0.9", dbtime.Now().Add(-5*time.Minute))}, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var rpt healthcheck.ProvisionerDaemonsReport + var deps healthcheck.ProvisionerDaemonsReportDeps + deps.CurrentVersion = tt.currentVersion + deps.CurrentAPIMajorVersion = tt.currentAPIMajorVersion + if tt.currentAPIMajorVersion == 0 { + deps.CurrentAPIMajorVersion = provisionersdk.CurrentMajor + } + now := dbtime.Now() + deps.TimeNow = func() time.Time { + return now + } + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + mDB.EXPECT().GetProvisionerDaemons(gomock.Any()).AnyTimes().Return(tt.provisionerDaemons, tt.provisionerDaemonsErr) + deps.Store = mDB + + rpt.Run(context.Background(), &deps) + + assert.Equal(t, tt.expectedSeverity, rpt.Severity) + if tt.expectedWarningCode != "" && assert.NotEmpty(t, rpt.Warnings) { + var found bool + for _, w := range rpt.Warnings { + if w.Code == tt.expectedWarningCode { + found = true + break + } + } + assert.True(t, found, "expected warning %s not found in %v", tt.expectedWarningCode, rpt.Warnings) + } else { + assert.Empty(t, rpt.Warnings) + } + if tt.expectedError != "" && assert.NotNil(t, rpt.Error) { + assert.Contains(t, *rpt.Error, tt.expectedError) + } + }) + } +} + +func fakeProvisionerDaemon(t *testing.T, name, version, apiVersion string) database.ProvisionerDaemon { + t.Helper() + return database.ProvisionerDaemon{ + ID: uuid.New(), + Name: name, + CreatedAt: dbtime.Now(), + LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho, database.ProvisionerTypeTerraform}, + ReplicaID: uuid.NullUUID{}, + Tags: map[string]string{}, + Version: version, + APIVersion: apiVersion, + } +} + +func fakeProvisionerDaemonStale(t *testing.T, name, version, apiVersion string, lastSeenAt time.Time) database.ProvisionerDaemon { + t.Helper() + d := fakeProvisionerDaemon(t, name, version, apiVersion) + d.LastSeenAt.Valid = true + d.LastSeenAt.Time = lastSeenAt + return d +} diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index d89ade60b6..915b50a31d 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -1786,7 +1786,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi Tags: database.StringMap{}, LastSeenAt: sql.NullTime{}, Version: buildinfo.Version(), - APIVersion: provisionersdk.APIVersionCurrent, + APIVersion: provisionersdk.VersionCurrent.String(), }) require.NoError(t, err) diff --git a/coderd/util/apiversion/apiversion.go b/coderd/util/apiversion/apiversion.go index f9a1d0d539..7decaeab32 100644 --- a/coderd/util/apiversion/apiversion.go +++ b/coderd/util/apiversion/apiversion.go @@ -1,6 +1,7 @@ package apiversion import ( + "fmt" "strconv" "strings" @@ -41,6 +42,10 @@ func (v *APIVersion) WithBackwardCompat(majs ...int) *APIVersion { // - 1.x is supported, // - 2.0, 2.1, and 2.2 are supported, // - 2.3+ is not supported. +func (v *APIVersion) String() string { + return fmt.Sprintf("%d.%d", v.supportedMajor, v.supportedMinor) +} + func (v *APIVersion) Validate(version string) error { major, minor, err := Parse(version) if err != nil { diff --git a/codersdk/health.go b/codersdk/health.go index 495ce8bb8e..a53ca73192 100644 --- a/codersdk/health.go +++ b/codersdk/health.go @@ -12,11 +12,12 @@ type HealthSection string // If you add another const below, make sure to add it to HealthSections! const ( - HealthSectionDERP HealthSection = "DERP" - HealthSectionAccessURL HealthSection = "AccessURL" - HealthSectionWebsocket HealthSection = "Websocket" - HealthSectionDatabase HealthSection = "Database" - HealthSectionWorkspaceProxy HealthSection = "WorkspaceProxy" + HealthSectionDERP HealthSection = "DERP" + HealthSectionAccessURL HealthSection = "AccessURL" + HealthSectionWebsocket HealthSection = "Websocket" + HealthSectionDatabase HealthSection = "Database" + HealthSectionWorkspaceProxy HealthSection = "WorkspaceProxy" + HealthSectionProvisionerDaemons HealthSection = "ProvisionerDaemons" ) var HealthSections = []HealthSection{ diff --git a/docs/api/debug.md b/docs/api/debug.md index 8ea63c39a3..3668a886c3 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -282,6 +282,32 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ }, "failing_sections": ["DERP"], "healthy": true, + "provisioner_daemons": { + "dismissed": true, + "error": "string", + "provisioner_daemons": [ + { + "api_version": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "name": "string", + "provisioners": ["string"], + "tags": { + "property1": "string", + "property2": "string" + }, + "version": "string" + } + ], + "severity": "ok", + "warnings": [ + { + "code": "EUNKNOWN", + "message": "string" + } + ] + }, "severity": "ok", "time": "string", "websocket": { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c8ccc1fba5..8b653c7286 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3220,13 +3220,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| ---------------- | -| `DERP` | -| `AccessURL` | -| `Websocket` | -| `Database` | -| `WorkspaceProxy` | +| Value | +| -------------------- | +| `DERP` | +| `AccessURL` | +| `Websocket` | +| `Database` | +| `WorkspaceProxy` | +| `ProvisionerDaemons` | ## codersdk.HealthSettings @@ -7771,6 +7772,9 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `EACS04` | | `EDERP01` | | `EDERP02` | +| `EPD01` | +| `EPD02` | +| `EPD03` | ## health.Message @@ -7890,6 +7894,47 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `severity` | `warning` | | `severity` | `error` | +## healthcheck.ProvisionerDaemonsReport + +```json +{ + "dismissed": true, + "error": "string", + "provisioner_daemons": [ + { + "api_version": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "name": "string", + "provisioners": ["string"], + "tags": { + "property1": "string", + "property2": "string" + }, + "version": "string" + } + ], + "severity": "ok", + "warnings": [ + { + "code": "EUNKNOWN", + "message": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------------------- | ----------------------------------------------------------------- | -------- | ------------ | ----------- | +| `dismissed` | boolean | false | | | +| `error` | string | false | | | +| `provisioner_daemons` | array of [codersdk.ProvisionerDaemon](#codersdkprovisionerdaemon) | false | | | +| `severity` | [health.Severity](#healthseverity) | false | | | +| `warnings` | array of [health.Message](#healthmessage) | false | | | + ## healthcheck.Report ```json @@ -8131,6 +8176,32 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "failing_sections": ["DERP"], "healthy": true, + "provisioner_daemons": { + "dismissed": true, + "error": "string", + "provisioner_daemons": [ + { + "api_version": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "name": "string", + "provisioners": ["string"], + "tags": { + "property1": "string", + "property2": "string" + }, + "version": "string" + } + ], + "severity": "ok", + "warnings": [ + { + "code": "EUNKNOWN", + "message": "string" + } + ] + }, "severity": "ok", "time": "string", "websocket": { @@ -8186,18 +8257,19 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | -------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------- | -| `access_url` | [healthcheck.AccessURLReport](#healthcheckaccessurlreport) | false | | | -| `coder_version` | string | false | | The Coder version of the server that the report was generated on. | -| `database` | [healthcheck.DatabaseReport](#healthcheckdatabasereport) | false | | | -| `derp` | [derphealth.Report](#derphealthreport) | false | | | -| `failing_sections` | array of [codersdk.HealthSection](#codersdkhealthsection) | false | | Failing sections is a list of sections that have failed their healthcheck. | -| `healthy` | boolean | false | | Healthy is true if the report returns no errors. Deprecated: use `Severity` instead | -| `severity` | [health.Severity](#healthseverity) | false | | Severity indicates the status of Coder health. | -| `time` | string | false | | Time is the time the report was generated at. | -| `websocket` | [healthcheck.WebsocketReport](#healthcheckwebsocketreport) | false | | | -| `workspace_proxy` | [healthcheck.WorkspaceProxyReport](#healthcheckworkspaceproxyreport) | false | | | +| Name | Type | Required | Restrictions | Description | +| --------------------- | ---------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------- | +| `access_url` | [healthcheck.AccessURLReport](#healthcheckaccessurlreport) | false | | | +| `coder_version` | string | false | | The Coder version of the server that the report was generated on. | +| `database` | [healthcheck.DatabaseReport](#healthcheckdatabasereport) | false | | | +| `derp` | [derphealth.Report](#derphealthreport) | false | | | +| `failing_sections` | array of [codersdk.HealthSection](#codersdkhealthsection) | false | | Failing sections is a list of sections that have failed their healthcheck. | +| `healthy` | boolean | false | | Healthy is true if the report returns no errors. Deprecated: use `Severity` instead | +| `provisioner_daemons` | [healthcheck.ProvisionerDaemonsReport](#healthcheckprovisionerdaemonsreport) | false | | | +| `severity` | [health.Severity](#healthseverity) | false | | Severity indicates the status of Coder health. | +| `time` | string | false | | Time is the time the report was generated at. | +| `websocket` | [healthcheck.WebsocketReport](#healthcheckwebsocketreport) | false | | | +| `workspace_proxy` | [healthcheck.WorkspaceProxyReport](#healthcheckworkspaceproxyreport) | false | | | #### Enumerated Values diff --git a/enterprise/cli/provisionerdaemons_test.go b/enterprise/cli/provisionerdaemons_test.go index 3c0d377214..3651971e8f 100644 --- a/enterprise/cli/provisionerdaemons_test.go +++ b/enterprise/cli/provisionerdaemons_test.go @@ -51,7 +51,7 @@ func TestProvisionerDaemon_PSK(t *testing.T) { require.Equal(t, "matt-daemon", daemons[0].Name) require.Equal(t, provisionersdk.ScopeOrganization, daemons[0].Tags[provisionersdk.TagScope]) require.Equal(t, buildinfo.Version(), daemons[0].Version) - require.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion) + require.Equal(t, provisionersdk.VersionCurrent.String(), daemons[0].APIVersion) } func TestProvisionerDaemon_SessionToken(t *testing.T) { @@ -88,7 +88,7 @@ func TestProvisionerDaemon_SessionToken(t *testing.T) { assert.Equal(t, provisionersdk.ScopeUser, daemons[0].Tags[provisionersdk.TagScope]) assert.Equal(t, anotherUser.ID.String(), daemons[0].Tags[provisionersdk.TagOwner]) assert.Equal(t, buildinfo.Version(), daemons[0].Version) - assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion) + assert.Equal(t, provisionersdk.VersionCurrent.String(), daemons[0].APIVersion) }) t.Run("ScopeAnotherUser", func(t *testing.T) { @@ -124,7 +124,7 @@ func TestProvisionerDaemon_SessionToken(t *testing.T) { // This should get clobbered to the user who started the daemon. assert.Equal(t, anotherUser.ID.String(), daemons[0].Tags[provisionersdk.TagOwner]) assert.Equal(t, buildinfo.Version(), daemons[0].Version) - assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion) + assert.Equal(t, provisionersdk.VersionCurrent.String(), daemons[0].APIVersion) }) t.Run("ScopeOrg", func(t *testing.T) { @@ -158,6 +158,6 @@ func TestProvisionerDaemon_SessionToken(t *testing.T) { assert.Equal(t, "org-daemon", daemons[0].Name) assert.Equal(t, provisionersdk.ScopeOrganization, daemons[0].Tags[provisionersdk.TagScope]) assert.Equal(t, buildinfo.Version(), daemons[0].Version) - assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion) + assert.Equal(t, provisionersdk.VersionCurrent.String(), daemons[0].APIVersion) }) } diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index ffd3af57ac..92f034e352 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -26,6 +26,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" @@ -89,7 +90,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { } apiDaemons := make([]codersdk.ProvisionerDaemon, 0) for _, daemon := range daemons { - apiDaemons = append(apiDaemons, convertProvisionerDaemon(daemon)) + apiDaemons = append(apiDaemons, db2sdk.ProvisionerDaemon(daemon)) } httpapi.Write(ctx, rw, http.StatusOK, apiDaemons) } @@ -235,6 +236,11 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) versionHdrVal := r.Header.Get(codersdk.BuildVersionHeader) + apiVersion := "1.0" + if qv := r.URL.Query().Get("version"); qv != "" { + apiVersion = qv + } + // Create the daemon in the database. now := dbtime.Now() daemon, err := api.Database.UpsertProvisionerDaemon(authCtx, database.UpsertProvisionerDaemonParams{ @@ -244,7 +250,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) CreatedAt: now, LastSeenAt: sql.NullTime{Time: now, Valid: true}, Version: versionHdrVal, - APIVersion: provisionersdk.APIVersionCurrent, + APIVersion: apiVersion, }) if err != nil { if !xerrors.Is(err, context.Canceled) { @@ -355,22 +361,6 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) _ = conn.Close(websocket.StatusGoingAway, "") } -func convertProvisionerDaemon(daemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon { - result := codersdk.ProvisionerDaemon{ - ID: daemon.ID, - CreatedAt: daemon.CreatedAt, - LastSeenAt: codersdk.NullTime{NullTime: daemon.LastSeenAt}, - Name: daemon.Name, - Tags: daemon.Tags, - Version: daemon.Version, - APIVersion: daemon.APIVersion, - } - for _, provisionerType := range daemon.Provisioners { - result.Provisioners = append(result.Provisioners, codersdk.ProvisionerType(provisionerType)) - } - return result -} - // wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func // is called if a read or write error is encountered. type wsNetConn struct { diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index 1cce042447..ac48e21cdd 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -59,7 +59,7 @@ func TestProvisionerDaemonServe(t *testing.T) { if assert.Len(t, daemons, 1) { assert.Equal(t, daemonName, daemons[0].Name) assert.Equal(t, buildinfo.Version(), daemons[0].Version) - assert.Equal(t, provisionersdk.APIVersionCurrent, daemons[0].APIVersion) + assert.Equal(t, provisionersdk.VersionCurrent.String(), daemons[0].APIVersion) } }) diff --git a/helm/provisioner/charts/libcoder-0.1.0.tgz b/helm/provisioner/charts/libcoder-0.1.0.tgz index d04a06b78e..ce216fcde6 100644 Binary files a/helm/provisioner/charts/libcoder-0.1.0.tgz and b/helm/provisioner/charts/libcoder-0.1.0.tgz differ diff --git a/provisionersdk/serve.go b/provisionersdk/serve.go index fc6d94ba52..0b2e10234f 100644 --- a/provisionersdk/serve.go +++ b/provisionersdk/serve.go @@ -3,7 +3,6 @@ package provisionersdk import ( "context" "errors" - "fmt" "io" "net" "os" @@ -18,6 +17,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/util/apiversion" "github.com/coder/coder/v2/provisionersdk/proto" ) @@ -26,13 +26,10 @@ const ( CurrentMinor = 0 ) -var ( - SupportedMajors = []int{1} - // APIVersionCurrent is the current provisionerd API version. - // Breaking changes to the provisionerd API **MUST** increment - // CurrentMajor above. - APIVersionCurrent = fmt.Sprintf("%d.%d", CurrentMajor, CurrentMinor) -) +// VersionCurrent is the current provisionerd API version. +// Breaking changes to the provisionerd API **MUST** increment +// CurrentMajor above. +var VersionCurrent = apiversion.New(CurrentMajor, CurrentMinor) // ServeOptions are configurations to serve a provisioner. type ServeOptions struct { diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index 36b2829a8d..5840afd3d6 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -877,6 +877,8 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) { return TypescriptType{ValueType: "HealthSeverity"}, nil case "github.com/coder/coder/v2/codersdk.HealthSection": return TypescriptType{ValueType: "HealthSection"}, nil + case "github.com/coder/coder/v2/codersdk.ProvisionerDaemon": + return TypescriptType{ValueType: "ProvisionerDaemon"}, nil } // Some hard codes are a bit trickier. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b38c1b4829..37c671cbfa 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1865,12 +1865,14 @@ export type HealthSection = | "AccessURL" | "DERP" | "Database" + | "ProvisionerDaemons" | "Websocket" | "WorkspaceProxy"; export const HealthSections: HealthSection[] = [ "AccessURL", "DERP", "Database", + "ProvisionerDaemons", "Websocket", "WorkspaceProxy", ]; @@ -2203,6 +2205,15 @@ export interface HealthcheckDatabaseReport { readonly error?: string; } +// From healthcheck/provisioner.go +export interface HealthcheckProvisionerDaemonsReport { + readonly severity: HealthSeverity; + readonly warnings: HealthMessage[]; + readonly dismissed: boolean; + readonly error?: string; + readonly provisioner_daemons: ProvisionerDaemon[]; +} + // From healthcheck/healthcheck.go export interface HealthcheckReport { readonly time: string; @@ -2214,6 +2225,7 @@ export interface HealthcheckReport { readonly websocket: HealthcheckWebsocketReport; readonly database: HealthcheckDatabaseReport; readonly workspace_proxy: HealthcheckWorkspaceProxyReport; + readonly provisioner_daemons: HealthcheckProvisionerDaemonsReport; readonly coder_version: string; } @@ -2301,6 +2313,9 @@ export type HealthCode = | "EDB02" | "EDERP01" | "EDERP02" + | "EPD01" + | "EPD02" + | "EPD03" | "EUNKNOWN" | "EWP01" | "EWP02" @@ -2318,6 +2333,9 @@ export const HealthCodes: HealthCode[] = [ "EDB02", "EDERP01", "EDERP02", + "EPD01", + "EPD02", + "EPD03", "EUNKNOWN", "EWP01", "EWP02", diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3edf538eab..5ef64845cb 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3101,6 +3101,26 @@ export const MockHealth: TypesGen.HealthcheckReport = { ], }, }, + provisioner_daemons: { + severity: "ok", + warnings: [], + dismissed: false, + provisioner_daemons: [ + { + id: "e455b582-ac04-4323-9ad6-ab71301fa006", + created_at: "2024-01-04T15:53:03.21563Z", + last_seen_at: "2024-01-04T16:05:03.967551Z", + name: "vvuurrkk-2", + version: "v2.6.0-devel+965ad5e96", + api_version: "1.0", + provisioners: ["echo", "terraform"], + tags: { + owner: "", + scope: "organization", + }, + }, + ], + }, coder_version: "v2.5.0-devel+5fad61102", }; @@ -3189,6 +3209,13 @@ export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = { ], }, }, + provisioner_daemons: { + severity: "error", + error: "something went wrong lol", + warnings: [], + dismissed: false, + provisioner_daemons: [], + }, }; export const MockHealthSettings: TypesGen.HealthSettings = {