Skip to content

Commit 1b2bc9a

Browse files
committed
WIP: add version healthcheck for provisioner daemons
1 parent f80a1cf commit 1b2bc9a

File tree

3 files changed

+263
-0
lines changed

3 files changed

+263
-0
lines changed

coderd/healthcheck/health/model.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ const (
3434

3535
CodeDERPNodeUsesWebsocket Code = `EDERP01`
3636
CodeDERPOneNodeUnhealthy Code = `EDERP02`
37+
38+
CodeProvisionerDaemonsNoProvisionerDaemons Code = `EPD01`
39+
CodeProvisionerDaemonVersionOutOfDate Code = `EPD02`
40+
CodeProvisionerDaemonAPIMajorVersionNotAvailable Code = `EPD03`
41+
CodeProvisionerDaemonAPIMinorVersionNotAvailable Code = `EPD04`
3742
)
3843

3944
// @typescript-generate Severity

coderd/healthcheck/provisioner.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package healthcheck
2+
3+
import (
4+
"context"
5+
6+
"golang.org/x/mod/semver"
7+
8+
"github.com/coder/coder/v2/coderd/database"
9+
"github.com/coder/coder/v2/coderd/healthcheck/health"
10+
"github.com/coder/coder/v2/codersdk"
11+
)
12+
13+
// @typescript-generate ProvisionerDaemonReport
14+
type ProvisionerDaemonReport struct {
15+
Severity health.Severity `json:"severity"`
16+
Warnings []health.Message `json:"warnings"`
17+
Dismissed bool `json:"dismissed"`
18+
Error *string
19+
20+
Provisioners []codersdk.ProvisionerDaemon
21+
}
22+
23+
// @typescript-generate ProvisionerDaemonReportOptions
24+
type ProvisionerDaemonReportOptions struct {
25+
CurrentVersion string
26+
CurrentAPIVersion string
27+
28+
// ProvisionerDaemonsFn is a function that returns ProvisionerDaemons.
29+
// Satisfied by database.Store.ProvisionerDaemons
30+
ProvisionerDaemonsFn func(context.Context) ([]database.ProvisionerDaemon, error)
31+
32+
Dismissed bool
33+
}
34+
35+
func (r *ProvisionerDaemonReport) Run(ctx context.Context, opts *ProvisionerDaemonReportOptions) {
36+
r.Severity = health.SeverityOK
37+
r.Warnings = make([]health.Message, 0)
38+
r.Dismissed = opts.Dismissed
39+
40+
if opts.CurrentVersion == "" {
41+
r.Severity = health.SeverityError
42+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Developer error: CurrentVersion is empty!"))
43+
return
44+
}
45+
46+
if opts.CurrentAPIVersion == "" {
47+
r.Severity = health.SeverityError
48+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Developer error: CurrentAPIVersion is empty!"))
49+
return
50+
}
51+
52+
if opts.ProvisionerDaemonsFn == nil {
53+
r.Severity = health.SeverityError
54+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Developer error: ProvisionerDaemonsFn is nil!"))
55+
return
56+
}
57+
58+
daemons, err := opts.ProvisionerDaemonsFn(ctx)
59+
if err != nil {
60+
r.Severity = health.SeverityError
61+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Unable to fetch provisioner daemons: %s", err.Error()))
62+
return
63+
}
64+
65+
if len(daemons) == 0 {
66+
r.Severity = health.SeverityError
67+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeProvisionerDaemonsNoProvisionerDaemons, "No provisioner daemons found!"))
68+
}
69+
70+
for _, daemon := range daemons {
71+
// For release versions, just check MAJOR.MINOR and ignore patch.
72+
if !semver.IsValid(daemon.Version) {
73+
r.Severity = health.SeverityWarning
74+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Provisioner daemon %q reports invalid version %q", opts.CurrentVersion, daemon.Version))
75+
} else if semver.Compare(semver.MajorMinor(opts.CurrentVersion), semver.MajorMinor(daemon.Version)) > 1 {
76+
r.Severity = health.SeverityWarning
77+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Provisioner daemon %q has outdated version %q", daemon.Name, daemon.Version))
78+
}
79+
80+
// Provisioner daemon API version follows different rules.
81+
// 1) Coderd must support the requested API major version.
82+
// 2) The requested API minor version must be less than or equal to that of Coderd.
83+
ourMaj := semver.Major(opts.CurrentVersion)
84+
theirMaj := semver.Major(daemon.APIVersion)
85+
if semver.Compare(ourMaj, theirMaj) != 0 {
86+
r.Severity = health.SeverityError
87+
r.Warnings = append(r.Warnings, health.Messagef("Provisioner daemon %q requested major API version %s but only %s is available", daemon.Name, theirMaj, ourMaj))
88+
} else if semver.Compare(semver.MajorMinor(opts.CurrentAPIVersion), semver.MajorMinor(daemon.APIVersion)) > 1 {
89+
r.Severity = health.SeverityWarning
90+
r.Warnings = append(r.Warnings, health.Messagef(health.CodeUnknown, "Provisioner daemon %q requested API version %q but only %q is available", daemon.Name, daemon.Version, opts.CurrentAPIVersion))
91+
}
92+
}
93+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package healthcheck_test
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"testing"
7+
8+
"github.com/google/uuid"
9+
"github.com/stretchr/testify/assert"
10+
11+
"github.com/coder/coder/v2/coderd/database"
12+
"github.com/coder/coder/v2/coderd/database/dbtime"
13+
"github.com/coder/coder/v2/coderd/healthcheck"
14+
"github.com/coder/coder/v2/coderd/healthcheck/health"
15+
)
16+
17+
func TestProvisionerDaemonReport(t *testing.T) {
18+
t.Parallel()
19+
20+
var ()
21+
22+
for _, tt := range []struct {
23+
name string
24+
currentVersion string
25+
currentAPIVersion string
26+
provisionerDaemonsFn func(context.Context) ([]database.ProvisionerDaemon, error)
27+
expectedSeverity health.Severity
28+
expectedWarningCode health.Code
29+
}{
30+
{
31+
name: "current version empty",
32+
currentVersion: "",
33+
expectedSeverity: health.SeverityError,
34+
expectedWarningCode: health.CodeUnknown,
35+
},
36+
{
37+
name: "current api version empty",
38+
currentVersion: "v1.2.3",
39+
currentAPIVersion: "",
40+
expectedSeverity: health.SeverityError,
41+
expectedWarningCode: health.CodeUnknown,
42+
},
43+
{
44+
name: "provisionerdaemonsfn nil",
45+
currentVersion: "v1.2.3",
46+
currentAPIVersion: "v1.0",
47+
expectedSeverity: health.SeverityError,
48+
expectedWarningCode: health.CodeUnknown,
49+
},
50+
{
51+
name: "no daemons",
52+
currentVersion: "v1.2.3",
53+
currentAPIVersion: "v1.0",
54+
expectedSeverity: health.SeverityError,
55+
expectedWarningCode: health.CodeProvisionerDaemonsNoProvisionerDaemons,
56+
provisionerDaemonsFn: fakeProvisionerDaemonsFn(),
57+
},
58+
{
59+
name: "one daemon up to date",
60+
currentVersion: "v1.2.3",
61+
currentAPIVersion: "v1.0",
62+
expectedSeverity: health.SeverityOK,
63+
provisionerDaemonsFn: fakeProvisionerDaemonsFn(fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "v1.0")),
64+
},
65+
{
66+
name: "one daemon out of date",
67+
currentVersion: "v1.2.3",
68+
currentAPIVersion: "v1.0",
69+
expectedSeverity: health.SeverityWarning,
70+
expectedWarningCode: health.CodeProvisionerDaemonVersionOutOfDate,
71+
provisionerDaemonsFn: fakeProvisionerDaemonsFn(fakeProvisionerDaemon(t, "pd-old", "v1.1.2", "v1.0")),
72+
},
73+
{
74+
name: "major api version not available",
75+
currentVersion: "v1.2.3",
76+
currentAPIVersion: "v1.0",
77+
expectedSeverity: health.SeverityError,
78+
expectedWarningCode: health.CodeProvisionerDaemonAPIMajorVersionNotAvailable,
79+
provisionerDaemonsFn: fakeProvisionerDaemonsFn(fakeProvisionerDaemon(t, "pd-new-major", "v1.2.3", "v2.0")),
80+
},
81+
{
82+
name: "minor api version not available",
83+
currentVersion: "v1.2.3",
84+
currentAPIVersion: "v1.0",
85+
expectedSeverity: health.SeverityWarning,
86+
expectedWarningCode: health.CodeProvisionerDaemonAPIMinorVersionNotAvailable,
87+
provisionerDaemonsFn: fakeProvisionerDaemonsFn(fakeProvisionerDaemon(t, "pd-new-minor", "v1.2.3", "v1.1")),
88+
},
89+
{
90+
name: "one up to date, one out of date",
91+
currentVersion: "v1.2.3",
92+
currentAPIVersion: "v1.0",
93+
expectedSeverity: health.SeverityWarning,
94+
expectedWarningCode: health.CodeProvisionerDaemonVersionOutOfDate,
95+
provisionerDaemonsFn: fakeProvisionerDaemonsFn(
96+
fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "v1.0"),
97+
fakeProvisionerDaemon(t, "pd-old", "v1.1.2", "v1.0")),
98+
},
99+
{
100+
name: "one up to date, one newer",
101+
currentVersion: "v1.2.3",
102+
currentAPIVersion: "v1.0",
103+
expectedSeverity: health.SeverityOK,
104+
provisionerDaemonsFn: fakeProvisionerDaemonsFn(
105+
fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "v1.0"),
106+
fakeProvisionerDaemon(t, "pd-new", "v2.3.4", "v1.0")),
107+
},
108+
} {
109+
tt := tt
110+
t.Run(tt.name, func(t *testing.T) {
111+
t.Parallel()
112+
113+
var rpt healthcheck.ProvisionerDaemonReport
114+
var opts healthcheck.ProvisionerDaemonReportOptions
115+
opts.CurrentVersion = tt.currentVersion
116+
opts.CurrentAPIVersion = tt.currentAPIVersion
117+
if tt.provisionerDaemonsFn != nil {
118+
opts.ProvisionerDaemonsFn = tt.provisionerDaemonsFn
119+
}
120+
121+
rpt.Run(context.Background(), &opts)
122+
123+
assert.Equal(t, tt.expectedSeverity, rpt.Severity)
124+
if tt.expectedWarningCode != "" && assert.NotEmpty(t, rpt.Warnings) {
125+
var found bool
126+
for _, w := range rpt.Warnings {
127+
if w.Code == tt.expectedWarningCode {
128+
found = true
129+
break
130+
}
131+
}
132+
assert.True(t, found, "expected warning %s not found in %v", tt.expectedWarningCode, rpt.Warnings)
133+
} else {
134+
assert.Empty(t, rpt.Warnings)
135+
}
136+
})
137+
}
138+
}
139+
140+
func fakeProvisionerDaemon(t *testing.T, name, version, apiVersion string) database.ProvisionerDaemon {
141+
t.Helper()
142+
return database.ProvisionerDaemon{
143+
ID: uuid.New(),
144+
Name: name,
145+
CreatedAt: dbtime.Now(),
146+
LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true},
147+
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho, database.ProvisionerTypeTerraform},
148+
ReplicaID: uuid.NullUUID{},
149+
Tags: map[string]string{},
150+
Version: version,
151+
APIVersion: apiVersion,
152+
}
153+
}
154+
155+
func fakeProvisionerDaemonsFn(pds ...database.ProvisionerDaemon) func(context.Context) ([]database.ProvisionerDaemon, error) {
156+
return func(context.Context) ([]database.ProvisionerDaemon, error) {
157+
return pds, nil
158+
}
159+
}
160+
161+
func fakeProvisionerDaemonsFnErr(err error) func(context.Context) ([]database.ProvisionerDaemon, error) {
162+
return func(context.Context) ([]database.ProvisionerDaemon, error) {
163+
return nil, err
164+
}
165+
}

0 commit comments

Comments
 (0)