Disclaimer: I am a Senior Engineer on the team building MAAS at Canonical. Opinions expressed are solely my own.

Some of you might not know, but MAAS is an evolution of Ubuntu Orchestra which originated back in 2011. Every now and then it’s good to revisit old design decisions, especially if they could be solved more efficiently today. New languages and frameworks have emerged and user needs have evolved as well. That’s why I am always looking for ways we can improve MAAS. Let’s try to model power drivers differently.

As of MAAS version 3.6 all power drivers are part of the Rack Controller (and it seems that the directory under source tree reflects the old ubuntu-orchestra-provisioning-server)

MAAS supports various power drivers: IPMI, Redfish, HP Moonshot, OpenBMC, Intel AMT, Webhook and others. But what if your power driver is not among the supported ones? The current approach is either to fork MAAS or use the Webhook driver. By the way, there is a nice maaspower project by Giles Knap if you want to go the webhook-based approach.

This project implements MAAS power control for machines that do not already have a BMC type supported by MAAS. It uses webhooks to control any number of remote switching devices that can power cycle such machines.

Wouldn’t it be better if MAAS offered better support for adding custom power drivers?

To get an idea of how this could be done, we can look at how other successful open source projects have approached extensibility. There are a lot of nice architectural ideas described in the The Architecture of Open Source Applications book.

AOSA Volume

Here is what is written about Asterisk:

The core application acts primarily as a module registry. It also has code that knows how to connect all of the abstract interfaces together to make phone calls work. The concrete implementations of these interfaces are registered by loadable modules at runtime.

When the module loads, it registers all of its implementations of component abstractions with the Asterisk core application.

Sounds pretty similar in terms of architecture, doesn’t it? And frankly speaking MAAS already provides power driver registry; it is just that in order to add a new driver we have to modify MAAS codebase:

class PowerDriverRegistry(Registry):
    """Registry for power drivers."""

    @classmethod
    def get_schema(cls, detect_missing_packages=True):
        """Returns the full schema for the registry."""
        # Pod drivers are not included in the schema because they should
        # be used through `PodDriverRegistry`, except when a power action
        # is to be performed.
        schemas = [
            driver.get_schema(detect_missing_packages=detect_missing_packages)
            for _, driver in cls
        ]
        validate(schemas, JSON_POWER_DRIVERS_SCHEMA)
        return schemas

As of recent versions (especially starting with MAAS 3.3 and beyond), we have been gradually introducing Go into the codebase. The move isn’t a full rewrite but rather a selective adoption of Go for components where it offers significant advantages.

So how can we improve situation around power drivers? Based on my previous experience hashicorp/go-plugin fits here just well. If you are new to it, I recommend reading Eli Bendersky’s blog post about plugins in Go.

So to start with we need an interface that will define behaviour and contract all power driver plugins must implement. The plugin is expected to receive driver-specific options and return a result indicating the new power state.

type PowerDriver interface {
	PowerOn(opts map[string]interface{}) (PowerOnResult, error)
}

A bit of a required boilerplate

This is the implementation of plugin.Plugin so we can serve/consume this

type PowerDriverPlugin struct {
	Impl PowerDriver
}

func (p *PowerDriverPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
	return &PowerDriverRPCServer{Impl: p.Impl}, nil
}

func (*PowerDriverPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) {
	return &PowerDriverRPCClient{client: c}, nil
}

Client implementation that we will use to talk over RPC

var _ PowerDriver = &PowerDriverRPCClient{}

type PowerDriverRPCClient struct{ client *rpc.Client }

func (p *PowerDriverRPCClient) PowerOn(opts map[string]interface{}) (PowerOnResult, error) {
	var res PowerOnResult
	err := p.client.Call("Plugin.PowerOn", opts, &res)
	return res, err
}

RPC server that needs to be started on the driver side

type PowerDriverRPCServer struct {
	Impl PowerDriver
}

func (s *PowerDriverRPCServer) PowerOn(opts map[string]interface{}, res *PowerOnResult) error {
	v, err := s.Impl.PowerOn(opts)
	*res = v
	return err
}

Writing the driver itself is simple, all you need to do is import a package and implement required interface:

type driver struct{}

func (d *driver) PowerOn(opts map[string]interface{}) (power.PowerOnResult, error) {
    // Execute logic that talks to your BMC
	return power.PowerOnResult{}, nil
}

func main() {
	plugin.Serve(&plugin.ServeConfig{
		HandshakeConfig: power.Handshake,
		Plugins: map[string]plugin.Plugin{
			"power-driver": &power.PowerDriverPlugin{
				Impl: &driver{},
			},
		},
	})
}

So the idea is that users can build their own custom power drivers in Go by just implementing the required interface. As for other languages: it is possible to create plugins that would use gRPC. Once the plugin is created, it can be uploaded to the Region controller with a JSON schema definition (so MAAS can build a proper UI and validators for driver parameters).

How MAAS will consume the driver? We’ll need some sort of a power manager to execute the provided binary. The very naive implementation might look like this:

type PowerOnParam struct {
	DriverOpts map[string]interface{} `json:"driver_opts"`
	DriverType string                 `json:"driver_type"`
}

type Manager struct {
}

func NewManager() *Manager {
	return &Manager{}
}

func (m *Manager) PowerOn(ctx context.Context,
	param PowerOnParam) (common.PowerOnResult, error) {
	driver, err := m.driver(param.DriverType)
	if err != nil {
		return common.PowerOnResult{}, err
	}
	return driver.PowerOn(param.DriverOpts)
}

func (m *Manager) driver(driverType string) (common.PowerDriver, error) {
	err := m.download(driverType)
	if err != nil {
		return nil, err
	}
	pluginPath := filepath.Join(defaultPluginDir, driverType)

	client := plugin.NewClient(&plugin.ClientConfig{
		HandshakeConfig: common.Handshake,
		// We have a one-to-one match, one binary and one power-driver plugin.
		Plugins: map[string]plugin.Plugin{
			"power-driver": &common.PowerDriverPlugin{},
		},
		Cmd: exec.Command(pluginPath),
		AllowedProtocols: []plugin.Protocol{
			plugin.ProtocolNetRPC,
		},
	})

	cp, err := client.Client()
	if err != nil {
		return nil, err
	}

	raw, err := cp.Dispense("power-driver")
	if err != nil {
		return nil, err
	}

	return raw.(common.PowerDriver), nil
}

How does it work? The idea is very simple: when your plugin is fetched and executed, it will start listening on the socket and wait for the incoming RPC. If you run it with a proper handshake config, you can see this information:

{"@level":"debug","@message":"plugin address","@timestamp":"2025-05-26T15:18:05.201605+03:00","address":"/tmp/plugin3473286644","network":"unix"}
1|1|unix|/tmp/plugin3473286644|netrpc|

Thats how a plugin-based model can bring flexibility to MAAS power drivers.

But what if your driver requires some third-party tools to be available on the system? That’s where things get a bit more complicated but still solvable.