Full Code of ema/pets for AI

master e0d1403d56a1 cached
30 files
58.4 KB
17.2k tokens
104 symbols
1 requests
Download .txt
Repository: ema/pets
Branch: master
Commit: e0d1403d56a1
Files: 30
Total size: 58.4 KB

Directory structure:
gitextract_arwwy1mf/

├── .github/
│   └── workflows/
│       └── go.yml
├── LICENSE
├── Makefile
├── README.adoc
├── file.go
├── file_test.go
├── go.mod
├── go.sum
├── main.go
├── main_test.go
├── manpage.adoc
├── package.go
├── package_test.go
├── parser.go
├── parser_test.go
├── planner.go
├── planner_test.go
├── sample_pet/
│   ├── README
│   ├── cron/
│   │   ├── certbot
│   │   └── mdadm
│   ├── ssh/
│   │   ├── sshd_config
│   │   └── user_ssh_config
│   ├── ssmtp/
│   │   ├── revaliases
│   │   └── ssmtp.conf
│   ├── sudo/
│   │   ├── sudo_group
│   │   └── sudoers
│   └── vimrc
├── sparrow.yaml
├── util.go
└── validator.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/go.yml
================================================
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go

name: Go

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
  workflow_dispatch:
    branches: [ master ]

jobs:

  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v3
      with:
        go-version: 1.18

    - name: Install dependencies
      run: go get .

    - name: Build
      run: go build -v ./...

    - name: Test
      run: go test -v ./...


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2022 Emanuele Rocca

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: Makefile
================================================
all:
	go fmt
	CGO_ENABLED=0 go build
	CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o pets-arm
	asciidoctor -b manpage manpage.adoc

test:
	go test -v -coverprofile cover.out
	go tool cover -func=cover.out

cover: test
	go tool cover -html cover.out -o cover.html
	open cover.html &

run:
	go fmt
	go run github.com/ema/pets

clean:
	-rm pets pets.1 cover.out cover.html


================================================
FILE: README.adoc
================================================
= PETS

image:https://github.com/ema/pets/actions/workflows/go.yml/badge.svg[link="https://github.com/ema/pets/actions/workflows/go.yml"]

A Configuration Management System for computers that are Pets, not Cattle.

This is for people who need to administer a handful of machines, all fairly
different from each other and all Very Important. Those systems are not Cattle!
They're actually a bit more than Pets. They're almost Family. For example: a
laptop, workstation, and that personal tiny server in Sweden. They are all
named after something dear.

pets works on Linux systems. The following distro families are supported:

- Debian-like (APT)
- RedHat-like (YUM)
- Alpine (APK)
- Arch Linux (Pacman, yay)

== Summary

Pets is the first configuration management system driven by comments embedded
in the config files themselves, rather than by a domain-specific language
(DSL). For example, say you want to ensure that user "ema" has sudo rights.
Create a file with the following contents under `$HOME/pets/`, run `pets` as
root, done. The file can be called whatever you want. Note that pets will
install the `sudo` package for you if missing.

----
# pets: destfile=/etc/sudoers.d/ema, owner=root, group=root, mode=0440
# pets: package=sudo
# pets: pre=/usr/sbin/visudo -cf

ema ALL=(ALL:ALL) NOPASSWD:ALL
----

== Usage

Build and install pets with:

----
$ go install github.com/ema/pets@latest
----

The following options are supported:

----
$ pets -h
Usage of ./pets:
  -conf-dir string
        Pets configuration directory (default "/home/ema/pets")
  -debug
        Show debugging output
  -dry-run
        Only show changes without applying them
----

Let's say you've decided to put your configuration files under `/etc/pets`. The
system can then be used with:

----
# pets -conf-dir /etc/pets
----

See https://github.com/ema/pets/tree/master/sample_pet[sample_pet] for a basic
example of what your `/etc/pets` can look like. Note that directory structure
is arbitrary, you can have as many directories as you want, call them what you
want, and so on.

== Design overview

The idea behind Pets is that Configuration Management of individual hosts
shouldn't be harder than administering the system by hand. Other configuration
management tools typically focus on usage scenarios involving complex
relationships between multiple, fairly homogeneous systems: for example,
setting up a bunch of application servers behind a load-balancer, or
configuring a database and its replicas. For that you need a templating
language, some way to store and share information about the various systems,
and a way to either push the changes to all hosts or pull them from a central
location. All that complexity can discourage from using a configuration
management tool to begin with: why bother with Chef syntax and ERB templates if
you just need to edit a few files?

Pets instead focuses on the individual, local machine. No need to ssh anywhere,
no puppetmaster to configure, nada. It works by reading your regular, static
configuration files (say muttrc) with added pets modelines, inspired by the
concept of vim modelines. Pets can copy your configuration files to the right
place, fix permissions, install packages, and run commands upon file update.

Following from this basic idea, here are the design decisions:

- Runs locally on a single machine
- One directory holds the full configuration of the system
- No variables, no templates, just plain static config files
- No dependencies between different components (eg: updating file A if and
  after file B was updated)
- A single one-shot program reading the configuration directory and applying
  changes
- Changes are applied only if basic syntax checks pass
- Main interaction mechanism inspired by vim modelines

Here's the initial design document in all its beauty. Ignore the "watcher"
part, that was before I settled on a one-shot approach.

image::design.png[]

== Configuration directives

- destfile -- where to install this file. One of either *destfile* or *symlink* must be specified.
- symlink -- create a symbolic link to this file, instead of copying it like *destfile* would.
- owner -- the file owner, passed to chown(1)
- group -- the group this file belongs to, passed to chgrp(1)
- mode -- octal mode for chmod(1)
- package -- which package to install before creating the file. This
  directive can be specificed more than once to install multiple packages.
- pre -- validation command. This must succeed for the file to be
  created / updated.
- post -- apply command. Usually something like reloading a service.

Configuration directives are passed as key/value arguments, either on multiple
lines or separated by commas.

----
# pets: package=ssh, pre=/usr/sbin/sshd -t -f
----

The example above and the one below are equivalent

----
# pets: package=ssh
# pets: pre=/usr/sbin/sshd -t -f
----

== Examples

=== Firewall

Say you want to configure the local firewall to drop all incoming traffic
except for ssh? Here's an example that does the following:

- Installs `ferm` if missing
- Validates the configuration with `/usr/sbin/ferm -n`
- If the configuration is valid, copies it under `/etc/ferm/ferm.conf`
- Reloads the firewall rules with `systemctl reload`

----
# pets: destfile=/etc/ferm/ferm.conf, owner=root, group=root, mode=644
# pets: package=ferm
# pets: pre=/usr/sbin/ferm -n
# pets: post=/bin/systemctl reload ferm.service

domain (ip ip6) {
    table filter {
        chain INPUT {
            policy DROP;

            # connection tracking
            mod state state INVALID DROP;
            mod state state (ESTABLISHED RELATED) ACCEPT;

            # allow local packets
            interface lo ACCEPT;

            # respond to ping
            proto icmp ACCEPT;

            # allow SSH connections
            proto tcp dport ssh ACCEPT;
        }

        chain OUTPUT {
            policy ACCEPT;
        }

        chain FORWARD {
            policy DROP;
        }
    }
}
----

=== SSH Server

----
# pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644
# pets: package=ssh
# pets: package=openssh-client-dbgsym
# pets: pre=/usr/sbin/sshd -t -f
# pets: post=/bin/systemctl reload ssh.service
#
# Warning! This file has been generated by pets(1). Any manual modification
# will be lost.

Port 22
Protocol 2
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_dsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key

# Change to yes to enable challenge-response passwords (beware issues with
# some PAM modules and threads)
ChallengeResponseAuthentication no

# Change to no to disable tunnelled clear text passwords
PasswordAuthentication no

X11Forwarding yes

# Allow client to pass locale environment variables
AcceptEnv LANG LC_*

Subsystem sftp /usr/lib/openssh/sftp-server

UsePAM yes
----

== Reception
Pets was featured https://news.ycombinator.com/item?id=33414338[on Hacker News]
and https://lobste.rs/s/jc2oru/configuration_management_system_for[on
Lobsters].

The author of Chef started
https://twitter.com/adamhjk/status/1587169750249271296[an interesting Twitter
thread] about Pets too.


================================================
FILE: file.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"log"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"strings"
)

// PetsFile is the central data structure of the system: it is the in-memory
// representation of a configuration file (eg: sshd_config)
type PetsFile struct {
	// Absolute path to the configuration file
	Source string
	Pkgs   []PetsPackage
	// Full destination path where the file has to be installed
	Dest string
	// Directory where the file has to be installed. This is only set in
	// case we have to create the destination directory
	Directory string
	User      *user.User
	Group     *user.Group
	// use string instead of os.FileMode to avoid converting back and forth
	Mode string
	Pre  *exec.Cmd
	Post *exec.Cmd
	// Is this a symbolic link or an actual file to be copied?
	Link bool
}

func NewPetsFile() *PetsFile {
	return &PetsFile{
		Source: "",
		Dest:   "",
		Mode:   "",
		Link:   false,
	}
}

// NeedsCopy returns PetsCause UPDATE if Source needs to be copied over Dest,
// CREATE if the Destination file does not exist yet, NONE otherwise.
func (pf *PetsFile) NeedsCopy() PetsCause {
	if pf.Link || pf.Source == "" {
		return NONE
	}

	shaSource, err := Sha256(pf.Source)
	if err != nil {
		log.Printf("[ERROR] cannot determine sha256 of Source file %s: %v\n", pf.Source, err)
		return NONE
	}

	shaDest, err := Sha256(pf.Dest)
	if os.IsNotExist(err) {
		return CREATE
	} else if err != nil {
		log.Printf("[ERROR] cannot determine sha256 of Dest file %s: %v\n", pf.Dest, err)
		return NONE
	}

	if shaSource == shaDest {
		log.Printf("[DEBUG] same sha256 for %s and %s: %s\n", pf.Source, pf.Dest, shaSource)
		return NONE
	}

	log.Printf("[DEBUG] sha256[%s]=%s != sha256[%s]=%s\n", pf.Source, shaSource, pf.Dest, shaDest)
	return UPDATE
}

