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