40 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
b2668287ae Merge pull request 'README.md + Example + Docker build args + wmpidproxy fix' (#1) from readme into main
Reviewed-on: #1
2023-12-26 20:57:04 +00:00
08a040ac72 doc: getting-started 2023-12-27 07:51:31 +11:00
6df8ba2810 fix doc: dir reference 2023-12-26 10:00:04 +11:00
7785b3ec48 fix(pidproxy): replace Timer with Ticker
tested: ssh-docker
2023-12-26 09:53:37 +11:00
70a4d132c3 Merge branch 'main' into readme 2023-12-26 09:21:09 +11:00
b15066b513 fix(pidproxy): handle new line
example: sshd
2023-12-26 09:20:42 +11:00
e2275ef05e wip doc: merge getting started to README.md 2023-12-26 08:23:34 +11:00
541228bf68 wip: writing documentation 2023-12-25 13:38:18 +11:00
a87c568335 wip: config unit test 2023-12-25 13:13:03 +11:00
574d3deb56 wip: getting started 2023-12-19 15:54:28 +11:00
5679faecdb chore: exclude test binary on default build 2023-12-18 12:28:14 +00:00
2a4629c017 wip: README.md 2023-12-18 22:45:19 +11:00
c043c91f0e chore(Makefiles): to simplify Dockerfile
fix(wmexec): setgid before setuid
test(wmexec): tested
2023-12-17 23:45:03 +00:00
15a804aa7d feat(exec): cgo call to getpwnam and getgrnam 2023-12-17 05:23:26 +00:00
653b4ff158 feat(exec): initial 2023-12-17 03:36:47 +00:00
8cf92167df chore: fix .gitignore
feat(waiter): waiting for signal
2023-12-14 00:11:39 +00:00
9c74296c27 chore(pidproxy): remove binary from git and update .gitignore 2023-12-12 01:25:31 +00:00
8a85ad5107 feat(pidproxy): added signal handler 2023-12-12 00:29:32 +00:00
ad8499daa5 feat(pidproxy): read startsecs from env
chore: centralize env prefix
2023-12-10 22:23:43 +11:00
8704f80d4b docker: added bookworm image 2023-12-10 10:13:21 +00:00
dbe9dbba9c test: prep pidproxy 2023-12-10 09:37:55 +00:00
4ec5750cd5 feat: pidproxy and Makefile 2023-12-10 05:39:37 +00:00
3dc69325c1 test: add more cron 2023-12-09 10:42:05 +00:00
2eae19f64c fix: env binding in oneshot and less output from dummy 2023-12-09 09:58:49 +00:00
b589fb8f0c debug: adjust cron ticker interval & debug message 2023-12-09 09:35:16 +00:00
dd66cb9f1e test: prepare 2023-12-09 08:40:07 +00:00
d5eb872b13 fix: mismatch WaitGroup 2023-12-09 04:29:38 +00:00
9128503da1 wip: pipe stdout & stderr 2023-12-09 02:44:06 +00:00
d9d1fe72d4 fix: setting last run when true
improve(dev): set mount volume to speed up dev container start
2023-12-08 15:15:35 +11:00
2971f5c709 wip: ready for initial test 2023-12-08 15:15:35 +11:00
bd4ba67ad2 wip: config 2023-12-08 15:15:35 +11:00
eb7bde3cbe wip 2023-12-08 15:15:35 +11:00
a4ba011b36 wip: start simple 2023-12-08 15:15:35 +11:00
66 changed files with 2650 additions and 233 deletions

View File

@@ -1,10 +1,25 @@
{
"name": "Golang Dev",
"image": "golang-dev:1.21-bookworm-user",
"mounts": [
{
"source": "WingmateGoPath",
"target": "/go",
"type": "volume"
},
{
"source": "WingmateGolangDevHome",
"target": "/home/golang",
"type": "volume"
}
],
"customizations": {
"vscode": {
"extensions": [
"golang.go"
"golang.go",
"ms-azuretools.vscode-docker",
"ms-vscode.makefile-tools",
"ms-vscode.cpptools-extension-pack"
]
}
}

8
.gitignore vendored
View File

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

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/wingmate.iml" filepath="$PROJECT_DIR$/.idea/wingmate.iml" />
</modules>
</component>
</project>

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>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

9
.idea/wingmate.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1 +1 @@
golang 1.21.4
golang 1.21.5

54
Makefile Normal file
View File

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

10
cmd/exec/Makefile Normal file
View File

@@ -0,0 +1,10 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:
install exec ${DESTDIR}/wmexec

11
cmd/exec/ent.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build !(cgo && linux)
package main
func getUid(user string) (uint64, error) {
panic("not implemented")
}
func getGid(group string) (uint64, error) {
panic("not implemented")
}

51
cmd/exec/ent_lin.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build cgo && linux
package main
/*
#include<errno.h>
#include<string.h>
#include<sys/types.h>
#include<pwd.h>
#include<grp.h>
static uid_t getuid(const char* username) {
struct passwd local, *rv;
errno = 0;
rv = getpwnam(username);
if (errno != 0) {
return 0;
}
memcpy(&local, rv, sizeof(struct passwd));
return local.pw_uid;
}
static gid_t getgid(const char* groupname) {
struct group local, *rv;
errno = 0;
rv = getgrnam(groupname);
if (errno != 0) {
return 0;
}
memcpy(&local, rv, sizeof(struct group));
return local.gr_gid;
}
*/
import "C"
func getUid(user string) (uint64, error) {
u, err := C.getuid(C.CString(user))
if err != nil {
return 0, err
}
return uint64(u), nil
}
func getGid(group string) (uint64, error) {
g, err := C.getgid(C.CString(group))
if err != nil {
return 0, err
}
return uint64(g), nil
}

133
cmd/exec/exec.go Normal file
View File

@@ -0,0 +1,133 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"strings"
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sys/unix"
)
const (
setsidFlag = "setsid"
EnvSetsid = "SETSID"
userFlag = "user"
EnvUser = "USER"
)
var (
rootCmd = &cobra.Command{
Use: "wmexec",
RunE: execCmd,
}
childArgs []string
)
func main() {
var (
found bool
i int
arg string
selfArgs []string
)
rootCmd.PersistentFlags().BoolP(setsidFlag, "s", false, "set to true to run setsid() before exec")
viper.BindPFlag(EnvSetsid, rootCmd.PersistentFlags().Lookup(setsidFlag))
rootCmd.PersistentFlags().StringP(userFlag, "u", "", "\"user:[group]\"")
viper.BindPFlag(EnvUser, rootCmd.PersistentFlags().Lookup(userFlag))
viper.SetEnvPrefix(wingmate.EnvPrefix)
viper.BindEnv(EnvUser)
viper.BindEnv(EnvSetsid)
viper.SetDefault(EnvSetsid, false)
viper.SetDefault(EnvUser, "")
found = false
for i, arg = range os.Args {
if arg == "--" {
found = true
if len(os.Args) <= i+1 {
log.Println("invalid argument")
os.Exit(1)
}
selfArgs = os.Args[1:i]
childArgs = os.Args[i+1:]
break
}
}
if !found {
log.Println("invalid argument")
os.Exit(1)
}
if len(childArgs) == 0 {
log.Println("invalid argument")
os.Exit(1)
}
rootCmd.SetArgs(selfArgs)
if err := rootCmd.Execute(); err != nil {
log.Println(err)
os.Exit(1)
}
}
func execCmd(cmd *cobra.Command, args []string) error {
if viper.GetBool(EnvSetsid) {
_, _ = unix.Setsid()
}
var (
uid uint64
gid uint64
err error
path string
)
ug := viper.GetString(EnvUser)
if len(ug) > 0 {
user, group, ok := strings.Cut(ug, ":")
if ok {
if gid, err = strconv.ParseUint(group, 10, 32); err != nil {
if gid, err = getGid(group); err != nil {
return fmt.Errorf("cgo getgid: %w", err)
}
}
if err = unix.Setgid(int(gid)); err != nil {
return fmt.Errorf("setgid: %w", err)
}
}
uid, err = strconv.ParseUint(user, 10, 32)
if err != nil {
if uid, err = getUid(user); err != nil {
return fmt.Errorf("cgo getuid: %w", err)
}
}
if err = unix.Setuid(int(uid)); err != nil {
return fmt.Errorf("setuid: %w", err)
}
}
if path, err = exec.LookPath(childArgs[0]); err != nil {
if !errors.Is(err, exec.ErrDot) {
return fmt.Errorf("lookpath: %w", err)
}
}
if err = unix.Exec(path, childArgs, os.Environ()); err != nil {
return fmt.Errorf("exec: %w", err)
}
return nil
}

8
cmd/experiment/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,10 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:
install dummy ${DESTDIR}/wmdummy

View File

@@ -0,0 +1,17 @@
package main
import (
"fmt"
"log"
"time"
)
func main() {
log.Println("using log.Println")
fmt.Println("using fmt.Println")
time.Sleep(time.Second * 5)
log.Println("log: finishing up")
fmt.Println("fmt: finishing up")
}

View File

@@ -0,0 +1,10 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:
install oneshot ${DESTDIR}/wmoneshot

View File

@@ -0,0 +1,78 @@
package main
import (
"log"
"math/rand"
"os"
"os/exec"
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/viper"
)
const (
// DummyPath = "/workspaces/wingmate/cmd/experiment/dummy/dummy"
DummyPath = "/usr/local/bin/wmdummy"
EnvDummyPath = "DUMMY_PATH"
EnvLog = "LOG"
EnvLogMessage = "LOG_MESSAGE"
EnvDefaultLogMessage = "oneshot executed"
EnvInstanceNum = "INSTANCE_NUM"
EnvDefaultInstances = -1
)
func main() {
viper.SetEnvPrefix(wingmate.EnvPrefix)
viper.BindEnv(EnvDummyPath)
viper.BindEnv(EnvLog)
viper.BindEnv(EnvLogMessage)
viper.BindEnv(EnvInstanceNum)
viper.SetDefault(EnvDummyPath, DummyPath)
viper.SetDefault(EnvLogMessage, EnvDefaultLogMessage)
viper.SetDefault(EnvInstanceNum, EnvDefaultInstances)
exePath := viper.GetString(EnvDummyPath)
logPath := viper.GetString(EnvLog)
logMessage := viper.GetString(EnvLogMessage)
log.Println("log path:", logPath)
if logPath != "" {
var (
err error
file *os.File
)
if file, err = os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o666); err == nil {
defer func() {
_ = file.Close()
}()
if err = wingmate.NewLog(file); err == nil {
wingmate.Log().Info().Msg(logMessage)
}
}
}
StartInstances(exePath)
}
func StartInstances(exePath string) {
num := (rand.Uint32() % 16) + 16
iNum := viper.GetInt(EnvInstanceNum)
if iNum > 0 {
num = uint32(iNum)
}
var (
ctr uint32
cmd *exec.Cmd
err error
)
for ctr = 0; ctr < num; ctr++ {
cmd = exec.Command(exePath)
if err = cmd.Start(); err != nil {
log.Printf("failed to run %s: %+v\n", exePath, err)
}
}
}