// NeedsLink returns PetsCause LINK if a symbolic link using Source as TARGET
// and Dest as LINK_NAME needs to be created. See ln(1) for the most confusing
// terminology.
func (pf *PetsFile) NeedsLink() PetsCause {
	if !pf.Link || pf.Source == "" || pf.Dest == "" {
		return NONE
	}

	fi, err := os.Lstat(pf.Dest)

	if os.IsNotExist(err) {
		// Dest does not exist yet. Happy path, we are gonna create it!
		return LINK
	}

	if err != nil {
		// There was an error calling lstat, putting all my money on
		// permission denied.
		log.Printf("[ERROR] cannot lstat Dest file %s: %v\n", pf.Dest, err)
		return NONE
	}

	// We are here because Dest already exists and lstat succeeded. At this
	// point there are two options:
	// (1) Dest is already a link to Source \o/
	// (2) Dest is a file, or a directory, or a link to something else /o\
	//
	// In any case there is no action to take, but let's come up with a valid
	// excuse for not doing anything.

	// Easy case first: Dest exists and it is not a symlink
	if fi.Mode()&os.ModeSymlink == 0 {
		log.Printf("[ERROR] %s already exists\n", pf.Dest)
		return NONE
	}

	// Dest is a symlink
	path, err := filepath.EvalSymlinks(pf.Dest)

	if err != nil {
		log.Printf("[ERROR] cannot EvalSymlinks() Dest file %s: %v\n", pf.Dest, err)
	} else if pf.Source == path {
		// Happy path
		log.Printf("[DEBUG] %s is a symlink to %s already\n", pf.Dest, pf.Source)
	} else {
		log.Printf("[ERROR] %s is a symlink to %s instead of %s\n", pf.Dest, path, pf.Source)
	}
	return NONE
}

// NeedsDir returns PetsCause DIR if there is no directory at Directory,
// meaning that it has to be created. Most of this is suspiciously similar to
// NeedsLink above.
func (pf *PetsFile) NeedsDir() PetsCause {
	if pf.Directory == "" {
		return NONE
	}

	fi, err := os.Lstat(pf.Directory)

	if os.IsNotExist(err) {
		// Directory does not exist yet. Happy path, we are gonna create it!
		return DIR
	}

	if err != nil {
		// There was an error calling lstat, putting all my money on
		// permission denied.
		log.Printf("[ERROR] cannot lstat Directory %s: %v\n", pf.Directory, err)
		return NONE
	}

	// We are here because Directory already exists and lstat succeeded. At this
	// point there are two options:
	// (1) Dest is a directory \o/
	// (2) Dest is a file, a symlink, or something else (a squirrel?) /o\
	//
	// In any case there is no action to take, but let's come up with a valid
	// excuse for not doing anything.

	if !fi.IsDir() {
		log.Printf("[ERROR] %s already exists and it is not a directory\n", pf.Directory)
	}
	return NONE
}

func (pf *PetsFile) IsValid(pathErrorOK bool) bool {
	// Check if the specified package(s) exists
	for _, pkg := range pf.Pkgs {
		if !pkg.IsValid() {
			return false
		}
	}

	// Check pre-update validation command if the file has changed.
	if pf.NeedsCopy() != NONE && !runPre(pf, pathErrorOK) {
		return false
	}

	return true
}

func (pf *PetsFile) AddDest(dest string) {
	pf.Dest = dest
	pf.Directory = filepath.Dir(dest)
}

func (pf *PetsFile) AddLink(dest string) {
	pf.Dest = dest
	pf.Directory = filepath.Dir(dest)
	pf.Link = true
}

func (pf *PetsFile) AddUser(userName string) error {
	user, err := user.Lookup(userName)
	if err != nil {
		// TODO: one day we may add support for creating users
		return err
	}
	pf.User = user
	return nil
}

func (pf *PetsFile) AddGroup(groupName string) error {
	group, err := user.LookupGroup(groupName)
	if err != nil {
		// TODO: one day we may add support for creating groups
		return err
	}
	pf.Group = group
	return nil
}

func (pf *PetsFile) AddMode(mode string) error {
	_, err := StringToFileMode(mode)
	if err == nil {
		// The specified 'mode' string is valid.
		pf.Mode = mode
	}
	return err
}

func (pf *PetsFile) AddPre(pre string) {
	preArgs := strings.Fields(pre)
	if len(preArgs) > 0 {
		pf.Pre = NewCmd(preArgs)
	}
}

func (pf *PetsFile) AddPost(post string) {
	postArgs := strings.Fields(post)
	if len(postArgs) > 0 {
		pf.Post = NewCmd(postArgs)
	}
}


================================================
FILE: file_test.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"testing"
)

func TestBadUser(t *testing.T) {
	f, err := NewTestFile("", "", "", "never-did-this-user-exist", "", "", "", "")

	assertError(t, err)

	if f != nil {
		t.Errorf("Expecting f to be nil, got %v instead", f)
	}
}

func TestBadGroup(t *testing.T) {
	f, err := NewTestFile("", "", "", "root", "never-did-this-user-exist", "", "", "")

	assertError(t, err)

	if f != nil {
		t.Errorf("Expecting f to be nil, got %v instead", f)
	}
}

func TestShortModes(t *testing.T) {
	f, err := NewTestFile("", "", "", "root", "root", "600", "", "")

	assertNoError(t, err)

	assertEquals(t, f.Mode, "600")

	f, err = NewTestFile("", "", "", "root", "root", "755", "", "")

	assertNoError(t, err)

	assertEquals(t, f.Mode, "755")
}

func TestOK(t *testing.T) {
	f, err := NewTestFile("syntax on\n", "vim", "/tmp/vimrc", "root", "root", "0600", "cat -n /etc/motd /etc/passwd", "df")
	assertNoError(t, err)

	assertEquals(t, f.Pkgs[0], PetsPackage("vim"))
	assertEquals(t, f.Dest, "/tmp/vimrc")
	assertEquals(t, f.Mode, "0600")
}

func TestFileIsValidTrue(t *testing.T) {
	// Everything correct
	f, err := NewTestFile("/dev/null", "gvim", "/dev/null", "root", "root", "0600", "/bin/true", "")
	assertNoError(t, err)

	assertEquals(t, f.IsValid(false), true)
}

func TestFileIsValidBadPackage(t *testing.T) {
	// Bad package name
	f, err := NewTestFile("/dev/null", "not-an-actual-package", "/dev/null", "root", "root", "0600", "/bin/true", "")
	assertNoError(t, err)

	assertEquals(t, f.IsValid(false), false)
}

func TestFileIsValidPrePathError(t *testing.T) {
	// Path error in validation command
	f, err := NewTestFile("README.adoc", "gvim", "/etc/motd", "root", "root", "0600", "/bin/whatever-but-not-a-valid-path", "")
	assertNoError(t, err)
	assertEquals(t, f.IsValid(true), true)
}

func TestFileIsValidPathError(t *testing.T) {
	f, err := NewTestFile("README.adoc", "gvim", "/etc/motd", "root", "root", "0600", "/bin/whatever-but-not-a-valid-path", "")
	assertNoError(t, err)

	// Passing pathErrorOK=true to IsValid
	assertEquals(t, f.IsValid(true), true)

	// Passing pathErrorOK=false to IsValid
	assertEquals(t, f.IsValid(false), false)
}

func TestNeedsCopyNoSource(t *testing.T) {
	f := NewPetsFile()
	f.Source = ""
	assertEquals(t, int(f.NeedsCopy()), int(NONE))
}

func TestNeedsCopySourceNotThere(t *testing.T) {
	f := NewPetsFile()
	f.Source = "something-very-funny.lol"
	assertEquals(t, int(f.NeedsCopy()), int(NONE))
}

func TestNeedsLinkNoDest(t *testing.T) {
	f := NewPetsFile()
	f.Source = "sample_pet/vimrc"
	assertEquals(t, int(f.NeedsLink()), int(NONE))
}

func TestNeedsLinkHappyPathLINK(t *testing.T) {
	f := NewPetsFile()
	f.Source = "sample_pet/vimrc"
	f.AddLink("/tmp/this_does_not_exist_yet.vimrc")
	assertEquals(t, int(f.NeedsLink()), int(LINK))
}

func TestNeedsLinkHappyPathNONE(t *testing.T) {
	f := NewPetsFile()
	f.Source = "sample_pet/README"
	f.AddLink("sample_pet/README.txt")
	assertEquals(t, int(f.NeedsLink()), int(NONE))
}

func TestNeedsLinkDestExists(t *testing.T) {
	f := NewPetsFile()
	f.Source = "sample_pet/vimrc"
	f.AddLink("/etc/passwd")
	assertEquals(t, int(f.NeedsLink()), int(NONE))
}

func TestNeedsLinkDestIsSymlink(t *testing.T) {
	f := NewPetsFile()
	f.Source = "sample_pet/vimrc"
	f.AddLink("/etc/mtab")
	assertEquals(t, int(f.NeedsLink()), int(NONE))
}

func TestNeedsDirNoDirectory(t *testing.T) {
	f := NewPetsFile()
	assertEquals(t, int(f.NeedsDir()), int(NONE))
}

func TestNeedsDirHappyPathDIR(t *testing.T) {
	f := NewPetsFile()
	f.Directory = "/etc/does/not/exist"
	assertEquals(t, int(f.NeedsDir()), int(DIR))
}

func TestNeedsDirHappyPathNONE(t *testing.T) {
	f := NewPetsFile()
	f.Directory = "/etc"
	assertEquals(t, int(f.NeedsDir()), int(NONE))
}

func TestNeedsDirDestIsFile(t *testing.T) {
	f := NewPetsFile()
	f.Directory = "/etc/passwd"
	assertEquals(t, int(f.NeedsDir()), int(NONE))
}


================================================
FILE: go.mod
================================================
module github.com/ema/pets

go 1.19

require github.com/hashicorp/logutils v1.0.0 // indirect


================================================
FILE: go.sum
================================================
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=


================================================
FILE: main.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"flag"
	"log"
	"os"
	"path/filepath"
	"time"

	"github.com/hashicorp/logutils"
)

