Giter VIP home page Giter VIP logo

marionette's Introduction

Go Report Card license Release

marionette

marionette is a simple command-line application which is designed to carry out system automation tasks. It was designed to resemble the well-known configuration-management application puppet, albeit in a much simpler and more minimal manner.

marionette contains a small number of built-in modules, providing enough primitives to turn a blank virtual machine into a host running a few services:

  • Cloning git repositories.
  • Creating/modifying files/directories.
  • Pulling Docker images from public container-registries.
  • Installing and removing packages.
    • Debian GNU/Linux, and CentOS are supported, using apt-get, dpkg, and yum as appropriate.
  • Executing shell commands.
  • Making HTTP-requests.

In the future it is possible that more modules will be added, but this will require users to file bug-reports requesting them, contribute code, or the author realizing something is necessary.

Installation & Usage

Binaries for several systems are available upon our download page. If you prefer to use something more recent you can install directly from the repository via:

go install github.com/skx/marionette@latest

The main application can then be launched with the path to a set of rules, which it will then try to apply:

marionette [flags] ./rules.txt ./rules2.txt ... ./rulesN.txt

The following flags are supported:

  • -debug
    • Show many low-level details when executing the supplied rules-file(s).
  • -verbose
    • Show extra details when executing the supplied rules-file(s).
  • -version
    • Show the released version number, and exit.

Typically a user would run with -verbose, and a developer might examine the output produced when -debug is specified.

In addition to the general-purpose flags -dp and -dl exist for developers, to dump the output of the parser and lexer, respectively.

Rule Definition

The general form of our rules looks like this:

$MODULE [triggered] {
            name  => "NAME OF RULE",
            arg_1 => "Value 1 ... ",
            arg_2 => [ "array values", "are fine" ],
            arg_3 => "Value 3 .. ",
}

Each rule starts by declaring the type of module which is being invoked, then there is a block containing "key => value" sections. Different modules will accept/expect different keys to configure themselves. (Unknown arguments will generally be ignored.)

A rule may also contain an optional triggered attribute. Rules which contain the triggered modifier are not executed unless explicitly invoked by another rule - think of it as a "handler" if you're used to ansible.

Here is an example rule which executes a shell-command:





# Run a command, unconditionally
shell {
        command => "uptime > /tmp/uptime.txt"
}

Another simple example to illustrate the available syntax might look like the following, which ensures that I have ~/bin/ owned by myself:

directory {
             target  => "/home/${USER}/bin",
             mode    => "0755",
             owner   => "${USER}",
             group   => "${USER}",
}

There are four magical keys which can be supplied to all modules:

Name Usage
require This is used for dependency management
notify This is used for dependency management
if This is used to make a rule conditional
unless This is used to make a rule conditional

Dependency Management

There are two keys which can be used to link rules together, to handle dependencies:

  • require
    • This key contains either a single rule-name, or a list of any rule-names, which must be executed before this one.
  • notify
    • A list of any number of rules which should be notified, if the given rule resulted in a state-change.

Note You only need to give rules names to link them for the purpose of managing dependencies.

Imagine we wanted to create a new directory, and write a file there. We could do that with a pair of rules:

  • One to create a directory.
  • One to generate the output file.

We could wing-it and write the rules in the logical order, but it would be far better to link the two rules explicitly.

There are two ways we could implement this. The simplest way would be this:

shell { command   => "uptime > /tmp/blah/uptime",
        require   => "Create /tmp/blah" }

directory{ name   => "Create /tmp/blah",
           target => "/tmp/blah" }

The alternative would have been to have the directory-creation trigger the shell-execution rule via an explicit notification:





# This command will notify the "Test" rule, if it creates the directory




# because it was not already present.
directory{ target => "/tmp/blah",
           notify => "Test"
}




# Run the command, when triggered/notified.
shell triggered { name         => "Test",
                  command      => "uptime > /tmp/blah/uptime",
}

The difference in these two approaches is how often things run:

  • In the first case we always run uptime > /tmp/blah/uptime
    • We just make sure that before that the directory has been created.
  • In the second case we run the command only once.
    • We run it only after the directory is created.
    • Because the directory-creation triggers the notification only when the rule changes (i.e. the directory goes from being "absent" to "present").

You'll note that any rule which is followed by the token triggered will only be executed when it is triggered by name. If there is no notify key referring to that rule it will never be executed.

Conditionals

Rules may be made conditional, via the magical keys if and unless.

The following example runs a command, using apt-get, only if a specific file exists upon the filesystem:

shell { name    => "Upgrade",
        command => "apt-get dist-upgrade --yes --force-yes",
        if      => exists("/usr/bin/apt-get") }

For the reverse, running a rule unless something is true, we can use the unless key:

let arch = `/usr/bin/arch`

file { name   => "Create file",
       target => "/tmp/foo",
       unless => equal( "x86_64", "${arch}" ) }

Here we see that we've used two functions equal and exists, these are both built-in functions which do what you'd expect.

