Skip to content

Commit 0131ce3

Browse files
authored
Merge pull request #298 from ninech/deploio-custom-probes
feat: add optional health probe configuration
2 parents 42de2fe + 6d2c1f9 commit 0131ce3

File tree

6 files changed

+361
-0
lines changed

6 files changed

+361
-0
lines changed

api/util/probe.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package util
2+
3+
import (
4+
apps "github.com/ninech/apis/apps/v1alpha1"
5+
)
6+
7+
// SetState describes how a field should be applied.
8+
type SetState int
9+
10+
const (
11+
// leave as-is
12+
Unset SetState = iota
13+
// set to value
14+
Set
15+
// explicitly remove/unset
16+
Clear
17+
)
18+
19+
// OptString is an "Optional string field" wrapper.
20+
// It carries both a string value and a state (Unset / Set / Clear).
21+
type OptString struct {
22+
State SetState
23+
Val string
24+
}
25+
26+
// OptInt32 is an "Optional int32 field" wrapper.
27+
// It works the same way as OptString, but for numeric fields.
28+
// Again, this lets us distinguish Unset (no flag) vs Set (positive value)
29+
// vs Clear (explicitly reset to nil/default).
30+
type OptInt32 struct {
31+
State SetState
32+
Val int32
33+
}
34+
35+
// ProbePatch is the normalized type used by both create and update paths.
36+
type ProbePatch struct {
37+
Path OptString
38+
PeriodSeconds OptInt32
39+
}
40+
41+
// Patcher is implemented by command-specific flag structs to produce a ProbePatch.
42+
type Patcher interface {
43+
ToProbePatch() ProbePatch
44+
}
45+
46+
// ApplyProbePatch mutates cfg.
47+
func ApplyProbePatch(cfg *apps.Config, pp ProbePatch) {
48+
switch pp.Path.State {
49+
case Set:
50+
ensureProbe(cfg)
51+
ensureHTTPGet(cfg)
52+
cfg.HealthProbe.HTTPGet.Path = pp.Path.Val
53+
case Clear:
54+
cfg.HealthProbe.HTTPGet = nil
55+
}
56+
57+
switch pp.PeriodSeconds.State {
58+
case Set:
59+
ensureProbe(cfg)
60+
v := pp.PeriodSeconds.Val
61+
cfg.HealthProbe.PeriodSeconds = &v
62+
case Clear:
63+
cfg.HealthProbe.PeriodSeconds = nil
64+
}
65+
66+
if cfg.HealthProbe != nil &&
67+
cfg.HealthProbe.HTTPGet == nil &&
68+
cfg.HealthProbe.PeriodSeconds == nil {
69+
cfg.HealthProbe = nil
70+
}
71+
}
72+
73+
func ensureProbe(cfg *apps.Config) {
74+
if cfg.HealthProbe == nil {
75+
cfg.HealthProbe = &apps.Probe{}
76+
}
77+
}
78+
79+
func ensureHTTPGet(cfg *apps.Config) {
80+
if cfg.HealthProbe.HTTPGet == nil {
81+
cfg.HealthProbe.HTTPGet = &apps.HTTPGetAction{}
82+
}
83+
}

