Asteris

Extending Converge

// Rebecca Skinner

In this article on extending Converge, we’ll walk through the process of developing a brand new Converge resource from scratch. We’ll go into detail on the resource authors’ API and building a resource that’s safe and usable. This article was generated from a literate program, you can download the original orgmode document if you want to build the examples yourself.

Update: 2017-01-06

This article was updated on 2017-01-06. The How Resource Work section has been updated to use the updated Resource Authors API.

Introduction

Converge provides a large and growing collection of built-in resources. In many cases where a resource isn’t available, you can use the task resource to manage your system; however, creating a custom resource can offer a few advantages:

  1. Improve the readability of your HCL files by reducing verbosity
  2. Help make it easy for your customers to use your tool or service by giving them a first-class way to interact with it from Converge
  3. Allow for more sophisticated error handling by allowing you to perform more nuanced data handling
  4. Make it easier to develop reusable modules

This article will walk through the process of creating a custom resource for controlling file ownership. The version of the resource we’ll be creating is focused on showing off the resource authors API. You can refer to the full implementation in the source tree to get a feel for what a full production resource looks like.

How Resources Work

Converge Resource Architecture

Before we begin working on the implementation of our resource, let’s take a moment to look at how the resource author’s API works and what goes into building a module. A Converge resource is a go package that has registered structs that implement the Resource and Task interfaces defined in resource/resource.go.

When Converge starts up, each of the resources register themselves by calling registry.Register. This is done from each package’s init function, which is automatically executed by the go runtime (you can read more about the init function here). After the resources have been registered, the HCL file is parsed.

The HCL file is parsed and used to create new Resource structs using the types that are given in the call to Register. Once the file has been parsed, and Resource structs created, the engine will call Prepare on them.

Each call to Prepare returns a Task, and then Check and Apply are called on each of those tasks. Let’s take a more detailed look at our Resource and Task interfaces.

Resource

type Resource interface {
        Prepare(context.Context, Renderer) (Task, error)
}

The Resource interface is often called simply a Preparer because of it’s single function name Prepare. A resource performs two essential functions that occur early on in the execution of an HCL file:

The Resource struct will use struct tags to define how the HCL will be deserialized. The Prepare function will take a Renderer, but in most cases it will be unnecessary since any values in the HCL file will be rendered before Prepare is called.

Task

type Task interface {
        Check(context.Context, Renderer) (TaskStatus, error)
        Apply(context.Context) (TaskStatus, error)
}

The Task interface defines how we handle planning and applying changes. The Check function is called when Converge runs plan or apply. Check is responsible for finding the differences between the asserted and current states, and determining if any changes need to be made. The Apply function is only called during converge apply, and only when Check reports changes.

A Note About Contexts

All of our interface functions take a context.Context as their first parameter. In go 1.7 the contexts package was brought into the official go stdlib. If you are using a tool like goimports you may find that it will import that version:

  import "context"

For compatibility with some third-party libraries, Converge requires the older version of the context library. Ensure you are importing golang.org/x/net/context instead.

  import "golang.org/x/net/context"

Contexts should be respected and evaluated for timeouts and cancellation during all long-running operations. See the full documentation documentation on contexts for more information on how to use them.

Implementing file.owner

To create our resource, we’ll start by making a new directory at $CONVERGE/resource/file/owner. This will be where most of the code for our resource will reside.

Our resource will include two files:

We’ll start by creating a skeleton resource so that we can build Converge. Once we’ve gotten to a point where we can compile Converge with our resource, we’ll begin adding functionality step-by-step to allow it to be loaded, to run plan, and then to apply.

A Skeleton Resource

By convention, Converge resources are stored in the resource directory with a name that reflects the resource name. For our file.owner resource, we’ll be storing our code in resource/file/owner/.

preparer.go

The first thing we need to do is define our resource so that it can be used in the rest of the system. In preparer.go, we’ll call the special init function that will allow us to register the resource so that it can be used from Converge HCL.

func init() {
        registry.Register("file.owner", (*Preparer)(nil), (*Owner)(nil))
}

In our init function we make a call to registry.Register:

func Register(name string, i interface{}, reverse ...interface{})

For our owner module we’ll be calling it with these parameters:

If we try to build now we’ll get an error. Converge expects that our Resource and Task typed fields in init will implement the correct interfaces, but right now we haven’t even defined them as types. Let’s start with our resource.Resource implementation, which is called Preparer by convention.

Recall that the type for a resource is defined by:

type Resource interface {
        Prepare(context.Context, Renderer) (Task, error)
}

Our Preparer struct then must implement this interface. A skeleton implementation is here:

type Preparer struct{}

func (p *Preparer) Prepare(context.Context, resource.Renderer) (resource.Task, error) {
        return &Owner{}, nil
}

owner.go

Recall that the type for a task is defined by:

type Task interface {
        Check(context.Context, Renderer) (TaskStatus, error)
        Apply(context.Context) (TaskStatus, error)
}

This interface shows that our Owner struct then needs to implement Check and Apply. Our basic Check function is shown below.

type Owner struct {}
func (o *Owner) Check(context.Context, resource.Renderer) (resource.TaskStatus, error) {
        status := resource.NewStatus()
        return status, nil
}

Note that we use resource.NewStatus instead of creating a status with &resource.Status{}. This ensures that all of the fields of our status have been initialized so that we can get proper formatting of changes and differences later.

Next, we can add an apply function:

func (o *Owner) Apply(context.Context) (resource.TaskStatus, error) {
        status := resource.NewStatus()
        return status, nil
}

resource.go

Although our skeleton resource is complete, we haven’t yet told the core engine to load it. To load our resource we need to add an import in load/resource.go. When you open the file you’ll see several other resources being imported. Here’s a subset of what you might see:

_ "github.com/asteris-llc/converge/resource/docker/container"
_ "github.com/asteris-llc/converge/resource/docker/image"
_ "github.com/asteris-llc/converge/resource/docker/network"

Anywhere in the import list, add _ "github.com/asteris-llc/converge/resource/file/owner"

owner.hcl

We should create an HCL file that will let us describe what our resource will look like. This will help us make sure that we’re writing the resource based on how we want to use it. Let’s make an HCL file that will change the owner and group of a file. By convention, this should go in the samples directory so let’s create samples/fileOwner.hcl:

param "group" {
  default = "root"
}

param "user" {
  default = "root"
}

param "file" {
  default = "file.txt"
}

file.owner "owner" {
  destination = "{{param `file`}}"
  user = "{{param `user`}}"
  group = "{{param `group`}}"
}

Building Our Resource

We can now build and test our skeleton resource. The default target will build all of our resources, so from your Converge directory you can simply use make to build Converge.

[email protected]:~/go/src/github.com/asteris-llc/converge$ make
go build -ldflags="-X github.com/asteris-llc/converge/cmd.Version=0.5.0-beta1-15-g593c8e02-dirty"
[email protected]:~/go/src/github.com/asteris-llc/converge$ ./converge plan --local samples/fileOwner.hcl
2016-12-27T14:15:10-06:00 |WARN| setting session-local token    token=c11ca17e-b77f-4f29-ac1c-edbb7b6d21a7
2016-12-27T14:15:10-06:00 |WARN| no SSL config in use, server will accept unencrypted connections   component=rpc
2016-12-27T14:15:10-06:00 |INFO| serving GRPC   addr=http://127.0.0.1:47740 component=rpc
2016-12-27T14:15:10-06:00 |INFO| serving REST   addr=http://127.0.0.1:47740 component=rpc
2016-12-27T14:15:10-06:00 |WARN| skipping module verification   component=client
2016-12-27T14:15:10-06:00 |ERROR| could not render  component=rpc error=2 error(s) occurred:

* I don't have a field named "destination".
* I don't have a field named "user". Maybe you meant: group location=samples/fileOwner.hcl runID=6cf47f15-5a32-4691-a1c5-053c995e0d07
 2016-12-27T14:15:10-06:00 |FATAL| could not get responses  component=client error=error getting status response: rpc error: code = 2 desc = rendering samples/fileOwner.hcl: 2 error(s) occurred:

* I don't have a field named "destination".
* I don't have a field named "user". Maybe you meant: group file=samples/fileOwner.hcl