The following list shows all the built-in functions that you may use (but only within the if or unless keys):

  • contains(haystack, needle)
    • Returns true if the first string contains the second.
  • exists( /some/file )
    • Return true if the specified file/directory exists.
  • equal( foo, bar )
    • Return true if the two values are identical.
  • nonempty(string|variable)
    • Return true if the string/variable is non-empty.
    • set is a synonym.
  • prompt(string|variable)
    • Prompt the user for input, at run-time, and return it.
  • on_path(string|variable)
    • Return true if a binary with the given name is on the users' PATH.
  • empty(string|variable)
    • Return true if the string/variable is empty (i.e. has zero length).
    • unset is a synonym.
  • success(string)
    • Returns true if the command string is executed and returns a non-error exit-code (i.e. 0).
    • Output is discarded, and not captured.
  • failure(string)
    • Returns true if the command string is executed and returns an error exit-code (i.e. non-zero 0).
    • Output is discarded, and not captured.

More conditional primitives may be added if they appear to be necessary, or if users request them.

Conditionals may also be applied to variable assignments and file inclusion:





# Include a file of rules, on a per-arch basis
include "x86_64.rules" if equal( "${ARCH}","x86_64" )
include "i386.rules"   if equal( "${ARCH}","i386" )




# Setup a ${cmd} to download something, depending on what is present.
let cmd = "curl --output ${dst} ${url}" if on_path("curl")
let cmd = "wget -O ${dst} ${url}"       if on_path("wget")

In addition to these conditional functions the following primitives are built in, and may be freely used:

  • field(txt,index)
    • Split the given text on whitespace, and return the specified field by index.
    • 0 is the first field, 1 is the second, etc.
  • gt(a,b)
    • Return true if a>b
  • gte(a,b)
    • Return true if a>=b
  • lt(a,b)
    • Return true if a<b
  • lte(a,b)
    • Return true if a<=b
  • len(txt)
    • Return the length of the given value.
  • lower(txt)
    • Converts the given string to lower-case.
  • matches(text, regexp)
    • Return true if the text matches the specified regular expression.
  • rand(min,max,seed)
    • Return a random integer between min and max. Optionally set a seed value.
  • md5sum(txt)
    • Returns the MD5-digest of the given value.
  • sha1sum(txt)
    • Returns the SHA1-digest of the given value.
  • upper(txt)
    • Converts the given string to upper-case.

Examples

You can find a small set of example recipes beneath the examples directory:

Misc. Features

Command Execution

Backticks can be used to execute commands, in variable-assignments and in parameters to rules.

For example we might determine the system architecture like this:

let arch = `/usr/bin/arch`

shell { name    => "Show arch",
        command => "echo We are running on an ${arch} system" }

Here ${arch} expands to the output of the command, as you would expect, with any trailing newline removed.

Note ${ARCH} is available by default, as noted in the pre-declared variables section. This was just an example of command-execution.

Using commands inside parameter values is also supported:

file { name    => "set-todays-date",
       target  => "/tmp/today",
       content => `/usr/bin/date` }

The commands executed with the backticks have any embedded variables expanded before they run, so this works as you'd expect:

let fmt   = "+%Y"

file { name    => "set-todays-date",
       target  => "/tmp/today",
       content => `/bin/date ${fmt}` }

Include Files

You can break large rule-files into pieces, and include them in each other:





# main.in

let prefix="/etc/marionette"

include "foo.in"
include "${prefix}/test.in"

To simplify your recipe writing including other files may be made conditional, just like our rules:





# main.in

include "x86_64.rules" if equal( "${ARCH}","x86_64" )
include "i386.rules"   if equal( "${ARCH}","i386" )

Pre-Declared Variables

The following variables are available by default:

Name Value
${ARCH} The system architecture (as taken from sys.GOARCH).
${HOMEDIR} The home directory of the user running marionette.
${HOSTNAME} The hostname of the local system.
${OS} The operating system name (as taken from sys.GOOS).
${USERNAME} The username of user running marionette.

There are additionally two "magic" variables available which will always have values based upon the current rule-file being processed, whether that is a file specified upon the command-line, or as a result of an include statement:

Name Value
${INCLUDE_DIR} The absolute directory path of the current file being processed.
${INCLUDE_FILE} The absolute path of the current file being processed.

Outputs

Some modules will set "outputs" after they've executed, and those outputs will be documented explicitly in the later list of available modules.

When a module creates an output it will be available for subsequent modules to use, prefixed with the name of the rule which created it.

Here is an example showing the use of the stdout output which the shell module produces:





# Run a command - This will produce "${user.stdout}" and "${user.stderr}"




# variables which can be used later.
shell {
           name => "user",
        command => "/usr/bin/whoami"
}


log {
    message => "STEVE!",
    if      => equal( "${user.stdout}", "skx" )
}
log {
    message => "ROOT!",
    if      => equal( "${user.stdout}", "root" )
}

NOTE: The output stdout is available here as ${user.stdout} - the "user." prefix comes from the name of the rule which invoked the shell. So here we'd be able to process the result of ${kernel-version.output}