View File

@@ -0,0 +1,10 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:
install spawner ${DESTDIR}/wmspawner

View File

@@ -0,0 +1,40 @@
package main
import (
"log"
"os/exec"
"time"
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/viper"
)
const (
EnvOneShotPath = "ONESHOT_PATH"
OneShotPath = "/usr/local/bin/wmoneshot"
)
func main() {
var (
cmd *exec.Cmd
err error
t *time.Ticker
)
viper.SetEnvPrefix(wingmate.EnvPrefix)
viper.BindEnv(EnvOneShotPath)
viper.SetDefault(EnvOneShotPath, OneShotPath)
exePath := viper.GetString(EnvOneShotPath)
t = time.NewTicker(time.Second * 5)
for {
cmd = exec.Command(exePath)
if err = cmd.Run(); err != nil {
log.Printf("failed to run %s: %+v\n", exePath, err)
} else {
log.Printf("%s executed\n", exePath)
}
<-t.C
}
}

View File

@@ -0,0 +1,10 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:
install starter ${DESTDIR}/wmstarter

View File

@@ -0,0 +1,73 @@
package main
import (
"bufio"
"io"
"log"
"os/exec"
"sync"
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/viper"
)
const (
// DummyPath = "/workspaces/wingmate/cmd/experiment/dummy/dummy"
DummyPath = "/usr/local/bin/wmdummy"
EnvDummyPath = "DUMMY_PATH"
)
func main() {
var (
stdout io.ReadCloser
stderr io.ReadCloser
wg *sync.WaitGroup
err error
exePath string
)
viper.SetEnvPrefix(wingmate.EnvPrefix)
viper.BindEnv(EnvDummyPath)
viper.SetDefault(EnvDummyPath, DummyPath)
exePath = viper.GetString(EnvDummyPath)
cmd := exec.Command(exePath)
if stdout, err = cmd.StdoutPipe(); err != nil {
log.Panic(err)
}
if stderr, err = cmd.StderrPipe(); err != nil {
log.Panic(err)
}
wg = &sync.WaitGroup{}
wg.Add(2)
go pulley(wg, stdout, "stdout")
go pulley(wg, stderr, "stderr")
if err = cmd.Start(); err != nil {
log.Panic(err)
}
wg.Wait()
if err = cmd.Wait(); err != nil {
log.Printf("got error when Waiting for child process: %#v\n", err)
}
}
func pulley(wg *sync.WaitGroup, src io.ReadCloser, srcName string) {
defer wg.Done()
scanner := bufio.NewScanner(src)
for scanner.Scan() {
log.Printf("coming out from %s: %s\n", srcName, scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Printf("got error whean reading from %s: %#v\n", srcName, err)
}
log.Printf("closing %s...\n", srcName)
_ = src.Close()
}

10
cmd/pidproxy/Makefile Normal file
View File

@@ -0,0 +1,10 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:
install pidproxy ${DESTDIR}/wmpidproxy

161
cmd/pidproxy/pidproxy.go Normal file
View File

@@ -0,0 +1,161 @@
package main
import (
"bufio"
"errors"
"log"
"os"
"os/exec"
"os/signal"
"strconv"
"syscall"
"time"
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sys/unix"
)
const (
pidFileFlag = "pid-file"
EnvStartSecs = "STARTSECS"
EnvDefaultStartSecs = 1
)
var (
rootCmd = &cobra.Command{
Use: "wmpidproxy",
RunE: pidProxy,
}
childArgs []string
)
func main() {
var (
i int
arg string
selfArgs []string
found bool
)
viper.SetEnvPrefix(wingmate.EnvPrefix)
viper.BindEnv(EnvStartSecs)
viper.SetDefault(EnvStartSecs, EnvDefaultStartSecs)
rootCmd.PersistentFlags().StringP(pidFileFlag, "p", "", "location of pid file")
rootCmd.MarkFlagRequired(pidFileFlag)
viper.BindPFlag(pidFileFlag, rootCmd.PersistentFlags().Lookup(pidFileFlag))
found = false
for i, arg = range os.Args {
if arg == "--" {
found = true
if len(os.Args) <= i+1 {
log.Println("invalid argument")
os.Exit(1)
}
selfArgs = os.Args[1:i]
childArgs = os.Args[i+1:]
break
}
}
if !found {
log.Println("invalid argument")
os.Exit(1)
}
if len(childArgs) == 0 {
log.Println("invalid argument")
os.Exit(1)
}
rootCmd.SetArgs(selfArgs)
if err := rootCmd.Execute(); err != nil {
log.Println(err)
os.Exit(1)
}
}
func pidProxy(cmd *cobra.Command, args []string) error {
pidfile := viper.GetString(pidFileFlag)
log.Printf("%s %v", pidfile, childArgs)
if len(childArgs) > 1 {
go startProcess(childArgs[0], childArgs[1:]...)
} else {
go startProcess(childArgs[0])
}
initialWait := viper.GetInt(EnvStartSecs)
time.Sleep(time.Second * time.Duration(initialWait))
var (
err error
pid int
sc chan os.Signal
t *time.Ticker
)
sc = make(chan os.Signal, 1)
signal.Notify(sc, unix.SIGTERM)
t = time.NewTicker(time.Second)
check:
for {
if pid, err = readPid(pidfile); err != nil {
return err
}
if err = unix.Kill(pid, syscall.Signal(0)); err != nil {
return err
}
select {
case <-t.C:
case <-sc:
if pid, err = readPid(pidfile); err != nil {
return err
}
if err = unix.Kill(pid, unix.SIGTERM); err != nil {
return err
}
break check
}
}
return nil
}
func readPid(pidFile string) (int, error) {
var (
file *os.File
err error
pid64 int64
)
if file, err = os.Open(pidFile); err != nil {
return 0, err
}
defer func() {
_ = file.Close()
}()
scanner := bufio.NewScanner(file)
if scanner.Scan() {
if pid64, err = strconv.ParseInt(scanner.Text(), 10, 64); err != nil {
return 0, err
}
return int(pid64), nil
} else {
return 0, errors.New("invalid scanner")
}
}
func startProcess(arg0 string, args ...string) {
if err := exec.Command(arg0, args...).Run(); err != nil {
log.Println("exec:", err)
return
}
}

11
cmd/wingmate/Makefile Normal file
View File

@@ -0,0 +1,11 @@
all:
git describe > version.txt
go build -v
clean:
rm version.txt
go clean -i -cache -testcache
install:
install wingmate ${DESTDIR}/wingmate

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

@@ -7,19 +7,56 @@ import (
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/viper"
"golang.org/x/sys/unix"
)
const (
EnvPrefix = "WINGMATE"
EnvConfigPath = "CONFIG_PATH"
DefaultConfigPath = "/etc/wingmate"
ServiceDirName = "service"
CrontabFileName = "crontab"
EnvPrefix = "WINGMATE"
EnvConfigPath = "CONFIG_PATH"
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) {
@@ -28,19 +65,22 @@ func Read() (*Config, error) {
viper.SetDefault(EnvConfigPath, DefaultConfigPath)
var (
dirent []os.DirEntry
err error
svcdir string
serviceAvailable bool
cronAvailable bool
cron []*Cron
crontabfile string
dirent []os.DirEntry
err 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)
@@ -48,26 +88,42 @@ func Read() (*Config, error) {
if len(dirent) > 0 {
for _, d := range dirent {
if d.Type().IsRegular() {
serviceAvailable = true
outConfig.ServicePaths = append(outConfig.ServicePaths, filepath.Join(svcdir, d.Name()))
svcPath := filepath.Join(svcdir, d.Name())
if err = unix.Access(svcPath, unix.X_OK); err == nil {
serviceAvailable = true
outConfig.ServiceV0 = append(outConfig.ServiceV0, svcPath)
}
}
}
}
if err != nil {
wingmate.Log().Error().Msgf("encounter error when reading service directory %s: %#v", svcdir, err)
wingmate.Log().Error().Msgf("encounter error when reading service directory %s: %+v", svcdir, err)
}
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)
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")
}

132
config/config_test.go Normal file
View File

@@ -0,0 +1,132 @@
package config
import (
"os"
"path"
"testing"
"gitea.suyono.dev/suyono/wingmate"
"github.com/spf13/viper"
"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 {
name string
testFunc func(t *testing.T)
}
mkSvcDir := func(t *testing.T) {
if err := os.MkdirAll(path.Join(configDir, serviceDir), 0755); err != nil {
t.Fatal("create dir", err)
}
}
touchFile := func(t *testing.T, name string, perm os.FileMode) {
f, err := os.OpenFile(name, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm)
if err != nil {
t.Fatal("create file", err)
}
_ = f.Close()
}
_ = wingmate.NewLog(os.Stderr)
tests := []testEntry{
{
name: "positive",
testFunc: func(t *testing.T) {
mkSvcDir(t)
touchFile(t, path.Join(configDir, serviceDir, "one.sh"), 0755)
touchFile(t, path.Join(configDir, serviceDir, "two.sh"), 0755)
cfg, err := Read()
assert.Nil(t, err)
assert.ElementsMatch(
t,
cfg.ServiceV0,
[]string{
path.Join(configDir, serviceDir, "one.sh"),
path.Join(configDir, serviceDir, "two.sh"),
},
)
},
},
{
name: "with directory",
testFunc: func(t *testing.T) {
const subdir1 = "subdir1"
mkSvcDir(t)
assert.Nil(t, os.Mkdir(path.Join(configDir, serviceDir, subdir1), 0755))
touchFile(t, path.Join(configDir, serviceDir, subdir1, "one.sh"), 0755)
touchFile(t, path.Join(configDir, serviceDir, "two.sh"), 0755)
cfg, err := Read()
assert.Nil(t, err)
assert.ElementsMatch(
t,
cfg.ServiceV0,
[]string{
path.Join(configDir, serviceDir, "two.sh"),
},
)
},
},
{
name: "wrong mode",
testFunc: func(t *testing.T) {
mkSvcDir(t)
touchFile(t, path.Join(configDir, serviceDir, "one.sh"), 0755)
touchFile(t, path.Join(configDir, serviceDir, "two.sh"), 0644)
cfg, err := Read()
assert.Nil(t, err)
assert.ElementsMatch(
t,
cfg.ServiceV0,
[]string{
path.Join(configDir, serviceDir, "one.sh"),
},
)
},
},
{
name: "empty",
testFunc: func(t *testing.T) {
mkSvcDir(t)
_, err := Read()
assert.NotNil(t, err)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup(t)
tt.testFunc(t)
tear(t)
})
}
}

View File

@@ -4,54 +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 interface {
// Minute() CronTimeSpec
// Hour() CronTimeSpec
// DayOfMonth() CronTimeSpec
// Month() CronTimeSpec
// DayOfWeek() CronTimeSpec
// }
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
@@ -60,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
}
@@ -87,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)
}
@@ -129,32 +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.hasRun {
c.lastRun = now
c.hasRun = true
return true
}
if now.Sub(c.lastRun) <= time.Minute && now.Minute() == c.lastRun.Minute() {
return false
}
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())) {
return true
}
return false
}
type fieldRange struct {
min int
max int
@@ -187,33 +149,33 @@ 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)
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)
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
@@ -222,7 +184,7 @@ func (c *Cron) setField(field cronField, input string) error {
current += parsed
}
*cField = &specMultiOccurrence{
*cField = &SpecMultiOccurrence{
values: multi,
}
} else {
@@ -231,31 +193,31 @@ func (c *Cron) setField(field cronField, input string) error {
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)
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)
if !fr.valid(parsed) {
return fmt.Errorf("error parse field %+v with input %s: invalid value", field, input)
}
multi = append(multi, parsed)
}
*cField = &specMultiOccurrence{
*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)
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)
if !fr.valid(parsed) {
return fmt.Errorf("error parse field %+v with input %s: invalid value", field, input)
}
*cField = &specExact{
*cField = &SpecExact{
value: parsed,
}
}
@@ -264,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 * *"`,
}