// ParseFlags parses the CLI flags and returns: the configuration directory as
// string, a bool for debugging output, and another bool for dryRun.
func ParseFlags() (string, bool, bool) {
	var confDir string
	defaultConfDir := filepath.Join(os.Getenv("HOME"), "pets")
	flag.StringVar(&confDir, "conf-dir", defaultConfDir, "Pets configuration directory")
	debug := flag.Bool("debug", false, "Show debugging output")
	dryRun := flag.Bool("dry-run", false, "Only show changes without applying them")
	flag.Parse()
	return confDir, *debug, *dryRun
}

// GetLogFilter returns a LevelFilter suitable for log.SetOutput().
func GetLogFilter(debug bool) *logutils.LevelFilter {
	minLogLevel := "INFO"
	if debug {
		minLogLevel = "DEBUG"
	}

	return &logutils.LevelFilter{
		Levels:   []logutils.LogLevel{"DEBUG", "INFO", "ERROR"},
		MinLevel: logutils.LogLevel(minLogLevel),
		Writer:   os.Stdout,
	}
}

func main() {
	startTime := time.Now()

	confDir, debug, dryRun := ParseFlags()

	log.SetOutput(GetLogFilter(debug))

	// Print distro family
	family := WhichPackageManager()
	if family == APT {
		log.Println("[DEBUG] Running on a Debian-like system")
	} else if family == YUM {
		log.Println("[DEBUG] Running on a RedHat-like system")
	}
	// *** Config parser ***

	// Generate a list of PetsFiles from the given config directory.
	log.Println("[DEBUG] * configuration parsing starts *")

	files, err := ParseFiles(confDir)
	if err != nil {
		log.Println(err)
	}

	log.Printf("[INFO] Found %d pets configuration files", len(files))

	log.Println("[DEBUG] * configuration parsing ends *")

	// *** Config validator ***
	log.Println("[DEBUG] * configuration validation starts *")
	globalErrors := CheckGlobalConstraints(files)

	if globalErrors != nil {
		log.Println(globalErrors)
		// Global validation errors mean we should stop the whole update.
		return
	}

	// Check validation errors in individual files. At this stage, the
	// command in the "pre" validation directive may not be installed yet.
	// Ignore PathErrors for now. Get a list of valid files.
	goodPets := CheckLocalConstraints(files, true)

	log.Println("[DEBUG] * configuration validation ends *")

	// Generate the list of actions to perform.
	actions := NewPetsActions(goodPets)

	// *** Update visualizer ***
	// Display:
	// - packages to install
	// - files created/modified
	// - content diff (maybe?)
	// - owner changes
	// - permissions changes
	// - which post-update commands will be executed
	for _, action := range actions {
		log.Println("[INFO]", action)
	}

	if dryRun {
		log.Println("[INFO] user requested dry-run mode, not applying any changes")
		return
	}

	// *** Update executor ***
	// Install missing packages
	// Create missing directories
	// Run pre-update command and stop the update if it fails
	// Update files
	// Change permissions/owners
	// Run post-update commands
	exitStatus := 0
	for _, action := range actions {
		log.Printf("[INFO] running '%s'\n", action.Command)

		err = action.Perform()
		if err != nil {
			log.Printf("[ERROR] performing action %s: %s\n", action, err)
			exitStatus = 1
			break
		}
	}

	log.Printf("[INFO] pets run took %v\n", time.Since(startTime).Round(time.Millisecond))

	os.Exit(exitStatus)
}


================================================
FILE: main_test.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"testing"
)

func TestParseFlags(t *testing.T) {
	confDir, debug, dryRun := ParseFlags()
	if len(confDir) == 0 {
		t.Errorf("ParseFlags() returned a empty confDir")
	}
	assertEquals(t, debug, false)
	assertEquals(t, dryRun, false)
}

func TestGetLogFilter(t *testing.T) {
	filter := GetLogFilter(true)
	assertEquals(t, string(filter.MinLevel), "DEBUG")

	filter = GetLogFilter(false)
	assertEquals(t, string(filter.MinLevel), "INFO")
}


================================================
FILE: manpage.adoc
================================================
= pets(1)
Emanuele Rocca
v1.0.0
:doctype: manpage
:manmanual: PETS
:mansource: PETS
:man-linkstyle: pass:[blue R < >]

== Name

pets - configuration management system for pets, not cattle

== Synopsis

*pets* [_OPTION_]...

== Options

*-conf-dir*=_DIR_::
  Read pets configuration from _DIR_.

*-debug*::
  Show debugging output.

*-dry-run*::
  Only show changes without applying them.

== Configuration Example

A pets configuration file setting up a minimal vimrc for root:

----
# pets: destfile=/root/.vimrc
# pets: package=vim

syntax on
set background=light
----

== Directives
Configuration directives are passed as key/value arguments, either on multiple
lines or separated by commas. The full list of supported directives is:

- destfile -- where to install this file. One of either *destfile* or *symlink* must be specified.
- symlink -- create a symbolic link to this file, instead of copying it like *destfile* would.
- owner -- the file owner, passed to chown(1)
- group -- the group this file belongs to, passed to chgrp(1)
- mode -- octal mode for chmod(1)
- package -- which package to install before creating the file. This
  directive can be specificed more than once to install multiple packages.
- pre -- validation command. This must succeed for the file to be
  created / updated.
- post -- apply command. Usually something like reloading a service.

== Exit status

*0*::
  Success.
  Everything went according to the plan.

*1*::
  Failure.
  An important error occurred.

== Resources

*Project web site:* https://github.com/ema/pets

== Copying

Copyright (C) 2022 {author}. +
Free use of this software is granted under the terms of the MIT License.


================================================
FILE: package.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"log"
	"os"
	"os/exec"
	"strings"
)

// A PetsPackage represents a distribution package.
type PetsPackage string

// PackageManager available on the system. APT on Debian-based distros, YUM on
// RedHat and derivatives.
type PackageManager int

const (
	APT = iota
	YUM
	APK
	YAY
	PACMAN
)

// WhichPackageManager is available on the system
func WhichPackageManager() PackageManager {
	var err error

	apt := NewCmd([]string{"apt", "--help"})
	_, _, err = RunCmd(apt)
	if err == nil {
		return APT
	}

	yum := NewCmd([]string{"yum", "--help"})
	_, _, err = RunCmd(yum)
	if err == nil {
		return YUM
	}

	apk := NewCmd([]string{"apk", "--version"})
	_, _, err = RunCmd(apk)
	if err == nil {
		return APK
	}

	// Yay has to be first because yay wraps pacman
	yay := NewCmd([]string{"yay", "--version"})
	if _, _, err = RunCmd(yay); err == nil {
		return YAY
	}

	pacman := NewCmd([]string{"pacman", "--version"})
	if _, _, err = RunCmd(pacman); err == nil {
		return PACMAN
	}

	panic("Unknown Package Manager")
}

func (pp PetsPackage) getPkgInfo() string {
	var pkgInfo *exec.Cmd

	switch WhichPackageManager() {
	case APT:
		pkgInfo = NewCmd([]string{"apt-cache", "policy", string(pp)})
	case YUM:
		pkgInfo = NewCmd([]string{"yum", "info", string(pp)})
	case APK:
		pkgInfo = NewCmd([]string{"apk", "search", "-e", string(pp)})
	case PACMAN:
		pkgInfo = NewCmd([]string{"pacman", "-Si", string(pp)})
	case YAY:
		pkgInfo = NewCmd([]string{"yay", "-Si", string(pp)})
	}

	stdout, _, err := RunCmd(pkgInfo)

	if err != nil {
		log.Printf("[ERROR] pkgInfoPolicy() command %s failed: %s\n", pkgInfo, err)
		return ""
	}

	return stdout
}

// IsValid returns true if the given PetsPackage is available in the distro.
func (pp PetsPackage) IsValid() bool {
	stdout := pp.getPkgInfo()
	family := WhichPackageManager()

	if family == APT && strings.HasPrefix(stdout, string(pp)) {
		// Return true if the output of apt-cache policy begins with pp
		log.Printf("[DEBUG] %s is a valid package name\n", pp)
		return true
	}

	if family == YUM {
		for _, line := range strings.Split(stdout, "\n") {
			line = strings.TrimSpace(line)
			pkgName := strings.SplitN(line, ": ", 2)
			if len(pkgName) == 2 {
				if strings.TrimSpace(pkgName[0]) == "Name" {
					return pkgName[1] == string(pp)
				}
			}
		}
	}

	if family == APK && strings.HasPrefix(stdout, string(pp)) {
		// Return true if the output of apk search -e begins with pp
		log.Printf("[DEBUG] %s is a valid package name\n", pp)
		return true
	}

	if (family == PACMAN || family == YAY) && !strings.HasPrefix(stdout, "error:") {
		// Return true if the output of pacman -Si doesnt begins with error
		log.Printf("[DEBUG] %s is a valid package name\n", pp)
		return true
	}

	log.Printf("[ERROR] %s is not an available package\n", pp)
	return false
}

// IsInstalled returns true if the given PetsPackage is installed on the
// system.
func (pp PetsPackage) IsInstalled() bool {
	family := WhichPackageManager()

	if family == APT {
		stdout := pp.getPkgInfo()
		for _, line := range strings.Split(stdout, "\n") {
			line = strings.TrimSpace(line)
			if strings.HasPrefix(line, "Installed: ") {
				version := strings.SplitN(line, ": ", 2)
				return version[1] != "(none)"
			}
		}

		log.Printf("[ERROR] no 'Installed:' line in apt-cache policy %s\n", pp)
		return false
	}

	if family == YUM {
		installed := NewCmd([]string{"rpm", "-qa", string(pp)})
		stdout, _, err := RunCmd(installed)
		if err != nil {
			log.Printf("[ERROR] running %s: '%s'", installed, err)
			return false
		}
		return len(stdout) > 0
	}

	if family == APK {
		installed := NewCmd([]string{"apk", "info", "-e", string(pp)})
		stdout, _, err := RunCmd(installed)
		if err != nil {
			log.Printf("[ERROR] running %s: '%s'\n", installed, err)
			return false
		}

		// apk info -e $pkg prints the package name to stdout if the package is
		// installed, nothing otherwise
		return strings.TrimSpace(stdout) == string(pp)
	}

	if family == PACMAN || family == YAY {
		installed := NewCmd([]string{"pacman", "-Q", string(pp)})
		if family == YAY {
			installed = NewCmd([]string{"yay", "-Q", string(pp)})
		}
		// pacman and yay will return 0 if the package is installed 1 if not
		if _, _, err := RunCmd(installed); err != nil {
			if exitError, ok := err.(*exec.ExitError); ok {
				return exitError.ExitCode() == 0
			}
			log.Printf("[ERROR] running %s: '%s'", installed, err)
			return false
		}
		return true
	}
	return false
}

