Skip to content

Commit

Permalink
crond: add support for crontab file only (on any OS)
Browse files Browse the repository at this point in the history
Allows to use an external scheduler that supports crontab format
the existing crond support using crontab binary remains unchanged for linux
  • Loading branch information
jkellerer committed Nov 11, 2023
1 parent 3d72803 commit 214fe26
Show file tree
Hide file tree
Showing 16 changed files with 263 additions and 106 deletions.
10 changes: 6 additions & 4 deletions constants/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (

// Scheduler type
const (
SchedulerLaunchd = "launchd"
SchedulerWindows = "taskscheduler"
SchedulerSystemd = "systemd"
SchedulerCrond = "crond"
SchedulerLaunchd = "launchd"
SchedulerWindows = "taskscheduler"
SchedulerSystemd = "systemd"
SchedulerCrond = "crond"
SchedulerCrontab = "crontab"
SchedulerOSDefault = ""
)

var (
Expand Down
73 changes: 26 additions & 47 deletions crond/crontab.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
//+build !darwin,!windows

package crond

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
)
Expand All @@ -18,24 +13,26 @@ const (
)

type Crontab struct {
entries []Entry
file, charset string
entries []Entry
}

var (
crontabBinary = "crontab"
)

func NewCrontab(entries []Entry) *Crontab {
return &Crontab{
entries: entries,
}
}

// SetFile toggles whether to read & write a crontab file instead of using CrontabBinary
func (c *Crontab) SetFile(file string) {
c.file = file
}

// Update crontab entries:
//
// If addEntries is set to true, it will delete and add all new entries
// - If addEntries is set to true, it will delete and add all new entries
//
// If addEntries is set to false, it will only delete the matching entries
// - If addEntries is set to false, it will only delete the matching entries
//
// Return values are the number of entries deleted, and an error if any
func (c *Crontab) Update(source string, addEntries bool, w io.StringWriter) (int, error) {
Expand Down Expand Up @@ -116,61 +113,41 @@ func (c *Crontab) Generate(w io.StringWriter) error {
return nil
}

func (c *Crontab) LoadCurrent() (string, error) {
buffer := &strings.Builder{}
cmd := exec.Command(crontabBinary, "-l")
cmd.Stdout = buffer
cmd.Stderr = buffer
err := cmd.Run()
if err != nil && strings.HasPrefix(buffer.String(), "no crontab for ") {
// it's ok to be empty
return "", nil
} else if err != nil {
return "", fmt.Errorf("%w: %s", err, buffer.String())
}
return cleanupCrontab(buffer.String()), nil
func (c *Crontab) LoadCurrent() (content string, err error) {
content, c.charset, err = loadCrontab(c.file)
if err == nil {
content = cleanupCrontab(content)
}
return
}

func (c *Crontab) Rewrite() error {
crontab, err := c.LoadCurrent()
if err != nil {
return err
}
input := &bytes.Buffer{}
_, err = c.Update(crontab, true, input)
if err != nil {
return err
}

cmd := exec.Command(crontabBinary, "-")
cmd.Stdin = input
cmd.Stderr = os.Stderr
err = cmd.Run()
buffer := new(strings.Builder)
_, err = c.Update(crontab, true, buffer)
if err != nil {
return err
}
return nil

return saveCrontab(c.file, buffer.String(), c.charset)
}

func (c *Crontab) Remove() (int, error) {
crontab, err := c.LoadCurrent()
if err != nil {
return 0, err
}
buffer := &bytes.Buffer{}
num, err := c.Update(crontab, false, buffer)
if err != nil {
return num, err
}

cmd := exec.Command(crontabBinary, "-")
cmd.Stdin = buffer
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return num, err
buffer := new(strings.Builder)
num, err := c.Update(crontab, false, buffer)
if err == nil {
err = saveCrontab(c.file, buffer.String(), c.charset)
}
return num, nil
return num, err
}

func cleanupCrontab(crontab string) string {
Expand All @@ -181,7 +158,9 @@ func cleanupCrontab(crontab string) string {
}

// extractOwnSection returns before our section, inside, and after if found.
//
// - It is not returning both start and end markers.
//
// - If not found, it returns the file content in the first string
func extractOwnSection(crontab string) (string, string, string, bool) {
start := strings.Index(crontab, startMarker)
Expand Down
32 changes: 29 additions & 3 deletions crond/crontab_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
//+build !darwin,!windows

package crond

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -153,14 +152,41 @@ func TestRemoveCrontab(t *testing.T) {
assert.Equal(t, "something\n"+startMarker+endMarker, buffer.String())
}

func TestLoadCurrentFromFile(t *testing.T) {
file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab"))
defer func() {
CrontabBinary = DefaultCrontabBinary
_ = os.Remove(file)
}()

crontab := NewCrontab([]Entry{NewEntry(calendar.NewEvent(func(event *calendar.Event) {
event.Minute.MustAddValue(1)
event.Hour.MustAddValue(1)
}), "", "", "", "resticprofile backup", "")})

CrontabBinary = ""
assert.ErrorContains(t, crontab.Rewrite(), "no contrab file was specified")

crontab.SetFile(file)

assert.NoFileExists(t, file)
assert.NoError(t, crontab.Rewrite())
assert.FileExists(t, file)

result, err := crontab.LoadCurrent()
assert.NoError(t, err)
assert.Contains(t, result, "01 01 * * *\tresticprofile backup")
}

func TestLoadCurrent(t *testing.T) {
defer func() {
CrontabBinary = DefaultCrontabBinary
_ = os.Remove("./crontab")
}()
cmd := exec.Command("go", "build", "-o", "crontab", "./stdin")
err := cmd.Run()
require.NoError(t, err)
crontabBinary = "./crontab"
CrontabBinary = "./crontab"

crontab := NewCrontab(nil)
assert.NotNil(t, crontab)
Expand Down
2 changes: 0 additions & 2 deletions crond/entry.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
//+build !darwin,!windows

package crond

import (
Expand Down
2 changes: 0 additions & 2 deletions crond/entry_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
//+build !darwin,!windows

package crond

import (
Expand Down
103 changes: 103 additions & 0 deletions crond/io.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package crond

import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"strings"
)

const (
maxCrontabFileSize = 16 * 1024 * 1024
defaultCrontabFilePerms = fs.FileMode(0644)
)

func verifyCrontabFile(file string) error {
if file == "" {
return fmt.Errorf("no contrab file was specified")
}
return nil
}

func loadCrontabFile(file string) (content, charset string, err error) {
if err = verifyCrontabFile(file); err != nil {
return
}
var f *os.File
if f, err = os.Open(file); err == nil {
defer func() { _ = f.Close() }()

var bytes []byte
bytes, err = io.ReadAll(io.LimitReader(f, maxCrontabFileSize))
if err == nil && len(bytes) == maxCrontabFileSize {
err = fmt.Errorf("max file size of %d bytes exceeded in %q", maxCrontabFileSize, file)
}
if err == nil {
// TODO: handle charsets
charset = ""
content = string(bytes)
}
} else if errors.Is(err, os.ErrNotExist) {
err = nil
}
return
}

func saveCrontabFile(file, content, charset string) (err error) {
if err = verifyCrontabFile(file); err != nil {
return
}

// TODO: handle charsets
bytes := []byte(content)

if len(bytes) >= maxCrontabFileSize {
err = fmt.Errorf("max file size of %d bytes exceeded in new %q", maxCrontabFileSize, file)
} else {
err = os.WriteFile(file, bytes, defaultCrontabFilePerms)
}
return
}

// CrontabBinary is the name of the crontab binary to use when no file is set
var CrontabBinary = DefaultCrontabBinary

func loadCrontab(file string) (content, charset string, err error) {
if file == "" && CrontabBinary != "" {
buffer := new(strings.Builder)
{
cmd := exec.Command(CrontabBinary, "-l")
cmd.Stdout = buffer
cmd.Stderr = buffer
err = cmd.Run()
}
if err != nil {
if strings.HasPrefix(buffer.String(), "no crontab for ") {
err = nil // it's ok to be empty
} else {
err = fmt.Errorf("%w: %s", err, buffer.String())
}
}
if err == nil {
content = buffer.String()
}
return
} else {
return loadCrontabFile(file)
}
}

func saveCrontab(file, content, charset string) (err error) {
if file == "" && CrontabBinary != "" {
cmd := exec.Command(CrontabBinary, "-")
cmd.Stdin = strings.NewReader(content)
cmd.Stderr = os.Stderr
err = cmd.Run()
} else {
err = saveCrontabFile(file, content, charset)
}
return
}
4 changes: 4 additions & 0 deletions crond/io_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package crond

// DefaultCrontabBinary names the default `crontab` executable for this platform
const DefaultCrontabBinary = "crontab"
6 changes: 6 additions & 0 deletions crond/io_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !linux

package crond

// DefaultCrontabBinary is empty as this platform has no default `crontab` executable
const DefaultCrontabBinary = ""
19 changes: 19 additions & 0 deletions schedule/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ type Handler interface {
DisplayJobStatus(job *config.ScheduleConfig) error
}

// NewHandler creates a schedule handler depending on the configuration
func NewHandler(config SchedulerConfig) Handler {
for _, provider := range providers {
if handler := provider(config); handler != nil {
return handler
}
}
panic(fmt.Errorf("scheduler %q is not supported in this environment", config.Type()))
}

type HandlerProvider func(config SchedulerConfig) Handler

var providers []HandlerProvider

// AddHandlerProvider allows to register a provider for a SchedulerConfig handler
func AddHandlerProvider(provider HandlerProvider) {
providers = append(providers, provider)
}

func lookupBinary(name, binary string) error {
found, err := exec.LookPath(binary)
if err != nil || found == "" {
Expand Down
Loading

0 comments on commit 214fe26

Please sign in to comment.