22
docker/alpine/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM golang:1.21-alpine as builder
ADD . /root/wingmate
WORKDIR /root/wingmate/
ARG TEST_BUILD
RUN apk update && apk add git make build-base && \
CGO_ENABLED=1 make all && \
make DESTDIR=/usr/local/bin/wingmate install
FROM alpine:3.18
RUN apk add tzdata && ln -s /usr/share/zoneinfo/Australia/Sydney /etc/localtime && \
adduser -h /home/user1 -D -s /bin/sh user1 && \
adduser -h /home/user2 -D -s /bin/sh user2
COPY --from=builder /usr/local/bin/wingmate/ /usr/local/bin/
ADD --chmod=755 docker/alpine/entry.sh /usr/local/bin/entry.sh
ADD --chmod=755 docker/alpine/etc /etc
ENTRYPOINT [ "/usr/local/bin/entry.sh" ]
CMD [ "/usr/local/bin/wingmate" ]

7
docker/alpine/entry.sh Normal file
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 @@
*/5 * * * * /etc/wingmate/crontab.d/cron1.sh
17,42 */2 * * * /etc/wingmate/crontab.d/cron2.sh
7,19,23,47 22 * * * /etc/wingmate/crontab.d/cron3.sh

View File

@@ -0,0 +1,9 @@
#!/bin/sh
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
export WINGMATE_LOG=/var/log/cron1.log
export WINGMATE_LOG_MESSAGE="cron executed in minute 5,10,15,20,25,30,35,40,45,50,55"
echo "I'm runnig with dummy=$WINGMATE_DUMMY_PATH, log=$WINGMATE_LOG and mesage=$WINGMATE_LOG_MESSAGE" >> /var/log/debug-cron.log
exec /usr/local/bin/wmoneshot