// InstallCommand returns the command needed to install packages on this
// system.
func InstallCommand() *exec.Cmd {
	switch WhichPackageManager() {
	case APT:
		cmd := NewCmd([]string{"apt-get", "-y", "install"})
		cmd.Env = os.Environ()
		cmd.Env = append(cmd.Env, "DEBIAN_FRONTEND=noninteractive")
		return cmd
	case YUM:
		return NewCmd([]string{"yum", "-y", "install"})
	case APK:
		return NewCmd([]string{"apk", "add"})
	case PACMAN:
		return NewCmd([]string{"pacman", "-S", "--noconfirm"})
	case YAY:
		return NewCmd([]string{"yay", "-S", "--noconfirm"})
	}
	return nil
}


================================================
FILE: package_test.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"testing"
)

func TestPkgIsValid(t *testing.T) {
	pkg := PetsPackage("coreutils")
	assertEquals(t, pkg.IsValid(), true)
}

func TestPkgIsNotValid(t *testing.T) {
	pkg := PetsPackage("obviously-this-cannot-be valid ?")
	assertEquals(t, pkg.IsValid(), false)
}

func TestIsInstalled(t *testing.T) {
	pkg := PetsPackage("binutils")
	assertEquals(t, pkg.IsInstalled(), true)
}

func TestIsNotInstalled(t *testing.T) {
	pkg := PetsPackage("abiword")
	assertEquals(t, pkg.IsInstalled(), false)

	pkg = PetsPackage("this is getting ridiculous")
	assertEquals(t, pkg.IsInstalled(), false)
}


================================================
FILE: parser.go
================================================
// Copyright (C) 2022 Emanuele Rocca
//
// Pets configuration parser. Walk through a Pets directory and parse
// modelines.

package main

import (
	"bufio"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"regexp"
	"strings"
)

// Because it is important to know when enough is enough.
const MAXLINES int = 10

// ReadModelines looks into the given file and searches for pets modelines. A
// modeline is any string which includes the 'pets:' substring. All modelines
// found are returned as-is in a slice.
func ReadModelines(path string) ([]string, error) {
	modelines := []string{}

	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	scannedLines := 0
	for scanner.Scan() {
		if scannedLines == MAXLINES {
			return modelines, nil
		}

		line := scanner.Text()

		if strings.Contains(line, "pets:") {
			modelines = append(modelines, line)
		}

		scannedLines += 1
	}
	return modelines, nil
}

// ParseModeline parses a single pets modeline and populates the given PetsFile
// object. The line should something like:
// # pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644
func ParseModeline(line string, pf *PetsFile) error {
	// We just ignore and throw away anything before the 'pets:' modeline
	// identifier
	re, err := regexp.Compile("pets:(.*)")
	if err != nil {
		return err
	}

	matches := re.FindStringSubmatch(line)

	if len(matches) < 2 {
		// We thought this was a pets modeline -- but then it turned out to be
		// something different, very different indeed.
		return fmt.Errorf("[ERROR] invalid pets modeline: %v", line)
	}

	components := strings.Split(matches[1], ",")
	for _, comp := range components {
		// Ignore whitespace
		elem := strings.TrimSpace(comp)
		if len(elem) == 0 || elem == "\t" {
			continue
		}

		keyword, argument, found := strings.Cut(elem, "=")

		// Just in case something bad should happen
		badKeyword := fmt.Errorf("[ERROR] invalid keyword/argument '%v'", elem)

		if !found {
			return badKeyword // See? :(
		}

		switch keyword {
		case "destfile":
			pf.AddDest(argument)
		case "symlink":
			pf.AddLink(argument)
		case "owner":
			err = pf.AddUser(argument)
			if err != nil {
				log.Printf("[ERROR] unknown 'owner' %s, skipping directive\n", argument)
			}
		case "group":
			err = pf.AddGroup(argument)
			if err != nil {
				log.Printf("[ERROR] unknown 'group' %s, skipping directive\n", argument)
			}
		case "mode":
			pf.AddMode(argument)
		case "package":
			// haha gotcha this one has no setter
			pf.Pkgs = append(pf.Pkgs, PetsPackage(argument))
		case "pre":
			pf.AddPre(argument)
		case "post":
			pf.AddPost(argument)
		default:
			return badKeyword
		}

		// :)
		//log.Printf("[DEBUG] keyword '%v', argument '%v'\n", keyword, argument)
	}

	return nil
}

// ParseFiles walks the given directory, identifies all configuration files
// with pets modelines, and returns a list of parsed PetsFile(s).
func ParseFiles(directory string) ([]*PetsFile, error) {
	var petsFiles []*PetsFile

	log.Printf("[DEBUG] using configuration directory '%s'\n", directory)

	err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
		// This function is called once for each file in the Pets configuration
		// directory
		if err != nil {
			return err
		}

		if info.IsDir() {
			// Skip directories
			return nil
		}

		modelines, err := ReadModelines(path)
		if err != nil {
			// Returning the error we stop parsing all other files too. Debatable
			// whether we want to do that here or not. ReadModelines should not
			// fail technically, so it's probably fine to do it. Alternatively, we
			// could just log to stderr and return nil like we do later on for
			// syntax errors.
			return err
		}

		if len(modelines) == 0 {
			// Not a Pets file. We don't take it personal though
			return nil
		}

		log.Printf("[DEBUG] %d pets modelines found in %s\n", len(modelines), path)

		// Instantiate a PetsFile representation. The only thing we know so far
		// is the source path. Every long journey begins with a single step!
		pf := NewPetsFile()

		// Get absolute path to the source. Technically we would be fine with a
		// relative path too, but it's good to remove abiguity. Plus absolute
		// paths make things easier in case we have to create a symlink.
		abs, err := filepath.Abs(path)
		if err != nil {
			return err
		}

		pf.Source = abs

		for _, line := range modelines {
			err := ParseModeline(line, pf)
			if err != nil {
				// Possibly a syntax error, skip the whole file but do not return
				// an error! Otherwise all other files will be skipped too.
				log.Println(err) // XXX: log to stderr
				return nil
			}
		}

		if pf.Dest == "" {
			// 'destfile' or 'symlink' are mandatory arguments. If we did not
			// find any, consider it an error.
			log.Println(fmt.Errorf("[ERROR] Neither 'destfile' nor 'symlink' directives found in '%s'", path))
			return nil
		}

		log.Printf("[DEBUG] '%s' pets syntax OK\n", path)
		petsFiles = append(petsFiles, pf)
		return nil
	})

	return petsFiles, err
}


================================================
FILE: parser_test.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"testing"
)

func TestReadModelinesFileNotFound(t *testing.T) {
	modelines, err := ReadModelines("very-unlikely-to-find-this.txt")

	assertError(t, err)

	if modelines != nil {
		t.Errorf("Expecting nil modelines, got %v instead", modelines)
	}
}

func TestReadModelinesZero(t *testing.T) {
	modelines, err := ReadModelines("README.adoc")
	assertNoError(t, err)
	assertEquals(t, len(modelines), 0)
}

func TestReadModelinesNonZero(t *testing.T) {
	modelines, err := ReadModelines("sample_pet/ssh/user_ssh_config")
	assertNoError(t, err)
	assertEquals(t, len(modelines), 2)
}

func TestParseModelineErr(t *testing.T) {
	var pf PetsFile
	err := ParseModeline("", &pf)
	assertError(t, err)
}

func TestParseModelineBadKeyword(t *testing.T) {
	var pf PetsFile
	err := ParseModeline("# pets: something=funny", &pf)
	assertError(t, err)
}

func TestParseModelineOKDestfile(t *testing.T) {
	var pf PetsFile
	err := ParseModeline("# pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644", &pf)
	assertNoError(t, err)

	assertEquals(t, pf.Dest, "/etc/ssh/sshd_config")
	assertEquals(t, pf.User.Uid, "0")
	assertEquals(t, pf.Group.Gid, "0")
	assertEquals(t, pf.Mode, "0644")
	assertEquals(t, pf.Link, false)
}

func TestParseModelineOKSymlink(t *testing.T) {
	var pf PetsFile
	err := ParseModeline("# pets: symlink=/etc/ssh/sshd_config", &pf)
	assertNoError(t, err)

	assertEquals(t, pf.Dest, "/etc/ssh/sshd_config")
	assertEquals(t, pf.Link, true)
}

func TestParseModelineOKPackage(t *testing.T) {
	var pf PetsFile
	err := ParseModeline("# pets: package=vim", &pf)
	assertNoError(t, err)

	assertEquals(t, pf.Dest, "")
	assertEquals(t, string(pf.Pkgs[0]), "vim")
}


================================================
FILE: planner.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"fmt"
	"log"
	"os"
	"os/exec"
	"strconv"
	"syscall"
)

// PetsCause conveys the reason behind a given action.
type PetsCause int

const (
	NONE   = iota // no reason at all
	PKG           // required package is missing
	CREATE        // configuration file is missing and needs to be created
	UPDATE        // configuration file differs and needs to be updated
	LINK          // symbolic link needs to be created
	DIR           // directory needs to be created
	OWNER         // needs chown()
	MODE          // needs chmod()
	POST          // post-update command
)

func (pc PetsCause) String() string {
	return map[PetsCause]string{
		PKG:    "PACKAGE_INSTALL",
		CREATE: "FILE_CREATE",
		UPDATE: "FILE_UPDATE",
		LINK:   "LINK_CREATE",
		DIR:    "DIR_CREATE",
		OWNER:  "OWNER",
		MODE:   "CHMOD",
		POST:   "POST_UPDATE",
	}[pc]
}

