cf-cmd: A command-line tool for running CFEngine snippets

While writing this book, I've had to run hundreds of little CFEngine snippets to run tests, develop examples, verify functionality, or get a solid idea of what some constructs did. After building the typical "test bundle" scaffolding in an editor for the hundredth time, I decided to do something about it. The result is the cf-cmd command. I will let it speak for itself:

$ cf-cmd help
cf-cmd v1.0 - Diego Zamboni diego@zzamboni.org>

cf-cmd is a tool that allows you to run small CFEngine snippets quickly,
by automatically wrapping them around a standard "test" bundle.

The CFEngine Standard Library is automatically included.

The following inputs are understood by this tool:

help     Print this message
list     Print current policy
clear    Clear current policy
go|run   Execute current policy using cf-agent
type:    Switch to the given promise type
         (classes:, commands:, databases:, environments:, files:, interfaces:,
         methods:, outputs:, packages:, processes:, reports:, services:,
         storage:, vars:)
           The current promise type is shown in the prompt.

All other lines are added literally to the current promise type.

Commands can be abbreviated to any part of their name (for example,
"r" or "ru" for "run").

You can add lines to any of the standard promise types inside the test
bundle by switching to the appropriate promise type first.

The default promise type is "reports:", to make it easier to quickly print
the value of expressions.

You can give the inputs also on the command line, they are interpreted
in exactly the same way (make sure to quote things correctly).

Examples:
  cf-cmd '"Flavor: $(sys.flavor)";' list run
  cf-cmd '"var1 = $(var1)";' vars: '"var1" string => "test";' l r
  cf-cmd h

You should try out those examples at the end to see what they do. An example interactive session looks like this (I have bolded the inputs to make them easier to see):

$ cf-cmd 
reports: > "this is a test";
reports: > list
body common control {
   inputs => { "/var/cfengine/inputs/cfengine_stdlib.cf" };
   bundlesequence => { "test" };
}

bundle agent test
{
reports:
cfengine::
  "this is a test";
}
reports: > run
-> Running policy with 'cf-agent -KI -f ./test.cf'
R: this is a test
reports: > clear
reports: > l    (abbreviation of "list")
body common control {
   inputs => { "/var/cfengine/inputs/cfengine_stdlib.cf" };
   bundlesequence => { "test" };
}

bundle agent test
{
}
reports: > files: 
-> Switching to files: promise type.
files: > "/tmp/test"
files: >   create => "true",
files: >   classes => if_repaired("done");
files: > reports: 
-> Switching to reports: promise type.
reports: > done::
reports: > "Success";
reports: > l
body common control {
   inputs => { "/var/cfengine/inputs/cfengine_stdlib.cf" };
   bundlesequence => { "test" };
}

bundle agent test
{
files:
  "/tmp/test"
    create => "true",
    classes => if_repaired("done");
reports:
  done::
  "Success";
}
reports: > run
-> Running policy with 'cf-agent -KI -f ./test.cf'
 -> Created file /tmp/test, mode = 600
R: Success
reports: > 

The interactive prompt supports editing and completion of all commands and promise types - press Tab to view available completions.