View File

@@ -0,0 +1,7 @@
#!/bin/sh
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
export WINGMATE_LOG=/var/log/cron2.log
export WINGMATE_LOG_MESSAGE="cron scheduled using 17,42 */2 * * *"
exec /usr/local/bin/wmoneshot

View File

@@ -0,0 +1,7 @@
#!/bin/sh
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
export WINGMATE_LOG=/var/log/cron3.log
export WINGMATE_LOG_MESSAGE="cron entry: 7,19,23,47 22 * * * /etc/wingmate/crontab.d/cron3.sh"
exec /usr/local/bin/wmoneshot

View File

@@ -0,0 +1,4 @@
#!/bin/sh
export DUMMY_PATH=/usr/local/bin/wmdummy
exec /usr/local/bin/wmexec --setsid --user user1:user1 -- /usr/local/bin/wmstarter

View File

@@ -0,0 +1,5 @@
#!/bin/sh
export WINGMATE_ONESHOT_PATH=/usr/local/bin/wmoneshot
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
exec /usr/local/bin/wmexec --user 1001 -- /usr/local/bin/wmspawner

View File

@@ -0,0 +1,20 @@
FROM golang:1.21-bookworm as builder
ADD . /root/wingmate
WORKDIR /root/wingmate/
ARG TEST_BUILD
RUN make all && make DESTDIR=/usr/local/bin/wingmate install
FROM debian:bookworm
RUN ln -sf /usr/share/zoneinfo/Australia/Sydney /etc/localtime && \
apt update && apt install -y procps && \
useradd -m -s /bin/bash user1
COPY --from=builder /usr/local/bin/wingmate/ /usr/local/bin/
ADD --chmod=755 docker/bookworm/entry.sh /usr/local/bin/entry.sh
ADD --chmod=755 docker/bookworm/etc /etc
ENTRYPOINT [ "/usr/local/bin/entry.sh" ]
CMD [ "/usr/local/bin/wingmate" ]

