Compare commits

..

29 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
25 changed files with 1338 additions and 216 deletions

268
README.md
View File

@ -1,19 +1,25 @@
# 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. > Success: "foo" started as a "launchd" SYSTEM service, running as "root"
(see more in the **Why** section below)
## Why?
Because it sucks to debug launchctl, systemd, etc.
Also, I wanted a reasonable way to install [Telebit](https://telebit.io) on Windows.
(see more in the **More Why** section below)
## Features ## Features
- Unprivileged (User Mode) Services - 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_)
@ -29,37 +35,30 @@ Because debugging launchctl, systemd, etc absolutely sucks!
- node - node
- python - python
- ruby - ruby
- PATH
- Logging - Logging
- Debugging - Debugging
- Windows - Windows
- Building - Building
- Why - More Why
- Legal - Legal
# Usage # Usage
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>
sudo serviceman list --all
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,8 +67,34 @@ 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
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. There are a number of pre-built binaries.
If none of them work for you, or you prefer to build from source, If none of them work for you, or you prefer to build from source,
@ -77,14 +102,25 @@ see the instructions for building far down below.
## Downloads ## Downloads
```
curl -fsSL "https://rootprojects.org/serviceman/dist/$(uname -s)/$(uname -m)/serviceman" -o serviceman
chmod +x ./serviceman
```
### MacOS ### MacOS
<details>
<summary>See download options</summary>
MacOS (darwin): [64-bit Download ](https://rootprojects.org/serviceman/dist/darwin/amd64/serviceman) MacOS (darwin): [64-bit Download ](https://rootprojects.org/serviceman/dist/darwin/amd64/serviceman)
``` ```
curl https://rootprojects.org/serviceman/dist/darwin/amd64/serviceman -o serviceman curl https://rootprojects.org/serviceman/dist/darwin/amd64/serviceman -o serviceman
chmod +x ./serviceman
``` ```
</details>
### Windows ### Windows
<details> <details>
@ -117,6 +153,7 @@ powershell.exe "(New-Object Net.WebClient).DownloadFile('https://rootprojects.or
### Linux ### Linux
<details> <details>
<summary>See download options</summary> <summary>See download options</summary>
@ -124,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 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) Linux (32-bit): [Download](https://rootprojects.org/serviceman/dist/linux/386/serviceman)
``` ```
curl https://rootprojects.org/serviceman/dist/linux/386/serviceman -o serviceman curl https://rootprojects.org/serviceman/dist/linux/386/serviceman -o serviceman
chmod +x ./serviceman
``` ```
</details> </details>
@ -143,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` 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) RPi 3 (armv7): [Download](https://rootprojects.org/serviceman/dist/linux/armv7/serviceman)
``` ```
curl https://rootprojects.org/serviceman/dist/linux/armv7/serviceman -o 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) ARMv6: [Download](https://rootprojects.org/serviceman/dist/linux/armv6/serviceman)
``` ```
curl https://rootprojects.org/serviceman/dist/linux/armv6/serviceman -o 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) RPi Zero (armv5): [Download](https://rootprojects.org/serviceman/dist/linux/armv5/serviceman)
``` ```
curl https://rootprojects.org/serviceman/dist/linux/armv5/serviceman -o serviceman curl https://rootprojects.org/serviceman/dist/linux/armv5/serviceman -o serviceman
chmod +x ./serviceman
``` ```
</details> </details>
@ -171,56 +214,112 @@ 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**
``` ```
chmod a+x ./serviceman
sudo mv ./serviceman /usr/local/bin/ 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 +343,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 +353,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 +395,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 +415,52 @@ 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 <details>
<summary>Setting PATH</summary>
Although serviceman can expand the executable's path, You can set the `$PATH` (`%PATH%` on Windows) for your service like this:
if you have any arguments with relative paths
you should switch to using absolute paths.
``` ```bash
dinglehopper --config ./conf.json 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 # Logging
@ -323,6 +469,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 +501,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.
@ -480,7 +630,7 @@ go build -mod=vendor -ldflags "-H=windowsgui" -o serviceman.exe
go build -mod=vendor -o /usr/local/bin/serviceman go build -mod=vendor -o /usr/local/bin/serviceman
``` ```
# Why # More Why
I created this for two reasons: 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 GOOS=linux GOARCH=arm GOARM=5 go build -mod=vendor -o dist/linux/armv5/${exe} $gocmd
echo "" 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 # https://rootprojects.org/serviceman/dist/windows/amd64/serviceman.exe

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
@ -27,6 +28,8 @@
{{if .User -}} {{if .User -}}
<key>UserName</key> <key>UserName</key>
<string>{{ .User }}</string> <string>{{ .User }}</string>
{{end -}}
{{if .Group -}}
<key>GroupName</key> <key>GroupName</key>
<string>{{ .Group }}</string> <string>{{ .Group }}</string>
<key>InitGroups</key> <key>InitGroups</key>

View File

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

View File

@ -14,7 +14,7 @@ import (
// Install will do a best-effort attempt to install a start-on-startup // Install will do a best-effort attempt to install a start-on-startup
// user or system service via systemd, launchd, or reg.exe // user or system service via systemd, launchd, or reg.exe
func Install(c *service.Service) 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 {
@ -51,6 +51,10 @@ func Stop(conf *service.Service) error {
return stop(conf) 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 // 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 // to write to system folders, bind to privileged ports, and otherwise
// successfully run a system service. // successfully run a system service.
@ -67,6 +71,16 @@ func WhereIs(exe string) (string, error) {
return filepath.Abs(filepath.ToSlash(exepath)) 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 { type ErrDaemonize struct {
DaemonArgs []string DaemonArgs []string
error string error string

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,33 @@ func stop(conf *service.Service) error {
return nil 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 // 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
@ -122,34 +143,22 @@ func install(c *service.Service) error {
} }
// Check paths first // Check paths first
err := os.MkdirAll(filepath.Dir(plistDir), 0755) err := os.MkdirAll(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 +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("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,49 +159,53 @@ func stop(conf *service.Service) error {
return nil return nil
} }
func install(c *service.Service) error { // Render will create a systemd .service file using the simple internal template
// Linux-specific config options func Render(c *service.Service) ([]byte, error) {
if c.System { defaultUserGroup(c)
if "" == c.User {
c.User = "root" // 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
} }
if "" == c.Group { err = tmpl.Execute(rw, c)
c.Group = c.User if nil != err {
return nil, err
} }
return rw.Bytes(), nil
}
func install(c *service.Service) (string, error) {
defaultUserGroup(c)
// Check paths first // Check paths first
serviceDir := srvSysPath serviceDir := srvSysPath
if !c.System { if !c.System {
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 +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("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 }
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" "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

@ -6,6 +6,7 @@ import (
"io/ioutil" "io/ioutil"
"log" "log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@ -30,7 +31,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 +74,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 +101,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,23 +109,70 @@ 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 {
return runner.Stop(conf) 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 { func getRunnerArgs(c *service.Service) []string {
self := os.Args[0] self := os.Args[0]
debug := "" 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 // copies self to install path and returns config path
func installServiceman(c *service.Service) ([]string, error) { func installServiceman(c *service.Service) ([]string, error) {
// TODO check version and upgrade or dismiss // TODO check version and upgrade or dismiss
@ -163,6 +289,13 @@ func installServiceman(c *service.Service) ([]string, error) {
if nil != err { if nil != err {
return 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) bin, err := ioutil.ReadFile(self)
if nil != err { if nil != err {
return 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 { 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

@ -3,6 +3,7 @@ package manager
import ( import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@ -121,7 +122,12 @@ func getSystemSrvs() ([]string, error) {
} }
func getUserSrvs(home string) ([]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" // "come.example.foo.plist" matches "foo"
@ -227,3 +233,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

@ -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 { if "" != conf.Workdir {
cmd.Dir = 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() err = cmd.Start()
if nil != err { if nil != err {
fmt.Fprintf(lf, "[%s] Could not start %q process: %s\n", time.Now(), conf.Name, 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) _, 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"
@ -18,7 +22,7 @@ import (
) )
var GitRev = "000000000" var GitRev = "000000000"
var GitVersion = "v0.3.2-pre+dirty" var GitVersion = "v0.5.3-pre+dirty"
var GitTimestamp = time.Now().Format(time.RFC3339) var GitTimestamp = time.Now().Format(time.RFC3339)
func usage() { func usage() {
@ -26,6 +30,7 @@ func usage() {
fmt.Println("\tserviceman <command> --help") fmt.Println("\tserviceman <command> --help")
fmt.Println("\tserviceman add ./foo-app -- --foo-arg") fmt.Println("\tserviceman add ./foo-app -- --foo-arg")
fmt.Println("\tserviceman run --config ./foo-app.json") fmt.Println("\tserviceman run --config ./foo-app.json")
fmt.Println("\tserviceman list --all")
fmt.Println("\tserviceman start <name>") fmt.Println("\tserviceman start <name>")
fmt.Println("\tserviceman stop <name>") fmt.Println("\tserviceman stop <name>")
} }
@ -50,6 +55,8 @@ func main() {
start() start()
case "stop": case "stop":
stop() stop()
case "list":
list()
default: default:
fmt.Fprintf(os.Stderr, "Unknown argument %s\n", top) fmt.Fprintf(os.Stderr, "Unknown argument %s\n", top)
usage() usage()
@ -62,41 +69,268 @@ 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
pathEnv := ""
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")
flag.BoolVar(&force, "force", false, "if the interpreter or executable doesn't exist, or things don't make sense, try anyway") 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.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.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 {
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 { if forUser {
conf.System = false conf.System = false
} else if forSystem { } else if forSystem {
@ -105,51 +339,140 @@ func add() {
conf.System = manager.IsPrivileged() conf.System = manager.IsPrivileged()
} }
n := len(args) // Pretty much just for HomeDir
if 0 == n { conf.NormalizeWithoutPath()
fmt.Println("Usage: serviceman add ./foo-app -- --foo-arg")
os.Exit(2) managed, others, errs := manager.List(conf)
return 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]) 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])
}
if 0 == len(others) {
fmt.Println("\t(none)")
}
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 { if nil != err {
fmt.Fprintf(os.Stderr, "Error: '%s' could not be found in PATH or working directory.\n", args[0]) var msg string
if !force { if strings.Contains(filepath.ToSlash(exe), "/") {
os.Exit(3) if _, err := os.Stat(exe); err != nil {
return 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 { } else {
args[0] = execpath 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)
} }
conf.Exec = args[0] }
args = args[1:] if !force {
return "", fmt.Errorf(msg)
if n >= 2 { }
conf.Interpreter = conf.Exec fmt.Fprintf(os.Stderr, "%s\n", msg)
conf.Exec = args[0] return exe, nil
conf.Argv = append(args[1:], conf.Argv...)
} }
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) func testScript(exepath string, force bool) ([]string, error) {
if conf.System && !manager.IsPrivileged() { f, err := os.Open(exepath)
fmt.Fprintf(os.Stderr, "Warning: You may need to use 'sudo' to add %q as a privileged system service.\n", conf.Name) 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) // Nott sure if this is more readable and idiomatic as if else or switch
switch e := err.(type) { // However, the order matters
case nil: switch {
// do nothing case utf8.Valid(b):
case *manager.ErrDaemonize: // Looks like an executable script
runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...) 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: 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) // Deal with #!/whatever
fmt.Println()
// 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 +508,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 +561,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 +614,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)
} }