Compare commits

..

No commits in common. "f95897cf308c3e19d6fe4b088547d8290907e384" and "389b88331dc6ff5320b58c517810f22cae81c8f7" have entirely different histories.

12 changed files with 194 additions and 572 deletions

206
README.md
View File

@ -1,25 +1,19 @@
# go-serviceman # go-serviceman
Cross-platform service management made easy. A cross-platform service manager.
> sudo serviceman add --name foo ./serve.js --port 3000 Because debugging launchctl, systemd, etc absolutely sucks!
> Success: "foo" started as a "launchd" SYSTEM service, running as "root" ...and I wanted a reasonable way to install [Telebit](https://telebit.io) on Windows.
(see more in the **Why** section below)
## Why?
Because it sucks to debug launchctl, systemd, etc.
Also, I wanted a reasonable way to install [Telebit](https://telebit.io) on Windows.
(see more in the **More Why** section below)
## Features ## Features
- Unprivileged (User Mode) Services with `--user` (_Default_) - Unprivileged (User Mode) Services
- [x] Linux (`sytemctl --user`) - [x] Linux (`sytemctl --user`)
- [x] MacOS (`launchctl`) - [x] MacOS (`launchctl`)
- [x] Windows (`HKEY_CURRENT_USER/.../Run`) - [x] Windows (`HKEY_CURRENT_USER/.../Run`)
- Privileged (System) Services with `--system` (_Default_ for `root`) - Privileged (System) Services
- [x] Linux (`sudo sytemctl`) - [x] Linux (`sudo sytemctl`)
- [x] MacOS (`sudo launchctl`) - [x] MacOS (`sudo launchctl`)
- [ ] Windows (_not yet implemented_) - [ ] Windows (_not yet implemented_)
@ -39,24 +33,33 @@ Also, I wanted a reasonable way to install [Telebit](https://telebit.io) on Wind
- Debugging - Debugging
- Windows - Windows
- Building - Building
- More Why - Why
- Legal - Legal
# Usage # Usage
The basic pattern of usage: The basic pattern of usage:
```bash ```
sudo serviceman add --name "foobar" [options] [interpreter] <service> [--] [service options] serviceman add [options] [interpreter] <service> -- [service options]
sudo serviceman start <service> serviceman start <service>
sudo serviceman stop <service> serviceman stop <service>
serviceman version serviceman version
``` ```
And what that might look like: And what that might look like:
```bash ```
sudo serviceman add --name "foo" foo.exe -c ./config.json # Here the service is named "foo" implicitly
# '--bar /baz' will be used for arguments to foo.exe in the service file
serviceman add foo.exe -- --bar /baz
```
```
# Here the service is named "foo-app" explicitly
# 'node' will be found in the path
# './index.js' will be resolved to a full path
serviceman add --name "foo-app" node ./index.js
``` ```
You can also view the help: You can also view the help:
@ -65,14 +68,6 @@ You can also view the help:
serviceman add --help serviceman add --help
``` ```
# System Services VS User Mode Services
User services start **on login**.
System services start **on boot**.
The **default** is to register a _user_ services. To register a _system_ service, use `sudo` or run as `root`.
# Install # Install
There are a number of pre-built binaries. There are a number of pre-built binaries.
@ -176,8 +171,8 @@ curl https://rootprojects.org/serviceman/dist/linux/armv5/serviceman -o servicem
``` ```
mkdir %userprofile%\bin mkdir %userprofile%\bin
move serviceman.exe %userprofile%\bin\serviceman.exe
reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin" reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin"
move serviceman.exe %userprofile%\bin\serviceman.exe
``` ```
**All Others** **All Others**
@ -189,100 +184,43 @@ sudo mv ./serviceman /usr/local/bin/
# Examples # Examples
```bash > **serviceman add** &lt;program> **--** &lt;program options>
sudo serviceman add --name <name> <program> [options] [--] [raw options]
# Example
sudo serviceman add --name "gizmo" gizmo --foo bar/baz
```
Anything that looks like file or directory will be **resolved to its absolute path**:
```bash
# Example of path resolution
gizmo --foo /User/me/gizmo/bar/baz
```
Use `--` to prevent this behavior:
```bash
# Complex Example
sudo serviceman add --name "gizmo" gizmo -c ./config.ini -- --separator .
```
For native **Windows** programs that use `/` for flags, you'll need to resolve some paths yourself:
```bash
# Windows Example
serviceman add --name "gizmo" gizmo.exe .\input.txt -- /c \User\me\gizmo\config.ini /q /s .
```
In this case `./config.ini` would still be resolved (before `--`), but `.` would not (after `--`)
<details> <details>
<summary>Compiled Programs</summary> <summary>Compiled Programs</summary>
Normally you might your program somewhat like this: Normally you might your program somewhat like this:
```bash ```
gizmo run --port 8421 --config envs/prod.ini dinglehopper --port 8421
``` ```
Adding a service for that program with `serviceman` would look like this: Adding a service for that program with `serviceman` would look like this:
```bash > **serviceman add** dinglehopper **--** --port 8421
sudo serviceman add --name "gizmo" gizmo run --port 8421 --config envs/prod.ini
```
serviceman will find `gizmo` in your PATH and resolve `envs/prod.ini` to its absolute path. serviceman will find dinglehopper in your PATH.
</details> </details>
<details> <details>
<summary>Using with scripts</summary> <summary>Using with scripts</summary>
```bash
./snarfblat.sh --port 8421
```
Although your text script may be executable, you'll need to specify the interpreter Although your text script may be executable, you'll need to specify the interpreter
in order for `serviceman` to configure the service correctly. in order for `serviceman` to configure the service correctly.
This can be done in two ways: For example, if you had a bash script that you normally ran like this:
1. Put a **hashbang** in your script, such as `#!/bin/bash`. ```
2. Prepend the **interpreter** explicitly to your command, such as `bash ./dinglehopper.sh`. ./snarfblat.sh --port 8421
For example, suppose you had a script like this:
`iamok.sh`:
```bash
while true; do
sleep 1; echo "Still Alive, Still Alive!"
done
``` ```
Normally you would run the script like this: You'd create a system service for it like this:
```bash > serviceman add **bash** ./snarfblat.sh **--** --port 8421
./imok.sh
```
So you'd either need to modify the script to include a hashbang: `serviceman` will resolve `./snarfblat.sh` correctly because it comes
before the **--**.
```bash
#!/usr/bin/env bash
while true; do
sleep 1; echo "I'm Ok!"
done
```
Or you'd need to prepend it with `bash` when creating a service for it:
```bash
sudo serviceman add --name "imok" bash ./imok.sh
```
**Background Information** **Background Information**
@ -306,8 +244,6 @@ like this:
#!/usr/local/bin/node --harmony --inspect #!/usr/local/bin/node --harmony --inspect
``` ```
Serviceman understands all 3 of those approaches.
</details> </details>
<details> <details>
@ -316,37 +252,14 @@ Serviceman understands all 3 of those approaches.
If normally you run your node script something like this: If normally you run your node script something like this:
```bash ```bash
pushd ~/my-node-project/ node ./demo.js --foo bar --baz
npm start
``` ```
Then you would add it as a system service like this: Then you would add it as a system service like this:
```bash > **serviceman add** node ./demo.js **--** --foo bar --baz
sudo serviceman add npm start
```
If normally you run your node script something like this: It is important that you specify `node ./demo.js` and not just `./demo.js`
```bash
pushd ~/my-node-project/
node ./serve.js --foo bar --baz
```
Then you would add it as a system service like this:
```bash
sudo serviceman add node ./serve.js --foo bar --baz
```
It's important that any paths start with `./` and have the `.js`
so that serviceman knows to resolve the full path.
```bash
# Bad Examples
sudo serviceman add node ./demo # Wouldn't work for 'demo.js' - not a real filename
sudo serviceman add node demo # Wouldn't work for './demo/' - doesn't look like a directory
```
See **Using with scripts** for more detailed information. See **Using with scripts** for more detailed information.
@ -358,15 +271,14 @@ See **Using with scripts** for more detailed information.
If normally you run your python script something like this: If normally you run your python script something like this:
```bash ```bash
pushd ~/my-python-project/ python ./demo.py --foo bar --baz
python ./serve.py --config ./config.ini
``` ```
Then you would add it as a system service like this: Then you would add it as a system service like this:
```bash > **serviceman add** python ./demo.py **--** --foo bar --baz
sudo serviceman add python ./serve.py --config ./config.ini
``` It is important that you specify `python ./demo.py` and not just `./demo.py`
See **Using with scripts** for more detailed information. See **Using with scripts** for more detailed information.
@ -378,32 +290,31 @@ See **Using with scripts** for more detailed information.
If normally you run your ruby script something like this: If normally you run your ruby script something like this:
```bash ```bash
pushd ~/my-ruby-project/ ruby ./demo.rb --foo bar --baz
ruby ./serve.rb --config ./config.yaml
``` ```
Then you would add it as a system service like this: Then you would add it as a system service like this:
```bash > **serviceman add** ruby ./demo.rb **--** --foo bar --baz
sudo serviceman add ruby ./serve.rb --config ./config.yaml
``` It is important that you specify `ruby ./demo.rb` and not just `./demo.rb`
See **Using with scripts** for more detailed information. See **Using with scripts** for more detailed information.
</details> </details>
## Hints ## Relative vs Absolute Paths
- If something goes wrong, read the output **completely** - it'll probably be helpful Although serviceman can expand the executable's path,
- Run `serviceman` from your **project directory**, just as you would run it normally if you have any arguments with relative paths
- Otherwise specify `--name <service-name>` and `--workdir <project directory>` you should switch to using absolute paths.
- Use `--` in front of arguments that should not be resolved as paths
- This also holds true if you need `--` as an argument, such as `-- --foo -- --bar`
``` ```
# Example of a / that isn't a path dinglehopper --config ./conf.json
# (it needs to be escaped with --) ```
sudo serviceman add dinglehopper config/prod -- --category color/blue
```
serviceman add dinglehopper -- --config /Users/me/dinglehopper/conf.json
``` ```
# Logging # Logging
@ -412,7 +323,6 @@ sudo serviceman add dinglehopper config/prod -- --category color/blue
```bash ```bash
sudo journalctl -xef --unit <NAME> sudo journalctl -xef --unit <NAME>
sudo journalctl -xef --user-unit <NAME>
``` ```
### Mac, Windows ### Mac, Windows
@ -444,9 +354,6 @@ why your app failed to start.
# Debugging # Debugging
- `serviceman add --dryrun <normal options>`
- `serviceman run --config <special config>`
One of the most irritating problems with all of these launchers is that they're One of the most irritating problems with all of these launchers is that they're
terrible to debug - it's often difficult to find the logs, and nearly impossible terrible to debug - it's often difficult to find the logs, and nearly impossible
to interpret them, if they exist at all. to interpret them, if they exist at all.
@ -573,7 +480,7 @@ go build -mod=vendor -ldflags "-H=windowsgui" -o serviceman.exe
go build -mod=vendor -o /usr/local/bin/serviceman go build -mod=vendor -o /usr/local/bin/serviceman
``` ```
# More Why # Why
I created this for two reasons: I created this for two reasons:
@ -592,4 +499,3 @@ MPL-2.0 |
Copyright 2019 AJ ONeal. Copyright 2019 AJ ONeal.
<!-- {{ end }} --> <!-- {{ end }} -->
<!-- {{ end }} -->

View File

@ -14,7 +14,7 @@ import (
// Install will do a best-effort attempt to install a start-on-startup // Install will do a best-effort attempt to install a start-on-startup
// user or system service via systemd, launchd, or reg.exe // user or system service via systemd, launchd, or reg.exe
func Install(c *service.Service) (string, error) { func Install(c *service.Service) error {
if "" == c.Exec { if "" == c.Exec {
c.Exec = c.Name c.Exec = c.Name
} }
@ -24,23 +24,23 @@ func Install(c *service.Service) (string, error) {
if nil != err { if nil != err {
fmt.Fprintf(os.Stderr, "Unrecoverable Error: %s", err) fmt.Fprintf(os.Stderr, "Unrecoverable Error: %s", err)
os.Exit(4) os.Exit(4)
return "", err return err
} else { } else {
c.Home = home c.Home = home
} }
} }
name, err := install(c) err := install(c)
if nil != err { if nil != err {
return "", err return err
} }
err = os.MkdirAll(c.Logdir, 0755) err = os.MkdirAll(c.Logdir, 0755)
if nil != err { if nil != err {
return "", err return err
} }
return name, nil return nil
} }
func Start(conf *service.Service) error { func Start(conf *service.Service) error {

View File

@ -50,11 +50,12 @@ func start(conf *service.Service) error {
cmds = adjustPrivs(system, cmds) cmds = adjustPrivs(system, cmds)
fmt.Println()
typ := "USER" typ := "USER"
if system { if system {
typ = "SYSTEM" typ = "SYSTEM"
} }
fmt.Printf("Starting launchd %s service...\n\n", typ) fmt.Printf("Starting launchd %s service...\n", typ)
for i := range cmds { for i := range cmds {
exe := cmds[i] exe := cmds[i]
fmt.Println("\t" + exe.String()) fmt.Println("\t" + exe.String())
@ -108,32 +109,11 @@ func stop(conf *service.Service) error {
return nil return nil
} }
func Render(c *service.Service) ([]byte, error) { func install(c *service.Service) error {
// Create service file from template
b, err := static.ReadFile("dist/Library/LaunchDaemons/_rdns_.plist.tmpl")
if err != nil {
return nil, err
}
s := string(b)
rw := &bytes.Buffer{}
// not sure what the template name does, but whatever
tmpl, err := template.New("service").Parse(s)
if err != nil {
return nil, err
}
err = tmpl.Execute(rw, c)
if nil != err {
return nil, err
}
return rw.Bytes(), nil
}
func install(c *service.Service) (string, error) {
// Darwin-specific config options // Darwin-specific config options
if c.PrivilegedPorts { if c.PrivilegedPorts {
if !c.System { if !c.System {
return "", fmt.Errorf("You must use root-owned LaunchDaemons (not user-owned LaunchAgents) to use priveleged ports on OS X") return fmt.Errorf("You must use root-owned LaunchDaemons (not user-owned LaunchAgents) to use priveleged ports on OS X")
} }
} }
plistDir := srvSysPath plistDir := srvSysPath
@ -144,20 +124,32 @@ func install(c *service.Service) (string, error) {
// Check paths first // Check paths first
err := os.MkdirAll(filepath.Dir(plistDir), 0755) err := os.MkdirAll(filepath.Dir(plistDir), 0755)
if nil != err { if nil != err {
return "", err return err
} }
b, err := Render(c) // Create service file from template
b, err := static.ReadFile("dist/Library/LaunchDaemons/_rdns_.plist.tmpl")
if err != nil {
return err
}
s := string(b)
rw := &bytes.Buffer{}
// not sure what the template name does, but whatever
tmpl, err := template.New("service").Parse(s)
if err != nil {
return err
}
err = tmpl.Execute(rw, c)
if nil != err { if nil != err {
return "", err return err
} }
// Write the file out // Write the file out
// TODO rdns // TODO rdns
plistName := c.ReverseDNS + ".plist" plistName := c.ReverseDNS + ".plist"
plistPath := filepath.Join(plistDir, plistName) plistPath := filepath.Join(plistDir, plistName)
if err := ioutil.WriteFile(plistPath, b, 0644); err != nil { if err := ioutil.WriteFile(plistPath, rw.Bytes(), 0644); err != nil {
return "", fmt.Errorf("Error writing %s: %v", plistPath, err) return fmt.Errorf("Error writing %s: %v", plistPath, err)
} }
// TODO --no-start // TODO --no-start
@ -166,8 +158,9 @@ func install(c *service.Service) (string, error) {
fmt.Printf("If things don't go well you should be able to get additional logging from launchctl:\n") fmt.Printf("If things don't go well you should be able to get additional logging from launchctl:\n")
fmt.Printf("\tsudo launchctl log level debug\n") fmt.Printf("\tsudo launchctl log level debug\n")
fmt.Printf("\ttail -f /var/log/system.log\n") fmt.Printf("\ttail -f /var/log/system.log\n")
return "", err return err
} }
return "launchd", nil fmt.Printf("Added and started '%s' as a launchctl service.\n", c.Name)
return nil
} }

View File

@ -88,11 +88,12 @@ func start(conf *service.Service) error {
cmds = adjustPrivs(system, cmds) cmds = adjustPrivs(system, cmds)
fmt.Println()
typ := "USER MODE" typ := "USER MODE"
if system { if system {
typ = "SYSTEM" typ = "SYSTEM"
} }
fmt.Printf("Starting systemd %s service unit...\n\n", typ) fmt.Printf("Starting systemd %s service unit...\n", typ)
for i := range cmds { for i := range cmds {
exe := cmds[i] exe := cmds[i]
fmt.Println("\t" + exe.String()) fmt.Println("\t" + exe.String())
@ -159,28 +160,7 @@ func stop(conf *service.Service) error {
return nil return nil
} }
func Render(c *service.Service) ([]byte, error) { func install(c *service.Service) error {
// Create service file from template
b, err := static.ReadFile("dist/etc/systemd/system/_name_.service.tmpl")
if err != nil {
return nil, err
}
s := string(b)
rw := &bytes.Buffer{}
// not sure what the template name does, but whatever
tmpl, err := template.New("service").Parse(s)
if err != nil {
return nil, err
}
err = tmpl.Execute(rw, c)
if nil != err {
return nil, err
}
return rw.Bytes(), nil
}
func install(c *service.Service) (string, error) {
// Linux-specific config options // Linux-specific config options
if c.System { if c.System {
if "" == c.User { if "" == c.User {
@ -197,20 +177,32 @@ func install(c *service.Service) (string, error) {
serviceDir = filepath.Join(c.Home, srvUserPath) serviceDir = filepath.Join(c.Home, srvUserPath)
err := os.MkdirAll(serviceDir, 0755) err := os.MkdirAll(serviceDir, 0755)
if nil != err { if nil != err {
return "", err return err
} }
} }
b, err := Render(c) // Create service file from template
b, err := static.ReadFile("dist/etc/systemd/system/_name_.service.tmpl")
if err != nil {
return err
}
s := string(b)
rw := &bytes.Buffer{}
// not sure what the template name does, but whatever
tmpl, err := template.New("service").Parse(s)
if err != nil {
return err
}
err = tmpl.Execute(rw, c)
if nil != err { if nil != err {
return "", err return err
} }
// Write the file out // Write the file out
serviceName := c.Name + ".service" serviceName := c.Name + ".service"
servicePath := filepath.Join(serviceDir, serviceName) servicePath := filepath.Join(serviceDir, serviceName)
if err := ioutil.WriteFile(servicePath, b, 0644); err != nil { if err := ioutil.WriteFile(servicePath, rw.Bytes(), 0644); err != nil {
return "", fmt.Errorf("Error writing %s: %v", servicePath, err) return fmt.Errorf("Error writing %s: %v", servicePath, err)
} }
// TODO --no-start // TODO --no-start
@ -225,8 +217,9 @@ func install(c *service.Service) (string, error) {
} }
fmt.Printf("If things don't go well you should be able to get additional logging from journalctl:\n") fmt.Printf("If things don't go well you should be able to get additional logging from journalctl:\n")
fmt.Printf("\t%sjournalctl -xe %s %s.service\n", sudo, unit, c.Name) fmt.Printf("\t%sjournalctl -xe %s %s.service\n", sudo, unit, c.Name)
return "", err return err
} }
return "systemd", nil fmt.Printf("Added and started '%s' as a systemd service.\n", c.Name)
return nil
} }

View File

@ -6,10 +6,6 @@ import (
"git.rootprojects.org/root/go-serviceman/service" "git.rootprojects.org/root/go-serviceman/service"
) )
func Render(c *service.Service) ([]byte, error) {
return nil, nil
}
func install(c *service.Service) error { func install(c *service.Service) error {
return nil, nil return nil, nil
} }

View File

@ -30,7 +30,7 @@ func init() {
// TODO system service requires elevated privileges // TODO system service requires elevated privileges
// See https://coolaj86.com/articles/golang-and-windows-and-admins-oh-my/ // See https://coolaj86.com/articles/golang-and-windows-and-admins-oh-my/
func install(c *service.Service) (string, error) { func install(c *service.Service) error {
/* /*
// LEAVE THIS DOCUMENTATION HERE // LEAVE THIS DOCUMENTATION HERE
reg.exe reg.exe
@ -73,7 +73,7 @@ func install(c *service.Service) (string, error) {
args, err := installServiceman(c) args, err := installServiceman(c)
if nil != err { if nil != err {
return "", err return err
} }
/* /*
@ -100,7 +100,7 @@ func install(c *service.Service) (string, error) {
regSZ := fmt.Sprintf(`"%s" %s`, args[0], strings.Join(args[1:], " ")) regSZ := fmt.Sprintf(`"%s" %s`, args[0], strings.Join(args[1:], " "))
if len(regSZ) > 260 { if len(regSZ) > 260 {
return "", fmt.Errorf("data value is too long for registry entry") return fmt.Errorf("data value is too long for registry entry")
} }
// In order for a windows gui program to not show a console, // In order for a windows gui program to not show a console,
// it has to not output any messages? // it has to not output any messages?
@ -108,22 +108,17 @@ func install(c *service.Service) (string, error) {
//fmt.Println(autorunKey, c.Title, regSZ) //fmt.Println(autorunKey, c.Title, regSZ)
k.SetStringValue(c.Title, regSZ) k.SetStringValue(c.Title, regSZ)
err = start(c) // to return ErrDaemonize
return "serviceman", err return start(c)
}
func Render(c *service.Service) ([]byte, error) {
b, err := json.Marshal(c)
if nil != err {
return nil, err
}
return b, nil
} }
func start(conf *service.Service) error { func start(conf *service.Service) error {
args := getRunnerArgs(conf) args := getRunnerArgs(conf)
args = append(args, "--daemon") return &ErrDaemonize{
return Run(args[0], args[1:]...) DaemonArgs: append(args, "--daemon"),
error: "Not as much an error as a bad value...",
}
//return runner.Start(conf)
} }
func stop(conf *service.Service) error { func stop(conf *service.Service) error {
@ -178,7 +173,7 @@ func installServiceman(c *service.Service) ([]string, error) {
} }
} }
b, err := Render(c) b, err := json.Marshal(c)
if nil != err { if nil != err {
// this should be impossible, so we'll just panic // this should be impossible, so we'll just panic
panic(err) panic(err)

View File

@ -227,21 +227,3 @@ func adjustPrivs(system bool, cmds []Runnable) []Runnable {
return cmds return cmds
} }
func Run(bin string, args ...string) error {
cmd := exec.Command(bin, args...)
// for debugging
/*
out, err := cmd.CombinedOutput()
if nil != err {
fmt.Println(err)
}
fmt.Println(string(out))
*/
err := cmd.Start()
if nil != err {
return err
}
return nil
}

View File

@ -116,7 +116,7 @@ func (s *Service) Normalize(force bool) {
_, err := os.Stat(optpath) _, err := os.Stat(optpath)
if nil == err { if nil == err {
bad = false bad = false
//fmt.Fprintf(os.Stderr, "Using '%s' for '%s'\n", optpath, s.Exec) fmt.Fprintf(os.Stderr, "Using '%s' for '%s'\n", optpath, s.Exec)
s.Exec = optpath s.Exec = optpath
} }
} }

View File

@ -1,6 +1,5 @@
//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver //go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver
// main runs the things and does the stuff
package main package main
import ( import (
@ -10,11 +9,8 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"os/exec" "os/exec"
"os/user"
"path/filepath"
"strings" "strings"
"time" "time"
"unicode/utf8"
"git.rootprojects.org/root/go-serviceman/manager" "git.rootprojects.org/root/go-serviceman/manager"
"git.rootprojects.org/root/go-serviceman/runner" "git.rootprojects.org/root/go-serviceman/runner"
@ -66,15 +62,26 @@ func add() {
Restart: true, Restart: true,
} }
args := []string{}
for i := range os.Args {
if "--" == os.Args[i] {
if len(os.Args) > i+1 {
args = os.Args[i+1:]
}
os.Args = os.Args[:i]
break
}
}
conf.Argv = args
force := false force := false
forUser := false forUser := false
forSystem := false forSystem := false
dryrun := false
flag.StringVar(&conf.Title, "title", "", "a human-friendly name for the service") flag.StringVar(&conf.Title, "title", "", "a human-friendly name for the service")
flag.StringVar(&conf.Desc, "desc", "", "a human-friendly description of the service (ex: Foo App)") flag.StringVar(&conf.Desc, "desc", "", "a human-friendly description of the service (ex: Foo App)")
flag.StringVar(&conf.Name, "name", "", "a computer-friendly name for the service (ex: foo-app)") flag.StringVar(&conf.Name, "name", "", "a computer-friendly name for the service (ex: foo-app)")
flag.StringVar(&conf.URL, "url", "", "the documentation on home page of the service") flag.StringVar(&conf.URL, "url", "", "the documentation on home page of the service")
flag.StringVar(&conf.Workdir, "workdir", "", "the directory in which the service should be started (if supported)") //flag.StringVar(&conf.Workdir, "workdir", "", "the directory in which the service should be started")
flag.StringVar(&conf.ReverseDNS, "rdns", "", "a plist-friendly Reverse DNS name for launchctl (ex: com.example.foo-app)") flag.StringVar(&conf.ReverseDNS, "rdns", "", "a plist-friendly Reverse DNS name for launchctl (ex: com.example.foo-app)")
flag.BoolVar(&forSystem, "system", false, "attempt to add system service as an unprivileged/unelevated user") flag.BoolVar(&forSystem, "system", false, "attempt to add system service as an unprivileged/unelevated user")
flag.BoolVar(&forUser, "user", false, "add user space / user mode service even when admin/root/sudo/elevated") flag.BoolVar(&forUser, "user", false, "add user space / user mode service even when admin/root/sudo/elevated")
@ -82,339 +89,67 @@ func add() {
flag.StringVar(&conf.User, "username", "", "run the service as this user") flag.StringVar(&conf.User, "username", "", "run the service as this user")
flag.StringVar(&conf.Group, "groupname", "", "run the service as this group") flag.StringVar(&conf.Group, "groupname", "", "run the service as this group")
flag.BoolVar(&conf.PrivilegedPorts, "cap-net-bind", false, "this service should have access to privileged ports") flag.BoolVar(&conf.PrivilegedPorts, "cap-net-bind", false, "this service should have access to privileged ports")
flag.BoolVar(&dryrun, "dryrun", false, "output the service file without modifying anything on disk")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "Flags and arguments after \"--\" will be completely ignored by serviceman\n", os.Args[0])
}
flag.Parse() flag.Parse()
flagargs := flag.Args() args = flag.Args()
// You must have something to run, duh
n := len(flagargs)
if 0 == n {
fmt.Println("Usage: serviceman add ./foo-app --foo-arg")
os.Exit(2)
return
}
if forUser && forSystem { if forUser && forSystem {
fmt.Println("Pfff! You can't --user AND --system! What are you trying to pull?") fmt.Println("Pfff! You can't --user AND --system! What are you trying to pull?")
os.Exit(1) os.Exit(1)
return return
} }
// There are three groups of flags
// serviceman --flag1 arg1 non-flag-arg --child1 -- --raw1 -- --raw2
// serviceman --flag1 arg1 // these belong to serviceman
// non-flag-arg --child1 // these will be interpretted
// -- // separator
// --raw1 -- --raw2 // after the separater (including additional separators) will be ignored
rawargs := []string{}
for i := range flagargs {
if "--" == flagargs[i] {
if len(flagargs) > i+1 {
rawargs = flagargs[i+1:]
}
flagargs = flagargs[:i]
break
}
}
// Assumptions
ass := []string{}
if forUser { if forUser {
conf.System = false conf.System = false
} else if forSystem { } else if forSystem {
conf.System = true conf.System = true
} else { } else {
conf.System = manager.IsPrivileged() conf.System = manager.IsPrivileged()
if conf.System {
ass = append(ass, "# Because you're a privileged user")
ass = append(ass, " --system")
ass = append(ass, "")
} else {
ass = append(ass, "# Because you're a unprivileged user")
ass = append(ass, " --user")
ass = append(ass, "")
}
}
if "" == conf.Workdir {
dir, _ := os.Getwd()
conf.Workdir = dir
ass = append(ass, "# Because this is your current working directory")
ass = append(ass, fmt.Sprintf(" --workdir %s", conf.Workdir))
ass = append(ass, "")
}
if "" == conf.Name {
name, _ := os.Getwd()
base := filepath.Base(name)
ext := filepath.Ext(base)
n := (len(base) - len(ext))
name = base[:n]
if "" == name {
name = base
}
conf.Name = name
ass = append(ass, "# Because this is the name of your current working directory")
ass = append(ass, fmt.Sprintf(" --name %s", conf.Name))
ass = append(ass, "")
} }
exepath, err := findExec(flagargs[0], force) n := len(args)
if nil != err { if 0 == n {
fmt.Fprintf(os.Stderr, "%s\n", err) fmt.Println("Usage: serviceman add ./foo-app -- --foo-arg")
os.Exit(3) os.Exit(2)
return
}
flagargs[0] = exepath
exeargs, err := testScript(flagargs[0], force)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(3)
return return
} }
flagargs = append(exeargs, flagargs...) execpath, err := manager.WhereIs(args[0])
// TODO if nil != err {
for i := range flagargs { fmt.Fprintf(os.Stderr, "Error: '%s' could not be found in PATH or working directory.\n", args[0])
arg := flagargs[i] if !force {
arg = filepath.ToSlash(arg) os.Exit(3)
// Paths considered to be anything starting with ./, .\, /, \, C: return
if "." == arg || strings.Contains(arg, "/") {
//if "." == arg || (len(arg) >= 2 && "./" == arg[:2] || '/' == arg[0] || "C:" == strings.ToUpper(arg[:1])) {
var err error
arg, err = filepath.Abs(arg)
if nil == err {
_, err = os.Stat(arg)
}
if nil != err {
fmt.Printf("%q appears to be a file path, but %q could not be read\n", flagargs[i], arg)
if !force {
os.Exit(7)
return
}
continue
}
if '\\' != os.PathSeparator {
// Convert paths back to .\ for Windows
arg = filepath.FromSlash(arg)
}
// Lookin' good
flagargs[i] = arg
} }
} else {
args[0] = execpath
}
conf.Exec = args[0]
args = args[1:]
if n >= 2 {
conf.Interpreter = conf.Exec
conf.Exec = args[0]
conf.Argv = append(args[1:], conf.Argv...)
} }
// We won't bother with Interpreter here conf.Normalize(force)
// (it's really just for documentation),
// but we will add any and all unchecked args to the full slice
conf.Exec = flagargs[0]
conf.Argv = append(flagargs[1:], rawargs...)
// TODO update docs: go to the work directory
// TODO test with "npm start"
conf.NormalizeWithoutPath()
//fmt.Printf("\n%#v\n\n", conf) //fmt.Printf("\n%#v\n\n", conf)
if conf.System && !manager.IsPrivileged() { if conf.System && !manager.IsPrivileged() {
fmt.Fprintf(os.Stderr, "Warning: You may need to use 'sudo' to add %q as a privileged system service.\n", conf.Name) fmt.Fprintf(os.Stderr, "Warning: You may need to use 'sudo' to add %q as a privileged system service.\n", conf.Name)
} }
if len(ass) > 0 { err = manager.Install(conf)
fmt.Println("OPTIONS: Making some assumptions...\n") switch e := err.(type) {
for i := range ass { case nil:
fmt.Println("\t" + ass[i]) // do nothing
} case *manager.ErrDaemonize:
} runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...)
default:
// Find who this is running as
// And pretty print the command to run
runAs := conf.User
var wasflag bool
fmt.Printf("COMMAND: Service %q will be run like this (more or less):\n\n", conf.Title)
if conf.System {
if "" == runAs {
runAs = "root"
}
fmt.Printf("\t# Starts on system boot, as %q\n", runAs)
} else {
u, _ := user.Current()
runAs = u.Name
if "" == runAs {
runAs = u.Username
}
fmt.Printf("\t# Starts as %q, when %q logs in\n", runAs, u.Username)
}
//fmt.Printf("\tpushd %s\n", conf.Workdir)
fmt.Printf("\t%s\n", conf.Exec)
for i := range conf.Argv {
arg := conf.Argv[i]
if '-' == arg[0] {
if wasflag {
fmt.Println()
}
wasflag = true
fmt.Printf("\t\t%s", arg)
} else {
if wasflag {
fmt.Printf(" %s\n", arg)
} else {
fmt.Printf("\t\t%s\n", arg)
}
wasflag = false
}
}
if wasflag {
fmt.Println()
}
fmt.Println()
// TODO output config without installing
if dryrun {
b, err := manager.Render(conf)
if nil != err {
fmt.Fprintf(os.Stderr, "Error rendering: %s\n", err)
os.Exit(10)
}
fmt.Println(string(b))
return
}
fmt.Printf("LAUNCHER: ")
servicetype, err := manager.Install(conf)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err) fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(500)
return
} }
fmt.Printf("LOGS: ")
printLogMessage(conf) printLogMessage(conf)
fmt.Println() fmt.Println()
servicemode := "USER MODE"
if conf.System {
servicemode = "SYSTEM"
}
fmt.Printf(
"SUCCESS:\n\n\t%q started as a %q %s service, running as %q\n",
conf.Name,
servicetype,
servicemode,
runAs,
)
fmt.Println()
}
func findExec(exe string, force bool) (string, error) {
// ex: node => /usr/local/bin/node
// ex: ./demo.js => /Users/aj/project/demo.js
exepath, err := exec.LookPath(exe)
if nil != err {
var msg string
if strings.Contains(filepath.ToSlash(exe), "/") {
if _, err := os.Stat(exe); err != nil {
msg = fmt.Sprintf("Error: '%s' could not be found in PATH or working directory.\n", exe)
} else {
msg = fmt.Sprintf("Error: '%s' is not an executable.\nYou may be able to fix that. Try running this:\n\tchmod a+x %s\n", exe, exe)
}
} else {
if _, err := os.Stat(exe); err != nil {
msg = fmt.Sprintf("Error: '%s' could not be found in PATH", exe)
} else {
msg = fmt.Sprintf("Error: '%s' could not be found in PATH, did you mean './%s'?\n", exe, exe)
}
}
if !force {
return "", fmt.Errorf(msg)
}
fmt.Fprintf(os.Stderr, "%s\n", msg)
return exe, nil
}
// ex: \Users\aj\project\demo.js => /Users/aj/project/demo.js
// Can't have an error here when lookpath succeeded
exepath, _ = filepath.Abs(filepath.ToSlash(exepath))
return exepath, nil
}
func testScript(exepath string, force bool) ([]string, error) {
f, err := os.Open(exepath)
b := make([]byte, 256)
if nil == err {
_, err = f.Read(b)
}
if nil != err || len(b) < len("#!/x") {
msg := fmt.Sprintf("Error when testing if '%s' is a binary or script: could not read file: %s\n", exepath, err)
if !force {
return nil, fmt.Errorf(msg)
}
fmt.Fprintf(os.Stderr, "%s\n", msg)
return nil, nil
}
// Nott sure if this is more readable and idiomatic as if else or switch
// However, the order matters
switch {
case utf8.Valid(b):
// Looks like an executable script
if "#!/" == string(b[:3]) {
break
}
msg := fmt.Sprintf("Error: %q looks like a script, but we don't know the interpreter.\nYou can probably fix this by...\n"+
"\tExplicitly naming the interpreter (ex: 'python my-script.py' instead of just 'my-script.py')\n"+
"\tPlacing a hashbang at the top of the script (ex: '#!/usr/bin/env python')", exepath)
if !force {
return nil, fmt.Errorf(msg)
}
return nil, nil
case "#!/" != string(b[:3]):
// Looks like a normal binary
return nil, nil
default:
// Looks like a corrupt script file
msg := "Error: It looks like you've specified a corrupt script file."
if !force {
return nil, fmt.Errorf(msg)
}
return nil, nil
}
// Deal with #!/whatever
// Get that first line
// "#!/usr/bin/env node" => ["/usr/bin/env", "node"]
// "#!/usr/bin/node --harmony => ["/usr/bin/node", "--harmony"]
s := string(b[2:]) // strip leading #!
s = strings.Split(strings.Replace(s, "\r\n", "\n", -1), "\n")[0]
allargs := strings.Split(strings.TrimSpace(s), " ")
args := []string{}
for i := range allargs {
arg := strings.TrimSpace(allargs[i])
if "" != arg {
args = append(args, arg)
}
}
if strings.HasSuffix(args[0], "/env") && len(args) > 1 {
// TODO warn that "env" is probably not an executable if 1 = len(args)?
args = args[1:]
}
exepath, err = findExec(args[0], force)
if nil != err {
return nil, err
}
args[0] = exepath
return args, nil
} }
func start() { func start() {
@ -450,10 +185,14 @@ func start() {
conf.NormalizeWithoutPath() conf.NormalizeWithoutPath()
err := manager.Start(conf) err := manager.Start(conf)
if nil != err { switch e := err.(type) {
fmt.Fprintf(os.Stderr, "%s\n", err) case nil:
os.Exit(500) // do nothing
return case *manager.ErrDaemonize:
runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...)
default:
fmt.Println(err)
os.Exit(127)
} }
} }
@ -503,7 +242,7 @@ func run() {
flag.Parse() flag.Parse()
if "" == confpath { if "" == confpath {
fmt.Fprintf(os.Stderr, "%s\n", strings.Join(flag.Args(), " ")) fmt.Fprintf(os.Stderr, "%s", strings.Join(flag.Args(), " "))
fmt.Fprintf(os.Stderr, "--config /path/to/config.json is required\n") fmt.Fprintf(os.Stderr, "--config /path/to/config.json is required\n")
usage() usage()
os.Exit(1) os.Exit(1)
@ -556,5 +295,23 @@ func run() {
return return
} }
manager.Run(os.Args[0], "run", "--config", confpath) runAsDaemon(os.Args[0], "run", "--config", confpath)
}
func runAsDaemon(bin string, args ...string) {
cmd := exec.Command(bin, args...)
// for debugging
/*
out, err := cmd.CombinedOutput()
if nil != err {
fmt.Println(err)
}
fmt.Println(string(out))
*/
err := cmd.Start()
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(500)
}
} }

View File

@ -7,5 +7,5 @@ import (
) )
func printLogMessage(conf *service.Service) { func printLogMessage(conf *service.Service) {
fmt.Printf("If all went well the logs should have been created at:\n\n\t%s\n", conf.Logdir) fmt.Printf("If all went well the logs should have been created at:\n\t%s\n", conf.Logdir)
} }

View File

@ -17,7 +17,7 @@ func printLogMessage(conf *service.Service) {
} else { } else {
unit = "--user-unit" unit = "--user-unit"
} }
fmt.Println("If all went well you should be able to see some goodies in the logs:\n") fmt.Println("If all went well you should be able to see some goodies in the logs:")
fmt.Printf("\t%sjournalctl -xe %s %s.service\n", sudo, unit, conf.Name) fmt.Printf("\t%sjournalctl -xe %s %s.service\n", sudo, unit, conf.Name)
if !conf.System { if !conf.System {
fmt.Println("\nIf that's not the case, see https://unix.stackexchange.com/a/486566/45554.") fmt.Println("\nIf that's not the case, see https://unix.stackexchange.com/a/486566/45554.")

View File

@ -7,5 +7,5 @@ import (
) )
func printLogMessage(conf *service.Service) { func printLogMessage(conf *service.Service) {
fmt.Printf("If all went well the logs should have been created at:\n\n\t%s\n", conf.Logdir) fmt.Printf("If all went well the logs should have been created at:\n\t%s\n", conf.Logdir)
} }