7
docker/bookworm/entry.sh Normal file
View File

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

View File

@@ -0,0 +1,3 @@
*/5 * * * * /etc/wingmate/crontab.d/cron1.sh
17,42 */2 * * * /etc/wingmate/crontab.d/cron2.sh
7,19,23,47 22 * * * /etc/wingmate/crontab.d/cron3.sh

View File

@@ -0,0 +1,9 @@
#!/usr/bin/bash
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
export WINGMATE_LOG=/var/log/cron1.log
export WINGMATE_LOG_MESSAGE="cron executed in minute 5,10,15,20,25,30,35,40,45,50,55"
echo "I'm runnig with dummy=$WINGMATE_DUMMY_PATH, log=$WINGMATE_LOG and mesage=$WINGMATE_LOG_MESSAGE" >> /var/log/debug-cron.log
exec /usr/local/bin/wmoneshot

View File

@@ -0,0 +1,7 @@
#!/usr/bin/bash
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
export WINGMATE_LOG=/var/log/cron2.log
export WINGMATE_LOG_MESSAGE="cron scheduled using 17,42 */2 * * *"
exec /usr/local/bin/wmoneshot

View File

@@ -0,0 +1,7 @@
#!/usr/bin/bash
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
export WINGMATE_LOG=/var/log/cron3.log
export WINGMATE_LOG_MESSAGE="cron entry: 7,19,23,47 22 * * * /etc/wingmate/crontab.d/cron3.sh"
exec /usr/local/bin/wmoneshot

