Compare commits

...

34 Commits

Author SHA1 Message Date
32c71bd698 add webi install instructions 2020-07-06 02:42:08 +00:00
8277fa7ac6 add canonical link 2020-04-22 19:22:41 +00:00
3b6c4bbb7d update curl command 2020-01-18 02:18:08 +00:00
1196f1d389 more generic download command 2020-01-18 02:16:15 +00:00
2824ee4c62 test service directory creation 2019-08-10 20:13:51 -06:00
e7a02191d8 lint: clarify double newline 2019-08-10 20:12:43 -06:00
693e61d7d4 address windows bug with incomplete args[0] paths 2019-08-10 15:54:56 -06:00
258623ae44 update generated files 2019-08-10 15:54:34 -06:00
8b6479dc95 update serviceman on npm 2019-08-09 01:20:17 -06:00
3513b64aa7 add 'serviceman list --all' to docs 2019-08-05 10:09:11 -06:00
b64dbc6ca6 build script: don't publish after build 2019-08-05 10:07:18 -06:00
6c6c0123ed clarify generated comment 2019-08-05 10:05:39 -06:00
AJ ONeal
b7989893cd tested and fixed on Windows 2019-08-05 10:03:07 -06:00
c84dc517a9 add error hints 2019-08-05 05:57:50 -06:00
34ed9cc065 list() for Mac and Linux 2019-08-05 05:53:32 -06:00
f03a0755af list Windows user services 2019-08-05 05:20:50 -06:00
cc0176e058 add serviceman comments 2019-08-05 05:20:50 -06:00
b3cbca14c6 template update 2019-08-05 04:51:00 -06:00
6ec2de0602 minor doc update for PATH 2019-08-05 04:47:27 -06:00
76710d58fa progress 2019-08-05 04:43:38 -06:00
94c00a777d M manager/dist/etc/systemd/system/_name_.service.tmpl
M  serviceman.go
2019-08-05 04:43:38 -06:00
c78cd82059 fix #5: only use group when specified in both dry-run and real templates 2019-08-05 04:40:47 -06:00
c8453f8d54 make chmod instruction more obvious 2019-07-28 05:14:33 -06:00
04ee9550ee bump source build ver 2019-07-14 02:06:45 -06:00
a31ba75927 v0.5.2: fix postinstall, really 2019-07-14 01:57:43 -06:00
386a6694e3 v0.5.1: fix postinstall 2019-07-14 01:55:35 -06:00
f97f217bc6 v0.5.0: Initial Release 2019-07-14 01:53:17 -06:00
f95897cf30 show simple example sooner 2019-07-13 21:14:32 -06:00
40a82f26c4 better arg handling, more descriptive output 2019-07-13 20:50:00 -06:00
389b88331d bump 2019-07-10 02:01:12 -06:00
8e1bd12df7 bugfix: also enable on restart on linux 2019-07-10 02:00:43 -06:00
dd25ba0787 update README 2019-07-10 01:55:01 -06:00
f9631d852a update README 2019-07-10 01:48:55 -06:00
b74d9f4332 mark latest dirty version for source builds 2019-07-10 01:26:09 -06:00
25 changed files with 1382 additions and 248 deletions

338
README.md
View File