Now we see that our resource has been loaded, but our HCL file isn’t parsable because we haven’t created any fields. It’s time to fill out our skeleton preparer with a basic resource.

Resource Basics

Updating Our Structs

Before we begin fully implementing our resource, we need to add our structs: Preparer, our Resource implementation, and Owner, our Task implementation. We’ll add on a bit to each of these later, but let’s start by getting a basic version created that will allow our HCL file to be parsed and our resource to be executed - even if it doesn’t do anything.

Preparer

The fields that are valid in an HCL file, along with their types, are defined in the preparer. We can start with a very basic preparer. We use the hcl tag to designate the fields in the HCL documents. The type of the struct fields map to the types in the HCL file. Here, all of our values are strings.

type Preparer struct {
        Destination string `hcl:"destination"`
        Username    string `hcl:"user"`
        Groupname   string `hcl:"group"`
}

Owner

Our owner struct is very similar to our preparer, but we’ve added a couple of extra fields for uid and gid. This will make more sense when we’ve fully implemented our Prepare function.

type Owner struct {
        Destination string
        Username    string
        UID         *int
        Group       string
        GID         *int
}

Running Our Resource

With just these two structs and our skeleton prepare, check, and apply functions, we now have a fully loadable and runnable resource. It doesn’t do anything, but we can validate that everything up to this point is configured and working correctly so that we can move forward with confidence.

[email protected]:~/go/src/github.com/asteris-llc/converge$ ./converge plan --local samples/fileOwner.hcl
2016-12-27T15:47:40-06:00 |WARN| setting session-local token    token=a8b39614-31ef-4adf-b3a2-80ebc783e94c
2016-12-27T15:47:40-06:00 |WARN| no SSL config in use, server will accept unencrypted connections   component=rpc
2016-12-27T15:47:40-06:00 |INFO| serving GRPC   addr=http://127.0.0.1:47740 component=rpc
2016-12-27T15:47:40-06:00 |INFO| serving REST   addr=http://127.0.0.1:47740 component=rpc
2016-12-27T15:47:41-06:00 |WARN| skipping module verification   component=client
2016-12-27T15:47:41-06:00 |INFO| got status component=client file=samples/fileOwner.hcl id=root/file.owner.owner run=STARTED stage=PLAN
2016-12-27T15:47:41-06:00 |INFO| got status component=client file=samples/fileOwner.hcl id=root run=STARTED stage=PLAN

root/file.owner.owner:
 Messages:
 Has Changes: no
 Changes: No changes

Summary: 0 errors, 0 changes

preparer.go

Before we move on to the changes to our Prepare function, we should consider the usability of our resource. In particular, we want to consider what information the user needs to provide and the type of that information. Where it’s reasonable to do so, accepting alternative forms of input can improve the user experience for a resource. For our resource we may want to allow the user to input either a username or a uid; likewise we may want to allow a groupname or gid. We also want to make sure the user only specifies one or the other of username/uid and groupname/gid.

Let’s take a look at our updated preparer:

// Preparer for Owner
//
// Owner specifies file ownership.  You may specify either or both of a user and
// group to set ownership.  You may refer to a user or group either by name or
// id.
type Preparer struct {
        // Destination is the location on disk where the content will be rendered.
        Destination string `hcl:"destination" required:"true" nonempty:"true"`

        // Username specifies user-owernship by user name
        Username string `hcl:"user" mutally_exclusive:"user,uid"`

        // UID specifies user-ownership by UID
        UID *int `hcl:"uid" mutually_exclusive:"user,uid"`

        // Groupname specifies group-ownership by groupname
        Groupname string `hcl:"group" mutually_exclusive:"group,gid"`

        // GID specifies group ownership by gid
        GID *int `hcl:"gid" mutually_exclusive:"group,gid"`
}

We’ve added a few new concepts here, let’s break them down one-by-one:

The required and nonempty tags

Our Destination field uses the non-empty and required tags. When the nonempty tag is specified, it causes Converge to raise an error if the user specifies a zero value for the field. Similarly, the required flag will raise an error if the user omits a field.

The mutually_exclusive tag

