first commit
This commit is contained in:
commit
2950e72101
51
README.md
Normal file
51
README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# unitard - automatically deploy a systemd unit file from your application
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
import "github.com/tardisx/unitard"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
appName := "coolapp"
|
||||||
|
if deploy {
|
||||||
|
unit, _ := unitard.NewUnit(appName)
|
||||||
|
unit.Deploy()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
// rest of your application here
|
||||||
|
}
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
`Deploy()` automatically creates a systemd unit file, reloads the systemd daemon
|
||||||
|
so it can use it, enables the unit (so it starts on boot) and starts the service
|
||||||
|
running.
|
||||||
|
|
||||||
|
This means you can have a single binary deployment. Copy your executable to "somewhere"
|
||||||
|
on your target system, run it with `-deploy` (or however you have enabled the call to `Deploy()`)
|
||||||
|
and your application starts running in the background, and will restart on boot.
|
||||||
|
|
||||||
|
There is also an `Undeploy()` func, which you should of course
|
||||||
|
provide as an option to your users. It stops the running service, removes the unit file and restarts systemd.
|
||||||
|
|
||||||
|
## What's with the name?
|
||||||
|
|
||||||
|
It's the systemd UNIT for Automatic Restart Deployment. Or, just a stupid pun based on my username.
|
||||||
|
|
||||||
|
## Does this work for root?
|
||||||
|
|
||||||
|
It's designed to not. It leverages the systemd `--user` facility, where users can configure
|
||||||
|
their own services to run persistently, with all configuration being done out of their home
|
||||||
|
directory (in `~/.config/systemd`).
|
||||||
|
|
||||||
|
See https://wiki.archlinux.org/title/Systemd/User for more information.
|
||||||
|
|
||||||
|
## It works! Until I logout, and then my program stops!
|
||||||
|
|
||||||
|
You need to enable "lingering" - see the link above.
|
||||||
|
|
||||||
|
## I want it to do X
|
||||||
|
|
||||||
|
It's designed to be almost zero configuration - you just provide an application name
|
||||||
|
(which gets used to name the `.service` file). This is by intent.
|
||||||
|
|
||||||
|
However it's not impossible that there are sensible user-configurable things. Raise an issue.
|
10
templates/basic.service
Normal file
10
templates/basic.service
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# service file automatically created with github.com/tardisx/unitard
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description={{ .description }}
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart={{ .execStart }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
226
unitard.go
Normal file
226
unitard.go
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
// Package unitard provides an easy-to-use method to automatically deploy
|
||||||
|
// (and undeploy) a systemd service configuration directly from your application binary.
|
||||||
|
package unitard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.service
|
||||||
|
var fs embed.FS
|
||||||
|
|
||||||
|
type Unit struct {
|
||||||
|
name string
|
||||||
|
binary string
|
||||||
|
|
||||||
|
systemCtlPath string // path to systemctl command
|
||||||
|
unitFilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnitOpts interface{}
|
||||||
|
|
||||||
|
// NewUnit creates a new systemd unit representation, with a particular name.
|
||||||
|
// No changes will be made to the system configuration until Deploy or Undeploy
|
||||||
|
// are called.
|
||||||
|
// NewUnit will check that the local environment is suitably configured, it will
|
||||||
|
// return an error if it is not.
|
||||||
|
func NewUnit(unitName string, unitOpts ...UnitOpts) (Unit, error) {
|
||||||
|
|
||||||
|
ok := checkName(unitName)
|
||||||
|
if !ok {
|
||||||
|
return Unit{}, fmt.Errorf("sorry, name '%s' is not valid", unitName)
|
||||||
|
}
|
||||||
|
u := Unit{
|
||||||
|
name: unitName,
|
||||||
|
binary: binaryName(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unitOpts) > 0 {
|
||||||
|
return Unit{}, fmt.Errorf("sorry, UnitOpts are not yet supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := u.setupEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return Unit{}, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnitFilename returns the full path to the systemd unit file that will be used for
|
||||||
|
// Deploy or Undeploy.
|
||||||
|
func (u Unit) UnitFilename() string {
|
||||||
|
return fmt.Sprintf("%s%c%s.service", u.unitFilePath, os.PathSeparator, u.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy creates/overwrites the unit file, enables and starts it running.
|
||||||
|
func (u Unit) Deploy() error {
|
||||||
|
|
||||||
|
// create/overwrite the unit file
|
||||||
|
unitFileName := u.UnitFilename()
|
||||||
|
f, err := os.Create(unitFileName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create unit file '%s': %s", unitFileName, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
err = u.writeTemplate(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// and start it up
|
||||||
|
err = u.enableAndStartUnit()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u Unit) writeTemplate(f io.Writer) error {
|
||||||
|
t, err := template.New("").ParseFS(fs, "templates/*.service")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]string{
|
||||||
|
"description": u.name,
|
||||||
|
"execStart": u.binary,
|
||||||
|
}
|
||||||
|
err = t.ExecuteTemplate(f, "basic.service", data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u Unit) enableAndStartUnit() error {
|
||||||
|
err := u.runExpectZero(u.systemCtlPath, "--user", "daemon-reload")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = u.runExpectZero(u.systemCtlPath, "--user", "enable", u.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = u.runExpectZero(u.systemCtlPath, "--user", "restart", u.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undeploy is the opposite of deploy - it will stop the service, disable it,
|
||||||
|
// remove the service file and refresh systemd.
|
||||||
|
func (u Unit) Undeploy() error {
|
||||||
|
err := u.runExpectZero(u.systemCtlPath, "--user", "disable", u.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = u.runExpectZero(u.systemCtlPath, "--user", "stop", u.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = os.Remove(u.UnitFilename())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = u.runExpectZero(u.systemCtlPath, "--user", "daemon-reload")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runExpectZero runs a command + optional arguments, returning an
|
||||||
|
// error if it cannot be run, or if it returns a non-zero exit code
|
||||||
|
func (u Unit) runExpectZero(command string, args ...string) error {
|
||||||
|
cmd := exec.Command(command, args...)
|
||||||
|
err := cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not start systemctl: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logStringA := []string{command}
|
||||||
|
logStringA = append(logStringA, args...)
|
||||||
|
logString := strings.Join(logStringA, " ")
|
||||||
|
|
||||||
|
err = cmd.Wait()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("problem running '%s': %s", logString, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.ProcessState.ExitCode() != 0 {
|
||||||
|
return fmt.Errorf("problem running '%s': exit code non-zero: %d", logString, cmd.ProcessState.ExitCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// binaryName returns the fully-qualified path to the binary on disk
|
||||||
|
func binaryName() string {
|
||||||
|
binary, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return binary
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkName(name string) bool {
|
||||||
|
// because it is used for the filename, we should restrict it
|
||||||
|
return regexp.MustCompile("^[a-zA-Z0-9_]+$").Match([]byte(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupEnvironment ensures we have systemd installed and other things ready
|
||||||
|
func (u *Unit) setupEnvironment() error {
|
||||||
|
// check we have systemctl
|
||||||
|
systemCtlPath, err := exec.LookPath("systemctl")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not find systemctl: %s", err)
|
||||||
|
}
|
||||||
|
u.systemCtlPath = systemCtlPath
|
||||||
|
|
||||||
|
// check we aren't root
|
||||||
|
uid := os.Getuid()
|
||||||
|
if uid == 0 {
|
||||||
|
return fmt.Errorf("cannot run as root")
|
||||||
|
}
|
||||||
|
if uid == -1 {
|
||||||
|
return fmt.Errorf("cannot run on windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for the service file path
|
||||||
|
userHomeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not find users home dir: %s", err)
|
||||||
|
}
|
||||||
|
unitFileDirectory := fmt.Sprintf("%s%c%s%c%s%c%s", userHomeDir, os.PathSeparator,
|
||||||
|
".config", os.PathSeparator,
|
||||||
|
"systemd", os.PathSeparator,
|
||||||
|
"user",
|
||||||
|
)
|
||||||
|
|
||||||
|
err = os.MkdirAll(unitFileDirectory, 0777)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create the user systemd path '%s': %s", unitFileDirectory, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sfp, err := os.Stat(unitFileDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not find user service directory '%s': %s", unitFileDirectory, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sfp.IsDir() {
|
||||||
|
return fmt.Errorf("'%s' - not a directory", unitFileDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.unitFilePath = unitFileDirectory
|
||||||
|
return nil
|
||||||
|
}
|
57
unitard_test.go
Normal file
57
unitard_test.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package unitard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemplate(t *testing.T) {
|
||||||
|
u := Unit{
|
||||||
|
name: "test_unit",
|
||||||
|
binary: "/fullpath/to/foobar",
|
||||||
|
systemCtlPath: "/who/cares",
|
||||||
|
unitFilePath: "/doesnt/matter",
|
||||||
|
}
|
||||||
|
|
||||||
|
buff := bytes.NewBuffer(nil) // create empty buffer
|
||||||
|
|
||||||
|
err := u.writeTemplate(buff)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to write template: %s", err)
|
||||||
|
}
|
||||||
|
t.Logf("template:\n%s", buff.String())
|
||||||
|
|
||||||
|
if !strings.Contains(buff.String(), "Description=test_unit") {
|
||||||
|
t.Error("template does not contain description?")
|
||||||
|
}
|
||||||
|
if !strings.Contains(buff.String(), "ExecStart=/fullpath/to/foobar") {
|
||||||
|
t.Error("template does not contain exec start?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckName(t *testing.T) {
|
||||||
|
validNames := []string{
|
||||||
|
"test_unit",
|
||||||
|
"leotard123",
|
||||||
|
"winger_01",
|
||||||
|
}
|
||||||
|
invalidNames := []string{
|
||||||
|
"no way",
|
||||||
|
"doesn't_work",
|
||||||
|
"C:/dev/null",
|
||||||
|
"/no/slashes",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range validNames {
|
||||||
|
if !checkName(v) {
|
||||||
|
t.Errorf("%s not valid but should be", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, i := range invalidNames {
|
||||||
|
if checkName(i) {
|
||||||
|
t.Errorf("%s valid but should be", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user