better arg handling, more descriptive output

This commit is contained in:
AJ ONeal 2019-07-13 20:50:00 -06:00
parent 389b88331d
commit 40a82f26c4
12 changed files with 563 additions and 192 deletions

193
README.md
View File

@ -9,11 +9,11 @@ Because debugging launchctl, systemd, etc absolutely sucks!
## Features ## Features
- Unprivileged (User Mode) Services - Unprivileged (User Mode) Services with `--user` (_Default_)
- [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 - Privileged (System) Services with `--system` (_Default_ for `root`)
- [x] Linux (`sudo sytemctl`) - [x] Linux (`sudo sytemctl`)
- [x] MacOS (`sudo launchctl`) - [x] MacOS (`sudo launchctl`)
- [ ] Windows (_not yet implemented_) - [ ] Windows (_not yet implemented_)
@ -40,26 +40,17 @@ Because debugging launchctl, systemd, etc absolutely sucks!
The basic pattern of usage: The basic pattern of usage:
``` ```bash
serviceman add [options] [interpreter] <service> -- [service options] sudo serviceman add --name "foobar" [options] [interpreter] <service> [--] [service options]
serviceman start <service> sudo serviceman start <service>
serviceman stop <service> sudo serviceman stop <service>
serviceman version serviceman version
``` ```
And what that might look like: And what that might look like:
``` ```bash
# Here the service is named "foo" implicitly sudo serviceman add --name "foo" foo.exe -c ./config.json
# '--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:
@ -68,6 +59,14 @@ 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.
@ -171,8 +170,8 @@ curl https://rootprojects.org/serviceman/dist/linux/armv5/serviceman -o servicem
``` ```
mkdir %userprofile%\bin mkdir %userprofile%\bin
reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin"
move serviceman.exe %userprofile%\bin\serviceman.exe move serviceman.exe %userprofile%\bin\serviceman.exe
reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin"
``` ```
**All Others** **All Others**
@ -184,43 +183,100 @@ sudo mv ./serviceman /usr/local/bin/
# Examples # Examples
> **serviceman add** &lt;program> **--** &lt;program options> ```bash
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
dinglehopper --port 8421 gizmo run --port 8421 --config envs/prod.ini
``` ```
Adding a service for that program with `serviceman` would look like this: Adding a service for that program with `serviceman` would look like this:
> **serviceman add** dinglehopper **--** --port 8421 ```bash
sudo serviceman add --name "gizmo" gizmo run --port 8421 --config envs/prod.ini
```
serviceman will find dinglehopper in your PATH. serviceman will find `gizmo` in your PATH and resolve `envs/prod.ini` to its absolute path.
</details> </details>
<details> <details>
<summary>Using with scripts</summary> <summary>Using with scripts</summary>
Although your text script may be executable, you'll need to specify the interpreter ```bash
in order for `serviceman` to configure the service correctly.
For example, if you had a bash script that you normally ran like this:
```
./snarfblat.sh --port 8421 ./snarfblat.sh --port 8421
``` ```
You'd create a system service for it like this: Although your text script may be executable, you'll need to specify the interpreter
in order for `serviceman` to configure the service correctly.
> serviceman add **bash** ./snarfblat.sh **--** --port 8421 This can be done in two ways:
`serviceman` will resolve `./snarfblat.sh` correctly because it comes 1. Put a **hashbang** in your script, such as `#!/bin/bash`.
before the **--**. 2. Prepend the **interpreter** explicitly to your command, such as `bash ./dinglehopper.sh`.
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:
```bash
./imok.sh
```
So you'd either need to modify the script to include a hashbang:
```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**
@ -244,6 +300,8 @@ 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>
@ -252,14 +310,37 @@ like this:
If normally you run your node script something like this: If normally you run your node script something like this:
```bash ```bash
node ./demo.js --foo bar --baz pushd ~/my-node-project/
npm start
``` ```
Then you would add it as a system service like this: Then you would add it as a system service like this:
> **serviceman add** node ./demo.js **--** --foo bar --baz ```bash
sudo serviceman add npm start
```
It is important that you specify `node ./demo.js` and not just `./demo.js` If normally you run your node script something like this:
```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.
@ -271,14 +352,15 @@ 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
python ./demo.py --foo bar --baz pushd ~/my-python-project/
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:
> **serviceman add** python ./demo.py **--** --foo bar --baz ```bash
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.
@ -290,31 +372,32 @@ 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
ruby ./demo.rb --foo bar --baz pushd ~/my-ruby-project/
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:
> **serviceman add** ruby ./demo.rb **--** --foo bar --baz ```bash
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>
## Relative vs Absolute Paths ## Hints
Although serviceman can expand the executable's path, - If something goes wrong, read the output **completely** - it'll probably be helpful
if you have any arguments with relative paths - Run `serviceman` from your **project directory**, just as you would run it normally
you should switch to using absolute paths. - Otherwise specify `--name <service-name>` and `--workdir <project directory>`
- 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`
``` ```
dinglehopper --config ./conf.json # Example of a / that isn't a path
``` # (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
@ -323,6 +406,7 @@ serviceman add dinglehopper -- --config /Users/me/dinglehopper/conf.json
```bash ```bash
sudo journalctl -xef --unit <NAME> sudo journalctl -xef --unit <NAME>
sudo journalctl -xef --user-unit <NAME>
``` ```
### Mac, Windows ### Mac, Windows
@ -354,6 +438,9 @@ 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.

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) error { func Install(c *service.Service) (string, error) {
if "" == c.Exec { if "" == c.Exec {
c.Exec = c.Name c.Exec = c.Name
} }
@ -24,23 +24,23 @@ func Install(c *service.Service) 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
} }
} }
err := install(c) name, 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 nil return name, nil
} }
func Start(conf *service.Service) error { func Start(conf *service.Service) error {

View File

@ -50,12 +50,11 @@ 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", typ) fmt.Printf("Starting launchd %s service...\n\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())
@ -109,11 +108,32 @@ func stop(conf *service.Service) error {
return nil return nil
} }
func install(c *service.Service) error { func Render(c *service.Service) ([]byte, 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
@ -124,32 +144,20 @@ func install(c *service.Service) 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
} }
// Create service file from template b, err := Render(c)
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, rw.Bytes(), 0644); err != nil { if err := ioutil.WriteFile(plistPath, b, 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
@ -158,9 +166,8 @@ func install(c *service.Service) 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
} }
fmt.Printf("Added and started '%s' as a launchctl service.\n", c.Name) return "launchd", nil
return nil
} }

