Go Clients

We have a Golang RPC library that’s similar in spirit to the Ruby client library while being more idiomatic Golang and more suitable to long-running large scale automation tasks. Use this if you want to write some form of long-running never ending automated system or scale to very large fleets. This library can interact with any Choria RPC Agent based on their DDL, you do not need to generate any code or stubs. A very good example of this library in use is the code for the choria req utility.

Having pointed out that it’s really good at long-running tasks - in contrast to the Ruby library - recently we’ve significantly improved it’s use as a package for quick CLI tools as we have a plethora of tools now in the choria CLI writting using this library.

By its nature it’s more verbose and more involved to use - while the Ruby one is optimized for short quick scripts.

Recently we added the ability to generate focussed clients for Choria RPC Agents that are very easy to use and yields quite easy to read code. These generated clients use the above mentioned client library, so they are scalable to huge fleets and suitable for use in lond running orchestration systems.

This guide will focus on these generated clients. You’re encouraged to consider them first when looking at interacting with your fleet from Go.

Generated Clients

As you might be aware every Agent has a DDL file that describes the agent - all its actions, inputs, outputs, aggregation methods and validations. As of a recent improvement these DDL files now contain enough that highly usable clients can be generated for static languages like Golang.

We have a video explainer of this feature which you can access on YouTube.

This section will cover generating your own clients for your own agents, the Choria project includes generated clients for rpcutil, scout and choria_util.

Preparing your DDL

The generator will handle almost all existing agent DDLs, however in the past we did not support or enforce data types for the output items from agents. This makes it extremely hard to create fully usable clients for static languages like Golang.

We’ve recently added optional type hints to outputs in DDLs, and I strongly suggest you take a look at the DDL files you’re intending to use and add output hints. You should also review the code and ensure that the output types don’t vary, if an item varies, and you cannot fix that scenario then leave it untyped so Go will give you interface{} instances which you can then handle via reflect.

Here’s a sample fixup I did of the class puppet agent as an example, it’s easier than it sounds!

Generating the client

We’ll look at building a custom utility to do what mco rpc puppet disable message="testing golang" -W customer=acme does, but using the new Go generated client, we’ll also show how to do things like batching and finally custom discovery.

$ mkdir -p puppet/disable
$ mkdir -p puppet/client
$ cd puppet
$ choria plugin generate client /.../puppet.json client
INFO[0000] Writing Choria Client for Agent puppet Version 2.3.2 to client
INFO[0000] Writing client/action_disable.go for action disable
INFO[0000] Writing client/action_enable.go for action enable
INFO[0000] Writing client/action_last_run_summary.go for action last_run_summary
INFO[0000] Writing client/action_resource.go for action resource
INFO[0000] Writing client/action_runonce.go for action runonce
INFO[0000] Writing client/action_status.go for action status
INFO[0000] Writing client/resultdetails.go
INFO[0000] Writing client/requestor.go
INFO[0000] Writing client/ddl.go
INFO[0000] Writing client/discover.go
INFO[0000] Writing client/rpcoptions.go
INFO[0000] Writing client/client.go
INFO[0000] Writing client/initoptions.go
INFO[0000] Writing client/logging.go
INFO[0000] Writing client/doc.go

Here the /…/puppet.json is a path to the DDL, often this would be in /opt/puppetlabs/mcollective/plugins/mcollective/agent/puppet.json.

This creates the client, lets initialize go mod:

$ go mod init example.net/puppet
$ go mod tidy
$ cat go.mod
module example.net/pupppet

go 1.14

require (
        github.com/choria-io/go-choria v0.19.0
        github.com/gosuri/uiprogress v0.0.1
        github.com/sirupsen/logrus v1.7.0
)

Writing a puppet disable tool

The generated API has methods for every action, every input and output, allows you to write custom discovery plugins and more.

Here’s a basic disable tool, while this has some niceties missing like progress bars and custom configs it shows that setting up and getting going is really easy and even this code will comfortably scale to 50 000 nodes or more:

package main

import (
	"context"
	"fmt"

	p "example.net/puppet/client"
)

func panicIfErr(err error) {
    if err != nil {
        panic(err)
    }
}

func disable(ctx context.Context) error {
    // instance of puppet client, panics if fails
    // uses default choria config path as setup by the modules
    pc := p.Must()

    // disables puppet with a custom message and custom filter
    res, err := pc.OptionFactFilter("customer=acme").Disable().Message("testing golang").Do(ctx)
    panicIfErr(err)

    res.EachOutput(func(r *p.DisableOutput) {
        if r.ResultDetails().OK() {
            fmt.Printf("OK: %-40s: enabled: %v\n", r.ResultDetails().Sender(), r.Enabled())
        } else {
            fmt.Printf("!!: %-40s: message: %s\n", r.ResultDetails().Sender(), r.ResultDetails().StatusMessage())
        }
    })

    nr := res.Stats().NoResponseFrom()
    if len(nr) > 0 {
        fmt.Printf("No responses received from %d hosts", len(nr))
    }

    return nil
}

func main() {
    panicIfErr(disable(context.Background()))
}

Code Details

Let’s look at a few key items about this code.