api/util/probe_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package util
2+
3+
import (
4+
"testing"
5+
6+
apps "github.com/ninech/apis/apps/v1alpha1"
7+
"github.com/stretchr/testify/assert"
8+
"k8s.io/utils/ptr"
9+
)
10+
11+
func TestApplyProbePatch(t *testing.T) {
12+
setPath := func(s string) OptString {
13+
return OptString{State: Set, Val: s}
14+
}
15+
clearPath := func() OptString {
16+
return OptString{State: Clear}
17+
}
18+
unsetPath := func() OptString {
19+
return OptString{State: Unset}
20+
}
21+
setPer := func(n int32) OptInt32 {
22+
return OptInt32{State: Set, Val: n}
23+
}
24+
clearPer := func() OptInt32 {
25+
return OptInt32{State: Clear}
26+
}
27+
unsetPer := func() OptInt32 {
28+
return OptInt32{State: Unset}
29+
}
30+
31+
tests := []struct {
32+
name string
33+
cfg apps.Config
34+
pp ProbePatch
35+
want func(t *testing.T, got *apps.Config)
36+
}{
37+
{
38+
name: "no-op when everything Unset and cfg nil",
39+
cfg: apps.Config{},
40+
pp: ProbePatch{Path: unsetPath(), PeriodSeconds: unsetPer()},
41+
want: func(t *testing.T, got *apps.Config) {
42+
assert.Nil(t, got.HealthProbe)
43+
},
44+
},
45+
{
46+
name: "set Path creates probe+httpget and assigns path",
47+
cfg: apps.Config{},
48+
pp: ProbePatch{Path: setPath("/healthz"), PeriodSeconds: unsetPer()},
49+
want: func(t *testing.T, got *apps.Config) {
50+
if assert.NotNil(t, got.HealthProbe) && assert.NotNil(t, got.HealthProbe.HTTPGet) {
51+
assert.Equal(t, "/healthz", got.HealthProbe.HTTPGet.Path)
52+
}
53+
assert.Nil(t, got.HealthProbe.PeriodSeconds)
54+
},
55+
},
56+
{
57+
name: "set PeriodSeconds creates probe and sets value (owns memory)",
58+
cfg: apps.Config{},
59+
pp: ProbePatch{Path: unsetPath(), PeriodSeconds: setPer(7)},
60+
want: func(t *testing.T, got *apps.Config) {
61+
if assert.NotNil(t, got.HealthProbe) {
62+
if assert.NotNil(t, got.HealthProbe.PeriodSeconds) {
63+
assert.Equal(t, int32(7), *got.HealthProbe.PeriodSeconds)
64+
}
65+
assert.Nil(t, got.HealthProbe.HTTPGet)
66+
}
67+
},
68+
},
69+
{
70+
name: "set both fields on existing probe updates both",
71+
cfg: apps.Config{
72+
HealthProbe: &apps.Probe{
73+
ProbeHandler: apps.ProbeHandler{
74+
HTTPGet: &apps.HTTPGetAction{Path: "/old"},
75+
},
76+
PeriodSeconds: ptr.To(int32(3)),
77+
},
78+
},
79+
pp: ProbePatch{Path: setPath("/new"), PeriodSeconds: setPer(9)},
80+
want: func(t *testing.T, got *apps.Config) {
81+
if assert.NotNil(t, got.HealthProbe) && assert.NotNil(t, got.HealthProbe.HTTPGet) {
82+
assert.Equal(t, "/new", got.HealthProbe.HTTPGet.Path)
83+
}
84+
if assert.NotNil(t, got.HealthProbe.PeriodSeconds) {
85+
assert.Equal(t, int32(9), *got.HealthProbe.PeriodSeconds)
86+
}
87+
},
88+
},
89+
{
90+
name: "clear Path removes HTTPGet but keeps probe if other fields remain",
91+
cfg: apps.Config{
92+
HealthProbe: &apps.Probe{
93+
ProbeHandler: apps.ProbeHandler{
94+
HTTPGet: &apps.HTTPGetAction{Path: "/keep-me?no"},
95+
},
96+
PeriodSeconds: ptr.To(int32(5)),
97+
},
98+
},
99+
pp: ProbePatch{Path: clearPath(), PeriodSeconds: unsetPer()},
100+
want: func(t *testing.T, got *apps.Config) {
101+
if assert.NotNil(t, got.HealthProbe) {
102+
assert.Nil(t, got.HealthProbe.HTTPGet)
103+
if assert.NotNil(t, got.HealthProbe.PeriodSeconds) {
104+
assert.Equal(t, int32(5), *got.HealthProbe.PeriodSeconds)
105+
}
106+
}
107+
},
108+
},
109+
{
110+
name: "clear PeriodSeconds sets it to nil but preserves HTTPGet",
111+
cfg: apps.Config{
112+
HealthProbe: &apps.Probe{
113+
ProbeHandler: apps.ProbeHandler{
114+
HTTPGet: &apps.HTTPGetAction{Path: "/ok"},
115+
},
116+
PeriodSeconds: ptr.To(int32(11)),
117+
},
118+
},
119+
pp: ProbePatch{Path: unsetPath(), PeriodSeconds: clearPer()},
120+
want: func(t *testing.T, got *apps.Config) {
121+
if assert.NotNil(t, got.HealthProbe) {
122+
assert.NotNil(t, got.HealthProbe.HTTPGet)
123+
assert.Equal(t, "/ok", got.HealthProbe.HTTPGet.Path)
124+
assert.Nil(t, got.HealthProbe.PeriodSeconds)
125+
}
126+
},
127+
},
128+
{
129+
name: "clearing last fields removes the whole HealthProbe",
130+
cfg: apps.Config{
131+
HealthProbe: &apps.Probe{
132+
ProbeHandler: apps.ProbeHandler{HTTPGet: &apps.HTTPGetAction{Path: "/gone"}},
133+
PeriodSeconds: nil,
134+
},
135+
},
136+
pp: ProbePatch{Path: clearPath(), PeriodSeconds: unsetPer()},
137+
want: func(t *testing.T, got *apps.Config) {
138+
assert.Nil(t, got.HealthProbe)
139+
},
140+
},
141+
{
142+
name: "unset fields do not create or modify probe",
143+
cfg: apps.Config{
144+
HealthProbe: nil,
145+
},
146+
pp: ProbePatch{Path: unsetPath(), PeriodSeconds: unsetPer()},
147+
want: func(t *testing.T, got *apps.Config) {
148+
assert.Nil(t, got.HealthProbe)
149+
},
150+
},
151+
}
152+
153+
for _, tt := range tests {
154+
t.Run(tt.name, func(t *testing.T) {
155+
cfg := tt.cfg
156+
ApplyProbePatch(&cfg, tt.pp)
157+
tt.want(t, &cfg)
158+
})
159+
}
160+
}