shell {
          name => "kernel-version",
          command => "uname -r",
}

Module Types

Our primitives are implemented in 100% pure golang, and are included with our binary, these are now described briefly:

directory

The directory module allows you to create a directory, or change the permissions of one.

Example usage:

directory {  name    => "My home should have a binary directory",
             target  => "/home/steve/bin",
             mode    => "0755",
}

Valid parameters are:

  • target is a mandatory parameter, and specifies the directory, or directories, to operate upon.
  • owner - Username of the owner, e.g. "root".
  • group - Groupname of the owner, e.g. "root".
  • mode - The mode to set, e.g. "0755".
  • state - Set the state of the directory.
    • state => "absent" remove it.
    • state => "present" create it (this is the default).

docker

This module allows fetching a container from a remote registry.

docker { image => "alpine:latest" }

The following keys are supported:

  • image - The image/images to fetch.
  • force
    • If this is set to true then we fetch the image even if it appears to be available locally already.

NOTE: We don't support private registries, or the use of authentication.

edit

This module allows minor edits to be applied to a file:

  • Removing lines matching a given regular expression.
  • Appending a line to the file if missing.
edit { name => "Remove my VPN hosts",
       target => "/etc/hosts",
       remove_lines => "\.vpn" }

The following keys are supported:

  • target - Mandatory filename to edit.
  • remove_lines - Remove any lines of the file matching the specified regular expression.
  • append_if_missing - Append the given text if not already present in the file.
  • search
  • replace
    • If both search and replace are non-empty then they will be used to update the content of the specified file.
    • search is treated as a regular expression, for added flexibility.

An example of changing a file might look like this:

edit { target  => "/etc/ssh/sshd_config",
       search  => "^PasswordAuthentication",
       replace => "# PasswordAuthentication",
}

fail

The fail-module is designed to terminate processing, if you find a situation where the local environment doesn't match your requirements. For example:

let path = `which useradd`

fail {
   message => "I can't find a working useradd binary to use",
   if      => empty(path)
}

The only valid parameter is message.

See also log, which will log a message but then continue execution.

file

The file module allows a file to be created, from a local file, or via a remote HTTP-source.

Example usage:

file {  name       => "fetch file",
        target     => "/tmp/steve.txt",
        source_url => "https://steve.fi/",
}

file {  name     => "write file",
        target   => "/tmp/name.txt",
        content  => "My name is Steve",
}

target is a mandatory parameter, and specifies the file to be operated upon.

There are four ways a file can be created:

  • content - Specify the content inline.
  • source_url - The file contents are fetched from a remote URL.
  • source - Content is copied from the existing path.
  • template - Content is produced by rendering a template from a path.

Other valid parameters are:

  • owner - Username of the owner, e.g. "root".
  • group - Groupname of the owner, e.g. "root".
  • mode - The mode to set, e.g. "0755".
  • state - Set the state of the file.
    • state => "absent" remove it.
    • state => "present" create it (this is the default).

Where template is used, the template file is rendered using the text/template Go package

git

Clone a remote repository to a local directory.

Example usage:

git { path => "/tmp/xxx",
      repository => "https://github.com/src-d/go-git",
}

Valid parameters are:

  • repository Contain the HTTP/HTTPS repository to clone.
  • path - The location we'll clone to.
  • branch - The branch to switch to, or be upon.
    • A missing branch will not be created.

If this module is used to notify another then it will trigger such a notification if either:

  • The repository wasn't present, and had to be cloned.
  • The repository was updated. (i.e. Remote changes were pulled in.)

group

The group module allows you to add or remove local Unix groups to your system.

Example:

group { group => "sysadmin",
        state => "present" }
  • elevate is an optional parameter, which should contain the path to "sudo", or similar program to grant root-privileges.
  • group is a mandatory parameter.
  • state should be one of absent or present, depending upon whether you want to add or remove the group.

http

The http module allows you to make HTTP requests.

Example:

http { url => "https://api.github.com/user",
       method => "PATCH",
       headers => [
         "Authorization: token ${AUTH_TOKEN}",
         "Accept: application/vnd.github.v3+json",
       ],
       body => "{\"name\":\"new name\"}",
       expect => "200" }

Valid parameters are:

  • url is a mandatory parameter, and specifies the URL to make the HTTP request to.
  • method - If this is set, the HTTP request will use this method, otherwise a GET request is made.
  • headers - If this is set, the headers provided will be sent with the request.
  • body - If this is set, this will be the body of the HTTP request.
  • expect - If this is set, an error will be triggered if the response status code does not match the expected status code.
    • If expect is not set, an error will be triggered for any non 2xx response status code.

The http module is always regarded as having made a change on a successful request.

http Outputs

The following outputs will be set:

  • body
    • The body returned from the request.
  • code
    • The HTTP-status code of the response.
  • status
    • The HTTP-status line of the response.

link

The link module allows you to create a symbolic link.

Example usage:

link { name => "Symlink test",
       source => "/etc/passwd",  target => "/tmp/password.txt" }

Valid parameters are:

  • target is a mandatory parameter, and specifies the location of the symlink to create.
  • source is a mandatory parameter, and specifies the item the symlink should point to.

log

The log module allows you to output a message, but continue execution.

Example usage:

log { message => "I'm ${USER} running on ${HOSTNAME}" }

The only valid parameter is message, which may be either a single value, or an array of values.

See also fail, which will log a message but then terminate execution.

package

The package-module allows you to install or remove a package from your system, via the execution of apt-get, dpkg, and yum, as appropriate.

Example usage:





# Install a single package
package { name    => "Install bash",
          package => "bash",
          state   => "installed",
        }




# Uninstall a series of packages
package { package => [ "nano", "vim-tiny", "nvi" ],
          state => "absent" }

Valid parameters are:

  • elevate is an optional parameter, which should contain the path to "sudo", or similar program to grant root-privileges.
  • package is a mandatory parameter, containing the package, or list of packages.
  • state - Should be one of installed or absent, depending upon whether you want to install or uninstall the named package(s).
  • update - If this is set to true then the system will be updated prior to installation.
    • In the case of a Debian system, for example, apt-get update will be executed.

sql

The SQL-module allows you to run arbitrary SQL against a database. Two parameters are required driver and dsn, which are used to open the database connection. For example:

sql {
       driver => "sqlite3",
       dsn    => "/tmp/sql.db",

       # Memory based-SQLite could be used like so:
       #  dsn   => "/tmp/foo.db?mode=memory",

       # MySQL connection would look like this:
       #
       #   driver => "mysql",
       #   dsn    => "user:password@(127.0.0.1:3306)/",
}

We support the following drivers:

  • mysql
  • postgres
  • sqlite3

To specify the query to run you should set one of the following two parameters:

  • sql
    • Literal SQL to execute.
  • sql_file
    • A file to read and execute in one execution.

NOTE: You may find you need to append multiStatements=true to your DSN to ensure correct operation when reading SQL from a file.

shell

The shell module allows you to run shell-commands, complete with redirection and pipes.

Example:

shell { name    => "I touch your file.",
        command => "touch /tmp/blah/test.me"
      }

command is the only mandatory parameter. Multiple values can be specified:

shell { name    => "restart the aplication",
        command => [
                     "systemctl stop  foo.service",
                     "systemctl start foo.service",
                   ]
      }

By default commands are executed directly, unless they contain redirection-characters (">", or "<"), or the use of a pipe ("|"). If special characters are used then we instead invoke the command via /bin/bash:

  • bash -c "${command}"

You may specify shell => true to force the use of a shell, despite the lack of redirection/pipe characters:

shell { shell   => true,
        command => "sed .. /etc/file.txt"
      }

shell Outputs

The following outputs will be set:

  • stdout
    • Anything the command wrote to STDOUT.
  • stderr
    • Anything the command wrote to STDERR.

user

The user module allows you to add or remove local Unix users to your system.

Example:

user { login => "steve",
       state => "present" }
  • elevate is an optional parameter, which should contain the path to "sudo", or similar program to grant root-privileges.
  • login is a mandatory parameter.
  • shell is an optional parameter to use for the users' shell.
  • state should be one of absent or present, depending upon whether you want to add or remove the user.

Future Plans

  • Gathering "facts" about the local system, and storing them as variables would be useful.

See Also

  • There are some brief notes on our implementation contained within HACKING.md.
    • This gives an overview of the structure.
  • There are some brief-notes on fuzz-testing the parser.
    • This should ensure that there is no input that can crash our application.

Github Setup

This repository is configured to run tests upon every commit, and when pull-requests are created/updated. The testing is carried out via .github/run-tests.sh which is used by the github-action-tester action.

Releases are automated in a similar fashion via .github/build, and the github-action-publish-binaries action.

Steve

marionette's People

Contributors

adam12 avatar alexwhitman avatar skx avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

marionette's Issues

Allow module aliases

Right now we support apt and dpkg - CentOS support coming soon - but that will mean users will need to be careful about what they specify:

    apt { name => "bash" }

vs.

    rpm{ name => "bash" }

Assuming we keep the same key-values/parameters it would be neat to say:

    pkg_add{ name => "bash" }

Where pkg_add will be an alias to dpkg, or rpm|yum|dnf, as appropriate.

To be honest we could probably just use one-handler, pkg|package and handle the aliasing in an internal fashion. Users could use apt, dnf, dpkg, etc, if they wished, but it would be hidden by default.

directory module doesn't -p

if I

directory { name => "Create Datadir",
target => "/opt/deep/down",
mode => "0755",
}

it doesn't work. I should be able to create a nested dir.

Escape single quotes in commands?

No matter how I try to escape, I can't do:

shell { name    => "Adding datadir, adding new",
        command => ["sed '17 i This is Line 17' ${HOME}/.my.cnf" ]
}

Have better examples

Remove input.txt, and create an examples/ directory with some standalone examples.

