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 }