├── .github ├── FUNDING.yml ├── build ├── run-tests.sh └── workflows │ ├── codeql-analysis.yml │ ├── pull_request.yml │ ├── push.yml │ └── release.yml ├── .gitignore ├── FUZZING.md ├── HACKING.md ├── LICENSE ├── README.md ├── ast ├── ast.go ├── builtin.go ├── builtin_test.go ├── object.go └── object_test.go ├── config ├── config.go └── config_test.go ├── environment ├── environment.go └── environment_test.go ├── examples ├── 433-mhz-temperature-grapher-docker.recipe ├── README.md ├── docker-compose.temperature.yml ├── download.recipe ├── install-go.recipe └── overview.recipe ├── executor ├── executor.go └── executor_test.go ├── file ├── file.go ├── file_chown_unix.go ├── file_chown_windows.go └── file_test.go ├── go.mod ├── go.sum ├── lexer ├── lexer.go └── lexer_test.go ├── main.go ├── modules ├── api.go ├── api_glue.go ├── api_glue_test.go ├── api_test.go ├── module_directory.go ├── module_directory_test.go ├── module_docker.go ├── module_edit.go ├── module_edit_test.go ├── module_fail.go ├── module_fail_test.go ├── module_file.go ├── module_file_test.go ├── module_git.go ├── module_group.go ├── module_group_not_unix.go ├── module_group_unix.go ├── module_http.go ├── module_http_test.go ├── module_link.go ├── module_log.go ├── module_log_test.go ├── module_package.go ├── module_package_test.go ├── module_shell.go ├── module_shell_test.go ├── module_sql.go ├── module_sql_test.go ├── module_user.go ├── module_user_not_unix.go ├── module_user_unix.go └── system │ └── packages.go ├── parser ├── fuzz_test.go ├── parser.go └── parser_test.go ├── token ├── token.go └── token_test.go ├── version.go └── version18.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: skx 3 | custom: https://steve.fi/donate/ 4 | -------------------------------------------------------------------------------- /.github/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # The basename of our binary 4 | BASE="marionette" 5 | 6 | D=$(pwd) 7 | 8 | # I don't even .. 9 | go env -w GOFLAGS="-buildvcs=false" 10 | 11 | # 12 | # We build on multiple platforms/archs 13 | # 14 | BUILD_PLATFORMS="linux darwin freebsd windows" 15 | BUILD_ARCHS="arm64 amd64 386" 16 | 17 | # For each platform 18 | for OS in ${BUILD_PLATFORMS[@]}; do 19 | 20 | # For each arch 21 | for ARCH in ${BUILD_ARCHS[@]}; do 22 | 23 | cd ${D} 24 | 25 | # Setup a suffix for the binary 26 | SUFFIX="${OS}" 27 | 28 | # i386 is better than 386 29 | if [ "$ARCH" = "386" ]; then 30 | SUFFIX="${SUFFIX}-i386" 31 | else 32 | SUFFIX="${SUFFIX}-${ARCH}" 33 | fi 34 | 35 | # Windows binaries should end in .EXE 36 | if [ "$OS" = "windows" ]; then 37 | SUFFIX="${SUFFIX}.exe" 38 | fi 39 | 40 | echo "Building for ${OS} [${ARCH}] -> ${BASE}-${SUFFIX}" 41 | 42 | # Run the build 43 | export GOARCH=${ARCH} 44 | export GOOS=${OS} 45 | export CGO_ENABLED=0 46 | 47 | # Build the main-binary 48 | go build -ldflags "-X main.version=$(git describe --tags 2>/dev/null || echo 'master')" -o "${BASE}-${SUFFIX}" 49 | done 50 | done 51 | -------------------------------------------------------------------------------- /.github/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Failures will cause this script to terminate 4 | set -e 5 | 6 | # I don't even .. 7 | go env -w GOFLAGS="-buildvcs=false" 8 | 9 | # Run the tests 10 | go test -race ./... 11 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 8 * * 0' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['go'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | name: Pull Request 3 | jobs: 4 | golangci: 5 | name: lint 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/setup-go@v2 9 | - uses: actions/checkout@v2 10 | - name: golangci-lint 11 | uses: golangci/golangci-lint-action@v2 12 | with: 13 | args: --timeout 5m0s 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@master 19 | - name: Test 20 | uses: skx/github-action-tester@master 21 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | name: Push Event 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - name: Test 13 | uses: skx/github-action-tester@master 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | name: Handle Release 5 | jobs: 6 | upload: 7 | name: Upload 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout the repository 11 | uses: actions/checkout@master 12 | - name: Generate the artifacts 13 | uses: skx/github-action-build@master 14 | - name: Upload the artifacts 15 | uses: skx/github-action-publish-binaries@master 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | with: 19 | args: marionette-* 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.in 2 | marionette 3 | -------------------------------------------------------------------------------- /FUZZING.md: -------------------------------------------------------------------------------- 1 | # Fuzz-Testing 2 | 3 | The upcoming 1.18 release of the golang compiler/toolset has integrated 4 | support for fuzz-testing. 5 | 6 | Fuzz-testing is basically magical and involves generating new inputs "randomly" 7 | and running test-cases with those inputs. 8 | 9 | 10 | ## Running 11 | 12 | If you're running 1.18beta1 or higher you can run the fuzz-testing against 13 | our parser like so: 14 | 15 | $ cd parser/ 16 | $ go test -fuzztime=300s -parallel=1 -fuzz=FuzzParser -v 17 | === RUN TestBlock 18 | --- PASS: TestBlock (0.00s) 19 | === RUN TestConditinalErrors 20 | --- PASS: TestConditinalErrors (0.00s) 21 | === RUN TestConditional 22 | --- PASS: TestConditional (0.00s) 23 | === FUZZ FuzzParser 24 | fuzz: elapsed: 0s, gathering baseline coverage: 0/149 completed 25 | fuzz: elapsed: 0s, gathering baseline coverage: 149/149 completed, now fuzzing with 1 workers 26 | fuzz: elapsed: 3s, execs: 42431 (14140/sec), new interesting: 0 (total: 143) 27 | fuzz: elapsed: 6s, execs: 93384 (16985/sec), new interesting: 0 (total: 143) 28 | fuzz: elapsed: 9s, execs: 145220 (17280/sec), new interesting: 0 (total: 143) 29 | fuzz: elapsed: 12s, execs: 193264 (16017/sec), new interesting: 0 (total: 143) 30 | .. 31 | fuzz: elapsed: 4m54s, execs: 5376429 (20034/sec), new interesting: 11 (total: 154) 32 | fuzz: elapsed: 4m57s, execs: 5436966 (20179/sec), new interesting: 11 (total: 154) 33 | fuzz: elapsed: 5m0s, execs: 5494052 (19027/sec), new interesting: 12 (total: 155) 34 | fuzz: elapsed: 5m1s, execs: 5494052 (0/sec), new interesting: 12 (total: 155) 35 | --- PASS: FuzzParser (301.02s) 36 | PASS 37 | ok github.com/skx/marionette/parser 301.042s 38 | 39 | 40 | You'll note that I've added `-parellel=1` to the test, because otherwise my desktop system becomes unresponsive while the testing is going on. 41 | -------------------------------------------------------------------------------- /HACKING.md: -------------------------------------------------------------------------------- 1 | 2 | Some brief notes on implementation/internals. 3 | 4 | * [Implementation Overview](#implementation-overview) 5 | 6 | 7 | 8 | 9 | # Implementation Overview 10 | 11 | Our implementation is pretty simple and all revolves around a set of rules. 12 | 13 | * Half our code is involved with producing the rules: 14 | * We have a [lexer](lexer/) to split our input into a set of [tokens](token/). 15 | * The [parser](parser/) reads those tokens to convert an input-file into a series of [AST](ast/) objects. 16 | 17 | * The other half of our code is involved with executing the rules. 18 | * The main driver is the [executor](executor/) package, which runs rules. 19 | * Conditional execution is managed via the built-in functions located in the [ast/builtin.go](ast/builtin.go) file. 20 | 21 | * We use a bunch of objects, stored beneath `ast/` which implement simple primitives 22 | * Arrays, Booleans, Functions, Numbers, and Strings are implemented in [ast/object.go](ast/object.go) 23 | * These all have an evaluation-method which allow them to self-execute and return strings. 24 | * The Array object evaluation-method is a bit of an outlier, it evaluates to a string version of the child-contents, joined by "`,`". 25 | 26 | In addition to the above we also have a [config](config/) object which is passed around to allow us to centralize global state, and we have a set of [file](file/) helpers which contain some central code. 27 | 28 | 29 | # Testing Overview 30 | 31 | There is an associated github action to run our test-cases, and some linters, every time a pull-request is created/updated against the remote repository. 32 | 33 | You should probably run the driver when you're testing: 34 | 35 | .github/run-tests.sh 36 | 37 | Note that this installs some tools if the environmental variable "`$CI`" is set, so you might need to do that the first time: 38 | 39 | CI=true .github/run-tests.sh 40 | -------------------------------------------------------------------------------- /ast/ast.go: -------------------------------------------------------------------------------- 1 | // Package ast contains a simple AST for our scripts. 2 | // 3 | // The intention is that the parser will process a list of 4 | // rules, and will generate a Program which will be executed. 5 | // 6 | // The program will consist of an arbitrary number of 7 | // assignments, inclusions, and rules. 8 | package ast 9 | 10 | import ( 11 | "fmt" 12 | "strings" 13 | ) 14 | 15 | // 16 | // Node represents a node that we can process. 17 | // 18 | type Node interface { 19 | 20 | // String will convert this Node object to a human-readable form. 21 | String() string 22 | } 23 | 24 | // Assign represents a variable assignment. 25 | type Assign struct { 26 | // Node is our parent object. 27 | Node 28 | 29 | // Key is the name of the variable. 30 | Key string 31 | 32 | // Value is the value which will be set. 33 | Value Object 34 | 35 | // ConditionType holds "if" or "unless" if this assignment 36 | // action is to be carried out conditionally. 37 | ConditionType string 38 | 39 | // Function holds a function to call, if this is a conditional 40 | // action. 41 | Function Funcall 42 | } 43 | 44 | // String turns an Assign object into a decent string. 45 | func (a *Assign) String() string { 46 | if a == nil { 47 | return "" 48 | } 49 | // No condition? 50 | if a.ConditionType == "" { 51 | return (fmt.Sprintf("Assign{Key:%s Value:%s}", a.Key, a.Value)) 52 | } 53 | 54 | return (fmt.Sprintf("Assign{Key:%s Value:%s ConditionType:%s Condition:%s}", a.Key, a.Value, a.ConditionType, a.Function)) 55 | } 56 | 57 | // Include represents a file inclusion. 58 | // 59 | // This is produced by the parser by include statements. 60 | type Include struct { 61 | // Node is our parent object. 62 | Node 63 | 64 | // Source holds the location to include. 65 | Source Object 66 | 67 | // ConditionType holds "if" or "unless" if this inclusion is to 68 | // be executed conditionally. 69 | ConditionType string 70 | 71 | // Function holds a function to call, if this is a conditional 72 | // action. 73 | Function Funcall 74 | } 75 | 76 | // String turns an Include object into a useful string. 77 | func (i *Include) String() string { 78 | if i == nil { 79 | return "" 80 | } 81 | if i.ConditionType == "" { 82 | return (fmt.Sprintf("Include{ Source:%s }", i.Source)) 83 | } 84 | return (fmt.Sprintf("Include{ Source:%s ConditionType:%s Condition:%s}", 85 | i.Source, i.ConditionType, i.Function)) 86 | } 87 | 88 | // Rule represents a parsed rule. 89 | type Rule struct { 90 | // Node is our parent node. 91 | Node 92 | 93 | // Type contains the rule-type. 94 | Type string 95 | 96 | // Name contains the name of the rule. 97 | Name string 98 | 99 | // Triggered is true if this rule is only triggered by 100 | // another rule notifying it. 101 | // 102 | // Triggered rules are ignored when processing our list. 103 | Triggered bool 104 | 105 | // Parameters contains the params supplied by the user. 106 | // 107 | // The keys will be strings, with the values being either 108 | // a single ast Node, or an array of them. 109 | // 110 | Params map[string]interface{} 111 | 112 | // ConditionType holds "if" or "unless" if this rule should 113 | // be executed only conditionally. 114 | ConditionType string 115 | 116 | // Function holds a function to call, if this is a conditional 117 | // action. 118 | Function Funcall 119 | } 120 | 121 | // String turns a Rule object into a useful string 122 | func (r *Rule) String() string { 123 | if r == nil { 124 | return "" 125 | } 126 | 127 | args := "" 128 | for k, v := range r.Params { 129 | 130 | // try to format the value 131 | val := "" 132 | 133 | str, ok := v.(Object) 134 | if ok { 135 | val = fmt.Sprintf("\"%s\"", str) 136 | } 137 | 138 | array, ok2 := v.([]Object) 139 | if ok2 { 140 | for _, s := range array { 141 | val += fmt.Sprintf(", \"%s\"", s) 142 | } 143 | val = strings.TrimPrefix(val, ", ") 144 | val = "[" + val + "]" 145 | } 146 | 147 | // now add on the value(s) 148 | args += fmt.Sprintf(", %s->%s", k, val) 149 | } 150 | 151 | // trip prefix 152 | args = strings.TrimPrefix(args, ", ") 153 | 154 | if r.ConditionType == "" { 155 | return fmt.Sprintf("Rule %s{%s}", r.Type, args) 156 | } 157 | 158 | return fmt.Sprintf("Rule %s{%s ConditionType:%s Condition:%s}", r.Type, args, r.ConditionType, r.Function) 159 | 160 | } 161 | 162 | // Program contains a program 163 | type Program struct { 164 | 165 | // Recipe contains the list of rule/assignment/include 166 | // statements we're going to process. 167 | Recipe []Node 168 | } 169 | -------------------------------------------------------------------------------- /ast/builtin_test.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/skx/marionette/file" 13 | ) 14 | 15 | func TestFunctionArgs(t *testing.T) { 16 | 17 | // Ensure that all functions error without an argument 18 | for name, fun := range FUNCTIONS { 19 | 20 | _, err := fun(nil, []string{}) 21 | 22 | if err == nil { 23 | t.Fatalf("expected error invoking %s with no arguments", name) 24 | } 25 | } 26 | 27 | // Ensure all functions abort with too many arguments 28 | for name, fun := range FUNCTIONS { 29 | _, err := fun(nil, []string{"one", "two", "three", "four"}) 30 | 31 | if err == nil { 32 | t.Fatalf("expected error invoking %s with four arguments", name) 33 | } 34 | } 35 | 36 | // number of args for each function; -1 to ignore arg check 37 | m := make(map[string]int) 38 | m["contains"] = 2 39 | m["empty"] = 1 40 | m["equal"] = 2 41 | m["equals"] = 2 42 | m["exists"] = 1 43 | m["failure"] = 1 44 | m["field"] = 2 45 | m["gt"] = 2 46 | m["gte"] = 2 47 | m["len"] = 1 48 | m["lower"] = 1 49 | m["lt"] = 2 50 | m["lte"] = 2 51 | m["matches"] = 2 52 | m["md5"] = 1 53 | m["md5sum"] = 1 54 | m["nonempty"] = 1 55 | m["on_path"] = 1 56 | m["prompt"] = 1 57 | m["rand"] = 2 58 | m["set"] = 1 59 | m["sha1"] = 1 60 | m["sha1sum"] = 1 61 | m["success"] = 1 62 | m["unset"] = 1 63 | m["upper"] = 1 64 | m["newer"] = -1 65 | m["older"] = -1 66 | one := []string{"1"} 67 | two := []string{"23", "34"} 68 | 69 | // Replace STDIN 70 | old := STDIN 71 | 72 | // Ensure that we can call functions with the right number 73 | // of arguments. 74 | for name, fun := range FUNCTIONS { 75 | 76 | // Replace STDIN 77 | STDIN = bufio.NewReader(strings.NewReader("STEVE\n")) 78 | 79 | var err error 80 | 81 | valid := m[name] 82 | 83 | if valid == 1 { 84 | t.Run(name, func(t *testing.T) { 85 | _, err = fun(nil, one) 86 | }) 87 | if err != nil { 88 | t.Fatalf("unexpected error with 1 arg:%s", err) 89 | } 90 | } else if valid == 2 { 91 | t.Run(name, func(t *testing.T) { 92 | _, err = fun(nil, two) 93 | }) 94 | if err != nil { 95 | t.Fatalf("unexpected error with 2 args for function '%s' with error: %s", name, err) 96 | } 97 | } else if valid != -1 { 98 | t.Fatalf("unhandled test-case for function '%s'", name) 99 | } 100 | 101 | } 102 | 103 | STDIN = old 104 | } 105 | 106 | func TestFunctions(t *testing.T) { 107 | 108 | // Replace STDIN 109 | old := STDIN 110 | 111 | type TestCase struct { 112 | // Name of function 113 | Name string 114 | 115 | // Arguments to pass to it 116 | Input []string 117 | 118 | // Expected output 119 | Output Object 120 | 121 | // Faked stdin? 122 | StdIn string 123 | 124 | // If non-empty we expect an error, and it should match 125 | // this text. 126 | Error string 127 | } 128 | 129 | var tmpfile [2]string 130 | for idx := range tmpfile { 131 | file, err := ioutil.TempFile(os.TempDir(), "marionette_go_test_") 132 | if err != nil { 133 | fmt.Printf("ERROR: can't create temporary files for tests, bailing") 134 | os.Exit(2) 135 | } 136 | defer os.Remove(file.Name()) 137 | 138 | tmpfile[idx] = file.Name() 139 | file.Close() 140 | 141 | // we need to sleep because some filesystems only have 1 second 142 | // granularity for timestamps 143 | if idx%2 == 0 { 144 | time.Sleep(time.Second) 145 | } 146 | } 147 | 148 | tests := []TestCase{ 149 | 150 | TestCase{Name: "lt", 151 | Input: []string{ 152 | "1", 153 | "2", 154 | }, 155 | Output: &Boolean{Value: true}, 156 | }, 157 | TestCase{Name: "lt", 158 | Input: []string{ 159 | "1", 160 | "kemp", 161 | }, 162 | Error: "strconv.ParseInt: parsing", 163 | }, 164 | TestCase{Name: "lt", 165 | Input: []string{ 166 | "steve", 167 | "2", 168 | }, 169 | Error: "strconv.ParseInt: parsing", 170 | }, 171 | TestCase{Name: "lt", 172 | Input: []string{ 173 | "2", 174 | "2", 175 | }, 176 | Output: &Boolean{Value: false}, 177 | }, 178 | TestCase{Name: "lt", 179 | Input: []string{ 180 | "3", 181 | "2", 182 | }, 183 | Output: &Boolean{Value: false}, 184 | }, 185 | TestCase{Name: "lte", 186 | Input: []string{ 187 | "1", 188 | "kemp", 189 | }, 190 | Error: "strconv.ParseInt: parsing", 191 | }, 192 | TestCase{Name: "lte", 193 | Input: []string{ 194 | "steve", 195 | "2", 196 | }, 197 | Error: "strconv.ParseInt: parsing", 198 | }, 199 | TestCase{Name: "lte", 200 | Input: []string{ 201 | "1", 202 | "2", 203 | }, 204 | Output: &Boolean{Value: true}, 205 | }, 206 | TestCase{Name: "lte", 207 | Input: []string{ 208 | "2", 209 | "2", 210 | }, 211 | Output: &Boolean{Value: true}, 212 | }, 213 | TestCase{Name: "lte", 214 | Input: []string{ 215 | "3", 216 | "2", 217 | }, 218 | Output: &Boolean{Value: false}, 219 | }, 220 | TestCase{Name: "gt", 221 | Input: []string{ 222 | "1", 223 | "2", 224 | }, 225 | Output: &Boolean{Value: false}, 226 | }, 227 | TestCase{Name: "gt", 228 | Input: []string{ 229 | "1", 230 | "steve", 231 | }, 232 | Error: "strconv.ParseInt: parsing", 233 | }, 234 | TestCase{Name: "gt", 235 | Input: []string{ 236 | "steve", 237 | "2", 238 | }, 239 | Error: "strconv.ParseInt: parsing", 240 | }, 241 | TestCase{Name: "gt", 242 | Input: []string{ 243 | "2", 244 | "2", 245 | }, 246 | Output: &Boolean{Value: false}, 247 | }, 248 | TestCase{Name: "gt", 249 | Input: []string{ 250 | "3", 251 | "2", 252 | }, 253 | Output: &Boolean{Value: true}, 254 | }, 255 | TestCase{Name: "gte", 256 | Input: []string{ 257 | "1", 258 | "steve", 259 | }, 260 | Error: "strconv.ParseInt: parsing", 261 | }, 262 | TestCase{Name: "gte", 263 | Input: []string{ 264 | "steve", 265 | "2", 266 | }, 267 | Error: "strconv.ParseInt: parsing", 268 | }, 269 | TestCase{Name: "gte", 270 | Input: []string{ 271 | "1", 272 | "2", 273 | }, 274 | Output: &Boolean{Value: false}, 275 | }, 276 | TestCase{Name: "gte", 277 | Input: []string{ 278 | "2", 279 | "2", 280 | }, 281 | Output: &Boolean{Value: true}, 282 | }, 283 | TestCase{Name: "gt", 284 | Input: []string{ 285 | "3", 286 | "2", 287 | }, 288 | Output: &Boolean{Value: true}, 289 | }, 290 | TestCase{Name: "equal", 291 | Input: []string{ 292 | "one", 293 | "two", 294 | }, 295 | Output: &Boolean{Value: false}, 296 | }, 297 | TestCase{Name: "equal", 298 | Input: []string{ 299 | "one", 300 | "one", 301 | }, 302 | Output: &Boolean{Value: true}, 303 | }, 304 | 305 | TestCase{Name: "contains", 306 | Input: []string{ 307 | "cake ", 308 | "pie", 309 | }, 310 | Output: &Boolean{Value: false}, 311 | }, 312 | TestCase{Name: "contains", 313 | Input: []string{ 314 | "cake", 315 | "ake", 316 | }, 317 | Output: &Boolean{Value: true}, 318 | }, 319 | TestCase{Name: "empty", 320 | Input: []string{ 321 | "", 322 | }, 323 | Output: &Boolean{Value: true}, 324 | }, 325 | TestCase{Name: "empty", 326 | Input: []string{ 327 | "one", 328 | }, 329 | Output: &Boolean{Value: false}, 330 | }, 331 | TestCase{Name: "field", 332 | Input: []string{ 333 | "Steve Kemp", 334 | "0", 335 | }, 336 | Output: &String{Value: "Steve"}, 337 | }, 338 | TestCase{Name: "field", 339 | Input: []string{ 340 | "Steve Kemp", 341 | "1", 342 | }, 343 | Output: &String{Value: "Kemp"}, 344 | }, 345 | TestCase{Name: "field", 346 | Input: []string{ 347 | "Forename Surname", 348 | "10", 349 | }, 350 | Output: &String{Value: ""}, 351 | }, 352 | TestCase{Name: "field", 353 | Input: []string{ 354 | "Forename Surname", 355 | "Nope", 356 | }, 357 | Error: "strconv", 358 | }, 359 | TestCase{Name: "len", 360 | Input: []string{ 361 | "one", 362 | }, 363 | Output: &Number{Value: 3}, 364 | }, 365 | TestCase{Name: "len", 366 | Input: []string{ 367 | "steve", 368 | }, 369 | Output: &Number{Value: 5}, 370 | }, 371 | TestCase{Name: "matches", 372 | Input: []string{ 373 | "password", 374 | "^pa[Ss]+word", 375 | }, 376 | Output: &Boolean{Value: true}, 377 | }, 378 | TestCase{Name: "matches", 379 | Input: []string{ 380 | "password", 381 | "^secret$", 382 | }, 383 | Output: &Boolean{Value: false}, 384 | }, 385 | TestCase{Name: "matches", 386 | Input: []string{ 387 | "password", 388 | "+", 389 | }, 390 | Error: "error parsing regexp", 391 | }, 392 | TestCase{Name: "md5", 393 | Input: []string{ 394 | "password", 395 | }, 396 | Output: &String{Value: "5f4dcc3b5aa765d61d8327deb882cf99"}, 397 | }, 398 | TestCase{Name: "prompt", 399 | Input: []string{ 400 | "What is your name?", 401 | }, 402 | StdIn: " STEVE \n", 403 | Output: &String{Value: "STEVE"}, 404 | }, 405 | TestCase{Name: "prompt", 406 | Input: []string{ 407 | "Foo", 408 | "Bar", 409 | }, 410 | Error: "wrong number of args", 411 | }, 412 | TestCase{Name: "prompt", 413 | Input: []string{ 414 | "Empty", 415 | }, 416 | StdIn: "", 417 | Error: "EOF", 418 | }, 419 | TestCase{Name: "rand", 420 | Input: []string{ 421 | "1", 422 | "100", 423 | "hostname", 424 | }, 425 | Output: &String{Value: "96"}, 426 | }, 427 | TestCase{Name: "rand", 428 | Input: []string{ 429 | "1", 430 | "steve", 431 | }, 432 | Error: "strconv.Atoi", 433 | }, 434 | TestCase{Name: "rand", 435 | Input: []string{ 436 | "steve", 437 | "2", 438 | }, 439 | Error: "strconv.Atoi", 440 | }, 441 | TestCase{Name: "sha1sum", 442 | Input: []string{ 443 | "secret", 444 | }, 445 | Output: &String{Value: "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4"}, 446 | }, 447 | TestCase{Name: "upper", 448 | Input: []string{ 449 | "one", 450 | }, 451 | Output: &String{Value: "ONE"}, 452 | }, 453 | TestCase{Name: "lower", 454 | Input: []string{ 455 | "OnE", 456 | }, 457 | Output: &String{Value: "one"}, 458 | }, 459 | TestCase{Name: "set", 460 | Input: []string{ 461 | "OnE", 462 | }, 463 | Output: &Boolean{Value: true}, 464 | }, 465 | TestCase{Name: "set", 466 | Input: []string{ 467 | "", 468 | }, 469 | Output: &Boolean{Value: false}, 470 | }, 471 | // test newer and older for other arg lengths 472 | TestCase{Name: "newer", 473 | Input: []string{ 474 | tmpfile[0], 475 | }, 476 | Error: "requires two arguments", 477 | }, 478 | TestCase{Name: "newer", 479 | Input: []string{ 480 | tmpfile[0], 481 | tmpfile[1], 482 | tmpfile[1], 483 | }, 484 | Error: "requires two arguments", 485 | }, 486 | TestCase{Name: "older", 487 | Input: []string{ 488 | tmpfile[0], 489 | }, 490 | Error: "requires two arguments", 491 | }, 492 | TestCase{Name: "older", 493 | Input: []string{ 494 | tmpfile[0], 495 | tmpfile[1], 496 | tmpfile[1], 497 | }, 498 | Error: "requires two arguments", 499 | }, 500 | // and then functional tests 501 | TestCase{Name: "newer", 502 | Input: []string{ 503 | tmpfile[0], 504 | tmpfile[1], 505 | }, 506 | Output: &Boolean{Value: false}, 507 | }, 508 | TestCase{Name: "newer", 509 | Input: []string{ 510 | tmpfile[0], 511 | tmpfile[0], 512 | }, 513 | Output: &Boolean{Value: false}, 514 | }, 515 | TestCase{Name: "newer", 516 | Input: []string{ 517 | tmpfile[1], 518 | tmpfile[0], 519 | }, 520 | Output: &Boolean{Value: true}, 521 | }, 522 | TestCase{Name: "older", 523 | Input: []string{ 524 | tmpfile[0], 525 | tmpfile[1], 526 | }, 527 | Output: &Boolean{Value: true}, 528 | }, 529 | TestCase{Name: "older", 530 | Input: []string{ 531 | tmpfile[0], 532 | tmpfile[0], 533 | }, 534 | Output: &Boolean{Value: false}, 535 | }, 536 | TestCase{Name: "older", 537 | Input: []string{ 538 | tmpfile[1], 539 | tmpfile[0], 540 | }, 541 | Output: &Boolean{Value: false}, 542 | }, 543 | } 544 | 545 | if file.Exists("/etc/passwd") { 546 | tests = append(tests, 547 | TestCase{Name: "exists", 548 | Input: []string{ 549 | "/etc/passwd", 550 | }, 551 | Output: &Boolean{Value: true}, 552 | }) 553 | tests = append(tests, 554 | TestCase{Name: "exists", 555 | Input: []string{ 556 | "/etc/passwd.passwd/blah", 557 | }, 558 | Output: &Boolean{Value: false}, 559 | }, 560 | ) 561 | } 562 | 563 | for _, test := range tests { 564 | 565 | // Replace the contents of STDIN if we should 566 | if test.StdIn != "" { 567 | 568 | STDIN = bufio.NewReader(strings.NewReader(test.StdIn)) 569 | } 570 | 571 | t.Run(fmt.Sprintf("%s(%s) -> %s", test.Name, test.Input, test.Output), func(t *testing.T) { 572 | 573 | // Find the function 574 | fun, ok := FUNCTIONS[test.Name] 575 | if !ok { 576 | t.Fatalf("failed to find test") 577 | } 578 | 579 | // Call the function 580 | ret, err := fun(nil, test.Input) 581 | 582 | // Got an error making the call 583 | if err != nil { 584 | 585 | // Should we have done? 586 | if test.Error != "" { 587 | 588 | if !strings.Contains(err.Error(), test.Error) { 589 | t.Fatalf("expected error (%s), but got different one (%s)", test.Error, err.Error()) 590 | } 591 | } else { 592 | t.Fatalf("unexpected error calling %s(%v) %s", test.Name, test.Input, err) 593 | } 594 | } else { 595 | // Compare the results 596 | a := test.Output.String() 597 | b := ret.String() 598 | 599 | if a != b { 600 | t.Fatalf("error running test %s(%v) - %s != %s", test.Name, test.Input, a, b) 601 | } 602 | } 603 | }) 604 | } 605 | 606 | STDIN = old 607 | } 608 | -------------------------------------------------------------------------------- /ast/object.go: -------------------------------------------------------------------------------- 1 | // object.go - Contains our "object" implementation. 2 | 3 | package ast 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "strings" 9 | 10 | "github.com/skx/marionette/environment" 11 | ) 12 | 13 | // Object is an interface which must be implemented by anything which will 14 | // be used as a core-primitive value. 15 | // 16 | // A primitive can be output as a string, and also self-evaluate to return 17 | // a string-value. 18 | type Object interface { 19 | 20 | // String returns the object contents as a string 21 | String() string 22 | 23 | // Evaluate returns the value of the literal. 24 | // 25 | // The environment is made available because we want to 26 | // allow variable expansion within strings and backticks. 27 | Evaluate(env *environment.Environment) (string, error) 28 | } 29 | 30 | // 31 | // Primitive values follow 32 | // 33 | 34 | // Backtick is a value which returns the output of executing a command. 35 | type Backtick struct { 36 | // Object is our parent object. 37 | Object 38 | 39 | // Value is the command we're to execute. 40 | Value string 41 | } 42 | 43 | // String returns our object as a string. 44 | func (b Backtick) String() string { 45 | return fmt.Sprintf("Backtick{Command:%s}", b.Value) 46 | } 47 | 48 | // Evaluate returns the value of the Backtick object. 49 | func (b Backtick) Evaluate(env *environment.Environment) (string, error) { 50 | ret, err := env.ExpandBacktick(b.Value) 51 | return ret, err 52 | } 53 | 54 | // Array is a holder which can contain an arbitrary number of any of our primitive types. 55 | // 56 | // NOTE: Arrays cannot be nested, due to our parser-limitations. 57 | type Array struct { 58 | // Object is our parent object. 59 | Object 60 | 61 | // Values hold the literal values we contain. 62 | Values []Object 63 | } 64 | 65 | // String returns our object as a string. 66 | func (a Array) String() string { 67 | tmp := []string{} 68 | for _, arg := range a.Values { 69 | tmp = append(tmp, arg.String()) 70 | } 71 | 72 | return fmt.Sprintf("Array{%s}", strings.Join(tmp, ",")) 73 | } 74 | 75 | // Evaluate returns the value of the array object. 76 | // 77 | // The evaluation here consists of the joined output of evaluating all 78 | // the children we contain. 79 | func (a Array) Evaluate(env *environment.Environment) (string, error) { 80 | tmp := "" 81 | for _, obj := range a.Values { 82 | if len(tmp) > 0 { 83 | tmp += "," 84 | } 85 | 86 | out, err := obj.Evaluate(env) 87 | if err != nil { 88 | return "", err 89 | } 90 | tmp += out 91 | } 92 | return tmp, nil 93 | } 94 | 95 | // Boolean represents a true/false value 96 | type Boolean struct { 97 | // Object is our parent object. 98 | Object 99 | 100 | // Value is the literal value we hold 101 | Value bool 102 | } 103 | 104 | // String returns our object as a string. 105 | func (b Boolean) String() string { 106 | if b.Value { 107 | return ("Boolean{true}") 108 | } 109 | return ("Boolean{false}") 110 | } 111 | 112 | // Evaluate returns the value of the Boolean object. 113 | func (b Boolean) Evaluate(env *environment.Environment) (string, error) { 114 | if b.Value { 115 | return "true", nil 116 | } 117 | return "false", nil 118 | } 119 | 120 | // Funcall represents a function-call. 121 | type Funcall struct { 122 | // Object is our parent object. 123 | Object 124 | 125 | // Name is the name of the function to be invoked. 126 | Name string 127 | 128 | // Arguments are the arguments to be passed to the call. 129 | Args []Object 130 | } 131 | 132 | // Evaluate returns the value of the function call. 133 | func (f Funcall) Evaluate(env *environment.Environment) (string, error) { 134 | 135 | // Lookup the function 136 | fn, ok := FUNCTIONS[f.Name] 137 | if !ok { 138 | return "", fmt.Errorf("function %s not defined", f.Name) 139 | } 140 | 141 | // Holder for expanded arguments 142 | args := []string{} 143 | 144 | // Convert each argument to a string 145 | for _, arg := range f.Args { 146 | 147 | // Evaluate 148 | val, err := arg.Evaluate(env) 149 | if err != nil { 150 | return "", err 151 | } 152 | 153 | // Save the string-representation into our temporary 154 | // set of arguments. 155 | args = append(args, val) 156 | } 157 | 158 | log.Printf("[DEBUG] Invoking function - %s(%s)", f.Name, strings.Join(args, ",")) 159 | 160 | // Call the function, with the stringified arguments. 161 | ret, err := fn(env, args) 162 | if err != nil { 163 | return "", err 164 | } 165 | 166 | log.Printf("[DEBUG] Function result - %s(%s) -> %s", f.Name, strings.Join(args, ","), ret) 167 | 168 | // Get the output of the return value as string 169 | return ret.Evaluate(env) 170 | } 171 | 172 | // String returns our object as a string. 173 | func (f Funcall) String() string { 174 | args := "" 175 | for _, a := range f.Args { 176 | if len(args) > 0 { 177 | args += "," 178 | } 179 | args += a.String() 180 | } 181 | return fmt.Sprintf("Funcall{%s(%s)}", f.Name, args) 182 | } 183 | 184 | // Number represents an integer/hexadecimal/octal number. 185 | // 186 | // Note that we support integers only, not floating-point numbers. 187 | type Number struct { 188 | // Object is our parent object. 189 | Object 190 | 191 | // Value is the literal number we're holding. 192 | Value int64 193 | } 194 | 195 | // String returns our object as a string. 196 | func (n Number) String() string { 197 | return fmt.Sprintf("Number{%d}", n.Value) 198 | } 199 | 200 | // Evaluate returns the value of the Number object. 201 | func (n Number) Evaluate(env *environment.Environment) (string, error) { 202 | return fmt.Sprintf("%d", n.Value), nil 203 | } 204 | 205 | // String represents a string literal 206 | type String struct { 207 | // Object is our parent object. 208 | Object 209 | 210 | // Value is the literal string we've got 211 | Value string 212 | } 213 | 214 | // String returns our object as a string. 215 | func (s String) String() string { 216 | return fmt.Sprintf("String{%s}", s.Value) 217 | } 218 | 219 | // Evaluate returns the value of the String object. 220 | // 221 | // This means expanding the variables contained within the string. 222 | func (s String) Evaluate(env *environment.Environment) (string, error) { 223 | return env.ExpandVariables(s.Value), nil 224 | 225 | } 226 | -------------------------------------------------------------------------------- /ast/object_test.go: -------------------------------------------------------------------------------- 1 | package ast 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/skx/marionette/environment" 8 | ) 9 | 10 | // TestBrokenBacktick tests the basic backtick functions fail 11 | func TestBrokenBacktick(t *testing.T) { 12 | 13 | e := environment.New() 14 | 15 | b := &Backtick{Value: "/no/such/binary-can-be/here"} 16 | 17 | _, err := b.Evaluate(e) 18 | if err == nil { 19 | t.Fatalf("expected error running missing binary") 20 | } 21 | } 22 | 23 | // TestArrays handles basic array testing 24 | func TestArrays(t *testing.T) { 25 | 26 | // Array 27 | a := &Array{Values: []Object{ 28 | &Number{Value: 12}, 29 | &Number{Value: 34}, 30 | }} 31 | 32 | _, err := a.Evaluate(nil) 33 | if err != nil { 34 | t.Fatalf("unexpected error evaluating an array") 35 | } 36 | } 37 | 38 | // TestSimpleFunction tests some simple functions. 39 | func TestSimpleFunction(t *testing.T) { 40 | 41 | // Test calling a function that doesn't exist 42 | f := &Funcall{Name: "not-found.bogus"} 43 | _, err := f.Evaluate(nil) 44 | if err == nil { 45 | t.Fatalf("expected error calling missing function") 46 | } 47 | 48 | // Test calling a function with the wrong number of arguments 49 | f.Name = "matches" 50 | f.Args = []Object{ 51 | &String{Value: "haystack"}, 52 | } 53 | _, err = f.Evaluate(nil) 54 | if err == nil { 55 | t.Fatalf("expected error calling function with wrong args") 56 | } 57 | if !strings.Contains(err.Error(), "wrong number of args") { 58 | t.Fatalf("got error, but wrong one:%s", err.Error()) 59 | } 60 | 61 | // Test calling a function with an arg that will fail 62 | f.Name = "matches" 63 | f.Args = []Object{ 64 | &Backtick{Value: "`/f/not-found"}, 65 | &Backtick{Value: "`/f/not-found"}, 66 | } 67 | _, err = f.Evaluate(nil) 68 | if err == nil { 69 | t.Fatalf("expected error calling function with broken arg") 70 | } 71 | if !strings.Contains(err.Error(), "error running command") { 72 | t.Fatalf("got error, but wrong one:%s", err.Error()) 73 | } 74 | 75 | // Test calling a function with no error 76 | f.Name = "len" 77 | f.Args = []Object{ 78 | &String{Value: "Hello, World"}, 79 | } 80 | out, err2 := f.Evaluate(nil) 81 | if err2 != nil { 82 | t.Fatalf("unexpected error calling 'len'") 83 | } 84 | 85 | if out != "12" { 86 | t.Fatalf("unexpected result for len(Hello, World) : %s", out) 87 | } 88 | } 89 | 90 | func TestStringification(t *testing.T) { 91 | 92 | // Array 93 | a := &Array{Values: []Object{ 94 | &Number{Value: 12}, 95 | &Number{Value: 34}, 96 | }} 97 | if !strings.Contains(a.String(), "Array") { 98 | t.Fatalf("stringified object is bogus") 99 | } 100 | if !strings.Contains(a.String(), "Number{12},Number{34}") { 101 | t.Fatalf("stringified object is bogus") 102 | } 103 | 104 | // Backtick 105 | b := &Backtick{Value: "/usr/bin/id"} 106 | if !strings.Contains(b.String(), "Backtick") { 107 | t.Fatalf("stringified object is bogus") 108 | } 109 | if !strings.Contains(b.String(), "/usr/bin/id") { 110 | t.Fatalf("stringified object is bogus") 111 | } 112 | 113 | // Boolean 114 | bo := &Boolean{Value: true} 115 | if !strings.Contains(bo.String(), "Boolean") { 116 | t.Fatalf("stringified object is bogus") 117 | } 118 | if !strings.Contains(bo.String(), "t") { 119 | t.Fatalf("stringified object is bogus") 120 | } 121 | 122 | // Boolean: Evaluate - true 123 | boe, berr := bo.Evaluate(nil) 124 | if berr != nil { 125 | t.Fatalf("unexpected error evaluating object:%s", berr.Error()) 126 | } 127 | if boe != "true" { 128 | t.Fatalf("wrong value evaluating bool:%s", boe) 129 | } 130 | 131 | // Boolean: Evaluate - false 132 | bo = &Boolean{Value: false} 133 | boe, berr = bo.Evaluate(nil) 134 | if berr != nil { 135 | t.Fatalf("unexpected error evaluating object:%s", berr.Error()) 136 | } 137 | if boe != "false" { 138 | t.Fatalf("wrong value evaluating bool:%s", boe) 139 | } 140 | 141 | // Funcall 142 | f := &Funcall{Name: "equal", Args: []Object{ 143 | &String{Value: "one"}, 144 | &String{Value: "two"}, 145 | }} 146 | if !strings.Contains(f.String(), "Funcall") { 147 | t.Fatalf("stringified object is bogus") 148 | } 149 | if !strings.Contains(f.String(), "equal") { 150 | t.Fatalf("stringified object is bogus") 151 | } 152 | if !strings.Contains(f.String(), "String{one},String{two}") { 153 | t.Fatalf("stringified object is bogus") 154 | } 155 | 156 | // Number 157 | n := &Number{Value: 323} 158 | if !strings.Contains(n.String(), "Number") { 159 | t.Fatalf("stringified object is bogus") 160 | } 161 | if !strings.Contains(n.String(), "323") { 162 | t.Fatalf("stringified object is bogus") 163 | } 164 | 165 | // Number: Evaluate 166 | no, err := n.Evaluate(nil) 167 | if err != nil { 168 | t.Fatalf("unexpected error evaluating object:%s", err.Error()) 169 | } 170 | if no != "323" { 171 | t.Fatalf("wrong value evaluating number:%s", no) 172 | } 173 | // String 174 | tmp := &String{Value: "steve"} 175 | if !strings.Contains(tmp.String(), "steve") { 176 | t.Fatalf("stringified object is bogus") 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Package config holds global options. 2 | // 3 | // Options are intended to be set via the command-line flags, 4 | // and made available to all our plugins. 5 | package config 6 | 7 | import "fmt" 8 | 9 | // Config holds the state which is set by the main driver, and is 10 | // made available to all of our plugins. 11 | type Config struct { 12 | 13 | // Debug is used to let our plugins know that the marionette 14 | // CLI was started with the `-debug` flag present. 15 | Debug bool 16 | 17 | // Verbose is used to let our plugins know that the marionette 18 | // CLI was started with the `-verbose` flag present. 19 | Verbose bool 20 | } 21 | 22 | // String converts this object to a string, only used for the test-case. 23 | func (c *Config) String() string { 24 | if c == nil { 25 | return "Config{nil}" 26 | } 27 | return fmt.Sprintf("Config{Debug:%t Verbose:%t}", c.Debug, c.Verbose) 28 | } 29 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | // Package config holds global options. 2 | // 3 | // Options are intended to be set via the command-line flags, 4 | // and made available to all our plugins. 5 | package config 6 | 7 | import ( 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestConfig(t *testing.T) { 13 | 14 | // Create an object 15 | c := &Config{Debug: false, Verbose: false} 16 | 17 | // Verify the options are sane 18 | if c.Debug != false { 19 | t.Fatalf("structure test failed") 20 | } 21 | if c.Verbose != false { 22 | t.Fatalf("structure test failed") 23 | } 24 | 25 | // Convert to string 26 | out := c.String() 27 | if !strings.Contains(out, "Debug:false") { 28 | t.Fatalf("string output has wrong content") 29 | } 30 | if !strings.Contains(out, "Verbose:false") { 31 | t.Fatalf("string output has wrong content") 32 | } 33 | 34 | // Change settings 35 | c.Debug = true 36 | out = c.String() 37 | if !strings.Contains(out, "Debug:true") { 38 | t.Fatalf("string output has wrong content") 39 | } 40 | if !strings.Contains(out, "Verbose:false") { 41 | t.Fatalf("string output has wrong content") 42 | } 43 | 44 | // Change settings 45 | c.Verbose = true 46 | out = c.String() 47 | if !strings.Contains(out, "Debug:true") { 48 | t.Fatalf("string output has wrong content") 49 | } 50 | if !strings.Contains(out, "Verbose:true") { 51 | t.Fatalf("string output has wrong content") 52 | } 53 | 54 | // Nil-test 55 | c = nil 56 | out = c.String() 57 | if out != "Config{nil}" { 58 | t.Fatalf("string output has wrong content for nil object") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /environment/environment.go: -------------------------------------------------------------------------------- 1 | // Package environment is used to store and retrieve variables 2 | // by our run-time Executor. 3 | package environment 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "os/user" 11 | "runtime" 12 | "strings" 13 | ) 14 | 15 | // Environment stores our state 16 | type Environment struct { 17 | 18 | // The variables we're holding. 19 | vars map[string]string 20 | } 21 | 22 | // New returns a new Environment object. 23 | // 24 | // The new environment receives some default variable/values, 25 | // which currently include the architecture of the host system, 26 | // the operating-system upon which we're running, & etc. 27 | func New() *Environment { 28 | // Create a new environment 29 | tmp := &Environment{vars: make(map[string]string)} 30 | 31 | // Set some default values 32 | tmp.vars["ARCH"] = runtime.GOARCH 33 | tmp.vars["OS"] = runtime.GOOS 34 | 35 | // Default hostname 36 | tmp.vars["HOSTNAME"] = "unknown" 37 | 38 | // Get the real one, and set it if no errors 39 | host, err := os.Hostname() 40 | if err == nil { 41 | tmp.vars["HOSTNAME"] = host 42 | } 43 | 44 | // Default username and homedir as empty 45 | tmp.vars["USERNAME"] = "" 46 | tmp.vars["HOMEDIR"] = "" 47 | 48 | // Get the real username and homedir, and set it if no errors 49 | user, err := user.Current() 50 | if err == nil { 51 | tmp.vars["USERNAME"] = user.Username 52 | tmp.vars["HOMEDIR"] = user.HomeDir 53 | } 54 | 55 | // Log our default variables 56 | for key, val := range tmp.vars { 57 | log.Printf("[DEBUG] Set default variable %s -> %s\n", key, val) 58 | } 59 | 60 | return tmp 61 | } 62 | 63 | // Set updates the environment to store the given value against the 64 | // specified key. 65 | // 66 | // Any previously-existing value will be overwritten. 67 | func (e *Environment) Set(key string, val string) { 68 | e.vars[key] = val 69 | } 70 | 71 | // Get retrieves the named value from the environment, along 72 | // with a boolean value to indicate whether the retrieval was 73 | // successful. 74 | func (e *Environment) Get(key string) (string, bool) { 75 | val, ok := e.vars[key] 76 | return val, ok 77 | } 78 | 79 | // Variables returns all of variables which have been set, as 80 | // well as their values. 81 | // 82 | // This is used such that include-files inherit the variables 83 | // which were already in-scope at the point the inclusion happens. 84 | func (e *Environment) Variables() map[string]string { 85 | return e.vars 86 | } 87 | 88 | // ExpandVariables takes a string which contains embedded 89 | // variable references, such as ${USERNAME}, and expands the 90 | // result. 91 | func (e *Environment) ExpandVariables(input string) string { 92 | return os.Expand(input, e.expandVariablesMapper) 93 | } 94 | 95 | // ExpandBacktick is similar to the ExpandVariables, it expands any 96 | // variables within the given string, then executes that as a command. 97 | func (e *Environment) ExpandBacktick(value string) (string, error) { 98 | 99 | // Expand any variables within the command. 100 | value = e.ExpandVariables(value) 101 | 102 | // Now we need to execute the command and return the value 103 | // Build up the thing to run, using a shell so that 104 | // we can handle pipes/redirection. 105 | toRun := []string{"/bin/bash", "-c", value} 106 | 107 | // Run the command 108 | cmd := exec.Command(toRun[0], toRun[1:]...) 109 | 110 | // Get the output 111 | output, err := cmd.CombinedOutput() 112 | if err != nil { 113 | return "", fmt.Errorf("error running command '%s' %s", value, err.Error()) 114 | } 115 | 116 | // Strip trailing newline. 117 | ret := strings.TrimSuffix(string(output), "\n") 118 | return ret, nil 119 | } 120 | 121 | // expandVariablesMapper is a helper to expand variables. 122 | // 123 | // ${foo} will be converted to the contents of the variable named foo 124 | // which was created with `let foo = "bar"`, or failing that the contents 125 | // of the environmental variable named `foo`. 126 | // 127 | func (e *Environment) expandVariablesMapper(val string) string { 128 | 129 | // Lookup a variable which exists? 130 | res, ok := e.Get(val) 131 | if ok { 132 | return res 133 | } 134 | 135 | // Lookup an environmental variable? 136 | return os.Getenv(val) 137 | } 138 | -------------------------------------------------------------------------------- /environment/environment_test.go: -------------------------------------------------------------------------------- 1 | package environment 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "testing" 7 | ) 8 | 9 | // Test built-in values 10 | func TestExpected(t *testing.T) { 11 | 12 | x := New() 13 | 14 | // Get the arch 15 | a, aOK := x.Get("ARCH") 16 | if !aOK { 17 | t.Fatalf("Failed to get ${ARCH}") 18 | } 19 | if a != runtime.GOARCH { 20 | t.Fatalf("${ARCH} had wrong value != %s", runtime.GOARCH) 21 | } 22 | 23 | // Get the OS 24 | o, oOK := x.Get("OS") 25 | if !oOK { 26 | t.Fatalf("Failed to get ${OS}") 27 | } 28 | if o != runtime.GOOS { 29 | t.Fatalf("${OS} had wrong value != %s", runtime.GOOS) 30 | } 31 | 32 | // Test getting environmental variables 33 | testVar := "steve" 34 | os.Setenv("TEST_ME", testVar) 35 | out := x.ExpandVariables("${TEST_ME}") 36 | if out != "steve" { 37 | t.Fatalf("${TEST_ME} had wrong value != %s", testVar) 38 | } 39 | 40 | // Chagne the variable in the map, which will 41 | // take precedence to the env 42 | updated := "OK, Computer" 43 | x.vars["TEST_ME"] = updated 44 | out = x.ExpandVariables("${TEST_ME}") 45 | if out != updated { 46 | t.Fatalf("${TEST_ME} had wrong value %s != %s", out, updated) 47 | } 48 | 49 | } 50 | 51 | // TestSet ensures a value will remain 52 | func TestSet(t *testing.T) { 53 | 54 | e := New() 55 | 56 | // Count the variables 57 | vars := e.Variables() 58 | vlen := len(vars) 59 | 60 | // Confirm getting a missing value fails 61 | _, ok := e.Get("STEVE") 62 | if ok { 63 | t.Fatalf("Got value for STEVE, shouldn't have done") 64 | } 65 | 66 | // Set the value 67 | e.Set("STEVE", "KEMP") 68 | 69 | // Get it again 70 | val := "" 71 | val, ok = e.Get("STEVE") 72 | if !ok { 73 | t.Fatalf("After setting the value wasn't available") 74 | } 75 | if val != "KEMP" { 76 | t.Fatalf("Wrong value retrieved") 77 | } 78 | 79 | if len(e.Variables()) != vlen+1 { 80 | t.Errorf("After setting variable length didn't increase") 81 | } 82 | // Update the value 83 | e.Set("STEVE", "STEVE") 84 | 85 | // Get it again 86 | val, ok = e.Get("STEVE") 87 | if !ok { 88 | t.Fatalf("After setting the value wasn't available") 89 | } 90 | if val != "STEVE" { 91 | t.Fatalf("Wrong value retrieved, after update") 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /examples/433-mhz-temperature-grapher-docker.recipe: -------------------------------------------------------------------------------- 1 | # 2 | # This recipe deploys a number of docker containers which can be used 3 | # to log temperature received from a 433Mhz transmitter via a USB 4 | # SDR-dongle. 5 | # 6 | # (Specifics don't matter here, but it is something I use to graph 7 | # the temperature/humidity on my balcony, and within my sauna!) 8 | # 9 | # We start by creating a new directory to store state "~/temperature", 10 | # then we copy a docker-compose.yml file into that directory. 11 | # 12 | # Once the docker-compose file has been deployed we could then 13 | # be ready to launch it - but to demonstrate our functionality 14 | # we go ahead and manually pull the appropriate containers. 15 | # 16 | # 17 | 18 | 19 | directory { 20 | target => "${HOME}/temperature", 21 | state => "present", 22 | name => "temperature:directory", 23 | } 24 | 25 | # 26 | # Write the docker-compose.yml file into the new directory 27 | # 28 | # 29 | file { 30 | name => "temperature:docker-compose.yml", 31 | require => "temperature:directory", 32 | source => "docker-compose.temperature.yml", 33 | target => "${HOME}/temperature/docker-compose.yml", 34 | } 35 | 36 | 37 | # 38 | # Pull the most recent versions of the appropriate containers. 39 | # 40 | # Note: If the containers weren't present then docker-compose 41 | # would fetch them. This is just an example. 42 | # 43 | docker { 44 | name => "temperature:containers", 45 | image => [ 46 | "influxdb:1.8", 47 | "grafana/grafana:latest", 48 | "hertzg/rtl_433:latest", 49 | ], 50 | force => true, 51 | notify => "temperature:restart" 52 | } 53 | 54 | 55 | # 56 | # If we've changed then we'll restart the containers. 57 | # 58 | shell triggered { 59 | name => "temperature:restart", 60 | command => [ 61 | "cd ~/temperature && docker-compose down", 62 | "cd ~/temperature && docker-compose up -d", 63 | ] 64 | 65 | } 66 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains some simple examples, to show how this tool can be used. 4 | 5 | * [install-go.recipe](install-go.recipe) 6 | * This shows downloading a binary distribution of the golang compiler/toolset. 7 | * The binary release is downloaded beneath `/opt/go-${version}`. 8 | * A symlink is created to make version upgrades simple. 9 | 10 | * [433-mhz-temperature-grapher-docker.recipe](433-mhz-temperature-grapher-docker.recipe) 11 | * This example writes a `docker-compose.yml` file into a directory. 12 | * It also pulls three docker containers from their remote source. 13 | * Finally it restarts the application. 14 | 15 | * [download.recipe](download.recipe) 16 | * Demonstrates how to use the conditional assignments to choose programs. 17 | * Selecting either wget or curl, depending on which is available. 18 | 19 | * [overview.recipe](overview.recipe) 20 | * This is a well-commented example that shows numerous examples of the various modules. 21 | -------------------------------------------------------------------------------- /examples/docker-compose.temperature.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | influxdb: 4 | image: "influxdb:1.8" 5 | container_name: influxdb 6 | environment: 7 | TZ: ${TZ} 8 | INFLUXDB_ADMIN_USER: admin 9 | INFLUXDB_ADMIN_PASSWORD: admin123 10 | restart: unless-stopped 11 | volumes: 12 | - ${PWD}/influxdb:/var/lib/influxdb 13 | healthcheck: 14 | test: ["CMD", "curl", "-sI", "http://127.0.0.1:8086/ping"] 15 | interval: 30s 16 | timeout: 1s 17 | retries: 24 18 | ports: 19 | - 8086:8086 20 | grafana: 21 | image: "grafana/grafana:latest" 22 | container_name: grafana 23 | environment: 24 | TZ: ${TZ} 25 | restart: unless-stopped 26 | user: "$PUID:$PGID" 27 | depends_on: 28 | - influxdb 29 | volumes: 30 | - ${PWD}/grafana:/var/lib/grafana 31 | ports: 32 | - 3000:3000 33 | rtl433: 34 | image: hertzg/rtl_433:latest 35 | depends_on: 36 | - influxdb 37 | devices: 38 | - '/dev/bus/usb' 39 | command: 40 | - '-Mtime:unix:usec:utc' 41 | - '-Mbits' 42 | - '-Mlevel' 43 | - '-Mprotocol' 44 | - '-Mstats:2:300' 45 | - '-Finflux://influxdb:8086/write?db=data&p=data&u=submit' 46 | -------------------------------------------------------------------------------- /examples/download.recipe: -------------------------------------------------------------------------------- 1 | # 2 | # This example uses either wget, or curl, to download a file 3 | # 4 | 5 | # 6 | # Going to download this URL 7 | # 8 | let url = "https://example.com/" 9 | 10 | # 11 | # Going to save here 12 | # 13 | let dst = "/tmp/save" 14 | 15 | # 16 | # Find a binary 17 | # 18 | let cmd = "curl --output ${dst} ${url}" if on_path("curl") 19 | let cmd = "wget -O ${dst} ${url}" if on_path("wget") 20 | 21 | # 22 | # Fail if we didn't 23 | # 24 | fail { 25 | message => "Failed to find curl or wget on the PATH", 26 | if => unset("${cmd}") 27 | } 28 | 29 | log { 30 | message => "Download command: ${cmd}" 31 | } 32 | -------------------------------------------------------------------------------- /examples/install-go.recipe: -------------------------------------------------------------------------------- 1 | # 2 | # This set of rules is designed to deploy a binary version of the golang toolchain upon a local system. 3 | # 4 | # We create /opt/go-archive to contain the binary release and unpack that named version beneath: 5 | # 6 | # /opt/go-${version} 7 | # 8 | # To make using this easier we create a symlink which points to 9 | # that version at: 10 | # 11 | # /opt/go 12 | # 13 | # The user can then add `/opt/go/bin` to their path to use the version: 14 | # 15 | # export PATH=/opt/go/bin:$PATH 16 | # export GOROOT=/opt/go 17 | # 18 | 19 | 20 | # 21 | # The version of golang we're installing 22 | # 23 | let version = "1.17.6" 24 | 25 | # 26 | # Here we handle the archive and the download paths: 27 | # 28 | let install = "/opt" 29 | let archive = "/opt/go-archive" 30 | 31 | 32 | 33 | # 34 | # So the first thing we do is create a directory to contain the binaries, 35 | # and a location to download the source to. 36 | # 37 | directory { 38 | state => "present", 39 | target => [ 40 | "${archive}", 41 | "${install}/go-${version}", 42 | ] 43 | } 44 | 45 | 46 | # 47 | # Ensure we have wget to download 48 | # 49 | package { 50 | package => "wget", 51 | state => "installed", 52 | name => "golang:wget", 53 | } 54 | 55 | # 56 | # Download the binary release to our archive-location. 57 | # 58 | shell { 59 | name => "golang:download", 60 | command => "wget -O ${archive}/${version}.tar.gz https://go.dev/dl/go${version}.${OS}-${ARCH}.tar.gz", 61 | unless => exists("${archive}/${version}.tar.gz") 62 | require => "golang:wget", 63 | } 64 | 65 | # 66 | # Unpack the release, stripping the leading directory. 67 | # 68 | shell { 69 | name => "golang:unpack", 70 | command => "tar xf ${archive}/${version}.tar.gz --directory=${install}/go-${version} --strip-components=1", 71 | 72 | # If there is a /bin directory then we've unpacked already. 73 | unless => exists("${install}/go-${version}/bin") 74 | 75 | # We can only unpack if we've downloaded 76 | require => "golang:download" 77 | } 78 | 79 | # 80 | # Now create a symlink 81 | # 82 | link { 83 | name => "golang:symlink", 84 | source => "/opt/go-${version}", 85 | target => "/opt/go", 86 | require => "golang:unpack", 87 | } 88 | 89 | # 90 | # Show that we're done. 91 | # 92 | log { 93 | message => "/opt/go now points to the binary release for ${OS} [${ARCH}] at ${install}/go-${version}" 94 | require => "golang:symlink" 95 | } 96 | -------------------------------------------------------------------------------- /examples/overview.recipe: -------------------------------------------------------------------------------- 1 | # 2 | # This is an example rule-file for marionette. 3 | # 4 | # The primary purpose of this file is to demonstrate the syntax, and 5 | # give examples of what our rules can do. 6 | # 7 | # As you can guess comments are prefixed with a "#", and they are ignored 8 | # entirely. 9 | # 10 | 11 | 12 | # 13 | # Variables can be defined as simple strings, via the `let` keyword 14 | # 15 | let foo = "bar" 16 | 17 | 18 | # 19 | # Variables can also be defined to contain the output of commands. 20 | # 21 | # NOTE: Any trailing newline will be removed from the output. 22 | # 23 | let today = `date` 24 | 25 | 26 | # 27 | # The general form of our rules is something like this: 28 | # 29 | # $MODULE { 30 | # name => "NAME OF RULE", 31 | # arg_1 => "Value 1 ... ", 32 | # arg_2 => [ "array values", "are fine" ], 33 | # arg_3 => "Value 3 .. ", 34 | # } 35 | # 36 | # 37 | # Where `$MODULE` name is the name of the module which is being used, 38 | # and then there is a hash of "key => values". 39 | # 40 | # It is important to note that rules should have names, as these names are 41 | # used to trigger subsequant rules, or define dependencies. If you 42 | # do not specify a name then one will be auto-generated, but the names 43 | # will be different each time the rule-file is processed. 44 | # 45 | # Each rule contains the appropriate parameters, and values, to drive 46 | # the module. However there are also two magical keys which are global 47 | # and used for defining relationships/dependencies: 48 | # 49 | # require: 50 | # Specify the single, or multiple, rules this depends upon. 51 | # 52 | # notify: 53 | # Specify either a single rule to trigger, or multiple rules to 54 | # trigger, when this rule triggers. 55 | # 56 | # Triggering in this sense means that the rule resulted in a change to 57 | # your system. If you write a rule that says "/tmp/blah" must exist 58 | # and that directory is missing then it will be created, and the rule 59 | # will "notify" any rules which are specified. Once the directory is 60 | # present that will no longer occur. 61 | # 62 | # Finally there are two more magical keys which are used to make a block 63 | # conditional: 64 | # 65 | # if: 66 | # Only run the block if the specified expression is true. 67 | # 68 | # unless: 69 | # Only run the block if the specified expression is false. 70 | # 71 | 72 | 73 | # 74 | # Now we'll start with some simple examples. 75 | # 76 | 77 | # 78 | # We'll create the directory /tmp/test, if it is missing. 79 | # 80 | # There is nothing complex here, as this is a simple example. 81 | # 82 | directory { name => "hello-world", 83 | target => "/tmp/test", 84 | mode => "0755", } 85 | 86 | 87 | # 88 | # Now we'll create a child directory. 89 | # 90 | # (We don't need to do this in two steps, we can create a directory such 91 | # as "/tmp/foo/bar/test/meow/kitten" in one step. Each subdirectory will 92 | # be created as you'd expect.) 93 | # 94 | # Here we're choosing to say that this rule will only occur after the 95 | # previous one. 96 | # 97 | directory { name => "example-two", 98 | target => "/tmp/test/me", 99 | require => "hello-world" } 100 | 101 | # 102 | # NOTE: 103 | # 104 | # If we required two dependencies, or two things to happen before our 105 | # rule was executed we'd use an array: 106 | # 107 | # require => [ "rule-one", "rule-two" ] 108 | # 109 | 110 | # 111 | # Now look at the other kind of relationship we can define, which is 112 | # triggering/notifying other rules when we change. 113 | # 114 | # First of all we'll define a rule with the special "triggered" marker, 115 | # this means the rule will NEVER execute UNLESS it is triggered explicitly 116 | # by name. 117 | # 118 | 119 | shell triggered { name => "test-shell-command", 120 | command => "wc -l /tmp/input > /tmp/output.txt" } 121 | 122 | 123 | # 124 | # With that rule defined we can now create a file "/tmp/input" with 125 | # some fixed content, and explicitly notify the rule it should run. 126 | # 127 | file { name => "test-static-content", 128 | target => "/tmp/input", 129 | content => "This is my file content 130 | The string has inline newlines. 131 | I think three lines is enough 132 | ", 133 | notify => "test-shell-command" } 134 | 135 | # 136 | # As a result of running this two things should have happened: 137 | # 138 | # 1. The file /tmp/input.txt should have our fixed content saved to it. 139 | # 140 | # 2. The file /tmp/output.txt should have been created, because we 141 | # notified the "test-shell-command" rule it should run. 142 | # 143 | # Future runs will change nothing, unless you remove the input file, or 144 | # edit it such that it contains the wrong content. 145 | # 146 | 147 | 148 | # 149 | # We'll now demonstrate backtick usage, in two different ways. 150 | # 151 | # Recall at the top of our file we added: 152 | # 153 | # let today = `date` 154 | # 155 | # We can use that variable as you'd expect to write the date to a file. 156 | # 157 | 158 | file { name => "test-variable", 159 | target => "/tmp/today", 160 | content => "${today}" 161 | } 162 | 163 | 164 | 165 | 166 | # 167 | # That concludes our general introduction. 168 | # 169 | # We've seen: 170 | # 171 | # 1. How to use a module "file", "directory", and "shell". 172 | # 173 | # 2. How to declare dependencies. 174 | # 175 | # 3. How to trigger other rules, and keep them from firing unless 176 | # notified explicitly. (Via the use of the `triggered` token.) 177 | # 178 | # 179 | 180 | 181 | ## 182 | ## Other examples 183 | ## 184 | 185 | # Create a file with content from the given remote URL 186 | # 187 | # Here we see "${foo}" is used, that will expand to the variable defined 188 | # at the top of this file. 189 | # 190 | # Variable expansion applies to all strings used as values in our parameter 191 | # blocks. Keys are not expanded. 192 | # 193 | file { name => "fetch file", 194 | target => "/tmp/${foo}.txt", 195 | source_url => "https://steve.fi/", 196 | 197 | # 198 | # Implied since I run as non-root 199 | # 200 | # owner => "${USER}", 201 | # group => "${USER}", 202 | 203 | notify => [ "I count your lines" ], 204 | } 205 | 206 | 207 | # 208 | # We support simple conditionals for rules, via the magic keys 209 | # "if" and "unless". These allow you to skip rules if a file 210 | # exists, or not. 211 | # 212 | # NOTE: Here the value of `if` and `unless` are simple expressions, 213 | # and they're explicitly not quoted because that would require the 214 | # use of complex escapes if you wanted to compare two values: 215 | # 216 | # "if" => "equals( \"foo\", \"bar\" )", 217 | # 218 | shell { name => "Echo Test", 219 | command => "echo I'm Unix, probably.", 220 | if => exists( "/bin/ls" ) } 221 | 222 | # 223 | # These are some shell-commands. 224 | # 225 | # First one is a repeat of the previous example; it is triggered by the 226 | # download. 227 | # 228 | # The second runs every time. 229 | # 230 | shell triggered { name => "I count your lines", 231 | command => "wc -l /tmp/${foo}.txt > /tmp/line.count" } 232 | 233 | shell { name => "I touch your file.", 234 | command => "touch /tmp/test.me" } 235 | 236 | 237 | # 238 | # Create a symlink /tmp/password.txt, pointing to /etc/passwd. 239 | # 240 | link { name => "Symlink test", 241 | source => "/etc/passwd", 242 | target => "/tmp/password.txt" } 243 | 244 | 245 | # 246 | # Clone a remote repository to the local system. 247 | # 248 | git { path => "/tmp/foot/bar/baz", 249 | repository => "https://github.com/skx/marionette", 250 | branch => "master" } 251 | 252 | # 253 | # Copy this file to a new name. 254 | # 255 | file { name => "copy input.txt to copy.txt", 256 | source => "overview.recipe", 257 | target => "overview.copy" } 258 | 259 | # 260 | # Edit the copied to remove any comments, i.e lines prefixed with "#". 261 | # 262 | edit { name => "drop comments", 263 | target => "overview.copy", 264 | remove_lines => "^#" } 265 | 266 | 267 | # 268 | # Create a new user for the local system, "server", unless it already exists. 269 | # 270 | # NOTE: The use of `echo` means we don't really run the user-creation, as that 271 | # would fail unless you ran the script under sudo. 272 | # 273 | let user = "server" 274 | 275 | shell { 276 | name => "Create user: ${user}", 277 | command => "echo useradd --system --no-create-home ${user}", 278 | unless => success("id ${user}"), 279 | } 280 | -------------------------------------------------------------------------------- /file/file.go: -------------------------------------------------------------------------------- 1 | // Package file contains some simple utility functions. 2 | package file 3 | 4 | import ( 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "io" 8 | "os" 9 | ) 10 | 11 | // Copy copies the contents of the source file into the destination file. 12 | func Copy(src string, dst string) error { 13 | in, err := os.Open(src) 14 | if err != nil { 15 | return err 16 | } 17 | defer in.Close() 18 | 19 | out, err := os.Create(dst) 20 | if err != nil { 21 | return err 22 | } 23 | defer out.Close() 24 | 25 | _, err = io.Copy(out, in) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | // We changed 31 | return out.Close() 32 | } 33 | 34 | // Exists reports whether the named file or directory exists. 35 | func Exists(name string) bool { 36 | if _, err := os.Stat(name); err != nil { 37 | if os.IsNotExist(err) { 38 | return false 39 | } 40 | } 41 | return true 42 | } 43 | 44 | // Size returns the named files size. 45 | func Size(name string) (int64, error) { 46 | fi, err := os.Stat(name) 47 | 48 | if err != nil { 49 | return 0, err 50 | } 51 | 52 | return fi.Size(), nil 53 | } 54 | 55 | // HashFile returns the SHA1-hash of the contents of the specified file. 56 | func HashFile(filePath string) (string, error) { 57 | var returnSHA1String string 58 | 59 | file, err := os.Open(filePath) 60 | if err != nil { 61 | return returnSHA1String, err 62 | } 63 | 64 | defer file.Close() 65 | 66 | hash := sha1.New() 67 | 68 | if _, err := io.Copy(hash, file); err != nil { 69 | return returnSHA1String, err 70 | } 71 | 72 | hashInBytes := hash.Sum(nil)[:20] 73 | returnSHA1String = hex.EncodeToString(hashInBytes) 74 | 75 | return returnSHA1String, nil 76 | } 77 | 78 | // Identical compares the contents of the two specified files, returning 79 | // true if they're identical. 80 | func Identical(a string, b string) (bool, error) { 81 | 82 | hashA, errA := HashFile(a) 83 | if errA != nil { 84 | return false, errA 85 | } 86 | 87 | hashB, errB := HashFile(b) 88 | if errB != nil { 89 | return false, errB 90 | } 91 | 92 | // Are the hashes are identical? 93 | // If so then the files are identical. 94 | if hashA == hashB { 95 | return true, nil 96 | } 97 | 98 | return false, nil 99 | } 100 | -------------------------------------------------------------------------------- /file/file_chown_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package file 5 | 6 | import ( 7 | "os" 8 | "os/user" 9 | "strconv" 10 | "syscall" 11 | ) 12 | 13 | // ChangeMode changes the mode of the given file/directory to the 14 | // specified value. 15 | // 16 | // If the mode was changed, this function will return true. 17 | func ChangeMode(path string, mode string) (bool, error) { 18 | 19 | // Get the mode as an integer. 20 | // 21 | // NOTE: We expect octal input. 22 | m, _ := strconv.ParseInt(mode, 8, 64) 23 | 24 | // Get the details of the file, so we can see if we need 25 | // to change owner, group, and mode. 26 | info, err := os.Stat(path) 27 | if err != nil { 28 | return false, err 29 | } 30 | 31 | // If the mode doesn't match what we expect then change it 32 | if info.Mode().Perm() != os.FileMode(m) { 33 | err = os.Chmod(path, os.FileMode(m)) 34 | if err != nil { 35 | return false, err 36 | } 37 | 38 | return true, nil 39 | } 40 | 41 | return false, nil 42 | } 43 | 44 | // ChangeOwner changes the owner of the given file/directory to 45 | // the specified value. 46 | // 47 | // If the ownership was changed this function will return true. 48 | // 49 | func ChangeOwner(path string, owner string) (bool, error) { 50 | 51 | // Get the details of the file, so we can see if we need 52 | // to change owner, group, and mode. 53 | info, err := os.Stat(path) 54 | if err != nil { 55 | return false, err 56 | } 57 | 58 | // Get the user-details of who we should change to. 59 | var data *user.User 60 | data, err = user.Lookup(owner) 61 | if err != nil { 62 | return false, err 63 | } 64 | 65 | // Existing values 66 | UID := int(info.Sys().(*syscall.Stat_t).Uid) 67 | GID := int(info.Sys().(*syscall.Stat_t).Gid) 68 | 69 | // proposed owner 70 | uid, _ := strconv.Atoi(data.Uid) 71 | 72 | if uid != UID { 73 | err = os.Chown(path, uid, GID) 74 | return true, err 75 | } 76 | 77 | return false, nil 78 | } 79 | 80 | // ChangeGroup changes the group of the given file/directory to 81 | // the specified value. 82 | // 83 | // If the ownership was changed this function will return true. 84 | // 85 | func ChangeGroup(path string, group string) (bool, error) { 86 | 87 | // Get the details of the file, so we can see if we need 88 | // to change owner, group, and mode. 89 | info, err := os.Stat(path) 90 | if err != nil { 91 | return false, err 92 | } 93 | 94 | // Get the user-details of who we should change to. 95 | var data *user.User 96 | data, err = user.Lookup(group) 97 | if err != nil { 98 | return false, err 99 | } 100 | 101 | // Existing values 102 | UID := int(info.Sys().(*syscall.Stat_t).Uid) 103 | GID := int(info.Sys().(*syscall.Stat_t).Gid) 104 | 105 | // proposed owner 106 | gid, _ := strconv.Atoi(data.Gid) 107 | 108 | if gid != GID { 109 | err = os.Chown(path, UID, gid) 110 | 111 | return true, err 112 | } 113 | 114 | return false, nil 115 | } 116 | -------------------------------------------------------------------------------- /file/file_chown_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package file 5 | 6 | // ChangeMode is a NOP on Microsoft Windows 7 | func ChangeMode(path string, mode string) (bool, error) { 8 | return false, nil 9 | } 10 | 11 | // ChangeOwner is a NOP on Microsoft Windows. 12 | func ChangeOwner(path string, group string) (bool, error) { 13 | return false, nil 14 | } 15 | 16 | // ChangeGroup is a NOP on Microsoft Windows. 17 | func ChangeGroup(path string, group string) (bool, error) { 18 | return false, nil 19 | } 20 | -------------------------------------------------------------------------------- /file/file_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | // TestExists ensures we report on file existence. 10 | func TestExists(t *testing.T) { 11 | 12 | // Create a file, and ensure it exists 13 | tmpfile, err := ioutil.TempFile("", "marionette-") 14 | if err != nil { 15 | t.Fatalf("create a temporary file failed") 16 | } 17 | 18 | // Does it exist 19 | res := Exists(tmpfile.Name()) 20 | if !res { 21 | t.Fatalf("after creating a temporary file it doesnt exist") 22 | } 23 | 24 | // Remove the file 25 | os.Remove(tmpfile.Name()) 26 | 27 | // Does it exist, still? 28 | res = Exists(tmpfile.Name()) 29 | if res { 30 | t.Fatalf("after removing a temporary file it still exists") 31 | } 32 | } 33 | 34 | // TestHash tests our hashing function 35 | func TestHash(t *testing.T) { 36 | 37 | type Test struct { 38 | input string 39 | output string 40 | } 41 | 42 | tests := []Test{{input: "hello", output: "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"}, 43 | {input: "steve", output: "9ce5770b3bb4b2a1d59be2d97e34379cd192299f"}, 44 | } 45 | 46 | for _, test := range tests { 47 | 48 | // Create a file with the given content 49 | tmpfile, err := ioutil.TempFile("", "marionette-") 50 | if err != nil { 51 | t.Fatalf("create a temporary file failed") 52 | } 53 | 54 | // Write the input 55 | _, err = tmpfile.Write([]byte(test.input)) 56 | if err != nil { 57 | t.Fatalf("error writing temporary file") 58 | } 59 | 60 | out := "" 61 | out, err = HashFile(tmpfile.Name()) 62 | if err != nil { 63 | t.Fatalf("failed to hash file") 64 | } 65 | 66 | if out != test.output { 67 | t.Fatalf("invalid hash %s != %s", out, test.output) 68 | } 69 | 70 | os.Remove(tmpfile.Name()) 71 | } 72 | 73 | // Hashing a missing file should fail 74 | _, err := HashFile("/this/does/not/exist") 75 | if err == nil { 76 | t.Fatalf("should have seen an error, didn't") 77 | } 78 | } 79 | 80 | // TestIdentical checks our identical file handling. 81 | func TestIdentical(t *testing.T) { 82 | 83 | // create a pair of files 84 | a, err := ioutil.TempFile("", "marionette-") 85 | if err != nil { 86 | t.Fatalf("create a temporary file failed") 87 | } 88 | var b *os.File 89 | b, err = ioutil.TempFile("", "marionette-") 90 | if err != nil { 91 | t.Fatalf("create a temporary file failed") 92 | } 93 | 94 | // Two identical files 95 | out, err := Identical(a.Name(), b.Name()) 96 | if err != nil { 97 | t.Fatalf("unexpected error comparing files") 98 | } 99 | if !out { 100 | t.Fatalf("two files should be identical") 101 | } 102 | 103 | // Left missing 104 | _, err = Identical(a.Name()+"foo", b.Name()) 105 | if err == nil { 106 | t.Fatalf("expected error comparing a missing file") 107 | } 108 | 109 | // Right missing 110 | _, err = Identical(a.Name(), b.Name()+"foo") 111 | if err == nil { 112 | t.Fatalf("expected error comparing a missing file") 113 | } 114 | 115 | // Now write some data to one file 116 | _, err = a.Write([]byte("random data")) 117 | if err != nil { 118 | t.Fatalf("error writing temporary file") 119 | } 120 | 121 | // Now we have two different files 122 | out, err = Identical(a.Name(), b.Name()) 123 | if err != nil { 124 | t.Fatalf("unexpected error comparing files") 125 | } 126 | if out { 127 | t.Fatalf("two files should be different") 128 | } 129 | 130 | // Cleanup 131 | os.Remove(a.Name()) 132 | os.Remove(b.Name()) 133 | } 134 | 135 | // TestCopy does minimal testing of the Copy function. 136 | func TestCopy(t *testing.T) { 137 | 138 | // create a pair of files 139 | a, err := ioutil.TempFile("", "marionette-") 140 | if err != nil { 141 | t.Fatalf("create a temporary file failed") 142 | } 143 | var b *os.File 144 | b, err = ioutil.TempFile("", "marionette-") 145 | if err != nil { 146 | t.Fatalf("create a temporary file failed") 147 | } 148 | 149 | // Two files 150 | err = Copy(a.Name(), b.Name()) 151 | if err != nil { 152 | t.Errorf("found unexpected error copying files") 153 | } 154 | 155 | // Source missing 156 | err = Copy(a.Name()+"foo", b.Name()) 157 | if err == nil { 158 | t.Errorf("expected error copying missing source") 159 | } 160 | 161 | // Destination invalid 162 | err = Copy(a.Name(), "/path/to/file/not/found"+b.Name()) 163 | if err == nil { 164 | t.Errorf("expected error copying missing destination directory") 165 | } 166 | 167 | // Cleanup 168 | os.Remove(a.Name()) 169 | os.Remove(b.Name()) 170 | } 171 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/skx/marionette 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.5.2 // indirect 7 | github.com/containerd/containerd v1.6.1 // indirect 8 | github.com/docker/distribution v2.8.0+incompatible // indirect 9 | github.com/docker/docker v20.10.12+incompatible 10 | github.com/go-sql-driver/mysql v1.6.0 11 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 12 | github.com/google/uuid v1.3.0 13 | github.com/gorilla/mux v1.7.4 // indirect 14 | github.com/hashicorp/logutils v1.0.0 15 | github.com/kevinburke/ssh_config v1.1.0 // indirect 16 | github.com/lib/pq v1.10.4 17 | github.com/mattn/go-sqlite3 v1.14.12 18 | github.com/opencontainers/image-spec v1.0.2 // indirect 19 | github.com/sergi/go-diff v1.2.0 // indirect 20 | github.com/xanzy/ssh-agent v0.3.1 // indirect 21 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect 22 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect 23 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect 24 | google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8 // indirect 25 | gopkg.in/src-d/go-git.v4 v4.13.1 26 | ) 27 | -------------------------------------------------------------------------------- /lexer/lexer.go: -------------------------------------------------------------------------------- 1 | // Package lexer contains a simple lexer for reading an input-string 2 | // and converting it into a series of tokens. 3 | // 4 | // In terms of syntax we're not very complex, so our lexer only needs 5 | // to care about simple tokens: 6 | // 7 | // - Comments 8 | // - Strings 9 | // - Some simple characters such as "(", ")", "[", "]", "=>", "=", etc. 10 | // - 11 | // 12 | // We can catch some basic errors in the lexing stage, such as unterminated 13 | // strings, but the parser is the better place to catch such things. 14 | package lexer 15 | 16 | import ( 17 | "errors" 18 | "fmt" 19 | "os" 20 | "strconv" 21 | "strings" 22 | "unicode" 23 | 24 | "github.com/skx/marionette/token" 25 | ) 26 | 27 | // Lexer is used as the lexer for our deployr "language". 28 | type Lexer struct { 29 | debug bool // dump tokens as they're read? 30 | decimal bool // convert numbers to decimal? 31 | position int // current character position 32 | readPosition int // next character position 33 | ch rune // current character 34 | characters []rune // rune slice of input string 35 | lookup map[rune]token.Token // lookup map for simple tokens 36 | } 37 | 38 | // New a Lexer instance from string input. 39 | func New(input string) *Lexer { 40 | l := &Lexer{ 41 | characters: []rune(input), 42 | debug: false, 43 | decimal: false, 44 | lookup: make(map[rune]token.Token), 45 | } 46 | l.readChar() 47 | 48 | if os.Getenv("DEBUG_LEXER") == "true" { 49 | l.debug = true 50 | } 51 | if os.Getenv("DECIMAL_NUMBERS") == "true" { 52 | l.decimal = true 53 | } 54 | 55 | // 56 | // Lookup map of simple token-types. 57 | // 58 | l.lookup['('] = token.Token{Literal: "(", Type: token.LPAREN} 59 | l.lookup[')'] = token.Token{Literal: ")", Type: token.RPAREN} 60 | l.lookup['['] = token.Token{Literal: "[", Type: token.LSQUARE} 61 | l.lookup[']'] = token.Token{Literal: "]", Type: token.RSQUARE} 62 | l.lookup['{'] = token.Token{Literal: "{", Type: token.LBRACE} 63 | l.lookup['}'] = token.Token{Literal: "}", Type: token.RBRACE} 64 | l.lookup[','] = token.Token{Literal: ",", Type: token.COMMA} 65 | l.lookup[rune(0)] = token.Token{Literal: "", Type: token.EOF} 66 | 67 | return l 68 | } 69 | 70 | // read one forward character 71 | func (l *Lexer) readChar() { 72 | if l.readPosition >= len(l.characters) { 73 | l.ch = rune(0) 74 | } else { 75 | l.ch = l.characters[l.readPosition] 76 | } 77 | l.position = l.readPosition 78 | l.readPosition++ 79 | } 80 | 81 | // NextToken consumes and returns the next token from our input. 82 | // 83 | // It is a simple method which can optionally dump the tokens to the console 84 | // if $DEBUG_LEXER is non-empty. 85 | func (l *Lexer) NextToken() token.Token { 86 | 87 | tok := l.nextTokenReal() 88 | if l.debug { 89 | fmt.Printf("%v\n", tok) 90 | } 91 | 92 | return tok 93 | } 94 | 95 | // nextTokenReal does the real work of consuming and returning the next 96 | // token from our input string. 97 | func (l *Lexer) nextTokenReal() token.Token { 98 | var tok token.Token 99 | l.skipWhitespace() 100 | 101 | // skip single-line comments 102 | // 103 | // This also skips the shebang line at the start of a file - as 104 | // "#!/usr/bin/blah" is treated as a comment. 105 | if l.ch == rune('#') { 106 | l.skipComment() 107 | return (l.NextToken()) 108 | } 109 | 110 | // Semi-colons are skipped, always. 111 | if l.ch == rune(';') { 112 | l.readChar() 113 | return (l.NextToken()) 114 | } 115 | 116 | // Was this a simple token-type? 117 | val, ok := l.lookup[l.ch] 118 | if ok { 119 | // Yes, then skip the character itself, and return the 120 | // value we found. 121 | l.readChar() 122 | return val 123 | 124 | } 125 | 126 | // OK it wasn't a simple type 127 | switch l.ch { 128 | case rune('='): 129 | tok.Literal = "=" 130 | tok.Type = token.ASSIGN 131 | if l.peekChar() == rune('>') { 132 | l.readChar() 133 | 134 | tok.Type = token.LASSIGN 135 | tok.Literal = "=>" 136 | } 137 | case rune('`'): 138 | str, err := l.readBacktick() 139 | 140 | if err == nil { 141 | tok.Type = token.BACKTICK 142 | tok.Literal = str 143 | } else { 144 | tok.Type = token.ILLEGAL 145 | tok.Literal = err.Error() 146 | } 147 | case rune('"'): 148 | str, err := l.readString() 149 | 150 | if err == nil { 151 | tok.Type = token.STRING 152 | tok.Literal = str 153 | } else { 154 | tok.Type = token.ILLEGAL 155 | tok.Literal = err.Error() 156 | } 157 | default: 158 | // is it a number? 159 | if l.ch == '-' || l.ch == '+' || isDigit(l.ch) { 160 | // Read it. 161 | tok = l.readDecimal() 162 | return tok 163 | } 164 | 165 | // is it an ident? 166 | tok.Literal = l.readIdentifier() 167 | tok.Type = token.IDENT 168 | 169 | // We don't have keywords, but we'll convert 170 | // the ident "true" or "false" into a boolean-type. 171 | if tok.Literal == "true" || tok.Literal == "false" { 172 | tok.Type = token.BOOLEAN 173 | } 174 | 175 | return tok 176 | } 177 | 178 | // skip the character we've processed, and return the value 179 | l.readChar() 180 | return tok 181 | } 182 | 183 | // readDecimal returns a token consisting of decimal numbers, base 10, 2, or 184 | // 16. 185 | func (l *Lexer) readDecimal() token.Token { 186 | 187 | str := "" 188 | 189 | // We usually just accept digits, plus the negative unary marker. 190 | accept := "-+0123456789" 191 | 192 | // But if we have `0x` as a prefix we accept hexadecimal instead. 193 | if l.ch == '0' && l.peekChar() == 'x' { 194 | accept = "0x123456789abcdefABCDEF" 195 | } 196 | 197 | // If we have `0b` as a prefix we accept binary digits only. 198 | if l.ch == '0' && l.peekChar() == 'b' { 199 | accept = "b01" 200 | } 201 | 202 | // While we have a valid character append it to our 203 | // result and keep reading/consuming characters. 204 | for strings.Contains(accept, string(l.ch)) { 205 | str += string(l.ch) 206 | l.readChar() 207 | } 208 | 209 | // If we have a `-` or `+` it can only occur at the beginning 210 | for _, chr := range []string{"-", "+"} { 211 | if strings.Contains(str, chr) { 212 | if !strings.HasPrefix(str, chr) { 213 | return token.Token{ 214 | Type: token.ILLEGAL, 215 | Literal: "'" + chr + "' may only occur at the start of the number", 216 | } 217 | } 218 | } 219 | } 220 | 221 | // Don't convert the number to decimal - just use the literal value. 222 | if !l.decimal { 223 | return token.Token{Type: token.NUMBER, Literal: str} 224 | } 225 | 226 | // OK convert to an integer, which we'll later turn to a string. 227 | // 228 | // We do this so we can convert 0xff -> "255", or "0b0011" to "3". 229 | val, err := strconv.ParseInt(str, 0, 64) 230 | if err != nil { 231 | tok := token.Token{Type: token.ILLEGAL, Literal: err.Error()} 232 | return tok 233 | } 234 | 235 | // Now return that number as a string. 236 | return token.Token{Type: token.NUMBER, Literal: fmt.Sprintf("%d", val)} 237 | } 238 | 239 | // read Identifier 240 | func (l *Lexer) readIdentifier() string { 241 | position := l.position 242 | for isIdentifier(l.ch) { 243 | l.readChar() 244 | } 245 | return string(l.characters[position:l.position]) 246 | } 247 | 248 | // skip white space 249 | func (l *Lexer) skipWhitespace() { 250 | for isWhitespace(l.ch) { 251 | l.readChar() 252 | } 253 | } 254 | 255 | // skip comment (until the end of the line). 256 | func (l *Lexer) skipComment() { 257 | for l.ch != '\n' && l.ch != rune(0) { 258 | l.readChar() 259 | } 260 | l.skipWhitespace() 261 | } 262 | 263 | // read string 264 | func (l *Lexer) readString() (string, error) { 265 | out := "" 266 | 267 | for { 268 | l.readChar() 269 | if l.ch == '"' { 270 | break 271 | } 272 | if l.ch == rune(0) { 273 | return "", errors.New("unterminated string") 274 | } 275 | 276 | // 277 | // Handle \n, \r, \t, \", etc. 278 | // 279 | if l.ch == '\\' { 280 | 281 | // Line ending with "\" + newline 282 | if l.peekChar() == '\n' { 283 | // consume the newline. 284 | l.readChar() 285 | continue 286 | } 287 | 288 | l.readChar() 289 | 290 | if l.ch == rune('n') { 291 | l.ch = '\n' 292 | } 293 | if l.ch == rune('r') { 294 | l.ch = '\r' 295 | } 296 | if l.ch == rune('t') { 297 | l.ch = '\t' 298 | } 299 | if l.ch == rune('"') { 300 | l.ch = '"' 301 | } 302 | if l.ch == rune('\\') { 303 | l.ch = '\\' 304 | } 305 | } 306 | out = out + string(l.ch) 307 | 308 | } 309 | 310 | return out, nil 311 | } 312 | 313 | // read a backtick-enquoted string 314 | func (l *Lexer) readBacktick() (string, error) { 315 | out := "" 316 | 317 | for { 318 | l.readChar() 319 | if l.ch == '`' { 320 | break 321 | } 322 | if l.ch == rune(0) { 323 | return "", errors.New("unterminated backtick") 324 | } 325 | out = out + string(l.ch) 326 | } 327 | 328 | return out, nil 329 | } 330 | 331 | // peek ahead at the next character 332 | func (l *Lexer) peekChar() rune { 333 | if l.readPosition >= len(l.characters) { 334 | return rune(0) 335 | } 336 | return l.characters[l.readPosition] 337 | } 338 | 339 | // determinate whether the given character is legal within an identifier or not. 340 | // 341 | // This is very permissive. 342 | func isIdentifier(ch rune) bool { 343 | return !isWhitespace(ch) && 344 | ch != rune(',') && 345 | ch != rune('(') && 346 | ch != rune(')') && 347 | ch != rune('{') && 348 | ch != rune('}') && 349 | ch != rune('=') && 350 | ch != rune(';') && 351 | !isEmpty(ch) 352 | } 353 | 354 | // Is the character white space? 355 | func isWhitespace(ch rune) bool { 356 | return unicode.IsSpace(ch) 357 | } 358 | 359 | // Is the given character empty? 360 | func isEmpty(ch rune) bool { 361 | return rune(0) == ch 362 | } 363 | 364 | // Is the given character a digit? 365 | func isDigit(ch rune) bool { 366 | return rune('0') <= ch && ch <= rune('9') 367 | } 368 | -------------------------------------------------------------------------------- /lexer/lexer_test.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/skx/marionette/token" 9 | ) 10 | 11 | // TestEmpty tests a couple of different empty strings 12 | func TestEmpty(t *testing.T) { 13 | empty := []string{ 14 | ";;;;;;;;;;;;;;;", 15 | "", 16 | "#!/usr/bin/blah", 17 | "#!/usr/bin/blah\n# Comment1\n# Comment2", 18 | } 19 | 20 | for _, line := range empty { 21 | lexer := New(line) 22 | result := lexer.NextToken() 23 | 24 | if result.Type != token.EOF { 25 | t.Fatalf("First token of empty input is %v", result) 26 | } 27 | } 28 | 29 | } 30 | 31 | // TestAssign tests we can assign something. 32 | func TestAssign(t *testing.T) { 33 | 34 | // Setup debugging 35 | old := os.Getenv("DEBUG_LEXER") 36 | os.Setenv("DEBUG_LEXER", "true") 37 | input := `let foo = "steve"; 38 | let bar = true; 39 | let baz = false;` 40 | 41 | tests := []struct { 42 | expectedType token.Type 43 | expectedLiteral string 44 | }{ 45 | {token.IDENT, "let"}, 46 | {token.IDENT, "foo"}, 47 | {token.ASSIGN, "="}, 48 | {token.STRING, "steve"}, 49 | {token.IDENT, "let"}, 50 | {token.IDENT, "bar"}, 51 | {token.ASSIGN, "="}, 52 | {token.BOOLEAN, "true"}, 53 | {token.IDENT, "let"}, 54 | {token.IDENT, "baz"}, 55 | {token.ASSIGN, "="}, 56 | {token.BOOLEAN, "false"}, 57 | {token.EOF, ""}, 58 | } 59 | l := New(input) 60 | for i, tt := range tests { 61 | tok := l.NextToken() 62 | if tok.Literal != tt.expectedLiteral { 63 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 64 | } 65 | if tok.Type != tt.expectedType { 66 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 67 | 68 | } 69 | } 70 | os.Setenv("DEBUG_LEXER", old) 71 | } 72 | 73 | // TestEscape ensures that strings have escape-characters processed. 74 | func TestStringEscape(t *testing.T) { 75 | input := `"Steve\n\r\\" "Kemp\n\t\n" "Inline \"quotes\"."` 76 | 77 | tests := []struct { 78 | expectedType token.Type 79 | expectedLiteral string 80 | }{ 81 | {token.STRING, "Steve\n\r\\"}, 82 | {token.STRING, "Kemp\n\t\n"}, 83 | {token.STRING, "Inline \"quotes\"."}, 84 | {token.EOF, ""}, 85 | } 86 | l := New(input) 87 | for i, tt := range tests { 88 | tok := l.NextToken() 89 | if tok.Type != tt.expectedType { 90 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 91 | } 92 | if tok.Literal != tt.expectedLiteral { 93 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 94 | } 95 | } 96 | } 97 | 98 | // TestComments ensures that single-line comments work. 99 | func TestComments(t *testing.T) { 100 | input := `# This is a comment 101 | "Steve" 102 | # This is another comment` 103 | 104 | tests := []struct { 105 | expectedType token.Type 106 | expectedLiteral string 107 | }{ 108 | {token.STRING, "Steve"}, 109 | {token.EOF, ""}, 110 | } 111 | l := New(input) 112 | for i, tt := range tests { 113 | tok := l.NextToken() 114 | if tok.Type != tt.expectedType { 115 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 116 | } 117 | if tok.Literal != tt.expectedLiteral { 118 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 119 | } 120 | } 121 | } 122 | 123 | // TestShebang skips the shebang 124 | func TestShebang(t *testing.T) { 125 | input := `#!/usr/bin/env marionette 126 | "Steve" 127 | # This is another comment` 128 | 129 | tests := []struct { 130 | expectedType token.Type 131 | expectedLiteral string 132 | }{ 133 | {token.STRING, "Steve"}, 134 | {token.EOF, ""}, 135 | } 136 | l := New(input) 137 | for i, tt := range tests { 138 | tok := l.NextToken() 139 | if tok.Type != tt.expectedType { 140 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 141 | } 142 | if tok.Literal != tt.expectedLiteral { 143 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 144 | } 145 | } 146 | } 147 | 148 | // TestUnterminatedString ensures that an unclosed-string is an error 149 | func TestUnterminatedString(t *testing.T) { 150 | input := `#!/usr/bin/env marionette 151 | "Steve` 152 | 153 | tests := []struct { 154 | expectedType token.Type 155 | expectedLiteral string 156 | }{ 157 | {token.ILLEGAL, "unterminated string"}, 158 | {token.EOF, ""}, 159 | } 160 | l := New(input) 161 | for i, tt := range tests { 162 | tok := l.NextToken() 163 | if tok.Type != tt.expectedType { 164 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 165 | } 166 | if tok.Literal != tt.expectedLiteral { 167 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 168 | } 169 | } 170 | } 171 | 172 | // TestBacktick string ensures that an backtick-string is OK. 173 | func TestBacktick(t *testing.T) { 174 | input := "`ls`" 175 | 176 | tests := []struct { 177 | expectedType token.Type 178 | expectedLiteral string 179 | }{ 180 | {token.BACKTICK, "ls"}, 181 | {token.EOF, ""}, 182 | } 183 | l := New(input) 184 | for i, tt := range tests { 185 | tok := l.NextToken() 186 | if tok.Type != tt.expectedType { 187 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 188 | } 189 | if tok.Literal != tt.expectedLiteral { 190 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 191 | } 192 | } 193 | } 194 | 195 | // TestUnterminatedBacktick string ensures that an unclosed-backtick is an error 196 | func TestUnterminatedBacktick(t *testing.T) { 197 | input := "`Steve" 198 | 199 | tests := []struct { 200 | expectedType token.Type 201 | expectedLiteral string 202 | }{ 203 | {token.ILLEGAL, "unterminated backtick"}, 204 | {token.EOF, ""}, 205 | } 206 | l := New(input) 207 | for i, tt := range tests { 208 | tok := l.NextToken() 209 | if tok.Type != tt.expectedType { 210 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 211 | } 212 | if tok.Literal != tt.expectedLiteral { 213 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 214 | } 215 | } 216 | } 217 | 218 | // TestContinue checks we continue newlines. 219 | func TestContinue(t *testing.T) { 220 | input := `#!/usr/bin/env marionette 221 | "This is a test \ 222 | which continues" 223 | ` 224 | 225 | tests := []struct { 226 | expectedType token.Type 227 | expectedLiteral string 228 | }{ 229 | {token.STRING, "This is a test which continues"}, 230 | {token.EOF, ""}, 231 | } 232 | l := New(input) 233 | for i, tt := range tests { 234 | tok := l.NextToken() 235 | if tok.Type != tt.expectedType { 236 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 237 | } 238 | if tok.Literal != tt.expectedLiteral { 239 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 240 | } 241 | } 242 | } 243 | 244 | // TestSpecial ensures we can recognize special characters. 245 | func TestSpecial(t *testing.T) { 246 | input := `[]{},=>=()` 247 | 248 | tests := []struct { 249 | expectedType token.Type 250 | expectedLiteral string 251 | }{ 252 | {token.LSQUARE, "["}, 253 | {token.RSQUARE, "]"}, 254 | {token.LBRACE, "{"}, 255 | {token.RBRACE, "}"}, 256 | {token.COMMA, ","}, 257 | {token.LASSIGN, "=>"}, 258 | {token.ASSIGN, "="}, 259 | {token.LPAREN, "("}, 260 | {token.RPAREN, ")"}, 261 | {token.EOF, ""}, 262 | } 263 | l := New(input) 264 | for i, tt := range tests { 265 | tok := l.NextToken() 266 | if tok.Type != tt.expectedType { 267 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 268 | } 269 | if tok.Literal != tt.expectedLiteral { 270 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 271 | } 272 | } 273 | } 274 | 275 | // Test15Assignment ensures that bug #15 is resolved 276 | // https://github.com/skx/marionette/issues/15 277 | func Test15Assignment(t *testing.T) { 278 | input := `let foo="bar"` 279 | 280 | tests := []struct { 281 | expectedType token.Type 282 | expectedLiteral string 283 | }{ 284 | {token.IDENT, "let"}, 285 | {token.IDENT, "foo"}, 286 | {token.ASSIGN, "="}, 287 | {token.STRING, "bar"}, 288 | {token.EOF, ""}, 289 | } 290 | l := New(input) 291 | for i, tt := range tests { 292 | tok := l.NextToken() 293 | if tok.Type != tt.expectedType { 294 | t.Fatalf("tests[%d] - tokentype wrong, expected=%q, got=%q", i, tt.expectedType, tok.Type) 295 | } 296 | if tok.Literal != tt.expectedLiteral { 297 | t.Fatalf("tests[%d] - Literal wrong, expected=%q, got=%q", i, tt.expectedLiteral, tok.Literal) 298 | } 299 | } 300 | 301 | // 302 | // We've parsed the whole input. 303 | // 304 | // Reading more should just return \0. 305 | // 306 | i := 0 307 | for i < 10 { 308 | 309 | p := l.peekChar() 310 | if p != rune(0) { 311 | t.Errorf("after reading past input we didn't get null") 312 | } 313 | i++ 314 | } 315 | } 316 | 317 | // TestParseNumber ensures we can catch errors in numbers 318 | // 319 | // This includes handling the unary +/- prefixes which might be present. 320 | func TestParseNumber(t *testing.T) { 321 | 322 | // Parsing a number 323 | lex := New("449691189") 324 | tok := lex.NextToken() 325 | 326 | if tok.Type != token.NUMBER { 327 | t.Fatalf("parsed number as wrong type") 328 | } 329 | if tok.Literal != "449691189" { 330 | t.Fatalf("error lexing got:%s", tok.Literal) 331 | } 332 | 333 | // Now a number that's out of range. 334 | lex = New("18446744073709551620") 335 | lex.decimal = true 336 | 337 | tok = lex.NextToken() 338 | 339 | if tok.Type != token.ILLEGAL { 340 | t.Fatalf("parsed number as wrong type") 341 | } 342 | if !strings.Contains(tok.Literal, "out of range") { 343 | t.Fatalf("got error, but wrong one: %s", tok.Literal) 344 | } 345 | 346 | // Now a malformed number 347 | lex = New("10-10") 348 | tok = lex.NextToken() 349 | if tok.Type != token.ILLEGAL { 350 | t.Fatalf("parsed number as wrong type") 351 | } 352 | if !strings.Contains(tok.Literal, "'-' may only occur at the start of the number") { 353 | t.Fatalf("got error, but wrong one: %s", tok.Literal) 354 | } 355 | 356 | // Another malformed number 357 | lex = New("10+10") 358 | tok = lex.NextToken() 359 | if tok.Type != token.ILLEGAL { 360 | t.Fatalf("parsed number as wrong type") 361 | } 362 | if !strings.Contains(tok.Literal, "'+' may only occur at the start of the number") { 363 | t.Fatalf("got error, but wrong one: %s", tok.Literal) 364 | } 365 | 366 | } 367 | 368 | // TestInteger tests that we parse integers appropriately. 369 | // 370 | // This includes handling the unary +/- prefixes which might be present. 371 | func TestInteger(t *testing.T) { 372 | 373 | old := os.Getenv("DECIMAL_NUMBERS") 374 | os.Setenv("DECIMAL_NUMBERS", "true") 375 | 376 | type TestCase struct { 377 | input string 378 | output string 379 | } 380 | 381 | tests := []TestCase{ 382 | {input: "3", output: "3"}, 383 | {input: "+3", output: "3"}, 384 | {input: "-3", output: "-3"}, 385 | {input: "-0", output: "0"}, 386 | {input: "+0", output: "0"}, 387 | {input: "-10", output: "-10"}, 388 | {input: "0xff", output: "255"}, 389 | {input: "0b11111111", output: "255"}, 390 | } 391 | 392 | for _, tst := range tests { 393 | 394 | lex := New(tst.input) 395 | tok := lex.NextToken() 396 | 397 | if tok.Type != token.NUMBER { 398 | t.Fatalf("failed to parse '%s' as number: %s", tst.input, tok) 399 | } 400 | if tok.Literal != tst.output { 401 | t.Fatalf("error lexing %s - expected:%s got:%s", tst.input, tst.output, tok.Literal) 402 | } 403 | } 404 | 405 | os.Setenv("DECIMAL_NUMBERS", old) 406 | } 407 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // This is the simple driver to execute the named file(s). 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | 11 | "github.com/hashicorp/logutils" 12 | "github.com/skx/marionette/config" 13 | "github.com/skx/marionette/executor" 14 | "github.com/skx/marionette/parser" 15 | ) 16 | 17 | func runFile(filename string, cfg *config.Config) error { 18 | 19 | // Read the file contents. 20 | data, err := ioutil.ReadFile(filename) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | // Create a new parser with our file content. 26 | p := parser.New(string(data)) 27 | 28 | // Parse the rules 29 | out, err := p.Parse() 30 | if err != nil { 31 | return err 32 | } 33 | 34 | // Now we'll create an executor with the program 35 | ex := executor.New(out.Recipe) 36 | 37 | // Set the configuration options. 38 | ex.SetConfig(cfg) 39 | 40 | // Mark the file as having been processed. 41 | ex.MarkSeen(filename) 42 | 43 | // Set "magic" variables for the current include file. 44 | err = ex.SetMagicIncludeVars(filename) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | // Check for broken dependencies 50 | err = ex.Check() 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // Now execute! 56 | err = ex.Execute() 57 | if err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // main is our entry-point 65 | func main() { 66 | 67 | // Parse our command-line flags. 68 | dL := flag.Bool("dl", false, "Debug the lexer?") 69 | dP := flag.Bool("dp", false, "Debug the parser?") 70 | 71 | decimal := flag.Bool("decimal", true, "Convert numbers to decimal, automatically.") 72 | debug := flag.Bool("debug", false, "Be very verbose in logging.") 73 | verbose := flag.Bool("verbose", false, "Show logs when executing.") 74 | version := flag.Bool("version", false, "Show our version number.") 75 | flag.Parse() 76 | 77 | // If we're showing the version, then do so and exit 78 | if *version { 79 | showVersion() 80 | return 81 | } 82 | 83 | // The lexer and parser can optionally output information 84 | // to the console. 85 | // 86 | // These decide whether to do this via environmental variables 87 | // if we've been given the appropriate flags then we set those 88 | // variables here. 89 | if *dL { 90 | os.Setenv("DEBUG_LEXER", "true") 91 | } 92 | if *dP { 93 | os.Setenv("DEBUG_PARSER", "true") 94 | } 95 | if *decimal { 96 | os.Setenv("DECIMAL_NUMBERS", "true") 97 | } 98 | 99 | // 100 | // By default we set the log-level to "USER", which will 101 | // allow the user-generated messages from our log-module 102 | // to be visible. 103 | // 104 | // If we're running with -verbose we'll show "INFO", and 105 | // if we're called with -debug we'll show DEBUG 106 | // running verbosely we'll show info. 107 | dbg := logutils.LogLevel("DEBUG") 108 | inf := logutils.LogLevel("INFO") 109 | usr := logutils.LogLevel("USER") 110 | 111 | // default to user 112 | lvl := usr 113 | if *verbose { 114 | lvl = inf 115 | } 116 | if *debug { 117 | lvl = dbg 118 | } 119 | 120 | // Setup the filter 121 | filter := &logutils.LevelFilter{ 122 | Levels: []logutils.LogLevel{"DEBUG", "INFO", "USER", "ERROR"}, 123 | MinLevel: lvl, 124 | Writer: os.Stderr, 125 | } 126 | log.SetOutput(filter) 127 | 128 | // Create our configuration object 129 | cfg := &config.Config{ 130 | Debug: *debug, 131 | Verbose: *verbose, 132 | } 133 | 134 | // Ensure we got at least one recipe to execute. 135 | if len(flag.Args()) < 1 { 136 | 137 | fmt.Printf("Usage:\n\n") 138 | fmt.Printf(" marionette [flags] ./rules.txt ./rules2.txt ... ./rulesN.txt\n\n") 139 | fmt.Printf("Flags:\n\n") 140 | flag.PrintDefaults() 141 | return 142 | } 143 | 144 | // Process each given file. 145 | for _, file := range flag.Args() { 146 | err := runFile(file, cfg) 147 | if err != nil { 148 | fmt.Printf("Error:%s\n", err.Error()) 149 | return 150 | } 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /modules/api.go: -------------------------------------------------------------------------------- 1 | // Package modules contain the implementation of our modules. Each 2 | // module has a name/type such as "git", "file", etc. The modules 3 | // each accept an arbitrary set of parameters which are module-specific. 4 | package modules 5 | 6 | import ( 7 | "github.com/skx/marionette/config" 8 | "github.com/skx/marionette/environment" 9 | ) 10 | 11 | const ( 12 | checkString = iota 13 | onlyArray = iota 14 | ) 15 | 16 | // ModuleConstructor is the signature of a constructor-function. 17 | type ModuleConstructor func(cfg *config.Config, env *environment.Environment) ModuleAPI 18 | 19 | // ModuleAPI is the interface which all of our modules must implement. 20 | // 21 | // There are only two methods, one to check if the supplied parameters 22 | // make sense, the other to actually execute the rule. 23 | // 24 | // If a module wishes to setup a variable in the environment then they 25 | // can optionally implement the `ModuleOutput` interface too. 26 | type ModuleAPI interface { 27 | 28 | // Check allows a module to ensures that any mandatory parameters 29 | // are present, or perform similar setup-work. 30 | // 31 | // If no error is returned then the module will be executed later 32 | // via a call to Execute. 33 | Check(map[string]interface{}) error 34 | 35 | // Execute runs the module with the given arguments. 36 | // 37 | // The return value is true if the module made a change 38 | // and false otherwise. 39 | Execute(map[string]interface{}) (bool, error) 40 | } 41 | 42 | // ModuleOutput is an optional interface that may be implemented by any of 43 | // our internal modules. 44 | // 45 | // If this interface is implemented it is possible for modules to set 46 | // values in the environment after they've been executed. 47 | type ModuleOutput interface { 48 | 49 | // GetOutputs will return a set of key-value pairs. 50 | // 51 | // These will be set in the environment, scoped by the rule-name, 52 | // if the module is successfully executed. 53 | GetOutputs() map[string]string 54 | } 55 | 56 | // StringParam returns the named parameter, as a string, from the map. 57 | // 58 | // If the parameter was not present an empty array is returned. 59 | func StringParam(vars map[string]interface{}, param string) string { 60 | 61 | // Get the value 62 | val, ok := vars[param] 63 | if !ok { 64 | return "" 65 | } 66 | 67 | // Can it be cast into a string? 68 | str, valid := val.(string) 69 | if valid { 70 | return str 71 | } 72 | 73 | // OK not a string parameter 74 | return "" 75 | } 76 | 77 | // ArrayParam returns the named parameter, as an array, from the map. 78 | // 79 | // If the parameter was not present an empty array is returned. 80 | func ArrayParam(vars map[string]interface{}, param string) []string { 81 | return arrayBuildParam(vars, param, onlyArray) 82 | } 83 | 84 | // ArrayCastParam returns the named parameter as a string array 85 | // regardless if the param is stringable or an array of stringables 86 | // 87 | // If the parameter was not present an empty array is returned. 88 | func ArrayCastParam(vars map[string]interface{}, param string) []string { 89 | return arrayBuildParam(vars, param, checkString) 90 | } 91 | 92 | func arrayBuildParam(vars map[string]interface{}, param string, stringFlag int) []string { 93 | 94 | var empty []string 95 | 96 | // Get the value 97 | val, ok := vars[param] 98 | if !ok { 99 | return empty 100 | } 101 | 102 | // Can it be cast into a string? 103 | // Then return an array with just the one string 104 | if stringFlag == checkString { 105 | str, valid := val.(string) 106 | if valid { 107 | return []string{str} 108 | } 109 | } 110 | 111 | // Can it be cast into a string array? 112 | strs, valid := val.([]string) 113 | if valid { 114 | return strs 115 | } 116 | 117 | // OK not a string parameter 118 | return empty 119 | } 120 | -------------------------------------------------------------------------------- /modules/api_glue.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/skx/marionette/config" 7 | "github.com/skx/marionette/environment" 8 | ) 9 | 10 | // This is a map of known modules. 11 | var handlers = struct { 12 | m map[string]ModuleConstructor 13 | sync.RWMutex 14 | }{m: make(map[string]ModuleConstructor)} 15 | 16 | // Register records a new module. 17 | func Register(id string, newfunc ModuleConstructor) { 18 | handlers.Lock() 19 | handlers.m[id] = newfunc 20 | handlers.Unlock() 21 | } 22 | 23 | // RegisterAlias allows a new name to refer to an existing implementation. 24 | func RegisterAlias(alias string, impl string) { 25 | handlers.Lock() 26 | handlers.m[alias] = handlers.m[impl] 27 | handlers.Unlock() 28 | } 29 | 30 | // Lookup is the factory-method which looks up and returns 31 | // an object of the given type - if possible. 32 | func Lookup(id string, cfg *config.Config, env *environment.Environment) (a ModuleAPI) { 33 | handlers.RLock() 34 | ctor, ok := handlers.m[id] 35 | handlers.RUnlock() 36 | if ok { 37 | a = ctor(cfg, env) 38 | } 39 | return 40 | } 41 | 42 | // Modules returns the names of all the registered module-names. 43 | func Modules() []string { 44 | var result []string 45 | 46 | // For each handler save the name 47 | handlers.RLock() 48 | for index := range handlers.m { 49 | result = append(result, index) 50 | } 51 | handlers.RUnlock() 52 | 53 | // And return the result 54 | return result 55 | 56 | } 57 | -------------------------------------------------------------------------------- /modules/api_glue_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/skx/marionette/config" 8 | "github.com/skx/marionette/environment" 9 | ) 10 | 11 | func TestModules(t *testing.T) { 12 | 13 | // Create our configuration & environment objects 14 | cfg := &config.Config{Verbose: false} 15 | env := &environment.Environment{} 16 | 17 | // Get all modules 18 | modules := Modules() 19 | 20 | for _, module := range modules { 21 | 22 | mod := Lookup(module, cfg, env) 23 | if mod == nil { 24 | t.Fatalf("failed to load module") 25 | } 26 | } 27 | 28 | count := len(modules) 29 | if count != 16 { 30 | t.Fatalf("unexpected number of modules: %d", len(modules)) 31 | } 32 | 33 | // Register an alias 34 | RegisterAlias("cmd", "shell") 35 | 36 | // Now "cmd" is an alias for "shell" 37 | if len(Modules()) != count+1 { 38 | t.Fatalf("unexpected number of modules: %d", len(Modules())) 39 | } 40 | 41 | a := fmt.Sprintf("%v", Lookup("cmd", cfg, env)) 42 | b := fmt.Sprintf("%v", Lookup("shell", cfg, env)) 43 | if a != b { 44 | t.Fatalf("alias didn't seem to work?: %s != %s", a, b) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /modules/api_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import "testing" 4 | 5 | func TestArrayParam(t *testing.T) { 6 | 7 | // Setup arguments 8 | args := make(map[string]interface{}) 9 | 10 | // Known-Array 11 | input := []string{ 12 | "Homer", 13 | "Marge", 14 | "Bart", 15 | "Lisa", 16 | "Maggie", 17 | } 18 | 19 | // String + Array values 20 | args["foo"] = "bar" 21 | args["family"] = input 22 | 23 | // Confirm string was OK 24 | if StringParam(args, "foo") != "bar" { 25 | t.Fatalf("failed to get string value") 26 | } 27 | 28 | // Get the array 29 | array := ArrayParam(args, "family") 30 | 31 | // confirm length matches expectation 32 | if len(array) != len(input) { 33 | t.Fatalf("Unexpected length") 34 | } 35 | 36 | // And values 37 | for i, v := range input { 38 | if array[i] != v { 39 | t.Fatalf("array mismatch for value %d", i) 40 | } 41 | } 42 | 43 | // Treat the string as an array 44 | array = ArrayParam(args, "foo") 45 | if len(array) != 0 { 46 | t.Fatalf("Got result for bogus key") 47 | } 48 | 49 | // Unknown key 50 | array = ArrayParam(args, "testing") 51 | if len(array) != 0 { 52 | t.Fatalf("Got result for missing key") 53 | } 54 | 55 | // Get String as one element array 56 | array = ArrayCastParam(args, "foo") 57 | 58 | // confirm we have only one element 59 | if len(array) != 1 { 60 | t.Fatalf("String cast as array should return single element array") 61 | } 62 | 63 | // check value of only element; should be string value 64 | if array[0] != "bar" { 65 | t.Fatalf("failed to get single element string value") 66 | } 67 | 68 | // check array cast of array behaves like native array 69 | array = ArrayCastParam(args, "family") 70 | 71 | // confirm length matches expectation 72 | if len(array) != len(input) { 73 | t.Fatalf("Unexpected length") 74 | } 75 | 76 | // And values 77 | for i, v := range input { 78 | if array[i] != v { 79 | t.Fatalf("array mismatch for value %d", i) 80 | } 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /modules/module_directory.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/skx/marionette/config" 9 | "github.com/skx/marionette/environment" 10 | "github.com/skx/marionette/file" 11 | ) 12 | 13 | // DirectoryModule stores our state 14 | type DirectoryModule struct { 15 | // cfg contains our configuration object. 16 | cfg *config.Config 17 | 18 | // env holds our environment 19 | env *environment.Environment 20 | } 21 | 22 | // Check is part of the module-api, and checks arguments. 23 | func (f *DirectoryModule) Check(args map[string]interface{}) error { 24 | 25 | // Ensure we have a target (i.e. name to operate upon). 26 | _, ok := args["target"] 27 | if !ok { 28 | return fmt.Errorf("missing 'target' parameter") 29 | } 30 | 31 | // Target may be either a string or an array, so we don't test 32 | // the type here. 33 | return nil 34 | } 35 | 36 | // Execute is part of the module-api, and is invoked to run a rule. 37 | func (f *DirectoryModule) Execute(args map[string]interface{}) (bool, error) { 38 | 39 | // Ensure we have one or more targets to process 40 | dirs := ArrayCastParam(args, "target") 41 | if len(dirs) < 1 { 42 | return false, fmt.Errorf("missing 'target' parameter") 43 | } 44 | 45 | // default to not being changed 46 | changed := false 47 | 48 | // process each argument 49 | for _, arg := range dirs { 50 | 51 | // we'll see if it changed 52 | // 53 | // if any single directory resulted in a change then 54 | // our return value will reflect that 55 | change, err := f.executeSingle(arg, args) 56 | 57 | // but first process any error 58 | if err != nil { 59 | return false, err 60 | } 61 | 62 | // record the change 63 | if change { 64 | changed = true 65 | } 66 | } 67 | 68 | return changed, nil 69 | } 70 | 71 | // executeSingle executes a single directory action. 72 | // 73 | // All parameters are available, as is the single target of this function. 74 | func (f *DirectoryModule) executeSingle(target string, args map[string]interface{}) (bool, error) { 75 | 76 | // Default to not having changed 77 | changed := false 78 | 79 | // We assume we're creating the directory, but we might be removing it. 80 | state := StringParam(args, "state") 81 | if state == "" { 82 | state = "present" 83 | } 84 | 85 | // Remove the directory, if we should. 86 | if state == "absent" { 87 | 88 | // Does it exist? 89 | if !file.Exists(target) { 90 | // Does not exist - nothing to do 91 | return false, nil 92 | } 93 | 94 | // OK remove 95 | os.RemoveAll(target) 96 | return true, nil 97 | } 98 | 99 | // Get the mode, if any. We'll have a default here. 100 | mode := StringParam(args, "mode") 101 | if mode == "" { 102 | mode = "0755" 103 | } 104 | 105 | // Convert mode to int 106 | modeI, _ := strconv.ParseInt(mode, 8, 64) 107 | 108 | // Create the directory, if it is missing, with the correct mode. 109 | if !file.Exists(target) { 110 | 111 | // make the directory hierarchy 112 | er := os.MkdirAll(target, os.FileMode(modeI)) 113 | if er != nil { 114 | return false, er 115 | } 116 | 117 | changed = true 118 | } 119 | 120 | // User and group changes 121 | owner := StringParam(args, "owner") 122 | group := StringParam(args, "group") 123 | 124 | // User and group changes 125 | if owner != "" { 126 | change, err := file.ChangeOwner(target, owner) 127 | if err != nil { 128 | return false, err 129 | } 130 | if change { 131 | changed = true 132 | } 133 | } 134 | if group != "" { 135 | change, err := file.ChangeGroup(target, group) 136 | if err != nil { 137 | return false, err 138 | } 139 | if change { 140 | changed = true 141 | } 142 | } 143 | 144 | // If we created the directory it will have the correct 145 | // mode, but if it was already present with the wrong value 146 | // we must fix it. 147 | change, err := file.ChangeMode(target, mode) 148 | if err != nil { 149 | return false, err 150 | } 151 | if change { 152 | changed = true 153 | } 154 | 155 | return changed, nil 156 | } 157 | 158 | // init is used to dynamically register our module. 159 | func init() { 160 | Register("directory", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 161 | return &DirectoryModule{cfg: cfg, 162 | env: env} 163 | }) 164 | } 165 | -------------------------------------------------------------------------------- /modules/module_directory_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/skx/marionette/file" 10 | ) 11 | 12 | func TestDirectoryCheck(t *testing.T) { 13 | 14 | d := &DirectoryModule{} 15 | 16 | args := make(map[string]interface{}) 17 | 18 | // Missing 'target' 19 | err := d.Check(args) 20 | if err == nil { 21 | t.Fatalf("expected error due to missing target") 22 | } 23 | if !strings.Contains(err.Error(), "missing 'target'") { 24 | t.Fatalf("got error - but wrong one : %s", err) 25 | } 26 | 27 | // Valid target 28 | args["target"] = "/foo/bar" 29 | err = d.Check(args) 30 | if err != nil { 31 | t.Fatalf("unexpected error") 32 | } 33 | } 34 | 35 | func TestDirectoryMultiple(t *testing.T) { 36 | 37 | // Create a temporary directory 38 | dir, err := os.MkdirTemp("", "m_d_t") 39 | if err != nil { 40 | t.Fatalf("failed to make temporary directory") 41 | } 42 | 43 | // pair of directories we'll create 44 | a := filepath.Join(dir, "one") 45 | b := filepath.Join(dir, "two") 46 | 47 | // Create a bunch of directories 48 | args := make(map[string]interface{}) 49 | args["target"] = []string{ 50 | a, 51 | b, 52 | } 53 | 54 | d := &DirectoryModule{} 55 | changed, err := d.Execute(args) 56 | 57 | if err != nil { 58 | t.Fatalf("error making multiple directories:%s", err) 59 | } 60 | if !changed { 61 | t.Fatalf("expected to see a change") 62 | } 63 | 64 | // Second time around the directories should exist, 65 | // so we see no change 66 | changed, err = d.Execute(args) 67 | 68 | if err != nil { 69 | t.Fatalf("error making multiple directories:%s", err) 70 | } 71 | if changed { 72 | t.Fatalf("expected to see no change when directories exist") 73 | } 74 | 75 | // Ensure the directories exist 76 | if !file.Exists(a) { 77 | t.Fatalf("expected to see directory present!") 78 | } 79 | if !file.Exists(b) { 80 | t.Fatalf("expected to see directory present!") 81 | } 82 | 83 | // Now remove them 84 | args["state"] = "absent" 85 | changed, err = d.Execute(args) 86 | 87 | if err != nil { 88 | t.Fatalf("error removing multiple directories:%s", err) 89 | } 90 | if !changed { 91 | t.Fatalf("expected to see a change when removing directories") 92 | } 93 | 94 | if file.Exists(a) { 95 | t.Fatalf("expected to see no directory present after removal!") 96 | } 97 | if file.Exists(b) { 98 | t.Fatalf("expected to see no directory present after removal!") 99 | } 100 | 101 | // remove them again - should be no change 102 | changed, err = d.Execute(args) 103 | if err != nil { 104 | t.Fatalf("error removing multiple directories:%s", err) 105 | } 106 | if changed { 107 | t.Fatalf("expected to see no change when removing absent directories") 108 | } 109 | 110 | // cleanup 111 | os.RemoveAll(dir) 112 | } 113 | 114 | // Issue 104 115 | func TestDirectoryMkdirP(t *testing.T) { 116 | 117 | // Create a temporary directory 118 | dir, err := os.MkdirTemp("", "t_d_m_p") 119 | if err != nil { 120 | t.Fatalf("failed to make temporary directory") 121 | } 122 | 123 | // the nested directory we'll create 124 | a := filepath.Join(dir, "one", "two", "three", "four") 125 | 126 | // Create a bunch of directories 127 | args := make(map[string]interface{}) 128 | args["target"] = []string{ 129 | a, 130 | } 131 | 132 | d := &DirectoryModule{} 133 | changed, err := d.Execute(args) 134 | 135 | if err != nil { 136 | t.Fatalf("error making nested-directories:%s", err) 137 | } 138 | if !changed { 139 | t.Fatalf("expected to see a change") 140 | } 141 | 142 | // Ensure the directories exist 143 | if !file.Exists(a) { 144 | t.Fatalf("expected to see directory present!") 145 | } 146 | 147 | // Now remove them 148 | args["state"] = "absent" 149 | changed, err = d.Execute(args) 150 | 151 | if err != nil { 152 | t.Fatalf("error removing nested-directories:%s", err) 153 | } 154 | if !changed { 155 | t.Fatalf("expected to see a change when removing directories") 156 | } 157 | 158 | if file.Exists(a) { 159 | t.Fatalf("expected to see no directory present after removal!") 160 | } 161 | 162 | // remove them again - should be no change 163 | changed, err = d.Execute(args) 164 | if err != nil { 165 | t.Fatalf("error removing multiple directories:%s", err) 166 | } 167 | if changed { 168 | t.Fatalf("expected to see no change when removing absent directories") 169 | } 170 | 171 | // cleanup 172 | os.RemoveAll(dir) 173 | } 174 | -------------------------------------------------------------------------------- /modules/module_docker.go: -------------------------------------------------------------------------------- 1 | // Allow fetching Docker images. 2 | 3 | package modules 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | 12 | "github.com/docker/docker/api/types" 13 | "github.com/docker/docker/client" 14 | "github.com/skx/marionette/config" 15 | "github.com/skx/marionette/environment" 16 | ) 17 | 18 | // DockerModule stores our state 19 | type DockerModule struct { 20 | 21 | // cfg contains our configuration object. 22 | cfg *config.Config 23 | 24 | // env holds our environment 25 | env *environment.Environment 26 | 27 | // Cached list of image-tags we've got available on the local host. 28 | Tags []string 29 | } 30 | 31 | // Check is part of the module-api, and checks arguments. 32 | func (dm *DockerModule) Check(args map[string]interface{}) error { 33 | 34 | // Ensure we have an image to pull. 35 | _, ok := args["image"] 36 | if !ok { 37 | return fmt.Errorf("missing 'image' parameter") 38 | } 39 | 40 | return nil 41 | } 42 | 43 | // isInstalled tests if the given image is installed 44 | func (dm *DockerModule) isInstalled(img string) (bool, error) { 45 | 46 | // 47 | // Cached tag-list already? 48 | // 49 | if len(dm.Tags) > 0 { 50 | 51 | // 52 | // Does the image appear in any of our cached tags? 53 | // 54 | for _, x := range dm.Tags { 55 | if x == img { 56 | return true, nil 57 | } 58 | } 59 | 60 | // 61 | // Not found. 62 | // 63 | return false, nil 64 | } 65 | 66 | // Create a new client. 67 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 68 | if err != nil { 69 | return false, err 70 | } 71 | 72 | // Get all images 73 | images, err := cli.ImageList(context.Background(), types.ImageListOptions{}) 74 | if err != nil { 75 | return false, err 76 | 77 | } 78 | 79 | // 80 | // If we reached here we have no cached tags. 81 | // 82 | // Save the tags in the cache before we look 83 | // for a match. 84 | // 85 | found := false 86 | for _, image := range images { 87 | for _, x := range image.RepoTags { 88 | 89 | // Update the cache 90 | dm.Tags = append(dm.Tags, x) 91 | if x == img { 92 | found = true 93 | } 94 | } 95 | } 96 | 97 | // Return the result 98 | return found, nil 99 | } 100 | 101 | // installImage pulls the given image from the remote repository. 102 | // 103 | // NOTE: No authentication, or private registries are supported. 104 | func (dm *DockerModule) installImage(img string) error { 105 | 106 | // Create client. 107 | ctx := context.Background() 108 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | // Pull the image. 114 | out, err := cli.ImagePull(ctx, img, types.ImagePullOptions{}) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | // Copy output to console. 120 | // 121 | // TODO: Clean this up 122 | defer out.Close() 123 | 124 | if dm.cfg.Debug { 125 | _, err := io.Copy(os.Stdout, out) 126 | if err != nil { 127 | return err 128 | } 129 | } 130 | 131 | // No error. 132 | return nil 133 | } 134 | 135 | // Execute is part of the module-api, and is invoked to run a rule. 136 | func (dm *DockerModule) Execute(args map[string]interface{}) (bool, error) { 137 | 138 | // No need to check if we have images as this was already done 139 | // in Check() 140 | images := ArrayCastParam(args, "image") 141 | 142 | // Force the pull? 143 | force := StringParam(args, "force") 144 | 145 | // installed something? 146 | installed := false 147 | 148 | // For each image the user wanted to fetch 149 | for _, img := range images { 150 | 151 | // Check if it is installed 152 | present, err := dm.isInstalled(img) 153 | if err != nil { 154 | return false, err 155 | } 156 | 157 | // Not installed; fetch. 158 | if !present || (force == "yes") || (force == "true") { 159 | 160 | // Show what we're doing 161 | log.Printf("[INFO] Pulling docker image %s\n", img) 162 | 163 | err := dm.installImage(img) 164 | if err != nil { 165 | return false, err 166 | } 167 | installed = true 168 | } 169 | } 170 | 171 | // Return whether we installed something. 172 | return installed, nil 173 | } 174 | 175 | // init is used to dynamically register our module. 176 | func init() { 177 | Register("docker", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 178 | return &DockerModule{ 179 | cfg: cfg, 180 | env: env, 181 | } 182 | }) 183 | } 184 | -------------------------------------------------------------------------------- /modules/module_edit.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "regexp" 9 | 10 | "github.com/skx/marionette/config" 11 | "github.com/skx/marionette/environment" 12 | "github.com/skx/marionette/file" 13 | ) 14 | 15 | // EditModule stores our state. 16 | type EditModule struct { 17 | 18 | // cfg contains our configuration object. 19 | cfg *config.Config 20 | 21 | // env holds our environment 22 | env *environment.Environment 23 | } 24 | 25 | // Check is part of the module-api, and checks arguments. 26 | func (e *EditModule) Check(args map[string]interface{}) error { 27 | 28 | // Ensure we have a target (i.e. file to operate upon). 29 | _, ok := args["target"] 30 | if !ok { 31 | return fmt.Errorf("missing 'target' parameter") 32 | } 33 | 34 | target := StringParam(args, "target") 35 | if target == "" { 36 | return fmt.Errorf("failed to convert target to string") 37 | } 38 | 39 | return nil 40 | } 41 | 42 | // Execute is part of the module-api, and is invoked to run a rule. 43 | func (e *EditModule) Execute(args map[string]interface{}) (bool, error) { 44 | 45 | var ret bool 46 | 47 | // Get the target 48 | target := StringParam(args, "target") 49 | if target == "" { 50 | return false, fmt.Errorf("failed to convert target to string") 51 | } 52 | 53 | // 54 | // Now look at our actions 55 | // 56 | 57 | // Remove lines matching a regexp. 58 | remove := StringParam(args, "remove_lines") 59 | if remove != "" { 60 | changed, err := e.RemoveLines(target, remove) 61 | if err != nil { 62 | return false, err 63 | } 64 | 65 | if changed { 66 | ret = true 67 | } 68 | } 69 | 70 | // Append a line if missing 71 | append := StringParam(args, "append_if_missing") 72 | if append != "" { 73 | changed, err := e.Append(target, append) 74 | if err != nil { 75 | return false, err 76 | } 77 | if changed { 78 | ret = true 79 | } 80 | } 81 | 82 | // Search & replace. 83 | search := StringParam(args, "search") 84 | replace := StringParam(args, "replace") 85 | if search != "" && replace != "" { 86 | changed, err := e.SearchReplace(target, search, replace) 87 | if err != nil { 88 | return false, err 89 | } 90 | if changed { 91 | ret = true 92 | } 93 | } 94 | 95 | return ret, nil 96 | } 97 | 98 | // Append the given line to the file, if it is missing. 99 | func (e *EditModule) Append(path string, text string) (bool, error) { 100 | 101 | // If the target file doesn't exist create it 102 | if !file.Exists(path) { 103 | 104 | f, err := os.OpenFile(path, 105 | os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 106 | if err != nil { 107 | return false, err 108 | } 109 | defer f.Close() 110 | if _, err := f.WriteString("\n" + text); err != nil { 111 | return false, err 112 | } 113 | return true, nil 114 | } 115 | 116 | // Open the file 117 | file, err := os.Open(path) 118 | if err != nil { 119 | return false, err 120 | } 121 | defer file.Close() 122 | 123 | // Did we find what we're looking for? 124 | found := false 125 | 126 | // Process line by line 127 | scanner := bufio.NewScanner(file) 128 | for scanner.Scan() { 129 | line := scanner.Text() 130 | if text == line { 131 | found = true 132 | } 133 | } 134 | 135 | if err = scanner.Err(); err != nil { 136 | return false, err 137 | } 138 | 139 | // If we found the line we do nothing 140 | if found { 141 | return false, nil 142 | } 143 | 144 | // Otherwise we need to append the text 145 | f, err := os.OpenFile(path, 146 | os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 147 | if err != nil { 148 | return false, err 149 | } 150 | defer f.Close() 151 | if _, err := f.WriteString("\n" + text); err != nil { 152 | return false, err 153 | } 154 | 155 | return true, nil 156 | } 157 | 158 | // RemoveLines remove any lines from the file which match the given 159 | // regular expression. 160 | func (e *EditModule) RemoveLines(path string, pattern string) (bool, error) { 161 | 162 | // If the target file doesn't exist then we cannot 163 | // remove content from it. 164 | if !file.Exists(path) { 165 | return false, nil 166 | } 167 | 168 | re, err := regexp.Compile(pattern) 169 | if err != nil { 170 | return false, err 171 | } 172 | 173 | // Open the input file 174 | in, err := os.Open(path) 175 | if err != nil { 176 | return false, err 177 | } 178 | defer in.Close() 179 | 180 | // Open a temporary file 181 | tmpfile, err := ioutil.TempFile("", "marionette-") 182 | if err != nil { 183 | return false, err 184 | } 185 | defer os.Remove(tmpfile.Name()) 186 | 187 | // Process the input file line by line 188 | scanner := bufio.NewScanner(in) 189 | for scanner.Scan() { 190 | 191 | // Get the line 192 | line := scanner.Text() 193 | 194 | // If it doesn't match the regexp, write to the temporary file 195 | if !re.MatchString(line) { 196 | _, er := tmpfile.WriteString(line + "\n") 197 | if er != nil { 198 | return false, er 199 | } 200 | } 201 | } 202 | 203 | identical, err := file.Identical(tmpfile.Name(), path) 204 | if err != nil { 205 | return false, err 206 | } 207 | 208 | if identical { 209 | return false, nil 210 | } 211 | 212 | // otherwise change 213 | err = file.Copy(tmpfile.Name(), path) 214 | return true, err 215 | } 216 | 217 | // SearchReplace performs a search and replace operation across all lines 218 | // of the given file. 219 | // 220 | // Searches are literal, rather than regexp. 221 | func (e *EditModule) SearchReplace(path string, search string, replace string) (bool, error) { 222 | 223 | // If the target file doesn't exist then we cannot change it. 224 | if !file.Exists(path) { 225 | return false, nil 226 | } 227 | 228 | // Compile the regular expression 229 | term, errRE := regexp.Compile(search) 230 | if errRE != nil { 231 | return false, errRE 232 | } 233 | 234 | // Open the input file 235 | in, err := os.Open(path) 236 | if err != nil { 237 | return false, err 238 | } 239 | defer in.Close() 240 | 241 | // Open a temporary file 242 | tmpfile, err := ioutil.TempFile("", "marionette-") 243 | if err != nil { 244 | return false, err 245 | } 246 | defer os.Remove(tmpfile.Name()) 247 | 248 | // Process the input file line by line 249 | scanner := bufio.NewScanner(in) 250 | for scanner.Scan() { 251 | 252 | // Get the line 253 | line := scanner.Text() 254 | 255 | // Perform any search-replace operation within the line 256 | line = term.ReplaceAllString(line, replace) 257 | 258 | // Write the (updated) line to the temporary file 259 | _, er := tmpfile.WriteString(line + "\n") 260 | if er != nil { 261 | return false, er 262 | } 263 | } 264 | 265 | // Now see if the content we wrote differs from the 266 | // original input so we can signal a change, or not. 267 | identical, err := file.Identical(tmpfile.Name(), path) 268 | if err != nil { 269 | return false, err 270 | } 271 | 272 | if identical { 273 | return false, nil 274 | } 275 | 276 | // otherwise change 277 | err = file.Copy(tmpfile.Name(), path) 278 | return true, err 279 | } 280 | 281 | // init is used to dynamically register our module. 282 | func init() { 283 | Register("edit", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 284 | return &EditModule{ 285 | cfg: cfg, 286 | env: env, 287 | } 288 | }) 289 | } 290 | -------------------------------------------------------------------------------- /modules/module_edit_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/skx/marionette/file" 10 | ) 11 | 12 | func TestEditCheck(t *testing.T) { 13 | 14 | e := &EditModule{} 15 | 16 | args := make(map[string]interface{}) 17 | 18 | // Missing 'target' 19 | err := e.Check(args) 20 | if err == nil { 21 | t.Fatalf("expected error due to missing target") 22 | } 23 | if !strings.Contains(err.Error(), "missing 'target'") { 24 | t.Fatalf("got error - but wrong one : %s", err) 25 | } 26 | 27 | // Wrong kind of target 28 | args["target"] = 3 29 | err = e.Check(args) 30 | if err == nil { 31 | t.Fatalf("expected error due to missing target") 32 | } 33 | if !strings.Contains(err.Error(), "failed to convert") { 34 | t.Fatalf("got error - but wrong one : %s", err) 35 | } 36 | 37 | // Valid target 38 | args["target"] = "/foo/bar" 39 | err = e.Check(args) 40 | if err != nil { 41 | t.Fatalf("unexpected error") 42 | } 43 | } 44 | 45 | func TestEditAppend(t *testing.T) { 46 | 47 | // create a temporary file 48 | tmpfile, err := ioutil.TempFile("", "marionette-") 49 | if err != nil { 50 | t.Fatalf("create a temporary file failed") 51 | } 52 | 53 | // delete it 54 | os.Remove(tmpfile.Name()) 55 | 56 | e := &EditModule{} 57 | 58 | // Append my name 59 | args := make(map[string]interface{}) 60 | args["target"] = tmpfile.Name() 61 | args["append_if_missing"] = "Steve Kemp" 62 | 63 | changed, err := e.Execute(args) 64 | if err != nil { 65 | t.Fatalf("error changing file") 66 | } 67 | if !changed { 68 | t.Fatalf("expected file change, got none") 69 | } 70 | 71 | // If the file doesn't exist now that's a bug 72 | if !file.Exists(tmpfile.Name()) { 73 | t.Fatalf("file doesn't exist") 74 | } 75 | 76 | // Get the file size 77 | var size int64 78 | 79 | size, err = file.Size(tmpfile.Name()) 80 | if err != nil { 81 | t.Fatalf("error getting file size") 82 | } 83 | 84 | // Call again 85 | changed, err = e.Execute(args) 86 | if err != nil { 87 | t.Fatalf("error changing file") 88 | } 89 | if changed { 90 | t.Fatalf("didn't expect file change, got one") 91 | } 92 | 93 | // file size shouldn't have changed 94 | var newSize int64 95 | newSize, err = file.Size(tmpfile.Name()) 96 | if err != nil { 97 | t.Fatalf("error getting file size") 98 | } 99 | 100 | if newSize != size { 101 | t.Fatalf("file size changed!") 102 | } 103 | 104 | // Finally append "Test" 105 | args["append_if_missing"] = "Test" 106 | changed, err = e.Execute(args) 107 | if err != nil { 108 | t.Fatalf("error changing file") 109 | } 110 | if !changed { 111 | t.Fatalf("expected file change, got none") 112 | } 113 | 114 | // And confirm new size is four (+newline) bytes longer 115 | newSize, err = file.Size(tmpfile.Name()) 116 | if err != nil { 117 | t.Fatalf("error getting file size") 118 | } 119 | 120 | if newSize != (size + 5) { 121 | t.Fatalf("file size mismatch!") 122 | } 123 | os.Remove(tmpfile.Name()) 124 | } 125 | 126 | func TestEditRemove(t *testing.T) { 127 | 128 | // create a temporary file 129 | tmpfile, err := ioutil.TempFile("", "marionette-") 130 | if err != nil { 131 | t.Fatalf("create a temporary file failed") 132 | } 133 | 134 | // Write the input 135 | _, err = tmpfile.Write([]byte("# This is a comment\n# So is this\n")) 136 | if err != nil { 137 | t.Fatalf("error writing temporary file") 138 | } 139 | 140 | e := &EditModule{} 141 | 142 | // Remove all lines matching "^#" in the temporary file 143 | args := make(map[string]interface{}) 144 | args["target"] = tmpfile.Name() 145 | args["remove_lines"] = "^#" 146 | 147 | // Make the change 148 | changed, err := e.Execute(args) 149 | if err != nil { 150 | t.Fatalf("unexpected error") 151 | } 152 | if !changed { 153 | t.Fatalf("expected change, but got none") 154 | } 155 | 156 | // Second time nothing should happen 157 | changed, err = e.Execute(args) 158 | if err != nil { 159 | t.Fatalf("unexpected error") 160 | } 161 | if changed { 162 | t.Fatalf("unexpected change, nothing should happen") 163 | } 164 | 165 | // Confirm the file is zero-sized 166 | size, er := file.Size(tmpfile.Name()) 167 | if er != nil { 168 | t.Fatalf("error getting file size") 169 | } 170 | if size != 0 { 171 | t.Fatalf("the edit didn't work") 172 | } 173 | 174 | // Now test that an invalid regexp is taken 175 | args["remove_lines"] = "*" 176 | _, err = e.Execute(args) 177 | if err == nil { 178 | t.Fatalf("expected error, got none") 179 | } 180 | 181 | // Remove the temporary file, and confirm we get something similar 182 | os.Remove(tmpfile.Name()) 183 | _, err = e.Execute(args) 184 | if err != nil { 185 | t.Fatalf("didn't expect error, got one") 186 | } 187 | 188 | } 189 | -------------------------------------------------------------------------------- /modules/module_fail.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/skx/marionette/config" 8 | "github.com/skx/marionette/environment" 9 | ) 10 | 11 | // FailModule stores our state. 12 | type FailModule struct { 13 | 14 | // cfg contains our configuration object. 15 | cfg *config.Config 16 | 17 | // env holds our environment 18 | env *environment.Environment 19 | } 20 | 21 | // Check is part of the module-api, and checks arguments. 22 | func (f *FailModule) Check(args map[string]interface{}) error { 23 | 24 | // Ensure we have a message to abort with. 25 | _, ok := args["message"] 26 | if !ok { 27 | return fmt.Errorf("missing 'message' parameter") 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // Execute is part of the module-api, and is invoked to run a rule. 34 | func (f *FailModule) Execute(args map[string]interface{}) (bool, error) { 35 | 36 | // Get the message/messages to log. 37 | strs := ArrayCastParam(args, "message") 38 | 39 | // Ensure that we've got something 40 | if len(strs) < 1 { 41 | return false, fmt.Errorf("missing 'message' parameter") 42 | } 43 | 44 | // process each argument 45 | complete := "" 46 | for _, str := range strs { 47 | fmt.Fprintf(os.Stderr, "FAIL: %s\n", str) 48 | complete += str + "\n" 49 | } 50 | 51 | // Return the joined error-message 52 | return false, fmt.Errorf("%s", complete) 53 | 54 | } 55 | 56 | // init is used to dynamically register our module. 57 | func init() { 58 | Register("fail", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 59 | return &FailModule{ 60 | cfg: cfg, 61 | env: env, 62 | } 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /modules/module_fail_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestFailCheck(t *testing.T) { 9 | 10 | f := &FailModule{} 11 | 12 | args := make(map[string]interface{}) 13 | 14 | // Missing 'message' 15 | err := f.Check(args) 16 | if err == nil { 17 | t.Fatalf("expected error due to missing message") 18 | } 19 | if !strings.Contains(err.Error(), "missing 'message'") { 20 | t.Fatalf("got error - but wrong one : %s", err) 21 | } 22 | 23 | // Valid target 24 | args["message"] = []string{"OK", "Computer"} 25 | err = f.Check(args) 26 | if err != nil { 27 | t.Fatalf("unexpected error") 28 | } 29 | } 30 | 31 | func TestFail(t *testing.T) { 32 | 33 | f := &FailModule{} 34 | 35 | // Setup params 36 | args := make(map[string]interface{}) 37 | 38 | changed, err := f.Execute(args) 39 | if err == nil { 40 | t.Fatalf("expected error, got none") 41 | } 42 | if !strings.Contains(err.Error(), "missing 'message'") { 43 | t.Fatalf("got error - but wrong one : %s", err) 44 | } 45 | if changed { 46 | t.Fatalf("unexpected change") 47 | } 48 | 49 | // Setup a message 50 | args["message"] = "I have no cake" 51 | 52 | changed, err = f.Execute(args) 53 | if err == nil { 54 | t.Fatalf("expected error, got none") 55 | } 56 | if changed { 57 | t.Fatalf("unexpected change") 58 | } 59 | if !strings.Contains(err.Error(), "I have no cake") { 60 | t.Fatalf("failure message was unexpected") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /modules/module_file.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "text/template" 11 | 12 | "github.com/skx/marionette/config" 13 | "github.com/skx/marionette/environment" 14 | "github.com/skx/marionette/file" 15 | ) 16 | 17 | // FileModule stores our state 18 | type FileModule struct { 19 | 20 | // cfg contains our configuration object. 21 | cfg *config.Config 22 | 23 | // env contains the environment. 24 | env *environment.Environment 25 | } 26 | 27 | // Check is part of the module-api, and checks arguments. 28 | func (f *FileModule) Check(args map[string]interface{}) error { 29 | 30 | // Ensure we have a target (i.e. name to operate upon). 31 | _, ok := args["target"] 32 | if !ok { 33 | return fmt.Errorf("missing 'target' parameter") 34 | } 35 | 36 | target := StringParam(args, "target") 37 | if target == "" { 38 | return fmt.Errorf("failed to convert target to string") 39 | } 40 | 41 | return nil 42 | } 43 | 44 | // Execute is part of the module-api, and is invoked to run a rule. 45 | func (f *FileModule) Execute(args map[string]interface{}) (bool, error) { 46 | 47 | var ret bool 48 | var err error 49 | 50 | // Get the target (i.e. file/directory we're operating upon.) 51 | target := StringParam(args, "target") 52 | 53 | // Get the directory-name 54 | dir := filepath.Dir(target) 55 | if !file.Exists(dir) { 56 | return false, fmt.Errorf("error: directory %s does not exist, when processing %s", dir, target) 57 | } 58 | 59 | // We assume we're creating the file, but we might be removing it. 60 | state := StringParam(args, "state") 61 | if state == "" { 62 | state = "present" 63 | } 64 | 65 | // 66 | // Now we start to handle the request. 67 | // 68 | // Remove the file/directory, if we should. 69 | if state == "absent" { 70 | return f.removeFile(target) 71 | } 72 | 73 | // 74 | // At this point we're going to create/update the file 75 | // via one of our support options. 76 | // 77 | // Go do that, then once that is complete we can update 78 | // the owner/group/mode, etc. 79 | // 80 | ret, err = f.populateFile(target, args) 81 | if err != nil { 82 | return ret, err 83 | } 84 | 85 | // File permission changes 86 | mode := StringParam(args, "mode") 87 | if mode != "" { 88 | var changed bool 89 | changed, err = file.ChangeMode(target, mode) 90 | if err != nil { 91 | return false, err 92 | } 93 | if changed { 94 | ret = true 95 | } 96 | } 97 | 98 | // User and group changes 99 | owner := StringParam(args, "owner") 100 | if owner != "" { 101 | var changed bool 102 | changed, err = file.ChangeOwner(target, owner) 103 | if err != nil { 104 | return false, err 105 | } 106 | if changed { 107 | ret = true 108 | } 109 | } 110 | group := StringParam(args, "group") 111 | if group != "" { 112 | var changed bool 113 | changed, err = file.ChangeGroup(target, group) 114 | if err != nil { 115 | return false, err 116 | } 117 | if changed { 118 | ret = true 119 | } 120 | } 121 | 122 | return ret, err 123 | } 124 | 125 | // removeFile removes the named file, returning whether a change 126 | // was made or not 127 | func (f *FileModule) removeFile(target string) (bool, error) { 128 | 129 | // Does it exist? 130 | if file.Exists(target) { 131 | err := os.Remove(target) 132 | return true, err 133 | } 134 | 135 | // Didn't exist, nothing to change. 136 | return false, nil 137 | } 138 | 139 | // populateFile is designed to create/update the file contents via one 140 | // of our supported methods. 141 | func (f *FileModule) populateFile(target string, args map[string]interface{}) (bool, error) { 142 | 143 | var ret bool 144 | var err error 145 | 146 | // If we have a source file, copy that into place 147 | source := StringParam(args, "source") 148 | if source != "" { 149 | 150 | ret, err = f.CopyFile(source, target) 151 | return ret, err 152 | } 153 | 154 | // If we have a template file, render it. 155 | template := StringParam(args, "template") 156 | if template != "" { 157 | ret, err = f.CopyTemplateFile(template, target) 158 | return ret, err 159 | } 160 | 161 | // If we have a content to set, then use it. 162 | content := StringParam(args, "content") 163 | if content != "" { 164 | ret, err = f.CreateFile(target, content) 165 | return ret, err 166 | } 167 | 168 | // If we have a source URL, fetch. 169 | srcURL := StringParam(args, "source_url") 170 | if srcURL != "" { 171 | ret, err = f.FetchURL(srcURL, target) 172 | return ret, err 173 | } 174 | 175 | return ret, fmt.Errorf("neither 'content', 'source', 'source_url', or 'template' were specified") 176 | } 177 | 178 | // CopyFile copies the source file to the destination, returning if we changed 179 | // the contents. 180 | func (f *FileModule) CopyFile(src string, dst string) (bool, error) { 181 | 182 | // File doesn't exist - copy it 183 | if !file.Exists(dst) { 184 | err := file.Copy(src, dst) 185 | return true, err 186 | } 187 | 188 | // Are the files identical? 189 | identical, err := file.Identical(src, dst) 190 | if err != nil { 191 | return false, err 192 | } 193 | 194 | // If identical no change 195 | if identical { 196 | return false, err 197 | } 198 | 199 | // Since they differ we refresh and that's a change 200 | err = file.Copy(src, dst) 201 | return true, err 202 | } 203 | 204 | // CopyTemplateFile copies the template file to the destination, rendering the 205 | // template and returning if we changed the contents. 206 | func (f *FileModule) CopyTemplateFile(src string, dst string) (bool, error) { 207 | 208 | // Create a temporary file to write the rendered template to 209 | tmpfile, err := ioutil.TempFile("", "marionette-") 210 | if err != nil { 211 | return false, nil 212 | } 213 | defer os.Remove(tmpfile.Name()) 214 | 215 | // Parse the template file 216 | tpl, err := template.ParseFiles(src) 217 | if err != nil { 218 | return false, err 219 | } 220 | 221 | // Render the template, writing to the temp file 222 | err = tpl.Execute(tmpfile, f.env.Variables()) 223 | if err != nil { 224 | return false, err 225 | } 226 | 227 | return f.CopyFile(tmpfile.Name(), dst) 228 | } 229 | 230 | // FetchURL retrieves the contents of the remote URL and saves them to 231 | // the given file. If the contents are identical no change is reported. 232 | func (f *FileModule) FetchURL(url string, dst string) (bool, error) { 233 | 234 | // Download to temporary file 235 | tmpfile, err := ioutil.TempFile("", "marionette-") 236 | if err != nil { 237 | return false, nil 238 | } 239 | defer os.Remove(tmpfile.Name()) 240 | 241 | // Get the remote URL 242 | resp, err := http.Get(url) 243 | if err != nil { 244 | return false, err 245 | } 246 | defer resp.Body.Close() 247 | 248 | // Write the body to file 249 | _, err = io.Copy(tmpfile, resp.Body) 250 | if err != nil { 251 | return false, err 252 | } 253 | 254 | return f.CopyFile(tmpfile.Name(), dst) 255 | } 256 | 257 | // CreateFile writes the given content to the named file. 258 | // If the contents are identical no change is reported. 259 | func (f *FileModule) CreateFile(dst string, content string) (bool, error) { 260 | 261 | // Create a temporary file 262 | tmpfile, err := ioutil.TempFile("", "marionette-") 263 | if err != nil { 264 | return false, nil 265 | } 266 | defer os.Remove(tmpfile.Name()) 267 | 268 | // Write to it. 269 | err = ioutil.WriteFile(tmpfile.Name(), []byte(content), 0644) 270 | if err != nil { 271 | return false, err 272 | } 273 | 274 | return f.CopyFile(tmpfile.Name(), dst) 275 | } 276 | 277 | // init is used to dynamically register our module. 278 | func init() { 279 | Register("file", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 280 | return &FileModule{ 281 | cfg: cfg, 282 | env: env, 283 | } 284 | }) 285 | } 286 | -------------------------------------------------------------------------------- /modules/module_file_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/skx/marionette/file" 10 | ) 11 | 12 | func TestCheck(t *testing.T) { 13 | 14 | f := &FileModule{} 15 | 16 | args := make(map[string]interface{}) 17 | 18 | // Missing 'target' 19 | err := f.Check(args) 20 | if err == nil { 21 | t.Fatalf("expected error due to missing target") 22 | } 23 | if !strings.Contains(err.Error(), "missing 'target'") { 24 | t.Fatalf("got error - but wrong one : %s", err) 25 | } 26 | 27 | // Wrong kind of target 28 | args["target"] = 3 29 | err = f.Check(args) 30 | if err == nil { 31 | t.Fatalf("expected error due to missing target") 32 | } 33 | if !strings.Contains(err.Error(), "failed to convert") { 34 | t.Fatalf("got error - but wrong one : %s", err) 35 | } 36 | 37 | // Valid target 38 | args["target"] = "/foo/bar" 39 | err = f.Check(args) 40 | if err != nil { 41 | t.Fatalf("unexpected error") 42 | } 43 | } 44 | 45 | func TestAbsent(t *testing.T) { 46 | 47 | // Create a temporary file 48 | tmpfile, err := ioutil.TempFile("", "marionette-") 49 | if err != nil { 50 | t.Fatalf("create a temporary file failed") 51 | } 52 | 53 | defer os.Remove(tmpfile.Name()) 54 | 55 | // Confirm it exists 56 | if !file.Exists(tmpfile.Name()) { 57 | t.Fatalf("file doesn't exist, after creation") 58 | } 59 | 60 | // Remove it 61 | args := make(map[string]interface{}) 62 | args["target"] = tmpfile.Name() 63 | args["state"] = "absent" 64 | 65 | // Run the module 66 | f := &FileModule{} 67 | changed, err := f.Execute(args) 68 | 69 | if err != nil { 70 | t.Fatalf("unexpected error") 71 | } 72 | if !changed { 73 | t.Fatalf("expected a change") 74 | } 75 | 76 | // The file is gone? 77 | if file.Exists(tmpfile.Name()) { 78 | t.Fatalf("File still exists, but should have been removed!") 79 | } 80 | 81 | // Run the module again to confirm "no change" when asked to remove a file 82 | // that does not exist. 83 | changed, err = f.Execute(args) 84 | 85 | if err != nil { 86 | t.Fatalf("unexpected error") 87 | } 88 | if changed { 89 | t.Fatalf("didn't expect a change, but got one") 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /modules/module_git.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | mcfg "github.com/skx/marionette/config" 10 | "github.com/skx/marionette/environment" 11 | "github.com/skx/marionette/file" 12 | "gopkg.in/src-d/go-git.v4" 13 | "gopkg.in/src-d/go-git.v4/config" 14 | "gopkg.in/src-d/go-git.v4/plumbing" 15 | ) 16 | 17 | // GitModule stores our state 18 | type GitModule struct { 19 | 20 | // cfg contains our configuration object. 21 | cfg *mcfg.Config 22 | 23 | // env holds our environment 24 | env *environment.Environment 25 | } 26 | 27 | // Check is part of the module-api, and checks arguments. 28 | func (g *GitModule) Check(args map[string]interface{}) error { 29 | 30 | // Required keys for this module 31 | required := []string{"repository", "path"} 32 | 33 | // Ensure they exist. 34 | for _, key := range required { 35 | _, ok := args[key] 36 | if !ok { 37 | return fmt.Errorf("missing '%s' parameter", key) 38 | } 39 | 40 | val := StringParam(args, key) 41 | if val == "" { 42 | return fmt.Errorf("'%s' wasn't a simple string", key) 43 | 44 | } 45 | 46 | } 47 | return nil 48 | } 49 | 50 | // Execute is part of the module-api, and is invoked to run a rule. 51 | func (g *GitModule) Execute(args map[string]interface{}) (bool, error) { 52 | 53 | // Repository location - we've already confirmed these are valid 54 | // in our check function. 55 | repo := StringParam(args, "repository") 56 | path := StringParam(args, "path") 57 | 58 | // optional branch to checkout 59 | branch := StringParam(args, "branch") 60 | 61 | // Have we changed? 62 | changed := false 63 | 64 | // If we don't have "path/.git" then we need to fetch it 65 | tmp := filepath.Join(path, ".git") 66 | if !file.Exists(tmp) { 67 | 68 | // Show what we're doing. 69 | log.Printf("[DEBUG] %s not present, cloning %s", tmp, repo) 70 | 71 | // Clone since it is missing. 72 | _, err := git.PlainClone(path, false, &git.CloneOptions{ 73 | URL: repo, 74 | Progress: os.Stdout, 75 | }) 76 | 77 | if err != nil { 78 | return false, err 79 | } 80 | 81 | changed = true 82 | } else { 83 | log.Printf("[DEBUG] Repository exists at %s", tmp) 84 | } 85 | 86 | // 87 | // OK now we need to pull in any changes. 88 | // 89 | 90 | // Open the repo. 91 | r, err := git.PlainOpen(path) 92 | if err != nil { 93 | return false, fmt.Errorf("git.PlainOpen failed %s", err) 94 | } 95 | 96 | // Get the head-commit 97 | ref, err := r.Reference(plumbing.HEAD, true) 98 | if err != nil { 99 | return false, fmt.Errorf("git.Head() failed %s", err) 100 | } 101 | 102 | // Get the work tree 103 | w, err := r.Worktree() 104 | if err != nil { 105 | return false, fmt.Errorf("git.Worktree failed %s", err) 106 | } 107 | 108 | options := &git.PullOptions{RemoteName: "origin"} 109 | 110 | // If we're to switch branch do that 111 | if branch != "" { 112 | 113 | // fetch references 114 | err = r.Fetch(&git.FetchOptions{ 115 | RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}, 116 | }) 117 | if err != nil && err != git.NoErrAlreadyUpToDate { 118 | return false, fmt.Errorf("git.Fetch failed %s", err) 119 | } 120 | 121 | // checkout the branch 122 | err = w.Checkout(&git.CheckoutOptions{ 123 | Branch: plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)), 124 | Force: true, 125 | }) 126 | if err != nil { 127 | return false, fmt.Errorf("git.Checkout failed for branch %s: %s", branch, err) 128 | } 129 | 130 | options.ReferenceName = plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)) 131 | } 132 | 133 | // Do the pull 134 | err = w.Pull(options) 135 | if err != nil && err != git.NoErrAlreadyUpToDate { 136 | return false, fmt.Errorf("git.Pull failed %s", err) 137 | } 138 | 139 | // Get the second ref 140 | ref2, err := r.Reference(plumbing.HEAD, true) 141 | if err != nil { 142 | return false, fmt.Errorf("git.Head failed for comparison %s", err) 143 | } 144 | 145 | log.Printf("[DEBUG] First reference %s, second reference %s", ref.Hash(), ref2.Hash()) 146 | 147 | // If the hashes differ we've updated, and thus changed 148 | if ref2.Hash() != ref.Hash() { 149 | changed = true 150 | } 151 | 152 | return changed, err 153 | } 154 | 155 | // init is used to dynamically register our module. 156 | func init() { 157 | Register("git", func(cfg *mcfg.Config, env *environment.Environment) ModuleAPI { 158 | return &GitModule{ 159 | cfg: cfg, 160 | env: env, 161 | } 162 | }) 163 | } 164 | -------------------------------------------------------------------------------- /modules/module_group.go: -------------------------------------------------------------------------------- 1 | // Common code for the "group" module. 2 | // 3 | // Execute is implemented in a per-OS fashion. 4 | 5 | package modules 6 | 7 | import ( 8 | "fmt" 9 | "regexp" 10 | 11 | mcfg "github.com/skx/marionette/config" 12 | "github.com/skx/marionette/environment" 13 | ) 14 | 15 | // GroupModule stores our state 16 | type GroupModule struct { 17 | 18 | // cfg contains our configuration object. 19 | cfg *mcfg.Config 20 | 21 | // env holds our environment 22 | env *environment.Environment 23 | 24 | // Regular expression for testing if parameters are safe 25 | // and won't cause shell injection issues. 26 | reg *regexp.Regexp 27 | } 28 | 29 | // Check is part of the module-api, and checks arguments. 30 | func (g *GroupModule) Check(args map[string]interface{}) error { 31 | 32 | // Required keys for this module 33 | required := []string{"group", "state"} 34 | 35 | // Ensure they exist. 36 | for _, key := range required { 37 | 38 | // Get the param 39 | _, ok := args[key] 40 | if !ok { 41 | return fmt.Errorf("missing '%s' parameter", key) 42 | } 43 | 44 | // Ensure it is a simple string 45 | val := StringParam(args, key) 46 | if val == "" { 47 | return fmt.Errorf("parameter '%s' wasn't a simple string", key) 48 | } 49 | 50 | // Ensure it has decent characters 51 | if !g.reg.MatchString(val) { 52 | return fmt.Errorf("parameter '%s' failed validation", key) 53 | } 54 | 55 | } 56 | 57 | // Ensure state is one of "present"/"absent" 58 | state := StringParam(args, "state") 59 | if state == "absent" { 60 | return nil 61 | } 62 | if state == "present" { 63 | return nil 64 | } 65 | 66 | return fmt.Errorf("state must be one of 'absent' or 'present'") 67 | } 68 | 69 | // init is used to dynamically register our module. 70 | func init() { 71 | Register("group", func(cfg *mcfg.Config, env *environment.Environment) ModuleAPI { 72 | return &GroupModule{ 73 | cfg: cfg, 74 | env: env, 75 | reg: regexp.MustCompile(`^[-_/a-zA-Z0-9]+$`), 76 | } 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /modules/module_group_not_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || windows 2 | 3 | package modules 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "runtime" 9 | ) 10 | 11 | // Execute is part of the module-api, and is invoked to run a rule. 12 | func (g *GroupModule) Execute(args map[string]interface{}) (bool, error) { 13 | 14 | message := "the 'group' module is not implemented on this platform" 15 | 16 | log.Printf("[ERROR] %s: %s", message, runtime.GOOS) 17 | 18 | return false, fmt.Errorf("%s: %s", message, runtime.GOOS) 19 | } 20 | -------------------------------------------------------------------------------- /modules/module_group_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin && !windows 2 | 3 | package modules 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "os/exec" 9 | "os/user" 10 | "syscall" 11 | ) 12 | 13 | // Execute is part of the module-api, and is invoked to run a rule. 14 | func (g *GroupModule) Execute(args map[string]interface{}) (bool, error) { 15 | 16 | // Group/State - we've already confirmed these are valid 17 | // in our check function. 18 | group := StringParam(args, "group") 19 | state := StringParam(args, "state") 20 | 21 | // Does the group already exist? 22 | if g.groupExists(group) { 23 | 24 | if state == "present" { 25 | 26 | // We're supposed to create the group, but it 27 | // already exists. Do nothing. 28 | return false, nil 29 | } 30 | if state == "absent" { 31 | 32 | // remove the group 33 | err := g.removeGroup(args) 34 | return true, err 35 | } 36 | } 37 | 38 | if state == "absent" { 39 | 40 | // The group is not present, and we're supposed to remove 41 | // it. Do nothing. 42 | return false, nil 43 | } 44 | 45 | // Create the group 46 | ret := g.createGroup(args) 47 | 48 | // error? 49 | if ret != nil { 50 | return false, ret 51 | } 52 | 53 | return true, nil 54 | } 55 | 56 | // groupExists tests if the given group exists. 57 | func (g *GroupModule) groupExists(group string) bool { 58 | 59 | _, err := user.LookupGroup(group) 60 | 61 | return err == nil 62 | } 63 | 64 | // createGroup creates a local group. 65 | func (g *GroupModule) createGroup(args map[string]interface{}) error { 66 | 67 | group := StringParam(args, "group") 68 | 69 | // The creation command 70 | cmdArgs := []string{"groupadd", group} 71 | 72 | // do we need to enhance our permissions? 73 | privs := StringParam(args, "elevate") 74 | if privs != "" { 75 | cmdArgs = append([]string{privs}, cmdArgs...) 76 | } 77 | 78 | // Show what we're doing 79 | log.Printf("[DEBUG] Running %s", cmdArgs) 80 | 81 | // Run it 82 | cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) 83 | if err := cmd.Start(); err != nil { 84 | return err 85 | } 86 | 87 | // Wait for completion 88 | if err := cmd.Wait(); err != nil { 89 | 90 | if exiterr, ok := err.(*exec.ExitError); ok { 91 | 92 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 93 | return fmt.Errorf("exit code was %d", status.ExitStatus()) 94 | } 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // removeGroup removes the local group 102 | func (g *GroupModule) removeGroup(args map[string]interface{}) error { 103 | 104 | group := StringParam(args, "group") 105 | 106 | // The removal command 107 | cmdArgs := []string{"groupdel", group} 108 | 109 | // do we need to enhance our permissions? 110 | privs := StringParam(args, "elevate") 111 | if privs != "" { 112 | cmdArgs = append([]string{privs}, cmdArgs...) 113 | } 114 | 115 | // Show what we're doing 116 | log.Printf("[DEBUG] Running %s", cmdArgs) 117 | 118 | // Run it 119 | cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) 120 | if err := cmd.Start(); err != nil { 121 | return err 122 | } 123 | 124 | // Wait for completion 125 | if err := cmd.Wait(); err != nil { 126 | 127 | if exiterr, ok := err.(*exec.ExitError); ok { 128 | 129 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 130 | return fmt.Errorf("exit code was %d", status.ExitStatus()) 131 | } 132 | } 133 | } 134 | 135 | return nil 136 | } 137 | -------------------------------------------------------------------------------- /modules/module_http.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/skx/marionette/config" 12 | "github.com/skx/marionette/environment" 13 | ) 14 | 15 | // HTTPModule stores our state. 16 | type HTTPModule struct { 17 | 18 | // cfg contains our configuration object. 19 | cfg *config.Config 20 | 21 | // env holds our environment 22 | env *environment.Environment 23 | 24 | // Save the status-code, after our request was completed. 25 | statusCode int 26 | 27 | // Save the status-line, after our request was completed. 28 | statusLine string 29 | 30 | // Save the body, after our request was completed 31 | body string 32 | } 33 | 34 | // Check is part of the module-api, and checks arguments. 35 | func (f *HTTPModule) Check(args map[string]interface{}) error { 36 | 37 | // Ensure we have a url to request. 38 | _, ok := args["url"] 39 | if !ok { 40 | return fmt.Errorf("missing 'url' parameter") 41 | } 42 | 43 | // Ensure the url is a string. 44 | url := StringParam(args, "url") 45 | if url == "" { 46 | return fmt.Errorf("failed to convert 'url' to string") 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // Execute is part of the module-api, and is invoked to run a rule. 53 | func (f *HTTPModule) Execute(args map[string]interface{}) (bool, error) { 54 | 55 | // Get the url. 56 | url := StringParam(args, "url") 57 | if url == "" { 58 | return false, fmt.Errorf("missing 'url' parameter") 59 | } 60 | 61 | // Default to a GET request if method not supplied. 62 | method := StringParam(args, "method") 63 | if method == "" { 64 | method = "GET" 65 | } 66 | method = strings.ToUpper(method) 67 | 68 | body := StringParam(args, "body") 69 | 70 | request, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(body))) 71 | if err != nil { 72 | return false, err 73 | } 74 | 75 | // Add any headers, whether string or array 76 | headers := ArrayCastParam(args, "headers") 77 | if len(headers) > 0 { 78 | for _, header := range headers { 79 | parts := strings.SplitN(header, ":", 2) 80 | request.Header.Add(parts[0], parts[1]) 81 | } 82 | } 83 | 84 | // Perform the request. 85 | client := http.Client{} 86 | response, err := client.Do(request) 87 | if err != nil { 88 | return false, err 89 | } 90 | 91 | // Make sure we close the body. 92 | defer response.Body.Close() 93 | 94 | // Read the response. 95 | var content []byte 96 | content, err = ioutil.ReadAll(response.Body) 97 | if err != nil { 98 | return false, err 99 | } 100 | 101 | // Check the response against the expected status code if supplied. 102 | expectedStatus := StringParam(args, "expect") 103 | if expectedStatus != "" { 104 | expectedInt, err := strconv.Atoi(expectedStatus) 105 | if err != nil { 106 | return false, err 107 | } 108 | 109 | if response.StatusCode != expectedInt { 110 | return false, fmt.Errorf("request returned unexpected status: %d, expected %d", response.StatusCode, expectedInt) 111 | } 112 | } else { 113 | // Otherwise, return error if not a 2xx status code. 114 | if response.StatusCode < 200 || response.StatusCode >= 300 { 115 | return false, fmt.Errorf("request returned unsuccessful status: %d", response.StatusCode) 116 | } 117 | } 118 | 119 | // Save the result values 120 | f.statusCode = response.StatusCode 121 | f.statusLine = response.Status 122 | f.body = string(content) 123 | 124 | return true, nil 125 | 126 | } 127 | 128 | // GetOutputs is an optional interface method which allows the 129 | // module to return values to the caller - prefixed by the rule-name. 130 | func (f *HTTPModule) GetOutputs() map[string]string { 131 | 132 | // Prepare a map of key->values to return 133 | m := make(map[string]string) 134 | 135 | // Populate with information from our execution. 136 | m["code"] = fmt.Sprintf("%d", f.statusCode) 137 | m["status"] = f.statusLine 138 | m["body"] = f.body 139 | 140 | return m 141 | } 142 | 143 | // init is used to dynamically register our module. 144 | func init() { 145 | Register("http", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 146 | return &HTTPModule{ 147 | cfg: cfg, 148 | env: env, 149 | } 150 | }) 151 | } 152 | -------------------------------------------------------------------------------- /modules/module_http_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestHttpCheck(t *testing.T) { 9 | 10 | h := &HTTPModule{} 11 | 12 | args := make(map[string]interface{}) 13 | 14 | // Missing 'url' 15 | err := h.Check(args) 16 | if err == nil { 17 | t.Fatalf("expected error due to missing url") 18 | } 19 | if !strings.Contains(err.Error(), "missing 'url'") { 20 | t.Fatalf("got error - but wrong one : %s", err) 21 | } 22 | 23 | // Valid target 24 | args["url"] = "https://github.com" 25 | err = h.Check(args) 26 | if err != nil { 27 | t.Fatalf("unexpected error") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /modules/module_link.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/skx/marionette/config" 8 | "github.com/skx/marionette/environment" 9 | "github.com/skx/marionette/file" 10 | ) 11 | 12 | // LinkModule stores our state 13 | type LinkModule struct { 14 | 15 | // cfg contains our configuration object. 16 | cfg *config.Config 17 | 18 | // env holds our environment 19 | env *environment.Environment 20 | } 21 | 22 | // Check is part of the module-api, and checks arguments. 23 | func (f *LinkModule) Check(args map[string]interface{}) error { 24 | 25 | required := []string{"source", "target"} 26 | 27 | for _, key := range required { 28 | _, ok := args[key] 29 | if !ok { 30 | return fmt.Errorf("missing '%s' parameter", key) 31 | } 32 | } 33 | 34 | return nil 35 | } 36 | 37 | // Execute is part of the module-api, and is invoked to run a rule. 38 | func (f *LinkModule) Execute(args map[string]interface{}) (bool, error) { 39 | 40 | // Get the target 41 | target := StringParam(args, "target") 42 | if target == "" { 43 | return false, fmt.Errorf("failed to convert target to string") 44 | } 45 | 46 | // Get the source 47 | source := StringParam(args, "source") 48 | if source == "" { 49 | return false, fmt.Errorf("failed to convert source to string") 50 | } 51 | 52 | // If the target doesn't exist we create the link. 53 | if !file.Exists(target) { 54 | err := os.Symlink(source, target) 55 | return true, err 56 | } 57 | 58 | // If the target does exist see if it is correct. 59 | fileInfo, err := os.Lstat(target) 60 | 61 | if err != nil { 62 | return false, err 63 | } 64 | 65 | // Is it a symlink? 66 | if fileInfo.Mode()&os.ModeSymlink != 0 { 67 | 68 | // If so get the target file to which it points. 69 | var originFile string 70 | originFile, err = os.Readlink(target) 71 | if err != nil { 72 | return false, err 73 | } 74 | 75 | // OK we have a target - is it correct? 76 | if originFile == source { 77 | return false, nil 78 | } 79 | 80 | // OK there is a symlink, but it points to the 81 | // wrong target-file. Remove it. 82 | err = os.Remove(target) 83 | if err != nil { 84 | return false, err 85 | } 86 | } else { 87 | 88 | // We found something that wasn't a symlink. 89 | // 90 | // Remove it. 91 | err = os.Remove(target) 92 | if err != nil { 93 | return false, err 94 | } 95 | 96 | } 97 | 98 | // Fix the symlink. 99 | err = os.Symlink(source, target) 100 | return true, err 101 | } 102 | 103 | // init is used to dynamically register our module. 104 | func init() { 105 | Register("link", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 106 | return &LinkModule{ 107 | cfg: cfg, 108 | env: env, 109 | } 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /modules/module_log.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/skx/marionette/config" 8 | "github.com/skx/marionette/environment" 9 | ) 10 | 11 | // LogModule stores our state 12 | type LogModule struct { 13 | 14 | // cfg contains our configuration object. 15 | cfg *config.Config 16 | 17 | // env holds our environment 18 | env *environment.Environment 19 | } 20 | 21 | // Check is part of the module-api, and checks arguments. 22 | func (f *LogModule) Check(args map[string]interface{}) error { 23 | 24 | // Ensure we have a message, or messages, to log. 25 | _, ok := args["message"] 26 | if !ok { 27 | return fmt.Errorf("missing 'message' parameter") 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // Execute is part of the module-api, and is invoked to run a rule. 34 | func (f *LogModule) Execute(args map[string]interface{}) (bool, error) { 35 | 36 | // Get the message/messages to log. 37 | strs := ArrayCastParam(args, "message") 38 | 39 | // Ensure that we've got something 40 | if len(strs) < 1 { 41 | return false, fmt.Errorf("missing 'message' parameter") 42 | } 43 | 44 | // process each argument 45 | for _, str := range strs { 46 | log.Print("[USER] " + str) 47 | } 48 | 49 | return true, nil 50 | } 51 | 52 | // init is used to dynamically register our module. 53 | func init() { 54 | Register("log", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 55 | return &LogModule{ 56 | cfg: cfg, 57 | env: env, 58 | } 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /modules/module_log_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | // Test the argument validation works 11 | func TestLogArguments(t *testing.T) { 12 | 13 | // Save our log writer 14 | before := log.Writer() 15 | defer log.SetOutput(before) 16 | 17 | // Chang logger to write to a temporary buffer. 18 | var buf bytes.Buffer 19 | log.SetOutput(&buf) 20 | 21 | // Create the object, and arguments. 22 | l := &LogModule{} 23 | args := make(map[string]interface{}) 24 | empty := make(map[string]interface{}) 25 | 26 | // Missing argument 27 | err := l.Check(args) 28 | if err == nil { 29 | t.Fatalf("expected error due to missing message-parameter") 30 | } 31 | if !strings.Contains(err.Error(), "missing 'message'") { 32 | t.Fatalf("got error - but wrong one : %s", err) 33 | } 34 | 35 | // Valid argument 36 | args["message"] = "Hello, world" 37 | err = l.Check(args) 38 | if err != nil { 39 | t.Fatalf("unexpected error checking") 40 | } 41 | 42 | // 43 | // Try to execute - missing argument 44 | // 45 | c := false 46 | c, err = l.Execute(empty) 47 | if err == nil { 48 | t.Fatalf("expected an error with no message.") 49 | } 50 | if c { 51 | t.Fatalf("expected no-change in error-condition") 52 | } 53 | 54 | // 55 | // Try to execute - valid argument 56 | // 57 | c, err = l.Execute(args) 58 | if err != nil { 59 | t.Fatalf("unexpected error executing") 60 | } 61 | if !c { 62 | t.Fatalf("logger should have resulted in a change") 63 | } 64 | 65 | // 66 | // Confirm we got our message in the log-output 67 | // 68 | output := buf.String() 69 | if !strings.Contains(output, "Hello, world") { 70 | t.Fatalf("log message wasn't found") 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /modules/module_package.go: -------------------------------------------------------------------------------- 1 | // This module handles package installation/removal. 2 | 3 | package modules 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "strings" 9 | 10 | "github.com/skx/marionette/config" 11 | "github.com/skx/marionette/environment" 12 | "github.com/skx/marionette/modules/system" 13 | ) 14 | 15 | // PackageModule stores our state 16 | type PackageModule struct { 17 | 18 | // cfg contains our configuration object. 19 | cfg *config.Config 20 | 21 | // env holds our environment 22 | env *environment.Environment 23 | 24 | // state when using a compatibility-module 25 | state string 26 | } 27 | 28 | // Check is part of the module-api, and checks arguments. 29 | func (pm *PackageModule) Check(args map[string]interface{}) error { 30 | 31 | // Ensure we have a package, or set of packages, to install/uninstall. 32 | _, ok := args["package"] 33 | if !ok { 34 | return fmt.Errorf("missing 'package' parameter") 35 | } 36 | 37 | // Ensure we have a state to move towards. 38 | state, ok2 := args["state"] 39 | if !ok2 { 40 | if pm.state != "" { 41 | state = pm.state 42 | } else { 43 | return fmt.Errorf("missing 'state' parameter") 44 | } 45 | } 46 | 47 | // The state should make sense. 48 | if state != "installed" && state != "absent" { 49 | return fmt.Errorf("package state must be either 'installed' or 'absent'") 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // Execute is part of the module-api, and is invoked to run a rule. 56 | func (pm *PackageModule) Execute(args map[string]interface{}) (bool, error) { 57 | 58 | // Did we make a change, by installing/removing a package? 59 | changed := false 60 | 61 | // Package abstraction 62 | pkg := system.New() 63 | 64 | // Do we need to use doas/sudo? 65 | privs := StringParam(args, "elevate") 66 | if privs != "" { 67 | pkg.UsePrivilegeHelper(privs) 68 | } 69 | 70 | // Get the packages we're working with 71 | packages := ArrayCastParam(args, "package") 72 | 73 | // Do we need to update? 74 | // 75 | // This only makes sense for a package-installation, but 76 | // we'll accept it for the module globally as there is no 77 | // harm in it. 78 | p := StringParam(args, "update") 79 | if p == "yes" || p == "true" { 80 | err := pkg.Update() 81 | if err != nil { 82 | return false, err 83 | } 84 | } 85 | 86 | // Are we installing, or uninstalling? 87 | state := StringParam(args, "state") 88 | if state == "" { 89 | if pm.state != "" { 90 | state = pm.state 91 | } else { 92 | return false, fmt.Errorf("state must be a string") 93 | } 94 | } 95 | 96 | // We might have 10+ packages, but we want to ensure that we 97 | // install/remove all the packages at once. 98 | // 99 | // So while we can test the packages that are already present 100 | // to work out our actions we do need to add/remove things 101 | // en mass 102 | toInstall := []string{} 103 | toRemove := []string{} 104 | 105 | // For each package install/uninstall 106 | for _, name := range packages { 107 | 108 | log.Printf("[DEBUG] Testing package %s", name) 109 | 110 | // Is it installed? 111 | inst, err := pkg.IsInstalled(name) 112 | if err != nil { 113 | return false, err 114 | } 115 | 116 | // Show the output 117 | if inst { 118 | log.Printf("[DEBUG] Package installed: %s", name) 119 | } else { 120 | log.Printf("[DEBUG] Package not installed: %s", name) 121 | } 122 | 123 | if state == "installed" { 124 | 125 | // Already installed, do nothing. 126 | if inst { 127 | continue 128 | } 129 | 130 | // Save this package as something to install 131 | // once we've tested the rest. 132 | toInstall = append(toInstall, name) 133 | } 134 | 135 | if state == "absent" { 136 | 137 | // If it is not installed we have nothing to do. 138 | if !inst { 139 | continue 140 | } 141 | 142 | // Save this package as something to remove 143 | // once we've tested the rest 144 | toRemove = append(toRemove, name) 145 | } 146 | } 147 | 148 | // Something to install? 149 | if len(toInstall) > 0 { 150 | 151 | // Log it 152 | log.Printf("[DEBUG] Package(s) which need to be installed: %s", strings.Join(toInstall, ",")) 153 | 154 | // Do it 155 | err := pkg.Install(toInstall) 156 | if err != nil { 157 | return false, err 158 | } 159 | 160 | // We resulted in a change, because we had things to install 161 | // and presumably they're now installed. 162 | changed = true 163 | } 164 | 165 | // Something to uninstall? 166 | if len(toRemove) > 0 { 167 | 168 | // Log it 169 | log.Printf("[DEBUG] Package(s) which need to be removed: %s", strings.Join(toRemove, ",")) 170 | 171 | // Do it 172 | err := pkg.Uninstall(toRemove) 173 | if err != nil { 174 | return false, err 175 | } 176 | 177 | // We resulted in a change, because we had things to remove 178 | // and presumably they're now purged. 179 | changed = true 180 | } 181 | 182 | return changed, nil 183 | } 184 | 185 | // init is used to dynamically register our module. 186 | func init() { 187 | Register("package", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 188 | return &PackageModule{ 189 | cfg: cfg, 190 | env: env, 191 | } 192 | }) 193 | 194 | // compatibility with previous releases. 195 | Register("apt", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 196 | return &PackageModule{ 197 | cfg: cfg, 198 | env: env, 199 | state: "installed", 200 | } 201 | }) 202 | Register("dpkg", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 203 | return &PackageModule{ 204 | cfg: cfg, 205 | env: env, 206 | state: "absent", 207 | } 208 | }) 209 | } 210 | -------------------------------------------------------------------------------- /modules/module_package_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestPackageCheck(t *testing.T) { 9 | 10 | p := &PackageModule{} 11 | 12 | args := make(map[string]interface{}) 13 | 14 | // Missing 'package' 15 | err := p.Check(args) 16 | if err == nil { 17 | t.Fatalf("expected error due to missing package") 18 | } 19 | if !strings.Contains(err.Error(), "missing 'package'") { 20 | t.Fatalf("got error - but wrong one : %s", err) 21 | } 22 | 23 | args["package"] = []string{"bash", "curl"} 24 | 25 | // state can be either "installed" or "absent" 26 | valid := []string{"installed", "absent"} 27 | for _, state := range valid { 28 | args["state"] = state 29 | 30 | err = p.Check(args) 31 | 32 | if err != nil { 33 | t.Fatalf("unexpected error: %s", err.Error()) 34 | } 35 | } 36 | 37 | // Confirm a different one breaks 38 | args["state"] = "removed" 39 | err = p.Check(args) 40 | 41 | if err == nil { 42 | t.Fatalf("expected error, got none") 43 | } 44 | if !strings.Contains(err.Error(), "package state must be either") { 45 | t.Fatalf("got error, but not the correct one") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /modules/module_shell.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/skx/marionette/config" 11 | "github.com/skx/marionette/environment" 12 | ) 13 | 14 | // ShellModule stores our state 15 | type ShellModule struct { 16 | 17 | // cfg contains our configuration object. 18 | cfg *config.Config 19 | 20 | // env holds our environment 21 | env *environment.Environment 22 | 23 | // Saved copy of STDOUT. 24 | stdout []byte 25 | 26 | // Saved copy of STDERR. 27 | stderr []byte 28 | } 29 | 30 | // Check is part of the module-api, and checks arguments. 31 | func (f *ShellModule) Check(args map[string]interface{}) error { 32 | 33 | // Ensure we have one or more commands to run. 34 | _, ok := args["command"] 35 | if !ok { 36 | return fmt.Errorf("missing 'command' parameter") 37 | } 38 | 39 | return nil 40 | } 41 | 42 | // Execute is part of the module-api, and is invoked to run a rule. 43 | func (f *ShellModule) Execute(args map[string]interface{}) (bool, error) { 44 | 45 | // get command(s) 46 | cmds := ArrayCastParam(args, "command") 47 | 48 | // Ensure we have one or more commands to run. 49 | if len(cmds) < 1 { 50 | return false, fmt.Errorf("missing 'command' parameter") 51 | } 52 | 53 | // process each argument 54 | for _, cmd := range cmds { 55 | 56 | // Run this command 57 | err := f.executeSingle(cmd, args) 58 | 59 | // process any error 60 | if err != nil { 61 | return false, err 62 | } 63 | } 64 | 65 | // shell commands always result in a change 66 | return true, nil 67 | } 68 | 69 | // executeSingle executes a single command. 70 | // 71 | // All parameters are available, as is the string command to run. 72 | func (f *ShellModule) executeSingle(command string, args map[string]interface{}) error { 73 | 74 | // 75 | // Should we run using a shell? 76 | // 77 | useShell := false 78 | 79 | // 80 | // Does the user explicitly request the use of a shell? 81 | // 82 | shell := StringParam(args, "shell") 83 | if strings.ToLower(shell) == "true" { 84 | useShell = true 85 | } 86 | 87 | // 88 | // If the user didn't explicitly specify a shell must be used 89 | // we must do so anyway if we see a redirection, or the use of 90 | // a pipe. 91 | // 92 | if strings.Contains(command, ">") || strings.Contains(command, "&") || strings.Contains(command, "|") || strings.Contains(command, "<") { 93 | useShell = true 94 | } 95 | 96 | // 97 | // By default we split on space to find the things to execute. 98 | // 99 | var bits []string 100 | bits = strings.Split(command, " ") 101 | 102 | // 103 | // But 104 | // 105 | // If the user explicitly specified the need to use a shell. 106 | // 107 | // or 108 | // 109 | // We found a redirection/similar then we must run via a shell. 110 | // 111 | if useShell { 112 | bits = []string{"bash", "-c", command} 113 | } 114 | 115 | // Show what we're executing. 116 | log.Printf("[DEBUG] CMD: %s", strings.Join(bits, " ")) 117 | 118 | // Now run 119 | cmd := exec.Command(bits[0], bits[1:]...) 120 | 121 | // Setup buffers for saving STDOUT/STDERR. 122 | var execOut bytes.Buffer 123 | var execErr bytes.Buffer 124 | 125 | // Wire up the output buffers. 126 | // 127 | // In the past we'd output these to the console, but now we're 128 | // implementing the GetOutput interface they'll be shown when 129 | // running with -debug anyway. 130 | // 131 | cmd.Stdout = &execOut 132 | cmd.Stderr = &execErr 133 | 134 | // Run the command 135 | err := cmd.Run() 136 | if err != nil { 137 | return fmt.Errorf("error running command '%s' %s", command, err.Error()) 138 | } 139 | 140 | // Save the outputs 141 | f.stdout = execOut.Bytes() 142 | f.stderr = execErr.Bytes() 143 | 144 | return nil 145 | } 146 | 147 | // GetOutputs is an optional interface method which allows the 148 | // module to return values to the caller - prefixed by the rule-name. 149 | func (f *ShellModule) GetOutputs() map[string]string { 150 | 151 | // Prepare a map of key->values to return 152 | m := make(map[string]string) 153 | 154 | // Populate with information from our execution. 155 | m["stdout"] = strings.TrimSpace(string(f.stdout)) 156 | m["stderr"] = strings.TrimSpace(string(f.stderr)) 157 | 158 | return m 159 | } 160 | 161 | // init is used to dynamically register our module. 162 | func init() { 163 | Register("shell", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 164 | return &ShellModule{ 165 | cfg: cfg, 166 | env: env, 167 | } 168 | }) 169 | } 170 | -------------------------------------------------------------------------------- /modules/module_shell_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package modules 5 | 6 | import ( 7 | "strings" 8 | "testing" 9 | 10 | "github.com/skx/marionette/config" 11 | ) 12 | 13 | func TestShellCheck(t *testing.T) { 14 | 15 | s := &ShellModule{} 16 | 17 | args := make(map[string]interface{}) 18 | 19 | // Missing 'command' 20 | err := s.Check(args) 21 | if err == nil { 22 | t.Fatalf("expected error due to missing command") 23 | } 24 | if !strings.Contains(err.Error(), "missing 'command'") { 25 | t.Fatalf("got error - but wrong one : %s", err) 26 | } 27 | 28 | // Valid target 29 | args["command"] = "/usr/bin/uptime" 30 | err = s.Check(args) 31 | if err != nil { 32 | t.Fatalf("unexpected error") 33 | } 34 | } 35 | 36 | func TestShell(t *testing.T) { 37 | 38 | // Quiet and Verbose 39 | sQuiet := &ShellModule{cfg: &config.Config{Verbose: false}} 40 | sVerbose := &ShellModule{cfg: &config.Config{Verbose: true}} 41 | 42 | // Arguments 43 | args := make(map[string]interface{}) 44 | 45 | // Run with no arguments to see an error 46 | changed, err := sQuiet.Execute(args) 47 | if changed { 48 | t.Fatalf("unexpected change") 49 | } 50 | if err == nil { 51 | t.Fatalf("Expected error with no command") 52 | } 53 | if !strings.Contains(err.Error(), "missing 'command'") { 54 | t.Fatalf("Got error, but wrong one") 55 | } 56 | 57 | // Now setup a command to run - a harmless one! 58 | args["command"] = "true" 59 | 60 | changed, err = sQuiet.Execute(args) 61 | 62 | if !changed { 63 | t.Fatalf("Expected to see changed result") 64 | } 65 | if err != nil { 66 | t.Fatalf("unexpected error:%s", err.Error()) 67 | } 68 | 69 | changed, err = sVerbose.Execute(args) 70 | 71 | if !changed { 72 | t.Fatalf("Expected to see changed result") 73 | } 74 | if err != nil { 75 | t.Fatalf("unexpected error:%s", err.Error()) 76 | } 77 | 78 | // Try a command with redirection 79 | args["command"] = "true >/dev/null" 80 | changed, err = sQuiet.Execute(args) 81 | 82 | if !changed { 83 | t.Fatalf("Expected to see changed result") 84 | } 85 | if err != nil { 86 | t.Fatalf("unexpected error:%s", err.Error()) 87 | } 88 | 89 | // Now finally try a command that doesn't exist. 90 | args["command"] = "/this/does/not/exist" 91 | changed, err = sQuiet.Execute(args) 92 | 93 | if err == nil { 94 | t.Fatalf("wanted error running missing command, got none") 95 | } 96 | if changed { 97 | t.Fatalf("Didn't expect to see changed result") 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /modules/module_sql.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "strings" 9 | 10 | "github.com/skx/marionette/config" 11 | "github.com/skx/marionette/environment" 12 | 13 | // TODO - more? 14 | _ "github.com/go-sql-driver/mysql" 15 | _ "github.com/lib/pq" 16 | _ "github.com/mattn/go-sqlite3" 17 | ) 18 | 19 | // SQLModule stores our state. 20 | type SQLModule struct { 21 | 22 | // cfg contains our configuration object. 23 | cfg *config.Config 24 | 25 | // env holds our environment 26 | env *environment.Environment 27 | } 28 | 29 | // Check is part of the module-api, and checks arguments. 30 | func (f *SQLModule) Check(args map[string]interface{}) error { 31 | 32 | // Ensure we have a driver-name 33 | driver, ok := args["driver"] 34 | if !ok { 35 | return fmt.Errorf("missing 'driver' parameter") 36 | } 37 | 38 | // Ensure the driver is known to us. 39 | drivers := []string{"mysql", "postgres", "sqlite3"} 40 | 41 | found := false 42 | for _, d := range drivers { 43 | if driver == d { 44 | found = true 45 | } 46 | } 47 | if !found { 48 | return fmt.Errorf("unknown driver: %s - valid options are %s", driver, strings.Join(drivers, ",")) 49 | } 50 | 51 | // Ensure we have a dsn-name 52 | _, ok = args["dsn"] 53 | if !ok { 54 | return fmt.Errorf("missing 'dsn' parameter") 55 | } 56 | 57 | // We must have one of "sql" or "sql_file" 58 | count := 0 59 | 60 | for _, arg := range []string{"sql", "sql_file"} { 61 | _, ok := args[arg] 62 | if ok { 63 | count++ 64 | } 65 | } 66 | 67 | if count != 1 { 68 | return fmt.Errorf("you must specify one of 'sql' or 'sql_file'") 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // Execute is part of the module-api, and is invoked to run a rule. 75 | func (f *SQLModule) Execute(args map[string]interface{}) (bool, error) { 76 | 77 | // Get our DSN + Driver 78 | dsn := StringParam(args, "dsn") 79 | driver := StringParam(args, "driver") 80 | 81 | // One of these will be valid 82 | sqlText := StringParam(args, "sql") 83 | sqlFile := StringParam(args, "file") 84 | 85 | // Open the database 86 | db, err := sql.Open(driver, dsn) 87 | if err != nil { 88 | return false, err 89 | } 90 | 91 | // Avoid leaking the handle. 92 | defer db.Close() 93 | 94 | // We're either running a query with a literal string, 95 | // or reading from a file. 96 | if sqlFile != "" { 97 | 98 | // If reading from a file then do so. 99 | data, err := ioutil.ReadFile(sqlFile) 100 | if err != nil { 101 | return false, err 102 | } 103 | 104 | sqlText = string(data) 105 | } 106 | 107 | // Now actually run the SQL 108 | res, execErr := db.Exec(sqlText) 109 | if execErr != nil { 110 | return false, execErr 111 | } 112 | 113 | // Try to see if we can get a useful output. 114 | rows, rErr := res.RowsAffected() 115 | ins, iErr := res.LastInsertId() 116 | 117 | // Show rows-affected, or the appropriate error. 118 | // 119 | // NOTE: An error here doesn't break our module invocation. 120 | if rErr == nil { 121 | log.Printf("[DEBUG] sql - affected rows %d", rows) 122 | } else { 123 | log.Printf("[DEBUG] sql - affected rows error %s", rErr) 124 | } 125 | 126 | // Show the last insert-id, or the appropriate error. 127 | // 128 | // NOTE: An error here doesn't break our module invocation. 129 | if iErr == nil { 130 | log.Printf("[DEBUG] sql - last insert id %d", ins) 131 | } else { 132 | log.Printf("[DEBUG] sql - last insert error %s", iErr) 133 | } 134 | 135 | // Return no error. 136 | // 137 | // But since we can't prove different we'll always regard 138 | // this module as having made a change - just like the 139 | // shell-execution. 140 | return true, nil 141 | 142 | } 143 | 144 | // init is used to dynamically register our module. 145 | func init() { 146 | Register("sql", func(cfg *config.Config, env *environment.Environment) ModuleAPI { 147 | return &SQLModule{ 148 | cfg: cfg, 149 | env: env, 150 | } 151 | }) 152 | } 153 | -------------------------------------------------------------------------------- /modules/module_sql_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestSqlArgs(t *testing.T) { 9 | 10 | s := &SQLModule{} 11 | 12 | args := make(map[string]interface{}) 13 | 14 | // Missing 'driver' 15 | err := s.Check(args) 16 | if err == nil { 17 | t.Fatalf("expected error due to missing driver") 18 | } 19 | if !strings.Contains(err.Error(), "missing 'driver'") { 20 | t.Fatalf("got error - but wrong one : %s", err) 21 | } 22 | 23 | // setup a driver 24 | args["driver"] = "moi" 25 | 26 | err = s.Check(args) 27 | if err == nil { 28 | t.Fatalf("expected error due to unknown driver") 29 | } 30 | if !strings.Contains(err.Error(), "unknown driver") { 31 | t.Fatalf("got error - but wrong one : %s", err) 32 | } 33 | 34 | // now we have a good driver 35 | args["driver"] = "mysql" 36 | 37 | // Missing 'dsn' 38 | err = s.Check(args) 39 | if err == nil { 40 | t.Fatalf("expected error due to missing dsn") 41 | } 42 | if !strings.Contains(err.Error(), "missing 'dsn'") { 43 | t.Fatalf("got error - but wrong one : %s", err) 44 | } 45 | 46 | // setup a dsn 47 | args["dsn"] = "root@blah" 48 | 49 | err = s.Check(args) 50 | if err == nil { 51 | t.Fatalf("expected error due to missing sql") 52 | } 53 | if !strings.Contains(err.Error(), "must specify one of") { 54 | t.Fatalf("got error - but wrong one : %s", err) 55 | } 56 | 57 | // setup sql 58 | args["sql"] = "SELECT 1" 59 | err = s.Check(args) 60 | if err != nil { 61 | t.Fatalf("unexpected error %s", err) 62 | } 63 | 64 | // setup sql_file 65 | args["sql_file"] = "/etc/passwd" 66 | 67 | err = s.Check(args) 68 | if err == nil { 69 | t.Fatalf("expected error due to setting sql AND sql_file") 70 | } 71 | if !strings.Contains(err.Error(), "must specify one of") { 72 | t.Fatalf("got error - but wrong one : %s", err) 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /modules/module_user.go: -------------------------------------------------------------------------------- 1 | // Common code for the "user" module. 2 | // 3 | // Execute is implemented in a per-OS fashion. 4 | 5 | package modules 6 | 7 | import ( 8 | "fmt" 9 | "regexp" 10 | 11 | mcfg "github.com/skx/marionette/config" 12 | "github.com/skx/marionette/environment" 13 | ) 14 | 15 | // UserModule stores our state 16 | type UserModule struct { 17 | 18 | // cfg contains our configuration object. 19 | cfg *mcfg.Config 20 | 21 | // env holds our environment 22 | env *environment.Environment 23 | 24 | // Regular expression for testing if parameters are safe 25 | // and won't cause shell injection issues. 26 | reg *regexp.Regexp 27 | } 28 | 29 | // Check is part of the module-api, and checks arguments. 30 | func (g *UserModule) Check(args map[string]interface{}) error { 31 | 32 | // Required keys for this module 33 | required := []string{"login", "state"} 34 | 35 | // Ensure they exist. 36 | for _, key := range required { 37 | 38 | // Get the param 39 | _, ok := args[key] 40 | if !ok { 41 | return fmt.Errorf("missing '%s' parameter", key) 42 | } 43 | 44 | // Ensure it is a simple string 45 | val := StringParam(args, key) 46 | if val == "" { 47 | return fmt.Errorf("parameter '%s' wasn't a simple string", key) 48 | } 49 | 50 | // Ensure it has decent characters 51 | if !g.reg.MatchString(val) { 52 | return fmt.Errorf("parameter '%s' failed validation", key) 53 | } 54 | 55 | } 56 | 57 | // Ensure state is one of "present"/"absent" 58 | state := StringParam(args, "state") 59 | if state == "absent" { 60 | return nil 61 | } 62 | if state == "present" { 63 | return nil 64 | } 65 | 66 | return fmt.Errorf("state must be one of 'absent' or 'present'") 67 | } 68 | 69 | // init is used to dynamically register our module. 70 | func init() { 71 | Register("user", func(cfg *mcfg.Config, env *environment.Environment) ModuleAPI { 72 | return &UserModule{ 73 | cfg: cfg, 74 | env: env, 75 | reg: regexp.MustCompile(`^[-_/a-zA-Z0-9]+$`), 76 | } 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /modules/module_user_not_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || windows 2 | 3 | package modules 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "runtime" 9 | ) 10 | 11 | // Execute is part of the module-api, and is invoked to run a rule. 12 | func (g *UserModule) Execute(args map[string]interface{}) (bool, error) { 13 | 14 | message := "the 'user' module is not implemented on this platform" 15 | 16 | log.Printf("[ERROR] %s: %s", message, runtime.GOOS) 17 | 18 | return false, fmt.Errorf("%s: %s", message, runtime.GOOS) 19 | } 20 | -------------------------------------------------------------------------------- /modules/module_user_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin && !windows 2 | 3 | package modules 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "os/exec" 9 | "os/user" 10 | "syscall" 11 | ) 12 | 13 | // Execute is part of the module-api, and is invoked to run a rule. 14 | func (g *UserModule) Execute(args map[string]interface{}) (bool, error) { 15 | 16 | // User/State - we've already confirmed these are valid 17 | // in our check function. 18 | login := StringParam(args, "login") 19 | state := StringParam(args, "state") 20 | 21 | // Does the user exist? 22 | if g.userExists(login) { 23 | 24 | if state == "present" { 25 | 26 | // We're supposed to create the user, but it 27 | // already exists. Do nothing. 28 | return false, nil 29 | } 30 | if state == "absent" { 31 | 32 | // remove the user 33 | err := g.removeUser(args) 34 | return true, err 35 | } 36 | } 37 | 38 | if state == "absent" { 39 | 40 | // The user is not present, and we're supposed to remove 41 | // it. Do nothing. 42 | return false, nil 43 | } 44 | 45 | // Create the user 46 | ret := g.createUser(args) 47 | 48 | // error? 49 | if ret != nil { 50 | return false, ret 51 | } 52 | 53 | return true, nil 54 | } 55 | 56 | // userExists tests if the given user exists. 57 | func (g *UserModule) userExists(login string) bool { 58 | 59 | _, err := user.Lookup(login) 60 | 61 | return err == nil 62 | } 63 | 64 | // createUser creates a local user. 65 | func (g *UserModule) createUser(args map[string]interface{}) error { 66 | 67 | login := StringParam(args, "login") 68 | 69 | // Optional arguments 70 | shell := StringParam(args, "shell") 71 | 72 | // Setup default shell, if nothing was specified. 73 | if shell == "" { 74 | shell = "/bin/bash" 75 | } 76 | 77 | // The user-creation command 78 | cmdArgs := []string{"useradd", "--shell", shell, login} 79 | 80 | // do we need to enhance our permissions? 81 | privs := StringParam(args, "elevate") 82 | if privs != "" { 83 | cmdArgs = append([]string{privs}, cmdArgs...) 84 | } 85 | 86 | // Show what we're doing 87 | log.Printf("[DEBUG] Running %s", cmdArgs) 88 | 89 | // Run it 90 | cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) 91 | if err := cmd.Start(); err != nil { 92 | return err 93 | } 94 | 95 | // Wait for completion 96 | if err := cmd.Wait(); err != nil { 97 | 98 | if exiterr, ok := err.(*exec.ExitError); ok { 99 | 100 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 101 | return fmt.Errorf("exit code was %d", status.ExitStatus()) 102 | } 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // removeUser removes the local user 110 | func (g *UserModule) removeUser(args map[string]interface{}) error { 111 | 112 | login := StringParam(args, "login") 113 | 114 | // The user-removal command 115 | cmdArgs := []string{"userdel", login} 116 | 117 | // do we need to enhance our permissions? 118 | privs := StringParam(args, "elevate") 119 | if privs != "" { 120 | cmdArgs = append([]string{privs}, cmdArgs...) 121 | } 122 | 123 | // Show what we're doing 124 | log.Printf("[DEBUG] Running %s", cmdArgs) 125 | 126 | // Run it 127 | cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) 128 | if err := cmd.Start(); err != nil { 129 | return err 130 | } 131 | 132 | // Wait for completion 133 | if err := cmd.Wait(); err != nil { 134 | 135 | if exiterr, ok := err.(*exec.ExitError); ok { 136 | 137 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 138 | return fmt.Errorf("exit code was %d", status.ExitStatus()) 139 | } 140 | } 141 | } 142 | 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /modules/system/packages.go: -------------------------------------------------------------------------------- 1 | // Package system contains some helpers for working with operating-system 2 | // package management. 3 | // 4 | // Currently only Debian GNU/Linux systems are supported, but that might 5 | // change. 6 | package system 7 | 8 | import ( 9 | "fmt" 10 | "log" 11 | "os" 12 | "os/exec" 13 | "strings" 14 | "syscall" 15 | 16 | "github.com/google/shlex" 17 | ) 18 | 19 | // Known-system types 20 | const ( 21 | YUM = "YUM" 22 | DEBIAN = "DEBIAN" 23 | ) 24 | 25 | // Mapping between CLI packages and systems 26 | var ( 27 | 28 | // These are used to identify systems. 29 | mappings = map[string]string{ 30 | "/usr/bin/dpkg": DEBIAN, 31 | "/usr/bin/yum": YUM, 32 | } 33 | 34 | // Is installed? 35 | checkCmd = map[string]string{ 36 | DEBIAN: "/usr/bin/dpkg -s %s", 37 | YUM: "/usr/bin/yum list installed %s", 38 | } 39 | 40 | // Install command for different systems. 41 | installCmd = map[string]string{ 42 | DEBIAN: "/usr/bin/apt-get install --yes %s", 43 | YUM: "/usr/bin/yum install --assumeyes %s", 44 | } 45 | 46 | // Uninstallation command for different systems 47 | uninstallCmd = map[string]string{ 48 | DEBIAN: "/usr/bin/dpkg --purge %s", 49 | YUM: "/usr/bin/yum remove --assumeyes %s", 50 | } 51 | 52 | // Update command for each system 53 | updateCmd = map[string]string{ 54 | DEBIAN: "/usr/bin/apt-get update --quiet --quiet", 55 | YUM: "/usr/bin/yum clean expire-cache --quiet", 56 | } 57 | 58 | // Environment variables used for commands on each system 59 | envCmd = map[string]string{ 60 | DEBIAN: "DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a", 61 | YUM: "", 62 | } 63 | ) 64 | 65 | // Package maintains our object state 66 | type Package struct { 67 | 68 | // System contains our identified system. 69 | system string 70 | 71 | // privilegedhelper contains the name of a binary to prefix 72 | // our commands with, to elevate privileges 73 | privilegedhelper string 74 | } 75 | 76 | // New creates a new instance of this object, attempting to identify the 77 | // system during the initial phase. 78 | func New() *Package { 79 | p := &Package{} 80 | p.identify() 81 | return p 82 | } 83 | 84 | // UsePrivilegeHelper is used to ensure that all executed commands 85 | // are prefixed with "sudo ..", "doas ..", or similar. 86 | func (p *Package) UsePrivilegeHelper(cmd string) { 87 | p.privilegedhelper = cmd 88 | } 89 | 90 | // identify tries to identify this system, if a binary we know is found 91 | // then it is assumed to be used - this might cause confusion if a Debian 92 | // system has RPM installed, for example, but should otherwise perform 93 | // reasonably well. 94 | func (p *Package) identify() { 95 | 96 | // Look over our helpers 97 | for file, system := range mappings { 98 | 99 | _, err := os.Stat(file) 100 | if err == nil { 101 | p.system = system 102 | return 103 | } 104 | } 105 | } 106 | 107 | // System returns the O/S we've identified 108 | func (p *Package) System() string { 109 | return p.system 110 | } 111 | 112 | // IsKnown reports whether this system is using a known packaging-system. 113 | func (p *Package) IsKnown() bool { 114 | return (p.system != "") 115 | } 116 | 117 | // Update carries out the update command for a given system 118 | func (p *Package) Update() error { 119 | 120 | if !p.IsKnown() { 121 | return fmt.Errorf("failed to recognize system-type") 122 | } 123 | 124 | // Get the command 125 | tmp := updateCmd[p.System()] 126 | 127 | // Add privileges if we need to 128 | if p.privilegedhelper != "" { 129 | tmp = p.privilegedhelper + " " + tmp 130 | } 131 | 132 | // Split 133 | run, err := shlex.Split(tmp) 134 | if err != nil { 135 | return err 136 | } 137 | env, err := shlex.Split(envCmd[p.System()]) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | // Run the command 143 | return p.run(run, env) 144 | } 145 | 146 | // IsInstalled checks a package installed? 147 | func (p *Package) IsInstalled(name string) (bool, error) { 148 | 149 | if !p.IsKnown() { 150 | return false, fmt.Errorf("failed to recognize system-type") 151 | } 152 | 153 | // Get the command 154 | tmp := checkCmd[p.System()] 155 | tmp = strings.ReplaceAll(tmp, "%s", name) 156 | 157 | // Split 158 | run, err := shlex.Split(tmp) 159 | if err != nil { 160 | return false, err 161 | } 162 | env, err := shlex.Split(envCmd[p.System()]) 163 | if err != nil { 164 | return false, err 165 | } 166 | 167 | // Run the command 168 | err = p.run(run, env) 169 | 170 | // No error? Then the package is installed 171 | if err == nil { 172 | return true, nil 173 | } 174 | 175 | // Error means it isn't. 176 | return false, nil 177 | } 178 | 179 | // Install a single package to the system. 180 | func (p *Package) Install(name []string) error { 181 | 182 | if !p.IsKnown() { 183 | return fmt.Errorf("failed to recognize system-type") 184 | } 185 | 186 | // Get the command 187 | tmp := installCmd[p.System()] 188 | tmp = strings.ReplaceAll(tmp, "%s", strings.Join(name, " ")) 189 | 190 | // Add privileges if we need to 191 | if p.privilegedhelper != "" { 192 | tmp = p.privilegedhelper + " " + tmp 193 | } 194 | 195 | // Show what we're going to run 196 | log.Printf("[DEBUG] packages:Install will run %s\n", tmp) 197 | 198 | // Split 199 | run, err := shlex.Split(tmp) 200 | if err != nil { 201 | return err 202 | } 203 | env, err := shlex.Split(envCmd[p.System()]) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | // Run the command 209 | return p.run(run, env) 210 | } 211 | 212 | // Uninstall a single package from the system. 213 | func (p *Package) Uninstall(name []string) error { 214 | 215 | if !p.IsKnown() { 216 | return fmt.Errorf("failed to recognize system-type") 217 | } 218 | 219 | // Get the command 220 | tmp := uninstallCmd[p.System()] 221 | tmp = strings.ReplaceAll(tmp, "%s", strings.Join(name, " ")) 222 | 223 | // Add privileges if we need to 224 | if p.privilegedhelper != "" { 225 | tmp = p.privilegedhelper + " " + tmp 226 | } 227 | 228 | // Show what we're going to run 229 | log.Printf("[DEBUG] packages:Uninstall will run %s\n", tmp) 230 | 231 | // Split 232 | run, err := shlex.Split(tmp) 233 | if err != nil { 234 | return err 235 | } 236 | env, err := shlex.Split(envCmd[p.System()]) 237 | if err != nil { 238 | return err 239 | } 240 | 241 | // Run the command 242 | return p.run(run, env) 243 | } 244 | 245 | // run executes the named command and returns an error unless 246 | // the execution launched and the return-code was zero. 247 | func (p *Package) run(run []string, env []string) error { 248 | 249 | // Run 250 | cmd := exec.Command(run[0], run[1:]...) 251 | cmd.Env = append(cmd.Environ(), env...) 252 | if err := cmd.Start(); err != nil { 253 | return err 254 | } 255 | 256 | // Wait for completion 257 | if err := cmd.Wait(); err != nil { 258 | 259 | if exiterr, ok := err.(*exec.ExitError); ok { 260 | 261 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 262 | return fmt.Errorf("exit code for '%s' was %d", strings.Join(run, " "), status.ExitStatus()) 263 | } 264 | } 265 | } 266 | 267 | return nil 268 | 269 | } 270 | -------------------------------------------------------------------------------- /parser/fuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package parser 5 | 6 | import ( 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func FuzzParser(f *testing.F) { 12 | 13 | // empty string 14 | f.Add([]byte("")) 15 | 16 | // invalid entries 17 | f.Add([]byte("let")) 18 | f.Add([]byte("3=")) 19 | f.Add([]byte("let false=\"steve\" ")) 20 | 21 | // assignments 22 | f.Add([]byte("let foo=\"bar\"")) 23 | f.Add([]byte("let foo=3")) 24 | f.Add([]byte("let foo=true")) 25 | f.Add([]byte("let foo=false")) 26 | f.Add([]byte("let id=`/usr/bin/id -u`")) 27 | 28 | // blocks 29 | f.Add([]byte(`shell { command => "/usr/bin/uptime", shell => true } `)) 30 | f.Add([]byte(`shell { command => "/usr/bin/uptime", shell => "true" } `)) 31 | f.Add([]byte(`shell { command => [ "/usr/bin/uptime", "/usr/bin/id" ] } `)) 32 | 33 | // block with conditions 34 | f.Add([]byte(`shell { command => "uptime", if => equal(\"one\",\"two\"); } `)) 35 | f.Add([]byte(`shell { command => "uptime", unless => false(\"/bin/true\"); } `)) 36 | 37 | // Assignments 38 | f.Add([]byte(`let a = "foo"`)) 39 | f.Add([]byte(`let a = true`)) 40 | f.Add([]byte(`let a = false;`)) 41 | f.Add([]byte(`let a = 32`)) 42 | f.Add([]byte(`let invalid = [ "steve", "kemp"]`)) 43 | 44 | // Known errors are listed here. 45 | // 46 | // The purpose of fuzzing is to find panics, or unexpected errors. 47 | // 48 | // Some programs are obviously invalid though, so we don't want to 49 | // report those known-bad things. 50 | known := []string{ 51 | "expected", 52 | "expected identifier name after conditional", 53 | "assignment can only be made to identifiers", 54 | "illegal token", 55 | "end of file", 56 | "you cannot assign an array to a variable", 57 | "unterminated assignment", 58 | "strconv.ParseInt: parsing", 59 | "unexpected bare identifier", 60 | } 61 | 62 | f.Fuzz(func(t *testing.T, input []byte) { 63 | 64 | // Create a new parser 65 | c := New(string(input)) 66 | 67 | // Parse, looking for errors 68 | _, err := c.Parse() 69 | if err != nil { 70 | 71 | // We got an error. Is it a known one? 72 | for _, e := range known { 73 | 74 | // This is a known error, we expect to get 75 | if strings.Contains(err.Error(), e) { 76 | return 77 | } 78 | } 79 | 80 | // New error! Fuzzers are magic, and this is a good 81 | // discovery :) 82 | t.Errorf("Input gave bad error: %s %s\n", input, err) 83 | } 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // Test-cases for our parser. 3 | // 4 | 5 | package parser 6 | 7 | import ( 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/skx/marionette/ast" 13 | ) 14 | 15 | // TestAssignment performs basic assignment-statement testing 16 | func TestAssignment(t *testing.T) { 17 | 18 | // Broken statements 19 | broken := []string{ 20 | "let", 21 | "let foo", 22 | "let foo=", 23 | "let foo=bar", 24 | "let a=\"b\" unless", 25 | "let a=\"b\" unless false", 26 | "let a=\"b\" unless false(", 27 | "let a=\"b\" unless false(/bin/ls,", 28 | "let a=\"3\" if ", 29 | "let a=\"3\" if true", 30 | "let a=\"3\" if true(", 31 | "let a=\"3\" if true(/bin/ls", 32 | "let a=\"3\" if true(/bin/ls,", 33 | "let 3=\"3\"", 34 | "let false=\"3\"", 35 | "let true=\"3\"", 36 | } 37 | 38 | // Ensure each one fails 39 | for _, test := range broken { 40 | 41 | // Create a sub-test for this input 42 | t.Run(test, func(t *testing.T) { 43 | 44 | p := New(test) 45 | _, err := p.Parse() 46 | 47 | if err == nil { 48 | t.Errorf("expected error parsing broken assign '%s' - got none", test) 49 | } 50 | }) 51 | } 52 | 53 | // Now test valid assignments 54 | valid := []string{ 55 | "let x = `/bin/true`", 56 | "let x = `/bin/true` if equal(\"a\",\"a\")", 57 | "let a = \"boo\"", 58 | "let _false_ = \"ok\"", 59 | "let _true_like = \"ok\"", 60 | } 61 | 62 | // Ensure each one succeeds 63 | for _, test := range valid { 64 | 65 | // Create a sub-test for this input 66 | t.Run(test, func(t *testing.T) { 67 | p := New(test) 68 | _, err := p.Parse() 69 | 70 | if err != nil { 71 | t.Errorf("unexpected error parsing assignment '%s': %s", test, err) 72 | } 73 | }) 74 | } 75 | } 76 | 77 | // TestBlock performs basic block-parsing. 78 | func TestBlock(t *testing.T) { 79 | 80 | // Broken tests 81 | broken := []string{ 82 | `"foo`, 83 | "foo", 84 | `foo { name : "test" }`, 85 | `foo { name = "steve"}`, 86 | `foo { name = "steve`, 87 | `foo { name = { "steve" } }`, 88 | `foo { name =`, 89 | `foo { name `, 90 | `foo { "name" `, 91 | `foo { "unterminated `, 92 | `foo { `, 93 | `foo { name => "unterminated `, 94 | `foo { name => `, 95 | `foo { name => { "steve, "kemp"] }`, 96 | `foo { name => [ "steve`, 97 | `foo { name => [ ,,, "",,,`, 98 | } 99 | 100 | for _, test := range broken { 101 | 102 | // Create a sub-test for this input 103 | t.Run(test, func(t *testing.T) { 104 | p := New(test) 105 | _, err := p.Parse() 106 | 107 | if err == nil { 108 | t.Errorf("expected error parsing broken block '%s' - got none", test) 109 | } 110 | }) 111 | } 112 | 113 | // valid tests 114 | valid := []string{`file { target => "steve", name => "steve" }`, 115 | `moi triggered { test => "steve", name => "steve" }`, 116 | `foo { name => [ "one", "two", ] }`, 117 | } 118 | 119 | for _, test := range valid { 120 | 121 | // Create a sub-test for this input 122 | t.Run(test, func(t *testing.T) { 123 | 124 | p := New(test) 125 | rules, err := p.Parse() 126 | 127 | if err != nil { 128 | t.Errorf("unexpected error parsing '%s' %s", test, err.Error()) 129 | } 130 | 131 | if len(rules.Recipe) != 1 { 132 | t.Errorf("expected a single rule") 133 | } 134 | }) 135 | } 136 | } 137 | 138 | // TestConditionalErrors performs some sanity-checks that broken conditionals 139 | // result in expected errors. 140 | func TestConditionalErrors(t *testing.T) { 141 | 142 | type TestCase struct { 143 | Input string 144 | Error string 145 | } 146 | 147 | // Broken tests 148 | broken := []TestCase{ 149 | {Input: `shell { name => "OK1", 150 | command => "echo Comparison Worked!", 151 | if => }`, 152 | Error: "unexpected type parsing primitive"}, 153 | 154 | {Input: `shell { name => "OK2", 155 | command => "echo Comparison Worked!", 156 | if => equal( 157 | }`, 158 | Error: "unexpected type parsing primitive:token"}, 159 | {Input: `shell { name => "OK3", 160 | command => "echo Comparison Worked!", 161 | unless`, 162 | Error: "expected => after conditional unless"}, 163 | {Input: `shell { name => "OK4", 164 | command => "echo Comparison Worked!", 165 | unless => foo foo`, 166 | Error: "unexpected bare identifier foo"}, 167 | } 168 | 169 | for _, test := range broken { 170 | 171 | // Create a sub-test for this input 172 | t.Run(test.Input, func(t *testing.T) { 173 | 174 | p := New(test.Input) 175 | _, err := p.Parse() 176 | 177 | if err == nil { 178 | t.Errorf("expected error parsing broken input '%s' - got none", test.Input) 179 | } else { 180 | if !strings.Contains(err.Error(), test.Error) { 181 | t.Errorf("error '%s' did not match '%s' when hadnling %s", err.Error(), test.Error, test.Input) 182 | } 183 | } 184 | }) 185 | } 186 | } 187 | 188 | // TestConditional performs a basic sanity-check that a conditional 189 | // looks sane. 190 | func TestConditional(t *testing.T) { 191 | 192 | input := `shell { name => "OK", 193 | command => "echo Comparison Worked!", 194 | if => equal( "foo", "foo" ), 195 | }` 196 | 197 | p := New(input) 198 | out, err := p.Parse() 199 | 200 | if err != nil { 201 | t.Errorf("unexpected error parsing valid input '%s': %s", input, err.Error()) 202 | } 203 | 204 | // We should have one result 205 | if len(out.Recipe) != 1 { 206 | t.Errorf("unexpected number of results") 207 | } 208 | 209 | rule := out.Recipe[0].(*ast.Rule) 210 | 211 | // Did we get the right type of condition? 212 | if rule.ConditionType != "if" { 213 | t.Errorf("we didn't parse a conditional") 214 | } 215 | 216 | // Does it look like the right test? 217 | formatted := rule.Function.String() 218 | if formatted != "Funcall{equal(String{foo},String{foo})}" { 219 | t.Errorf("failed to stringify valid comparison: %s", formatted) 220 | } 221 | } 222 | 223 | // TestInclude performs basic testing of our include-file handling 224 | func TestInclude(t *testing.T) { 225 | 226 | // Broken statements 227 | broken := []string{ 228 | "include", 229 | "include 22.2", 230 | "include \"test.inc\" unless false(/bin/ls", 231 | "include \"test.inc\" unless false(/bin/ls,", 232 | "include \"test.inc\" if true(/bin/ls,", 233 | "include \"test.inc\" if true(/bin/ls", 234 | } 235 | 236 | // Ensure each one fails 237 | for _, test := range broken { 238 | 239 | // Create a sub-test for this input 240 | t.Run(test, func(t *testing.T) { 241 | p := New(test) 242 | _, err := p.Parse() 243 | 244 | if err == nil { 245 | t.Errorf("expected error parsing broken include '%s' - got none", test) 246 | } 247 | }) 248 | } 249 | 250 | // Now test valid includes 251 | valid := []string{ 252 | "include \"test.inc\"", 253 | "include [ \"test.inc\", \"test.inc\"] ", 254 | "include \"test.inc\" unless failure(\"/bin/ls\")", 255 | "include \"test.inc\" if success(\"/bin/ls\")", 256 | } 257 | 258 | // Ensure each one succeeds 259 | for _, test := range valid { 260 | 261 | // Create a sub-test for this input 262 | t.Run(test, func(t *testing.T) { 263 | p := New(test) 264 | _, err := p.Parse() 265 | 266 | if err != nil { 267 | t.Errorf("unexpected error parsing include '%s': %s", test, err) 268 | } 269 | }) 270 | } 271 | } 272 | 273 | // #86 - Test we can parse modules without spaces 274 | func TestModuleSpace(t *testing.T) { 275 | 276 | input := `shell{command=>"id"}` 277 | 278 | p := New(input) 279 | out, err := p.Parse() 280 | 281 | // This should be error-free 282 | if err != nil { 283 | t.Errorf("unexpected error parsing input '%s': %s", input, err.Error()) 284 | } 285 | 286 | // We should have one result 287 | if len(out.Recipe) != 1 { 288 | t.Errorf("unexpected number of results") 289 | } 290 | } 291 | 292 | // Test that we can output debug-strings 293 | func TestDebug(t *testing.T) { 294 | 295 | // One example of each rule-type 296 | input := ` 297 | include "foo.txt" 298 | let a = 3 299 | shell{command=>"id"} 300 | ` 301 | os.Setenv("DEBUG_PARSER", "true") 302 | p := New(input) 303 | out, err := p.Parse() 304 | 305 | // This should be error-free 306 | if err != nil { 307 | t.Errorf("unexpected error parsing input '%s': %s", input, err.Error()) 308 | } 309 | 310 | // We should have three results 311 | if len(out.Recipe) != 3 { 312 | t.Errorf("unexpected number of results") 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /token/token.go: -------------------------------------------------------------------------------- 1 | // Package token contains the token-types which our lexer produces, 2 | // and which our parser understands. 3 | package token 4 | 5 | import "fmt" 6 | 7 | // Type is a string. 8 | type Type string 9 | 10 | // Token struct represent the token which is returned from the lexer. 11 | type Token struct { 12 | Type Type 13 | Literal string 14 | } 15 | 16 | // pre-defined TokenTypes 17 | const ( 18 | // Things 19 | ASSIGN = "=" 20 | BACKTICK = "`" 21 | COMMA = "," 22 | EOF = "EOF" 23 | LASSIGN = "=>" 24 | LBRACE = "{" 25 | LPAREN = "(" 26 | LSQUARE = "[" 27 | RBRACE = "}" 28 | RPAREN = ")" 29 | RSQUARE = "]" 30 | 31 | // types 32 | BOOLEAN = "BOOLEAN" 33 | IDENT = "IDENT" 34 | ILLEGAL = "ILLEGAL" 35 | NUMBER = "NUMBER" 36 | STRING = "STRING" 37 | ) 38 | 39 | // String turns the token into a readable string 40 | func (t Token) String() string { 41 | 42 | // string? 43 | if t.Type == STRING { 44 | return t.Literal 45 | } 46 | 47 | // backtick? 48 | if t.Type == BACKTICK { 49 | return "`" + t.Literal + "`" 50 | } 51 | 52 | // everything else is less pretty 53 | return fmt.Sprintf("token{Type:%s Literal:%s}", t.Type, t.Literal) 54 | } 55 | -------------------------------------------------------------------------------- /token/token_test.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import "testing" 4 | 5 | func TestTokenString(t *testing.T) { 6 | // string 7 | s := &Token{Type: STRING, Literal: "Moi"} 8 | if s.String() != "Moi" { 9 | t.Fatalf("Unexpected string-version of token.STRING") 10 | } 11 | 12 | // backtick 13 | b := &Token{Type: BACKTICK, Literal: "/bin/ls"} 14 | if b.String() != "`/bin/ls`" { 15 | t.Fatalf("Unexpected string-version of token.BACKTICK") 16 | } 17 | 18 | // misc 19 | m := &Token{Type: LSQUARE, Literal: "["} 20 | if m.String() != "token{Type:[ Literal:[}" { 21 | t.Fatalf("Unexpected string-version of token.LSQUARE") 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | //go:build !go1.18 2 | // +build !go1.18 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | var ( 11 | version = "unreleased" 12 | ) 13 | 14 | func showVersion() { 15 | fmt.Printf("%s\n", version) 16 | } 17 | -------------------------------------------------------------------------------- /version18.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "runtime/debug" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | version = "unreleased" 14 | ) 15 | 16 | func showVersion() { 17 | fmt.Printf("%s\n", version) 18 | 19 | info, ok := debug.ReadBuildInfo() 20 | 21 | if ok { 22 | for _, settings := range info.Settings { 23 | if strings.Contains(settings.Key, "vcs") { 24 | fmt.Printf("%s: %s\n", settings.Key, settings.Value) 25 | } 26 | } 27 | } 28 | 29 | } 30 | --------------------------------------------------------------------------------