M .gitignore => .gitignore +2 -1
@@ 1,4 1,5 @@
bin/**
+libexec/**
# Created by https://www.toptal.com/developers/gitignore/api/go,emacs,vim,vscode
# Edit at https://www.toptal.com/developers/gitignore?templates=go,emacs,vim,vscode
@@ 104,4 105,4 @@ tags
!.vscode/extensions.json
*.code-workspace
-# End of https://www.toptal.com/developers/gitignore/api/go,emacs,vim,vscode>
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/go,emacs,vim,vscode
M GNUmakefile => GNUmakefile +20 -9
@@ 1,4 1,11 @@
-TARGETS := bin/govern
+NAME := govern
+PACKAGE := git.sr.ht/~nesv/govern
+DESTDIR := ${CURDIR}
+RUNNERS := file pkg service
+TARGETS := ${DESTDIR}/bin/${NAME} \
+ ${DESTDIR}/libexec/${NAME}/runner/file \
+ ${DESTDIR}/libexec/${NAME}/runner/pkg \
+ ${DESTDIR}/libexec/${NAME}/runner/service
SOURCES := $(wildcard internal/agent/*.go) \
$(wildcard internal/exec/*.go) \
$(wildcard internal/facts/*.go) \
@@ 8,7 15,7 @@ SOURCES := $(wildcard internal/agent/*.go) \
$(wildcard internal/server/*.go) \
$(wildcard internal/state/*.go)
-GO := $(shell which go 2>/dev/null)
+GO ?= $(shell which go 2>/dev/null)
ifeq (${GO},"")
$(error Cannot find go in your $$PATH)
endif
@@ 20,15 27,19 @@ endif
all: ${TARGETS}
-bin/govern: $(wildcard *.go) ${SOURCES}
+${DESTDIR}/bin/${NAME}: $(wildcard *.go) ${SOURCES}
${GO} build ${GC_FLAGS} -o $@
-.PHONY: runners
-runners: bin/runner/pkg
-
-bin/runner/pkg: $(wildcard runners/pkg/*.go) $(wildcard runner/*.go)
- ${GO} build ${GC_FLAGS} -o $@ ./runners/pkg
+${DESTDIR}/libexec/${NAME}/runner/%: $(wildcard runners/%/*.go) $(wildcard runner/*.go)
+ ${GO} build ${GC_FLAGS} -o $@ ${PACKAGE}/runners/$*
.PHONY: clean
clean:
- rm -rf bin
+ -rm ${TARGETS}
+
+.PHONY: check
+check:
+ go vet ./...
+ifneq ($(shell comamnd -v staticcheck 2>/dev/null),)
+ staticcheck ./...
+endif
M cmd/hclast/main.go => cmd/hclast/main.go +1 -14
@@ 4,13 4,11 @@ import (
"errors"
"fmt"
"io"
- "io/ioutil"
"os"
"strings"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
- "github.com/hashicorp/hcl/hcl/printer"
hclstrconv "github.com/hashicorp/hcl/hcl/strconv"
)
@@ 30,7 28,7 @@ func main() {
defer f.Close()
lr := io.LimitReader(f, 1<<20)
- p, err := ioutil.ReadAll(lr)
+ p, err := io.ReadAll(lr)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ 104,14 102,3 @@ func fmtResourceName(i *ast.ObjectItem) (string, error) {
}
return strings.Join(elem, ":"), nil
}
-
-func walkFunc(node ast.Node) (ast.Node, bool) {
- if node == nil {
- return nil, false
- }
-
- fmt.Printf("=== %#v\n", node)
- printer.Fprint(os.Stdout, node)
- fmt.Println()
- return node, true
-}
M example.config.hcl => example.config.hcl +1 -2
@@ 1,7 1,6 @@
facts "./facts" {}
-runners "./bin/runner" {}
-runners "./runners" {}
+runners "./libexec/govern/runner" {}
states "./example/state" {}
M go.mod => go.mod +12 -2
@@ 1,14 1,24 @@
module git.sr.ht/~nesv/govern
-go 1.16
+go 1.19
require (
github.com/go-mangos/mangos v1.2.0
+ github.com/hashicorp/hcl v1.0.0
github.com/hashicorp/hcl/v2 v2.9.1
github.com/nesv/cmndr v1.1.0
github.com/pkg/errors v0.9.1
github.com/rs/xid v0.0.0-20170604230408-02dd45c33376
- github.com/stretchr/testify v1.7.0 // indirect
github.com/zclconf/go-cty v1.8.0
k8s.io/apimachinery v0.20.4
)
+
+require (
+ github.com/agext/levenshtein v1.2.1 // indirect
+ github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+ github.com/google/go-cmp v0.5.2 // indirect
+ github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/stretchr/testify v1.7.0 // indirect
+ golang.org/x/text v0.3.5 // indirect
+)
M go.sum => go.sum +2 -1
@@ 6,7 6,6 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
-github.com/apparentlymart/go-textseg v1.0.0 h1:rRmlIsPEEhUTIKQb7T++Nz/A5Q6C9IuX2wFoYVvnCs0=
github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
@@ 69,6 68,8 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.9.1 h1:eOy4gREY0/ZQHNItlfuEZqtcQbXIxzojlP301hDpnac=
github.com/hashicorp/hcl/v2 v2.9.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
A runner/command.go => runner/command.go +30 -0
@@ 0,0 1,30 @@
+package runner
+
+import (
+ "errors"
+ "os/exec"
+)
+
+// Command is a convenience function that runs the given command,
+// and if successful will return the command's output (if any).
+// If the command does not exist successfully,
+// err will be non-nil,
+// and output will contain the contents of STDERR.
+func Command(args ...string) (output string, err error) {
+ var cmd *exec.Cmd
+ if len(args) > 2 {
+ cmd = exec.Command(args[0], args[1:]...)
+ } else {
+ cmd = exec.Command(args[0])
+ }
+
+ out, err := cmd.Output()
+ var exit *exec.ExitError
+ if errors.As(err, &exit) {
+ return string(exit.Stderr), err
+ } else if err != nil {
+ return "", err
+ }
+
+ return string(out), nil
+}
A runner/helper.go => runner/helper.go +10 -0
@@ 0,0 1,10 @@
+package runner
+
+import "fmt"
+
+// OK is a convenience function that prints "ok" to STDOUT,
+// and returns a nil error.
+func OK() error {
+ fmt.Println("ok")
+ return nil
+}
M runner/runner.go => runner/runner.go +28 -11
@@ 23,14 23,23 @@ type Runner struct {
// DefaultState is the default value for the "state" attribute.
// When a state file-defined resource does not have its "state"
// attribute specified, DefaultState will be used.
+ //
+ // The caller is still required to register a handler function with
+ // r.HandleState.
DefaultState string
- // Run is the function Runner will execute,
- // after parsing command-line arguments,
- // and environment variables.
- Run func(runner *Runner, resourceName string, attributes map[string]string) error
+ // States maps a resource state name to a handler function that will
+ // ensure that desired state.
+ //
+ // Note that govern does not make any impositions on what a "valid"
+ // state name is.
+ States map[string]StateFunc
}
+// StateFunc defines a function type that is responsible for ensuring a
+// resource's state.
+type StateFunc func(r *Runner, resourceName string, attributes map[string]string) error
+
// Exec executes the runner.
//
// Exec will ensure that there are at least three (3) arguments provided on the
@@ 52,17 61,15 @@ func (r *Runner) Exec() error {
return r.ExecArgs(name, args...)
}
-//
+// ExecArgs executes the runner with the resource name and any arguments.
func (r *Runner) ExecArgs(name string, args ...string) error {
- if r.Run == nil {
- return errors.New("nil run function")
+ if len(r.States) == 0{
+ return errors.New("no state handlers registered")
}
// Parse the command-line args into attributes.
// They are expected to be "key=value" pairs.
- attrs := map[string]string{
- "state": r.DefaultState,
- }
+ attrs := make(map[string]string)
for _, v := range args {
s := strings.SplitN(v, "=", 2)
if len(s) < 2 {
@@ 71,7 78,17 @@ func (r *Runner) ExecArgs(name string, args ...string) error {
attrs[s[0]] = s[1]
}
- return r.Run(r, name, attrs)
+ state, ok := attrs["state"]
+ if !ok {
+ state=r.DefaultState
+ }
+
+ handler, ok := r.States[state]
+ if !ok {
+ return fmt.Errorf("no handler registered for state: %s", state)
+ }
+
+ return handler(r, name, attrs)
}
// Fact retrieves the fact with the given name,
A runners/file.lua => runners/file.lua +1 -0
@@ 0,0 1,1 @@
+#!/usr/bin/env lua
M runners/file/main.go => runners/file/main.go +42 -20
@@ 19,15 19,18 @@ import (
"strconv"
"strings"
- "git.sr.ht/~nesv/govern/runner"
-
"github.com/pkg/errors"
+
+ "git.sr.ht/~nesv/govern/runner"
)
func main() {
r := runner.Runner{
DefaultState: "present",
- Run: run,
+ States: map[string]runner.StateFunc{
+ "present": present,
+ "absent": absent,
+ },
}
if err := r.Exec(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
@@ 35,18 38,32 @@ func main() {
}
}
-func run(r *runner.Runner, name string, attrs map[string]string) error {
- // Make sure the path name is absolute.
+func absent(r *runner.Runner, name string, attrs map[string]string) error {
+ name, err := cleanPath(name)
+ if err != nil {
+ return err
+ }
+
+ if err := os.Remove(name); err != nil {
+ return fmt.Errorf("remove: %w", err)
+ }
+
+ return nil
+}
+
+func cleanPath(name string) (string, error) {
name = filepath.Clean(name)
if !filepath.IsAbs(name) {
- return errors.New("path must be absolute")
+ return "", errors.New("path name must be absolute")
}
+ return name, nil
+}
- state := attrs["state"]
- switch state {
- case "absent", "present":
- default:
- return fmt.Errorf("unknown state: %s", state)
+func present(r *runner.Runner, name string, attrs map[string]string) error {
+ // Make sure the path name is absolute.
+ name, err := cleanPath(name)
+ if err != nil {
+ return err
}
// Does the file exist?
@@ 61,22 78,16 @@ func run(r *runner.Runner, name string, attrs map[string]string) error {
// Assume the file exists.
//
// For directories, the directory runner should be used instead.
- if info.IsDir() && state == "present" {
+ if info != nil && info.IsDir() {
return errors.New("path refers to a directory")
}
- // See if we need to delete the file, or update its contents.
- if state == "absent" {
- // The file does not exist, and the caller has indicated the
- // file should not exist.
- return errors.Wrap(os.Remove(name), "remove")
- }
-
// Check to see if the existing file has the same
// checksum digest as the one specified in the "checksum"
// attribute.
// If the checksums match, we do not have to update its
// contents.
+ var fetched bool
if checksum := attrs["checksum"]; checksum != "" {
checksumsMatch, err := compareChecksum(name, checksum)
if err != nil {
@@ 91,6 102,7 @@ func run(r *runner.Runner, name string, attrs map[string]string) error {
if err != nil {
return errors.Wrap(err, "fetch")
}
+ fetched = true
// Make sure the checksum of the new file matches the
// checksum given in the resource attribute.
@@ 107,6 119,16 @@ func run(r *runner.Runner, name string, attrs map[string]string) error {
}
}
+ if !fetched {
+ tmppath, err := fetch(attrs["source"], filepath.Dir(name))
+ if err != nil {
+ return errors.Wrap(err, "fetch")
+ }
+ if err := os.Rename(tmppath, name); err != nil {
+ return errors.Wrap(err, "replace existing file")
+ }
+ }
+
// Set the mode.
if err := chmod(name, attrs["mode"]); err != nil {
return errors.Wrap(err, "chmod")
@@ 292,7 314,7 @@ func gethash(checksum string) (h hash.Hash, digest []byte, err error) {
parts := strings.SplitN(checksum, ":", 2)
if len(parts) < 2 {
- return nil, nil, errors.New("invalid checksum")
+ return nil, nil, errors.New("invalid checksum: missing \":\" separator")
}
switch algo := parts[0]; algo {
A runners/pkg.lua => runners/pkg.lua +23 -0
@@ 0,0 1,23 @@
+#!/usr/bin/env lua
+
+--[[ local govern_bin = os.getenv("GOVERN")
+assert(govern_bin ~= nil, "GOVERN not set in environment")
+
+local govern_facts_path = os.getenv("GOVERN_FACTS_PATH")
+assert(govern_facts_path ~= nil, "GOVERN_FACTS_PATH not set in environment") ]]
+
+local Commands = {}
+Commands["Arch Linux"] = {
+ installed = "pacman -Sq --needed --noconfirm",
+ latest = "pacman -Syq --needed --noconfirm",
+ absent = "pacman -R --noconfirm",
+ _query = "pacman -Q",
+}
+
+local CommandToExecute = { Commands["Arch Linux"]._query }
+for k, v in ipairs(arg) do
+ CommandToExecute[#CommandToExecute+1] = tostring(v)
+end
+local cmd = table.concat(CommandToExecute, " ")
+print(":: " .. cmd)
+os.execute(cmd)
A runners/pkg/arch_linux.go => runners/pkg/arch_linux.go +119 -0
@@ 0,0 1,119 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "git.sr.ht/~nesv/govern/runner"
+)
+
+func archInstalled(r *runner.Runner, name string, attrs map[string]string) error {
+ packageName := name
+ if v := attrs["version"]; v != "" {
+ packageName = fmt.Sprintf("%s%s%s", packageName, "=", v)
+ }
+
+ // Are we running in "pretend" (a.k.a. "no-op mode")?
+ if r.Pretend() {
+ installed, err := archPackageInstalled(packageName)
+ if err != nil {
+ return fmt.Errorf("get installed version: %w", err)
+ }
+
+ if installed {
+ return runner.OK()
+ }
+
+ fmt.Println("would be installed")
+ return nil
+ }
+
+ // Run the command!
+ if _, err := runner.Command("sudo", "pacman", "-Sq", "--needed", "--noconfirm", packageName); err != nil {
+ return err
+ }
+
+ return runner.OK()
+}
+
+func archPackageInstalled(pkgname string) (bool, error) {
+ version, err := archInstalledVersion(pkgname)
+ if err != nil {
+ return false, err
+ }
+
+ return version != "", nil
+}
+
+func archInstalledVersion(pkgname string) (string, error) {
+ output, err := runner.Command("pacman", "-Q", pkgname)
+ if err != nil && strings.Contains(output, "was not found") {
+ return "", nil
+ } else if err != nil {
+ return "", fmt.Errorf("query pacman: %w", err)
+ }
+
+ fields := strings.Fields(output)
+ if n := len(fields); n < 2 {
+ return "", fmt.Errorf("expected 2 fields, got %d", n)
+ }
+
+ return fields[1], nil
+}
+
+func archLatest(r *runner.Runner, name string, attrs map[string]string) error {
+ version := attrs["version"]
+ if r.Pretend() {
+ installedVersion, err := archInstalledVersion(name)
+ if err != nil {
+ return err
+ }
+
+ if installedVersion == "" {
+ fmt.Println("will be installed")
+ return nil
+ }
+
+ if installedVersion != version {
+ fmt.Println("will be upgraded")
+ return nil
+ }
+
+ return runner.OK()
+ }
+
+ if version != "" {
+ name = fmt.Sprintf("%s=%s", name, version)
+ }
+
+ if _, err := runner.Command("sudo", "pacman", "-Syq", "--needed", "--noconfirm", name); err != nil {
+ return err
+ }
+
+ return runner.OK()
+}
+
+func archRemoved(r *runner.Runner, name string, attrs map[string]string) error {
+ if r.Pretend() {
+ version, err := archInstalledVersion(name)
+ if err != nil {
+ return err
+ }
+
+ if version == "" {
+ return runner.OK()
+ }
+
+ fmt.Println("will be removed")
+ return nil
+ }
+
+ output, err := runner.Command("sudo", "pacman", "-R", "--noconfirm", name)
+ if err != nil && strings.Contains(output, "target not found") {
+ return runner.OK()
+ } else if err != nil {
+ return err
+ }
+
+ return runner.OK()
+}
M runners/pkg/main.go => runners/pkg/main.go +24 -78
@@ 2,86 2,50 @@
package main
import (
- "errors"
"fmt"
"os"
- "os/exec"
"git.sr.ht/~nesv/govern/runner"
)
func main() {
- r := runner.Runner{
- DefaultState: "installed",
- Run: run,
- }
- if err := r.Exec(); err != nil {
+ if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
-func run(r *runner.Runner, name string, attrs map[string]string) error {
- distro, err := r.Fact("os/distribution")
- if err != nil {
- return fmt.Errorf("get fact %q: %w", "os/distribution", err)
- }
-
- packageName := name
- if v := attrs["version"]; v != "" {
- packageName = fmt.Sprintf("%s%s%s", packageName, packageVersionSeparator[distro], v)
+func run() error {
+ r := runner.Runner{
+ DefaultState:stateInstalled,
}
- // Make sure the "state" attribute is one we understand.
- state := attrs["state"]
- switch state {
- case "installed", "latest", "absent":
- default:
- return fmt.Errorf("unknown state: %s", state)
+ distro, err := r.Fact("os/distribution")
+ if err != nil {
+ return fmt.Errorf("get fact: %w", err)
}
- // Are we running in "pretend" (a.k.a. "no-op mode")?
- if r.Pretend() {
- installed, err := packageInstalled(distro, packageName)
- if err != nil {
- return fmt.Errorf("get installed version: %w", err)
- }
-
- switch state {
- case "installed", "latest":
- if !installed {
- fmt.Println("would be installed/upgraded")
- return nil
- }
- fmt.Println("ok")
-
- case "absent":
- if !installed {
- fmt.Println("ok")
- return nil
- }
- fmt.Println("would be uninstalled")
- }
- return nil
+ handlers, ok := distroHandlers[distro]
+ if !ok {
+ return fmt.Errorf("distribution not supported: %s", distro)
}
- // Run the command!
- var (
- args = append(commands[distro][state], packageName)
- cmd = exec.Command("sudo", args...)
- exerr *exec.ExitError
- )
- out, err := cmd.CombinedOutput()
- if errors.As(err, &exerr) {
- return fmt.Errorf("%s", out)
- } else if err != nil {
- return err
- }
- return nil
+ r.States=handlers
+ return r.Exec()
}
-var packageVersionSeparator = map[string]string{
- "Arch Linux": "=",
+const (
+ stateInstalled = "installed"
+ stateLatest = "latest"
+ stateRemoved = "removed"
+)
+
+var distroHandlers = map[string]map[string]runner.StateFunc{
+ "Arch Linux": {
+ stateInstalled: archInstalled,
+ stateLatest: archLatest,
+ stateRemoved: archRemoved,
+ },
}
var commands = map[string]map[string][]string{
@@ 91,21 55,3 @@ var commands = map[string]map[string][]string{
"absent": {"pacman", "-R", "--noconfirm"},
},
}
-
-// packageInstalled indicates if the package name is installed.
-func packageInstalled(distro, pkgname string) (bool, error) {
- cmd := exec.Command(queryPackage[distro][0], queryPackage[distro][1:]...)
- out, err := cmd.Output()
-
- var exiterr *exec.ExitError
- if errors.As(err, &exiterr) {
- return false, errors.New(string(exiterr.Stderr))
- } else if err != nil {
- return false, err
- }
- return len(out) > 0, nil
-}
-
-var queryPackage = map[string][]string{
- "Arch Linux": {"pacman", "-Q"},
-}
D runners/service => runners/service +0 -4
@@ 1,4 0,0 @@
-#!/bin/sh
-set -eu
-
-exit 0
A runners/service/internal/systemd/systemd_linux.go => runners/service/internal/systemd/systemd_linux.go +171 -0
@@ 0,0 1,171 @@
+package systemd
+
+import (
+ "errors"
+ "fmt"
+ "os/exec"
+
+ "git.sr.ht/~nesv/govern/runner"
+)
+
+// Start activates a systemd unit.
+// If attrs["enabled"] is a "true",
+// Start will also enable the unit.
+func Start(r *runner.Runner, name string, attrs map[string]string) error {
+ active, err := isActive(name)
+ if err != nil {
+ return fmt.Errorf("is active: %w", err)
+ }
+
+ enabled, err := isEnabled(name)
+ if err != nil {
+ return fmt.Errorf("is enabled: %w", err)
+ }
+
+ var wantEnabled bool
+ if v := attrs["enabled"]; v == "true" {
+ wantEnabled = true
+ }
+
+ if active && enabled && wantEnabled {
+ return runner.OK()
+ }
+
+ if r.Pretend() {
+ enable := enabled && wantEnabled
+ if !active && enable {
+ fmt.Println("will be activated and enabled")
+ return nil
+ }
+ if active && !enable {
+ fmt.Println("will be enabled")
+ return nil
+ }
+ if !active {
+ fmt.Println("will be activated")
+ return nil
+ }
+ return runner.OK()
+ }
+
+ if active && !enabled && wantEnabled {
+ return Enable(r, name, attrs)
+ }
+
+ // Activate the unit.
+ cmd := []string{"sudo", "systemctl"}
+ if wantEnabled {
+ cmd = append(cmd, "enable", "--now", name)
+ } else {
+ cmd = append(cmd, "start", name)
+ }
+
+ if _, err := runner.Command(cmd...); err != nil {
+ return err
+ }
+
+ return runner.OK()
+}
+
+func isActive(name string) (bool, error) {
+ output, err := runner.Command("systemctl", "is-active", name)
+ var exit *exec.ExitError
+ if errors.As(err, &exit) {
+ switch code := exit.ExitCode(); code {
+ case 1:
+ // program is dead and /var/run pid file exists
+ // unit not failed (used by is-failed)
+ return true, nil
+ case 2:
+ // unused
+ panic("systemctl is-enabled exit code=2")
+ case 3:
+ // program is not running
+ return false, nil
+ case 4:
+ // no such unit
+ return false, nil
+ }
+ panic(fmt.Sprintf("systemctl is-active: code=%d", exit.ExitCode()))
+ } else if err != nil {
+ return false, err
+ }
+
+ switch output {
+ case "active":
+ return true, nil
+ case "inactive":
+ return false, nil
+ }
+
+ panic("how did we get here?")
+}
+
+func isEnabled(name string) (bool, error) {
+ output, err := runner.Command("systemctl", "is-enabled", name)
+ var exit *exec.ExitError
+ if errors.As(err, &exit) {
+ switch code := exit.ExitCode(); code {
+ case 1:
+ // program is dead and /var/run pid file exists
+ return false, nil
+ case 2:
+ // unused
+ panic("systemctl is-enabled code=2")
+ case 3:
+ // program is not running
+ panic("systemctl is-enabled code=3")
+ case 4:
+ // no such unit
+ return false, nil
+ }
+ panic(fmt.Sprintf("systemctl is-active: code=%d", exit.ExitCode()))
+ } else if err != nil {
+ return false, err
+ }
+
+ if output == "enabled" {
+ return true, nil
+ }
+
+ return false, nil
+}
+
+// Enable enables a systemd unit.
+func Enable(r *runner.Runner, name string, attrs map[string]string) error {
+ enabled, err := isEnabled(name)
+ if err != nil {
+ return fmt.Errorf("is enabled: %w", err)
+ }
+
+ if enabled {
+ return runner.OK()
+ }
+
+ if _, err := runner.Command("sudo", "systemctl", "enable", name); err != nil {
+ return err
+ }
+ return runner.OK()
+}
+
+// Disable disables a systemd unit.
+func Disable(r *runner.Runner, name string, attrs map[string]string) error {
+ enabled, err := isEnabled(name)
+ if err != nil {
+ return fmt.Errorf("is enabled: %w", err)
+ }
+
+ if !enabled {
+ return runner.OK()
+ }
+
+ if _, err := runner.Command("sudo", "systemctl", "disable", name); err != nil {
+ return err
+ }
+
+ return runner.OK()
+}
+
+func Stop(r *runner.Runner, name string, attrs map[string]string) error {
+ return errors.New("not implemented")
+}
A runners/service/main.go => runners/service/main.go +60 -0
@@ 0,0 1,60 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+
+ "git.sr.ht/~nesv/govern/runner"
+ "git.sr.ht/~nesv/govern/runners/service/internal/systemd"
+)
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ r := runner.Runner{
+ DefaultState: "started",
+ }
+
+ handlers, err := detectInitSystem()
+ if err != nil {
+ return err
+ }
+ r.States = handlers
+
+ return r.Exec()
+}
+
+func detectInitSystem() (map[string]runner.StateFunc, error) {
+ for name, handlers := range inithandlers {
+ if _, err := exec.LookPath(name); err != nil {
+ return nil, fmt.Errorf("lookup: %w", err)
+ }
+
+ return handlers, nil
+ }
+
+ return nil, errors.New("no supported init system found")
+}
+
+var inithandlers = map[string]map[string]runner.StateFunc{
+ "systemctl": {
+ "running": systemd.Start,
+ "stopped": systemd.Stop,
+ "restarted": noop,
+ "reloaded": noop,
+ "enabled": systemd.Enable,
+ "disabled": systemd.Disable,
+ "masked": noop,
+ },
+}
+
+func noop(r *runner.Runner, name string, attrs map[string]string) error {
+ return nil
+}