Our Username and UID along with the Groupname and GID specify the mutually_exclusive tag. Only one field out of a set of mutually exclusive fields may be specified. The mutually_exclusive tag should be given a list of all fields in the mutually exclusive group, and should be specified for each of those fields. The field names for mutually_exclusive is the HCL name, not the struct field name.

Pointer values

The pointer values we use for UID and GID are a way of specifying optional values for HCL documents. If the user does not specify a uid or gid then the pointers will be nil. If a value is specified then it will be stored at the address of the pointer.

Documentation

The comments for the preparer are used to generate the documentation for the resource. The comments for each field, along with the type and the struct tags, are used to generate the documentation for the fields. These documents tell the user how to write HCL to use your resource.

After we’ve updated the structure we can begin to implement our final Preparer. Our ownership module is able to leverage Converge’s built in data validation through struct tags to ensure that we have valid data, but for other types of of modules this would be where we would perform any initial data validation. Errors returned from the preparer will be passed directly to the user and will be associated with the HCL resource that generated the error.

Since we know that we have valid data our Prepare function need only return a new Owner with it’s fields initialized.

func (p *Preparer) Prepare(context.Context, resource.Renderer) (resource.Task, error) {
        return &Owner{
                Destination: p.Destination,
                Username:    p.Username,
                UID:         p.UID,
                Group:       p.Groupname,
                GID:         p.GID,
        }, nil
}

owner.go

Just as we started working on Preparer by thinking about the usability of our resource from an HCL prespective, in our Owner struct we need to consider usability in terms of exported values. Converge supports exporting values for use with the lookup call through a pair of struct tags: export and re-export-as. We are not embedding any structs in our Owner, so we need only be concerned with export-ing values. We’ll add documentation strings to each field as well, so that the generated resource documents will have information about our exported fields.

// Owner represents the ownership mode of a file or directory
type Owner struct {
        // the path to the file that should change; or if `recursive` is set, the path
        // to the root of the filesystem to recursively change.
        Destination string `export:"destination"`

        // the username of the user that should be given ownership of the file
        Username string `export:"username"`

        // the uid of the user that should be given ownership of the file
        UID *int `export:"uid"`

        // the group name of the group that should be given ownership of the file
        Group string `export:"group"`

        // the gid of the group that should be given ownership of the file
        GID *int `export:"gid"`
}

Now that we have enough information in our Task, we can begin implementing our resource. We’ll start with Check, and then move onto Apply.

The Planning Phase

Our check function will need to do two things:

  1. Compare the current permissions on the file with the user-specified permissions
  2. Populate missing fields with valid data

While the requirement to check permissions on the file may be self-explanatory, the requirement to fill in missing field data might seem strange. The reason for this has to do with the use of the lookup function. Once the Check function in our task has been executed, any other resources that are dependent on it for exported data will attempt to resolve that data. Recall that we allowed a user to enter a username or uid, and a groupname or gid. Because of the mutually exclusive tag we know that only some of that information will be set at any time. From a usability perspective, we want to ensure that users are able to lookup all of the information that is documented as being exported, so we should ensure that all of our exported fields have valid data before we return from Check.

Filling In Data

For both the user and group we need to either take the ID and get a name, or take the name and get the ID. The built-in os/user package provides some useful functions to help us accomplish this.

func LookupGroup(name string) (*Group, error)
func LookupGroupId(gid string) (*Group, error)
func Lookup(username string) (*User, error)
func LookupId(uid string) (*User, error)

Both of the user lookup functions return a User struct that has all of the information we need to fully populate our Owner.

type User struct {
        Uid      string // user ID
        Gid      string // primary group ID
        Username string
        Name     string
        HomeDir  string
}
type Group struct {
        Gid  string // group ID
        Name string // group name
}

Let’s begin by creating a pair of functions: populateFromUser and populateFromGroup. These functions will take a user.User or user.Group and populate the relevant fields of our Owner struct.

func (o *Owner) populateFromGroup(g *user.Group) error {
        gid, err := strconv.Atoi(g.Gid)
        if err != nil {
                return err
        }
        o.Group = g.Name
        o.GID = &gid
        return nil
}
func (o *Owner) populateFromUser(u *user.User) error {
        uid, err := strconv.Atoi(u.Uid)
        if err != nil {
                return err
        }
        o.Username = u.Name
        o.UID = &uid
        return nil
}