View File

@@ -0,0 +1,4 @@
#!/usr/bin/bash
export DUMMY_PATH=/usr/local/bin/wmdummy
exec /usr/local/bin/wmexec --setsid --user user1:user1 -- /usr/local/bin/wmstarter

View File

@@ -0,0 +1,5 @@
#!/usr/bin/bash
export WINGMATE_ONESHOT_PATH=/usr/local/bin/wmoneshot
export WINGMATE_DUMMY_PATH=/usr/local/bin/wmdummy
exec /usr/local/bin/wmexec --user 1200 -- /usr/local/bin/wmspawner

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

7
go.mod
View File

@@ -3,8 +3,8 @@ module gitea.suyono.dev/suyono/wingmate
go 1.21
require (
github.com/rs/zerolog v1.30.0
github.com/spf13/cobra v1.7.0
github.com/rs/zerolog v1.31.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.17.0
github.com/stretchr/testify v1.8.4
golang.org/x/sys v0.15.0
@@ -17,7 +17,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -28,6 +28,7 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect

10
go.sum
View File

@@ -48,6 +48,7 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -150,6 +151,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
@@ -166,6 +169,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
@@ -183,6 +188,8 @@ github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -194,12 +201,15 @@ github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=

View File

@@ -1,6 +1,7 @@
package init
import (
"io"
"os/exec"
"sync"
"time"
@@ -8,19 +9,66 @@ import (
"gitea.suyono.dev/suyono/wingmate"
)
func (i *Init) cron(wg *sync.WaitGroup, cron Cron, exitFlag <-chan any) {
const (
cronTag = "cron"
)
func (i *Init) cron(wg *sync.WaitGroup, cron CronTask, exitFlag <-chan any) {
defer wg.Done()
ticker := time.NewTicker(time.Second * 20)
var (
iwg *sync.WaitGroup
err error
stdout io.ReadCloser
stderr io.ReadCloser
cmd *exec.Cmd
)
ticker := time.NewTicker(time.Second * 30)
cron:
for {
if cron.TimeToRun(time.Now()) {
cmd := exec.Command(cron.Command().Path())
if err := cmd.Run(); err != nil {
wingmate.Log().Error().Msgf("running cron %s error %#v", cron.Command().Path(), err)
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.Name()).Msgf("stdout pipe: %+v", err)
goto fail
}
if stderr, err = cmd.StderrPipe(); err != nil {
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.Name())
iwg.Add(1)
go i.pipeReader(iwg, stderr, cronTag, cron.Name())
if err := cmd.Start(); err != nil {
wingmate.Log().Error().Msgf("starting cron %s error %+v", cron.Name(), err)
_ = stdout.Close()
_ = stderr.Close()
iwg.Wait()
goto fail
}
iwg.Wait()
if err = cmd.Wait(); err != nil {
wingmate.Log().Error().Str(cronTag, cron.Name()).Msgf("got error when waiting: %+v", err)
}
}
fail:
select {
case <-exitFlag:
ticker.Stop()

View File

@@ -1,22 +1,48 @@
package init
import (
"os"
"sync"
"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 {
@@ -34,24 +60,26 @@ func (i *Init) Start() {
wg *sync.WaitGroup
signalTrigger chan any
sighandlerExit chan any
sigchld chan os.Signal
)
signalTrigger = make(chan any)
sighandlerExit = make(chan any)
sigchld = make(chan os.Signal, 1)
wg = &sync.WaitGroup{}
wg.Add(1)
go i.waiter(wg, signalTrigger, sighandlerExit)
go i.waiter(wg, signalTrigger, sighandlerExit, sigchld)
wg.Add(1)
go i.sighandler(wg, signalTrigger, sighandlerExit)
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

@@ -1,26 +1,81 @@
package init
import (
"bufio"
"io"
"os/exec"
"sync"
"time"
"gitea.suyono.dev/suyono/wingmate"
)
func (i *Init) service(wg *sync.WaitGroup, path Path, exitFlag <-chan any) {
const (
serviceTag = "service"
)
func (i *Init) service(wg *sync.WaitGroup, task Task, exitFlag <-chan any) {
defer wg.Done()
var (
err error
err error
iwg *sync.WaitGroup
stderr io.ReadCloser
stdout io.ReadCloser
failStatus bool
cmd *exec.Cmd
)
defer func() {
wingmate.Log().Info().Str(serviceTag, task.Name()).Msg("stopped")
}()
service:
for {
cmd := exec.Command(path.Path())
if err = cmd.Run(); err != nil {
wingmate.Log().Error().Msgf("starting service %s error %#v", path.Path(), err)
failStatus = false
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, task.Name()).Msgf("stdout pipe: %#v", err)
failStatus = true
goto fail
}
iwg.Add(1)
go i.pipeReader(iwg, stdout, serviceTag, task.Name())
if stderr, err = cmd.StderrPipe(); err != nil {
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, task.Name())
if err = cmd.Start(); err != nil {
wingmate.Log().Error().Msgf("starting service %s error %#v", task.Name(), err)
failStatus = true
_ = stdout.Close()
_ = stderr.Close()
iwg.Wait()
goto fail
}
iwg.Wait()
if err = cmd.Wait(); err != nil {
wingmate.Log().Error().Str(serviceTag, task.Name()).Msgf("got error when waiting: %+v", err)
}
fail:
if failStatus {
time.Sleep(time.Second)
failStatus = false
}
select {
case <-exitFlag:
break service
@@ -29,3 +84,18 @@ service:
}
}
func (i *Init) pipeReader(wg *sync.WaitGroup, pipe io.ReadCloser, tag, serviceName string) {
defer wg.Done()
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
wingmate.Log().Info().Str(tag, serviceName).Msg(scanner.Text())
}
if err := scanner.Err(); err != nil {
wingmate.Log().Error().Str(tag, serviceName).Msgf("got error when reading pipe: %#v", err)
}
wingmate.Log().Info().Str(tag, serviceName).Msg("closing pipe")
}

View File

@@ -5,11 +5,16 @@ import (
"os/signal"
"sync"
"gitea.suyono.dev/suyono/wingmate"
"golang.org/x/sys/unix"
)
func (i *Init) sighandler(wg *sync.WaitGroup, trigger chan<- any, selfExit <-chan any) {
defer wg.Wait()
func (i *Init) sighandler(wg *sync.WaitGroup, trigger chan<- any, selfExit <-chan any, sigchld chan<- os.Signal) {
defer wg.Done()
defer func() {
wingmate.Log().Warn().Msg("signal handler: exiting")
}()
isOpen := true
@@ -23,14 +28,21 @@ signal:
switch s {
case unix.SIGTERM, unix.SIGINT:
if isOpen {
wingmate.Log().Info().Msg("initiating shutdown...")
close(trigger)
wg.Add(1)
go i.signalPump(wg, selfExit)
isOpen = false
}
case unix.SIGCHLD:
// do nothing
select {
case sigchld <- s:
default:
}
}
case <-selfExit:
wingmate.Log().Warn().Msg("signal handler received completion flag")
break signal
}
}

71
init/signal-pump.go Normal file
View File

@@ -0,0 +1,71 @@
package init
import (
"sync"
"time"
"gitea.suyono.dev/suyono/wingmate"
"golang.org/x/sys/unix"
)
type status int
const (
triggered status = iota
expired
)
func (i *Init) signalPump(wg *sync.WaitGroup, selfExit <-chan any) {
defer wg.Done()
defer func() {
wingmate.Log().Info().Msg("signal pump completed")
}()
if seStatus := i.sigTermPump(time.Now(), selfExit); seStatus == triggered {
return
}
i.sigKillPump(time.Now(), selfExit)
}
func (i *Init) sigKillPump(startTime time.Time, selfExit <-chan any) {
t := time.NewTicker(time.Millisecond * 200)
defer t.Stop()
wingmate.Log().Info().Msg("start pumping SIGKILL signal")
defer func() {
wingmate.Log().Info().Msg("stop pumping SIGKILL signal")
}()
for time.Since(startTime) < time.Second {
_ = unix.Kill(-1, unix.SIGKILL)
select {
case <-t.C:
case <-selfExit:
return
}
}
}
func (i *Init) sigTermPump(startTime time.Time, selfExit <-chan any) status {
t := time.NewTicker(time.Millisecond * 100)
defer t.Stop()
wingmate.Log().Info().Msg("start pumping SIGTERM signal")
defer func() {
wingmate.Log().Info().Msg("stop pumping SIGTERM signal")
}()
for time.Since(startTime) < time.Duration(time.Second*4) {
_ = unix.Kill(-1, unix.SIGTERM)
select {
case <-t.C:
case <-selfExit:
return triggered
}
}
return expired
}

View File

@@ -2,35 +2,68 @@ package init
import (
"errors"
"os"
"sync"
"gitea.suyono.dev/suyono/wingmate"
"golang.org/x/sys/unix"
)
func (i *Init) waiter(wg *sync.WaitGroup, runningFlag <-chan any, sigHandlerFlag chan<- any) {
func (i *Init) waiter(wg *sync.WaitGroup, runningFlag <-chan any, sigHandlerFlag chan<- any, sigchld <-chan os.Signal) {
var (
ws unix.WaitStatus
// pid int
err error
running bool
ws unix.WaitStatus
err error
running bool
flagged bool
waitingForSignal bool
)
defer wg.Done()
defer func() {
wingmate.Log().Info().Msg("waiter exiting...")
}()
running = true
flagged = true
waitingForSignal = true
wait:
for {
select {
case <-runningFlag:
running = false
default:
if running {
if waitingForSignal {
select {
case <-runningFlag:
wingmate.Log().Info().Msg("waiter received shutdown signal...")
running = false
case <-sigchld:
waitingForSignal = false
}
} else {
select {
case <-runningFlag:
wingmate.Log().Info().Msg("waiter received shutdown signal...")
running = false
default:
}
}
}
if _, err = unix.Wait4(-1, &ws, 0, nil); err != nil {
if errors.Is(err, unix.ECHILD) {
if !running {
close(sigHandlerFlag)
if flagged {
close(sigHandlerFlag)
flagged = false
wingmate.Log().Warn().Msg("waiter: inner flag")
}
wingmate.Log().Warn().Msg("waiter: no child left")
break wait
}
}
wingmate.Log().Warn().Msgf("Wait4 returns error: %+v", err)
waitingForSignal = true
}
}
}

View File

@@ -1,9 +1,15 @@
package wingmate
import (
"io"
"time"
"gitea.suyono.dev/suyono/wingmate/logger"
"github.com/rs/zerolog"
"io"
)
const (
timeTag = "time"
)
var (
@@ -29,13 +35,28 @@ func Log() logger.Log {
}
func (w *wrapper) Info() logger.Content {
return w.log.Info()
return (*eventWrapper)(w.log.Info().Time(timeTag, time.Now()))
}
func (w *wrapper) Warn() logger.Content {
return w.log.Warn()
return (*eventWrapper)(w.log.Warn().Time(timeTag, time.Now()))
}
func (w *wrapper) Error() logger.Content {
return w.log.Error()
return (*eventWrapper)(w.log.Error().Time(timeTag, time.Now()))
}
type eventWrapper zerolog.Event
func (w *eventWrapper) Msg(msg string) {
(*zerolog.Event)(w).Msg(msg)
}
func (w *eventWrapper) Msgf(format string, data ...any) {
(*zerolog.Event)(w).Msgf(format, data...)
}
func (w *eventWrapper) Str(key, value string) logger.Content {
rv := (*zerolog.Event)(w).Str(key, value)
return (*eventWrapper)(rv)
}

View File

@@ -3,6 +3,7 @@ package logger
type Content interface {
Msg(string)
Msgf(string, ...any)
Str(string, string) Content
}
type Level interface {

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)
}

View File

@@ -6,4 +6,6 @@ const (
Any CronTimeType = iota
Exact
MultipleOccurrence
EnvPrefix = "WINGMATE"
)