Setup

Here we do the basic setup, config loading, finding the network, security etc. In this case I am panicing on any error:

pc := p.Must()

We can also pass in various options and do more traditional error handling. Some other options are Logger() and Discovery() which will show later.

pc, err := p.New(p.ConfigFile("~/.puppet_client.conf"))

Batching, canaries, discovery filters etc

In the example we have this:

res, err := pc.OptionFactFilter("customer=acme").Disable().Message("testing golang").Do(ctx)

Here the OptionFactFilter() is a RPC framework option thats applicable to any RPC call and not related to any specific agent.

The godoc (rpcutil example) is the definitive document, but here are a few of the options you’d have:

Option Description
OptionAgentFilter(...string) One or more agent filters, matches the behavior of -A on the CLI
OptionClassFilter(...string) One or more class filters, matches the behavior of -C on the CLI
OptionCollective(string) The name of the sub collective to target, matches -T on the CLI
OptionCombinedFilter(...string) One or more combined filters, matches the behavior of -W on the CLI
OptionCombpoundFilter(...string) One or more compound filters, matches the behavior of -S on the CLI
OptionDiscoveryTimeout(time.Duration) How long to wait for discovery, matched –discovery-timeout or –dt on the CLI
OptionExprFilter(string) Filter the responses using an expr filter
OptionFactFilter(...string) One or more fact filters, matches the behavior of -F on the CLI
OptionIdentityFilter(...string) One or more identity filters, matches the behavior of -I on the CLI
OptionInBatches(size, sleep int) Performs the task in batches with a specific sleep, –batch and –batch-sleep on the CLI
OptionLimitMethod(string) How to pick the random set random or first, no CLI equivalent but settable in the config
OptionLimitSeed(int64) When using random method this lets you initialize the random number, set to the same number for predictable select
OptionLimitSize(string) Limit the request to a subset of nodes like 10 or 20%, matches –limit on the CLI
OptionReset() Put this first to reset all the options from previous calls else they are sticky
OptionTargets([]string) Supply a node list to use rather than rely on discovery
OptionWorkers(int) How many connections to make to the Choria Broker and how many routines to process results, defaults to 3
OptionReplyTo(string) A custom reply subject, the specific client will never get any replies

Actions and Inputs

In the example we have this:

res, err := pc.OptionFactFilter("customer=acme").Disable().Message("testing golang").Do(ctx)

Here we are invoking the Disable() action, it has no mandatory inputs. If for example message was required then the function would have been Disable(message string). In this case though message is optional thus you call Disable().Message(“optional message”), you can chain in all other optional inputs in the same manner.

Outputs

The disable action has 2 outputs - enabled and status. In the Puppet Agent DDL I specified their data types so the method signatures are:

func (d *DisableOutput) Enabled() bool
func (d *DisableOutput) Status() string

If one of these did not have type hints the return type would have been interface{} and you’d need to figure that out yourself. Some types like hash can not be turned into structures automatically so they would be map[string]interface{}.

Results

Processing

Here we iterate all the results and show a small one liner:

res, _ := pc.OptionFactFilter("customer=acme").Disable().Message("testing golang").Do(ctx)

res.EachOutput(func(r *p.DisableOutput) {
    if r.ResultDetails().OK() {
        fmt.Printf("OK: %-40s: enabled: %v\n", r.ResultDetails().Sender(), r.Enabled())
    } else {
        fmt.Printf("!!: %-40s: message: %s\n", r.ResultDetails().Sender(), r.ResultDetails().StatusMessage())
    }
})

The ResultDetails() gives you access to Sender() string, OK() bool, StatusMessage() string and StatusCode() StatusCode, these map to the similar things in the standard RPC libraries.

You can call res.HashMap() which will give you a map[string]interface{} of the whole result structure. We have helpers to parse the results into Go structures:

    res.EachOutput(func(r *p.DisableOutput) {
        if r.ResultDetails().OK() {
            fmt.Printf("OK: %-40s: enabled: %v\n", r.ResultDetails().Sender(), r.Enabled())
        } else {
            fmt.Printf("!!: %-40s: message: %s\n", r.ResultDetails().Sender(), r.ResultDetails().StatusMessage())
        }
    })

Rendering Results

Choria CLI tools have a particular way of showing progress, results and summaries that can be hard to duplicate on your own. These Client packages include standard options to achieve the same behaviour the core utilities make possible.

First we can enable the progress bar:

pc := p.Must(p.Progress())

Then we can render the results in a number of formats:

Text mode, the default for choria req rpcutil ping:

res, _ := pc.OptionFactFilter("customer=acme").Disable().Message("testing golang").Do(ctx)

res.RenderResults(os.Stdout, p.TextFormat, p.DisplayDDL, verbose, silent, colorize, logger)

Table mode, the same as choria req rpcutil ping --table

res.RenderResults(os.Stdout, p.TableFormat, p.DisplayDDL, verbose, silent, colorize, logger)

JSON mode, the same as choria req rpcutil ping --json

res.RenderResults(os.Stdout, p.JSONFormat, p.DisplayDDL, verbose, silent, colorize, logger)

The DisplayDDL allows you to override what results are displayed.

