[
  {
    "path": ".github/workflows/go.yml",
    "content": "# This workflow will build a golang project\n# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go\n\nname: Go\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n  workflow_dispatch:\n    branches: [ master ]\n\njobs:\n\n  build:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v3\n\n    - name: Set up Go\n      uses: actions/setup-go@v3\n      with:\n        go-version: 1.18\n\n    - name: Install dependencies\n      run: go get .\n\n    - name: Build\n      run: go build -v ./...\n\n    - name: Test\n      run: go test -v ./...\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Emanuele Rocca\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "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 manpage.adoc\n\ntest:\n\tgo test -v -coverprofile cover.out\n\tgo tool cover -func=cover.out\n\ncover: test\n\tgo tool cover -html cover.out -o cover.html\n\topen cover.html &\n\nrun:\n\tgo fmt\n\tgo run github.com/ema/pets\n\nclean:\n\t-rm pets pets.1 cover.out cover.html\n"
  },
  {
    "path": "README.adoc",
    "content": "= PETS\n\nimage:https://github.com/ema/pets/actions/workflows/go.yml/badge.svg[link=\"https://github.com/ema/pets/actions/workflows/go.yml\"]\n\nA Configuration Management System for computers that are Pets, not Cattle.\n\nThis is for people who need to administer a handful of machines, all fairly\ndifferent from each other and all Very Important. Those systems are not Cattle!\nThey're actually a bit more than Pets. They're almost Family. For example: a\nlaptop, workstation, and that personal tiny server in Sweden. They are all\nnamed after something dear.\n\npets works on Linux systems. The following distro families are supported:\n\n- Debian-like (APT)\n- RedHat-like (YUM)\n- Alpine (APK)\n- Arch Linux (Pacman, yay)\n\n== Summary\n\nPets is the first configuration management system driven by comments embedded\nin the config files themselves, rather than by a domain-specific language\n(DSL). For example, say you want to ensure that user \"ema\" has sudo rights.\nCreate a file with the following contents under `$HOME/pets/`, run `pets` as\nroot, done. The file can be called whatever you want. Note that pets will\ninstall the `sudo` package for you if missing.\n\n----\n# pets: destfile=/etc/sudoers.d/ema, owner=root, group=root, mode=0440\n# pets: package=sudo\n# pets: pre=/usr/sbin/visudo -cf\n\nema ALL=(ALL:ALL) NOPASSWD:ALL\n----\n\n== Usage\n\nBuild and install pets with:\n\n----\n$ go install github.com/ema/pets@latest\n----\n\nThe following options are supported:\n\n----\n$ pets -h\nUsage of ./pets:\n  -conf-dir string\n        Pets configuration directory (default \"/home/ema/pets\")\n  -debug\n        Show debugging output\n  -dry-run\n        Only show changes without applying them\n----\n\nLet's say you've decided to put your configuration files under `/etc/pets`. The\nsystem can then be used with:\n\n----\n# pets -conf-dir /etc/pets\n----\n\nSee https://github.com/ema/pets/tree/master/sample_pet[sample_pet] for a basic\nexample of what your `/etc/pets` can look like. Note that directory structure\nis arbitrary, you can have as many directories as you want, call them what you\nwant, and so on.\n\n== Design overview\n\nThe idea behind Pets is that Configuration Management of individual hosts\nshouldn't be harder than administering the system by hand. Other configuration\nmanagement tools typically focus on usage scenarios involving complex\nrelationships between multiple, fairly homogeneous systems: for example,\nsetting up a bunch of application servers behind a load-balancer, or\nconfiguring a database and its replicas. For that you need a templating\nlanguage, some way to store and share information about the various systems,\nand a way to either push the changes to all hosts or pull them from a central\nlocation. All that complexity can discourage from using a configuration\nmanagement tool to begin with: why bother with Chef syntax and ERB templates if\nyou just need to edit a few files?\n\nPets instead focuses on the individual, local machine. No need to ssh anywhere,\nno puppetmaster to configure, nada. It works by reading your regular, static\nconfiguration files (say muttrc) with added pets modelines, inspired by the\nconcept of vim modelines. Pets can copy your configuration files to the right\nplace, fix permissions, install packages, and run commands upon file update.\n\nFollowing from this basic idea, here are the design decisions:\n\n- Runs locally on a single machine\n- One directory holds the full configuration of the system\n- No variables, no templates, just plain static config files\n- No dependencies between different components (eg: updating file A if and\n  after file B was updated)\n- A single one-shot program reading the configuration directory and applying\n  changes\n- Changes are applied only if basic syntax checks pass\n- Main interaction mechanism inspired by vim modelines\n\nHere's the initial design document in all its beauty. Ignore the \"watcher\"\npart, that was before I settled on a one-shot approach.\n\nimage::design.png[]\n\n== Configuration directives\n\n- destfile -- where to install this file. One of either *destfile* or *symlink* must be specified.\n- symlink -- create a symbolic link to this file, instead of copying it like *destfile* would.\n- owner -- the file owner, passed to chown(1)\n- group -- the group this file belongs to, passed to chgrp(1)\n- mode -- octal mode for chmod(1)\n- package -- which package to install before creating the file. This\n  directive can be specificed more than once to install multiple packages.\n- pre -- validation command. This must succeed for the file to be\n  created / updated.\n- post -- apply command. Usually something like reloading a service.\n\nConfiguration directives are passed as key/value arguments, either on multiple\nlines or separated by commas.\n\n----\n# pets: package=ssh, pre=/usr/sbin/sshd -t -f\n----\n\nThe example above and the one below are equivalent\n\n----\n# pets: package=ssh\n# pets: pre=/usr/sbin/sshd -t -f\n----\n\n== Examples\n\n=== Firewall\n\nSay you want to configure the local firewall to drop all incoming traffic\nexcept for ssh? Here's an example that does the following:\n\n- Installs `ferm` if missing\n- Validates the configuration with `/usr/sbin/ferm -n`\n- If the configuration is valid, copies it under `/etc/ferm/ferm.conf`\n- Reloads the firewall rules with `systemctl reload`\n\n----\n# pets: destfile=/etc/ferm/ferm.conf, owner=root, group=root, mode=644\n# pets: package=ferm\n# pets: pre=/usr/sbin/ferm -n\n# pets: post=/bin/systemctl reload ferm.service\n\ndomain (ip ip6) {\n    table filter {\n        chain INPUT {\n            policy DROP;\n\n            # connection tracking\n            mod state state INVALID DROP;\n            mod state state (ESTABLISHED RELATED) ACCEPT;\n\n            # allow local packets\n            interface lo ACCEPT;\n\n            # respond to ping\n            proto icmp ACCEPT;\n\n            # allow SSH connections\n            proto tcp dport ssh ACCEPT;\n        }\n\n        chain OUTPUT {\n            policy ACCEPT;\n        }\n\n        chain FORWARD {\n            policy DROP;\n        }\n    }\n}\n----\n\n=== SSH Server\n\n----\n# pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644\n# pets: package=ssh\n# pets: package=openssh-client-dbgsym\n# pets: pre=/usr/sbin/sshd -t -f\n# pets: post=/bin/systemctl reload ssh.service\n#\n# Warning! This file has been generated by pets(1). Any manual modification\n# will be lost.\n\nPort 22\nProtocol 2\nHostKey /etc/ssh/ssh_host_rsa_key\nHostKey /etc/ssh/ssh_host_dsa_key\nHostKey /etc/ssh/ssh_host_ecdsa_key\nHostKey /etc/ssh/ssh_host_ed25519_key\n\n# Change to yes to enable challenge-response passwords (beware issues with\n# some PAM modules and threads)\nChallengeResponseAuthentication no\n\n# Change to no to disable tunnelled clear text passwords\nPasswordAuthentication no\n\nX11Forwarding yes\n\n# Allow client to pass locale environment variables\nAcceptEnv LANG LC_*\n\nSubsystem sftp /usr/lib/openssh/sftp-server\n\nUsePAM yes\n----\n\n== Reception\nPets was featured https://news.ycombinator.com/item?id=33414338[on Hacker News]\nand https://lobste.rs/s/jc2oru/configuration_management_system_for[on\nLobsters].\n\nThe author of Chef started\nhttps://twitter.com/adamhjk/status/1587169750249271296[an interesting Twitter\nthread] about Pets too.\n"
  },
  {
    "path": "file.go",
    "content": "// 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\"strings\"\n)\n\n// PetsFile is the central data structure of the system: it is the in-memory\n// representation of a configuration file (eg: sshd_config)\ntype PetsFile struct {\n\t// Absolute path to the configuration file\n\tSource string\n\tPkgs   []PetsPackage\n\t// Full destination path where the file has to be installed\n\tDest string\n\t// Directory where the file has to be installed. This is only set in\n\t// case we have to create the destination directory\n\tDirectory string\n\tUser      *user.User\n\tGroup     *user.Group\n\t// use string instead of os.FileMode to avoid converting back and forth\n\tMode string\n\tPre  *exec.Cmd\n\tPost *exec.Cmd\n\t// Is this a symbolic link or an actual file to be copied?\n\tLink bool\n}\n\nfunc NewPetsFile() *PetsFile {\n\treturn &PetsFile{\n\t\tSource: \"\",\n\t\tDest:   \"\",\n\t\tMode:   \"\",\n\t\tLink:   false,\n\t}\n}\n\n// NeedsCopy returns PetsCause UPDATE if Source needs to be copied over Dest,\n// CREATE if the Destination file does not exist yet, NONE otherwise.\nfunc (pf *PetsFile) NeedsCopy() PetsCause {\n\tif pf.Link || pf.Source == \"\" {\n\t\treturn NONE\n\t}\n\n\tshaSource, err := Sha256(pf.Source)\n\tif err != nil {\n\t\tlog.Printf(\"[ERROR] cannot determine sha256 of Source file %s: %v\\n\", pf.Source, err)\n\t\treturn NONE\n\t}\n\n\tshaDest, err := Sha256(pf.Dest)\n\tif os.IsNotExist(err) {\n\t\treturn CREATE\n\t} else if err != nil {\n\t\tlog.Printf(\"[ERROR] cannot determine sha256 of Dest file %s: %v\\n\", pf.Dest, err)\n\t\treturn NONE\n\t}\n\n\tif shaSource == shaDest {\n\t\tlog.Printf(\"[DEBUG] same sha256 for %s and %s: %s\\n\", pf.Source, pf.Dest, shaSource)\n\t\treturn NONE\n\t}\n\n\tlog.Printf(\"[DEBUG] sha256[%s]=%s != sha256[%s]=%s\\n\", pf.Source, shaSource, pf.Dest, shaDest)\n\treturn UPDATE\n}\n\n// NeedsLink returns PetsCause LINK if a symbolic link using Source as TARGET\n// and Dest as LINK_NAME needs to be created. See ln(1) for the most confusing\n// terminology.\nfunc (pf *PetsFile) NeedsLink() PetsCause {\n\tif !pf.Link || pf.Source == \"\" || pf.Dest == \"\" {\n\t\treturn NONE\n\t}\n\n\tfi, err := os.Lstat(pf.Dest)\n\n\tif os.IsNotExist(err) {\n\t\t// Dest does not exist yet. Happy path, we are gonna create it!\n\t\treturn LINK\n\t}\n\n\tif err != nil {\n\t\t// There was an error calling lstat, putting all my money on\n\t\t// permission denied.\n\t\tlog.Printf(\"[ERROR] cannot lstat Dest file %s: %v\\n\", pf.Dest, err)\n\t\treturn NONE\n\t}\n\n\t// We are here because Dest already exists and lstat succeeded. At this\n\t// point there are two options:\n\t// (1) Dest is already a link to Source \\o/\n\t// (2) Dest is a file, or a directory, or a link to something else /o\\\n\t//\n\t// In any case there is no action to take, but let's come up with a valid\n\t// excuse for not doing anything.\n\n\t// Easy case first: Dest exists and it is not a symlink\n\tif fi.Mode()&os.ModeSymlink == 0 {\n\t\tlog.Printf(\"[ERROR] %s already exists\\n\", pf.Dest)\n\t\treturn NONE\n\t}\n\n\t// Dest is a symlink\n\tpath, err := filepath.EvalSymlinks(pf.Dest)\n\n\tif err != nil {\n\t\tlog.Printf(\"[ERROR] cannot EvalSymlinks() Dest file %s: %v\\n\", pf.Dest, err)\n\t} else if pf.Source == path {\n\t\t// Happy path\n\t\tlog.Printf(\"[DEBUG] %s is a symlink to %s already\\n\", pf.Dest, pf.Source)\n\t} else {\n\t\tlog.Printf(\"[ERROR] %s is a symlink to %s instead of %s\\n\", pf.Dest, path, pf.Source)\n\t}\n\treturn NONE\n}\n\n// NeedsDir returns PetsCause DIR if there is no directory at Directory,\n// meaning that it has to be created. Most of this is suspiciously similar to\n// NeedsLink above.\nfunc (pf *PetsFile) NeedsDir() PetsCause {\n\tif pf.Directory == \"\" {\n\t\treturn NONE\n\t}\n\n\tfi, err := os.Lstat(pf.Directory)\n\n\tif os.IsNotExist(err) {\n\t\t// Directory does not exist yet. Happy path, we are gonna create it!\n\t\treturn DIR\n\t}\n\n\tif err != nil {\n\t\t// There was an error calling lstat, putting all my money on\n\t\t// permission denied.\n\t\tlog.Printf(\"[ERROR] cannot lstat Directory %s: %v\\n\", pf.Directory, err)\n\t\treturn NONE\n\t}\n\n\t// We are here because Directory already exists and lstat succeeded. At this\n\t// point there are two options:\n\t// (1) Dest is a directory \\o/\n\t// (2) Dest is a file, a symlink, or something else (a squirrel?) /o\\\n\t//\n\t// In any case there is no action to take, but let's come up with a valid\n\t// excuse for not doing anything.\n\n\tif !fi.IsDir() {\n\t\tlog.Printf(\"[ERROR] %s already exists and it is not a directory\\n\", pf.Directory)\n\t}\n\treturn NONE\n}\n\nfunc (pf *PetsFile) IsValid(pathErrorOK bool) bool {\n\t// Check if the specified package(s) exists\n\tfor _, pkg := range pf.Pkgs {\n\t\tif !pkg.IsValid() {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Check pre-update validation command if the file has changed.\n\tif pf.NeedsCopy() != NONE && !runPre(pf, pathErrorOK) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\nfunc (pf *PetsFile) AddDest(dest string) {\n\tpf.Dest = dest\n\tpf.Directory = filepath.Dir(dest)\n}\n\nfunc (pf *PetsFile) AddLink(dest string) {\n\tpf.Dest = dest\n\tpf.Directory = filepath.Dir(dest)\n\tpf.Link = true\n}\n\nfunc (pf *PetsFile) AddUser(userName string) error {\n\tuser, err := user.Lookup(userName)\n\tif err != nil {\n\t\t// TODO: one day we may add support for creating users\n\t\treturn err\n\t}\n\tpf.User = user\n\treturn nil\n}\n\nfunc (pf *PetsFile) AddGroup(groupName string) error {\n\tgroup, err := user.LookupGroup(groupName)\n\tif err != nil {\n\t\t// TODO: one day we may add support for creating groups\n\t\treturn err\n\t}\n\tpf.Group = group\n\treturn nil\n}\n\nfunc (pf *PetsFile) AddMode(mode string) error {\n\t_, err := StringToFileMode(mode)\n\tif err == nil {\n\t\t// The specified 'mode' string is valid.\n\t\tpf.Mode = mode\n\t}\n\treturn err\n}\n\nfunc (pf *PetsFile) AddPre(pre string) {\n\tpreArgs := strings.Fields(pre)\n\tif len(preArgs) > 0 {\n\t\tpf.Pre = NewCmd(preArgs)\n\t}\n}\n\nfunc (pf *PetsFile) AddPost(post string) {\n\tpostArgs := strings.Fields(post)\n\tif len(postArgs) > 0 {\n\t\tpf.Post = NewCmd(postArgs)\n\t}\n}\n"
  },
  {
    "path": "file_test.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestBadUser(t *testing.T) {\n\tf, err := NewTestFile(\"\", \"\", \"\", \"never-did-this-user-exist\", \"\", \"\", \"\", \"\")\n\n\tassertError(t, err)\n\n\tif f != nil {\n\t\tt.Errorf(\"Expecting f to be nil, got %v instead\", f)\n\t}\n}\n\nfunc TestBadGroup(t *testing.T) {\n\tf, err := NewTestFile(\"\", \"\", \"\", \"root\", \"never-did-this-user-exist\", \"\", \"\", \"\")\n\n\tassertError(t, err)\n\n\tif f != nil {\n\t\tt.Errorf(\"Expecting f to be nil, got %v instead\", f)\n\t}\n}\n\nfunc TestShortModes(t *testing.T) {\n\tf, err := NewTestFile(\"\", \"\", \"\", \"root\", \"root\", \"600\", \"\", \"\")\n\n\tassertNoError(t, err)\n\n\tassertEquals(t, f.Mode, \"600\")\n\n\tf, err = NewTestFile(\"\", \"\", \"\", \"root\", \"root\", \"755\", \"\", \"\")\n\n\tassertNoError(t, err)\n\n\tassertEquals(t, f.Mode, \"755\")\n}\n\nfunc TestOK(t *testing.T) {\n\tf, err := NewTestFile(\"syntax on\\n\", \"vim\", \"/tmp/vimrc\", \"root\", \"root\", \"0600\", \"cat -n /etc/motd /etc/passwd\", \"df\")\n\tassertNoError(t, err)\n\n\tassertEquals(t, f.Pkgs[0], PetsPackage(\"vim\"))\n\tassertEquals(t, f.Dest, \"/tmp/vimrc\")\n\tassertEquals(t, f.Mode, \"0600\")\n}\n\nfunc TestFileIsValidTrue(t *testing.T) {\n\t// Everything correct\n\tf, err := NewTestFile(\"/dev/null\", \"gvim\", \"/dev/null\", \"root\", \"root\", \"0600\", \"/bin/true\", \"\")\n\tassertNoError(t, err)\n\n\tassertEquals(t, f.IsValid(false), true)\n}\n\nfunc TestFileIsValidBadPackage(t *testing.T) {\n\t// Bad package name\n\tf, err := NewTestFile(\"/dev/null\", \"not-an-actual-package\", \"/dev/null\", \"root\", \"root\", \"0600\", \"/bin/true\", \"\")\n\tassertNoError(t, err)\n\n\tassertEquals(t, f.IsValid(false), false)\n}\n\nfunc TestFileIsValidPrePathError(t *testing.T) {\n\t// Path error in validation command\n\tf, err := NewTestFile(\"README.adoc\", \"gvim\", \"/etc/motd\", \"root\", \"root\", \"0600\", \"/bin/whatever-but-not-a-valid-path\", \"\")\n\tassertNoError(t, err)\n\tassertEquals(t, f.IsValid(true), true)\n}\n\nfunc TestFileIsValidPathError(t *testing.T) {\n\tf, err := NewTestFile(\"README.adoc\", \"gvim\", \"/etc/motd\", \"root\", \"root\", \"0600\", \"/bin/whatever-but-not-a-valid-path\", \"\")\n\tassertNoError(t, err)\n\n\t// Passing pathErrorOK=true to IsValid\n\tassertEquals(t, f.IsValid(true), true)\n\n\t// Passing pathErrorOK=false to IsValid\n\tassertEquals(t, f.IsValid(false), false)\n}\n\nfunc TestNeedsCopyNoSource(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Source = \"\"\n\tassertEquals(t, int(f.NeedsCopy()), int(NONE))\n}\n\nfunc TestNeedsCopySourceNotThere(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Source = \"something-very-funny.lol\"\n\tassertEquals(t, int(f.NeedsCopy()), int(NONE))\n}\n\nfunc TestNeedsLinkNoDest(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Source = \"sample_pet/vimrc\"\n\tassertEquals(t, int(f.NeedsLink()), int(NONE))\n}\n\nfunc TestNeedsLinkHappyPathLINK(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Source = \"sample_pet/vimrc\"\n\tf.AddLink(\"/tmp/this_does_not_exist_yet.vimrc\")\n\tassertEquals(t, int(f.NeedsLink()), int(LINK))\n}\n\nfunc TestNeedsLinkHappyPathNONE(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Source = \"sample_pet/README\"\n\tf.AddLink(\"sample_pet/README.txt\")\n\tassertEquals(t, int(f.NeedsLink()), int(NONE))\n}\n\nfunc TestNeedsLinkDestExists(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Source = \"sample_pet/vimrc\"\n\tf.AddLink(\"/etc/passwd\")\n\tassertEquals(t, int(f.NeedsLink()), int(NONE))\n}\n\nfunc TestNeedsLinkDestIsSymlink(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Source = \"sample_pet/vimrc\"\n\tf.AddLink(\"/etc/mtab\")\n\tassertEquals(t, int(f.NeedsLink()), int(NONE))\n}\n\nfunc TestNeedsDirNoDirectory(t *testing.T) {\n\tf := NewPetsFile()\n\tassertEquals(t, int(f.NeedsDir()), int(NONE))\n}\n\nfunc TestNeedsDirHappyPathDIR(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Directory = \"/etc/does/not/exist\"\n\tassertEquals(t, int(f.NeedsDir()), int(DIR))\n}\n\nfunc TestNeedsDirHappyPathNONE(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Directory = \"/etc\"\n\tassertEquals(t, int(f.NeedsDir()), int(NONE))\n}\n\nfunc TestNeedsDirDestIsFile(t *testing.T) {\n\tf := NewPetsFile()\n\tf.Directory = \"/etc/passwd\"\n\tassertEquals(t, int(f.NeedsDir()), int(NONE))\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/ema/pets\n\ngo 1.19\n\nrequire github.com/hashicorp/logutils v1.0.0 // indirect\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=\ngithub.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=\n"
  },
  {
    "path": "main.go",
    "content": "// 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/hashicorp/logutils\"\n)\n\n// ParseFlags parses the CLI flags and returns: the configuration directory as\n// string, a bool for debugging output, and another bool for dryRun.\nfunc ParseFlags() (string, bool, bool) {\n\tvar confDir string\n\tdefaultConfDir := filepath.Join(os.Getenv(\"HOME\"), \"pets\")\n\tflag.StringVar(&confDir, \"conf-dir\", defaultConfDir, \"Pets configuration directory\")\n\tdebug := flag.Bool(\"debug\", false, \"Show debugging output\")\n\tdryRun := flag.Bool(\"dry-run\", false, \"Only show changes without applying them\")\n\tflag.Parse()\n\treturn confDir, *debug, *dryRun\n}\n\n// GetLogFilter returns a LevelFilter suitable for log.SetOutput().\nfunc GetLogFilter(debug bool) *logutils.LevelFilter {\n\tminLogLevel := \"INFO\"\n\tif debug {\n\t\tminLogLevel = \"DEBUG\"\n\t}\n\n\treturn &logutils.LevelFilter{\n\t\tLevels:   []logutils.LogLevel{\"DEBUG\", \"INFO\", \"ERROR\"},\n\t\tMinLevel: logutils.LogLevel(minLogLevel),\n\t\tWriter:   os.Stdout,\n\t}\n}\n\nfunc main() {\n\tstartTime := time.Now()\n\n\tconfDir, debug, dryRun := ParseFlags()\n\n\tlog.SetOutput(GetLogFilter(debug))\n\n\t// Print distro family\n\tfamily := WhichPackageManager()\n\tif family == APT {\n\t\tlog.Println(\"[DEBUG] Running on a Debian-like system\")\n\t} else if family == YUM {\n\t\tlog.Println(\"[DEBUG] Running on a RedHat-like system\")\n\t}\n\t// *** Config parser ***\n\n\t// Generate a list of PetsFiles from the given config directory.\n\tlog.Println(\"[DEBUG] * configuration parsing starts *\")\n\n\tfiles, err := ParseFiles(confDir)\n\tif err != nil {\n\t\tlog.Println(err)\n\t}\n\n\tlog.Printf(\"[INFO] Found %d pets configuration files\", len(files))\n\n\tlog.Println(\"[DEBUG] * configuration parsing ends *\")\n\n\t// *** Config validator ***\n\tlog.Println(\"[DEBUG] * configuration validation starts *\")\n\tglobalErrors := CheckGlobalConstraints(files)\n\n\tif globalErrors != nil {\n\t\tlog.Println(globalErrors)\n\t\t// Global validation errors mean we should stop the whole update.\n\t\treturn\n\t}\n\n\t// Check validation errors in individual files. At this stage, the\n\t// command in the \"pre\" validation directive may not be installed yet.\n\t// Ignore PathErrors for now. Get a list of valid files.\n\tgoodPets := CheckLocalConstraints(files, true)\n\n\tlog.Println(\"[DEBUG] * configuration validation ends *\")\n\n\t// Generate the list of actions to perform.\n\tactions := NewPetsActions(goodPets)\n\n\t// *** Update visualizer ***\n\t// Display:\n\t// - packages to install\n\t// - files created/modified\n\t// - content diff (maybe?)\n\t// - owner changes\n\t// - permissions changes\n\t// - which post-update commands will be executed\n\tfor _, action := range actions {\n\t\tlog.Println(\"[INFO]\", action)\n\t}\n\n\tif dryRun {\n\t\tlog.Println(\"[INFO] user requested dry-run mode, not applying any changes\")\n\t\treturn\n\t}\n\n\t// *** Update executor ***\n\t// Install missing packages\n\t// Create missing directories\n\t// Run pre-update command and stop the update if it fails\n\t// Update files\n\t// Change permissions/owners\n\t// Run post-update commands\n\texitStatus := 0\n\tfor _, action := range actions {\n\t\tlog.Printf(\"[INFO] running '%s'\\n\", action.Command)\n\n\t\terr = action.Perform()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[ERROR] performing action %s: %s\\n\", action, err)\n\t\t\texitStatus = 1\n\t\t\tbreak\n\t\t}\n\t}\n\n\tlog.Printf(\"[INFO] pets run took %v\\n\", time.Since(startTime).Round(time.Millisecond))\n\n\tos.Exit(exitStatus)\n}\n"
  },
  {
    "path": "main_test.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestParseFlags(t *testing.T) {\n\tconfDir, debug, dryRun := ParseFlags()\n\tif len(confDir) == 0 {\n\t\tt.Errorf(\"ParseFlags() returned a empty confDir\")\n\t}\n\tassertEquals(t, debug, false)\n\tassertEquals(t, dryRun, false)\n}\n\nfunc TestGetLogFilter(t *testing.T) {\n\tfilter := GetLogFilter(true)\n\tassertEquals(t, string(filter.MinLevel), \"DEBUG\")\n\n\tfilter = GetLogFilter(false)\n\tassertEquals(t, string(filter.MinLevel), \"INFO\")\n}\n"
  },
  {
    "path": "manpage.adoc",
    "content": "= pets(1)\nEmanuele Rocca\nv1.0.0\n:doctype: manpage\n:manmanual: PETS\n:mansource: PETS\n:man-linkstyle: pass:[blue R < >]\n\n== Name\n\npets - configuration management system for pets, not cattle\n\n== Synopsis\n\n*pets* [_OPTION_]...\n\n== Options\n\n*-conf-dir*=_DIR_::\n  Read pets configuration from _DIR_.\n\n*-debug*::\n  Show debugging output.\n\n*-dry-run*::\n  Only show changes without applying them.\n\n== Configuration Example\n\nA pets configuration file setting up a minimal vimrc for root:\n\n----\n# pets: destfile=/root/.vimrc\n# pets: package=vim\n\nsyntax on\nset background=light\n----\n\n== Directives\nConfiguration directives are passed as key/value arguments, either on multiple\nlines or separated by commas. The full list of supported directives is:\n\n- destfile -- where to install this file. One of either *destfile* or *symlink* must be specified.\n- symlink -- create a symbolic link to this file, instead of copying it like *destfile* would.\n- owner -- the file owner, passed to chown(1)\n- group -- the group this file belongs to, passed to chgrp(1)\n- mode -- octal mode for chmod(1)\n- package -- which package to install before creating the file. This\n  directive can be specificed more than once to install multiple packages.\n- pre -- validation command. This must succeed for the file to be\n  created / updated.\n- post -- apply command. Usually something like reloading a service.\n\n== Exit status\n\n*0*::\n  Success.\n  Everything went according to the plan.\n\n*1*::\n  Failure.\n  An important error occurred.\n\n== Resources\n\n*Project web site:* https://github.com/ema/pets\n\n== Copying\n\nCopyright (C) 2022 {author}. +\nFree use of this software is granted under the terms of the MIT License.\n"
  },
  {
    "path": "package.go",
    "content": "// 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 represents a distribution package.\ntype PetsPackage string\n\n// PackageManager available on the system. APT on Debian-based distros, YUM on\n// RedHat and derivatives.\ntype PackageManager int\n\nconst (\n\tAPT = iota\n\tYUM\n\tAPK\n\tYAY\n\tPACMAN\n)\n\n// WhichPackageManager is available on the system\nfunc WhichPackageManager() PackageManager {\n\tvar err error\n\n\tapt := NewCmd([]string{\"apt\", \"--help\"})\n\t_, _, err = RunCmd(apt)\n\tif err == nil {\n\t\treturn APT\n\t}\n\n\tyum := NewCmd([]string{\"yum\", \"--help\"})\n\t_, _, err = RunCmd(yum)\n\tif err == nil {\n\t\treturn YUM\n\t}\n\n\tapk := NewCmd([]string{\"apk\", \"--version\"})\n\t_, _, err = RunCmd(apk)\n\tif err == nil {\n\t\treturn APK\n\t}\n\n\t// Yay has to be first because yay wraps pacman\n\tyay := NewCmd([]string{\"yay\", \"--version\"})\n\tif _, _, err = RunCmd(yay); err == nil {\n\t\treturn YAY\n\t}\n\n\tpacman := NewCmd([]string{\"pacman\", \"--version\"})\n\tif _, _, err = RunCmd(pacman); err == nil {\n\t\treturn PACMAN\n\t}\n\n\tpanic(\"Unknown Package Manager\")\n}\n\nfunc (pp PetsPackage) getPkgInfo() string {\n\tvar pkgInfo *exec.Cmd\n\n\tswitch WhichPackageManager() {\n\tcase APT:\n\t\tpkgInfo = NewCmd([]string{\"apt-cache\", \"policy\", string(pp)})\n\tcase YUM:\n\t\tpkgInfo = NewCmd([]string{\"yum\", \"info\", string(pp)})\n\tcase APK:\n\t\tpkgInfo = NewCmd([]string{\"apk\", \"search\", \"-e\", string(pp)})\n\tcase PACMAN:\n\t\tpkgInfo = NewCmd([]string{\"pacman\", \"-Si\", string(pp)})\n\tcase YAY:\n\t\tpkgInfo = NewCmd([]string{\"yay\", \"-Si\", string(pp)})\n\t}\n\n\tstdout, _, err := RunCmd(pkgInfo)\n\n\tif err != nil {\n\t\tlog.Printf(\"[ERROR] pkgInfoPolicy() command %s failed: %s\\n\", pkgInfo, err)\n\t\treturn \"\"\n\t}\n\n\treturn stdout\n}\n\n// IsValid returns true if the given PetsPackage is available in the distro.\nfunc (pp PetsPackage) IsValid() bool {\n\tstdout := pp.getPkgInfo()\n\tfamily := WhichPackageManager()\n\n\tif family == APT && strings.HasPrefix(stdout, string(pp)) {\n\t\t// Return true if the output of apt-cache policy begins with pp\n\t\tlog.Printf(\"[DEBUG] %s is a valid package name\\n\", pp)\n\t\treturn true\n\t}\n\n\tif family == YUM {\n\t\tfor _, line := range strings.Split(stdout, \"\\n\") {\n\t\t\tline = strings.TrimSpace(line)\n\t\t\tpkgName := strings.SplitN(line, \": \", 2)\n\t\t\tif len(pkgName) == 2 {\n\t\t\t\tif strings.TrimSpace(pkgName[0]) == \"Name\" {\n\t\t\t\t\treturn pkgName[1] == string(pp)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif family == APK && strings.HasPrefix(stdout, string(pp)) {\n\t\t// Return true if the output of apk search -e begins with pp\n\t\tlog.Printf(\"[DEBUG] %s is a valid package name\\n\", pp)\n\t\treturn true\n\t}\n\n\tif (family == PACMAN || family == YAY) && !strings.HasPrefix(stdout, \"error:\") {\n\t\t// Return true if the output of pacman -Si doesnt begins with error\n\t\tlog.Printf(\"[DEBUG] %s is a valid package name\\n\", pp)\n\t\treturn true\n\t}\n\n\tlog.Printf(\"[ERROR] %s is not an available package\\n\", pp)\n\treturn false\n}\n\n// IsInstalled returns true if the given PetsPackage is installed on the\n// system.\nfunc (pp PetsPackage) IsInstalled() bool {\n\tfamily := WhichPackageManager()\n\n\tif family == APT {\n\t\tstdout := pp.getPkgInfo()\n\t\tfor _, line := range strings.Split(stdout, \"\\n\") {\n\t\t\tline = strings.TrimSpace(line)\n\t\t\tif strings.HasPrefix(line, \"Installed: \") {\n\t\t\t\tversion := strings.SplitN(line, \": \", 2)\n\t\t\t\treturn version[1] != \"(none)\"\n\t\t\t}\n\t\t}\n\n\t\tlog.Printf(\"[ERROR] no 'Installed:' line in apt-cache policy %s\\n\", pp)\n\t\treturn false\n\t}\n\n\tif family == YUM {\n\t\tinstalled := NewCmd([]string{\"rpm\", \"-qa\", string(pp)})\n\t\tstdout, _, err := RunCmd(installed)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[ERROR] running %s: '%s'\", installed, err)\n\t\t\treturn false\n\t\t}\n\t\treturn len(stdout) > 0\n\t}\n\n\tif family == APK {\n\t\tinstalled := NewCmd([]string{\"apk\", \"info\", \"-e\", string(pp)})\n\t\tstdout, _, err := RunCmd(installed)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[ERROR] running %s: '%s'\\n\", installed, err)\n\t\t\treturn false\n\t\t}\n\n\t\t// apk info -e $pkg prints the package name to stdout if the package is\n\t\t// installed, nothing otherwise\n\t\treturn strings.TrimSpace(stdout) == string(pp)\n\t}\n\n\tif family == PACMAN || family == YAY {\n\t\tinstalled := NewCmd([]string{\"pacman\", \"-Q\", string(pp)})\n\t\tif family == YAY {\n\t\t\tinstalled = NewCmd([]string{\"yay\", \"-Q\", string(pp)})\n\t\t}\n\t\t// pacman and yay will return 0 if the package is installed 1 if not\n\t\tif _, _, err := RunCmd(installed); err != nil {\n\t\t\tif exitError, ok := err.(*exec.ExitError); ok {\n\t\t\t\treturn exitError.ExitCode() == 0\n\t\t\t}\n\t\t\tlog.Printf(\"[ERROR] running %s: '%s'\", installed, err)\n\t\t\treturn false\n\t\t}\n\t\treturn true\n\t}\n\treturn false\n}\n\n// InstallCommand returns the command needed to install packages on this\n// system.\nfunc InstallCommand() *exec.Cmd {\n\tswitch WhichPackageManager() {\n\tcase APT:\n\t\tcmd := NewCmd([]string{\"apt-get\", \"-y\", \"install\"})\n\t\tcmd.Env = os.Environ()\n\t\tcmd.Env = append(cmd.Env, \"DEBIAN_FRONTEND=noninteractive\")\n\t\treturn cmd\n\tcase YUM:\n\t\treturn NewCmd([]string{\"yum\", \"-y\", \"install\"})\n\tcase APK:\n\t\treturn NewCmd([]string{\"apk\", \"add\"})\n\tcase PACMAN:\n\t\treturn NewCmd([]string{\"pacman\", \"-S\", \"--noconfirm\"})\n\tcase YAY:\n\t\treturn NewCmd([]string{\"yay\", \"-S\", \"--noconfirm\"})\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "package_test.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestPkgIsValid(t *testing.T) {\n\tpkg := PetsPackage(\"coreutils\")\n\tassertEquals(t, pkg.IsValid(), true)\n}\n\nfunc TestPkgIsNotValid(t *testing.T) {\n\tpkg := PetsPackage(\"obviously-this-cannot-be valid ?\")\n\tassertEquals(t, pkg.IsValid(), false)\n}\n\nfunc TestIsInstalled(t *testing.T) {\n\tpkg := PetsPackage(\"binutils\")\n\tassertEquals(t, pkg.IsInstalled(), true)\n}\n\nfunc TestIsNotInstalled(t *testing.T) {\n\tpkg := PetsPackage(\"abiword\")\n\tassertEquals(t, pkg.IsInstalled(), false)\n\n\tpkg = PetsPackage(\"this is getting ridiculous\")\n\tassertEquals(t, pkg.IsInstalled(), false)\n}\n"
  },
  {
    "path": "parser.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n//\n// Pets configuration parser. Walk through a Pets directory and parse\n// modelines.\n\npackage main\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Because it is important to know when enough is enough.\nconst MAXLINES int = 10\n\n// ReadModelines looks into the given file and searches for pets modelines. A\n// modeline is any string which includes the 'pets:' substring. All modelines\n// found are returned as-is in a slice.\nfunc ReadModelines(path string) ([]string, error) {\n\tmodelines := []string{}\n\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tscanner := bufio.NewScanner(file)\n\tscannedLines := 0\n\tfor scanner.Scan() {\n\t\tif scannedLines == MAXLINES {\n\t\t\treturn modelines, nil\n\t\t}\n\n\t\tline := scanner.Text()\n\n\t\tif strings.Contains(line, \"pets:\") {\n\t\t\tmodelines = append(modelines, line)\n\t\t}\n\n\t\tscannedLines += 1\n\t}\n\treturn modelines, nil\n}\n\n// ParseModeline parses a single pets modeline and populates the given PetsFile\n// object. The line should something like:\n// # pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644\nfunc ParseModeline(line string, pf *PetsFile) error {\n\t// We just ignore and throw away anything before the 'pets:' modeline\n\t// identifier\n\tre, err := regexp.Compile(\"pets:(.*)\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tmatches := re.FindStringSubmatch(line)\n\n\tif len(matches) < 2 {\n\t\t// We thought this was a pets modeline -- but then it turned out to be\n\t\t// something different, very different indeed.\n\t\treturn fmt.Errorf(\"[ERROR] invalid pets modeline: %v\", line)\n\t}\n\n\tcomponents := strings.Split(matches[1], \",\")\n\tfor _, comp := range components {\n\t\t// Ignore whitespace\n\t\telem := strings.TrimSpace(comp)\n\t\tif len(elem) == 0 || elem == \"\\t\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tkeyword, argument, found := strings.Cut(elem, \"=\")\n\n\t\t// Just in case something bad should happen\n\t\tbadKeyword := fmt.Errorf(\"[ERROR] invalid keyword/argument '%v'\", elem)\n\n\t\tif !found {\n\t\t\treturn badKeyword // See? :(\n\t\t}\n\n\t\tswitch keyword {\n\t\tcase \"destfile\":\n\t\t\tpf.AddDest(argument)\n\t\tcase \"symlink\":\n\t\t\tpf.AddLink(argument)\n\t\tcase \"owner\":\n\t\t\terr = pf.AddUser(argument)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[ERROR] unknown 'owner' %s, skipping directive\\n\", argument)\n\t\t\t}\n\t\tcase \"group\":\n\t\t\terr = pf.AddGroup(argument)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[ERROR] unknown 'group' %s, skipping directive\\n\", argument)\n\t\t\t}\n\t\tcase \"mode\":\n\t\t\tpf.AddMode(argument)\n\t\tcase \"package\":\n\t\t\t// haha gotcha this one has no setter\n\t\t\tpf.Pkgs = append(pf.Pkgs, PetsPackage(argument))\n\t\tcase \"pre\":\n\t\t\tpf.AddPre(argument)\n\t\tcase \"post\":\n\t\t\tpf.AddPost(argument)\n\t\tdefault:\n\t\t\treturn badKeyword\n\t\t}\n\n\t\t// :)\n\t\t//log.Printf(\"[DEBUG] keyword '%v', argument '%v'\\n\", keyword, argument)\n\t}\n\n\treturn nil\n}\n\n// ParseFiles walks the given directory, identifies all configuration files\n// with pets modelines, and returns a list of parsed PetsFile(s).\nfunc ParseFiles(directory string) ([]*PetsFile, error) {\n\tvar petsFiles []*PetsFile\n\n\tlog.Printf(\"[DEBUG] using configuration directory '%s'\\n\", directory)\n\n\terr := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {\n\t\t// This function is called once for each file in the Pets configuration\n\t\t// directory\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\t// Skip directories\n\t\t\treturn nil\n\t\t}\n\n\t\tmodelines, err := ReadModelines(path)\n\t\tif err != nil {\n\t\t\t// Returning the error we stop parsing all other files too. Debatable\n\t\t\t// whether we want to do that here or not. ReadModelines should not\n\t\t\t// fail technically, so it's probably fine to do it. Alternatively, we\n\t\t\t// could just log to stderr and return nil like we do later on for\n\t\t\t// syntax errors.\n\t\t\treturn err\n\t\t}\n\n\t\tif len(modelines) == 0 {\n\t\t\t// Not a Pets file. We don't take it personal though\n\t\t\treturn nil\n\t\t}\n\n\t\tlog.Printf(\"[DEBUG] %d pets modelines found in %s\\n\", len(modelines), path)\n\n\t\t// Instantiate a PetsFile representation. The only thing we know so far\n\t\t// is the source path. Every long journey begins with a single step!\n\t\tpf := NewPetsFile()\n\n\t\t// Get absolute path to the source. Technically we would be fine with a\n\t\t// relative path too, but it's good to remove abiguity. Plus absolute\n\t\t// paths make things easier in case we have to create a symlink.\n\t\tabs, err := filepath.Abs(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tpf.Source = abs\n\n\t\tfor _, line := range modelines {\n\t\t\terr := ParseModeline(line, pf)\n\t\t\tif err != nil {\n\t\t\t\t// Possibly a syntax error, skip the whole file but do not return\n\t\t\t\t// an error! Otherwise all other files will be skipped too.\n\t\t\t\tlog.Println(err) // XXX: log to stderr\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\n\t\tif pf.Dest == \"\" {\n\t\t\t// 'destfile' or 'symlink' are mandatory arguments. If we did not\n\t\t\t// find any, consider it an error.\n\t\t\tlog.Println(fmt.Errorf(\"[ERROR] Neither 'destfile' nor 'symlink' directives found in '%s'\", path))\n\t\t\treturn nil\n\t\t}\n\n\t\tlog.Printf(\"[DEBUG] '%s' pets syntax OK\\n\", path)\n\t\tpetsFiles = append(petsFiles, pf)\n\t\treturn nil\n\t})\n\n\treturn petsFiles, err\n}\n"
  },
  {
    "path": "parser_test.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestReadModelinesFileNotFound(t *testing.T) {\n\tmodelines, err := ReadModelines(\"very-unlikely-to-find-this.txt\")\n\n\tassertError(t, err)\n\n\tif modelines != nil {\n\t\tt.Errorf(\"Expecting nil modelines, got %v instead\", modelines)\n\t}\n}\n\nfunc TestReadModelinesZero(t *testing.T) {\n\tmodelines, err := ReadModelines(\"README.adoc\")\n\tassertNoError(t, err)\n\tassertEquals(t, len(modelines), 0)\n}\n\nfunc TestReadModelinesNonZero(t *testing.T) {\n\tmodelines, err := ReadModelines(\"sample_pet/ssh/user_ssh_config\")\n\tassertNoError(t, err)\n\tassertEquals(t, len(modelines), 2)\n}\n\nfunc TestParseModelineErr(t *testing.T) {\n\tvar pf PetsFile\n\terr := ParseModeline(\"\", &pf)\n\tassertError(t, err)\n}\n\nfunc TestParseModelineBadKeyword(t *testing.T) {\n\tvar pf PetsFile\n\terr := ParseModeline(\"# pets: something=funny\", &pf)\n\tassertError(t, err)\n}\n\nfunc TestParseModelineOKDestfile(t *testing.T) {\n\tvar pf PetsFile\n\terr := ParseModeline(\"# pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644\", &pf)\n\tassertNoError(t, err)\n\n\tassertEquals(t, pf.Dest, \"/etc/ssh/sshd_config\")\n\tassertEquals(t, pf.User.Uid, \"0\")\n\tassertEquals(t, pf.Group.Gid, \"0\")\n\tassertEquals(t, pf.Mode, \"0644\")\n\tassertEquals(t, pf.Link, false)\n}\n\nfunc TestParseModelineOKSymlink(t *testing.T) {\n\tvar pf PetsFile\n\terr := ParseModeline(\"# pets: symlink=/etc/ssh/sshd_config\", &pf)\n\tassertNoError(t, err)\n\n\tassertEquals(t, pf.Dest, \"/etc/ssh/sshd_config\")\n\tassertEquals(t, pf.Link, true)\n}\n\nfunc TestParseModelineOKPackage(t *testing.T) {\n\tvar pf PetsFile\n\terr := ParseModeline(\"# pets: package=vim\", &pf)\n\tassertNoError(t, err)\n\n\tassertEquals(t, pf.Dest, \"\")\n\tassertEquals(t, string(pf.Pkgs[0]), \"vim\")\n}\n"
  },
  {
    "path": "planner.go",
    "content": "// 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// PetsCause conveys the reason behind a given action.\ntype PetsCause int\n\nconst (\n\tNONE   = iota // no reason at all\n\tPKG           // required package is missing\n\tCREATE        // configuration file is missing and needs to be created\n\tUPDATE        // configuration file differs and needs to be updated\n\tLINK          // symbolic link needs to be created\n\tDIR           // directory needs to be created\n\tOWNER         // needs chown()\n\tMODE          // needs chmod()\n\tPOST          // post-update command\n)\n\nfunc (pc PetsCause) String() string {\n\treturn map[PetsCause]string{\n\t\tPKG:    \"PACKAGE_INSTALL\",\n\t\tCREATE: \"FILE_CREATE\",\n\t\tUPDATE: \"FILE_UPDATE\",\n\t\tLINK:   \"LINK_CREATE\",\n\t\tDIR:    \"DIR_CREATE\",\n\t\tOWNER:  \"OWNER\",\n\t\tMODE:   \"CHMOD\",\n\t\tPOST:   \"POST_UPDATE\",\n\t}[pc]\n}\n\n// A PetsAction represents something to be done, namely running a certain\n// Command. PetsActions exist because of some Trigger, which is a PetsFile.\ntype PetsAction struct {\n\tCause   PetsCause\n\tCommand *exec.Cmd\n\tTrigger *PetsFile\n}\n\n// String representation of a PetsAction\nfunc (pa *PetsAction) String() string {\n\tif pa.Trigger != nil {\n\t\treturn fmt.Sprintf(\"[%s] %s triggered command: '%s'\", pa.Cause, pa.Trigger.Source, pa.Command)\n\t} else {\n\t\treturn fmt.Sprintf(\"[%s] triggered command: '%s'\", pa.Cause, pa.Command)\n\t}\n}\n\n// Perform executes the Command\nfunc (pa *PetsAction) Perform() error {\n\tstdout, stderr, err := RunCmd(pa.Command)\n\n\tif err != nil {\n\t\tlog.Printf(\"[ERROR] running Perform() -> %v\\n\", err)\n\t}\n\n\tif len(stdout) > 0 {\n\t\tlog.Printf(\"[INFO] stdout from Perform() -> %v\\n\", stdout)\n\t}\n\n\tif len(stderr) > 0 {\n\t\tlog.Printf(\"[ERROR] stderr from Perform() -> %v\\n\", stderr)\n\t}\n\n\treturn err\n}\n\n// PkgsToInstall returns two values, a boolean and a command. The former is\n// true if there are any new packages to install, the latter is the\n// distro-specific command to run to install the packages.\nfunc PkgsToInstall(triggers []*PetsFile) (bool, *exec.Cmd) {\n\tinstallPkgs := false\n\tinstallCmd := InstallCommand()\n\n\tfor _, trigger := range triggers {\n\t\tfor _, pkg := range trigger.Pkgs {\n\t\t\tif SliceContains(installCmd.Args, string(pkg)) {\n\t\t\t\tlog.Printf(\"[DEBUG] %s already marked to be installed\\n\", pkg)\n\t\t\t} else if pkg.IsInstalled() {\n\t\t\t\tlog.Printf(\"[DEBUG] %s already installed\\n\", pkg)\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[INFO] %s not installed\\n\", pkg)\n\t\t\t\tinstallCmd.Args = append(installCmd.Args, string(pkg))\n\t\t\t\tinstallPkgs = true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn installPkgs, installCmd\n}\n\n// FileToCopy figures out if the given trigger represents a file that needs to\n// be updated, and returns the corresponding PetsAction.\nfunc FileToCopy(trigger *PetsFile) *PetsAction {\n\tif trigger.Link {\n\t\treturn nil\n\t}\n\n\tcause := trigger.NeedsCopy()\n\n\tif cause == NONE {\n\t\treturn nil\n\t} else {\n\t\treturn &PetsAction{\n\t\t\tCause:   cause,\n\t\t\tCommand: NewCmd([]string{\"/bin/cp\", trigger.Source, trigger.Dest}),\n\t\t\tTrigger: trigger,\n\t\t}\n\t}\n}\n\n// LinkToCreate figures out if the given trigger represents a symbolic link\n// that needs to be created, and returns the corresponding PetsAction.\nfunc LinkToCreate(trigger *PetsFile) *PetsAction {\n\tif !trigger.Link {\n\t\treturn nil\n\t}\n\n\tcause := trigger.NeedsLink()\n\n\tif cause == NONE {\n\t\treturn nil\n\t} else {\n\t\treturn &PetsAction{\n\t\t\tCause:   cause,\n\t\t\tCommand: NewCmd([]string{\"/bin/ln\", \"-s\", trigger.Source, trigger.Dest}),\n\t\t\tTrigger: trigger,\n\t\t}\n\t}\n}\n\n// DirToCreate figures out if the given trigger represents a directory that\n// needs to be created, and returns the corresponding PetsAction.\nfunc DirToCreate(trigger *PetsFile) *PetsAction {\n\tif trigger.Directory == \"\" {\n\t\treturn nil\n\t}\n\n\tcause := trigger.NeedsDir()\n\n\tif cause == NONE {\n\t\treturn nil\n\t} else {\n\t\treturn &PetsAction{\n\t\t\tCause:   cause,\n\t\t\tCommand: NewCmd([]string{\"/bin/mkdir\", \"-p\", trigger.Directory}),\n\t\t\tTrigger: trigger,\n\t\t}\n\t}\n}\n\n// Chown returns a chown PetsAction or nil if none is needed.\nfunc Chown(trigger *PetsFile) *PetsAction {\n\t// Build arg (eg: 'root:staff', 'root', ':staff')\n\targ := \"\"\n\tvar wantUid, wantGid int\n\tvar err error\n\n\tif trigger.User != nil {\n\t\targ = trigger.User.Username\n\n\t\t// get the requested uid as integer\n\t\twantUid, err = strconv.Atoi(trigger.User.Uid)\n\t\tif err != nil {\n\t\t\t// This should really never ever happen, unless we're\n\t\t\t// running on Windows. :)\n\t\t\tpanic(err)\n\t\t}\n\t}\n\n\tif trigger.Group != nil {\n\t\targ = fmt.Sprintf(\"%s:%s\", arg, trigger.Group.Name)\n\n\t\t// get the requested gid as integer\n\t\twantGid, err = strconv.Atoi(trigger.Group.Gid)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n\n\tif arg == \"\" {\n\t\t// Return immediately if the file had no 'owner' / 'group' directives\n\t\treturn nil\n\t}\n\n\t// The action to (possibly) perform is a chown of the file.\n\taction := &PetsAction{\n\t\tCause:   OWNER,\n\t\tCommand: NewCmd([]string{\"/bin/chown\", arg, trigger.Dest}),\n\t\tTrigger: trigger,\n\t}\n\n\t// stat(2) the destination file to see if a chown is needed\n\tfileInfo, err := os.Stat(trigger.Dest)\n\tif os.IsNotExist(err) {\n\t\t// If the destination file is not there yet, prepare a chown\n\t\t// for later on.\n\t\treturn action\n\t}\n\n\tstat, _ := fileInfo.Sys().(*syscall.Stat_t)\n\n\tif trigger.User != nil && int(stat.Uid) != wantUid {\n\t\tlog.Printf(\"[INFO] %s is owned by uid %d instead of %s\\n\", trigger.Dest, stat.Uid, trigger.User.Username)\n\t\treturn action\n\t}\n\n\tif trigger.Group != nil && int(stat.Gid) != wantGid {\n\t\tlog.Printf(\"[INFO] %s is owned by gid %d instead of %s\\n\", trigger.Dest, stat.Gid, trigger.Group.Name)\n\t\treturn action\n\t}\n\n\tlog.Printf(\"[DEBUG] %s is owned by %d:%d already\\n\", trigger.Dest, stat.Uid, stat.Gid)\n\treturn nil\n}\n\n// Chmod returns a chmod PetsAction or nil if none is needed.\nfunc Chmod(trigger *PetsFile) *PetsAction {\n\tif trigger.Mode == \"\" {\n\t\t// Return immediately if the 'mode' directive was not specified.\n\t\treturn nil\n\t}\n\n\t// The action to (possibly) perform is a chmod of the file.\n\taction := &PetsAction{\n\t\tCause:   MODE,\n\t\tCommand: NewCmd([]string{\"/bin/chmod\", trigger.Mode, trigger.Dest}),\n\t\tTrigger: trigger,\n\t}\n\n\t// stat(2) the destination file to see if a chmod is needed\n\tfileInfo, err := os.Stat(trigger.Dest)\n\tif os.IsNotExist(err) {\n\t\t// If the destination file is not there yet, prepare a mod\n\t\t// for later on.\n\t\treturn action\n\t}\n\n\t// See if the desired mode and reality differ.\n\tnewMode, err := StringToFileMode(trigger.Mode)\n\tif err != nil {\n\t\tlog.Println(\"[ERROR] unexpected error in Chmod()\", err)\n\t\treturn nil\n\t}\n\n\toldMode := fileInfo.Mode()\n\n\tif oldMode != newMode {\n\t\tlog.Printf(\"[INFO] %s is %s instead of %s\\n\", trigger.Dest, oldMode, newMode)\n\t\treturn action\n\t}\n\n\tlog.Printf(\"[DEBUG] %s is %s already\\n\", trigger.Dest, newMode)\n\treturn nil\n}\n\n// NewPetsActions is the []PetsFile -> []PetsAction constructor.  Given a slice\n// of PetsFile(s), generate a list of PetsActions to perform.\nfunc NewPetsActions(triggers []*PetsFile) []*PetsAction {\n\tactions := []*PetsAction{}\n\n\t// First, install all needed packages. Build a list of all missing package\n\t// names first, and then install all of them in one go. This is to avoid\n\t// embarassing things like running in a loop apt install pkg1 ; apt install\n\t// pkg2 ; apt install pkg3 like some configuration management systems do.\n\tif installPkgs, installCmd := PkgsToInstall(triggers); installPkgs {\n\t\tactions = append(actions, &PetsAction{\n\t\t\tCause:   PKG,\n\t\t\tCommand: installCmd,\n\t\t})\n\t}\n\n\tfor _, trigger := range triggers {\n\t\tactionFired := false\n\n\t\t// Any directory to create\n\t\tif dirAction := DirToCreate(trigger); dirAction != nil {\n\t\t\tactions = append(actions, dirAction)\n\t\t\tactionFired = true\n\t\t}\n\n\t\t// Then, figure out which files need to be modified/created.\n\t\tif fileAction := FileToCopy(trigger); fileAction != nil {\n\t\t\tactions = append(actions, fileAction)\n\t\t\tactionFired = true\n\t\t}\n\n\t\t// Any symlink to create\n\t\tif linkAction := LinkToCreate(trigger); linkAction != nil {\n\t\t\tactions = append(actions, linkAction)\n\t\t\tactionFired = true\n\t\t}\n\n\t\t// Any owner changes needed\n\t\tif chown := Chown(trigger); chown != nil {\n\t\t\tactions = append(actions, chown)\n\t\t\tactionFired = true\n\t\t}\n\n\t\t// Any mode changes needed\n\t\tif chmod := Chmod(trigger); chmod != nil {\n\t\t\tactions = append(actions, chmod)\n\t\t\tactionFired = true\n\t\t}\n\n\t\t// Finally, post-update commands\n\t\tif trigger.Post != nil && actionFired {\n\t\t\tactions = append(actions, &PetsAction{\n\t\t\t\tCause:   POST,\n\t\t\t\tCommand: trigger.Post,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn actions\n}\n"
  },
  {
    "path": "planner_test.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n\npackage main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestPkgsToInstall(t *testing.T) {\n\t// Test with empty slice of PetsFiles\n\tpetsFiles := []*PetsFile{}\n\tisTodo, _ := PkgsToInstall(petsFiles)\n\tassertEquals(t, isTodo, false)\n\n\t// Test with one package already installed\n\tpf, err := NewTestFile(\"/dev/null\", \"binutils\", \"/etc/passwd\", \"root\", \"root\", \"0640\", \"\", \"\")\n\tassertNoError(t, err)\n\n\tpetsFiles = append(petsFiles, pf)\n\tisTodo, _ = PkgsToInstall(petsFiles)\n\tassertEquals(t, isTodo, false)\n\n\t// Add another package to the mix, this time it's not installed\n\tpetsFiles[0].Pkgs = append(petsFiles[0].Pkgs, PetsPackage(\"abiword\"))\n\tisTodo, _ = PkgsToInstall(petsFiles)\n\tassertEquals(t, isTodo, true)\n}\n\nfunc TestFileToCopy(t *testing.T) {\n\tpf, err := NewTestFile(\"sample_pet/ssh/sshd_config\", \"ssh\", \"sample_pet/ssh/sshd_config\", \"root\", \"root\", \"0640\", \"\", \"\")\n\tassertNoError(t, err)\n\n\tpa := FileToCopy(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\tpf, err = NewTestFile(\"sample_pet/ssh/sshd_config\", \"ssh\", \"/tmp/polpette\", \"root\", \"root\", \"0640\", \"\", \"\")\n\tassertNoError(t, err)\n\n\tpa = FileToCopy(pf)\n\tif pa == nil {\n\t\tt.Errorf(\"Expecting a PetsAction, got nil instead\")\n\t}\n\n\tassertEquals(t, pa.Cause.String(), \"FILE_CREATE\")\n\n\tpf, err = NewTestFile(\"sample_pet/ssh/sshd_config\", \"ssh\", \"sample_pet/ssh/user_ssh_config\", \"root\", \"root\", \"0640\", \"\", \"\")\n\tassertNoError(t, err)\n\n\tpa = FileToCopy(pf)\n\tif pa == nil {\n\t\tt.Errorf(\"Expecting a PetsAction, got nil instead\")\n\t}\n\n\tassertEquals(t, pa.Cause.String(), \"FILE_UPDATE\")\n}\n\nfunc TestChmod(t *testing.T) {\n\t// Expect Chmod() to return nil if the 'mode' directive is missing.\n\tpf := NewPetsFile()\n\tpf.Source = \"/dev/null\"\n\tpf.Dest = \"/dev/null\"\n\n\tpa := Chmod(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\tpf.AddMode(\"0644\")\n\n\tpa = Chmod(pf)\n\n\tassertEquals(t, pa.Cause.String(), \"CHMOD\")\n\tassertEquals(t, pa.Command.String(), \"/bin/chmod 0644 /dev/null\")\n\n\tpf.Dest = \"/etc/passwd\"\n\tpa = Chmod(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n}\n\nfunc TestChown(t *testing.T) {\n\tpf := NewPetsFile()\n\tpf.Source = \"/dev/null\"\n\tpf.Dest = \"/etc/passwd\"\n\n\t// If no 'user' or 'group' directives are specified\n\tpa := Chown(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\t// File owned by 'root:root' already\n\tpf.AddUser(\"root\")\n\tpf.AddGroup(\"root\")\n\tpa = Chown(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\tpf.AddUser(\"nobody\")\n\tpa = Chown(pf)\n\tif pa == nil {\n\t\tt.Errorf(\"Expecting some action, got nil instead\")\n\t}\n\n\tassertEquals(t, pa.Cause.String(), \"OWNER\")\n\tassertEquals(t, pa.Command.String(), \"/bin/chown nobody:root /etc/passwd\")\n}\n\nfunc TestLn(t *testing.T) {\n\tpf := NewPetsFile()\n\tpf.Source = \"sample_pet/vimrc\"\n\n\t// Link attribute and Dest not set\n\tpa := LinkToCreate(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\t// Destination already exists\n\tpf.AddLink(\"/etc/passwd\")\n\n\tpa = LinkToCreate(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\t// Happy path, destination does not exist yet\n\tpf.AddLink(\"/tmp/vimrc\")\n\n\tpa = LinkToCreate(pf)\n\tif pa == nil {\n\t\tt.Errorf(\"Expecting some action, got nil instead\")\n\t}\n\n\tassertEquals(t, pa.Cause.String(), \"LINK_CREATE\")\n\tassertEquals(t, pa.Command.String(), \"/bin/ln -s sample_pet/vimrc /tmp/vimrc\")\n}\n\nfunc TestMkdir(t *testing.T) {\n\tpf := NewPetsFile()\n\n\tpa := DirToCreate(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\tpf.Directory = \"/etc\"\n\n\tpa = DirToCreate(pf)\n\tif pa != nil {\n\t\tt.Errorf(\"Expecting nil, got %v instead\", pa)\n\t}\n\n\tpf.Directory = \"/etc/polpette/al/sugo\"\n\n\tpa = DirToCreate(pf)\n\tif pa == nil {\n\t\tt.Errorf(\"Expecting some action, got nil instead\")\n\t}\n\n\tassertEquals(t, pa.Cause.String(), \"DIR_CREATE\")\n\tassertEquals(t, pa.Command.String(), \"/bin/mkdir -p /etc/polpette/al/sugo\")\n}\n"
  },
  {
    "path": "sample_pet/README",
    "content": "Pets configuration examples.\n"
  },
  {
    "path": "sample_pet/cron/certbot",
    "content": "# pets: destfile=/etc/cron.d/certbot, owner=root, group=root, mode=640\n# pets: package=certbot\n# pets: package=cron\n\nSHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n0 */12 * * * root test -x /usr/bin/certbot -a \\! -d /run/systemd/system && perl -e 'sleep int(rand(43200))' && certbot -q renew\n"
  },
  {
    "path": "sample_pet/cron/mdadm",
    "content": "# pets: destfile=/etc/cron.d/mdadm, owner=root, group=root, mode=640\n# pets: package=cron\n# pets: package=mdadm\n\n57 0 * * 0 root if [ -x /usr/share/mdadm/checkarray ] && [ $(date +\\%d) -le 7 ]; then /usr/share/mdadm/checkarray --cron --all --idle --quiet; fi\n"
  },
  {
    "path": "sample_pet/ssh/sshd_config",
    "content": "# pets: destfile=/etc/ssh/sshd_config, owner=root, group=root, mode=0644\n# pets: package=ssh\n# pets: pre=/usr/sbin/sshd -t -f\n# pets: post=/bin/systemctl reload ssh.service\n#\n# Warning! This file has been generated by pets(1). Any manual modification\n# will be lost.\n\nPort 22\nProtocol 2\nHostKey /etc/ssh/ssh_host_rsa_key\nHostKey /etc/ssh/ssh_host_dsa_key\nHostKey /etc/ssh/ssh_host_ecdsa_key\nHostKey /etc/ssh/ssh_host_ed25519_key\n\n# Change to yes to enable challenge-response passwords (beware issues with\n# some PAM modules and threads)\nChallengeResponseAuthentication no\n\n# Change to no to disable tunnelled clear text passwords\nPasswordAuthentication no\n\nX11Forwarding yes\n\n# Allow client to pass locale environment variables\nAcceptEnv LANG LC_*\n\nSubsystem sftp /usr/lib/openssh/sftp-server\n\nUsePAM yes\n"
  },
  {
    "path": "sample_pet/ssh/user_ssh_config",
    "content": "# pets: destfile=/home/ema/.ssh/config, owner=ema, group=ema, mode=0644\n# pets: package=openssh-client\n#\n# Warning! This file has been generated by pets(1). Any manual modification\n# will be lost.\n\nForwardAgent no\nUseRoaming no\n\nVisualHostKey yes\nVerifyHostKeyDNS ask\nStrictHostKeyChecking ask\n\nHashKnownHosts no\nUserKnownHostsFile ~/.ssh/known_hosts\n\nPKCS11Provider /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so\n"
  },
  {
    "path": "sample_pet/ssmtp/revaliases",
    "content": "# pets: destfile=/etc/ssmtp/revaliases, owner=root, group=root, mode=0440\n# pets: package=ssmtp\nroot:username@gmail.com:smtp.gmail.com:465\nlocaluser:username@gmail.com:smtp.gmail.com:465\n"
  },
  {
    "path": "sample_pet/ssmtp/ssmtp.conf",
    "content": "# pets: destfile=/etc/ssmtp/ssmtp.conf, owner=root, group=root, mode=0440\n# pets: package=ssmtp\n\n# The user that gets all the mails (UID < 1000, usually the admin)\nroot=username@gmail.com\n\n# The mail server (where the mail is sent to), both port 465 or 587 should be acceptable\n# See also https://support.google.com/mail/answer/78799\nmailhub=smtp.gmail.com:465\n\n# The address where the mail appears to come from for user authentication.\nrewriteDomain=gmail.com\n\n# Use implicit TLS (port 465). When using port 587, change UseSTARTTLS=Yes\nTLS_CA_FILE=/etc/ssl/certs/ca-certificates.crt\nUseTLS=Yes\nUseSTARTTLS=No\n\n# Username/Password\nAuthUser=username\nAuthPass=password\nAuthMethod=LOGIN\n\n# Email 'From header's can override the default domain?\nFromLineOverride=yes\n"
  },
  {
    "path": "sample_pet/sudo/sudo_group",
    "content": "# pets: destfile=/etc/sudoers.d/sudo_group, owner=root, group=root, mode=0440\n# pets: package=sudo\n# pets: pre=/usr/sbin/visudo -cf\n#\n# Warning! This file has been generated by pets(1). Any manual modification\n# will be lost.\n\n# Allow members of group sudo to execute any command\n%sudo   ALL=(ALL:ALL) NOPASSWD:ALL\n"
  },
  {
    "path": "sample_pet/sudo/sudoers",
    "content": "# pets: destfile=/etc/sudoers, owner=root, group=root, mode=0440\n# pets: package=sudo\n# pets: pre=/usr/sbin/visudo -cf\n#\n# Warning! This file has been generated by pets(1). Any manual modification\n# will be lost.\n\nDefaults        env_reset\nDefaults        secure_path=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"\n\nroot    ALL=(ALL:ALL) ALL\n\n# Allow ema to execute any command\nema ALL=(ALL:ALL) NOPASSWD:ALL\n"
  },
  {
    "path": "sample_pet/vimrc",
    "content": "# pets: package=vim\n# pets: symlink=/root/.vimrc\n\nsyntax on\nset expandtab\nset shiftwidth=4\nset tabstop=4\n"
  },
  {
    "path": "sparrow.yaml",
    "content": "image:\n  - melezhik/sparrow:alpine\n  - melezhik/sparrow:debian\n  - melezhik/sparrow:ubuntu\n  - melezhik/sparrow:archlinux\n  \ntasks:\n -\n  name: go_test\n  language: Bash\n  code: |\n    set -e\n    echo \"Run tests for OS: $os ...\"\n    if test \"$os\" = \"ubuntu\" || test \"$os\" = \"debian\" || test \"$os\" = \"arch\" || test \"$os\" = \"archlinux\"; then\n      export PATH=/usr/local/go/bin:$PATH\n    fi\n    go version\n    cd source\n    go test -v\n  default: true\n  depends:\n    -\n      name: go_build\n -\n  name: go_build\n  language: Bash\n  code: |\n    set -e\n    if test \"$os\" = \"ubuntu\" || test \"$os\" = \"debian\" || test \"$os\" = \"arch\" || test \"$os\" = \"archlinux\"; then\n      export PATH=/usr/local/go/bin:$PATH\n    fi\n    go version\n    cd source\n    go build -v\n  depends:\n    -\n      name: install-go\n -\n    name: install-go\n    language: Bash\n    code: |\n      if test $os = \"alpine\"; then\n        sudo apk add go \\\n        --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community\n      else\n        sudo rm -rf /usr/local/go\n        curl -sfL https://go.dev/dl/go1.19.3.linux-amd64.tar.gz -o ~/go1.19.3.linux-amd64.tar.gz\n        sudo tar -C /usr/local -xzf ~/go*.linux-amd64.tar.gz\n      fi\n"
  },
  {
    "path": "util.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n//\n// A bunch of misc helper functions\n\npackage main\n\nimport (\n\t\"bytes\"\n\t\"crypto/sha256\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strconv\"\n\n\t\"testing\"\n)\n\n// NewCmd is a wrapper for exec.Command. It builds a new *exec.Cmd from a slice\n// of strings.\nfunc NewCmd(args []string) *exec.Cmd {\n\tvar cmd *exec.Cmd\n\n\tif len(args) == 1 {\n\t\tcmd = exec.Command(args[0])\n\t} else {\n\t\tcmd = exec.Command(args[0], args[1:]...)\n\t}\n\n\treturn cmd\n}\n\n// RunCmd runs the given command and returns two strings, one with stdout and\n// one with stderr. The error object returned by cmd.Run() is also returned.\nfunc RunCmd(cmd *exec.Cmd) (string, string, error) {\n\tvar outb bytes.Buffer\n\tvar errb bytes.Buffer\n\tcmd.Stdout = &outb\n\tcmd.Stderr = &errb\n\n\terr := cmd.Run()\n\n\treturn outb.String(), errb.String(), err\n}\n\n// Sha256 returns the sha256 of the given file. Shocking, I know.\nfunc Sha256(fileName string) (string, error) {\n\tf, err := os.Open(fileName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer f.Close()\n\n\th := sha256.New()\n\tif _, err := io.Copy(h, f); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%x\", h.Sum(nil)), nil\n}\n\nfunc StringToFileMode(mode string) (os.FileMode, error) {\n\toctalMode, err := strconv.ParseInt(mode, 8, 64)\n\treturn os.FileMode(octalMode), err\n}\n\nfunc SliceContains(slice []string, elem string) bool {\n\tfor _, value := range slice {\n\t\tif value == elem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// Various test helpers\nfunc assertEquals(t *testing.T, a, b interface{}) {\n\tif a != b {\n\t\tt.Errorf(\"%v != %v\", a, b)\n\t}\n}\n\nfunc assertNoError(t *testing.T, err error) {\n\tif err != nil {\n\t\tt.Errorf(\"Expecting err to be nil, got %v instead\", err)\n\t}\n}\n\nfunc assertError(t *testing.T, err error) {\n\tif err == nil {\n\t\tt.Errorf(\"Expecting an error, got nil instead\")\n\t}\n}\n\nfunc NewTestFile(src, pkg, dest, userName, groupName, mode, pre, post string) (*PetsFile, error) {\n\tvar err error\n\n\tp := NewPetsFile()\n\tp.Source = src\n\tp.Pkgs = []PetsPackage{PetsPackage(pkg)}\n\n\tp.AddDest(dest)\n\n\terr = p.AddUser(userName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = p.AddGroup(groupName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = p.AddMode(mode)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tp.AddPre(pre)\n\n\tp.AddPost(post)\n\n\treturn p, nil\n}\n"
  },
  {
    "path": "validator.go",
    "content": "// Copyright (C) 2022 Emanuele Rocca\n//\n// Pets configuration file validator. Given a list of in-memory PetsFile(s),\n// see if our sanity constraints are met. For example, we do not want multiple\n// files to be installed to the same destination path. Also, all validation\n// commands must succeed.\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n)\n\n// CheckGlobalConstraints validates assumptions that must hold across all\n// configuration files.\nfunc CheckGlobalConstraints(files []*PetsFile) error {\n\t// Keep the seen PetsFiles in a map so we can:\n\t// 1) identify and print duplicate sources\n\t// 2) avoid slices.Contains which is only in Go 1.18+ and not even bound to\n\t//    the Go 1 Compatibility Promise™\n\tseen := make(map[string]*PetsFile)\n\n\tfor _, pf := range files {\n\t\tother, exist := seen[pf.Dest]\n\t\tif exist {\n\t\t\treturn fmt.Errorf(\"[ERROR] duplicate definition for '%s': '%s' and '%s'\\n\", pf.Dest, pf.Source, other.Source)\n\t\t}\n\t\tseen[pf.Dest] = pf\n\t}\n\n\treturn nil\n}\n\n// runPre returns true if the pre-update validation command passes, or if it\n// was not specificed at all. The boolean argument pathErrorOK controls whether\n// or not we want to fail if the validation command is not around.\nfunc runPre(pf *PetsFile, pathErrorOK bool) bool {\n\tif pf.Pre == nil {\n\t\treturn true\n\t}\n\n\t// Some optimism.\n\ttoReturn := true\n\n\t// Run 'pre' validation command, append Source filename to\n\t// arguments.\n\t// eg: /usr/sbin/sshd -t -f sample_pet/ssh/sshd_config\n\tpf.Pre.Args = append(pf.Pre.Args, pf.Source)\n\n\tstdout, stderr, err := RunCmd(pf.Pre)\n\n\t_, pathError := err.(*fs.PathError)\n\n\tif err == nil {\n\t\tlog.Printf(\"[INFO] pre-update command %s successful\\n\", pf.Pre.Args)\n\t} else if pathError && pathErrorOK {\n\t\t// The command has failed because the validation command itself is\n\t\t// missing. This could be a chicken-and-egg problem: at this stage\n\t\t// configuration is not validated yet, hence any \"package\" directives\n\t\t// have not been applied.  Do not consider this as a failure, for now.\n\t\tlog.Printf(\"[INFO] pre-update command %s failed due to PathError. Ignoring for now\\n\", pf.Pre.Args)\n\t} else {\n\t\tlog.Printf(\"[ERROR] pre-update command %s: %s\\n\", pf.Pre.Args, err)\n\t\ttoReturn = false\n\t}\n\n\tif len(stdout) > 0 {\n\t\tlog.Printf(\"[INFO] stdout: %s\", stdout)\n\t}\n\n\tif len(stderr) > 0 {\n\t\tlog.Printf(\"[ERROR] stderr: %s\", stderr)\n\t}\n\n\treturn toReturn\n}\n\n// CheckLocalConstraints validates assumptions that must hold for the\n// individual configuration files. An error in one file means we're gonna skip\n// it but proceed with the rest. The function returns a slice of files for\n// which validation passed.\nfunc CheckLocalConstraints(files []*PetsFile, pathErrorOK bool) []*PetsFile {\n\tvar goodPets []*PetsFile\n\n\tfor _, pf := range files {\n\t\tlog.Printf(\"[DEBUG] validating %s\\n\", pf.Source)\n\n\t\tif pf.IsValid(pathErrorOK) {\n\t\t\tlog.Printf(\"[DEBUG] valid configuration file: %s\\n\", pf.Source)\n\t\t\tgoodPets = append(goodPets, pf)\n\t\t} else {\n\t\t\tlog.Printf(\"[ERROR] invalid configuration file: %s\\n\", pf.Source)\n\t\t}\n\t}\n\n\treturn goodPets\n}\n"
  }
]