first commit

This commit is contained in:
Justin Hawkins 2022-11-24 19:09:42 +10:30
commit 2950e72101
5 changed files with 347 additions and 0 deletions

51
README.md Normal file
View 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.

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/tardisx/unitard
go 1.17

10
templates/basic.service Normal file
View 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
View 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
View 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)
}
}
}