7 Commits

Author SHA1 Message Date
a2f7dbca82 wip: new config unit test 2024-01-06 23:09:34 +00:00
006f8278d7 wip: feat(cron in new config): added parser
wip: fix: remove unreachable code
wip: test cron
2024-01-04 22:55:16 +00:00
6dd0a8007c wip: refactor(config): yaml parsed
wip: chore(makefile): prepare version info
2024-01-02 23:11:58 +11:00
6a40403434 wip: refactor(config): added new structures
wip: feat(task): renamed structure
2024-01-01 22:45:02 +11:00
98d57cda84 wip: feat(task): added AutoStart and AutoRestart on ServiceTask 2024-01-01 11:35:18 +11:00
5bae155b3b wip: refactor(config): fix pointer type 2023-12-31 15:00:25 +11:00
22fee125bc wip: refactor for new config format 2023-12-31 13:51:17 +11:00
24 changed files with 1239 additions and 224 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
/cmd/wingmate/wingmate
/cmd/wingmate/version.txt
/cmd/pidproxy/pidproxy
/cmd/pidproxy/version.txt
/cmd/exec/exec
/cmd/exec/version.txt

55
.idea/remote-targets.xml generated Normal file
View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteTargetsManager">
<targets>
<target name="golang-dev:1.21-bookworm-user" type="docker" uuid="5b79636b-3db9-4e2a-9fd3-03277653ae58">
<config>
<option name="targetPlatform">
<TargetPlatform />
</option>
<option name="buildImageConfig">
<BuildImageConfig>
<option name="dockerFile" value="docker/bookworm/Dockerfile" />
</BuildImageConfig>
</option>
<option name="buildNotPull" value="false" />
<option name="containerConfig">
<ContainerConfig>
<option name="runCliOptions" value="-u &quot;1000:1000&quot; --rm" />
</ContainerConfig>
</option>
<option name="pullImageConfig">
<PullImageConfig>
<option name="tagToPull" value="golang-dev:1.21-bookworm-user" />
</PullImageConfig>
</option>
</config>
<ContributedStateBase type="GoLanguageRuntime">
<config>
<option name="compiledExecutablesVolume">
<VolumeState>
<option name="targetSpecificBits">
<map>
<entry key="mountAsVolume" value="false" />
</map>
</option>
</VolumeState>
</option>
<option name="goPath" value="/go" />
<option name="goRoot" value="/usr/local/go/bin/go" />
<option name="goVersion" value="go1.21.5 linux/amd64" />
<option name="projectSourcesVolume">
<VolumeState>
<option name="targetSpecificBits">
<map>
<entry key="mountAsVolume" value="false" />
</map>
</option>
</VolumeState>
</option>
</config>
</ContributedStateBase>
</target>
</targets>
</component>
</project>

View File

@@ -1,7 +1,9 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:

View File

@@ -1,4 +1,8 @@
/dummy/dummy
/dummy/version.txt
/starter/starter
/starter/version.txt
/oneshot/oneshot
/oneshot/version.txt
/spawner/spawner
/spawner/version.txt

View File

@@ -1,7 +1,9 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:

View File

@@ -1,7 +1,9 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:

View File

@@ -1,7 +1,9 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:

View File

@@ -1,7 +1,9 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:

View File

@@ -1,7 +1,9 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:

View File

@@ -1,7 +1,9 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:

View File

