~nesv/govern

c30b6f92e0ecaa2956d69ed669d6e786befa431d — Nick Saika 1 year, 3 months ago 669793b + 84a27ab v0.0.1
Merge branch 'pkg.runner'
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()
}