// A PetsAction represents something to be done, namely running a certain
// Command. PetsActions exist because of some Trigger, which is a PetsFile.
type PetsAction struct {
	Cause   PetsCause
	Command *exec.Cmd
	Trigger *PetsFile
}

// String representation of a PetsAction
func (pa *PetsAction) String() string {
	if pa.Trigger != nil {
		return fmt.Sprintf("[%s] %s triggered command: '%s'", pa.Cause, pa.Trigger.Source, pa.Command)
	} else {
		return fmt.Sprintf("[%s] triggered command: '%s'", pa.Cause, pa.Command)
	}
}

// Perform executes the Command
func (pa *PetsAction) Perform() error {
	stdout, stderr, err := RunCmd(pa.Command)

	if err != nil {
		log.Printf("[ERROR] running Perform() -> %v\n", err)
	}

	if len(stdout) > 0 {
		log.Printf("[INFO] stdout from Perform() -> %v\n", stdout)
	}

	if len(stderr) > 0 {
		log.Printf("[ERROR] stderr from Perform() -> %v\n", stderr)
	}

	return err
}

// PkgsToInstall returns two values, a boolean and a command. The former is
// true if there are any new packages to install, the latter is the
// distro-specific command to run to install the packages.
func PkgsToInstall(triggers []*PetsFile) (bool, *exec.Cmd) {
	installPkgs := false
	installCmd := InstallCommand()

	for _, trigger := range triggers {
		for _, pkg := range trigger.Pkgs {
			if SliceContains(installCmd.Args, string(pkg)) {
				log.Printf("[DEBUG] %s already marked to be installed\n", pkg)
			} else if pkg.IsInstalled() {
				log.Printf("[DEBUG] %s already installed\n", pkg)
			} else {
				log.Printf("[INFO] %s not installed\n", pkg)
				installCmd.Args = append(installCmd.Args, string(pkg))
				installPkgs = true
			}
		}
	}

	return installPkgs, installCmd
}

// FileToCopy figures out if the given trigger represents a file that needs to
// be updated, and returns the corresponding PetsAction.
func FileToCopy(trigger *PetsFile) *PetsAction {
	if trigger.Link {
		return nil
	}

	cause := trigger.NeedsCopy()

	if cause == NONE {
		return nil
	} else {
		return &PetsAction{
			Cause:   cause,
			Command: NewCmd([]string{"/bin/cp", trigger.Source, trigger.Dest}),
			Trigger: trigger,
		}
	}
}

// LinkToCreate figures out if the given trigger represents a symbolic link
// that needs to be created, and returns the corresponding PetsAction.
func LinkToCreate(trigger *PetsFile) *PetsAction {
	if !trigger.Link {
		return nil
	}

	cause := trigger.NeedsLink()

	if cause == NONE {
		return nil
	} else {
		return &PetsAction{
			Cause:   cause,
			Command: NewCmd([]string{"/bin/ln", "-s", trigger.Source, trigger.Dest}),
			Trigger: trigger,
		}
	}
}

// DirToCreate figures out if the given trigger represents a directory that
// needs to be created, and returns the corresponding PetsAction.
func DirToCreate(trigger *PetsFile) *PetsAction {
	if trigger.Directory == "" {
		return nil
	}

	cause := trigger.NeedsDir()

	if cause == NONE {
		return nil
	} else {
		return &PetsAction{
			Cause:   cause,
			Command: NewCmd([]string{"/bin/mkdir", "-p", trigger.Directory}),
			Trigger: trigger,
		}
	}
}

// Chown returns a chown PetsAction or nil if none is needed.
func Chown(trigger *PetsFile) *PetsAction {
	// Build arg (eg: 'root:staff', 'root', ':staff')
	arg := ""
	var wantUid, wantGid int
	var err error

	if trigger.User != nil {
		arg = trigger.User.Username

		// get the requested uid as integer
		wantUid, err = strconv.Atoi(trigger.User.Uid)
		if err != nil {
			// This should really never ever happen, unless we're
			// running on Windows. :)
			panic(err)
		}
	}

	if trigger.Group != nil {
		arg = fmt.Sprintf("%s:%s", arg, trigger.Group.Name)

		// get the requested gid as integer
		wantGid, err = strconv.Atoi(trigger.Group.Gid)
		if err != nil {
			panic(err)
		}
	}

	if arg == "" {
		// Return immediately if the file had no 'owner' / 'group' directives
		return nil
	}

	// The action to (possibly) perform is a chown of the file.
	action := &PetsAction{
		Cause:   OWNER,
		Command: NewCmd([]string{"/bin/chown", arg, trigger.Dest}),
		Trigger: trigger,
	}

	// stat(2) the destination file to see if a chown is needed
	fileInfo, err := os.Stat(trigger.Dest)
	if os.IsNotExist(err) {
		// If the destination file is not there yet, prepare a chown
		// for later on.
		return action
	}

	stat, _ := fileInfo.Sys().(*syscall.Stat_t)

	if trigger.User != nil && int(stat.Uid) != wantUid {
		log.Printf("[INFO] %s is owned by uid %d instead of %s\n", trigger.Dest, stat.Uid, trigger.User.Username)
		return action
	}

	if trigger.Group != nil && int(stat.Gid) != wantGid {
		log.Printf("[INFO] %s is owned by gid %d instead of %s\n", trigger.Dest, stat.Gid, trigger.Group.Name)
		return action
	}

	log.Printf("[DEBUG] %s is owned by %d:%d already\n", trigger.Dest, stat.Uid, stat.Gid)
	return nil
}

// Chmod returns a chmod PetsAction or nil if none is needed.
func Chmod(trigger *PetsFile) *PetsAction {
	if trigger.Mode == "" {
		// Return immediately if the 'mode' directive was not specified.
		return nil
	}

	// The action to (possibly) perform is a chmod of the file.
	action := &PetsAction{
		Cause:   MODE,
		Command: NewCmd([]string{"/bin/chmod", trigger.Mode, trigger.Dest}),
		Trigger: trigger,
	}

	// stat(2) the destination file to see if a chmod is needed
	fileInfo, err := os.Stat(trigger.Dest)
	if os.IsNotExist(err) {
		// If the destination file is not there yet, prepare a mod
		// for later on.
		return action
	}

	// See if the desired mode and reality differ.
	newMode, err := StringToFileMode(trigger.Mode)
	if err != nil {
		log.Println("[ERROR] unexpected error in Chmod()", err)
		return nil
	}

	oldMode := fileInfo.Mode()

	if oldMode != newMode {
		log.Printf("[INFO] %s is %s instead of %s\n", trigger.Dest, oldMode, newMode)
		return action
	}

	log.Printf("[DEBUG] %s is %s already\n", trigger.Dest, newMode)
	return nil
}

// NewPetsActions is the []PetsFile -> []PetsAction constructor.  Given a slice
// of PetsFile(s), generate a list of PetsActions to perform.
func NewPetsActions(triggers []*PetsFile) []*PetsAction {
	actions := []*PetsAction{}

	// First, install all needed packages. Build a list of all missing package
	// names first, and then install all of them in one go. This is to avoid
	// embarassing things like running in a loop apt install pkg1 ; apt install
	// pkg2 ; apt install pkg3 like some configuration management systems do.
	if installPkgs, installCmd := PkgsToInstall(triggers); installPkgs {
		actions = append(actions, &PetsAction{
			Cause:   PKG,
			Command: installCmd,
		})
	}

	for _, trigger := range triggers {
		actionFired := false

		// Any directory to create
		if dirAction := DirToCreate(trigger); dirAction != nil {
			actions = append(actions, dirAction)
			actionFired = true
		}

		// Then, figure out which files need to be modified/created.
		if fileAction := FileToCopy(trigger); fileAction != nil {
			actions = append(actions, fileAction)
			actionFired = true
		}

		// Any symlink to create
		if linkAction := LinkToCreate(trigger); linkAction != nil {
			actions = append(actions, linkAction)
			actionFired = true
		}

		// Any owner changes needed
		if chown := Chown(trigger); chown != nil {
			actions = append(actions, chown)
			actionFired = true
		}

		// Any mode changes needed
		if chmod := Chmod(trigger); chmod != nil {
			actions = append(actions, chmod)
			actionFired = true
		}

		// Finally, post-update commands
		if trigger.Post != nil && actionFired {
			actions = append(actions, &PetsAction{
				Cause:   POST,
				Command: trigger.Post,
			})
		}
	}

	return actions
}


================================================
FILE: planner_test.go
================================================
// Copyright (C) 2022 Emanuele Rocca

package main

import (
	"testing"
)

func TestPkgsToInstall(t *testing.T) {
	// Test with empty slice of PetsFiles
	petsFiles := []*PetsFile{}
	isTodo, _ := PkgsToInstall(petsFiles)
	assertEquals(t, isTodo, false)

	// Test with one package already installed
	pf, err := NewTestFile("/dev/null", "binutils", "/etc/passwd", "root", "root", "0640", "", "")
	assertNoError(t, err)

	petsFiles = append(petsFiles, pf)
	isTodo, _ = PkgsToInstall(petsFiles)
	assertEquals(t, isTodo, false)

	// Add another package to the mix, this time it's not installed
	petsFiles[0].Pkgs = append(petsFiles[0].Pkgs, PetsPackage("abiword"))
	isTodo, _ = PkgsToInstall(petsFiles)
	assertEquals(t, isTodo, true)
}