create/application.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type applicationCmd struct {
3838
Git gitConfig `embed:"" prefix:"git-"`
3939
Size *string `help:"Size of the application (defaults to \"${app_default_size}\")." placeholder:"${app_default_size}"`
4040
Port *int32 `help:"Port the application is listening on (defaults to ${app_default_port})." placeholder:"${app_default_port}"`
41+
HealthProbe healthProbe `embed:"" prefix:"health-probe-"`
4142
Replicas *int32 `help:"Amount of replicas of the running application (defaults to ${app_default_replicas})." placeholder:"${app_default_replicas}"`
4243
Hosts []string `help:"Host names where the application can be accessed. If empty, the application will just be accessible on a generated host name on the deploio.app domain."`
4344
BasicAuth *bool `help:"Enable/Disable basic authentication for the application (defaults to ${app_default_basic_auth})." placeholder:"${app_default_basic_auth}"`
@@ -65,6 +66,11 @@ type gitConfig struct {
6566
SSHPrivateKeyFromFile *string `help:"Path to a file containing a private key in PEM format to connect to the git repository via SSH." env:"GIT_SSH_PRIVATE_KEY_FROM_FILE" xor:"SSH_KEY" predictor:"file"`
6667
}
6768

69+
type healthProbe struct {
70+
PeriodSeconds int32 `placeholder:"${app_default_health_probe_period_seconds}" help:"How often (in seconds) to perform the custom health probe. Minimum is 1." default:"${app_default_health_probe_period_seconds}"`
71+
Path string `help:"URL path on the application's HTTP server used for the custom health probe. The platform performs an HTTP GET on this path to determine health. The app should return a non-success status during startup and once healthy, return a success HTTP status. Any code 200-399 indicates success; any other code indicates failure."`
72+
}
73+
6874
type deployJob struct {
6975
Command string `help:"Command to execute before a new release gets deployed. No deploy job will be executed if this is not specified." placeholder:"\"rake db:prepare\""`
7076
Name string `default:"release" help:"Name of the deploy job. The deployment will only continue if the job finished successfully."`
@@ -343,9 +349,28 @@ func (app *applicationCmd) config() apps.Config {
343349
if app.Replicas != nil {
344350
config.Replicas = app.Replicas
345351
}
352+
353+
app.HealthProbe.applyCreate(&config)
354+
346355
return config
347356
}
348357

358+
func (h healthProbe) ToProbePatch() util.ProbePatch {
359+
var pp util.ProbePatch
360+
361+
if p := strings.TrimSpace(h.Path); p != "" {
362+
pp.Path = util.OptString{State: util.Set, Val: p}
363+
}
364+
if h.PeriodSeconds > 0 {
365+
pp.PeriodSeconds = util.OptInt32{State: util.Set, Val: h.PeriodSeconds}
366+
}
367+
return pp
368+
}
369+
370+
func (h healthProbe) applyCreate(cfg *apps.Config) {
371+
util.ApplyProbePatch(cfg, h.ToProbePatch())
372+
}
373+
349374
func (app *applicationCmd) newApplication(project string) *apps.Application {
350375
name := getName(app.Name)
351376

@@ -660,6 +685,8 @@ func ApplicationKongVars() (kong.Vars, error) {
660685
}
661686
result["app_default_basic_auth"] = strconv.FormatBool(*apps.DefaultConfig.EnableBasicAuth)
662687

688+
result["app_default_health_probe_period_seconds"] = "10"
689+
663690
result["app_default_deploy_job_timeout"] = "5m"
664691
result["app_default_deploy_job_retries"] = "3"
665692
result["app_default_scheduled_job_timeout"] = "5m"

create/application_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func TestApplication(t *testing.T) {
8888
Size: ptr.To("mini"),
8989
Hosts: []string{"custom.example.org", "custom2.example.org"},
9090
Port: ptr.To(int32(1337)),
91+
HealthProbe: healthProbe{PeriodSeconds: int32(7), Path: "/he"},
9192
Replicas: ptr.To(int32(42)),
9293
BasicAuth: ptr.To(false),
9394
Env: map[string]string{"hello": "world"},
@@ -103,6 +104,8 @@ func TestApplication(t *testing.T) {
103104
assert.Equal(t, cmd.Hosts, app.Spec.ForProvider.Hosts)
104105
assert.Equal(t, apps.ApplicationSize(*cmd.Size), app.Spec.ForProvider.Config.Size)
105106
assert.Equal(t, *cmd.Port, *app.Spec.ForProvider.Config.Port)
107+
assert.Equal(t, cmd.HealthProbe.PeriodSeconds, *app.Spec.ForProvider.Config.HealthProbe.PeriodSeconds)
108+
assert.Equal(t, cmd.HealthProbe.Path, app.Spec.ForProvider.Config.HealthProbe.HTTPGet.Path)
106109
assert.Equal(t, *cmd.Replicas, *app.Spec.ForProvider.Config.Replicas)
107110
assert.Equal(t, *cmd.BasicAuth, *app.Spec.ForProvider.Config.EnableBasicAuth)
108111
assert.Equal(t, util.EnvVarsFromMap(cmd.Env), app.Spec.ForProvider.Config.Env)

update/application.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"strings"
78
"time"
89

910
"github.com/crossplane/crossplane-runtime/pkg/resource"
@@ -30,6 +31,8 @@ type applicationCmd struct {
3031
Git *gitConfig `embed:"" prefix:"git-"`
3132
Size *string `help:"Size of the app."`
3233
Port *int32 `help:"Port the app is listening on."`
34+
HealthProbe *healthProbe `embed:"" prefix:"health-probe-"`
35+
DeleteHealthProbe *bool `help:"Delete existing custom health probe."`
3336
Replicas *int32 `help:"Amount of replicas of the running app."`
3437
Hosts *[]string `help:"Host names where the application can be accessed. If empty, the application will just be accessible on a generated host name on the deploio.app domain."`
3538
BasicAuth *bool `help:"Enable/Disable basic authentication for the application."`
@@ -92,6 +95,11 @@ func (g gitConfig) empty() bool {
9295
g.SSHPrivateKeyFromFile == nil
9396
}
9497

98+
type healthProbe struct {
99+
PeriodSeconds *int32 `help:"How often (in seconds) to perform the custom health probe."`
100+
Path *string `help:"URL path on the application's HTTP server used for the custom health probe. The platform performs an HTTP GET on this path to determine health."`
101+
}
102+
95103
type deployJob struct {
96104
Enabled *bool `help:"Disables the deploy job if set to false." placeholder:"false"`
97105
Command *string `help:"Command to execute before a new release gets deployed. No deploy job will be executed if this is not specified." placeholder:"\"rake db:prepare\""`
@@ -236,6 +244,12 @@ func (cmd *applicationCmd) applyUpdates(app *apps.Application) {
236244
if cmd.Port != nil {
237245
app.Spec.ForProvider.Config.Port = cmd.Port
238246
}
247+
if cmd.HealthProbe != nil {
248+
cmd.HealthProbe.applyUpdates(&app.Spec.ForProvider.Config)
249+
}
250+
if cmd.DeleteHealthProbe != nil && *cmd.DeleteHealthProbe {
251+
app.Spec.ForProvider.Config.HealthProbe = nil
252+
}
239253
if cmd.Replicas != nil {
240254
app.Spec.ForProvider.Config.Replicas = cmd.Replicas
241255
}
@@ -333,6 +347,30 @@ func triggerTimestamp() string {
333347
return time.Now().UTC().Format(time.RFC3339)
334348
}
335349

350+
func (h healthProbe) ToProbePatch() util.ProbePatch {
351+
var pp util.ProbePatch
352+
353+
if h.Path != nil {
354+
if p := strings.TrimSpace(*h.Path); p == "" {
355+
pp.Path = util.OptString{State: util.Clear}
356+
} else {
357+
pp.Path = util.OptString{State: util.Set, Val: p}
358+
}
359+
}
360+
if h.PeriodSeconds != nil {
361+
if ps := *h.PeriodSeconds; ps <= 0 {
362+
pp.PeriodSeconds = util.OptInt32{State: util.Clear}
363+
} else {
364+
pp.PeriodSeconds = util.OptInt32{State: util.Set, Val: ps}
365+
}
366+
}
367+
return pp
368+
}
369+
370+
func (h healthProbe) applyUpdates(cfg *apps.Config) {
371+
util.ApplyProbePatch(cfg, h.ToProbePatch())
372+
}
373+
336374
func (job deployJob) applyUpdates(cfg *apps.Config) {
337375
if job.Enabled != nil && !*job.Enabled {
338376
// if enabled is explicitly set to false we set the DeployJob field to

0 commit comments

Comments
 (0)