M GNUmakefile => GNUmakefile +13 -7
@@ 15,23 15,29 @@ SOURCES := $(wildcard internal/agent/*.go) \
$(wildcard internal/server/*.go) \
$(wildcard internal/state/*.go)
-GO ?= $(shell which go 2>/dev/null)
-ifeq (${GO},"")
- $(error Cannot find go in your $$PATH)
-endif
-
GC_FLAGS ?=
ifneq (${TAGS},"")
GC_FLAGS += -tags="${TAGS}"
endif
+GO_BUILD := go build ${GC_FLAGS}
+
all: ${TARGETS}
${DESTDIR}/bin/${NAME}: $(wildcard *.go) ${SOURCES}
- ${GO} build ${GC_FLAGS} -o $@
+ ${GO_BUILD} -o $@
${DESTDIR}/libexec/${NAME}/runner/%: $(wildcard runners/%/*.go) $(wildcard runner/*.go)
- ${GO} build ${GC_FLAGS} -o $@ ${PACKAGE}/runners/$*
+ ${GO_BUILD} -o $@ ${PACKAGE}/runners/$*
+
+${DESTDIR}/libexec/${NAME}/runner/pkg: $(wildcard runners/pkg/*.go) $(wildcard runner/*.go)
+ ${GO_BUILD} -o $@ ${PACKAGE}/runners/pkg
+
+${DESTDIR}/libexec/${NAME}/runner/file: $(wildcard runners/file/*.go) $(wildcard runner/*.go)
+ ${GO_BUILD} -o $@ ${PACKAGE}/runners/file
+
+${DESTDIR}/libexec/${NAME}/runner/service: $(wildcard runners/service/*.go) $(wildcard runner/*.go)
+ ${GO_BUILD} -o $@ ${PACKAGE}/runners/file
.PHONY: clean
clean:
A example/state/devtools.hcl => example/state/devtools.hcl +7 -0
@@ 0,0 1,7 @@
+pkg "tig" {
+ state = "latest"
+}
+
+pkg "git" {
+ state = "latest"
+}<
\ No newline at end of file
D example/state/nginx.hcl => example/state/nginx.hcl +0 -40
@@ 1,40 0,0 @@
-pkg "nginx" {
- state = "installed"
-
- notify = ["service:nginx:restart"]
-}
-
-pkg "nginx-prometheus-exporter" {
- # Specifying the "version" argument implies state = installed.
- # If state = latest, and version != "", then an error will be raised.
- version = "0.8.0-1"
-}
-
-file "/tmp/nesv.ca-index.html" {
- source = "https://nesv.ca"
- checksum = "sha256:51620ae7f20bbf4723ea9cff5af399a75cb51dee018e79558a587d46b6e5e5bc"
- user = "nesv"
- group = "users"
- mode = 0644
- state = "present"
-}
-
-file "nginx.conf" {
- src = "nginx.conf"
- dest = "/usr/local/etc/nginx.conf"
- user = "root"
- group = "wheel"
- mode = 0755
- foo = ["something,", "else"]
- float = 0.234234
- bool = false
-
- before = ["service:nginx"]
- after = ["pkg:nginx", "pkg:nginx-mod-geoip2"]
- notify = ["service:nginx:reload"]
-}
-
-service "nginx" {
- enabled = true
- state = "running"
-}
M example/state/utils.hcl => example/state/utils.hcl +0 -4
@@ 3,7 3,3 @@ pkg "htop" {}
pkg "neofetch" {
state = "latest"
}
-
-pkg "bashtop" {
- state = "latest"
-}
M go.mod => go.mod +2 -0
@@ 4,6 4,7 @@ go 1.19
require (
github.com/go-mangos/mangos v1.2.0
+ github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/hcl v1.0.0
github.com/hashicorp/hcl/v2 v2.9.1
github.com/nesv/cmndr v1.1.0
@@ 19,6 20,7 @@ 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/hashicorp/errwrap v1.0.0 // 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
M go.sum => go.sum +4 -0
@@ 67,6 67,10 @@ github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
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=
M internal/facts/fact.go => internal/facts/fact.go +5 -0
@@ 2,6 2,7 @@ package facts
import (
"bytes"
+ "encoding/json"
"fmt"
"io"
"io/fs"
@@ 156,3 157,7 @@ func (f *Fact) Name() string {
func (f *Fact) Remote() bool {
return f.network != "" && f.addr != ""
}
+
+func (f *Fact) MarshalJSON() ([]byte, error) {
+ return json.Marshal(f.String())
+}
M internal/facts/facts.go => internal/facts/facts.go +8 -0
@@ 2,6 2,7 @@ package facts
import (
"context"
+ "encoding/json"
"fmt"
"io"
"io/fs"
@@ 185,3 186,10 @@ func (f *Facts) Names() []string {
return names
}
+
+func (f *Facts) MarshalJSON() ([]byte, error) {
+ f.mu.RLock()
+ defer f.mu.RUnlock()
+
+ return json.Marshal(f.facts)
+}
M main.go => main.go +20 -5
@@ 17,9 17,9 @@ import (
"git.sr.ht/~nesv/govern/internal/state"
"git.sr.ht/~nesv/govern/internal/template"
+ "github.com/hashicorp/go-multierror"
"github.com/nesv/cmndr"
"github.com/pkg/errors"
- kerrors "k8s.io/apimachinery/pkg/util/errors"
)
func main() {
@@ 42,6 42,7 @@ func main() {
root.Flags.BoolVar(&cmd.withSignals, "with-signals", false, "resources: Show the signals a resource can send")
root.Flags.BoolVar(&cmd.withAttributes, "with-attributes", false, "resources: Show resource attributes with --pretend, -n")
root.Flags.BoolVarP(&cmd.pretend, "pretend", "n", false, "apply: Do not apply any states")
+ root.Flags.StringVar(&cmd.format, "format", "table", "facts: Set the output format [table, json]")
facts := cmndr.New("facts", cmd.showFacts)
facts.Description = "Collect facts for the current host"
@@ 86,6 87,7 @@ type command struct {
configPath string
factsDirs []string
factsIgnore []string
+ format string
stateDirs []string
statesIgnore []string
templateDirs []string
@@ 124,11 126,24 @@ func (c *command) showFacts(cmd *cmndr.Cmd, args []string) error {
return errors.Wrap(err, "collect facts")
}
- if _, err := f.WriteTo(os.Stdout); err != nil {
- return errors.Wrap(err, "write to stdout")
+ switch c.format {
+ case "json":
+ p, err := f.MarshalJSON()
+ if err != nil {
+ return errors.Wrap(err, "marshal json")
+ }
+
+ fmt.Fprintf(os.Stdout, "%s\n", p)
+ return nil
+
+ case "table":
+ if _, err := f.WriteTo(os.Stdout); err != nil {
+ return errors.Wrap(err, "write to stdout")
+ }
+ return nil
}
- return nil
+ return fmt.Errorf("unsupported format: %s", c.format)
}
func (c *command) collectFacts() (*facts.Facts, error) {
@@ 273,7 288,7 @@ func (c *command) applyStates(cmd *cmndr.Cmd, args []string) error {
errs = append(errs, fmt.Errorf("flush tabwriter: %w", err))
}
- return kerrors.NewAggregate(errs)
+ return multierror.Append(nil, errs...)
}
func (c *command) showResources(cmd *cmndr.Cmd, args []string) error {
M runner/runner.go => runner/runner.go +80 -11
@@ 34,6 34,12 @@ type Runner struct {
// Note that govern does not make any impositions on what a "valid"
// state name is.
States map[string]StateFunc
+
+ // A listing of facts runners intend to use, and require to be
+ // available on the underlying system.
+ // When Runner.Exec is called, each of these facts will be checked
+ // to ensure they exist.
+ RequiredFacts []string
}
// StateFunc defines a function type that is responsible for ensuring a
@@ 63,10 69,21 @@ func (r *Runner) Exec() error {
// ExecArgs executes the runner with the resource name and any arguments.
func (r *Runner) ExecArgs(name string, args ...string) error {
- if len(r.States) == 0{
+ if len(r.States) == 0 {
return errors.New("no state handlers registered")
}
+ // Make sure all of our required facts exist.
+ for _, name := range r.RequiredFacts {
+ exists, err := requireFact(name)
+ if err != nil {
+ return fmt.Errorf("require fact %q: %w", name, err)
+ }
+ if !exists {
+ return fmt.Errorf("required fact is not available: %s", name)
+ }
+ }
+
// Parse the command-line args into attributes.
// They are expected to be "key=value" pairs.
attrs := make(map[string]string)
@@ 80,10 97,10 @@ func (r *Runner) ExecArgs(name string, args ...string) error {
state, ok := attrs["state"]
if !ok {
- state=r.DefaultState
+ state = r.DefaultState
}
- handler, ok := r.States[state]
+ handler, ok := r.States[state]
if !ok {
return fmt.Errorf("no handler registered for state: %s", state)
}
@@ 91,12 108,58 @@ func (r *Runner) ExecArgs(name string, args ...string) error {
return handler(r, name, attrs)
}
+func requireFact(name string) (exists bool, err error) {
+ factsPath := os.Getenv("GOVERN_FACTS_PATH")
+ if factsPath == "" {
+ return false, errors.New("GOVERN_FACTS_PATH environment variable is not set")
+ }
+
+ for _, dir := range strings.Split(factsPath, ":") {
+ fpath := filepath.Join(dir, name)
+
+ // Stat the path.
+ // If the path does not exist, loop around again, and try the
+ // next directory in the list.
+ info, err := os.Stat(fpath)
+ if errors.Is(err, fs.ErrNotExist) {
+ continue
+ } else if err != nil {
+ return false, err
+ }
+
+ // Error out if the given fact name points at a directory.
+ if info.IsDir() {
+ return false, errors.New("refers to a directory")
+ }
+
+ return true, nil
+ }
+
+ return false, nil
+}
+
// Fact retrieves the fact with the given name,
// by invoking the govern binary provided in the GOVERN environment variable.
// Fact requires the GOVERN,
// and GOVERN_FACTS_PATH
// to be set in the environment.
func (r *Runner) Fact(name string) (string, error) {
+ var found bool
+ for _, v := range r.RequiredFacts {
+ if name == v {
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ return "", fmt.Errorf("fact was not declared as required: %s", name)
+ }
+
+ return getFact(name)
+}
+
+func getFact(name string) (string, error) {
// Make sure the GOVERN environment variable is set,
// clean the file path,
// and make sure the path we were given exists and is executable.
@@ 136,18 199,24 @@ func (r *Runner) Fact(name string) (string, error) {
return "", err
}
- return r.parseFact(name, output)
+ return parseFact(name, output)
}
-func (r *Runner) parseFact(name string, output []byte) (string, error) {
+func parseFact(name string, output []byte) (string, error) {
+ needle := []byte(name)
+
for _, line := range bytes.Split(output, []byte("\n")) {
- if bytes.HasPrefix(line, []byte(name)) {
- var (
- fields = bytes.Fields(line)
- value = bytes.Join(fields[1:], []byte(" "))
- )
- return string(value), nil
+ if !bytes.HasPrefix(line, needle) {
+ continue
+ }
+
+ fields := bytes.Fields(line)
+ if !bytes.Equal(needle, fields[0]) {
+ continue
}
+
+ return string(bytes.Join(fields[1:], []byte(" "))), nil
+
}
return "", fmt.Errorf("no such fact: %s", name)
A runners/pkg/distributions.json => runners/pkg/distributions.json +26 -0
@@ 0,0 1,26 @@
+{
+ "Arch Linux": {
+ "separator": "=",
+ "installed": [
+ "pacman",
+ "-Sq",
+ "--needed",
+ "--noconfirm"
+ ],
+ "latest": [
+ "pacman",
+ "-Syq",
+ "--needed",
+ "--noconfirm"
+ ],
+ "absent": [
+ "pacman",
+ "-R",
+ "--noconfirm"
+ ],
+ "query": [
+ "pacman",
+ "-Q"
+ ]
+ }
+}
M runners/pkg/main.go => runners/pkg/main.go +190 -22
@@ 2,8 2,12 @@
package main
import (
+ _ "embed"
+ "encoding/json"
+ "errors"
"fmt"
"os"
+ "strings"
"git.sr.ht/~nesv/govern/runner"
)
@@ 16,8 20,43 @@ func main() {
}
func run() error {
+ distros, err := loadDistributions()
+ if err != nil {
+ return fmt.Errorf("load config: %w", err)
+ }
+
r := runner.Runner{
- DefaultState:stateInstalled,
+ DefaultState: "installed",
+ States: map[string]runner.StateFunc{
+ "installed": distros.install,
+ "latest": distros.ensureLatest,
+ "absent": distros.remove,
+ },
+ RequiredFacts: []string{
+ "os/distribution",
+ },
+ }
+
+ return r.Exec()
+}
+
+//go:embed distributions.json
+var distributionsJSON []byte
+
+func loadDistributions() (distributions, error) {
+ dd := make(distributions)
+ if err := json.Unmarshal(distributionsJSON, &dd); err != nil {
+ return nil, err
+ }
+
+ return dd, nil
+}
+
+type distributions map[string]distroConfig
+
+func (d distributions) install(r *runner.Runner, name string, attrs map[string]string) error {
+ if d == nil {
+ return errors.New("not initialized")
}
distro, err := r.Fact("os/distribution")
@@ 25,33 64,162 @@ func run() error {
return fmt.Errorf("get fact: %w", err)
}
- handlers, ok := distroHandlers[distro]
- if !ok {
+ config, found := d[distro]
+ if !found {
return fmt.Errorf("distribution not supported: %s", distro)
}
- r.States=handlers
- return r.Exec()
+ return config.install(r, name, attrs)
}
-const (
- stateInstalled = "installed"
- stateLatest = "latest"
- stateRemoved = "removed"
-)
+func (d distributions) ensureLatest(r *runner.Runner, name string, attrs map[string]string) error {
+ if d == nil {
+ return errors.New("not initialized")
+ }
+
+ distro, err := r.Fact("os/distribution")
+ if err != nil {
+ return fmt.Errorf("get fact: %w", err)
+ }
+
+ config, found := d[distro]
+ if !found {
+ return fmt.Errorf("distribution not supported: %s", distro)
+ }
+
+ return config.ensureLatest(r, name, attrs)
+}
+
+func (d distributions) remove(r *runner.Runner, name string, attrs map[string]string) error {
+ if d == nil {
+ return errors.New("not initialized")
+ }
+
+ distro, err := r.Fact("os/distribution")
+ if err != nil {
+ return fmt.Errorf("get fact: %w", err)
+ }
+
+ config, found := d[distro]
+ if !found {
+ return fmt.Errorf("distribution not supported: %s", distro)
+ }
+
+ return config.remove(r, name, attrs)
+}
+
+type distroConfig struct {
+ Separator string `json:"separator"`
+ Installed []string `json:"installed"`
+ Latest []string `json:"latest"`
+ Absent []string `json:"absent"`
+ Query []string `json:"query"`
+}
+
+func (d distroConfig) install(r *runner.Runner, name string, attrs map[string]string) error {
+ pkgName := name
+ if v := attrs["version"]; v != "" && d.Separator != "" {
+ pkgName = fmt.Sprintf("%s%s%s", pkgName, d.Separator, v)
+ }
+
+ if r.Pretend() {
+ installed, err := d.pkgInstalled(pkgName)
+ if err != nil {
+ return fmt.Errorf("get installed version: %w", err)
+ }
+
+ if installed {
+ return runner.OK()
+ }
+
+ fmt.Println("would be installed")
+ return nil
+ }
+
+ cmd := append(d.Installed, name)
+ out, err := runner.Command(cmd...)
+ if err != nil {
+ return fmt.Errorf("%w: %s", err, out)
+ }
+
+ return runner.OK()
+}
+
+func (d distroConfig) pkgInstalled(name string) (bool, error) {
+ version, err := d.pkgversion(name)
+ if err != nil {
+ return false, err
+ }
+
+ return version != "", nil
+}
+
+func (d distroConfig) pkgversion(name string) (string, error) {
+ out, err := runner.Command(append(d.Query, name)...)
+ if err != nil {
+ return "", fmt.Errorf("%w: %s", err, out)
+ }
-var distroHandlers = map[string]map[string]runner.StateFunc{
- "Arch Linux": {
- stateInstalled: archInstalled,
- stateLatest: archLatest,
- stateRemoved: archRemoved,
- },
+ fields := strings.Fields(out)
+ if n := len(fields); n < 2 {
+ return "", fmt.Errorf("expected 2 fields, got %d", n)
+ }
+
+ return fields[1], nil
+}
+
+func (d distroConfig) ensureLatest(r *runner.Runner, name string, attrs map[string]string) error {
+ version := attrs["version"]
+
+ if r.Pretend() {
+ installedVersion, err := d.pkgversion(name)
+ if err != nil {
+ return err
+ }
+
+ if installedVersion == "" {
+ fmt.Println("will be installed")
+ return nil
+ }
+
+ if version != "" && installedVersion != version {
+ fmt.Println("will be upgraded")
+ return nil
+ }
+
+ return runner.OK()
+ }
+
+ if version != "" {
+ name = fmt.Sprintf("%s%s%s", name, d.Separator, version)
+ }
+
+ cmd := append(d.Latest, name)
+ if _, err := runner.Command(cmd...); err != nil {
+ return err
+ }
+
+ return runner.OK()
}
-var commands = map[string]map[string][]string{
- "Arch Linux": {
- "installed": {"pacman", "-Sq", "--needed", "--noconfirm"},
- "latest": {"pacman", "-Syq", "--needed", "--noconfirm"},
- "absent": {"pacman", "-R", "--noconfirm"},
- },
+func (d distroConfig) remove(r *runner.Runner, name string, attrs map[string]string) error {
+ if r.Pretend() {
+ version, err := d.pkgversion(name)
+ if err != nil {
+ return err
+ }
+
+ if version == "" {
+ return runner.OK()
+ }
+
+ fmt.Println("will be removed")
+ return nil
+ }
+
+ if _, err := runner.Command(append(d.Absent, name)...); err != nil {
+ return err
+ }
+
+ return runner.OK()
}