Display Mode Description
DisplayDDL Follows the display setting in the DDL
DisplayOK Shows only OK results
DisplayFailed Shows only Failed results
DisplayNone Show no results
DisplayAll Show all results

The table and text output formats include a standard footer, but perhaps you are rendering the results your self but want a standard footer, you can produce this as here:

res.RenderResults(os.Stdout, p.TXTFooter, p.DisplayDDL, verbose, silent, colorize, logger)

Statistics

At the end of the call the result will hold some statistics, here we show nodes that did not respond:

nr := res.Stats().NoResponseFrom()
if len(nr) > 0 {
    fmt.Printf("No responses received from %d hosts", len(nr))
}

A wealth of information is available, the interface for the stats can be seen here:

type Stats interface {
    Agent() string
    Action() string
    All() bool
    NoResponseFrom() []string
    UnexpectedResponseFrom() []string
    DiscoveredCount() int
    DiscoveredNodes() *[]string
    FailCount() int
    OKCount() int
    ResponsesCount() int
    PublishDuration() (time.Duration, error)
    RequestDuration() (time.Duration, error)
    DiscoveryDuration() (time.Duration, error)
}

Custom Discovery

By default, the client uses a traditional broadcast discovery, you can though integrate with your own easily. Lets read a flat file.

type FlatFileDiscovery struct {
    nodesFile string
}

// Reset is there to clear caches, we don't cache here so noop
func (f *FlatFileDiscovery) Reset() {}
func (f *FlatFileDiscovery) Discover(ctx context.Context, _ ChoriaFramework, filter []FilterFunc) ([]string, error) {
    if len(filter) > 0 {
        return []string{}, errors.New("Flat file discovery does not support filters")
    }

    file, err := os.Open(f.nodesFile)
    if err != nil {
        return []string{}, err
    }
    defer file.Close()

    found := []string{}
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        found = append(found, strings.TrimSpace(scanner.Text()))
    }

    err = scanner.Err()
    if err != nil {
        return []string{}, err
    }

    return found, nil
}

We can ask the client to use this discovery method like here:

pc, err := p.New(p.Discovery(&FlatFileDiscovery{"/home/you/nodes.txt"}))

Now instead of network discovery the file will be read instead.

CLI Tool Helpers

Choria CLI utilities have a particular look and feel, the result rendering above can help you attain that for displaying results, but the command line flags is another important aspect.

The aim is to make a utility with flags like these:

./demo --help
usage: demo [<flags>]

Small demo app

Flags:
      --help             Show context-sensitive help (also try --help-long and --help-man).
      --verbose          Be verbose
  -F, --wf=WF ...        Match hosts with a certain fact
  -C, --wc=WC ...        Match hosts with a certain configuration management class
  -A, --wa=WA ...        Match hosts with a certain Choria agent
  -I, --wi=WI ...        Match hosts with a certain Choria identity
  -W, --with=FILTER ...  Combined classes and facts filter
  -S, --select=EXPR      Match hosts using a expr compound filter
  -T, --target=TARGET    Target a specific sub collective
      --nodes=NODES      List of nodes to interact with in JSON, YAML or TEXT formats
      --dm=DM            Sets a discovery method (mc, choria)
      --discovery-timeout=SECONDS  
                         Timeout for doing discovery

Which should switch the discovery method automatically depending on what flags are chosen etc. As Choria get new features you want these features available automatically without having to code them in.

Choria core utilities are all written using kingpin.v2, the below main.go is an entire choria utility that does choria req rpcutil ping with full discovery and rendering features.

This is still a bit verbose and we’ll be making some improvements to this in time.

package main

import (
	"context"
	"os"

	"gopkg.in/alecthomas/kingpin.v2"

	"github.com/choria-io/go-choria/choria"
	"github.com/choria-io/go-choria/client/discovery"
	"github.com/choria-io/go-choria/client/rpcutilclient"
)

var (
	opt     *discovery.StandardOptions
	verbose bool
)

func main() {
	app := kingpin.New("demo", "Small demo app").Action(run)
	app.Flag("verbose", "Be verbose").BoolVar(&verbose)

	opt = &discovery.StandardOptions{}
	opt.AddFilterFlags(app) // add flags like -W, -C, -A etc
	opt.AddFlatFileFlags(app) // add --nodes for node lists from files
	opt.AddSelectionFlags(app) // adds --dm and --discovery-timeout etc

	kingpin.MustParse(app.Parse(os.Args[1:]))
}

func run(_ *kingpin.ParseContext) error {
	fw, err := choria.New(choria.UserConfig())
	if err != nil {
		return err
	}

	opt.SetDefaultsFromChoria(fw)
	rpcutil, err := rpcutilclient.New(rpcutilclient.Progress(), rpcutilclient.Discovery(rpcutilclient.NewMetaNS(opt, true)))
	if err != nil {
		return err
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	res, err := rpcutil.Ping().Do(ctx)
	if err != nil {
		return err
	}

	res.RenderResults(os.Stdout, rpcutilclient.TextFormat, rpcutilclient.DisplayDDL, false, false, true, fw.Logger("render"))

	return nil
}