Once that is done make a new release.

Non-obvious variable assignment issues

let SOME_VAR = "123"
log {
	name => "log-one"
	message => "one - ${SOME_VAR}"
	require => "log-two"
}

let SOME_VAR = "abc"
let COMMAND_RESULT = `find /tmp`
log {
	name => "log-two"
	message => "two - ${SOME_VAR}"
	unless => set("${COMMAND_RESULT}")
}

The above will output

2022/01/11 13:21:35 [USER] two - 123
2022/01/11 13:21:35 [USER] one - 123

There are two issues here.

  1. People may think that two - abc should be logged by looking at the definition. It's understandable why it doesn't but it's non-obvious. This may be something that's just accepted and documented as a known limitation.

  2. The bigger issue is where the execution is conditional. With the removal of backtick execution from conditionals, log-two in the example ran because COMMAND_RESULT is empty at the point of execution, only after is find /tmp run and /tmp is unlikely to be empty. Now this came from the move to the AST based approach but I think backtick execution would need to make a come back.

Consider types - integers and booleans at least

The http module allows a HTTP-status code to be matched, which looks odd as a string. Similarly file-permissions for the file module must currently be expressed as a string because we have no support for numeric types.

Additionally the shell module has a shell => "true" option, which could be a boolean. Although there's no obvious other place where that is used yet.

I think we should parse values as either strings or numbers. We don't necessarily need to make any changes to usage the "StringValue" can convert at run-time easily enough.

The conversion could happen in the parser, or later. But the end result would be that:

  "mode" => 0755

Or

"expect" => 200

Would be synonymous with "mode => "0755" or "expect => "200".

Update our conditional support

Right now we can use exists path/to/file for the if and unless tokens.

We should allow string comparisions, so I'm going to change the syntax to be function-like:

 "if" => "exists( /path/to/file)"
 "if" => "equals( \"${distribution}\", \"Debian\" )"

However the problem there is that the strings are quoted, which is a pain. Single tokens are OK.

contains doesn't work on cat'd file