You can download the script here:
#!/usr/bin/env ruby
#
# CFengine command-line test utility.
#
# Diego Zamboni, December 27th, 2011
#
# Run as "cf-cmd help" to see usage information.
require 'readline'
require 'tmpdir'
######################################################################
# ATTENTION: customize this if necessary
# Where can I find a copy of cfengine_stdlib.cf
# $stdlib_loc = "#{ENV['HOME']}/CFEngine/src/copbl/cfengine_stdlib.cf";
$stdlib_loc = "/var/cfengine/inputs/cfengine_stdlib.cf";
######################################################################
class CfCmd
# Constructor
def initialize(stdlib, initialstate = 'reports:')
@version = "1.0";
# Initialize base parameters
@stdlib_loc = stdlib
@initialstate = initialstate
@mode = @initialstate
# Valid promise types and commands
@modes = %w(vars: classes: commands: databases: environments: files: interfaces:
methods: outputs: packages: processes: services: storage: reports:)
@commands = self.methods(/^cmd_/).grep(/^cmd_/).map { |c| c.to_s.gsub(/^cmd_/, "") }
# Initialize data structures
@lines = emptypolicy
# By default, initialize the reports: section with a cfengine:: class, so that things are always printed.
# Issue a "clear" command to eliminate it.
@lines['reports:'] = "cfengine::\n"
end
#----------------------------------------------------------------------
# Utility functions
# Policy template to use
def policytemplate(bundlebody)
<<EOF
body common control
{
inputs => { "#{ @stdlib_loc }" };
bundlesequence => { "test" };
}
bundle agent test
{
#{bundlebody}}
EOF
end
# Return current policy as a string
def buildpolicy
bundlebody = ""
@lines.keys.each { |k|
bundlebody += "#{k}\n#{@lines[k]}" unless @lines[k].empty?
}
return policytemplate(bundlebody)
end
# Empty all policy sections
def emptypolicy
res = {}
@modes.each { |m|
res[m] = ""
}
return res
end
# Find the first command (if any) that start with the given string
def findcmd(cmd)
cmds = @commands.grep(/^#{Regexp.quote(cmd)}/)
return cmds.empty? ? nil : cmds[0]
end
# Word wrap, with an optional prefix string. Based on http://snipplr.com/view.php?codeview&id=1081
def wrap_text(txt, col = 80, prefix="")
ncol = col - prefix.length
txt.gsub(/(.{1,#{ncol}})( +|$\n?)|(.{1,#{ncol}})/, "#{prefix}\\1\\3\n")
end
# Interpret and process a line
def interpret(line)
(cmd, args) = line.split(nil, 2)
if @modes.include?(cmd)
$stderr.puts "-> Switching to #{cmd} promise type."
@mode = cmd
elsif cmdname = findcmd(cmd)
eval "cmd_#{cmdname}(args)"
else
@lines[@mode] = @lines[@mode] + " #{line}\n"
end
end
#----------------------------------------------------------------------
# Command definitions
# List current policy
def cmd_list(args=nil)
puts "#{buildpolicy}";
end
# Clear current policy
def cmd_clear(args=nil)
@lines = emptypolicy
end
# Execute current policy
def cmd_go(args=nil)
fname = "./test.cf"
Dir.mktmpdir('cf-') { |dir|
Dir.chdir(dir)
File.open(fname, "w") { |f| f.puts(buildpolicy) }
cmd = "cf-agent #{args.to_s} -KI -f #{fname}"
$stderr.puts "-> Running policy with '#{cmd}'"
system(cmd)
}
end
# Synonymous for go
def cmd_run(args=nil)
cmd_go(args)
end
# Save to a file
def cmd_save(args=nil)
if args.nil?
$stderr.puts "-> Error: need a filename to which to save the script."
else
File.open(args, "w") { |f| f.puts(buildpolicy) }
$stderr.puts "-> Saved script to #{args}."
end
end
# Print help
def cmd_help(args=nil)
cmdname=File.basename($0)
puts <<EOM
#{cmdname} v#{@version} - Diego Zamboni <diego@zzamboni.org>
#{cmdname} is a tool that allows you to run small CFEngine snippets quickly,
by automatically wrapping them around a standard "test" bundle.
The CFEngine Standard Library is automatically included.
The following inputs are understood by this tool:
help Print this message
list Print current policy
clear Clear current policy
go|run Execute current policy using cf-agent
type: Switch to the given promise type
#{wrap_text("(" + @modes.sort.join(', ') +")", 80, ' ').chomp}
The current promise type is shown in the prompt.
All other lines are added literally to the current promise type.
Commands can be abbreviated to any part of their name (for example,
"r" or "ru" for "run").
You can add lines to any of the standard promise types inside the test
bundle by switching to the appropriate promise type first.
The default promise type is "reports:", to make it easier to quickly print
the value of expressions.
You can give the inputs also on the command line, they are interpreted
in exactly the same way (make sure to quote things correctly).
Examples:
#{cmdname} '"Flavor: $(sys.flavor)";' list run
#{cmdname} '"var1 = $(var1)";' vars: '"var1" string => "test";' l r
#{cmdname} h
EOM
end
#----------------------------------------------------------------------
# Interactive subroutine
def run
# Trap Ctrl-C and make it do a clean exit
stty_save = `stty -g`.chomp
trap('INT') { system('stty', stty_save); exit }
comp = proc { |s| (@commands + @modes).grep( /^#{Regexp.escape(s)}/ ) }
Readline.completion_append_character = " "
Readline.completion_proc = comp
while line = Readline.readline("#{@mode} > ", true)
interpret(line)
end
end
end # class CfCmd
# Do something if invoked directly (instead of included as a module)
if __FILE__ == $0
cfcmd = CfCmd.new($stdlib_loc)
if !ARGV.empty?
# If any command-line arguments are given, interpret them as commands
ARGV.each { |cmd|
cfcmd.interpret(cmd)
}
else
# Otherwise start interactive mode
cfcmd.run
end
end
view raw cf-cmd.rb hosted with ❤ by GitHub
.

Just put it somewhere in your path. If needed, modify the location of the cfengine_stdlib.cf file on your system (by default it looks for it under /var/cfengine/inputs/). You need Ruby installed (I tested with version 1.9.3).

If you find it useful, or if you find any problems or have any suggestions, please let me know. I'd be happy to hear from you.