Add cron schedules and sync docs
Some checks failed
ci / openapi (push) Failing after 7s
ci / agent (push) Failing after 5s
ci / server (push) Failing after 6s
ci / stack-smoke (push) Has been skipped
ci / web (push) Failing after 5s

This commit is contained in:
Stanislav Rossovskii
2026-06-23 19:18:01 +04:00
parent f5ec97fcb9
commit d6f9335398
24 changed files with 638 additions and 131 deletions

View File

@@ -11,6 +11,7 @@ import (
"unicode/utf8"
"github.com/BurntSushi/toml"
"github.com/robfig/cron/v3"
)
var idPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
@@ -58,6 +59,7 @@ type CheckConfig struct {
Name string `toml:"name"`
Command string `toml:"command"`
Interval Duration `toml:"interval"`
Cron string `toml:"cron"`
Timeout Duration `toml:"timeout"`
NotificationsEnabled *bool `toml:"notifications_enabled"`
DedupeKey string `toml:"dedupe_key"`
@@ -211,14 +213,24 @@ func (c *Config) Validate() error {
if strings.TrimSpace(ch.Command) == "" {
return fmt.Errorf("check %q: command is required", ch.ID)
}
if ch.Interval.Duration <= 0 {
return fmt.Errorf("check %q: interval must be > 0", ch.ID)
}
if ch.Timeout.Duration <= 0 {
return fmt.Errorf("check %q: timeout must be > 0", ch.ID)
}
if ch.Timeout.Duration > ch.Interval.Duration {
if ch.Interval.Duration < 0 {
return fmt.Errorf("check %q: interval must be > 0", ch.ID)
}
ch.Cron = strings.TrimSpace(ch.Cron)
hasInterval := ch.Interval.Duration > 0
hasCron := ch.Cron != ""
switch {
case hasInterval == hasCron:
return fmt.Errorf("check %q: exactly one of interval or cron is required", ch.ID)
case hasInterval && ch.Timeout.Duration > ch.Interval.Duration:
return fmt.Errorf("check %q: timeout must be <= interval", ch.ID)
case hasCron:
if _, err := ParseCronSchedule(ch.Cron); err != nil {
return fmt.Errorf("check %q: invalid cron: %w", ch.ID, err)
}
}
if len(ch.DedupeKey) > 256 {
return fmt.Errorf("check %q: dedupe_key too long", ch.ID)
@@ -253,6 +265,42 @@ func (c CheckConfig) NotificationsOn() bool {
return c.NotificationsEnabled == nil || *c.NotificationsEnabled
}
func (c CheckConfig) UsesInterval() bool {
return c.Interval.Duration > 0
}
func (c CheckConfig) UsesCron() bool {
return strings.TrimSpace(c.Cron) != ""
}
func ParseCronSchedule(spec string) (cron.Schedule, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, fmt.Errorf("cron is empty")
}
withoutTZ := specWithoutTZ(spec)
lower := strings.ToLower(withoutTZ)
if strings.HasPrefix(lower, "@every") {
return nil, fmt.Errorf("@every is not supported; use interval")
}
if lower == "@reboot" {
return nil, fmt.Errorf("@reboot is not supported")
}
return cron.ParseStandard(spec)
}
func specWithoutTZ(spec string) string {
fields := strings.Fields(spec)
if len(fields) == 0 {
return ""
}
first := fields[0]
if strings.HasPrefix(first, "CRON_TZ=") || strings.HasPrefix(first, "TZ=") {
return strings.ToLower(strings.Join(fields[1:], " "))
}
return strings.ToLower(strings.Join(fields, " "))
}
func validateResourceLimits(checkID string, limits ResourceLimits) error {
if limits.CPUTime.Duration < 0 {
return fmt.Errorf("check %q: resource_limits.cpu_time must be > 0", checkID)

View File

@@ -48,9 +48,84 @@ func TestLoadMinimal(t *testing.T) {
}
}
func TestLoadCronCheck(t *testing.T) {
c, err := Load(writeTmp(t, `
agent_id = "host-1"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
[[checks]]
id = "c1"
command = "true"
cron = "CRON_TZ=UTC 0 6 * * *"
timeout = "5m"
`))
if err != nil {
t.Fatal(err)
}
if !c.Checks[0].UsesCron() || c.Checks[0].UsesInterval() {
t.Fatalf("unexpected schedule mode: %+v", c.Checks[0])
}
}
func TestValidateCheckScheduleChoice(t *testing.T) {
for name, body := range map[string]string{
"both_interval_and_cron": `
interval = "10s"
cron = "0 * * * *"
timeout = "5s"
`,
"neither_interval_nor_cron": `
timeout = "5s"
`,
"invalid_cron": `
cron = "not a cron"
timeout = "5s"
`,
"seconds_cron": `
cron = "0 0 * * * *"
timeout = "5s"
`,
"every_cron": `
cron = "@every 10s"
timeout = "5s"
`,
"tz_every_cron": `
cron = "CRON_TZ=UTC @every 10s"
timeout = "5s"
`,
"reboot_cron": `
cron = "@reboot"
timeout = "5s"
`,
} {
t.Run(name, func(t *testing.T) {
_, err := Load(writeTmp(t, `
agent_id = "host-1"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
[[checks]]
id = "c1"
command = "true"
`+body+`
`))
if err == nil {
t.Fatal("expected schedule validation error")
}
})
}
}
func TestLoadResourceLimits(t *testing.T) {
c, err := Load(writeTmp(t, `
agent_id = "host-1"
agent_id = "host-1"
state_dir = "/tmp/x"
[server]