With these two functions, we can write a function that will let us populate all of the data in our owner struct:

func (o *Owner) fillInMissingData() error {
        var userInfo *user.User
        var groupInfo *user.Group
        var err error
        if nil == o.UID && o.Username != "" {
                if userInfo, err = user.Lookup(o.Username); err != nil {
                        return err
                }
        }
        if o.Username == "" && o.UID != nil {
                if userInfo, err = user.LookupId(strconv.Itoa(*o.UID)); err != nil {
                        return err
                }
        }

        if o.GID == nil && o.Group != "" {
                if groupInfo, err = user.LookupGroup(o.Group); err != nil {
                        return err
                }
        }

        if o.Group == "" && o.GID != nil {
                if groupInfo, err = user.LookupGroupId(strconv.Itoa(*o.GID)); err != nil {
                        return err
                }
        }

        if groupInfo != nil {
                if err = o.populateFromGroup(groupInfo); err != nil {
                        return err
                }
        }

        if userInfo != nil {
                if err = o.populateFromUser(userInfo); err != nil {
                        return err
                }
        }
        return nil
}

Finding Differences

After we’ve filled in all of our data, we need to find out who owns the current file. To do that, we’ll call os.Stat on the file. Getting the specific ownership information requires an OS specific cast.

func fileOwnership(path string) (int, int, error) {
        fileStat, err := os.Stat(path)
        if err != nil {
                return 0, 0, err
        }
        statT, ok := fileStat.Sys().(*syscall.Stat_t)
        if !ok || statT == nil {
                return 0, 0, ErrInvalidStat
        }
        return int(statT.Uid), int(statT.Gid), nil
}

With this function we can now easily get the existing user and group ownership for a file. This will make it easy to compare against the configured values.

The Check Function

Before we begin to fill in the details of our Check function, let’s take a moment to refresh our memory of the API for resource.Status, which are defined in resource/status.go:

  1. Status Levels

    The RaiseLevel function is used to set the error level on a status. RaiseLevel is preferred over setting Level directly. The following status levels are defined:

    • StatusNoChange: StatusNoChange means no changes are necessary. This status signals that execution of dependent resources can continue.

    • StatusWontChange: StatusWontChange indicates an acceptable delta that wont be corrected. This status signals that execution of dependent resources can continue.

    • StatusWillChange: StatusWillChange indicates an unacceptable delta that will be corrected. This status signals that execution of dependent resources can continue.

    • StatusMayChange: StatusMayChange indicates an unacceptable delta that may be corrected. This is considered a warning state that indicates that the resource needs to change but is likely dependent on the succesful execution of another resource. This status signals that execution of dependent resources can continue.

    • StatusCantChange: StatusCantChange indicates an unacceptable delta that can’t be corrected. This is just like StatusFatal except the user will see that the resource needs to change, but can’t because of the condition specified in your messaging. This status halts execution of dependent resources.

    • StatusFatal: StatusFatal indicates an error. This is just like StatusCantChange except it does not imply that there are changes to be made. This status halts execution of dependent resources. The default status level on an empty struct or one created with NewStatus is StatusNoChange.

  2. Differences

    Differences are used to show the user what will change during a plan, or what has changed during an apply. In the general case where differences can be easily represented as strings, you can use the AddDifference function to add differences:

    func (t *Status) AddDifference(name, original, current, defaultVal string)
    

    The parameters for AddDifference are:

    • name: the name of the thing that will change

    • original: the original value

    • current: the desired value. When running a plan current represents the intended value. When running apply, current will be the actual value that was set.

    • defaultVal: if original or current is an empty string, it can be replaced with defaultVal.

    Any differences that have been added will be shown during plan and apply:

    name: original => current
    
  3. Finalizing Check

    Now that we understand how to set the status level, and can add planned differences, we can fully implement our Check function. First we fill in our missing data, then get the the current file ownership. Where the actual ownership information differs from the planned ownership information, we add a difference and raise the status level.

    func (o *Owner) Check(context.Context, resource.Renderer) (resource.TaskStatus, error) {
            status := resource.NewStatus()
            if err := o.fillInMissingData(); err != nil {
                    return nil, err
            }
            uid, gid, err := fileOwnership(o.Destination)
            if err != nil {
                    return nil, err
            }
            if o.UID != nil && uid != *o.UID {
                    status.RaiseLevel(resource.StatusWillChange)
                    status.AddDifference("UID", strconv.Itoa(uid), strconv.Itoa(*o.UID), "")
            }
            if o.GID != nil && gid != *o.GID {
                    status.RaiseLevel(resource.StatusWillChange)
                    status.AddDifference("GID", strconv.Itoa(uid), strconv.Itoa(*o.UID), "")
            }
            return status, nil
    }
    