func TestFileToCopy(t *testing.T) {
	pf, err := NewTestFile("sample_pet/ssh/sshd_config", "ssh", "sample_pet/ssh/sshd_config", "root", "root", "0640", "", "")
	assertNoError(t, err)

	pa := FileToCopy(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	pf, err = NewTestFile("sample_pet/ssh/sshd_config", "ssh", "/tmp/polpette", "root", "root", "0640", "", "")
	assertNoError(t, err)

	pa = FileToCopy(pf)
	if pa == nil {
		t.Errorf("Expecting a PetsAction, got nil instead")
	}

	assertEquals(t, pa.Cause.String(), "FILE_CREATE")

	pf, err = NewTestFile("sample_pet/ssh/sshd_config", "ssh", "sample_pet/ssh/user_ssh_config", "root", "root", "0640", "", "")
	assertNoError(t, err)

	pa = FileToCopy(pf)
	if pa == nil {
		t.Errorf("Expecting a PetsAction, got nil instead")
	}

	assertEquals(t, pa.Cause.String(), "FILE_UPDATE")
}

func TestChmod(t *testing.T) {
	// Expect Chmod() to return nil if the 'mode' directive is missing.
	pf := NewPetsFile()
	pf.Source = "/dev/null"
	pf.Dest = "/dev/null"

	pa := Chmod(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	pf.AddMode("0644")

	pa = Chmod(pf)

	assertEquals(t, pa.Cause.String(), "CHMOD")
	assertEquals(t, pa.Command.String(), "/bin/chmod 0644 /dev/null")

	pf.Dest = "/etc/passwd"
	pa = Chmod(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}
}

func TestChown(t *testing.T) {
	pf := NewPetsFile()
	pf.Source = "/dev/null"
	pf.Dest = "/etc/passwd"

	// If no 'user' or 'group' directives are specified
	pa := Chown(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	// File owned by 'root:root' already
	pf.AddUser("root")
	pf.AddGroup("root")
	pa = Chown(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	pf.AddUser("nobody")
	pa = Chown(pf)
	if pa == nil {
		t.Errorf("Expecting some action, got nil instead")
	}

	assertEquals(t, pa.Cause.String(), "OWNER")
	assertEquals(t, pa.Command.String(), "/bin/chown nobody:root /etc/passwd")
}

func TestLn(t *testing.T) {
	pf := NewPetsFile()
	pf.Source = "sample_pet/vimrc"

	// Link attribute and Dest not set
	pa := LinkToCreate(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	// Destination already exists
	pf.AddLink("/etc/passwd")

	pa = LinkToCreate(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	// Happy path, destination does not exist yet
	pf.AddLink("/tmp/vimrc")

	pa = LinkToCreate(pf)
	if pa == nil {
		t.Errorf("Expecting some action, got nil instead")
	}

	assertEquals(t, pa.Cause.String(), "LINK_CREATE")
	assertEquals(t, pa.Command.String(), "/bin/ln -s sample_pet/vimrc /tmp/vimrc")
}

func TestMkdir(t *testing.T) {
	pf := NewPetsFile()

	pa := DirToCreate(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	pf.Directory = "/etc"

	pa = DirToCreate(pf)
	if pa != nil {
		t.Errorf("Expecting nil, got %v instead", pa)
	}

	pf.Directory = "/etc/polpette/al/sugo"

	pa = DirToCreate(pf)
	if pa == nil {
		t.Errorf("Expecting some action, got nil instead")
	}

	assertEquals(t, pa.Cause.String(), "DIR_CREATE")
	assertEquals(t, pa.Command.String(), "/bin/mkdir -p /etc/polpette/al/sugo")
}


================================================
FILE: sample_pet/README
================================================
Pets configuration examples.


================================================
FILE: sample_pet/cron/certbot
================================================
# pets: destfile=/etc/cron.d/certbot, owner=root, group=root, mode=640
# pets: package=certbot
# pets: package=cron

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(43200))' && certbot -q renew


================================================
FILE: sample_pet/cron/mdadm
================================================
# pets: destfile=/etc/cron.d/mdadm, owner=root, group=root, mode=640
# pets: package=cron
# pets: package=mdadm

57 0 * * 0 root if [ -x /usr/share/mdadm/checkarray ] && [ $(date +\%d) -le 7 ]; then /usr/share/mdadm/checkarray --cron --all --idle --quiet; fi


================================================
FILE: sample_pet/ssh/sshd_config
================================================
# pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644
# pets: package=ssh
# pets: pre=/usr/sbin/sshd -t -f
# pets: post=/bin/systemctl reload ssh.service
#
# Warning! This file has been generated by pets(1). Any manual modification
# will be lost.

Port 22
Protocol 2
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_dsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key

# Change to yes to enable challenge-response passwords (beware issues with
# some PAM modules and threads)
ChallengeResponseAuthentication no

# Change to no to disable tunnelled clear text passwords
PasswordAuthentication no

X11Forwarding yes

# Allow client to pass locale environment variables
AcceptEnv LANG LC_*

Subsystem sftp /usr/lib/openssh/sftp-server

UsePAM yes


================================================
FILE: sample_pet/ssh/user_ssh_config
================================================
# pets: destfile=/home/ema/.ssh/config, owner=ema, group=ema, mode=0644
# pets: package=openssh-client
#
# Warning! This file has been generated by pets(1). Any manual modification
# will be lost.

ForwardAgent no
UseRoaming no

VisualHostKey yes
VerifyHostKeyDNS ask
StrictHostKeyChecking ask

HashKnownHosts no
UserKnownHostsFile ~/.ssh/known_hosts

PKCS11Provider /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so


================================================
FILE: sample_pet/ssmtp/revaliases
================================================
# pets: destfile=/etc/ssmtp/revaliases, owner=root, group=root, mode=0440
# pets: package=ssmtp
root:username@gmail.com:smtp.gmail.com:465
localuser:username@gmail.com:smtp.gmail.com:465


================================================
FILE: sample_pet/ssmtp/ssmtp.conf
================================================
# pets: destfile=/etc/ssmtp/ssmtp.conf, owner=root, group=root, mode=0440
# pets: package=ssmtp

# The user that gets all the mails (UID < 1000, usually the admin)
root=username@gmail.com

# The mail server (where the mail is sent to), both port 465 or 587 should be acceptable
# See also https://support.google.com/mail/answer/78799
mailhub=smtp.gmail.com:465

# The address where the mail appears to come from for user authentication.
rewriteDomain=gmail.com

# Use implicit TLS (port 465). When using port 587, change UseSTARTTLS=Yes
TLS_CA_FILE=/etc/ssl/certs/ca-certificates.crt
UseTLS=Yes
UseSTARTTLS=No

# Username/Password
AuthUser=username
AuthPass=password
AuthMethod=LOGIN

# Email 'From header's can override the default domain?
FromLineOverride=yes


================================================
FILE: sample_pet/sudo/sudo_group
================================================
# pets: destfile=/etc/sudoers.d/sudo_group, owner=root, group=root, mode=0440
# pets: package=sudo
# pets: pre=/usr/sbin/visudo -cf
#
# Warning! This file has been generated by pets(1). Any manual modification
# will be lost.

# Allow members of group sudo to execute any command
%sudo   ALL=(ALL:ALL) NOPASSWD:ALL


================================================
FILE: sample_pet/sudo/sudoers
================================================
# pets: destfile=/etc/sudoers, owner=root, group=root, mode=0440
# pets: package=sudo
# pets: pre=/usr/sbin/visudo -cf
#
# Warning! This file has been generated by pets(1). Any manual modification
# will be lost.

Defaults        env_reset
Defaults        secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

root    ALL=(ALL:ALL) ALL

# Allow ema to execute any command
ema ALL=(ALL:ALL) NOPASSWD:ALL


================================================
FILE: sample_pet/vimrc
================================================
# pets: package=vim
# pets: symlink=/root/.vimrc

syntax on
set expandtab
set shiftwidth=4
set tabstop=4


================================================
FILE: sparrow.yaml
================================================
image:
  - melezhik/sparrow:alpine
  - melezhik/sparrow:debian
  - melezhik/sparrow:ubuntu
  - melezhik/sparrow:archlinux
  
tasks:
 -
  name: go_test
  language: Bash
  code: |
    set -e
    echo "Run tests for OS: $os ..."
    if test "$os" = "ubuntu" || test "$os" = "debian" || test "$os" = "arch" || test "$os" = "archlinux"; then
      export PATH=/usr/local/go/bin:$PATH
    fi
    go version
    cd source
    go test -v
  default: true
  depends:
    -
      name: go_build
 -
  name: go_build
  language: Bash
  code: |
    set -e
    if test "$os" = "ubuntu" || test "$os" = "debian" || test "$os" = "arch" || test "$os" = "archlinux"; then
      export PATH=/usr/local/go/bin:$PATH
    fi
    go version
    cd source
    go build -v
  depends:
    -
      name: install-go
 -
    name: install-go
    language: Bash
    code: |
      if test $os = "alpine"; then
        sudo apk add go \
        --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community
      else
        sudo rm -rf /usr/local/go
        curl -sfL https://go.dev/dl/go1.19.3.linux-amd64.tar.gz -o ~/go1.19.3.linux-amd64.tar.gz
        sudo tar -C /usr/local -xzf ~/go*.linux-amd64.tar.gz
      fi


================================================
FILE: util.go
================================================
// Copyright (C) 2022 Emanuele Rocca
//
// A bunch of misc helper functions

package main

import (
	"bytes"
	"crypto/sha256"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strconv"

	"testing"
)

// NewCmd is a wrapper for exec.Command. It builds a new *exec.Cmd from a slice
// of strings.
func NewCmd(args []string) *exec.Cmd {
	var cmd *exec.Cmd

	if len(args) == 1 {
		cmd = exec.Command(args[0])
	} else {
		cmd = exec.Command(args[0], args[1:]...)
	}

	return cmd
}

// RunCmd runs the given command and returns two strings, one with stdout and
// one with stderr. The error object returned by cmd.Run() is also returned.
func RunCmd(cmd *exec.Cmd) (string, string, error) {
	var outb bytes.Buffer
	var errb bytes.Buffer
	cmd.Stdout = &outb
	cmd.Stderr = &errb

	err := cmd.Run()

	return outb.String(), errb.String(), err
}

// Sha256 returns the sha256 of the given file. Shocking, I know.
func Sha256(fileName string) (string, error) {
	f, err := os.Open(fileName)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}

	return fmt.Sprintf("%x", h.Sum(nil)), nil
}

func StringToFileMode(mode string) (os.FileMode, error) {
	octalMode, err := strconv.ParseInt(mode, 8, 64)
	return os.FileMode(octalMode), err
}

func SliceContains(slice []string, elem string) bool {
	for _, value := range slice {
		if value == elem {
			return true
		}
	}
	return false
}

// Various test helpers
func assertEquals(t *testing.T, a, b interface{}) {
	if a != b {
		t.Errorf("%v != %v", a, b)
	}
}

func assertNoError(t *testing.T, err error) {
	if err != nil {
		t.Errorf("Expecting err to be nil, got %v instead", err)
	}
}

func assertError(t *testing.T, err error) {
	if err == nil {
		t.Errorf("Expecting an error, got nil instead")
	}
}

func NewTestFile(src, pkg, dest, userName, groupName, mode, pre, post string) (*PetsFile, error) {
	var err error

	p := NewPetsFile()
	p.Source = src
	p.Pkgs = []PetsPackage{PetsPackage(pkg)}

	p.AddDest(dest)

	err = p.AddUser(userName)
	if err != nil {
		return nil, err
	}

	err = p.AddGroup(groupName)
	if err != nil {
		return nil, err
	}

	err = p.AddMode(mode)
	if err != nil {
		return nil, err
	}

	p.AddPre(pre)

	p.AddPost(post)

	return p, nil
}


================================================
FILE: validator.go
================================================
// Copyright (C) 2022 Emanuele Rocca
//
// Pets configuration file validator. Given a list of in-memory PetsFile(s),
// see if our sanity constraints are met. For example, we do not want multiple
// files to be installed to the same destination path. Also, all validation
// commands must succeed.

package main

import (
	"fmt"
	"io/fs"
	"log"
)

// CheckGlobalConstraints validates assumptions that must hold across all
// configuration files.
func CheckGlobalConstraints(files []*PetsFile) error {
	// Keep the seen PetsFiles in a map so we can:
	// 1) identify and print duplicate sources
	// 2) avoid slices.Contains which is only in Go 1.18+ and not even bound to
	//    the Go 1 Compatibility Promise™
	seen := make(map[string]*PetsFile)

	for _, pf := range files {
		other, exist := seen[pf.Dest]
		if exist {
			return fmt.Errorf("[ERROR] duplicate definition for '%s': '%s' and '%s'\n", pf.Dest, pf.Source, other.Source)
		}
		seen[pf.Dest] = pf
	}

	return nil
}

// runPre returns true if the pre-update validation command passes, or if it
// was not specificed at all. The boolean argument pathErrorOK controls whether
// or not we want to fail if the validation command is not around.
func runPre(pf *PetsFile, pathErrorOK bool) bool {
	if pf.Pre == nil {
		return true
	}

	// Some optimism.
	toReturn := true

	// Run 'pre' validation command, append Source filename to
	// arguments.
	// eg: /usr/sbin/sshd -t -f sample_pet/ssh/sshd_config
	pf.Pre.Args = append(pf.Pre.Args, pf.Source)

	stdout, stderr, err := RunCmd(pf.Pre)

	_, pathError := err.(*fs.PathError)

	if err == nil {
		log.Printf("[INFO] pre-update command %s successful\n", pf.Pre.Args)
	} else if pathError && pathErrorOK {
		// The command has failed because the validation command itself is
		// missing. This could be a chicken-and-egg problem: at this stage
		// configuration is not validated yet, hence any "package" directives
		// have not been applied.  Do not consider this as a failure, for now.
		log.Printf("[INFO] pre-update command %s failed due to PathError. Ignoring for now\n", pf.Pre.Args)
	} else {
		log.Printf("[ERROR] pre-update command %s: %s\n", pf.Pre.Args, err)
		toReturn = false
	}

	if len(stdout) > 0 {
		log.Printf("[INFO] stdout: %s", stdout)
	}

	if len(stderr) > 0 {
		log.Printf("[ERROR] stderr: %s", stderr)
	}

	return toReturn
}

// CheckLocalConstraints validates assumptions that must hold for the
// individual configuration files. An error in one file means we're gonna skip
// it but proceed with the rest. The function returns a slice of files for
// which validation passed.
func CheckLocalConstraints(files []*PetsFile, pathErrorOK bool) []*PetsFile {
	var goodPets []*PetsFile

	for _, pf := range files {
		log.Printf("[DEBUG] validating %s\n", pf.Source)

		if pf.IsValid(pathErrorOK) {
			log.Printf("[DEBUG] valid configuration file: %s\n", pf.Source)
			goodPets = append(goodPets, pf)
		} else {
			log.Printf("[ERROR] invalid configuration file: %s\n", pf.Source)
		}
	}

	return goodPets
}
Download .txt
gitextract_arwwy1mf/

├── .github/
│   └── workflows/
│       └── go.yml
├── LICENSE
├── Makefile
├── README.adoc
├── file.go
├── file_test.go
├── go.mod
├── go.sum
├── main.go
├── main_test.go
├── manpage.adoc
├── package.go
├── package_test.go
├── parser.go
├── parser_test.go
├── planner.go
├── planner_test.go
├── sample_pet/
│   ├── README
│   ├── cron/
│   │   ├── certbot
│   │   └── mdadm
│   ├── ssh/
│   │   ├── sshd_config
│   │   └── user_ssh_config
│   ├── ssmtp/
│   │   ├── revaliases
│   │   └── ssmtp.conf
│   ├── sudo/
│   │   ├── sudo_group
│   │   └── sudoers
│   └── vimrc
├── sparrow.yaml
├── util.go
└── validator.go
Download .txt
SYMBOL INDEX (104 symbols across 12 files)

FILE: file.go
  type PetsFile (line 16) | type PetsFile struct
    method NeedsCopy (line 46) | func (pf *PetsFile) NeedsCopy() PetsCause {
    method NeedsLink (line 77) | func (pf *PetsFile) NeedsLink() PetsCause {
    method NeedsDir (line 127) | func (pf *PetsFile) NeedsDir() PetsCause {
    method IsValid (line 160) | func (pf *PetsFile) IsValid(pathErrorOK bool) bool {
    method AddDest (line 176) | func (pf *PetsFile) AddDest(dest string) {
    method AddLink (line 181) | func (pf *PetsFile) AddLink(dest string) {
    method AddUser (line 187) | func (pf *PetsFile) AddUser(userName string) error {
    method AddGroup (line 197) | func (pf *PetsFile) AddGroup(groupName string) error {
    method AddMode (line 207) | func (pf *PetsFile) AddMode(mode string) error {
    method AddPre (line 216) | func (pf *PetsFile) AddPre(pre string) {
    method AddPost (line 223) | func (pf *PetsFile) AddPost(post string) {
  function NewPetsFile (line 35) | func NewPetsFile() *PetsFile {

FILE: file_test.go
  function TestBadUser (line 9) | func TestBadUser(t *testing.T) {
  function TestBadGroup (line 19) | func TestBadGroup(t *testing.T) {
  function TestShortModes (line 29) | func TestShortModes(t *testing.T) {
  function TestOK (line 43) | func TestOK(t *testing.T) {
  function TestFileIsValidTrue (line 52) | func TestFileIsValidTrue(t *testing.T) {
  function TestFileIsValidBadPackage (line 60) | func TestFileIsValidBadPackage(t *testing.T) {
  function TestFileIsValidPrePathError (line 68) | func TestFileIsValidPrePathError(t *testing.T) {
  function TestFileIsValidPathError (line 75) | func TestFileIsValidPathError(t *testing.T) {
  function TestNeedsCopyNoSource (line 86) | func TestNeedsCopyNoSource(t *testing.T) {
  function TestNeedsCopySourceNotThere (line 92) | func TestNeedsCopySourceNotThere(t *testing.T) {
  function TestNeedsLinkNoDest (line 98) | func TestNeedsLinkNoDest(t *testing.T) {
  function TestNeedsLinkHappyPathLINK (line 104) | func TestNeedsLinkHappyPathLINK(t *testing.T) {
  function TestNeedsLinkHappyPathNONE (line 111) | func TestNeedsLinkHappyPathNONE(t *testing.T) {
  function TestNeedsLinkDestExists (line 118) | func TestNeedsLinkDestExists(t *testing.T) {
  function TestNeedsLinkDestIsSymlink (line 125) | func TestNeedsLinkDestIsSymlink(t *testing.T) {
  function TestNeedsDirNoDirectory (line 132) | func TestNeedsDirNoDirectory(t *testing.T) {
  function TestNeedsDirHappyPathDIR (line 137) | func TestNeedsDirHappyPathDIR(t *testing.T) {
  function TestNeedsDirHappyPathNONE (line 143) | func TestNeedsDirHappyPathNONE(t *testing.T) {
  function TestNeedsDirDestIsFile (line 149) | func TestNeedsDirDestIsFile(t *testing.T) {

FILE: main.go
  function ParseFlags (line 17) | func ParseFlags() (string, bool, bool) {
  function GetLogFilter (line 28) | func GetLogFilter(debug bool) *logutils.LevelFilter {
  function main (line 41) | func main() {

FILE: main_test.go
  function TestParseFlags (line 9) | func TestParseFlags(t *testing.T) {
  function TestGetLogFilter (line 18) | func TestGetLogFilter(t *testing.T) {

FILE: package.go
  type PetsPackage (line 13) | type PetsPackage
    method getPkgInfo (line 63) | func (pp PetsPackage) getPkgInfo() string {
    method IsValid (line 90) | func (pp PetsPackage) IsValid() bool {
    method IsInstalled (line 130) | func (pp PetsPackage) IsInstalled() bool {
  type PackageManager (line 17) | type PackageManager
  constant APT (line 20) | APT = iota
  constant YUM (line 21) | YUM
  constant APK (line 22) | APK
  constant YAY (line 23) | YAY
  constant PACMAN (line 24) | PACMAN
  function WhichPackageManager (line 28) | func WhichPackageManager() PackageManager {
  function InstallCommand (line 190) | func InstallCommand() *exec.Cmd {

FILE: package_test.go
  function TestPkgIsValid (line 9) | func TestPkgIsValid(t *testing.T) {
  function TestPkgIsNotValid (line 14) | func TestPkgIsNotValid(t *testing.T) {
  function TestIsInstalled (line 19) | func TestIsInstalled(t *testing.T) {
  function TestIsNotInstalled (line 24) | func TestIsNotInstalled(t *testing.T) {

FILE: parser.go
  constant MAXLINES (line 19) | MAXLINES int = 10
  function ReadModelines (line 24) | func ReadModelines(path string) ([]string, error) {
  function ParseModeline (line 54) | func ParseModeline(line string, pf *PetsFile) error {
  function ParseFiles (line 124) | func ParseFiles(directory string) ([]*PetsFile, error) {

FILE: parser_test.go
  function TestReadModelinesFileNotFound (line 9) | func TestReadModelinesFileNotFound(t *testing.T) {
  function TestReadModelinesZero (line 19) | func TestReadModelinesZero(t *testing.T) {
  function TestReadModelinesNonZero (line 25) | func TestReadModelinesNonZero(t *testing.T) {
  function TestParseModelineErr (line 31) | func TestParseModelineErr(t *testing.T) {
  function TestParseModelineBadKeyword (line 37) | func TestParseModelineBadKeyword(t *testing.T) {
  function TestParseModelineOKDestfile (line 43) | func TestParseModelineOKDestfile(t *testing.T) {
  function TestParseModelineOKSymlink (line 55) | func TestParseModelineOKSymlink(t *testing.T) {
  function TestParseModelineOKPackage (line 64) | func TestParseModelineOKPackage(t *testing.T) {

FILE: planner.go
  type PetsCause (line 15) | type PetsCause
    method String (line 29) | func (pc PetsCause) String() string {
  constant NONE (line 18) | NONE   = iota
  constant PKG (line 19) | PKG
  constant CREATE (line 20) | CREATE
  constant UPDATE (line 21) | UPDATE
  constant LINK (line 22) | LINK
  constant DIR (line 23) | DIR
  constant OWNER (line 24) | OWNER
  constant MODE (line 25) | MODE
  constant POST (line 26) | POST
  type PetsAction (line 44) | type PetsAction struct
    method String (line 51) | func (pa *PetsAction) String() string {
    method Perform (line 60) | func (pa *PetsAction) Perform() error {
  function PkgsToInstall (line 81) | func PkgsToInstall(triggers []*PetsFile) (bool, *exec.Cmd) {
  function FileToCopy (line 104) | func FileToCopy(trigger *PetsFile) *PetsAction {
  function LinkToCreate (line 124) | func LinkToCreate(trigger *PetsFile) *PetsAction {
  function DirToCreate (line 144) | func DirToCreate(trigger *PetsFile) *PetsAction {
  function Chown (line 163) | func Chown(trigger *PetsFile) *PetsAction {
  function Chmod (line 228) | func Chmod(trigger *PetsFile) *PetsAction {
  function NewPetsActions (line 269) | func NewPetsActions(triggers []*PetsFile) []*PetsAction {

FILE: planner_test.go
  function TestPkgsToInstall (line 9) | func TestPkgsToInstall(t *testing.T) {
  function TestFileToCopy (line 29) | func TestFileToCopy(t *testing.T) {
  function TestChmod (line 59) | func TestChmod(t *testing.T) {
  function TestChown (line 84) | func TestChown(t *testing.T) {
  function TestLn (line 113) | func TestLn(t *testing.T) {
  function TestMkdir (line 143) | func TestMkdir(t *testing.T) {

FILE: util.go
  function NewCmd (line 21) | func NewCmd(args []string) *exec.Cmd {
  function RunCmd (line 35) | func RunCmd(cmd *exec.Cmd) (string, string, error) {
  function Sha256 (line 47) | func Sha256(fileName string) (string, error) {
  function StringToFileMode (line 62) | func StringToFileMode(mode string) (os.FileMode, error) {
  function SliceContains (line 67) | func SliceContains(slice []string, elem string) bool {
  function assertEquals (line 77) | func assertEquals(t *testing.T, a, b interface{}) {
  function assertNoError (line 83) | func assertNoError(t *testing.T, err error) {
  function assertError (line 89) | func assertError(t *testing.T, err error) {
  function NewTestFile (line 95) | func NewTestFile(src, pkg, dest, userName, groupName, mode, pre, post st...

FILE: validator.go
  function CheckGlobalConstraints (line 18) | func CheckGlobalConstraints(files []*PetsFile) error {
  function runPre (line 39) | func runPre(pf *PetsFile, pathErrorOK bool) bool {
  function CheckLocalConstraints (line 84) | func CheckLocalConstraints(files []*PetsFile, pathErrorOK bool) []*PetsF...
Condensed preview — 30 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (66K chars).
[
  {
    "path": ".github/workflows/go.yml",
    "chars": 621,
    "preview": "# This workflow will build a golang project\n# For more information see: https://docs.github.com/en/actions/automating-bu"
  },
  {
    "path": "LICENSE",
    "chars": 1071,
    "preview": "MIT License\n\nCopyright (c) 2022 Emanuele Rocca\n\nPermission is hereby granted, free of charge, to any person obtaining a "
  },
  {
    "path": "Makefile",
    "chars": 373,
    "preview": "all:\n\tgo fmt\n\tCGO_ENABLED=0 go build\n\tCGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o pets-arm\n\tasciidoctor -b manpage"
  },
  {
    "path": "README.adoc",
    "chars": 7170,
    "preview": "= PETS\n\nimage:https://github.com/ema/pets/actions/workflows/go.yml/badge.svg[link=\"https://github.com/ema/pets/actions/w"
  },
  {
    "path": "file.go",
    "chars": 5777,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/user\"\n\t\"path/filepath\"\n\t\"strin"
  },
  {
    "path": "file_test.go",
    "chars": 3918,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestBadUser(t *testing.T) {\n\tf, err := N"
  },
  {
    "path": "go.mod",
    "chars": 94,
    "preview": "module github.com/ema/pets\n\ngo 1.19\n\nrequire github.com/hashicorp/logutils v1.0.0 // indirect\n"
  },
  {
    "path": "go.sum",
    "chars": 177,
    "preview": "github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=\ngithub.com/hashicorp/logutils v1.0."
  },
  {
    "path": "main.go",
    "chars": 3371,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"flag\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com"
  },
  {
    "path": "main_test.go",
    "chars": 498,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestParseFlags(t *testing.T) {\n\tconfDir,"
  },
  {
    "path": "manpage.adoc",
    "chars": 1678,
    "preview": "= pets(1)\nEmanuele Rocca\nv1.0.0\n:doctype: manpage\n:manmanual: PETS\n:mansource: PETS\n:man-linkstyle: pass:[blue R < >]\n\n="
  },
  {
    "path": "package.go",
    "chars": 5100,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n)\n\n// A PetsPackage repr"
  },
  {
    "path": "package_test.go",
    "chars": 645,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestPkgIsValid(t *testing.T) {\n\tpkg := P"
  },
  {
    "path": "parser.go",
    "chars": 5089,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n//\n// Pets configuration parser. Walk through a Pets directory and parse\n// modelin"
  },
  {
    "path": "parser_test.go",
    "chars": 1736,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestReadModelinesFileNotFound(t *testing"
  },
  {
    "path": "planner.go",
    "chars": 8452,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strconv\"\n\t\"syscall\"\n)\n\n// "
  },
  {
    "path": "planner_test.go",
    "chars": 3926,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestPkgsToInstall(t *testing.T) {\n\t// Te"
  },
  {
    "path": "sample_pet/README",
    "chars": 29,
    "preview": "Pets configuration examples.\n"
  },
  {
    "path": "sample_pet/cron/certbot",
    "chars": 326,
    "preview": "# pets: destfile=/etc/cron.d/certbot, owner=root, group=root, mode=640\n# pets: package=certbot\n# pets: package=cron\n\nSHE"
  },
  {
    "path": "sample_pet/cron/mdadm",
    "chars": 259,
    "preview": "# pets: destfile=/etc/cron.d/mdadm, owner=root, group=root, mode=640\n# pets: package=cron\n# pets: package=mdadm\n\n57 0 * "
  },
  {
    "path": "sample_pet/ssh/sshd_config",
    "chars": 805,
    "preview": "# pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644\n# pets: package=ssh\n# pets: pre=/usr/sbin/sshd "
  },
  {
    "path": "sample_pet/ssh/user_ssh_config",
    "chars": 410,
    "preview": "# pets: destfile=/home/ema/.ssh/config, owner=ema, group=ema, mode=0644\n# pets: package=openssh-client\n#\n# Warning! This"
  },
  {
    "path": "sample_pet/ssmtp/revaliases",
    "chars": 187,
    "preview": "# pets: destfile=/etc/ssmtp/revaliases, owner=root, group=root, mode=0440\n# pets: package=ssmtp\nroot:username@gmail.com:"
  },
  {
    "path": "sample_pet/ssmtp/ssmtp.conf",
    "chars": 762,
    "preview": "# pets: destfile=/etc/ssmtp/ssmtp.conf, owner=root, group=root, mode=0440\n# pets: package=ssmtp\n\n# The user that gets al"
  },
  {
    "path": "sample_pet/sudo/sudo_group",
    "chars": 315,
    "preview": "# pets: destfile=/etc/sudoers.d/sudo_group, owner=root, group=root, mode=0440\n# pets: package=sudo\n# pets: pre=/usr/sbin"
  },
  {
    "path": "sample_pet/sudo/sudoers",
    "chars": 425,
    "preview": "# pets: destfile=/etc/sudoers, owner=root, group=root, mode=0440\n# pets: package=sudo\n# pets: pre=/usr/sbin/visudo -cf\n#"
  },
  {
    "path": "sample_pet/vimrc",
    "chars": 105,
    "preview": "# pets: package=vim\n# pets: symlink=/root/.vimrc\n\nsyntax on\nset expandtab\nset shiftwidth=4\nset tabstop=4\n"
  },
  {
    "path": "sparrow.yaml",
    "chars": 1188,
    "preview": "image:\n  - melezhik/sparrow:alpine\n  - melezhik/sparrow:debian\n  - melezhik/sparrow:ubuntu\n  - melezhik/sparrow:archlinu"
  },
  {
    "path": "util.go",
    "chars": 2263,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n//\n// A bunch of misc helper functions\n\npackage main\n\nimport (\n\t\"bytes\"\n\t\"crypto/sh"
  },
  {
    "path": "validator.go",
    "chars": 3031,
    "preview": "// Copyright (C) 2022 Emanuele Rocca\n//\n// Pets configuration file validator. Given a list of in-memory PetsFile(s),\n// "
  }
]

About this extraction

This page contains the full source code of the ema/pets GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 30 files (58.4 KB), approximately 17.2k tokens, and a symbol index with 104 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!