Compare commits

..

13 Commits

18 changed files with 765 additions and 216 deletions

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,6 +1,13 @@
DESTDIR = /usr/local/bin DESTDIR = /usr/local/bin
all: wingmate dummy oneshot spawner starter pidproxy exec installs = install-dir
programs = wingmate pidproxy exec
ifdef TEST_BUILD
programs += oneshot spawner starter dummy
installs += install-test
endif
all: ${programs}
wingmate: wingmate:
$(MAKE) -C cmd/wingmate all $(MAKE) -C cmd/wingmate all
@ -32,12 +39,16 @@ clean:
$(MAKE) -C cmd/experiment/spawner clean $(MAKE) -C cmd/experiment/spawner clean
$(MAKE) -C cmd/experiment/starter clean $(MAKE) -C cmd/experiment/starter clean
install: install: ${installs}
install -d ${DESTDIR}
$(MAKE) -C cmd/wingmate DESTDIR=${DESTDIR} install $(MAKE) -C cmd/wingmate DESTDIR=${DESTDIR} install
$(MAKE) -C cmd/pidproxy DESTDIR=${DESTDIR} install $(MAKE) -C cmd/pidproxy DESTDIR=${DESTDIR} install
$(MAKE) -C cmd/exec DESTDIR=${DESTDIR} install $(MAKE) -C cmd/exec DESTDIR=${DESTDIR} install
install-test:
$(MAKE) -C cmd/experiment/dummy DESTDIR=${DESTDIR} install $(MAKE) -C cmd/experiment/dummy DESTDIR=${DESTDIR} install
$(MAKE) -C cmd/experiment/oneshot DESTDIR=${DESTDIR} install $(MAKE) -C cmd/experiment/oneshot DESTDIR=${DESTDIR} install
$(MAKE) -C cmd/experiment/spawner DESTDIR=${DESTDIR} install $(MAKE) -C cmd/experiment/spawner DESTDIR=${DESTDIR} install
$(MAKE) -C cmd/experiment/starter DESTDIR=${DESTDIR} install $(MAKE) -C cmd/experiment/starter DESTDIR=${DESTDIR} install
install-dir:
install -d ${DESTDIR}

137
README.md
View File