The Apply Function

Just like Check, the Apply function will need to gather the differences between the current and asserted ownership state, and add those differences to the status struct. Additionally, Apply needs to make the changes to the file ownership. We need to also be careful to ensure that we only add differences that have been applied to the system.

The built-in function to change file ownership that we will be using is from the os package:

// Chown changes the numeric uid and gid of the named file. If the file is a
// symbolic link, it changes the uid and gid of the link's target. If there is
// an error, it will be of type *PathError.
func Chown(name string, uid, gid int) error

Since the Chown function requires a uid and gid, we will add some logic in our apply function to keep the existing uid or gid if one hasn’t been specified by the user.

The completed version of our apply function is:

func (o *Owner) Apply(context.Context) (resource.TaskStatus, error) {
        status := resource.NewStatus()
        curUID, curGID, err := fileOwnership(o.Destination)
        if err != nil {
                return nil, err
        }
        newUID := curUID
        newGID := curGID
        if o.UID != nil {
                newUID = *o.UID
        }
        if o.GID != nil {
                newGID = *o.GID
        }
        if newUID != curUID {
                status.AddDifference("UID", strconv.Itoa(curUID), strconv.Itoa(newUID), "")
        }
        if newUID != curUID {
                status.AddDifference("GID", strconv.Itoa(curGID), strconv.Itoa(newGID), "")
        }
        return status, os.Chown(o.Destination, newUID, newGID)
}

Running our resource

After finishing Apply we can build Converge and see our resource running.

[email protected]:~/go/src/github.com/asteris-llc/converge$ sudo ./converge plan --local samples/fileOwner.hcl
2016-12-29T15:04:10-06:00 |WARN| setting session-local token    token=607119ee-bb1e-45cd-b474-abe618cd7825
2016-12-29T15:04:10-06:00 |WARN| no SSL config in use, server will accept unencrypted connections   component=rpc
2016-12-29T15:04:10-06:00 |INFO| serving GRPC   addr=http://127.0.0.1:47740 component=rpc
2016-12-29T15:04:10-06:00 |INFO| serving REST   addr=http://127.0.0.1:47740 component=rpc
2016-12-29T15:04:10-06:00 |WARN| skipping module verification   component=client
2016-12-29T15:04:10-06:00 |INFO| got status component=client file=samples/fileOwner.hcl id=root/file.owner.owner run=STARTED stage=PLAN
2016-12-29T15:04:10-06:00 |INFO| got status component=client file=samples/fileOwner.hcl id=root run=STARTED stage=PLAN

root/file.owner.owner:
 Messages:
 Has Changes: yes
 Changes:
  GID: "501" => "0"
  UID: "501" => "0"

Summary: 0 errors, 1 changes

Additional Considerations

Testing and cross-platform suport were not emphasized during this article because we wished to focus on the resource authors API. Both testing and cross-platform support are critical aspects of production-ready modules however.

Testing

Converge resources should be well tested. By convention we avoid mocking except when directly interacting with the outside world through network or filesystem IO, system calls, or any platform-dependent library calls.

Cross-Platform Support

Converge supports many different hardware architectures and operating systems. Resources should strive for maximum portability across all of these different configurations. If you are implementing a resource that will not work across all systems, you should use build flags to provide an implementation for unsupported platforms that will return an appropriate error to indicate to the user that the platform is not supported.

Conclusion

Creating custom Converge resources is fairly straightforward and can offer a much nicer user experience compared to using simple task resources. Download Converge and take a look at the existing resources, or submit a PR with support for your own favorite feature. As always, we are available to support and discuss the details of resource design and implementation on the converge slack.