@@ -1,60 +1,52 @@
package main
import (
"time"
"gitea.suyono.dev/suyono/wingmate/config"
wminit "gitea.suyono.dev/suyono/wingmate/init"
"gitea.suyono.dev/suyono/wingmate/task"
)
type wPath struct {
path string
}
func (p wPath) Path() string {
return p.path
}
type wConfig struct {
services []wminit.Path
cron []wminit.Cron
tasks *task.Tasks
}
func (c wConfig) Services() []wminit.Path {
return c.services
func (c *wConfig) Tasks() wminit.Tasks {
return c.tasks
}
func (c wConfig) Cron() []wminit.Cron {
return c.cron
}
type wCron struct {
iCron *config.Cron
}
func (c wCron) TimeToRun(now time.Time) bool {
return c.iCron.TimeToRun(now)
}
func (c wCron) Command() wminit.Path {
return wPath{
path: c.iCron.Command(),
}
}
func convert(cfg *config.Config) wConfig {
retval := wConfig{
services: make([]wminit.Path, 0, len(cfg.ServicePaths)),
cron: make([]wminit.Cron, 0, len(cfg.Cron)),
func convert(cfg *config.Config) *wConfig {
retval := &wConfig{
tasks: task.NewTasks(),
}
for _, s := range cfg.ServicePaths {
retval.services = append(retval.services, wPath{path: s})
for _, s := range cfg.ServiceV0 {
retval.tasks.AddV0Service(s)
}
for _, c := range cfg.Cron {
retval.cron = append(retval.cron, wCron{iCron: c})
var schedule task.CronSchedule
for _, c := range cfg.CronV0 {
schedule.Minute = convertSchedule(c.Minute)
schedule.Hour = convertSchedule(c.Hour)
schedule.DoM = convertSchedule(c.DoM)
schedule.Month = convertSchedule(c.Month)
schedule.DoW = convertSchedule(c.DoW)
retval.tasks.AddV0Cron(schedule, c.Command)
}
return retval
}
func convertSchedule(cfg config.CronTimeSpec) task.CronTimeSpec {
switch v := cfg.(type) {
case *config.SpecAny:
return task.NewCronAnySpec()
case *config.SpecExact:
return task.NewCronExactSpec(v.Value())
case *config.SpecMultiOccurrence:
return task.NewCronMultiOccurrenceSpec(v.Values()...)
}
panic("invalid conversion")
}

View File

@@ -16,11 +16,47 @@ const (
DefaultConfigPath = "/etc/wingmate"
ServiceDirName = "service"
CrontabFileName = "crontab"
WingmateConfigFileName = "wingmate"
WingmateConfigFileFormat = "yaml"
)
type Config struct {
ServicePaths []string
Cron []*Cron
ServiceV0 []string
CronV0 []*Cron
Service []ServiceTask
Cron []CronTask
}
type Task struct {
Command []string `mapstructure:"command"`
Environ []string `mapstructure:"environ"`
Setsid bool `mapstructure:"setsid"`
User string `mapstructure:"user"`
Group string `mapstructure:"group"`
Background bool `mapstructure:"background"`
WorkingDir string `mapstructure:"working_dir"`
}
type ServiceTask struct {
Name string `mapstructure:"-"`
Task `mapstructure:",squash"`
AutoStart bool `mapstructure:"autostart"`
AutoRestart bool `mapstructure:"autorestart"`
}
type CronTask struct {
Name string `mapstructure:"-"`
CronSchedule `mapstructure:"-"`
Task `mapstructure:",squash"`
Schedule string `mapstructure:"schedule"`
}
type CronSchedule struct {
Minute CronTimeSpec
Hour CronTimeSpec
DoM CronTimeSpec
Month CronTimeSpec
DoW CronTimeSpec
}
func Read() (*Config, error) {
@@ -34,14 +70,17 @@ func Read() (*Config, error) {
svcdir string
serviceAvailable bool
cronAvailable bool
wingmateConfigAvailable bool
cron []*Cron
crontabfile string
services []ServiceTask
crones []CronTask
)
serviceAvailable = false
cronAvailable = false
outConfig := &Config{
ServicePaths: make([]string, 0),
ServiceV0: make([]string, 0),
}
configPath := viper.GetString(EnvConfigPath)
svcdir = filepath.Join(configPath, ServiceDirName)
@@ -52,7 +91,7 @@ func Read() (*Config, error) {
svcPath := filepath.Join(svcdir, d.Name())
if err = unix.Access(svcPath, unix.X_OK); err == nil {
serviceAvailable = true
outConfig.ServicePaths = append(outConfig.ServicePaths, svcPath)
outConfig.ServiceV0 = append(outConfig.ServiceV0, svcPath)
}
}
}
@@ -64,14 +103,27 @@ func Read() (*Config, error) {
crontabfile = filepath.Join(configPath, CrontabFileName)
cron, err = readCrontab(crontabfile)
if len(cron) > 0 {
outConfig.Cron = cron
outConfig.CronV0 = cron
cronAvailable = true
}
if err != nil {
wingmate.Log().Error().Msgf("encounter error when reading crontab %s: %+v", crontabfile, err)
}
if !serviceAvailable && !cronAvailable {
wingmateConfigAvailable = false
if services, crones, err = readConfigYaml(configPath, WingmateConfigFileName, WingmateConfigFileFormat); err != nil {
wingmate.Log().Error().Msgf("encounter error when reading wingmate config file in %s/%s: %+v", configPath, WingmateConfigFileName, err)
}
if len(services) > 0 {
outConfig.Service = services
wingmateConfigAvailable = true
}
if len(crones) > 0 {
outConfig.Cron = crones
wingmateConfigAvailable = true
}
if !serviceAvailable && !cronAvailable && !wingmateConfigAvailable {
return nil, errors.New("no config found")
}

View File

@@ -10,6 +10,28 @@ import (
"github.com/stretchr/testify/assert"
)
const (
serviceDir = "service"
)
var (
configDir string
)
func setup(t *testing.T) {
var err error
if configDir, err = os.MkdirTemp("", "wingmate-*-test"); err != nil {
t.Fatal("setup", err)
}
viper.Set(EnvConfigPath, configDir)
}
func tear(t *testing.T) {
if err := os.RemoveAll(configDir); err != nil {
t.Fatal("tear", err)
}
}
func TestRead(t *testing.T) {
type testEntry struct {
@@ -17,26 +39,6 @@ func TestRead(t *testing.T) {
testFunc func(t *testing.T)
}
var (
configDir string
err error
)
const serviceDir = "service"
setup := func(t *testing.T) {
if configDir, err = os.MkdirTemp("", "wingmate-*-test"); err != nil {
t.Fatal("setup", err)
}
viper.Set(EnvConfigPath, configDir)
}
tear := func(t *testing.T) {
if err = os.RemoveAll(configDir); err != nil {
t.Fatal("tear", err)
}
}
mkSvcDir := func(t *testing.T) {
if err := os.MkdirAll(path.Join(configDir, serviceDir), 0755); err != nil {
t.Fatal("create dir", err)
@@ -64,7 +66,7 @@ func TestRead(t *testing.T) {
assert.Nil(t, err)
assert.ElementsMatch(
t,
cfg.ServicePaths,
cfg.ServiceV0,
[]string{
path.Join(configDir, serviceDir, "one.sh"),
path.Join(configDir, serviceDir, "two.sh"),
@@ -84,7 +86,7 @@ func TestRead(t *testing.T) {
assert.Nil(t, err)
assert.ElementsMatch(
t,
cfg.ServicePaths,
cfg.ServiceV0,
[]string{
path.Join(configDir, serviceDir, "two.sh"),
},
@@ -102,7 +104,7 @@ func TestRead(t *testing.T) {
assert.Nil(t, err)
assert.ElementsMatch(
t,
cfg.ServicePaths,
cfg.ServiceV0,
[]string{
path.Join(configDir, serviceDir, "one.sh"),
},

View File

@@ -4,46 +4,34 @@ import (
"bufio"
"errors"
"fmt"
"gitea.suyono.dev/suyono/wingmate"
"os"
"regexp"
"strconv"
"strings"
"time"
"gitea.suyono.dev/suyono/wingmate"
)
type CronExactSpec interface {
CronTimeSpec
Value() uint8
}
type CronMultipleOccurrenceSpec interface {
CronTimeSpec
Values() []uint8
}
type CronTimeSpec interface {
Type() wingmate.CronTimeType
Match(uint8) bool
//Type() wingmate.CronTimeType
//Match(uint8) bool
}
type Cron struct {
minute CronTimeSpec
hour CronTimeSpec
dom CronTimeSpec
month CronTimeSpec
dow CronTimeSpec
command string
lastRun time.Time
hasRun bool
Minute CronTimeSpec
Hour CronTimeSpec
DoM CronTimeSpec
Month CronTimeSpec
DoW CronTimeSpec
Command string
}
type cronField int
const (
CrontabEntryRegex = `^\s*(?P<minute>\S+)\s+(?P<hour>\S+)\s+(?P<dom>\S+)\s+(?P<month>\S+)\s+(?P<dow>\S+)\s+(?P<command>\S.*\S)\s*$`
CrontabSubmatchLen = 7
CrontabEntryRegexPattern = `^\s*(?P<minute>\S+)\s+(?P<hour>\S+)\s+(?P<dom>\S+)\s+(?P<month>\S+)\s+(?P<dow>\S+)\s+(?P<command>\S.*\S)\s*$`
CrontabCommentLineRegexPattern = `^\s*#.*$`
CrontabCommentSuffixRegexPattern = `^\s*([^#]+)#.*$`
CrontabSubMatchLen = 7
minute cronField = iota
hour
@@ -52,21 +40,22 @@ const (
dow
)
var (
crontabEntryRegex = regexp.MustCompile(CrontabEntryRegexPattern)
crontabCommentLineRegex = regexp.MustCompile(CrontabCommentLineRegexPattern)
crontabCommentSuffixRegex = regexp.MustCompile(CrontabCommentSuffixRegexPattern)
)
func readCrontab(path string) ([]*Cron, error) {
var (
file *os.File
err error
scanner *bufio.Scanner
line string
re *regexp.Regexp
parts []string
retval []*Cron
)
if re, err = regexp.Compile(CrontabEntryRegex); err != nil {
return nil, err
}
if file, err = os.Open(path); err != nil {
return nil, err
}
@@ -79,41 +68,48 @@ func readCrontab(path string) ([]*Cron, error) {
for scanner.Scan() {
line = scanner.Text()
parts = re.FindStringSubmatch(line)
if len(parts) != CrontabSubmatchLen {
if crontabCommentLineRegex.MatchString(line) {
continue
}
parts = crontabCommentSuffixRegex.FindStringSubmatch(line)
if len(parts) == 2 {
line = parts[1]
}
parts = crontabEntryRegex.FindStringSubmatch(line)
if len(parts) != CrontabSubMatchLen {
wingmate.Log().Error().Msgf("invalid entry %s", line)
continue
}
c := &Cron{
hasRun: false,
}
c := &Cron{}
if err = c.setField(minute, parts[1]); err != nil {
wingmate.Log().Error().Msgf("error parsing minute field %+v", err)
wingmate.Log().Error().Msgf("error parsing Minute field %+v", err)
continue
}
if err = c.setField(hour, parts[2]); err != nil {
wingmate.Log().Error().Msgf("error parsing hour field %+v", err)
wingmate.Log().Error().Msgf("error parsing Hour field %+v", err)
continue
}
if err = c.setField(dom, parts[3]); err != nil {
wingmate.Log().Error().Msgf("error parsing day of month field %+v", err)
wingmate.Log().Error().Msgf("error parsing Day of Month field %+v", err)
continue
}
if err = c.setField(month, parts[4]); err != nil {
wingmate.Log().Error().Msgf("error parsing month field %+v", err)
wingmate.Log().Error().Msgf("error parsing Month field %+v", err)
continue
}
if err = c.setField(dow, parts[5]); err != nil {
wingmate.Log().Error().Msgf("error parsing day of week field %+v", err)
wingmate.Log().Error().Msgf("error parsing Day of Week field %+v", err)
continue
}
c.command = parts[6]
c.Command = parts[6]
retval = append(retval, c)
}
@@ -121,35 +117,6 @@ func readCrontab(path string) ([]*Cron, error) {
return retval, nil
}
func (c *Cron) Command() string {
return c.command
}
func (c *Cron) TimeToRun(now time.Time) bool {
if c.minute.Match(uint8(now.Minute())) &&
c.hour.Match(uint8(now.Hour())) &&
c.dom.Match(uint8(now.Day())) &&
c.month.Match(uint8(now.Month())) &&
c.dow.Match(uint8(now.Weekday())) {
if c.hasRun {
if now.Sub(c.lastRun) <= time.Minute && now.Minute() == c.lastRun.Minute() {
return false
} else {
c.lastRun = now
return true
}
} else {
c.lastRun = now
c.hasRun = true
return true
}
}
return false
}
type fieldRange struct {
min int
max int
@@ -182,25 +149,25 @@ func (c *Cron) setField(field cronField, input string) error {
switch field {
case minute:
fr = newRange(0, 59)
cField = &c.minute
cField = &c.Minute
case hour:
fr = newRange(0, 23)
cField = &c.hour
cField = &c.Hour
case dom:
fr = newRange(1, 31)
cField = &c.dom
cField = &c.DoM
case month:
fr = newRange(1, 12)
cField = &c.month
cField = &c.Month
case dow:
fr = newRange(0, 6)
cField = &c.dow
cField = &c.DoW
default:
return errors.New("invalid cron field descriptor")
}
if input == "*" {
*cField = &specAny{}
*cField = &SpecAny{}
} else if strings.HasPrefix(input, "*/") {
if parsed64, err = strconv.ParseUint(input[2:], 10, 8); err != nil {
return fmt.Errorf("error parse field %+v with input %s: %w", field, input, err)
@@ -217,7 +184,7 @@ func (c *Cron) setField(field cronField, input string) error {
current += parsed
}
*cField = &specMultiOccurrence{
*cField = &SpecMultiOccurrence{
values: multi,
}
} else {
@@ -237,7 +204,7 @@ func (c *Cron) setField(field cronField, input string) error {
multi = append(multi, parsed)
}
*cField = &specMultiOccurrence{
*cField = &SpecMultiOccurrence{
values: multi,
}
} else {
@@ -250,7 +217,7 @@ func (c *Cron) setField(field cronField, input string) error {
return fmt.Errorf("error parse field %+v with input %s: invalid value", field, input)
}
*cField = &specExact{
*cField = &SpecExact{
value: parsed,
}
}
@@ -259,51 +226,21 @@ func (c *Cron) setField(field cronField, input string) error {
return nil
}
type specAny struct{}
type SpecAny struct{}
func (a *specAny) Type() wingmate.CronTimeType {
return wingmate.Any
}
func (a *specAny) Match(u uint8) bool {
return true
}
type specExact struct {
type SpecExact struct {
value uint8
}
func (e *specExact) Type() wingmate.CronTimeType {
return wingmate.Exact
}
func (e *specExact) Match(u uint8) bool {
return u == e.value
}
func (e *specExact) Value() uint8 {
func (e *SpecExact) Value() uint8 {
return e.value
}
type specMultiOccurrence struct {
type SpecMultiOccurrence struct {
values []uint8
}
func (m *specMultiOccurrence) Type() wingmate.CronTimeType {
return wingmate.MultipleOccurrence
}
func (m *specMultiOccurrence) Match(u uint8) bool {
for _, v := range m.values {
if v == u {
return true
}
}
return false
}
func (m *specMultiOccurrence) Values() []uint8 {
func (m *SpecMultiOccurrence) Values() []uint8 {
out := make([]uint8, len(m.values))
copy(out, m.values)
return out

129
config/crontab_test.go Normal file
View File

@@ -0,0 +1,129 @@
package config
import (
"os"
"path/filepath"
"testing"
"gitea.suyono.dev/suyono/wingmate"
"github.com/stretchr/testify/assert"
)
const (
crontabFileName = "crontab"
)
func TestCrontab(t *testing.T) {
type testEntry struct {
name string
crontab string
wantErr bool
}
_ = wingmate.NewLog(os.Stderr)
tests := []testEntry{
{
name: "positive",
crontab: crontabTestCase0,
wantErr: false,
},
{
name: "with comment",
crontab: crontabTestCase1,
wantErr: false,
},
{
name: "various values",
crontab: crontabTestCase2,
wantErr: false,
},
{
name: "failed to parse",
crontab: crontabTestCase3,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup(t)
defer tear(t)
writeCrontab(t, tt.crontab)
cfg, err := Read()
if tt.wantErr != (err != nil) {
t.Fatalf("wantErr is %v but err is %+v", tt.wantErr, err)
}
t.Logf("cfg: %+v", cfg)
for _, c := range cfg.CronV0 {
t.Logf("%+v", c)
}
})
}
}
func writeCrontab(t *testing.T, content string) {
var (
f *os.File
err error
)
if f, err = os.Create(filepath.Join(configDir, crontabFileName)); err != nil {
t.Fatal("create crontab file", err)
}
defer func() {
_ = f.Close()
}()
if _, err = f.Write([]byte(content)); err != nil {
t.Fatal("writing crontab file", err)
}
}
const crontabTestCase0 = `* * * * * /path/to/executable`
const crontabTestCase1 = `# this is a comment
## comment with space
* * * * * /path/to/executable
* * * * * /path/to/executable # comment as a suffix
`
const crontabTestCase2 = `# first comment
*/5 13 3,5,7 * * /path/to/executable`
const crontabTestCase3 = `a 13 3,5,7 * * /path/to/executable
*/5 a 3,5,7 * * /path/to/executable
*/5 13 a * * /path/to/executable
*/5 13 3,5,7 a * /path/to/executable
*/5 13 3,5,7 * a /path/to/executable
*/x 13 3,5,7 * * /path/to/executable
76 13 3,5,7 * * /path/to/executable
*/75 13 3,5,7 * * /path/to/executable
*/5 13 3,x,7 * * /path/to/executable
*/5 13 3,5,67 * * /path/to/executable
*/5 13 * * /path/to/executable
*/5 13 3,5,7 * * /path/to/executable`
func TestSpecExact(t *testing.T) {
var val uint8 = 45
s := SpecExact{
value: val,
}
assert.Equal(t, val, s.Value())
}
func TestSpecMulti(t *testing.T) {
val := []uint8{3, 5, 7, 15}
s := SpecMultiOccurrence{
values: val,
}
assert.ElementsMatch(t, val, s.Values())
}
func TestInvalidField(t *testing.T) {
c := &Cron{}
assert.NotNil(t, c.setField(cronField(99), "x"))
}

202
config/yaml.go Normal file
View File

@@ -0,0 +1,202 @@
package config
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/viper"
)
const (
CrontabScheduleRegexPattern = `^\s*(?P<minute>\S+)\s+(?P<hour>\S+)\s+(?P<dom>\S+)\s+(?P<month>\S+)\s+(?P<dow>\S+)\s*$`
CrontabScheduleSubMatchLen = 6
ServiceConfigGroup = "service"
CronConfigGroup = "cron"
ServiceKeyFormat = "service.%s"
CronKeyFormat = "cron.%s"
)
var (
crontabScheduleRegex = regexp.MustCompile(CrontabScheduleRegexPattern)
)
func readConfigYaml(path, name, format string) ([]ServiceTask, []CronTask, error) {
var (
err error
nameMap map[string]any
itemName string
serviceTask ServiceTask
cronTask CronTask
item any
services []ServiceTask
crones []CronTask
)
viper.AddConfigPath(path)
viper.SetConfigType(format)
viper.SetConfigName(name)
if err = viper.ReadInConfig(); err != nil {
return nil, nil, fmt.Errorf("reading config in dir %s, file %s, format %s: %w", path, name, format, err)
}
services = make([]ServiceTask, 0)
nameMap = viper.GetStringMap(ServiceConfigGroup)
for itemName, item = range nameMap {
serviceTask = ServiceTask{}
if err = viper.UnmarshalKey(fmt.Sprintf(ServiceKeyFormat, itemName), &serviceTask); err != nil {
wingmate.Log().Error().Msgf("failed to parse service %s: %+v | %+v", itemName, err, item)
continue
}
serviceTask.Name = itemName
services = append(services, serviceTask)
}
crones = make([]CronTask, 0)
nameMap = viper.GetStringMap(CronConfigGroup)
for itemName, item = range nameMap {
cronTask = CronTask{}
if err = viper.UnmarshalKey(fmt.Sprintf(CronKeyFormat, itemName), &cronTask); err != nil {
wingmate.Log().Error().Msgf("failed to parse cron %s: %v | %v", itemName, err, item)
continue
}
cronTask.Name = itemName
if cronTask.CronSchedule, err = parseYamlSchedule(cronTask.Schedule); err != nil {
wingmate.Log().Error().Msgf("parsing cron schedule: %+v", err)
continue
}
crones = append(crones, cronTask)
}
return services, crones, nil
}
func parseYamlSchedule(input string) (schedule CronSchedule, err error) {
var (
parts []string
pSched *CronSchedule
)
parts = crontabScheduleRegex.FindStringSubmatch(input)
if len(parts) != CrontabScheduleSubMatchLen {
return schedule, fmt.Errorf("invalid schedule: %s", input)
}
pSched = &schedule
if err = pSched.setField(minute, parts[1]); err != nil {
return schedule, fmt.Errorf("error parsing Minute field: %w", err)
}
if err = pSched.setField(hour, parts[2]); err != nil {
return schedule, fmt.Errorf("error parsing Hour field: %w", err)
}
if err = pSched.setField(dom, parts[3]); err != nil {
return schedule, fmt.Errorf("error parsing Day of Month field: %w", err)
}
if err = pSched.setField(month, parts[4]); err != nil {
return schedule, fmt.Errorf("error parsing Month field: %w", err)
}
if err = pSched.setField(dow, parts[5]); err != nil {
return schedule, fmt.Errorf("error parsing Day of Week field: %w", err)
}
return
}
func (c *CronSchedule) setField(field cronField, input string) error {
var (
fr *fieldRange
cField *CronTimeSpec
err error
parsed64 uint64
parsed uint8
multi []uint8
current uint8
multiStr []string
)
switch field {
case minute:
fr = newRange(0, 59)
cField = &c.Minute
case hour:
fr = newRange(0, 23)
cField = &c.Hour
case dom:
fr = newRange(1, 31)
cField = &c.DoM
case month:
fr = newRange(1, 12)
cField = &c.Month
case dow:
fr = newRange(0, 6)
cField = &c.DoW
default:
return errors.New("invalid cron field descriptor")
}
if input == "*" {
*cField = &SpecAny{}
} else if strings.HasPrefix(input, "*/") {
if parsed64, err = strconv.ParseUint(input[2:], 10, 8); err != nil {
return fmt.Errorf("error parse field %+v with input %s: %w", field, input, err)
}
parsed = uint8(parsed64)
if !fr.valid(parsed) {
return fmt.Errorf("error parse field %+v with input %s parsed to %d: invalid value", field, input, parsed)
}
multi = make([]uint8, 0)
current = parsed
for fr.valid(current) {
multi = append(multi, current)
current += parsed
}
*cField = &SpecMultiOccurrence{
values: multi,
}
} else {
multiStr = strings.Split(input, ",")
if len(multiStr) > 1 {
multi = make([]uint8, 0)
for _, s := range multiStr {
if parsed64, err = strconv.ParseUint(s, 10, 8); err != nil {
return fmt.Errorf("error parse field %+v with input %s: %w", field, input, err)
}
parsed = uint8(parsed64)
if !fr.valid(parsed) {
return fmt.Errorf("error parse field %+v with input %s: invalid value", field, input)
}
multi = append(multi, parsed)
}
*cField = &SpecMultiOccurrence{
values: multi,
}
} else {
if parsed64, err = strconv.ParseUint(input, 10, 8); err != nil {
return fmt.Errorf("error parse field %+v with input %s: %w", field, input, err)
}
parsed = uint8(parsed64)
if !fr.valid(parsed) {
return fmt.Errorf("error parse field %+v with input %s: invalid value", field, input)
}
*cField = &SpecExact{
value: parsed,
}
}
}
return nil
}

258
config/yaml_test.go Normal file
View File

@@ -0,0 +1,258 @@
package config
import (
"fmt"
"os"
"path"
"testing"
"gitea.suyono.dev/suyono/wingmate"
)
const configName = "wingmate.yaml"
func TestYaml(t *testing.T) {
type testEntry struct {
name string
config string
wantErr bool
}
_ = wingmate.NewLog(os.Stderr)
tests := []testEntry{
{
name: "positive",
config: yamlTestCase0,
wantErr: false,
},
{
name: "service only",
config: yamlTestCase1,
wantErr: false,
},
{
name: "cron only",
config: yamlTestCase2,
wantErr: false,
},
{
name: "invalid content - service",
config: yamlTestCase3,
wantErr: false,
},
}
for i, tc := range yamlBlobs {
tests = append(tests, testEntry{
name: fmt.Sprintf("negative - %d", i),
config: tc,
wantErr: true,
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup(t)
defer tear(t)
writeYaml(t, path.Join(configDir, configName), tt.config)
cfg, err := Read()
if tt.wantErr != (err != nil) {
t.Fatalf("wantErr is %v but err is %+v", tt.wantErr, err)
}
t.Logf("cfg: %+v", cfg)
})
}
}
func writeYaml(t *testing.T, path, content string) {
var (
f *os.File
err error
)
if f, err = os.Create(path); err != nil {
t.Fatal("create yaml file", err)
}
defer func() {
_ = f.Close()
}()
if _, err = f.Write([]byte(content)); err != nil {
t.Fatal("write yaml file", err)
}
}
const yamlTestCase0 = `version: "1"
service:
one:
command: ["command", "arg0", "arg1"]
environ: ["ENV1=value1", "ENV2=valueX"]
user: "user1"
group: "999"
working_dir: "/path/to/working"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 * * * 2,3"`
const yamlTestCase1 = `version: "1"
service:
one:
command: ["command", "arg0", "arg1"]
environ: ["ENV1=value1", "ENV2=valueX"]
user: "user1"
group: "999"
working_dir: "/path/to/working"`
const yamlTestCase2 = `version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 * * * 2,3"`
const yamlTestCase3 = `version: "1"
service:
one:
command: 12345
environ: ["ENV1=value1", "ENV2=valueX"]
user: "user1"
group: "999"
working_dir: "/path/to/working"`
var yamlBlobs = []string{
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "a 13 3,5,7 * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 a 3,5,7 * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 13 a * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 13 3,5,7 a *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 13 3,5,7 * a"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/x 13 3,5,7 * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "76 13 3,5,7 * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/75 13 3,5,7 * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 13 3,x,7 * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 13 3,5,67 * *"`,
`version: "1"
cron:
cron-one:
command:
- command-cron
- arg0
- arg1
environ: ["ENV1=v1", "ENV2=var2"]
user: "1001"
group: "978"
schedule: "*/5 13 * *"`,
}

View File

@@ -3,7 +3,9 @@ FROM golang:1.21-alpine as builder
ADD . /root/wingmate
WORKDIR /root/wingmate/
ARG TEST_BUILD
RUN apk add make build-base && CGO_ENABLED=1 make all && make DESTDIR=/usr/local/bin/wingmate install
RUN apk update && apk add git make build-base && \
CGO_ENABLED=1 make all && \
make DESTDIR=/usr/local/bin/wingmate install

View File

@@ -13,7 +13,7 @@ const (
cronTag = "cron"
)
func (i *Init) cron(wg *sync.WaitGroup, cron Cron, exitFlag <-chan any) {
func (i *Init) cron(wg *sync.WaitGroup, cron CronTask, exitFlag <-chan any) {
defer wg.Done()
var (
@@ -21,35 +21,40 @@ func (i *Init) cron(wg *sync.WaitGroup, cron Cron, exitFlag <-chan any) {
err error
stdout io.ReadCloser
stderr io.ReadCloser
cmd *exec.Cmd
)
ticker := time.NewTicker(time.Second * 30)
cron:
for {
if cron.TimeToRun(time.Now()) {
wingmate.Log().Info().Str(cronTag, cron.Command().Path()).Msg("executing")
cmd := exec.Command(cron.Command().Path())
wingmate.Log().Info().Str(cronTag, cron.Name()).Msg("executing")
if len(cron.Command()) == 1 {
cmd = exec.Command(cron.Command()[0])
} else {
cmd = exec.Command(cron.Command()[0], cron.Command()[1:]...)
}
iwg = &sync.WaitGroup{}
if stdout, err = cmd.StdoutPipe(); err != nil {
wingmate.Log().Error().Str(cronTag, cron.Command().Path()).Msgf("stdout pipe: %+v", err)
wingmate.Log().Error().Str(cronTag, cron.Name()).Msgf("stdout pipe: %+v", err)
goto fail
}
if stderr, err = cmd.StderrPipe(); err != nil {
wingmate.Log().Error().Str(cronTag, cron.Command().Path()).Msgf("stderr pipe: %+v", err)
wingmate.Log().Error().Str(cronTag, cron.Name()).Msgf("stderr pipe: %+v", err)
_ = stdout.Close()
goto fail
}
iwg.Add(1)
go i.pipeReader(iwg, stdout, cronTag, cron.Command().Path())
go i.pipeReader(iwg, stdout, cronTag, cron.Name())
iwg.Add(1)
go i.pipeReader(iwg, stderr, cronTag, cron.Command().Path())
go i.pipeReader(iwg, stderr, cronTag, cron.Name())
if err := cmd.Start(); err != nil {
wingmate.Log().Error().Msgf("starting cron %s error %+v", cron.Command().Path(), err)
wingmate.Log().Error().Msgf("starting cron %s error %+v", cron.Name(), err)
_ = stdout.Close()
_ = stderr.Close()
iwg.Wait()
@@ -59,7 +64,7 @@ cron:
iwg.Wait()
if err = cmd.Wait(); err != nil {
wingmate.Log().Error().Str(cronTag, cron.Command().Path()).Msgf("got error when waiting: %+v", err)
wingmate.Log().Error().Str(cronTag, cron.Name()).Msgf("got error when waiting: %+v", err)
}
}

View File

@@ -6,18 +6,43 @@ import (
"time"
)
type Path interface {
Path() string
type Tasks interface {
List() []Task
Services() []ServiceTask
Crones() []CronTask
Get(string) (Task, error)
}
type Cron interface {
Command() Path
type UserGroup interface {
}
type TaskStatus interface {
}
type Task interface {
Name() string
Command() []string
Environ() []string
Setsid() bool
UserGroup() UserGroup
Background() bool //NOTE: implies using wmpidproxy
WorkingDir() string
Status() TaskStatus
}
type CronTask interface {
Task
TimeToRun(time.Time) bool
}
type ServiceTask interface {
Task
AutoStart() bool
AutoRestart() bool
}
type Config interface {
Services() []Path
Cron() []Cron
Tasks() Tasks
}
type Init struct {
@@ -49,12 +74,12 @@ func (i *Init) Start() {
wg.Add(1)
go i.sighandler(wg, signalTrigger, sighandlerExit, sigchld)
for _, s := range i.config.Services() {
for _, s := range i.config.Tasks().Services() {
wg.Add(1)
go i.service(wg, s, signalTrigger)
}
for _, c := range i.config.Cron() {
for _, c := range i.config.Tasks().Crones() {
wg.Add(1)
go i.cron(wg, c, signalTrigger)
}

View File

@@ -14,7 +14,7 @@ const (
serviceTag = "service"
)
func (i *Init) service(wg *sync.WaitGroup, path Path, exitFlag <-chan any) {
func (i *Init) service(wg *sync.WaitGroup, task Task, exitFlag <-chan any) {
defer wg.Done()
var (
@@ -23,37 +23,42 @@ func (i *Init) service(wg *sync.WaitGroup, path Path, exitFlag <-chan any) {
stderr io.ReadCloser
stdout io.ReadCloser
failStatus bool
cmd *exec.Cmd
)
defer func() {
wingmate.Log().Info().Str(serviceTag, path.Path()).Msg("stopped")
wingmate.Log().Info().Str(serviceTag, task.Name()).Msg("stopped")
}()
service:
for {
failStatus = false
cmd := exec.Command(path.Path())
if len(task.Command()) == 1 {
cmd = exec.Command(task.Command()[0])
} else {
cmd = exec.Command(task.Command()[0], task.Command()[1:]...)
}
iwg = &sync.WaitGroup{}
if stdout, err = cmd.StdoutPipe(); err != nil {
wingmate.Log().Error().Str(serviceTag, path.Path()).Msgf("stdout pipe: %#v", err)
wingmate.Log().Error().Str(serviceTag, task.Name()).Msgf("stdout pipe: %#v", err)
failStatus = true
goto fail
}
iwg.Add(1)
go i.pipeReader(iwg, stdout, serviceTag, path.Path())
go i.pipeReader(iwg, stdout, serviceTag, task.Name())
if stderr, err = cmd.StderrPipe(); err != nil {
wingmate.Log().Error().Str(serviceTag, path.Path()).Msgf("stderr pipe: %#v", err)
wingmate.Log().Error().Str(serviceTag, task.Name()).Msgf("stderr pipe: %#v", err)
_ = stdout.Close()
failStatus = true
goto fail
}
iwg.Add(1)
go i.pipeReader(iwg, stderr, serviceTag, path.Path())
go i.pipeReader(iwg, stderr, serviceTag, task.Name())
if err = cmd.Start(); err != nil {
wingmate.Log().Error().Msgf("starting service %s error %#v", path.Path(), err)
wingmate.Log().Error().Msgf("starting service %s error %#v", task.Name(), err)
failStatus = true
_ = stdout.Close()
_ = stderr.Close()
@@ -64,7 +69,7 @@ service:
iwg.Wait()
if err = cmd.Wait(); err != nil {
wingmate.Log().Error().Str(serviceTag, path.Path()).Msgf("got error when waiting: %+v", err)
wingmate.Log().Error().Str(serviceTag, task.Name()).Msgf("got error when waiting: %+v", err)
}
fail:
if failStatus {

139
task/cron.go Normal file
View File

@@ -0,0 +1,139 @@
package task
import (
wminit "gitea.suyono.dev/suyono/wingmate/init"
"time"
)
type CronSchedule struct {
Minute CronTimeSpec
Hour CronTimeSpec
DoM CronTimeSpec
Month CronTimeSpec
DoW CronTimeSpec
}
type CronTimeSpec interface {
Match(uint8) bool
}
type CronAnySpec struct {
}
func NewCronAnySpec() *CronAnySpec {
return &CronAnySpec{}
}
func (cas *CronAnySpec) Match(u uint8) bool {
return true
}
type CronExactSpec struct {
value uint8
}
func NewCronExactSpec(v uint8) *CronExactSpec {
return &CronExactSpec{
value: v,
}
}
func (ces *CronExactSpec) Match(u uint8) bool {
return u == ces.value
}
type CronMultiOccurrenceSpec struct {
values []uint8
}
func NewCronMultiOccurrenceSpec(v ...uint8) *CronMultiOccurrenceSpec {
retval := &CronMultiOccurrenceSpec{}
if len(v) > 0 {
retval.values = make([]uint8, len(v))
copy(retval.values, v)
}
return retval
}
func (cms *CronMultiOccurrenceSpec) Match(u uint8) bool {
for _, v := range cms.values {
if v == u {
return true
}
}
return false
}
type Cron struct {
CronSchedule
name string
command []string
lastRun time.Time
hasRun bool //NOTE: make sure initialised as false
}
func (c *Cron) Name() string {
return c.name
}
func (c *Cron) Command() []string {
retval := make([]string, len(c.command))
copy(retval, c.command)
return retval
}
func (c *Cron) Environ() []string {
panic("not implemented")
return nil
}
func (c *Cron) Setsid() bool {
panic("not implemented")
return false
}
func (c *Cron) UserGroup() wminit.UserGroup {
panic("not implemented")
return nil
}
func (c *Cron) Background() bool {
panic("not implemented")
return false
}
func (c *Cron) WorkingDir() string {
panic("not implemented")
return ""
}
func (c *Cron) Status() wminit.TaskStatus {
panic("not implemented")
return nil
}
func (c *Cron) TimeToRun(now time.Time) bool {
if c.Minute.Match(uint8(now.Minute())) &&
c.Hour.Match(uint8(now.Hour())) &&
c.DoM.Match(uint8(now.Day())) &&
c.Month.Match(uint8(now.Month())) &&
c.DoW.Match(uint8(now.Weekday())) {
if c.hasRun {
if now.Sub(c.lastRun) <= time.Minute && now.Minute() == c.lastRun.Minute() {
return false
} else {
c.lastRun = now
return true
}
} else {
c.lastRun = now
c.hasRun = true
return true
}
}
return false
}

112
task/task.go Normal file
View File

@@ -0,0 +1,112 @@
package task
import (
wminit "gitea.suyono.dev/suyono/wingmate/init"
)
type Tasks struct {
services []wminit.ServiceTask
crones []wminit.CronTask
}
func NewTasks() *Tasks {
return &Tasks{
services: make([]wminit.ServiceTask, 0),
crones: make([]wminit.CronTask, 0),
}
}
func (ts *Tasks) AddV0Service(path string) {
ts.services = append(ts.services, &ServiceTask{
name: path,
command: []string{path},
})
}
func (ts *Tasks) AddV0Cron(schedule CronSchedule, path string) {
ts.crones = append(ts.crones, &Cron{
CronSchedule: schedule,
name: path,
command: []string{path},
hasRun: false,
})
}
func (ts *Tasks) List() []wminit.Task {
retval := make([]wminit.Task, 0, len(ts.services)+len(ts.crones))
for _, s := range ts.services {
retval = append(retval, s.(wminit.Task))
}
for _, c := range ts.crones {
retval = append(retval, c.(wminit.Task))
}
return retval
}
func (ts *Tasks) Services() []wminit.ServiceTask {
return ts.services
}
func (ts *Tasks) Crones() []wminit.CronTask {
return ts.crones
}
func (ts *Tasks) Get(name string) (wminit.Task, error) {
panic("not implemented")
return nil, nil
}
type ServiceTask struct {
name string
command []string
}
func (t *ServiceTask) Name() string {
return t.name
}
func (t *ServiceTask) Command() []string {
retval := make([]string, len(t.command))
copy(retval, t.command)
return retval
}
func (t *ServiceTask) Environ() []string {
panic("not implemented")
return nil
}
func (t *ServiceTask) Setsid() bool {
panic("not implemented")
return false
}
func (t *ServiceTask) UserGroup() wminit.UserGroup {
panic("not implemented")
return nil
}
func (t *ServiceTask) Background() bool {
panic("not implemented")
return false
}
func (t *ServiceTask) WorkingDir() string {
panic("not implemented")
return ""
}
func (t *ServiceTask) Status() wminit.TaskStatus {
panic("not implemented")
return nil
}
func (t *ServiceTask) AutoStart() bool {
panic("not implemented")
return false
}
func (t *ServiceTask) AutoRestart() bool {
panic("not implemented")
return false
}

79
task/task_test.go Normal file
View File

@@ -0,0 +1,79 @@
package task
import (
wminit "gitea.suyono.dev/suyono/wingmate/init"
"github.com/stretchr/testify/assert"
"testing"
)
func TestServicesV0(t *testing.T) {
service := "/path/to/executable"
tasks := NewTasks()
tasks.AddV0Service(service)
assert.Equal(t, tasks.Services()[0].Name(), service)
assert.ElementsMatch(t, tasks.Services()[0].Command(), []string{service})
}
func TestCronV0(t *testing.T) {
cron := "/path/to/executable"
tasks := NewTasks()
tasks.AddV0Cron(CronSchedule{
Minute: NewCronAnySpec(),
Hour: NewCronAnySpec(),
DoM: NewCronAnySpec(),
Month: NewCronAnySpec(),
DoW: NewCronAnySpec(),
}, cron)
assert.Equal(t, tasks.Crones()[0].Name(), cron)
assert.ElementsMatch(t, tasks.Crones()[0].Command(), []string{cron})
}
func TestTasks_List(t *testing.T) {
tasks := NewTasks()
tasks.services = []wminit.ServiceTask{
&ServiceTask{
name: "one",
command: []string{"/path/to/executable"},
},
&ServiceTask{
name: "two",
command: []string{"/path/to/executable"},
},
}
tasks.crones = []wminit.CronTask{
&Cron{
CronSchedule: CronSchedule{
Minute: NewCronAnySpec(),
Hour: NewCronAnySpec(),
DoM: NewCronAnySpec(),
Month: NewCronAnySpec(),
DoW: NewCronAnySpec(),
},
name: "cron-one",
command: []string{"/path/to/executable"},
},
&Cron{
CronSchedule: CronSchedule{
Minute: NewCronAnySpec(),
Hour: NewCronAnySpec(),
DoM: NewCronAnySpec(),
Month: NewCronAnySpec(),
DoW: NewCronAnySpec(),
},
name: "cron-two",
command: []string{"/path/to/executable"},
},
}
tl := tasks.List()
tnames := make([]string, 0)
testNames := []string{"one", "two", "cron-one", "cron-two"}
for _, ti := range tl {
tnames = append(tnames, ti.Name())
}
assert.ElementsMatch(t, testNames, tnames)
}