@ -1,69 +1,100 @@
# go-serviceman
# [go-serviceman](https://git.rootprojects.org/root/serviceman)
A cross-platform service manager.
Cross-platform service management made easy.
Because debugging launchctl, systemd, etc absolutely sucks!
> sudo serviceman add --name foo ./serve.js --port 3000
...and I wanted a reasonable way to install [Telebit](https://telebit.io) on Windows.
(see more in the **Why** section below)
> Success: "foo" started as a "launchd" SYSTEM service, running as "root"
## 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
- Unprivileged (User Mode) Services
- [x] Linux (`sytemctl --user`)
- [x] MacOS (`launchctl`)
- [x] Windows (`HKEY_CURRENT_USER/.../Run`)
- Privileged (System) Services
- [x] Linux (`sudo sytemctl`)
- [x] MacOS (`sudo launchctl`)
- [ ] Windows (_not yet implemented_)
- Unprivileged (User Mode) Services with `--user` (_Default_)
- [x] Linux (`sytemctl --user`)
- [x] MacOS (`launchctl`)
- [x] Windows (`HKEY_CURRENT_USER/.../Run`)
- Privileged (System) Services with `--system` (_Default_ for `root`)
- [x] Linux (`sudo sytemctl`)
- [x] MacOS (`sudo launchctl`)
- [ ] Windows (_not yet implemented_)
# Table of Contents
- Usage
- Install
- Examples
- compiled programs
- scripts
- bash
- node
- python
- ruby
- Logging
- Debugging
- Windows
- Building
- Why
- Legal
- Usage
- Install
- Examples
- compiled programs
- scripts
- bash
- node
- python
- ruby
- PATH
- Logging
- Debugging
- Windows
- Building
- More Why
- Legal
# Usage
The basic pattern of usage, and what that might look like:
The basic pattern of usage:
```
serviceman add [options] [interpreter] <service> -- [service options]
```bash
sudo serviceman add --name "foobar" [options] [interpreter] <service> [--] [service options]
sudo serviceman start <service>
sudo serviceman stop <service>
sudo serviceman list --all
serviceman version
```
```
serviceman add foo.exe
And what that might look like:
```bash
sudo serviceman add --name "foo" foo.exe -c ./config.json
```
```
serviceman add --title "Foo App" node ./foo.js -- --bar
```
You can also view the help and the version:
You can also view the help:
```
serviceman add --help
```
```
serviceman version
```
# 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
You can install `serviceman` directly from the official git releases with [`webi`](https://webinstall.dev/serviceman):
**Mac**, **Linux**:
```bash
curl -sL https://webinstall.dev/serviceman | bash
```
**Windows 10**:
```pwsh
curl.exe -sLA "MS" https://webinstall.dev/serviceman | powershell
```
You can run this from cmd.exe or PowerShell (curl.exe is a native part of Windows 10).
## Manual Install
There are a number of pre-built binaries.
If none of them work for you, or you prefer to build from source,
@ -71,14 +102,25 @@ see the instructions for building far down below.
## Downloads
```
curl -fsSL "https://rootprojects.org/serviceman/dist/$(uname -s)/$(uname -m)/serviceman" -o serviceman
chmod +x ./serviceman
```
### MacOS
<details>
<summary>See download options</summary>
MacOS (darwin): [64-bit Download ](https://rootprojects.org/serviceman/dist/darwin/amd64/serviceman)
```
curl https://rootprojects.org/serviceman/dist/darwin/amd64/serviceman -o serviceman
chmod +x ./serviceman
```
</details>
### Windows
<details>
@ -111,6 +153,7 @@ powershell.exe "(New-Object Net.WebClient).DownloadFile('https://rootprojects.or
### Linux
<details>
<summary>See download options</summary>
@ -118,12 +161,14 @@ Linux (64-bit): [Download](https://rootprojects.org/serviceman/dist/linux/amd64/
```
curl https://rootprojects.org/serviceman/dist/linux/amd64/serviceman -o serviceman
chmod +x ./serviceman
```
Linux (32-bit): [Download](https://rootprojects.org/serviceman/dist/linux/386/serviceman)
```
curl https://rootprojects.org/serviceman/dist/linux/386/serviceman -o serviceman
chmod +x ./serviceman
```
</details>
@ -137,24 +182,28 @@ RPi 4 (64-bit armv8): [Download](https://rootprojects.org/serviceman/dist/linux/
```
curl https://rootprojects.org/serviceman/dist/linux/armv8/serviceman -o serviceman`
chmod +x ./serviceman
```
RPi 3 (armv7): [Download](https://rootprojects.org/serviceman/dist/linux/armv7/serviceman)
```
curl https://rootprojects.org/serviceman/dist/linux/armv7/serviceman -o serviceman
chmod +x ./serviceman
```
ARMv6: [Download](https://rootprojects.org/serviceman/dist/linux/armv6/serviceman)
```
curl https://rootprojects.org/serviceman/dist/linux/armv6/serviceman -o serviceman
chmod +x ./serviceman
```
RPi Zero (armv5): [Download](https://rootprojects.org/serviceman/dist/linux/armv5/serviceman)
```
curl https://rootprojects.org/serviceman/dist/linux/armv5/serviceman -o serviceman
chmod +x ./serviceman
```
</details>
@ -165,56 +214,112 @@ curl https://rootprojects.org/serviceman/dist/linux/armv5/serviceman -o servicem
```
mkdir %userprofile%\bin
reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin"
move serviceman.exe %userprofile%\bin\serviceman.exe
reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin"
```
**All Others**
```
chmod a+x ./serviceman
sudo mv ./serviceman /usr/local/bin/
```
# 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>
<summary>Compiled Programs</summary>
Normally you might your program somewhat like this:
```
dinglehopper --port 8421
```bash
gizmo run --port 8421 --config envs/prod.ini
```
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>
<summary>Using with scripts</summary>
Although your text script may be executable, you'll need to specify the interpreter
in order for `serviceman` to configure the service correctly.
For example, if you had a bash script that you normally ran like this:
```
```bash
./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
before the **--**.
1. Put a **hashbang** in your script, such as `#!/bin/bash`.
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**
@ -238,6 +343,8 @@ like this:
#!/usr/local/bin/node --harmony --inspect
```
Serviceman understands all 3 of those approaches.
</details>
<details>
@ -246,14 +353,37 @@ like this:
If normally you run your node script something like this:
```bash
node ./demo.js --foo bar --baz
pushd ~/my-node-project/
npm start
```
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.
@ -265,14 +395,15 @@ See **Using with scripts** for more detailed information.
If normally you run your python script something like this:
```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:
> **serviceman add** python ./demo.py **--** --foo bar --baz
It is important that you specify `python ./demo.py` and not just `./demo.py`
```bash
sudo serviceman add python ./serve.py --config ./config.ini
```
See **Using with scripts** for more detailed information.
@ -284,31 +415,52 @@ See **Using with scripts** for more detailed information.
If normally you run your ruby script something like this:
```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:
> **serviceman add** ruby ./demo.rb **--** --foo bar --baz
It is important that you specify `ruby ./demo.rb` and not just `./demo.rb`
```bash
sudo serviceman add ruby ./serve.rb --config ./config.yaml
```
See **Using with scripts** for more detailed information.
</details>
## Relative vs Absolute Paths
<details>
<summary>Setting PATH</summary>
Although serviceman can expand the executable's path,
if you have any arguments with relative paths
you should switch to using absolute paths.
You can set the `$PATH` (`%PATH%` on Windows) for your service like this:
```
dinglehopper --config ./conf.json
```bash
sudo serviceman add ./myservice --path "/home/myuser/bin"
```
Snapshot your actual path like this:
```bash
sudo serviceman add ./myservice --path "$PATH"
```
serviceman add dinglehopper -- --config /Users/me/dinglehopper/conf.json
Remember that this takes a snapshot and sets it in the configuration, it's not
a live reference to your path.
</details>
## Hints
- If something goes wrong, read the output **completely** - it'll probably be helpful
- Run `serviceman` from your **project directory**, just as you would run it normally
- 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`
```
# Example of a / that isn't a path
# (it needs to be escaped with --)
sudo serviceman add dinglehopper config/prod -- --category color/blue
```
# Logging
@ -317,6 +469,7 @@ serviceman add dinglehopper -- --config /Users/me/dinglehopper/conf.json
```bash
sudo journalctl -xef --unit <NAME>
sudo journalctl -xef --user-unit <NAME>
```
### Mac, Windows
@ -336,9 +489,9 @@ By default it's one of these:
You set it with one of these:
- `--logdir <path>` (cli)
- `"logdir": "<path>"` (json)
- `Logdir: "<path>"` (go)
- `--logdir <path>` (cli)
- `"logdir": "<path>"` (json)
- `Logdir: "<path>"` (go)
If anything about the logging sucks, tell me... unless they're your logs
(which they probably are), in which case _you_ should fix them.
@ -348,6 +501,9 @@ why your app failed to start.
# 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
terrible to debug - it's often difficult to find the logs, and nearly impossible
to interpret them, if they exist at all.
@ -373,9 +529,9 @@ Where `conf.json` looks something like
```json
{
"title": "Demo",
"exec": "/Users/me/go-demo/demo",
"argv": ["--foo", "bar", "--baz", "qux"]
"title": "Demo",
"exec": "/Users/me/go-demo/demo",
"argv": ["--foo", "bar", "--baz", "qux"]
}
```
@ -389,10 +545,10 @@ names and relative paths.
```json
{
"title": "Demo",
"interpreter": "node.exe",
"exec": "./bin/demo.js",
"argv": ["--foo", "bar", "--baz", "qux"]
"title": "Demo",
"interpreter": "node.exe",
"exec": "./bin/demo.js",
"argv": ["--foo", "bar", "--baz", "qux"]
}
```
@ -400,12 +556,12 @@ That's equivalent to this:
```json
{
"title": "Demo",
"title": "Demo",
"name": "demo",
"name": "demo",
"exec": "node.exe",
"argv": ["./bin/demo.js", "--foo", "bar", "--baz", "qux"]
"exec": "node.exe",
"argv": ["./bin/demo.js", "--foo", "bar", "--baz", "qux"]
}
```
@ -474,7 +630,7 @@ go build -mod=vendor -ldflags "-H=windowsgui" -o serviceman.exe
go build -mod=vendor -o /usr/local/bin/serviceman
```
# Why
# More Why
I created this for two reasons:

View File

@ -39,5 +39,5 @@ echo "RPi Zero ARMv5"
GOOS=linux GOARCH=arm GOARM=5 go build -mod=vendor -o dist/linux/armv5/${exe} $gocmd
echo ""
rsync -av ./dist/ ubuntu@rootprojects.org:/srv/www/rootprojects.org/serviceman/dist/
#rsync -av ./dist/ ubuntu@rootprojects.org:/srv/www/rootprojects.org/serviceman/dist/
# https://rootprojects.org/serviceman/dist/windows/amd64/serviceman.exe

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated for serviceman. Edit as you wish, but leave this line. -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
@ -27,6 +28,8 @@
{{if .User -}}
<key>UserName</key>
<string>{{ .User }}</string>
{{end -}}
{{if .Group -}}
<key>GroupName</key>
<string>{{ .Group }}</string>
<key>InitGroups</key>

View File

@ -1,3 +1,4 @@
# Generated for serviceman. Edit as you wish, but leave this line.
# Pre-req
# sudo mkdir -p {{ .Local }}/opt/{{ .Name }}/ {{ .Local }}/var/log/{{ .Name }}
{{ if .System -}}
@ -35,6 +36,9 @@ User={{ .User }}
Group={{ .Group }}
{{ end -}}
{{- if .Envs }}
Environment="{{- range $key, $value := .Envs }}{{ $key }}={{ $value }};{{- end }}"
{{- end }}
{{ if .Workdir -}}
WorkingDirectory={{ .Workdir }}
{{ end -}}

View File

@ -14,7 +14,7 @@ import (
// Install will do a best-effort attempt to install a start-on-startup
// 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 {
c.Exec = c.Name
}
@ -24,23 +24,23 @@ func Install(c *service.Service) error {
if nil != err {
fmt.Fprintf(os.Stderr, "Unrecoverable Error: %s", err)
os.Exit(4)
return err
return "", err
} else {
c.Home = home
}
}
err := install(c)
name, err := install(c)
if nil != err {
return err
return "", err
}
err = os.MkdirAll(c.Logdir, 0755)
if nil != err {
return err
return "", err
}
return nil
return name, nil
}
func Start(conf *service.Service) error {
@ -51,6 +51,10 @@ func Stop(conf *service.Service) error {
return stop(conf)
}
func List(conf *service.Service) ([]string, []string, []error) {
return list(conf)
}
// IsPrivileged returns true if we suspect that the current user (or process) will be able
// to write to system folders, bind to privileged ports, and otherwise
// successfully run a system service.
@ -67,6 +71,16 @@ func WhereIs(exe string) (string, error) {
return filepath.Abs(filepath.ToSlash(exepath))
}
type ManageError struct {
Name string
Hint string
Parent error
}
func (e *ManageError) Error() string {
return e.Name + ": " + e.Hint + ": " + e.Parent.Error()
}
type ErrDaemonize struct {
DaemonArgs []string
error string

View File

@ -50,12 +50,11 @@ func start(conf *service.Service) error {
cmds = adjustPrivs(system, cmds)
fmt.Println()
typ := "USER"
if system {
typ = "SYSTEM"
}
fmt.Printf("Starting launchd %s service...\n", typ)
fmt.Printf("Starting launchd %s service...\n\n", typ)
for i := range cmds {
exe := cmds[i]
fmt.Println("\t" + exe.String())
@ -109,11 +108,33 @@ func stop(conf *service.Service) error {
return nil
}
func install(c *service.Service) error {
// Render will create a launchd .plist file using the simple internal template
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
if c.PrivilegedPorts {
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
@ -122,34 +143,22 @@ func install(c *service.Service) error {
}
// Check paths first
err := os.MkdirAll(filepath.Dir(plistDir), 0755)
err := os.MkdirAll(plistDir, 0755)
if nil != err {
return err
return "", err
}
// 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)
b, err := Render(c)
if nil != err {
return err
return "", err
}
// Write the file out
// TODO rdns
plistName := c.ReverseDNS + ".plist"
plistPath := filepath.Join(plistDir, plistName)
if err := ioutil.WriteFile(plistPath, rw.Bytes(), 0644); err != nil {
return fmt.Errorf("Error writing %s: %v", plistPath, err)
if err := ioutil.WriteFile(plistPath, b, 0644); err != nil {
return "", fmt.Errorf("Error writing %s: %v", plistPath, err)
}
// TODO --no-start
@ -158,9 +167,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("\tsudo launchctl log level debug\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 nil
return "launchd", nil
}

View File

@ -52,6 +52,12 @@ func start(conf *service.Service) error {
Args: []string{"stop", name + ".service"},
Must: false,
},
Runnable{
Exec: "systemctl",
Args: []string{"enable", name + ".service"},
Badwords: []string{"not found", "failed"},
Must: true,
},
Runnable{
Exec: "systemctl",
Args: []string{"start", name + ".service"},
@ -82,12 +88,11 @@ func start(conf *service.Service) error {
cmds = adjustPrivs(system, cmds)
fmt.Println()
typ := "USER MODE"
if 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 {
exe := cmds[i]
fmt.Println("\t" + exe.String())
@ -154,16 +159,32 @@ func stop(conf *service.Service) error {
return nil
}
func install(c *service.Service) error {
// Linux-specific config options
if c.System {
if "" == c.User {
c.User = "root"
}
// Render will create a systemd .service file using the simple internal template
func Render(c *service.Service) ([]byte, error) {
defaultUserGroup(c)
// Create service file from template
b, err := static.ReadFile("dist/etc/systemd/system/_name_.service.tmpl")
if err != nil {
return nil, err
}
if "" == c.Group {
c.Group = c.User
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) {
defaultUserGroup(c)
// Check paths first
serviceDir := srvSysPath
@ -171,32 +192,20 @@ func install(c *service.Service) error {
serviceDir = filepath.Join(c.Home, srvUserPath)
err := os.MkdirAll(serviceDir, 0755)
if nil != err {
return err
return "", err
}
}
// 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)
b, err := Render(c)
if nil != err {
return err
return "", err
}
// Write the file out
serviceName := c.Name + ".service"
servicePath := filepath.Join(serviceDir, serviceName)
if err := ioutil.WriteFile(servicePath, rw.Bytes(), 0644); err != nil {
return fmt.Errorf("Error writing %s: %v", servicePath, err)
if err := ioutil.WriteFile(servicePath, b, 0644); err != nil {
return "", fmt.Errorf("Error writing %s: %v", servicePath, err)
}
// TODO --no-start
@ -211,9 +220,20 @@ 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("\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 nil
return "systemd", nil
}
func defaultUserGroup(c *service.Service) {
// Linux-specific config options
if c.System {
if "" == c.User {
c.User = "root"
}
}
if "" == c.Group {
c.Group = c.User
}
}

74
manager/install_nixes.go Normal file
View File

@ -0,0 +1,74 @@
// +build !windows
package manager
import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"strings"
"git.rootprojects.org/root/go-serviceman/service"
)
// this code is shared between Mac and Linux, but may diverge in the future
func list(c *service.Service) ([]string, []string, []error) {
confDir := srvSysPath
if !c.System {
confDir = filepath.Join(c.Home, srvUserPath)
}
// Enuser path exists
err := os.MkdirAll(confDir, 0755)
if nil != err {
return nil, nil, []error{err}
}
fis, err := ioutil.ReadDir(confDir)
if nil != err {
return nil, nil, []error{err}
}
managed := []string{}
others := []string{}
errs := []error{}
b := make([]byte, 256)
for i := range fis {
fi := fis[i]
if !strings.HasSuffix(strings.ToLower(fi.Name()), srvExt) || len(fi.Name()) <= srvLen {
continue
}
confFile := filepath.Join(confDir, fi.Name())
r, err := os.Open(confFile)
if nil != err {
errs = append(errs, &ManageError{
Name: confFile,
Hint: "Open file",
Parent: err,
})
continue
}
n, err := r.Read(b)
if nil != err {
errs = append(errs, &ManageError{
Name: confFile,
Hint: "Read file",
Parent: err,
})
continue
}
b = b[:n]
name := fi.Name()[:len(fi.Name())-srvLen]
if bytes.Contains(b, []byte("for serviceman.")) {
managed = append(managed, name)
} else {
others = append(others, name)
}
}
return managed, others, errs
}

View File

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

View File

@ -6,6 +6,7 @@ import (
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
@ -30,7 +31,7 @@ func init() {
// TODO system service requires elevated privileges
// 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
reg.exe
@ -73,7 +74,7 @@ func install(c *service.Service) error {
args, err := installServiceman(c)
if nil != err {
return err
return "", err
}
/*
@ -100,7 +101,7 @@ func install(c *service.Service) error {
regSZ := fmt.Sprintf(`"%s" %s`, args[0], strings.Join(args[1:], " "))
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,
// it has to not output any messages?
@ -108,23 +109,70 @@ func install(c *service.Service) error {
//fmt.Println(autorunKey, c.Title, regSZ)
k.SetStringValue(c.Title, regSZ)
// to return ErrDaemonize
return start(c)
err = 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 {
args := getRunnerArgs(conf)
return &ErrDaemonize{
DaemonArgs: append(args, "--daemon"),
error: "Not as much an error as a bad value...",
}
//return runner.Start(conf)
args = append(args, "--daemon")
return Run(args[0], args[1:]...)
}
func stop(conf *service.Service) error {
return runner.Stop(conf)
}
func list(c *service.Service) ([]string, []string, []error) {
var errs []error
regs, err := listRegistry(c)
if nil != err {
errs = append(errs, err)
}
cfgs, errors := listConfigs(c)
if 0 != len(errors) {
errs = append(errs, errors...)
}
managed := []string{}
for i := range cfgs {
managed = append(managed, cfgs[i].Name)
}
others := []string{}
for i := range regs {
reg := regs[i]
if 0 == len(cfgs) {
others = append(others, reg)
continue
}
var found bool
for j := range cfgs {
cfg := cfgs[j]
// Registry Value Names are case-insensitive
if strings.ToLower(reg) == strings.ToLower(cfg.Title) {
found = true
}
}
if !found {
others = append(others, reg)
}
}
return managed, others, errs
}
func getRunnerArgs(c *service.Service) []string {
self := os.Args[0]
debug := ""
@ -149,6 +197,84 @@ func getRunnerArgs(c *service.Service) []string {
}
}
type winConf struct {
Filename string `json:"-"`
Name string `json:"name"`
Title string `json:"title"`
}
func listConfigs(c *service.Service) ([]winConf, []error) {
var errs []error
smdir := `\opt\serviceman`
if !c.System {
smdir = filepath.Join(c.Home, ".local", smdir)
}
confpath := filepath.Join(smdir, `etc`)
infos, err := ioutil.ReadDir(confpath)
if nil != err {
if os.IsNotExist(err) {
return nil, nil
}
errs = append(errs, &ManageError{
Name: confpath,
Hint: "Read directory",
Parent: err,
})
return nil, errs
}
// TODO report active status
srvs := []winConf{}
for i := range infos {
filename := strings.ToLower(infos[i].Name())
if len(filename) <= srvLen || !strings.HasSuffix(filename, srvExt) {
continue
}
name := filename[:len(filename)-srvLen]
b, err := ioutil.ReadFile(filepath.Join(confpath, filename))
if nil != err {
errs = append(errs, &ManageError{
Name: name,
Hint: "Read file",
Parent: err,
})
continue
}
cfg := winConf{Filename: filename}
err = json.Unmarshal(b, &cfg)
if nil != err {
errs = append(errs, &ManageError{
Name: name,
Hint: "Parse JSON",
Parent: err,
})
continue
}
srvs = append(srvs, cfg)
}
return srvs, errs
}
func listRegistry(c *service.Service) ([]string, error) {
autorunKey := `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
k, _, err := registry.CreateKey(
registry.CURRENT_USER,
autorunKey,
registry.QUERY_VALUE,
)
if err != nil {
log.Fatal(err)
}
defer k.Close()
return k.ReadValueNames(-1)
}
// copies self to install path and returns config path
func installServiceman(c *service.Service) ([]string, error) {
// TODO check version and upgrade or dismiss
@ -163,6 +289,13 @@ func installServiceman(c *service.Service) ([]string, error) {
if nil != err {
return nil, err
}
// Note: self may be the short name, in which case
// we should just use whatever is closest in the path
// exec.LookPath will handle this correctly
self, err = exec.LookPath(self)
if nil != err {
return nil, err
}
bin, err := ioutil.ReadFile(self)
if nil != err {
return nil, err
@ -173,7 +306,7 @@ func installServiceman(c *service.Service) ([]string, error) {
}
}
b, err := json.Marshal(c)
b, err := Render(c)
if nil != err {
// this should be impossible, so we'll just panic
panic(err)

View File

@ -3,6 +3,7 @@ package manager
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"
@ -121,7 +122,12 @@ func getSystemSrvs() ([]string, error) {
}
func getUserSrvs(home string) ([]string, error) {
return getSrvs(filepath.Join(home, srvUserPath))
confDir := filepath.Join(home, srvUserPath)
err := os.MkdirAll(confDir, 0755)
if nil != err {
return nil, err
}
return getSrvs(confDir)
}
// "come.example.foo.plist" matches "foo"
@ -227,3 +233,21 @@ func adjustPrivs(system bool, cmds []Runnable) []Runnable {
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

@ -0,0 +1,32 @@
package manager
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"testing"
)
func TestEmptyUserServicePath(t *testing.T) {
srvs, err := getUserSrvs("/tmp/fakeuser")
if nil != err {
t.Fatal(err)
}
if len(srvs) > 0 {
t.Fatal(fmt.Errorf("sanity fail: shouldn't get services from empty directory"))
}
dirs, err := ioutil.ReadDir(filepath.Join("/tmp/fakeuser", srvUserPath))
if nil != err {
t.Fatal(err)
}
if len(dirs) > 0 {
t.Fatal(fmt.Errorf("sanity fail: shouldn't get listing from empty directory"))
}
err = os.RemoveAll("/tmp/fakeuser")
if nil != err {
panic("couldn't remove /tmp/fakeuser")
}
}

File diff suppressed because one or more lines are too long

1
npm/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

28
npm/README.md Normal file
View File

@ -0,0 +1,28 @@
# serviceman
A cross-platform service manager
```bash
serviceman add --name "my-project" node ./serve.js --port 3000
serviceman stop my-project
serviceman start my-project
```
Works with launchd (Mac), systemd (Linux), or standalone (Windows).
## Meta Package
This is a meta-package to fetch and install the correction version of
[go-serviceman](https://git.rootprojects.org/root/serviceman)
for your architecture and platform.
```bash
npm install serviceman
```
## How does it work?
1. Resolves executable from PATH, or hashbang (ex: `#!/usr/bin/env node`)
2. Resolves file and directory paths to absolute paths (ex: `/Users/me/my-project/serve.js`)
3. Creates a template `.plist` (Mac), `.service` (Linux), or `.json` (Windows) file
4. Calls `launchd` (Mac), `systemd` (Linux), or `serviceman-runner` (Windows) to enable/start/stop/etc

1
npm/bin/serviceman Normal file
View File

@ -0,0 +1 @@
# this will be replaced by the postinstall script

18
npm/package-lock.json generated Normal file
View File

@ -0,0 +1,18 @@
{
"name": "serviceman",
"version": "0.5.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@root/mkdirp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@root/mkdirp/-/mkdirp-1.0.0.tgz",
"integrity": "sha512-hxGAYUx5029VggfG+U9naAhQkoMSXtOeXtbql97m3Hi6/sQSRL/4khKZPyOF6w11glyCOU38WCNLu9nUcSjOfA=="
},
"@root/request": {
"version": "1.3.11",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.11.tgz",
"integrity": "sha512-3a4Eeghcjsfe6zh7EJ+ni1l8OK9Fz2wL1OjP4UCa0YdvtH39kdXB9RGWuzyNv7dZi0+Ffkc83KfH0WbPMiuJFw=="
}
}
}

39
npm/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "serviceman",
"version": "0.7.0",
"description": "A cross-platform service manager",
"main": "index.js",
"homepage": "https://git.rootprojects.org/root/serviceman/src/branch/master/npm",
"files": [
"bin/",
"scripts/"
],
"bin": {
"serviceman": "bin/serviceman"
},
"scripts": {
"serviceman": "serviceman",
"postinstall": "node scripts/fetch-serviceman.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.rootprojects.org/root/serviceman.git"
},
"keywords": [
"launchd",
"systemd",
"winsvc",
"launchctl",
"systemctl",
"HKEY_CURRENT_USER",
"HKCU",
"Run"
],
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0",
"dependencies": {
"@root/mkdirp": "^1.0.0",
"@root/request": "^1.3.11"
}
}

269
npm/scripts/fetch-serviceman.js Executable file
View File

@ -0,0 +1,269 @@
#!/usr/bin/env node
'use strict';
var path = require('path');
var os = require('os');
// https://nodejs.org/api/os.html#os_os_arch
// 'arm', 'arm64', 'ia32', 'mips', 'mipsel', 'ppc', 'ppc64', 's390', 's390x', 'x32', and 'x64'
var arch = os.arch(); // process.arch
// https://nodejs.org/api/os.html#os_os_platform
// 'aix', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32'
var platform = os.platform(); // process.platform
var ext = /^win/i.test(platform) ? '.exe' : '';
// This is _probably_ right. It's good enough for us
// https://github.com/nodejs/node/issues/13629
if ('arm' === arch) {
arch += 'v' + process.config.variables.arm_version;
}
var map = {
// arches
armv6: 'armv6',
armv7: 'armv7',
arm64: 'armv8',
ia32: '386',
x32: '386',
x64: 'amd64',
// platforms
darwin: 'darwin',
linux: 'linux',
win32: 'windows'
};
arch = map[arch];
platform = map[platform];
if (!arch || !platform) {
console.error(
"'" + os.platform() + "' on '" + os.arch() + "' isn't supported yet."
);
console.error(
'Please open an issue at https://git.rootprojects.org/root/serviceman/issues'
);
process.exit(1);
}
var newVer = require('../package.json').version;
var fs = require('fs');
var exec = require('child_process').exec;
var request = require('@root/request');
var mkdirp = require('@root/mkdirp');
function needsUpdate(oldVer, newVer) {
// "v1.0.0-pre" is BEHIND "v1.0.0"
newVer = newVer
.replace(/^v/, '')
.split(/[\.\-\+]/)
.filter(Boolean);
oldVer = oldVer
.replace(/^v/, '')
.split(/[\.\-\+]/)
.filter(Boolean);
if (!oldVer.length) {
return true;
}
// ex: v1.0.0-pre vs v1.0.0
if (newVer[3] && !oldVer[3]) {
// don't install beta over stable
return false;
}
// ex: old is v1.0.0-pre
if (oldVer[3]) {
if (oldVer[2] > 0) {
oldVer[2] -= 1;
} else if (oldVer[1] > 0) {
oldVer[2] = 999;
oldVer[1] -= 1;
} else if (oldVer[0] > 0) {
oldVer[2] = 999;
oldVer[1] = 999;
oldVer[0] -= 1;
} else {
// v0.0.0
return true;
}
}
// ex: v1.0.1 vs v1.0.0-pre
if (newVer[3]) {
if (newVer[2] > 0) {
newVer[2] -= 1;
} else if (newVer[1] > 0) {
newVer[2] = 999;
newVer[1] -= 1;
} else if (newVer[0] > 0) {
newVer[2] = 999;
newVer[1] = 999;
newVer[0] -= 1;
} else {
// v0.0.0
return false;
}
}
// ex: v1.0.1 vs v1.0.0
if (oldVer[0] > newVer[0]) {
return false;
} else if (oldVer[0] < newVer[0]) {
return true;
} else if (oldVer[1] > newVer[1]) {
return false;
} else if (oldVer[1] < newVer[1]) {
return true;
} else if (oldVer[2] > newVer[2]) {
return false;
} else if (oldVer[2] < newVer[2]) {
return true;
} else if (!oldVer[3] && newVer[3]) {
return false;
} else if (oldVer[3] && !newVer[3]) {
return true;
} else {
return false;
}
}
/*
// Same version
console.log(false === needsUpdate('0.5.0', '0.5.0'));
// No previous version
console.log(true === needsUpdate('', '0.5.1'));
// The new version is slightly newer
console.log(true === needsUpdate('0.5.0', '0.5.1'));
console.log(true === needsUpdate('0.4.999-pre1', '0.5.0-pre1'));
// The new version is slightly older
console.log(false === needsUpdate('0.5.0', '0.5.0-pre1'));
console.log(false === needsUpdate('0.5.1', '0.5.0'));
*/
function install(name, bindirs, getVersion, parseVersion, urlTpl) {
exec(getVersion, { windowsHide: true }, function(err, stdout) {
var oldVer = parseVersion(stdout);
//console.log('old:', oldVer, 'new:', newVer);
if (!needsUpdate(oldVer, newVer)) {
console.info(
'Current ' + name + ' version is new enough:',
oldVer,
newVer
);
return;
//} else {
// console.info('Current serviceman version is older:', oldVer, newVer);
}
var url = urlTpl
.replace(/{{ .Version }}/g, newVer)
.replace(/{{ .Platform }}/g, platform)
.replace(/{{ .Arch }}/g, arch)
.replace(/{{ .Ext }}/g, ext);
console.info('Installing from', url);
return request({ uri: url, encoding: null }, function(err, resp) {
if (err) {
console.error(err);
return;
}
//console.log(resp.body.byteLength);
//console.log(typeof resp.body);
var bin = name + ext;
function next() {
if (!bindirs.length) {
return;
}
var bindir = bindirs.pop();
return mkdirp(bindir, function(err) {
if (err) {
console.error(err);
return;
}
var localsrv = path.join(bindir, bin);
return fs.writeFile(localsrv, resp.body, function(err) {
next();
if (err) {
console.error(err);
return;
}
fs.chmodSync(localsrv, parseInt('0755', 8));
console.info('Wrote', bin, 'to', bindir);
});
});
}
next();
});
});
}
function winstall(name, bindir) {
try {
fs.writeFileSync(
path.join(bindir, name),
'#!/usr/bin/env bash\n"$(dirname "$0")/serviceman.exe" "$@"\nexit $?'
);
} catch (e) {
// ignore
}
// because bugs in npm + git bash oddities, of course
// https://npm.community/t/globally-installed-package-does-not-execute-in-git-bash-on-windows/9394
try {
fs.writeFileSync(
path.join(path.join(__dirname, '../../.bin'), name),
[
'#!/bin/sh',
'# manual bugfix patch for npm on windows',
'basedir=$(dirname "$(echo "$0" | sed -e \'s,\\\\,/,g\')")',
'"$basedir/../' + name + '/bin/' + name + '" "$@"',
'exit $?'
].join('\n')
);
} catch (e) {
// ignore
}
try {
fs.writeFileSync(
path.join(path.join(__dirname, '../../..'), name),
[
'#!/bin/sh',
'# manual bugfix patch for npm on windows',
'basedir=$(dirname "$(echo "$0" | sed -e \'s,\\\\,/,g\')")',
'"$basedir/node_modules/' + name + '/bin/' + name + '" "$@"',
'exit $?'
].join('\n')
);
} catch (e) {
// ignore
}
// end bugfix
}
function run() {
//var homedir = require('os').homedir();
//var bindir = path.join(homedir, '.local', 'bin');
var bindir = path.resolve(__dirname, '..', 'bin');
var name = 'serviceman';
if ('.exe' === ext) {
winstall(name, bindir);
}
return install(
name,
[bindir],
'serviceman version',
function parseVersion(stdout) {
return (stdout || '').split(' ')[0];
},
'https://rootprojects.org/serviceman/dist/{{ .Platform }}/{{ .Arch }}/serviceman{{ .Ext }}'
);
}
if (require.main === module) {
run();
}

View File

@ -78,6 +78,11 @@ func Start(conf *service.Service) error {
if "" != conf.Workdir {
cmd.Dir = conf.Workdir
}
if len(conf.Envs) > 0 {
for k, v := range conf.Envs {
cmd.Env = append(cmd.Env, k+"="+v)
}
}
err = cmd.Start()
if nil != err {
fmt.Fprintf(lf, "[%s] Could not start %q process: %s\n", time.Now(), conf.Name, err)

View File

@ -116,7 +116,7 @@ func (s *Service) Normalize(force bool) {
_, err := os.Stat(optpath)
if nil == err {
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
}
}

View File

@ -1,5 +1,6 @@
//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver
// main runs the things and does the stuff
package main
import (
@ -9,8 +10,11 @@ import (
"io/ioutil"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"git.rootprojects.org/root/go-serviceman/manager"
"git.rootprojects.org/root/go-serviceman/runner"
@ -18,7 +22,7 @@ import (
)
var GitRev = "000000000"
var GitVersion = "v0.0.0"
var GitVersion = "v0.5.3-pre+dirty"
var GitTimestamp = time.Now().Format(time.RFC3339)
func usage() {
@ -26,6 +30,7 @@ func usage() {
fmt.Println("\tserviceman <command> --help")
fmt.Println("\tserviceman add ./foo-app -- --foo-arg")
fmt.Println("\tserviceman run --config ./foo-app.json")
fmt.Println("\tserviceman list --all")
fmt.Println("\tserviceman start <name>")
fmt.Println("\tserviceman stop <name>")
}
@ -50,6 +55,8 @@ func main() {
start()
case "stop":
stop()
case "list":
list()
default:
fmt.Fprintf(os.Stderr, "Unknown argument %s\n", top)
usage()
@ -62,41 +69,268 @@ func add() {
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
forUser := false
forSystem := false
dryrun := false
pathEnv := ""
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.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.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.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(&force, "force", false, "if the interpreter or executable doesn't exist, or things don't make sense, try anyway")
flag.StringVar(&pathEnv, "path", "", "set the path for the resulting systemd service")
flag.StringVar(&conf.User, "username", "", "run the service as this user")
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(&dryrun, "dryrun", false, "output the service file without modifying anything on disk")
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 {
fmt.Println("Pfff! You can't --user AND --system! What are you trying to pull?")
os.Exit(1)
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 {
conf.System = false
} else if forSystem {
conf.System = true
} else {
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, "")
}
if "" != pathEnv {
conf.Envs = make(map[string]string)
conf.Envs["PATH"] = pathEnv
}
exepath, err := findExec(flagargs[0], force)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err)
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
}
flagargs = append(exeargs, flagargs...)
// TODO
for i := range flagargs {
arg := flagargs[i]
arg = filepath.ToSlash(arg)
// 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
}
}
// 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)
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)
}
if len(ass) > 0 {
fmt.Printf("OPTIONS: Making some assumptions...\n\n")
for i := range ass {
fmt.Println("\t" + ass[i])
}
}
// 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)
fmt.Println()
servicemode := "USER MODE"
if conf.System {
servicemode = "SYSTEM"
}
fmt.Printf(
"SUCCESS:\n\n\t%q started as a %s %s service, running as %q\n",
conf.Name,
servicetype,
servicemode,
runAs,
)
fmt.Println()
}
func list() {
var verbose bool
forUser := false
forSystem := false
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(&verbose, "all", false, "show all services (even those not managed by serviceman)")
flag.Parse()
if forUser && forSystem {
fmt.Println("Pfff! You can't --user AND --system! What are you trying to pull?")
os.Exit(1)
return
}
conf := &service.Service{}
if forUser {
conf.System = false
} else if forSystem {
@ -105,51 +339,140 @@ func add() {
conf.System = manager.IsPrivileged()
}
n := len(args)
if 0 == n {
fmt.Println("Usage: serviceman add ./foo-app -- --foo-arg")
os.Exit(2)
return
// Pretty much just for HomeDir
conf.NormalizeWithoutPath()
managed, others, errs := manager.List(conf)
for i := range errs {
fmt.Fprintf(os.Stderr, "possible error: %s\n", errs[i])
}
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "\n")
}
execpath, err := manager.WhereIs(args[0])
if nil != err {
fmt.Fprintf(os.Stderr, "Error: '%s' could not be found in PATH or working directory.\n", args[0])
if !force {
os.Exit(3)
return
fmt.Printf("serviceman-managed services:\n\n")
for i := range managed {
fmt.Println("\t" + managed[i])
}
if 0 == len(managed) {
fmt.Println("\t(none)")
}
fmt.Println("")
if verbose {
fmt.Printf("other services:\n\n")
for i := range others {
fmt.Println("\t" + others[i])
}
} else {
args[0] = execpath
if 0 == len(others) {
fmt.Println("\t(none)")
}
fmt.Println("")
}
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...)
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
}
conf.Normalize(force)
// 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
}
//fmt.Printf("\n%#v\n\n", conf)
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)
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
}
err = manager.Install(conf)
switch e := err.(type) {
case nil:
// do nothing
case *manager.ErrDaemonize:
runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...)
// 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:
fmt.Fprintf(os.Stderr, "%s\n", err)
// 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
}
printLogMessage(conf)
fmt.Println()
// 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() {
@ -185,14 +508,10 @@ func start() {
conf.NormalizeWithoutPath()
err := manager.Start(conf)
switch e := err.(type) {
case nil:
// do nothing
case *manager.ErrDaemonize:
runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...)
default:
fmt.Println(err)
os.Exit(127)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(500)
return
}
}
@ -242,7 +561,7 @@ func run() {
flag.Parse()
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")
usage()
os.Exit(1)
@ -295,23 +614,5 @@ func run() {
return
}
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)
}
manager.Run(os.Args[0], "run", "--config", confpath)
}

View File

@ -7,5 +7,5 @@ import (
)
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 {
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)
if !conf.System {
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) {
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)
}