View File

@ -88,12 +88,11 @@ 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", typ) fmt.Printf("Starting systemd %s service unit...\n\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())
@ -160,7 +159,28 @@ func stop(conf *service.Service) error {
return nil return nil
} }
func install(c *service.Service) error { func Render(c *service.Service) ([]byte, 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 {
@ -177,32 +197,20 @@ func install(c *service.Service) 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
} }
} }
// Create service file from template b, err := Render(c)
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, rw.Bytes(), 0644); err != nil { if err := ioutil.WriteFile(servicePath, b, 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
@ -217,9 +225,8 @@ func install(c *service.Service) 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
} }
fmt.Printf("Added and started '%s' as a systemd service.\n", c.Name) return "systemd", nil
return nil
} }

View File

@ -6,6 +6,10 @@ 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) error { func install(c *service.Service) (string, error) {
/* /*
// LEAVE THIS DOCUMENTATION HERE // LEAVE THIS DOCUMENTATION HERE
reg.exe reg.exe
@ -73,7 +73,7 @@ func install(c *service.Service) 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) 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,17 +108,22 @@ func install(c *service.Service) error {
//fmt.Println(autorunKey, c.Title, regSZ) //fmt.Println(autorunKey, c.Title, regSZ)
k.SetStringValue(c.Title, regSZ) k.SetStringValue(c.Title, regSZ)
// to return ErrDaemonize err = start(c)
return start(c) return "serviceman", err
}
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)
return &ErrDaemonize{ args = append(args, "--daemon")
DaemonArgs: append(args, "--daemon"), return Run(args[0], args[1:]...)
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 {
@ -173,7 +178,7 @@ func installServiceman(c *service.Service) ([]string, error) {
} }
} }
b, err := json.Marshal(c) b, err := Render(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,3 +227,21 @@ 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,5 +1,6 @@
//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 (
@ -9,8 +10,11 @@ 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"
@ -62,26 +66,15 @@ 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") flag.StringVar(&conf.Workdir, "workdir", "", "the directory in which the service should be started (if supported)")
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")
@ -89,67 +82,339 @@ 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()
args = flag.Args() flagargs := 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, "")
} }
n := len(args) exepath, err := findExec(flagargs[0], force)
if 0 == n { if nil != err {
fmt.Println("Usage: serviceman add ./foo-app -- --foo-arg") fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(2) os.Exit(3)
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
} }
execpath, err := manager.WhereIs(args[0]) flagargs = append(exeargs, flagargs...)
if nil != err { // TODO
fmt.Fprintf(os.Stderr, "Error: '%s' could not be found in PATH or working directory.\n", args[0]) for i := range flagargs {
if !force { arg := flagargs[i]
os.Exit(3) arg = filepath.ToSlash(arg)
return // Paths considered to be anything starting with ./, .\, /, \, C:
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...)
} }
conf.Normalize(force) // We won't bother with Interpreter here
// (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)
} }
err = manager.Install(conf) if len(ass) > 0 {
switch e := err.(type) { fmt.Println("OPTIONS: Making some assumptions...\n")
case nil: for i := range ass {
// do nothing fmt.Println("\t" + ass[i])
case *manager.ErrDaemonize: }
runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...)
default:
fmt.Fprintf(os.Stderr, "%s\n", err)
} }
// 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)
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() {
@ -185,14 +450,10 @@ func start() {
conf.NormalizeWithoutPath() conf.NormalizeWithoutPath()
err := manager.Start(conf) err := manager.Start(conf)
switch e := err.(type) { if nil != err {
case nil: fmt.Fprintf(os.Stderr, "%s\n", err)
// do nothing os.Exit(500)
case *manager.ErrDaemonize: return
runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...)
default:
fmt.Println(err)
os.Exit(127)
} }
} }
@ -242,7 +503,7 @@ func run() {
flag.Parse() flag.Parse()
if "" == confpath { if "" == confpath {
fmt.Fprintf(os.Stderr, "%s", strings.Join(flag.Args(), " ")) fmt.Fprintf(os.Stderr, "%s\n", 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)
@ -295,23 +556,5 @@ func run() {
return return
} }
runAsDaemon(os.Args[0], "run", "--config", confpath) manager.Run(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\t%s\n", conf.Logdir) fmt.Printf("If all went well the logs should have been created at:\n\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:") fmt.Println("If all went well you should be able to see some goodies in the logs:\n")
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\t%s\n", conf.Logdir) fmt.Printf("If all went well the logs should have been created at:\n\n\t%s\n", conf.Logdir)
} }