@ -1,2 +1,139 @@
# wingmate # wingmate
Wingmate is a process manager for services. It works like init. It starts and restarts services.
It also has cron feature. It is designed to run in a container/docker.
The Wingmate binary do not need any external dependency.
Just copy the binary, and exec from the entry point script.
# Getting Started
## Binaries
There are three binaries in this project: `wingmate`, `wmpidproxy`, and `wmexec`.
`wingmate` is the core binary. It reads config, starts, restarts services. It also
runs cron. Read the [configuration](#configuration) section for files needed to run
`wingmate`.
`wmpidproxy` is a helper binary for monitoring _legacy style_ service (fork, exit
initial proces, and continue in background). Read [here](#wingmate-pid-proxy-binary)
for further details about `wmpidproxy`.
`wmexec` is a helper binary for running process in different `user` or `group`.
It also useful for setting the process as process group leader.
Read [here](#wingmate-exec-binary) for further details about `wmexec`.
## Building a container image based on wingmate image in Docker Hub
Wingmate has no dependency other than `alpine` base image, so you just need to copy
the binaries directly. If you have built your application into an `alpine` based image,
all you need to do is copy whichever binary you need, crontab file (if you use cron)
and add some shell script to glue them together. Here is a Dockerfile example.
```Dockerfile
# Dockerfile
FROM suyono/wingmate:alpine as source
FROM alpine:latest
ADD --chmod=755 wingmate/ /etc/wingmate/
ADD --chmod=755 entry.sh /usr/local/bin/entry.sh
COPY --from=source /usr/local/bin/wingmate /usr/local/bin/wingmate
COPY --from=source /usr/local/bin/wmpidproxy /usr/local/bin/wmpidproxy
COPY --from=source /usr/local/bin/wmexec /usr/local/bin/wmexec
ENTRYPOINT [ "/usr/local/bin/entry.sh" ]
CMD [ "/usr/local/bin/wingmate" ]
```
You can find some examples for shell script in [alpine docker](docker/alpine) and
[bookworm docker](docker/bookworm).
## Configuration
When `wingmate` binary starts, it will look for some files. By default, it will
try to read the content of `/etc/wingmate` directory. You can change the directory
where it reads by setting `WINGMATE_CONFIG_PATH` environment variable. The structure
inside the config path should look like this.
```shell
/etc
└── wingmate
├── crontab
├── crontab.d
│   ├── cron1.sh
│   ├── cron2.sh
│   └── cron3.sh
└── service
├── one.sh
└── spawner.sh
```
First, `wingmate` will try to read the content of `service` directory. The content of
this directory should be executables (either shell scripts or binaries). The wingmate
will run every executable in `service` directory without going into any subdirectory.
Next, `wingmate` will read the `crontab` file. `wingmate` expects the `crontab` file using
common UNIX crontab file format. Something like this
```shell
┌───────────── minute (059)
│ ┌───────────── hour (023)
│ │ ┌───────────── day of the month (131)
│ │ │ ┌───────────── month (112)
│ │ │ │ ┌───────────── day of the week (06) (Sunday to Saturday)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
* * * * * <commad or shell script or binary>
```
The command part only support simple command and arguments. Shell expression is not supported
yet. It is recommended to write a shell script and put the path to shell script in
the command part.
# Appendix
## Wingmate PID Proxy binary
`wingmate` works by monitoring its direct children process. When it sees one of its children
exited, it will start the child process again.
Sometimes you find some services work by running in the background. It means it forks a new
process, disconnect the new child from terminal, exit the parent process, and continue
running in the child process. This kind of service usually write its background process
PID in a pid file.
To monitor the background services, `wingmate` utilizes `wmpidproxy`. `wmpidproxy` runs
in foreground in-place of the background service. It also periodically check whether the
background service is still running, in current implementation it checks every second.
```shell
wmpidproxy --pid-file <path to pid file> -- <background service binary/start script>
```
#### Example
Running sshd background with `wingmate` and `wmpidproxy`: [here](example/ssh-docker)
#### Note
It is recommended to configure services to run in foreground if they support it. When services
running in foreground, they are running as direct children process of `wingmate`.
`wingmate` monitors children process effectively. Whenever a child process exited/terminated,
`wingmate` will start it again quickly. Running in foreground also removes the overhead of running
`wmpidproxy` together with the service.
## Wingmate Exec binary
`wingmate` runs all the services as its children using the same `uid`, `gid`, and in the
same process group. You can use `wmexec` to run service in different `uid`, `gid`, or make
the service process as its own process group leader.
#### Syntax
```shell
wmexec [--user <uid>[:<gid>]] [--setsid] -- <target executable>
```
| Option | Parameter | Description |
|----------|-----------|----------------------------------------------------------------------------------------------------------|
| --user | uid[:gid] | Set the real user ID and the real group id. Uid and Gid can be either in numeric form or in name form |
| --setsid | | Set the process become the leader of its own process group, effectively detaching from parent's terminal |
#### Example
You can find example for `wmexec` in [here](docker/alpine/etc/wingmate) and [here](docker/bookworm/etc/wingmate)

View File

@ -1,6 +1,8 @@
package main package main
import ( import (
"bufio"
"errors"
"log" "log"
"os" "os"
"os/exec" "os/exec"
@ -92,13 +94,13 @@ func pidProxy(cmd *cobra.Command, args []string) error {
err error err error
pid int pid int
sc chan os.Signal sc chan os.Signal
t *time.Timer t *time.Ticker
) )
sc = make(chan os.Signal, 1) sc = make(chan os.Signal, 1)
signal.Notify(sc, unix.SIGTERM) signal.Notify(sc, unix.SIGTERM)
t = time.NewTimer(time.Second) t = time.NewTicker(time.Second)
check: check:
for { for {
@ -130,8 +132,6 @@ func readPid(pidFile string) (int, error) {
var ( var (
file *os.File file *os.File
err error err error
buf []byte
n int
pid64 int64 pid64 int64
) )
@ -142,18 +142,15 @@ func readPid(pidFile string) (int, error) {
_ = file.Close() _ = file.Close()
}() }()
buf = make([]byte, 1024) scanner := bufio.NewScanner(file)
n, err = file.Read(buf) if scanner.Scan() {
if err != nil { if pid64, err = strconv.ParseInt(scanner.Text(), 10, 64); err != nil {
return 0, err return 0, err
} }
pid64, err = strconv.ParseInt(string(buf[:n]), 10, 64)
if err != nil {
return 0, err
}
return int(pid64), nil return int(pid64), nil
} else {
return 0, errors.New("invalid scanner")
}
} }
func startProcess(arg0 string, args ...string) { func startProcess(arg0 string, args ...string) {

View File

@ -1,60 +1,53 @@
package main package main
import ( import (
"time"
"gitea.suyono.dev/suyono/wingmate/config" "gitea.suyono.dev/suyono/wingmate/config"
wminit "gitea.suyono.dev/suyono/wingmate/init" 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 { type wConfig struct {
services []wminit.Path tasks *task.Tasks
cron []wminit.Cron
} }
func (c wConfig) Services() []wminit.Path { func (c *wConfig) Tasks() wminit.Tasks {
return c.services return c.tasks
} }
func (c wConfig) Cron() []wminit.Cron { func convert(cfg *config.Config) *wConfig {
return c.cron retval := &wConfig{
} tasks: task.NewTasks(),
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)),
} }
for _, s := range cfg.ServicePaths { for _, s := range cfg.ServicePaths {
retval.services = append(retval.services, wPath{path: s}) retval.tasks.AddV0Service(s)
} }
var schedule task.CronSchedule
for _, c := range cfg.Cron { for _, c := range cfg.Cron {
retval.cron = append(retval.cron, wCron{iCron: c}) 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 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")
return nil
}

View File

@ -10,33 +10,35 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestRead(t *testing.T) { const (
serviceDir = "service"
type testEntry struct { )
name string
testFunc func(t *testing.T)
}
var ( var (
configDir string configDir string
err error
) )
const serviceDir = "service" func setup(t *testing.T) {
var err error
setup := func(t *testing.T) {
if configDir, err = os.MkdirTemp("", "wingmate-*-test"); err != nil { if configDir, err = os.MkdirTemp("", "wingmate-*-test"); err != nil {
t.Fatal("setup", err) t.Fatal("setup", err)
} }
viper.Set(EnvConfigPath, configDir) viper.Set(EnvConfigPath, configDir)
} }
tear := func(t *testing.T) { func tear(t *testing.T) {
if err = os.RemoveAll(configDir); err != nil { if err := os.RemoveAll(configDir); err != nil {
t.Fatal("tear", err) t.Fatal("tear", err)
} }
} }
func TestRead(t *testing.T) {
type testEntry struct {
name string
testFunc func(t *testing.T)
}
mkSvcDir := func(t *testing.T) { mkSvcDir := func(t *testing.T) {
if err := os.MkdirAll(path.Join(configDir, serviceDir), 0755); err != nil { if err := os.MkdirAll(path.Join(configDir, serviceDir), 0755); err != nil {
t.Fatal("create dir", err) t.Fatal("create dir", err)

View File

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

128
config/crontab_test.go Normal file
View File

@ -0,0 +1,128 @@
package config
import (
"gitea.suyono.dev/suyono/wingmate"
"github.com/stretchr/testify/assert"
"os"
"path/filepath"
"testing"
)
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.Cron {
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 * a /path/to/executable
76 13 3,5,7 * a /path/to/executable
*/75 13 3,5,7 * a /path/to/executable
*/5 13 3,x,7 * a /path/to/executable
*/5 13 3,5,67 * a /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"))
}

View File

@ -2,6 +2,7 @@ FROM golang:1.21-alpine as builder
ADD . /root/wingmate ADD . /root/wingmate
WORKDIR /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 add make build-base && CGO_ENABLED=1 make all && make DESTDIR=/usr/local/bin/wingmate install

View File

@ -2,6 +2,7 @@ FROM golang:1.21-bookworm as builder
ADD . /root/wingmate ADD . /root/wingmate
WORKDIR /root/wingmate/ WORKDIR /root/wingmate/
ARG TEST_BUILD
RUN make all && make DESTDIR=/usr/local/bin/wingmate install RUN make all && make DESTDIR=/usr/local/bin/wingmate install

View File

@ -0,0 +1,14 @@
FROM suyono/wingmate:alpine as source
FROM alpine:3.19
RUN apk update && apk add tzdata openssh-server && \
ln -s /usr/share/zoneinfo/Australia/Sydney /etc/localtime && ssh-keygen -A
COPY --from=source /usr/local/bin/wingmate /usr/local/bin/
COPY --from=source /usr/local/bin/wmpidproxy /usr/local/bin/
ADD --chmod=755 example/ssh-docker/entry.sh /usr/local/bin/entry.sh
ADD --chmod=755 example/ssh-docker/etc /etc
ENTRYPOINT [ "/usr/local/bin/entry.sh" ]
CMD [ "/usr/local/bin/wingmate" ]

View File

@ -0,0 +1,7 @@
#!/bin/sh
if [ $# -gt 0 ]; then
exec "$@"
else
exec /usr/local/bin/wingmate
fi

View File

@ -0,0 +1,3 @@
#!/bin/sh
exec /usr/local/bin/wmpidproxy --pid-file /var/run/sshd.pid -- /usr/sbin/sshd

View File

@ -13,7 +13,7 @@ const (
cronTag = "cron" 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() defer wg.Done()
var ( var (
@ -21,35 +21,40 @@ func (i *Init) cron(wg *sync.WaitGroup, cron Cron, exitFlag <-chan any) {
err error err error
stdout io.ReadCloser stdout io.ReadCloser
stderr io.ReadCloser stderr io.ReadCloser
cmd *exec.Cmd
) )
ticker := time.NewTicker(time.Second * 30) ticker := time.NewTicker(time.Second * 30)
cron: cron:
for { for {
if cron.TimeToRun(time.Now()) { if cron.TimeToRun(time.Now()) {
wingmate.Log().Info().Str(cronTag, cron.Command().Path()).Msg("executing") wingmate.Log().Info().Str(cronTag, cron.Name()).Msg("executing")
cmd := exec.Command(cron.Command().Path()) if len(cron.Command()) == 1 {
cmd = exec.Command(cron.Command()[0])
} else {
cmd = exec.Command(cron.Command()[0], cron.Command()[1:]...)
}
iwg = &sync.WaitGroup{} iwg = &sync.WaitGroup{}
if stdout, err = cmd.StdoutPipe(); err != nil { 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 goto fail
} }
if stderr, err = cmd.StderrPipe(); err != nil { 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() _ = stdout.Close()
goto fail goto fail
} }
iwg.Add(1) iwg.Add(1)
go i.pipeReader(iwg, stdout, cronTag, cron.Command().Path()) go i.pipeReader(iwg, stdout, cronTag, cron.Name())
iwg.Add(1) 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 { 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() _ = stdout.Close()
_ = stderr.Close() _ = stderr.Close()
iwg.Wait() iwg.Wait()
@ -59,7 +64,7 @@ cron:
iwg.Wait() iwg.Wait()
if err = cmd.Wait(); err != nil { 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,37 @@ import (
"time" "time"
) )
type Path interface { type Tasks interface {
Path() string List() []Task
Services() []Task
Crones() []CronTask
Get(string) (Task, error)
} }
type Cron interface { type UserGroup interface {
Command() Path }
type TaskStatus interface {
}
type Task interface {
Name() string
Command() []string
Environ() []string
Setsid() bool
UserGroup() UserGroup
Background() bool
WorkingDir() string
Status() TaskStatus
}
type CronTask interface {
Task
TimeToRun(time.Time) bool TimeToRun(time.Time) bool
} }
type Config interface { type Config interface {
Services() []Path Tasks() Tasks
Cron() []Cron
} }
type Init struct { type Init struct {
@ -49,12 +68,12 @@ func (i *Init) Start() {
wg.Add(1) wg.Add(1)
go i.sighandler(wg, signalTrigger, sighandlerExit, sigchld) go i.sighandler(wg, signalTrigger, sighandlerExit, sigchld)
for _, s := range i.config.Services() { for _, s := range i.config.Tasks().Services() {
wg.Add(1) wg.Add(1)
go i.service(wg, s, signalTrigger) go i.service(wg, s, signalTrigger)
} }
for _, c := range i.config.Cron() { for _, c := range i.config.Tasks().Crones() {
wg.Add(1) wg.Add(1)
go i.cron(wg, c, signalTrigger) go i.cron(wg, c, signalTrigger)
} }

View File

@ -14,7 +14,7 @@ const (
serviceTag = "service" 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() defer wg.Done()
var ( var (
@ -23,37 +23,42 @@ func (i *Init) service(wg *sync.WaitGroup, path Path, exitFlag <-chan any) {
stderr io.ReadCloser stderr io.ReadCloser
stdout io.ReadCloser stdout io.ReadCloser
failStatus bool failStatus bool
cmd *exec.Cmd
) )
defer func() { defer func() {
wingmate.Log().Info().Str(serviceTag, path.Path()).Msg("stopped") wingmate.Log().Info().Str(serviceTag, task.Name()).Msg("stopped")
}() }()
service: service:
for { for {
failStatus = false 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{} iwg = &sync.WaitGroup{}
if stdout, err = cmd.StdoutPipe(); err != nil { 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 failStatus = true
goto fail goto fail
} }
iwg.Add(1) 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 { 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() _ = stdout.Close()
failStatus = true failStatus = true
goto fail goto fail
} }
iwg.Add(1) 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 { 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 failStatus = true
_ = stdout.Close() _ = stdout.Close()
_ = stderr.Close() _ = stderr.Close()
@ -64,7 +69,7 @@ service:
iwg.Wait() iwg.Wait()
if err = cmd.Wait(); err != nil { 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: fail:
if failStatus { 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
}

95
task/task.go Normal file
View File

@ -0,0 +1,95 @@
package task
import (
wminit "gitea.suyono.dev/suyono/wingmate/init"
)
type Tasks struct {
services []wminit.Task
crones []wminit.CronTask
}
func NewTasks() *Tasks {
return &Tasks{
services: make([]wminit.Task, 0),
crones: make([]wminit.CronTask, 0),
}
}
func (ts *Tasks) AddV0Service(path string) {
ts.services = append(ts.services, &Task{
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},
})
}
func (ts *Tasks) List() []wminit.Task {
panic("not implemented")
return nil
}
func (ts *Tasks) Services() []wminit.Task {
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 Task struct {
name string
command []string
}
func (t *Task) Name() string {
return t.name
}
func (t *Task) Command() []string {
retval := make([]string, len(t.command))
copy(retval, t.command)
return retval
}
func (t *Task) Environ() []string {
panic("not implemented")
return nil
}
func (t *Task) Setsid() bool {
panic("not implemented")
return false
}
func (t *Task) UserGroup() wminit.UserGroup {
panic("not implemented")
return nil
}
func (t *Task) Background() bool {
panic("not implemented")
return false
}
func (t *Task) WorkingDir() string {
panic("not implemented")
return ""
}
func (t *Task) Status() wminit.TaskStatus {
panic("not implemented")
return nil
}