commit 2950e72101d80bf946335834cff57be8c320039a Author: Justin Hawkins Date: Thu Nov 24 19:09:42 2022 +1030 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a4f3c3 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ac631c5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/tardisx/unitard + +go 1.17 diff --git a/templates/basic.service b/templates/basic.service new file mode 100644 index 0000000..61ad18d --- /dev/null +++ b/templates/basic.service @@ -0,0 +1,10 @@ +# service file automatically created with github.com/tardisx/unitard + +[Unit] +Description={{ .description }} + +[Service] +ExecStart={{ .execStart }} + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/unitard.go b/unitard.go new file mode 100644 index 0000000..7593212 --- /dev/null +++ b/unitard.go @@ -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 +} diff --git a/unitard_test.go b/unitard_test.go new file mode 100644 index 0000000..1f512a4 --- /dev/null +++ b/unitard_test.go @@ -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) + } + } + +}