```let mysqlcnf = cat ${HOME}/.my.cnf

shell { name => "Adding datadir, adding new",
command => "sh edit_mysql.sh",
unless => contains($mysqlcnf, 'Required')
}

The file is there, contains the string Required, and it doesn't work as it should.

Include-once functionality

What are your thoughts on preventing literal includes occurring more than once? Maybe store them in a map at the start of the include process?

This won't work for the backticks support but for plain literals it might be OK.

Use case:

# systemd.in
shell triggers {
  name => "Reload systemctl daemon",
  command => "systemctl daemon-reload"
}

# service_1.in
include "systemd.in"

file {
  target => "/etc/systemd/system/service_1.service",
  notify => "Reload systemctl daemon"
}

# service_2.in
include "systemd.in"

file {
  target => "/etc/systemd/system/service_2.service",
  notify => "Reload systemctl daemon"
}

Currently triggers an error about the Reload systemctl daemon name not being unique.

Circular dependency - strange result

This came from more of a "I wonder what would happen if..." thought.

Given the following circular dependencies:

log {
	name => "log1"
	message => "one"
	require => "log2"
}

log {
	name => "log2"
	message => "two"
	require => "log3"
}

log {
	name => "log3"
	message => "three"
	require => "log1"
}

The following output is given:

2022/01/09 18:42:06 [USER] two
2022/01/09 18:42:06 [USER] one
2022/01/09 18:42:06 [USER] two
2022/01/09 18:42:06 [USER] three

Now I'm not sure what I was expecting necessarily, possibly a never ending loop, possibly nothing at all, but certainly not that.

More useful and consistent logging.

Right now we have adhoc logging triggered by -verbose.

Instead it would be useful to get more configurable and consistent logging:

  • Logging from the driver.
    • Showing files loaded.
  • Logging from the parser.
    • Showing what objects were created.
  • Logging from the decisions of conditional calls
  • Logging from the modules themselves.

Probably worth looking at logging-libraries that are available to allow -logging=debug (for parser), -logging=info for the driver and rules. Or similar.

Unify package-management

Instead of having modules:

  • apt
  • dnf - TODO
  • emerge - TODO
  • dpkg
  • yum - TODO

We should just have a single "package" module, which looks up the back-end to use dynamically.

Templated/variable expansion in files

I have a use case where it would be useful to be able to expand variables within files copied via source (and I suppose source_url as well). This would probably be disabled by default for backwards compatibility but something like

file {
    "source" => "/path/to/file",
    "templated" => true
}

could be the way to handle it.

While variables are expanded in content statements, expansion in files would be useful for larger (> 1 line) content.

I was going to look at doing this myself but I noticed the environment isn't passed to modules. I'm not sure if, design wise, this would be appropriate?

Allow simple conditionals

Simple conditional based on file-presence:

foo { name => "Blah .. ",
        if => "exists /etc/foo" }

Simple conditional based on file-missing:

foo { name => "Blah .. ",
        unless => "exists /etc/foo" }

So two keys if and unless. We'll support only one thing initially:

  • exists /path/to/file

In the future perhaps we could allow /path/to/file contains "text", etc.

Move file/directory group/owner code into our common library.

Both the file and directory modules contain code for setting the owner/group of a file. Move this to the file-library.

Note that we should also handle Windows here, since we have a build-failure there:

 GOARCH=amd64 GOOS=windows go build .
# github.com/skx/marionette/modules
modules/module_directory.go:102:27: undefined: syscall.Stat_t
modules/module_directory.go:103:27: undefined: syscall.Stat_t
modules/module_directory.go:122:27: undefined: syscall.Stat_t
modules/module_directory.go:123:27: undefined: syscall.Stat_t
modules/module_file.go:120:27: undefined: syscall.Stat_t
modules/module_file.go:121:27: undefined: syscall.Stat_t
modules/module_file.go:141:27: undefined: syscall.Stat_t
modules/module_file.go:142:27: undefined: syscall.Stat_t

Add mysql module

This might be a bigger job:

mysql {
    username => "root",
    password => "secret",
    database => "users",
    sql => "SELECT COUNT(id) FROM wp_users WHERE login LIKE \"steve%\"",
}

Might be a good example of how it could work..

Conditional includes

if and unless are useful in rules but get repetitive if multiple rules require the same condition:

package {
    ....
    if => equal("${ARCH}", "x86_64")
}
file {
    ....
    if => equal("${ARCH}", "x86_64")
}
file {
    ....
    if => equal("${ARCH}", "x86_64")
}

A nicer implementation might be to include files based on conditionals:

include "x86_64_stuff.txt" if equal("${ARCH}", "x86_64")

This could use the same conditionals that are available for rules.

Another alternative would be to allow wrapping if and unless around rules:

if equal("${ARCH}", "x86_64") {
    package {
        ....
    }

    # ${ARCH} equals "x86_64" and ${VAR} equals "foo"
    if equal("${VAR}", "foo") {
        file {
            ....
        }
    }
}

Move conditionals to their own package

We currently have two conditionals:

  • exists(/path/to/file)
  • equals( one, two )

These are hardcoded in our executor; move them to their own package and allow new conditionals to be registered without touching the main code.

Assume function signature of:

  • blah( args []string) bool, error

Add test-cases for all current conditionals.

Allow modules to setup output variables

It occurred to me in a recent comment that it might be nice to write something like this:

let blah = shell{ command => "uptime" }

log { message => "${blah.stdout}" }

Assigning blocks to variables is a big change that is going to be fiddly and annoying. That said allowing a module to generate outputs is a little appealing, whether for debugging or real use.

The HTTP-module is another obvious case where we have status-code, return-line, and content, upon successful execution.

So instead of allowing the variable assignment I'll propose a simple, optional, interface which allows a module to set variables in the environment - scoped by the rule-name.

For example:

shell { name => "uptime", command => "uptime" }
log { message => "${uptime.stdout}" }

Here we see shell is executed with a name uptime. That will set:

  • ${uptime.stdout}
  • ${uptime.stderr}

That's a simple change, and should allow conditional execution in the future.

TLDR:

  • Some modules will be updated to store variables in the environment after they're executed.
  • These can be used in later rules in the obvious way.
    • shell { name => "user", command => "whoami"}
    • shell { command => "..", if => equal("${user.stdout}", "root" }

Module suggestions

Some suggestions for modules which I think would be useful and general enough to compliment existing modules:

Users

User addition, deletion and modification via useradd, userdel and usermod respectively.

Groups

Similar to users, but with groupadd, groupdel and groupmod.

Package Repositories

package already exists to manage packages, but a good compliment to this would be the management of additional repositories such as PPAs under Ubuntu based systems or things like EPEL for CentOS. Adding can be accomplished with add-apt-repository on Debian based systems but I don't know of a nice way of removing except for "manually" deleting the repo files. I have less experience with CentOS based systems but it seems adding and removing would be similarly "manual".

Posting this to provide somewhere to discuss whether these would be useful modules and possible implementation details.

Allow trailing semi-colons

This is fine:

let key="Name"
let val="Steve"

But if you add a trailing semi-colon, which habit forces upon me you get an error:

$ cat temp.in
let key="Name"
let val="Steve";
$ ./marionette temp.in
Error:expected '{', got {EOF }

Ideally we'd allow the trailing semi-colon, but if we don't we could have a better error message.

Modules should be able to use the `-verbose` setting

Right now -verbose just affects the output of the main driver, which executes the rules.

It seems natural to allow plugins to honor this flag too.

  • Define a configuration-struct.
  • Populate it, appropriately.
  • Pass it to modules.
    • Update all the modules that make sense to use -verbose flag.

Support binary plugins.

Given this input:

foo { name => "Steve",
        param1 => "Blah",
        ... }

Pipe the parameters as a JSON object to the binary ~/.marionette/foo. If the exit-code is zero then we can get the output string and process it if it is:

  • "changed"
  • "unchanged"

Code quality improvements

The last few commits to master were focused on reducing the code-complexity - specifically those reports on the goreportcard:

The site is down at the moment, but the complexity score of >15 is handled via gocyclo. I can use that to confirm I've fixed things.

In addition to the complexity issues I see that some modules aren't tested at 100%, and some not at all.

  • Update the parser-test at least since that is achingly close to 100% coverage.
  • Add some simple end-to-end tests of file, edit, etc modules.
    • ideally each of our modules should have at least one test-case.
    • but that can wait.

Easy wins should be easy, for example the lexer can be updated to use a lookup table for the majority of its token-types.

Allow file-inclusion

I wrote a couple of large modules today which would have been neater if I'd been able to separate the implementatin into include files.

While it might be nice to allow search-paths, even allowing inclusion from the pwd would suffice.

Trigger groups or "how to avoid uneccessary duplicated actions"

So apologies in advance as this is going to be a bit of a thought dump rather than any concrete ideas or suggestions. I'm hoping it may trigger some ideas or thoughts from others.

The Goal

The ability to:

  • Define additional repositories and packages in separate include files for reuse. I may want newer versions of Node.js or PHP on a server and desktop, but would only want Spotify on a desktop, for example.
  • Remove unecessary calls to apt-get update which seems inefficient.

The Background

This stems from me adding multiple additional repositories which started off as

file {
    name => "repo:foo",
    target => "/etc/apt/sources.list.d/foo.list",
    content => "deb https://...",
}

package {
    name => "install:foo",
    package => "foo",
    state => "installed",
    update => "yes",
    require => "repo:foo",
}

# Repeat file and package rules for other repos and packages

In this example update => "yes" is required as a new repo file has been added and the system needs to know that it provides foo before being able to install foo. However, in the layout above, adding N repositories following by N package rules will mean apt-get update runs N times.

This layout allows for rule separation but runs apt-get update too often.

The next stage was changing this to something like

# Add repo foo
# Add repo bar

package {
    package => [ "foo", "bar" ],
    update => "yes"
}

This limits the update to run once but means that all packages defined in the additional repos have to be installed in the same package rule and apt-get update will still run on subsequent runs even when no repo definitions have been added or changed.

This layout limits running of apt-get update, although it would still run at least once per execution, but doesn't allow for separating package definitions into multiple include files.

Next up was to run apt-get update manually:

# Add repo foo
# Add repo bar

shell {
    command => "apt-get update"
}

# package foo
# package bar

This is similar to the previous iteration in that apt-get update will still always run once but doesn't help with separation and is fragile as it relies on order of execution being the same as order of definition.

The next change was to make the shell rule triggered and notify from each repo addition, but this goes back to N repo additions trigging the shell rule N times on the first run (i.e. when all repos are new). It does however mean that apt-get update is not run at all on further executions unless repos are changed. This doesn't help with separating into multiple include files.

Ideas

As stated at the start, this isn't really a concrete idea or suggestion, but I'm thinking of something along the lines of a trigger group. This would be where a rule can be defined as triggered but rather than a 1:1 mapping between notifies and trigger execution, it would allow for triggers to react to a group of rules and only execute if one or more rules in the group result in a change being made. Conceptually it makes sense but I'm not sure of implementation at this stage.

I'm also not sure how this would work with rules being split across files as there could be some strange dependencies where the group doesn't know when to trigger as later include files may add additional rules to the group.

Embed an external module in our code.

We support external/binary plugins as a result of #2 , we should create a directory to hold them inline, and save them to disk on usage.

This would allow others to contribute easily.

Probably the important thing is to write a "WRITING MODULES" guide, and reference the example.

Conditional shell operations

Enjoying the project so far, but ran into something and I'm curious if you have any thoughts on how you might tackle conditional shell operations? ie. calling useradd only if the user doesn't exist.

I ended up something like this, but:

  1. I had to force shell redirection to get the command to be evaluated through the shell (using || wasn't enough).
  2. It always considers itself "changed".
shell {
    name => "Create ${user} user",
    command => "id ${user} >/dev/null || useradd --system --no-create-home ${user}"
}

What I think might work would be something along the lines of:

shell {
    name => "Create ${user} user",
    command => "useradd --system --no-create-home ${user}",
    unless => true("id ${user}")
}

I think adding true and false might be easy enough to support as conditionals, but I'm yet to try it.

Values declared in include-files are not available.

This is because we spawn a separate parser to process include-files.

Given this example file main.in:

include "temp.in"

shell {
    command => "echo ${value}",
}

And this include file, temp.in:

let value="Hello, world"

We'd expect:

$ marionette -verbose main.in
Hello, world
$

However we get nothing.

Solution:

  • Don't use a sub-parser. Keep all state in the same object (probably)?

Provide file path to environment

Do you think it would be possible to provide a local variable to a file about which directory it exists in? I'm not keen on hardcoding paths to files and was hoping to use something relative to the file itself.

Inside parser.go we ioutil.ReadFile(path) and then NewWithEnvironment(contents of file, p.e). I wonder if we could update p.e in that case to refer to the realpath of path, minus the filename and extension.

Something like p.e.Set("dir", get real path) and then unset it afterwards somehow.

Then inside my rules:

file { 
  target => "/foo"
  source => "${dir}/file_relative_to_this_file"
}

WDYT?

Allow testing for an executable on the users's PATH

Looking at #72 I see the following code:

let download="/usr/bin/curl" if exists(/usr/bin/curl)
let download="/usr/bin/wget" if exists(/usr/bin/wget)

This is obviously not portable (to windows), but the intent is merely to allow:

shell{ command => "${download} ..

So the key result should be that we can find a binary named curl or wget on the path. We don't need to consider the specific path. So we should instead write:

let download="curl"   if on_path(curl)
let download="wget" if on_path(wget)

i.e. A new conditional that returns "true" if the named binary is somewhere on the users' path. It will take care of windows with .exe/.com/.bat suffixes too.

Allow function-calls ..

Looking over the various examples it seems obvious that the conditional-keys we support are nothing more than function-calls:

shell {
             ...
             if => equals("foo", "bar" )
             ...
}

So what if we generalized that? What if we allowed:

let a = uppercase( "${username}" )

Or

directory { 
            target => sort( "/foo", "/foo/bar", "foo/baz/bar" ... )
}

Supporting this would be pretty simple if we dropped the use of the token.* types within the parser and executor. We'd still lex into such things, but then define some new ast-nodes:

ast.String{}
ast.Number{}
ast.Boolean{}
ast.Backtick{}

Once that was done we can define functions as:

type Funcall struct {
    Node
    Name string
    Args []Node
    ..
}

At that point we'd have a cleaner implementation:

  • We'd no longer have the executor know about token.* values.
  • We could handle all node-types easily, and identically.
  • And "conditionals" would be updated to use "real" function-calls.

As a final step we could add some built-in functions which are used more generally:

  • upper()
  • lower()
  • min(x,y)
  • max(y,z)

I'm not sure what kind of primitives would make sense, but it feels like losing the token-usage in the middle of the code would be a good thing even if there were zero new additions.

Implementation Plan

  • Define new AST types, return them from the parser, and use them in the executor.
    • This will result in zero functional changes.
  • Update conditional values to be function-calls.
    • This will result in zero functional changes.
    • As if and unless are the only places where function-calls are supported. If their implementation changes nobody will notice.
  • Finally define a few new functions which can be used for assignments, values, etc.
    • This will be the first time the new feature is exposed.

It is important to note that I don't want a whole new programming language, nor do I see adding more than 10-20 primitive functions. Too much complexity would spoil things, but we could definitely add trim and similar utility methods which would be useful.

Package installation order

Package installation could result in unexpected packages being installed depending on the order of installation. Take php7.4 for example. The package depends on libapache2-mod-php7.4 OR php7.4-fpm OR php7.4-cgi.

If a package definition of the below is used:

package {
    name => "php",
    package => [ "php7.4", "php7.4-fpm" ],
    state => "installed"
}

will effectively result in

apt-get install php7.4
apt-get install php7.4-fpm

with the issue being that the first command will also install libapache2-mod-php7.4. This is different to running apt-get install php7.4 php7.4-fpm as the dependency resolution will know that the package requirements are met without having to pick additional packages.

This can be resolved by a user by changing the order of the packages in the defintion to package => [ "php7.4-fpm", "php7.4" ].

The easy fix is to document this. The alternative is to change the package module to install all required packages in a single command.

Allow assignments to be conditional too

Putting together some decent examples, in #66, made me consider how other people might approach problems.

I tend to use wget for downloading binaries, for example. But these days curl is perhaps more common.

It might be nice to support this:

let download=""

let download="/usr/bin/curl" if exists(/usr/bin/curl)
let download="/usr/bin/wget" if exists(/usr/bin/wget)

fail { message => "Can't find download program", if empty(download)}

Of course there's the obvious argument "If you want to use wget just .. install it":

package { name => "wget", state => "installed", name => "wget:package" }

shell { name => "download" , require => "wget:package" }

Still supporting conditionals in rules and includes does mean that assignments are the odd one out ..

Parser error

Our lexing of variables is overly permissive.

The following fails:

$ cat t.in
let foo="bar"
$ ./marionette t.in 
Error:expected '=', got {EOF }

Here the "=" is parsed as part of the variable name, which is a bug.

Several modules should support an array of arguments

When working on #66 I had to write:

directory {
          target => "${archive}",
          state  => "present",
}
directory {
          target => "${install}/go-${version}",
          state  => "present",
}

This would have been nicer:

directory {
          target => [ "${archive}", "${install}/go-${version}", ]
          state  => "present",
}

To be honest many of our modules should accept arrays rather than just strings, but it was easier to write them in the way that felt natural - so package installation supports arrays, for example, because I do use that a lot. But log, fail, etc, etc, do not because I didn't need them to do so initially.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.