When a block stands for something real, a network, a container, a file on
disk, it needs a lifecycle. In xcl that lifecycle belongs to a provider:
a type with Create, Read, Changed, Update and Destroy methods that
xcl calls as a configuration is applied and destroyed. Providers are packaged
in plugins, and a plugin provides as many block types as it likes.
On each apply, xcl first Reads every entity it saved last time and asks
its provider’s Changed whether to leave it alone, Update it in place or
replace it. A replacement is a Destroy followed by a Create. Changed
and Update are both told which of the entity’s settings changed since the
last apply, and which of the entities it depends on the same apply will
update or replace, so a provider can decide, and act, from exactly what
changed. Every entity is decided before anything changes,
then the replaced ones are destroyed, dependents first, and finally
entities are created and updated in dependency order. See
Unchanged, update or replace for the full rules.
This example applies a configuration with three block types, provided by two plugins that do real work:
- The Docker plugin is an external plugin, compiled to its own binary,
docker-plugin. xcl starts it as a separate process and calls it over gRPC. It providesdocker "network"anddocker "container", and creates real Docker networks and containers through the Docker Engine SDK. It updates a running container’s networks in place, moving it between networks and reattaching it to a network that was replaced, without creating a new container. - The template plugin is an in-process plugin,
TemplatePlugin, compiled into the program. It providestemplate, a block type with no subtype, and renders a Handlebars template to a file. Here it renders the script the container runs when it starts, as well as a welcome file.
Because the Docker plugin creates real containers, the example needs a Docker
engine, reached through DOCKER_HOST or the default socket. The source is in
example/plugin.
To start a plugin of your own in the same layout, use the
plugin template.
A computed value crosses from one plugin to the other. Docker only assigns a
container its address when the container is created, so ip_address is a
computed field: the Docker plugin’s provider fills it in, in the plugin
process. The template reads that address in its variables, so xcl creates
the container first and hands the address, already resolved, to the
in-process template provider, which writes it into the rendered file.
Plugins are written in Go for now. The Docker plugin built here works from
both Go and Python applications; the program section below shows the same
xcl-docker tool in Go and in Python.
The configuration
The configuration is a network, an init script rendered by a template, a container on the network that runs the script when it starts, and a template that describes where the container can be reached:
# The configuration the plugin example applies. The docker blocks come from# the external Docker plugin, the template block from the in-process template# plugin.## Variables can be overridden with environment variables prefixed XCL_VAR_,# i.e. XCL_VAR_output_dir.
# output_dir is where the rendered templates are writtenvariable "output_dir" { default = "./build/rendered"}
# A Docker bridge network named app. The Docker plugin sets the computed# docker_id when it creates it.docker "network" "app" { subnet = "10.42.0.0/24"}
# A script the web container runs when it starts. The mode makes it# executable, which the nginx image needs before it runs a script from its# entrypoint directory. Editing the source renders the file again and leaves# the container alone; moving the destination replaces the template, and with# it the container that mounts the script.template "init" { source = <<-EOF #!/bin/sh echo "web joined the {{network}} network" > /usr/share/nginx/html/init.txt EOF
destination = "${variable.output_dir}/init.sh" mode = "0755"
variables = { network = docker.network.app.meta.name }}
# An nginx container named web on the app network. Referring to the network# orders the blocks, web is always created after app and destroyed before it.# Changing its networks moves the running container in place; changing its# image, command, environment or init script creates a new one.docker "container" "web" { image = "nginx:1.27-alpine"
# mounted into the nginx entrypoint directory, run when the container starts init_script = template.init.destination
# a nested block, repeated once per network the container joins network { name = docker.network.app.meta.name aliases = ["web"] }}
# template has no subtype, so it takes just a name. Its variables read the# container's ip_address, which the Docker plugin computes when it creates the# container, so a value crosses from the external plugin to the in-process# one.template "welcome" { source = <<-EOF Welcome to the {{network}} network. The web container answers at http://{{address}}/ EOF
destination = "${variable.output_dir}/welcome.txt"
variables = { network = docker.network.app.meta.name address = docker.container.web.ip_address }}The types
The Docker plugin’s block types are plain Go structs, laid out as an
application’s are: each has a Meta field, which receives the block’s
metadata, its name among it. A type reads its labels through label fields or
Meta, and one with neither is a
singleton. The plugin describes each type to
xcl from its tags, so every field carries an xcl tag, its json name is its
xcl name, and a computed field is also optional; a type that breaks one of
these fails to register, with an error naming the field. The Docker plugin keeps its
block types in a package of their own, entities, which
holds no providers and imports no Docker library, so a program can import it
to read back the blocks it applied without pulling in the plugin. The container
has two computed fields, docker_id and ip_address, marked computed in
their tags. They can not be set in configuration, only a provider can fill
them in. Its nested network block is a slice of NetworkAttachment, one
entry for each network block the container has, and its optional
init_script is the path of a script it runs when it starts:
// Container defines the block type docker "container", a Docker container// named after the blocktype Container struct { Meta types.Meta
// Image is the image the container runs, it is pulled when Docker does // not have it Image string `xcl:"image" json:"image"`
// Command overrides the image's command Command []string `xcl:"command,optional" json:"command,omitempty"`
// Environment holds the container's environment variables Environment map[string]string `xcl:"environment,optional" json:"environment,omitempty"`
// InitScript is the path of a script the container runs when it starts, // mounted read-only into the nginx image's entrypoint directory. It is // usually a template's destination, so replacing that template replaces // the container. InitScript string `xcl:"init_script,optional" json:"init_script,omitempty"`
// Networks are the networks the container joins, one nested network block // each. The container is created on the first and connected to the rest. Networks []NetworkAttachment `xcl:"network,block" json:"network,omitempty"`
// DockerID is computed, the ID Docker gives the container DockerID string `xcl:"docker_id,optional,computed" json:"docker_id,omitempty"`
// IPAddress is computed, the container's address on its first network IPAddress string `xcl:"ip_address,optional,computed" json:"ip_address,omitempty"`}
// NetworkAttachment is a nested network block of a docker "container" blocktype NetworkAttachment struct { // Name is the Docker name of the network, i.e. docker.network.app.meta.name Name string `xcl:"name" json:"name"`
// Aliases are extra names the container is known by on the network Aliases []string `xcl:"aliases,optional" json:"aliases,omitempty"`}entities.Network is built the same way, with a subnet and a computed
docker_id.
The Docker plugin
A plugin embeds plugins.PluginBase and registers the block types it
provides in Init, calling RegisterResourceProvider once per type with
the type’s Go struct and its provider. The Docker plugin’s Plugin type sits
in the root package of its module, docker, so it can be imported as well as
served. Its Init builds one Docker client first, wraps it once in the
container task layer, and gives that to both providers. The logger Init
receives is plugin scoped: it is for messages written outside a provider
call, and xcl names the plugin as the source of everything it logs.
// Init builds the Docker client from the environment, and the container task// layer over it once, then registers both block types, each with a provider// sharing that task layer. Building them does not contact Docker, the// providers do when they are called.//// logger is plugin scoped, for messages written outside a provider call like// this one. xcl adds provider=<block subtype> to what each provider's Init// logs. During a call the providers log through plugins.Logger(ctx) instead,// which xcl binds to the resource and step being worked on.func (p *Plugin) Init(logger logger.Logger, state plugins.State) error { client, err := dockerclient.New() if err != nil { return err }
tasks := containers.New(client)
logger.Debug("registering block types", "block_types", "docker.network, docker.container")
err = plugins.RegisterResourceProvider( &p.PluginBase, logger, state, "docker", "network", &entities.Network{}, providers.NewNetworkProvider(tasks), ) if err != nil { return err }
return plugins.RegisterResourceProvider( &p.PluginBase, logger, state, "docker", "container", &entities.Container{}, providers.NewContainerProvider(tasks), )}The providers
The providers, in the providers package, never use the Docker SDK and
import no Docker library. The Docker client layer lives under client, in two
packages. client/docker holds Docker, a narrow interface listing only the
SDK methods the plugin calls, each with the SDK’s own signature, alongside
New, which builds the real client from the environment, and Ping, which
checks an engine answers. The real SDK *client.Client satisfies the
interface with no adapter:
// Docker is the part of the Docker Engine SDK client the task layer uses. Each// method has the same signature as the SDK's, so *client.Client satisfies it.type Docker interface { Ping(ctx context.Context) (types.Ping, error)
NetworkCreate(ctx context.Context, name string, options network.CreateOptions) (network.CreateResponse, error) NetworkInspect(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) NetworkRemove(ctx context.Context, networkID string) error NetworkConnect(ctx context.Context, networkID, containerID string, config *network.EndpointSettings) error NetworkDisconnect(ctx context.Context, networkID, containerID string, force bool) error
ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) ImagePull(ctx context.Context, ref string, options image.PullOptions) (io.ReadCloser, error)
ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error) ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)}
// the real SDK client satisfies Docker without an adaptervar _ Docker = (*dockerclient.Client)(nil)client/containers is the task layer built on it, the one place the plugin
turns what its providers want done into SDK calls. It speaks in its own data
types, NetworkSpec, ContainerSpec and Attachment, so the providers hold
a containers.Tasks and never see a Docker type. Docker’s “not found” errors
come back matching containers.ErrNotFound, so a provider checks for them
with errors.Is:
// Tasks are the Docker tasks the plugin's providers performtype Tasks interface { // CreateNetwork creates a labelled, attachable bridge network and // returns its Docker ID CreateNetwork(ctx context.Context, name string, spec NetworkSpec) (string, error)
// NetworkContainers returns the IDs of the containers attached to the // network, sorted NetworkContainers(ctx context.Context, networkID string) ([]string, error)
// RemoveNetwork removes the network RemoveNetwork(ctx context.Context, networkID string) error
// ConnectNetwork attaches the container to the network with aliases ConnectNetwork(ctx context.Context, network, containerID string, aliases []string) error
// DisconnectNetwork force-detaches the container from the network DisconnectNetwork(ctx context.Context, network, containerID string) error
// EnsureImage pulls the image unless Docker already has it EnsureImage(ctx context.Context, ref string) error
// CreateContainer creates the container and returns its Docker ID CreateContainer(ctx context.Context, spec ContainerSpec) (string, error)
// StartContainer starts the container StartContainer(ctx context.Context, containerID string) error
// StopContainer stops the container StopContainer(ctx context.Context, containerID string) error
// RemoveContainer force-removes the container RemoveContainer(ctx context.Context, containerID string) error
// ContainerAddresses returns the container's address on each network it // is attached to, keyed by network name ContainerAddresses(ctx context.Context, containerID string) (map[string]string, error)}Each interface has a mock generated from it, so both layers are unit tested
without Docker: the providers’ tests use a mock of Tasks, and the task
layer’s own tests a mock of Docker.
A provider is typed on the block’s Go type, so every method receives an
*entities.Network with all its references already resolved. Create asks
the task layer for a bridge network named after the block, labelled so the
objects the example made can be found with docker network ls, and records
the ID Docker gives it in the computed DockerID. Destroy removes the
network by that ID, force-disconnecting any container still attached first,
since Docker refuses to remove a network with active endpoints. A network or
container that is already gone matches containers.ErrNotFound and counts as
removed. The provider embeds DefaultChanged and overrides Changed for the
one thing Docker cannot change in place, as
Update or replace below shows.
Each method logs through plugins.Logger(ctx), the logger xcl puts in the
call’s context. It is already bound to the entity, its type, its file and
the step, so the provider passes only what it has to say. That works the same
inside the plugin process as it does in-process: the host gives each message
its call’s entity and step.
// Create asks Docker for a labelled bridge network named after the block, and// records the ID Docker gives itfunc (p *networkProvider) Create(ctx context.Context, n *entities.Network) (*entities.Network, error) { id, err := p.tasks.CreateNetwork(ctx, n.Meta.Name, containers.NetworkSpec{ Subnet: n.Subnet, Labels: labels(n.Meta), }) if err != nil { return nil, fmt.Errorf("unable to create network %s: %w", n.Meta.Name, err) }
n.DockerID = id plugins.Logger(ctx).Info("created network", "name", n.Meta.Name, "id", n.DockerID)
return n, nil}
// Destroy removes the network, a network that is already gone counts as// removed. Every container still attached is force-disconnected first, so// rebuilding a network never fails on its active endpoints; the container's// own Update attaches it again to the network that replaces this one.func (p *networkProvider) Destroy(ctx context.Context, n *entities.Network, force bool) error { // sorted, so the containers are always detached in the same order attached, err := p.tasks.NetworkContainers(ctx, n.DockerID) if errors.Is(err, containers.ErrNotFound) { plugins.Logger(ctx).Info("network already removed", "name", n.Meta.Name, "id", n.DockerID) return nil }
if err != nil { return fmt.Errorf("unable to inspect network %s: %w", n.Meta.Name, err) }
for _, containerID := range attached { err := p.tasks.DisconnectNetwork(ctx, n.DockerID, containerID) if err != nil && !errors.Is(err, containers.ErrNotFound) { return fmt.Errorf("unable to disconnect container %s from network %s: %w", containerID, n.Meta.Name, err) }
plugins.Logger(ctx).Info("disconnected container from network", "name", n.Meta.Name, "container", containerID) }
err = p.tasks.RemoveNetwork(ctx, n.DockerID) if err != nil && !errors.Is(err, containers.ErrNotFound) { return fmt.Errorf("unable to remove network %s: %w", n.Meta.Name, err) }
plugins.Logger(ctx).Info("destroyed network", "name", n.Meta.Name, "id", n.DockerID)
return nil}The container provider’s Create does more. It pulls the image when Docker
does not have it, creates the container on its first network with its init
script mounted, then starts it and asks for its address. It describes the
container as a containers.ContainerSpec, resolving the init script to an
absolute path. The two computed fields are only set once everything has
worked, and a container that was created but failed to start
is removed again, so a failed Create leaves nothing behind:
// Create pulls the image when Docker does not have it, creates the container// on its first network, connects the rest, starts it and records its ID and// its address on the first network. When a step after the container was// created fails, the container is removed again.func (p *containerProvider) Create(ctx context.Context, c *entities.Container) (*entities.Container, error) { err := p.tasks.EnsureImage(ctx, c.Image) if err != nil { return nil, err }
spec := containers.ContainerSpec{ Name: c.Meta.Name, Image: c.Image, Command: c.Command, Environment: c.Environment, Labels: labels(c.Meta), }
if len(c.Networks) > 0 { first := c.Networks[0] spec.Network = &containers.Attachment{Name: first.Name, Aliases: first.Aliases} }
if c.InitScript != "" { path, err := filepath.Abs(c.InitScript) if err != nil { return nil, fmt.Errorf("unable to mount the init script of container %s: %w", c.Meta.Name, err) }
spec.InitScript = path }
id, err := p.tasks.CreateContainer(ctx, spec) if err != nil { return nil, fmt.Errorf("unable to create container %s: %w", c.Meta.Name, err) }
address, err := p.startContainer(ctx, c, id) if err != nil { removeErr := p.tasks.RemoveContainer(ctx, id) return nil, errors.Join(err, removeErr) }
c.DockerID = id c.IPAddress = address plugins.Logger(ctx).Info("created container", "name", c.Meta.Name, "image", c.Image, "ip_address", c.IPAddress)
return c, nil}The task layer turns that spec into the SDK’s container, network and host configuration. The init script is bind-mounted read-only into the nginx image’s entrypoint directory, where nginx runs every executable script before it starts, so the container runs it each time it starts:
// initScriptPath is where the init script is mounted. The nginx image's// entrypoint runs every executable script in /docker-entrypoint.d before it// starts nginx.const initScriptPath = "/docker-entrypoint.d/90-xcl-init.sh"
// CreateContainer creates the container on its network, with its environment// sorted and its init script mounted read-only at initScriptPathfunc (t *tasks) CreateContainer(ctx context.Context, spec ContainerSpec) (string, error) { config := &container.Config{ Image: spec.Image, Cmd: spec.Command, Env: environment(spec.Environment), Labels: spec.Labels, }
networking := &network.NetworkingConfig{} if spec.Network != nil { networking.EndpointsConfig = map[string]*network.EndpointSettings{ spec.Network.Name: {Aliases: spec.Network.Aliases}, } }
hostConfig := &container.HostConfig{} if spec.InitScript != "" { hostConfig.Binds = []string{spec.InitScript + ":" + initScriptPath + ":ro"} }
resp, err := t.client.ContainerCreate(ctx, config, hostConfig, networking, nil, spec.Name) if err != nil { return "", notFound(err) }
return resp.ID, nil}startContainer connects any further networks, starts the container and
reads its address on the first network back from ContainerAddresses:
// startContainer connects the created container to its further networks,// starts it and returns its address on its first networkfunc (p *containerProvider) startContainer(ctx context.Context, c *entities.Container, id string) (string, error) { for _, attachment := range c.Networks[min(1, len(c.Networks)):] { err := p.tasks.ConnectNetwork(ctx, attachment.Name, id, attachment.Aliases) if err != nil { return "", fmt.Errorf("unable to connect container %s to network %s: %w", c.Meta.Name, attachment.Name, err) } }
err := p.tasks.StartContainer(ctx, id) if err != nil { return "", fmt.Errorf("unable to start container %s: %w", c.Meta.Name, err) }
addresses, err := p.tasks.ContainerAddresses(ctx, id) if err != nil { return "", fmt.Errorf("unable to inspect container %s: %w", c.Meta.Name, err) }
return firstAddress(c, addresses), nil}Update or replace
DefaultChanged never answers replace, but some of what a Docker network or
container is made from is fixed when Docker creates it. So both providers
override Changed. A network cannot move to a new address range, so a change
within subnet replaces it. Any other change to its settings answers update,
which needs no Docker call, and when nothing changed the decision is left to
DefaultChanged:
// networkReplaceSettings are the settings Docker fixes when it creates a// network, a change to any of them needs a new networkvar networkReplaceSettings = []entity.Path{ entity.Path{}.Attribute("subnet"),}
// Changed decides from what it is told. It answers replace when the subnet// changed: Docker can not move a network to a new address range, the network// has to be removed and created again. Any other change to its settings// answers update, which needs no Docker call. When nothing changed the// decision is left to DefaultChanged.func (p *networkProvider) Changed(ctx context.Context, old *entities.Network, new *entities.Network, changes []entity.PropertyChange, dependencies []entity.DependencyChange) (entity.Change, error) { for _, change := range changes { for _, setting := range networkReplaceSettings { if change.Within(setting) { return entity.Replace, nil } } }
if len(changes) > 0 { return entity.Update, nil }
return p.DefaultChanged.Changed(ctx, old, new, changes, dependencies)}The container decides only from what it is told: changes, the settings
that differ from the last apply, and dependencies, the entities it depends
on that this apply will update or replace. Docker fixes a container’s image,
command, environment and mounts when it creates it, so a change within
image, command, environment or init_script replaces it. So does a
replaced dependency that isn’t a Docker network: the init script is
template.init.destination, and when that template is replaced the container
that mounts it is replaced too. Any other change to its own settings, such as
a network block added, removed or given new aliases, answers update, and so
does a network it depends on being updated or replaced. A dependency that is
only updated, such as the init template rendering new content to the same
file, is left to DefaultChanged, which leaves the container alone:
// replaceSettings are the settings Docker fixes when it creates a container,// a change to any of them needs a new containervar replaceSettings = []entity.Path{ entity.Path{}.Attribute("image"), entity.Path{}.Attribute("command"), entity.Path{}.Attribute("environment"), entity.Path{}.Attribute("init_script"),}
// Changed decides from what it is told. It answers replace when the image,// command, environment or init script changed, since Docker fixes them when// it creates a container, or when a dependency other than a Docker network is// replaced, such as the template that renders the init script. A change to// the container's networks, or a network that is updated or replaced, is// handled in place by Update, which moves the running container between// networks. Any other change to its settings answers update. A dependency// that is only updated, such as the init script's template rendering new// content, leaves the container alone: the decision is left to// DefaultChanged.func (p *containerProvider) Changed(ctx context.Context, old *entities.Container, new *entities.Container, changes []entity.PropertyChange, dependencies []entity.DependencyChange) (entity.Change, error) { for _, change := range changes { for _, setting := range replaceSettings { if change.Within(setting) { return entity.Replace, nil } } }
for _, dependency := range dependencies { if dependency.Change == entity.Replace && !isNetworkAddress(dependency.Address) { return entity.Replace, nil } }
if len(changes) > 0 { return entity.Update, nil }
for _, dependency := range dependencies { if isNetworkAddress(dependency.Address) { return entity.Update, nil } }
return p.DefaultChanged.Changed(ctx, old, new, changes, dependencies)}Update moves the running container between networks, working only from
what it is told, never from what Docker reports. c is the configuration as
it is now and changes is how it got there, so previousAttachments, in
attachments.go, puts back each network change’s Before to rebuild the network blocks the
container had after the last apply. Update disconnects the networks that
went away or whose aliases changed, connects the new ones, connects again
every network whose dependency was replaced, and reads the container’s new
address once if anything moved:
// Update moves the running container between networks, working only from what// it is told, never from what Docker reports://// - the previous attachments are the configured ones with each network// change's previous value put back// - networks that went away, or whose aliases changed, are disconnected// - networks that are new, or whose aliases changed, are connected// - networks that were replaced are connected again, since destroying a// network detaches every container//// When it moved the container it reads the container's new address, an// output, not previous state.func (p *containerProvider) Update(ctx context.Context, c *entities.Container, changes []entity.PropertyChange, dependencies []entity.DependencyChange) (*entities.Container, error) { previous, err := previousAttachments(c.Networks, changes) if err != nil { return nil, fmt.Errorf("unable to work out the previous networks of container %s: %w", c.Meta.Name, err) }
moved := false
for _, attachment := range previous { current, ok := findAttachment(c.Networks, attachment.Name) if ok && equalStrings(current.Aliases, attachment.Aliases) { continue }
// a network that was removed or rebuilt has already detached it err := p.tasks.DisconnectNetwork(ctx, attachment.Name, c.DockerID) if err != nil && !errors.Is(err, containers.ErrNotFound) { return nil, fmt.Errorf("unable to disconnect container %s from network %s: %w", c.Meta.Name, attachment.Name, err) }
plugins.Logger(ctx).Info("disconnected container from network", "name", c.Meta.Name, "network", attachment.Name) moved = true }
connected := map[string]bool{} for _, attachment := range c.Networks { before, ok := findAttachment(previous, attachment.Name) if ok && equalStrings(before.Aliases, attachment.Aliases) { continue }
if err := p.connect(ctx, c, attachment); err != nil { return nil, err }
connected[attachment.Name] = true moved = true }
for _, dependency := range dependencies { if dependency.Change != entity.Replace || !isNetworkAddress(dependency.Address) { continue }
attachment, ok := findAttachment(c.Networks, networkName(dependency.Address)) if !ok || connected[attachment.Name] { continue }
if err := p.connect(ctx, c, attachment); err != nil { return nil, err }
connected[attachment.Name] = true moved = true }
if !moved { return c, nil }
addresses, err := p.tasks.ContainerAddresses(ctx, c.DockerID) if err != nil { return nil, fmt.Errorf("unable to inspect container %s: %w", c.Meta.Name, err) }
c.IPAddress = firstAddress(c, addresses)
return c, nil}Destroying a network detaches every container, so a network that is replaced
leaves the container on nothing until Update connects it again. The
network’s Destroy, shown above, force-disconnects every
container still attached before it removes the network, because Docker
refuses to remove a network with active endpoints, and the container’s
Update then attaches it to the network that replaces it.
The network’s own Update returns it unchanged: every setting Docker cannot
change in place answers replace in its Changed, so it is only reached for
changes that need no Docker call.
The external binary
The Docker plugin is ordinary Go, written against the same plugins package
an in-process plugin uses. What makes it external is that it is built as a
program of its own. example/plugin/plugins/docker is a Go module of its
own, with its own go.mod and Makefile. The Plugin type is in
plugin.go, in the module’s root package, docker, and cmd/docker/main.go
is a main that hands it to plugins.Serve. The block types are in
entities, the providers in providers and the Docker client layer in
client. The host program imports only plugins/docker/entities, to read
the blocks it applied, so its build includes no Docker library. xcl does the
rest: the handshake with the host that started the process and the gRPC
transport, so the plugin never imports go-plugin. make build in
example/plugin compiles it to build/docker-plugin, next to xcl-docker,
and make build in plugins/docker builds it on its own:
// main serves the plugin to the host that started this processfunc main() { plugins.Serve(&docker.Plugin{})}The template plugin
The template plugin is compiled into the program. Its block type has no
subtype: Init registers template with an empty subtype, so a block is
written template "welcome" {}, with just a name, and addressed
template.welcome. The example has two: template.init renders the
container’s init script and template.welcome the welcome file. xcl names an in-process plugin after its Go type, so its
log lines come from TemplatePlugin.
// Init registers template with no subtype, so a block is written// template "<name>" {} and addressed template.<name>func (p *TemplatePlugin) Init(logger logger.Logger, state plugins.State) error { logger.Debug("registering block types", "block_types", "template")
return plugins.RegisterResourceProvider( &p.PluginBase, logger, state, "template", "", &Template{}, &provider{}, )}By the time the provider’s Create is called, every variable is resolved,
including the address the Docker plugin computed in another process. It
renders the template and writes the file, its Update renders it again, and
its Destroy removes the file:
// Create renders the template with its variables and writes the result to the// destinationfunc (p *provider) Create(ctx context.Context, t *Template) (*Template, error) { err := render(t) if err != nil { return nil, err }
plugins.Logger(ctx).Info("rendered template", "destination", t.Destination)
return t, nil}Unlike the Docker settings, a new source or new variables can be handled in
place, so the template provider leaves those to DefaultChanged, which
answers update. Only a new destination replaces it, so that its Destroy
removes the file at the old path:
// Changed answers replace when the destination changes: Update renders to the// new path but would leave the file at the old one behind, so the template is// destroyed, removing its file, and created again. A change to the source or// variables is left to DefaultChanged, which answers update, and Update// renders the file again.func (p *provider) Changed(ctx context.Context, old *Template, new *Template, changes []entity.PropertyChange, dependencies []entity.DependencyChange) (entity.Change, error) { if old.Destination != new.Destination { return entity.Replace, nil }
return p.DefaultChanged.Changed(ctx, old, new, changes, dependencies)}render parses the source with the Handlebars library xcl’s own template
function uses, with the same quote and trim helpers, executes it with the
block’s variables and writes the file with the block’s mode:
// render renders t's source with its variables and writes it to its// destination with its mode, creating the destination's parent directoriesfunc render(t *Template) error { mode, err := fileMode(t.Mode) if err != nil { return err }
tmpl, err := raymond.Parse(t.Source) if err != nil { return fmt.Errorf("unable to parse template: %w", err) }
// the helpers are the ones xcl's template function offers. They return // SafeString because Handlebars HTML-escapes what a helper returns // otherwise, which would write " for the quotes quote adds. tmpl.RegisterHelpers(map[string]any{ "quote": func(in string) raymond.SafeString { return raymond.SafeString(fmt.Sprintf(`"%s"`, in)) }, "trim": func(in string) raymond.SafeString { return raymond.SafeString(strings.TrimSpace(in)) }, })
out, err := tmpl.Exec(t.Variables) if err != nil { return fmt.Errorf("unable to render template: %w", err) }
err = os.MkdirAll(filepath.Dir(t.Destination), 0755) if err != nil { return fmt.Errorf("unable to create the directory for %s: %w", t.Destination, err) }
err = os.WriteFile(t.Destination, []byte(out), mode) if err != nil { return fmt.Errorf("unable to write %s: %w", t.Destination, err) }
// WriteFile only sets the mode of a file it creates, a file rendered // before keeps its old mode otherwise err = os.Chmod(t.Destination, mode) if err != nil { return fmt.Errorf("unable to set the mode of %s: %w", t.Destination, err) }
return nil}A template’s optional mode sets the rendered file’s permissions as an octal
string, "0644" when it is left out. The init script needs "0755": the
nginx image only runs an executable script from its entrypoint directory.
// Template defines the block type template, a Handlebars template rendered to// a filetype Template struct { // Meta is the block's metadata, which the status view reads Meta types.Meta
// Source is the Handlebars template text, use file() to read it from a // file Source string `xcl:"source" json:"source"`
// Destination is the path of the file the template is rendered to, its // parent directories are created when they do not exist Destination string `xcl:"destination" json:"destination"`
// Variables are the values the template refers to, i.e. {{address}} Variables map[string]string `xcl:"variables,optional" json:"variables,omitempty"`
// Mode is the rendered file's permissions as an octal string, i.e. // "0755" for a script that is run. It defaults to "0644". Mode string `xcl:"mode,optional" json:"mode,omitempty"`}The template "init" block in the configuration renders the script with that
mode, and the container’s init_script reads its destination:
template "init" { source = <<-EOF #!/bin/sh echo "web joined the {{network}} network" > /usr/share/nginx/html/init.txt EOF
destination = "${variable.output_dir}/init.sh" mode = "0755"
variables = { network = docker.network.app.meta.name }}
docker "container" "web" { image = "nginx:1.27-alpine"
# mounted into the nginx entrypoint directory, run when the container starts init_script = template.init.destination
...}Because the container reads template.init.destination, the template is a
dependency of the container. Editing the script’s source renders the file
again in place: the template is updated, and the container, told only that a
dependency was updated, is left alone. Moving the destination replaces the
template, and the container, told that a dependency that isn’t a network was
replaced, is replaced with it, so the new container mounts the script from
its new path.
The program
The program is xcl-docker, a command line tool built on xcl the way a real
one would be. It has five commands, and each is a separate run of the
program:
xcl-docker apply <path>applies the configuration atpath.xcl-docker plan <path>prints what applying the configuration atpathwould change, as a diff, without changing anything.xcl-docker statusprints what the saved state holds, as a tree.xcl-docker inspect <address>prints one entity from the saved state as configuration text.xcl-docker destroyremoves everything in the saved state.
apply and destroy print nothing of their own: the events they write to
standard error end with the operation’s success or error. plan, status
and inspect print only their output.
The runs share nothing but the state, saved in ./.xcl-docker unless
--state names another directory. xcl-docker finds the Docker plugin next
to its own executable, where make build puts it, unless --plugin names
another binary. The flags are parsed with the standard library’s flag
package (argparse in Python), and main passes the command to one function
per command:
switch command { case "apply": if flags.NArg() != 1 { fmt.Fprint(stderr, "apply needs the path of the configuration to apply\n\n"+usage) return 2 }
err = applyCommand(stderr, flags.Arg(0), *dockerPlugin, *stateDir) case "plan": if flags.NArg() != 1 { fmt.Fprint(stderr, "plan needs the path of the configuration to compare\n\n"+usage) return 2 }
err = planCommand(stdout, flags.Arg(0), *dockerPlugin, *stateDir) case "status": err = statusCommand(stdout, *dockerPlugin, *stateDir) case "inspect": if flags.NArg() != 1 { fmt.Fprint(stderr, "inspect needs the address of the resource to inspect\n\n"+usage) return 2 }
err = inspectCommand(stdout, flags.Arg(0), *dockerPlugin, *stateDir) case "destroy": err = destroyCommand(stderr, *dockerPlugin, *stateDir) default: fmt.Fprintf(stderr, "unknown command %q\n\n%s", command, usage) return 2 } try: match command: case "apply": if len(args.args) != 1: print("apply needs the path of the configuration to apply\n\n" + usage, file=stderr) return 2
apply_command(stderr, args.args[0], args.plugin, args.state) case "plan": if len(args.args) != 1: print("plan needs the path of the configuration to compare\n\n" + usage, file=stderr) return 2
plan_command(stdout, args.args[0], args.plugin, args.state) case "status": status_command(stdout, args.plugin, args.state) case "inspect": if len(args.args) != 1: print("inspect needs the address of the entity to inspect\n\n" + usage, file=stderr) return 2
inspect_command(stdout, args.args[0], args.plugin, args.state) case "destroy": destroy_command(stderr, args.plugin, args.state) case _: print(f"unknown command {command!r}\n\n{usage}", file=stderr) return 2 except xcl.Error as err: # a failed operation raises, the Go version returns the error print(explain_plugin_load(err), file=stderr) return 1Every command builds its Config the same way. Plugins come from a
registry, and the example uses the local registry, which holds the plugins an
application supplies itself. Registering a plugin only records it.
RegisterPlugin takes an in-process plugin and RegisterExternalPlugin the
path to an external plugin binary, and neither starts or checks anything nor
returns an error. xcl.WithRegistry gives the registry to the Config. The
plugins load at the start of the first operation, once per Config, and each
brings its own block types with it, so nothing else needs declaring. The
state is kept in a file, which is how one run picks up what the last one
saved:
func newConfig(handler xcl.EventHandler, dockerPlugin, stateDir string) (*xcl.Config, error) { // The local registry holds plugins the application supplies itself: the // in-process plugin, which provides every block type it registers in // Init, and the external plugin binary. Registering only records a // plugin, each is loaded, and the binary started, by the first operation // that needs plugins. local := registry.NewLocal() local.RegisterPlugin(&template.TemplatePlugin{}) local.RegisterExternalPlugin(dockerPlugin)
return xcl.NewConfig( xcl.WithRegistry(local), // Keep the state in a file, so each command, a separate run of the // program, works from what the last one saved xcl.WithStatePath(stateDir), xcl.WithEventHandler(handler), // events carry nothing by default, this asks for each resource as // state records it, which is what the receiver turns back into // configuration text xcl.WithEventData(xcl.EventDataProcessed), )}def new_config(handler, docker_plugin: str, state_dir: str) -> xcl.Config: # The local registry holds plugins the application supplies itself: here # the external plugin binary. Registering only records a plugin, it is # loaded, and the binary started, by the first operation that needs # plugins. local = xcl.LocalRegistry() local.register_external_plugin(docker_plugin)
return xcl.Config( registries=[local], # Keep the state in a file, so each command, a separate run of the # program, works from what the last one saved state_path=state_dir, on_event=handler, # events carry nothing by default, this asks for each entity as # state records it, which is what the receiver turns back into # configuration text event_data="processed", )Registering an in-process plugin is not yet available from Python: Python loads plugins as external binaries, so the template plugin would be built into a binary of its own and registered with register_external_plugin as well; see the parity list.
handler is the receiver for xcl’s events. Each command that
changes something passes it the example’s pretty terminal receiver, writing
to standard error at the level in XCL_LOG_LEVEL, info by default:
func eventHandler(out io.Writer) xcl.EventHandler { return prettylog.Handler(out, prettylog.LevelFromEnv())}def event_handler(out): quiet = os.environ.get("XCL_LOG_LEVEL", "info") != "debug"
def handle(event: xcl.Event) -> None: if quiet and event.level == "debug": return
print(f"{event.operation} {event.phase} {event.resource_id} {event.message}".rstrip(), file=out)
return handleThe receiver needs nothing else from the program. Each event returns the
entity it carries with Event.Entity() (event.entity() in Python), typed by
the Config that delivered it, which is how the receiver shows each entity’s
configuration as it is created.
apply checks that a Docker engine answers before it applies anything, so a
missing engine is a clear error rather than a failed Create. It asks with
pingDocker, in engine.go, which sends the engine the same GET /_ping the
Docker SDK’s own Ping sends, but through the standard library, so the
program still needs no Docker library. Nothing stops the Docker plugin: xcl starts its process for each operation and stops it
when the operation is done:
func applyCommand(stderr io.Writer, configDir, dockerPlugin, stateDir string) error { // The Docker plugin creates real containers, check an engine answers // before applying anything if err := pingDocker(context.Background()); err != nil { return err }
c, err := newConfig(eventHandler(stderr), dockerPlugin, stateDir) if err != nil { return err }
return apply(c, configDir)}def apply_command(stderr, config_dir: str, docker_plugin: str, state_dir: str) -> None: # The Docker plugin creates real containers, check an engine answers # before applying anything ping_docker()
with new_config(event_handler(stderr), docker_plugin, state_dir) as cfg: cfg.apply(config_dir)plan changes nothing either. It prints what applying a configuration would
do to the saved state: each entity it would create, update, replace or
delete, and each value that would change. It still needs Docker, so it checks
an engine answers first: a diff reads each entity in the state
through its provider, as an apply would, so it also sees changes made to the
real containers outside xcl. Like status it prints only its output, so it
gives xcl no event handler:
func planCommand(stdout io.Writer, configDir, dockerPlugin, stateDir string) error { if err := pingDocker(context.Background()); err != nil { return err }
c, err := newConfig(nil, dockerPlugin, stateDir) if err != nil { return err }
return plan(stdout, c, configDir)}def plan_command(stdout, config_dir: str, docker_plugin: str, state_dir: str) -> None: ping_docker()
with new_config(None, docker_plugin, state_dir) as cfg: plan(stdout, cfg, config_dir)plan hands the configuration’s path to Config.Diff, which compares it with
the state and returns the changes without creating, changing or saving
anything. changes.Pieces() lays them out as text, a block per changed
entity and a summary line, and a renderer writes it. As in inspect, below,
colour is decided for the writer itself, so highlight.ANSI renders it only
when the output is a terminal and highlight.Text otherwise:
func plan(out io.Writer, c *xcl.Config, configDir string) error { changes, err := c.Diff([]string{configDir}) if err != nil { return explainPluginLoad(err) }
// as with inspect, colour is decided for out itself, so a plan written to // a file or a pipe is plain text var renderer highlight.Renderer = highlight.Text if lipgloss.NewRenderer(out).ColorProfile() != termenv.Ascii { renderer = highlight.ANSI }
_, err = out.Write(renderer.Render(changes.Pieces()))
return err}def plan(out, cfg: xcl.Config, config_dir: str) -> None: changes = cfg.diff(config_dir)
# a block per changed entity, then the summary line for entity in changes.resources: if entity.action == "unchanged": continue
print(f"{entity.action} {entity.address}", file=out) for change in entity.changes: print(f" {change.path}: {change.before!r} -> {change.after_value!r}", file=out)
s = changes.summary print( f"{s.create} to create, {s.update} to update, {s.replace} to replace, " f"{s.delete} to delete, {s.unchanged} unchanged", file=out, )Rendering a diff as text (changes.Pieces(), with or without colour) is not yet available from Python, so plan prints the entities and summary counts the xcl.Diff holds; see the parity list.
status applies nothing. Load reads the saved state into the Config
without the configuration and without calling a provider, so the lookups
answer from what apply saved. It loads the plugins first, because a saved
plugin type is read back through its plugin’s schema, but it never contacts
Docker. It prints only the tree, so it gives xcl no event handler:
func statusCommand(stdout io.Writer, dockerPlugin, stateDir string) error { c, err := newConfig(nil, dockerPlugin, stateDir) if err != nil { return err }
if err := load(c); err != nil { return err }
return status(stdout, c)}def status_command(stdout, docker_plugin: str, state_dir: str) -> None: with new_config(None, docker_plugin, state_dir) as cfg: cfg.load()
status(stdout, cfg)status draws the tree with Lip Gloss.
It reads the networks, containers and templates back from the Config.
Plugin types are held as types generated from the plugin’s schema, and the
lookup copies them into the plugin’s Go type, so reading them back looks the
same as it does without plugins. The Docker blocks are read as
entities.Network and entities.Container, the plugin’s entities package
being the only part of it the program imports:
// Plugin types are held as types generated from the plugin's schema, the // lookup copies them into the plugin's Go type networks, err := xcl.FindByType[entities.Network](c, "docker", "network") if err != nil { return nil, err } # Plugin types are held as types generated from the plugin's schema. With # None as the class each comes back as an xcl.Entity, a read-only mapping # with .meta and attribute access, i.e. network.subnet networks = cfg.find_by_type(None, "docker", "network")A type with no subtype is looked up by its type alone:
// template has no subtype, so its address has one segment before the name templates, err := xcl.FindByType[template.Template](c, "template") if err != nil { return nil, err } # template has no subtype, so its address has one segment before the name templates = cfg.find_by_type(None, "template")Each entity saved in state records what it links to in Meta.Links, every
reference in its configuration. status nests each entity under the one it
depends on that is furthest down the chain, so the template, which reads both
the network and the container, sits under the container. The colours come
from a renderer made for standard output, so they show only in a terminal.
inspect reads the state the same way, finds the entity by its address and
turns it back into configuration text with EncodeEntity. It asks for the
values the provider set, each marked, and for every reference as it was
written, so the text shows how the entity is wired to the others. In a
terminal its pieces are rendered with highlight.ANSI, so it is syntax
highlighted:
func inspect(out io.Writer, c *xcl.Config, address string) error { entity, err := c.FindResource(address) if errors.Is(err, xcl.ErrNotFound) { return fmt.Errorf("no resource %s in the saved state, xcl-docker status lists them", address) } if err != nil { return err }
text, err := xcl.EncodeEntity(entity, xcl.IncludeComputed(), xcl.ShowReferences()) if err != nil { return err }
// xcl never checks for a terminal, asking it for colour is the program's // decision. It is decided for out itself, so output to a file or a pipe // is plain text. var renderer highlight.Renderer = highlight.Text if lipgloss.NewRenderer(out).ColorProfile() != termenv.Ascii { renderer = highlight.ANSI }
_, err = out.Write(renderer.Render(highlight.Pieces(text)))
return err}def inspect(out, cfg: xcl.Config, address: str) -> None: try: entity = cfg.find(None, address) except xcl.NotFoundError: raise SystemExit(f"no entity {address} in the saved state, xcl-docker status lists them")
# the entity is an xcl.Entity, a read-only mapping of the settings the # plugin's schema declares, including the values the provider set print(entity.meta.id, file=out) for name, value in entity.items(): print(f" {name} = {value!r}", file=out)Converting a live entity to configuration text (EncodeEntity, with IncludeComputed and ShowReferences) is not yet available from Python, so inspect prints the entity’s fields instead; see the parity list. The file is not named inspect.py: a module of that name beside main.py shadows the standard library’s inspect, which dataclasses imports, and import xcl fails.
destroy needs no configuration either. Destroy works from the saved
state alone and calls each provider’s Destroy, dependents before what they
depend on:
func destroyCommand(stderr io.Writer, dockerPlugin, stateDir string) error { if err := pingDocker(context.Background()); err != nil { return err }
c, err := newConfig(eventHandler(stderr), dockerPlugin, stateDir) if err != nil { return err }
return destroy(c)}def destroy_command(stderr, docker_plugin: str, state_dir: str) -> None: ping_docker()
with new_config(event_handler(stderr), docker_plugin, state_dir) as cfg: cfg.destroy()A registered plugin that fails to load, such as a binary that was never
built, fails the operation with an error matching xcl.ErrPluginLoad
(xcl.PluginLoadError in Python), which the example turns into a hint:
func explainPluginLoad(err error) error { if errors.Is(err, xcl.ErrPluginLoad) { return fmt.Errorf("%w, build it with `make build` in example/plugin", err) }
return err}def explain_plugin_load(err: xcl.Error) -> str: if isinstance(err, xcl.PluginLoadError): return f"{err}, build it with `make build` in example/plugin"
return str(err)Testing
The providers are unit tested without Docker. Each test hands the provider
a MockTasks, the Mockery mock of the containers.Tasks interface, sets the
calls it expects, and checks what the provider did with the answer:
func TestNetworkCreateFillsTheDockerID(t *testing.T) { tasks := mocks.NewMockTasks(t) tasks.EXPECT(). CreateNetwork(context.Background(), "app", containers.NetworkSpec{ Labels: networkTestLabels(), }). Return("net-123", nil). Once()
provider := &networkProvider{tasks: tasks}
created, err := provider.Create(context.Background(), networkTestNetwork())
require.NoError(t, err) require.Equal(t, "net-123", created.DockerID)}The task layer is tested the same way one level down, against a
MockDocker, the mock of the docker.Docker interface, checking the SDK
calls each task makes. The mocks are generated, not written:
MockTasks lives in client/containers/mocks and MockDocker in
client/docker/mocks, and after a change to either interface
make generate, run in plugins/docker, regenerates them with the Mockery
release pinned in the plugin’s Makefile.
The tests that need a real engine skip when no engine answers, so
make test passes on a machine without Docker. Those that run the providers
against Docker call the client layer’s docker.Ping first:
// requireDocker skips the test when no Docker engine is reachablefunc requireDocker(t *testing.T) { t.Helper()
if err := docker.Ping(context.Background()); err != nil { t.Skip(err.Error()) // Ping already says no Docker engine is reachable }}The end to end tests of the program do the same with its own pingDocker.
The plan tests in plan_test.go are among them. They apply the example
configuration into a temporary state directory, then check that planning the
same configuration reports no changes, that planning a copy with the
template’s source changed reports template.welcome as an update, and that
planning saves nothing. One more checks that plan without a path fails with
the usage, which needs no engine.
The address range change has tests of its own. The unit tests check each
provider’s Changed and Update against a mock, such as a new subnet
answering replace, a replaced network answering update for the container, and
an Update told the network was replaced connecting the container to it
again. Against a real engine, main_test.go applies ./config/full and then
./config/alt-subnet to the same state directory, and checks that the real
network Docker reports has the new range 10.42.0.0/23 and that the old
network is gone, that the container keeps its Docker ID, running and attached
to the new network, that the template was rendered again with the container’s
address, and that planning ./config/alt-subnet afterwards reports no changes.
plan_test.go checks the plan of ./config/alt-subnet after ./config/full: the
network replaced because it cannot be updated in place, the container updated
because the network is replaced, and the template updated.
scenarios_test.go runs the other variants the same way, each applied after
./config/full against real Docker. Moving the container to a new network keeps
the same running container, now on backend and off app. Moving the init
script’s destination creates a new container with the moved script mounted,
while editing the script’s content keeps the same container and renders the
new content. Removing the network deletes it and leaves the same container
running with no network and no saved address. Removing only the network,
while the container still names it, fails validation and changes nothing.
After each, planning the variant again reports no changes.
Run it
From the example/plugin directory, with a Docker engine running, make run
builds xcl-docker and the Docker plugin side by side into build/, then
runs the three commands one after another:
make runwhich is the same as:
make build./build/xcl-docker apply ./config/full./build/xcl-docker status./build/xcl-docker destroyThe events go to standard error. For apply, the Apply starts, then both plugins load,
the Docker plugin started as a process, each reporting the block types it
provides:
9:16AM INFO apply start source=core operation=apply phase=start9:16AM INFO load start source=core operation=load phase=start plugin=TemplatePlugin registry=local9:16AM INFO load success source=core operation=load phase=success block_types=template plugin=TemplatePlugin registry=local9:16AM INFO load start source=core operation=load phase=start plugin=docker-plugin registry=local9:16AM INFO load success source=core operation=load phase=success block_types="docker.network, docker.container" plugin=docker-plugin registry=localThen the lifecycle runs. Here are the init template, the container and the
welcome template, trimmed from the full run. After each create success the
receiver prints the entity as configuration text, with the values the
provider set marked:
9:16AM INFO create start source=core operation=create phase=start resource=template.init type=template.init file=config/full/main.xcl9:16AM INFO rendered template source=TemplatePlugin operation=create phase=log resource=template.init type=template.init file=config/full/main.xcl destination=./build/rendered/init.sh9:16AM INFO create success source=core operation=create phase=success resource=template.init type=template.init file=config/full/main.xcl duration=154.421µs template "init" { source = "#!/bin/sh\necho \"web joined the {{network}} network\" > /usr/share/nginx/html/init.txt\n" destination = "./build/rendered/init.sh" variables = { network = "app" } mode = "0755" }9:16AM INFO create start source=core operation=create phase=start resource=docker.container.web type=container.web file=config/full/main.xcl9:16AM INFO created container source=docker-plugin operation=create phase=log resource=docker.container.web type=container.web file=config/full/main.xcl image=nginx:1.27-alpine ip_address=10.42.0.2 name=web9:16AM INFO create success source=core operation=create phase=success resource=docker.container.web type=container.web file=config/full/main.xcl duration=272.641998ms docker "container" "web" { image = "nginx:1.27-alpine" init_script = "./build/rendered/init.sh"
network { name = "app" aliases = ["web"] }
docker_id = "ccc41ac142a180c52a0f9ecfbd2fe2ae66850ef79d4004b34dace51acd2419eb" # set by the provider ip_address = "10.42.0.2" # set by the provider }9:16AM INFO create start source=core operation=create phase=start resource=template.welcome type=template.welcome file=config/full/main.xcl9:16AM INFO rendered template source=TemplatePlugin operation=create phase=log resource=template.welcome type=template.welcome file=config/full/main.xcl destination=./build/rendered/welcome.txt9:16AM INFO create success source=core operation=create phase=success resource=template.welcome type=template.welcome file=config/full/main.xcl duration=121.441µs template "welcome" { source = "Welcome to the {{network}} network.\nThe web container answers at http://{{address}}/\n" destination = "./build/rendered/welcome.txt" variables = { address = "10.42.0.2" network = "app" } }9:16AM INFO apply success source=core operation=apply phase=success duration=288.028674msEach provider call is a start and a success from core, xcl itself, and
anything the provider logs arrives between them. The provider’s message
carries the plugin as its source, TemplatePlugin in-process and
docker-plugin for the binary, and the resource, type, file and step
(operation) of the call it was logged in, although the provider passed only
the message and its own details. The init script is rendered before the
container that mounts it is created, and the welcome template’s address is
the ip_address the Docker plugin logged a moment earlier.
The destroy runs the same providers in reverse dependency order: the welcome template first, then the container, then the init template it mounted, then the network (trimmed to the provider messages and the overall start and success):
9:16AM INFO destroy start source=core operation=destroy phase=start9:16AM INFO removed template source=TemplatePlugin operation=destroy phase=log resource=template.welcome type=template.welcome file=config/full/main.xcl destination=./build/rendered/welcome.txt9:16AM INFO destroyed container source=docker-plugin operation=destroy phase=log resource=docker.container.web type=container.web file=config/full/main.xcl name=web9:16AM INFO removed template source=TemplatePlugin operation=destroy phase=log resource=template.init type=template.init file=config/full/main.xcl destination=./build/rendered/init.sh9:16AM INFO destroyed network source=docker-plugin operation=destroy phase=log resource=docker.network.app type=network.app file=config/full/main.xcl id=c42d8263fbe208fb554b472e004822a50eaa132375cdfa096652ff3c63fb3182 name=app9:16AM INFO destroy success source=core operation=destroy phase=success duration=556.043249msstatus prints nothing but its tree: it gives xcl no event handler, so xcl
is silent while it reads the state.
With XCL_LOG_LEVEL=debug the plugin scoped messages show as well: what
each Init logged, and go-plugin’s own messages about starting the external
process, marked component=go-plugin. Trimmed to the load of an apply:
9:16AM INFO load start source=core operation=load phase=start plugin=TemplatePlugin registry=local9:16AM DEBU registering block types source=TemplatePlugin operation=load phase=log block_types=template9:16AM DEBU provider ready source=TemplatePlugin operation=load phase=log provider=template9:16AM INFO load success source=core operation=load phase=success block_types=template plugin=TemplatePlugin registry=local9:16AM INFO load start source=core operation=load phase=start plugin=docker-plugin registry=local9:16AM DEBU using plugin source=docker-plugin operation=load phase=log component=go-plugin version=19:16AM INFO load success source=core operation=load phase=success block_types="docker.network, docker.container" plugin=docker-plugin registry=local9:16AM DEBU provider ready source=docker-plugin operation=load phase=log9:16AM DEBU registering block types source=docker-plugin operation=load phase=log block_types="docker.network, docker.container"9:16AM DEBU provider ready source=docker-plugin operation=load phase=logThe two plugins’ Init messages do not look quite the same. The in-process
template provider’s provider ready carries provider=template, but the
Docker providers’ provider ready lines carry no provider= tag: the tag is
only added in-process. The Docker plugin’s Init messages, which cross the
process boundary, also arrive after its load success line rather than
between its load start and load success.
apply and destroy print nothing to standard output. status, a separate
run, prints the tree from the saved state alone, each entity under what it
depends on, with the computed values the Docker plugin set:
● docker.network.app 10.42.0.0/24 · c42d8263fbe2└── ● template.init ./build/rendered/init.sh └── ● docker.container.web nginx:1.27-alpine · 10.42.0.2 · ccc41ac142a1 └── ● template.welcome ./build/rendered/welcome.txtIn a terminal the dots are green for an entity that was created, red for one that failed, and the Docker and template addresses are coloured by plugin.
xcl-docker inspect docker.container.web, another run, prints the container
as configuration, with the values the Docker plugin set and the references to
the network and the init template as they were written:
docker "container" "web" { image = "nginx:1.27-alpine" init_script = template.init.destination
network { name = docker.network.app.meta.name aliases = ["web"] }
docker_id = "19e3e1e5dc0c9337844056a91078618ba7edb069c70f917af0ac60f88cb15ca3" # set by the provider ip_address = "10.42.0.2" # set by the provider}The rendered welcome file holds the container’s address, which crossed from the Docker plugin to the template plugin:
Welcome to the app network.The web container answers at http://10.42.0.2/and the init script, rendered with mode 0755, has run inside the container
when it started: docker exec web cat /usr/share/nginx/html/init.txt prints
web joined the app network.
Replacing the network: make replace
Before destroying, plan shows what applying a changed configuration would
do. Planning ./config/full as it was applied prints only
Diff: no changes, 4 unchanged. The example’s variant configurations
each sit in a directory of their own and are each a complete configuration, so
./config/full applies on its own as before. ./config/alt-subnet is ./config/full with
the network’s address range widened from 10.42.0.0/24 to 10.42.0.0/23.
make replace applies ./config/full, plans and applies ./config/alt-subnet, prints
the status and destroys everything. The plan, with ./config/full applied:
./build/xcl-docker plan ./config/alt-subnetprints, plain here and coloured in a terminal:
# docker.container.web will be updated because docker.network.app is replaced ~ docker "container" "web" {}
# docker.network.app will be replaced, it cannot be updated in place-/+ docker "network" "app" { ~ subnet = "10.42.0.0/24" -> "10.42.0.0/23" }
# template.welcome will be updated ~ template "welcome" { ~ variables["address"] = "10.42.0.2" -> (known after apply) }
Diff: 0 to create, 2 to update, 1 to replace, 0 to delete, 1 unchanged.The network’s provider answered replace for the new subnet. The
container’s configuration did not change at all, but its provider was told
that the network it depends on is replaced, and answered update: its Update
can reattach the running container. With no changes of its own, the plan
names the dependency behind the update. The welcome template’s address is
(known after apply) because it reads the container’s ip_address, which
the Docker plugin reads again when it updates the container. The init
template only reads the network’s name, which does not change, so it is
unchanged. Nothing was changed or saved by the plan.
Applying ./config/alt-subnet reads and decides every entity first, then
destroys the network, which force-disconnects the running container, creates
the network on the new range, updates the container, which connects it again,
and updates the template, in dependency order. Trimmed to the provider calls,
without the entities printed after each create success:
9:13AM INFO changed success source=core operation=changed phase=success resource=docker.network.app type=network.app file=config/alt-subnet/main.xcl duration=261.132µs9:13AM INFO changed success source=core operation=changed phase=success resource=template.init type=template.init file=config/alt-subnet/main.xcl duration=25.4µs9:13AM INFO changed success source=core operation=changed phase=success resource=docker.container.web type=container.web file=config/alt-subnet/main.xcl duration=178.281µs9:13AM INFO changed success source=core operation=changed phase=success resource=template.welcome type=template.welcome file=config/alt-subnet/main.xcl duration=22.8µs9:13AM INFO destroy start source=core operation=destroy phase=start resource=docker.network.app type=network.app file=config/full/main.xcl9:13AM INFO disconnected container from network source=docker-plugin operation=destroy phase=log resource=docker.network.app type=network.app file=config/full/main.xcl container=19e3e1e5dc0c9337844056a91078618ba7edb069c70f917af0ac60f88cb15ca3 name=app9:13AM INFO destroyed network source=docker-plugin operation=destroy phase=log resource=docker.network.app type=network.app file=config/full/main.xcl id=136cd65886da2bb84b734e171ae95ff290788de9d1e1c401bb144b27704bd751 name=app9:13AM INFO create start source=core operation=create phase=start resource=docker.network.app type=network.app file=config/alt-subnet/main.xcl9:13AM INFO created network source=docker-plugin operation=create phase=log resource=docker.network.app type=network.app file=config/alt-subnet/main.xcl id=1ab08a074f46cf0cef7dc4ce6678db70f101e5bcb44a10919ead399761d50693 name=app9:13AM INFO update start source=core operation=update phase=start resource=docker.container.web type=container.web file=config/alt-subnet/main.xcl9:13AM INFO connected container to network source=docker-plugin operation=update phase=log resource=docker.container.web type=container.web file=config/alt-subnet/main.xcl name=web network=app9:13AM INFO update start source=core operation=update phase=start resource=template.welcome type=template.welcome file=config/alt-subnet/main.xcl9:13AM INFO rendered template source=TemplatePlugin operation=update phase=log resource=template.welcome type=template.welcome file=config/alt-subnet/main.xcl destination=./build/rendered/welcome.txt9:13AM INFO apply success source=core operation=apply phase=success duration=196.256325msA replacement is a destroy and a create for the same entity, there is
no separate replace event. The destroy names config/full/main.xcl, the file the
saved entity came from, and the create config/alt-subnet/main.xcl. The new
network has a new Docker ID and the new range, and the container is the same
one, with the same Docker ID, now attached to it. status shows the new
network:
● docker.network.app 10.42.0.0/23 · 1ab08a074f46└── ● template.init ./build/rendered/init.sh └── ● docker.container.web nginx:1.27-alpine · 10.42.0.2 · 19e3e1e5dc0c └── ● template.welcome ./build/rendered/welcome.txtand planning ./config/alt-subnet again prints Diff: no changes, 4 unchanged.
Moving the container: make swap
./testdata/swap adds a second network, backend on 10.43.0.0/24, and moves
the container’s network block from app to backend. make swap applies
./config/full, then plans and applies ./testdata/swap. The plan:
# docker.container.web will be updated ~ docker "container" "web" { ~ network[0].name = "app" -> "backend" }
# docker.network.backend will be created + docker "network" "backend" { + subnet = "10.43.0.0/24" }
# template.welcome will be updated ~ template "welcome" { ~ variables["address"] = "10.42.0.2" -> (known after apply) }
Diff: 1 to create, 2 to update, 0 to replace, 0 to delete, 2 unchanged.The container has a change of its own, network[0].name, so its provider
answers update: a network change is never a reason to rebuild it. Applying
it creates backend, then the container’s Update works out from the
change’s Before that it was on app, disconnects it from app and
connects it to backend:
9:12AM INFO create start source=core operation=create phase=start resource=docker.network.backend type=network.backend file=testdata/swap/main.xcl9:12AM INFO created network source=docker-plugin operation=create phase=log resource=docker.network.backend type=network.backend file=testdata/swap/main.xcl id=7d7ed04f1b31a78024cdd7d91081eea532aaad38f03435bafd7467e9640385d1 name=backend9:12AM INFO update start source=core operation=update phase=start resource=docker.container.web type=container.web file=testdata/swap/main.xcl9:12AM INFO disconnected container from network source=docker-plugin operation=update phase=log resource=docker.container.web type=container.web file=testdata/swap/main.xcl name=web network=app9:12AM INFO connected container to network source=docker-plugin operation=update phase=log resource=docker.container.web type=container.web file=testdata/swap/main.xcl name=web network=backend9:12AM INFO update start source=core operation=update phase=start resource=template.welcome type=template.welcome file=testdata/swap/main.xcl9:12AM INFO rendered template source=TemplatePlugin operation=update phase=log resource=template.welcome type=template.welcome file=testdata/swap/main.xcl destination=./build/rendered/welcome.txt9:12AM INFO apply success source=core operation=apply phase=success duration=206.616252msThe container keeps its Docker ID and now has an address on backend.
app stays, since the init template still reads its name:
● docker.network.app 10.42.0.0/24 · 4259e7f4c389└── ● template.init ./build/rendered/init.sh └── ● docker.container.web nginx:1.27-alpine · 10.43.0.2 · 77383d58c1ca └── ● template.welcome ./build/rendered/welcome.txt● docker.network.backend 10.43.0.0/24 · 7d7ed04f1b31Moving the init script: make rebuild-init
./config/modified-init renders the init script to init-v2.sh instead of init.sh.
make rebuild-init applies ./config/full, then plans and applies
./config/modified-init. The plan:
# docker.container.web will be replaced because template.init is replaced-/+ docker "container" "web" { ~ init_script = "./build/rendered/init.sh" -> "./build/rendered/init-v2.sh" }
# template.init will be replaced, it cannot be updated in place-/+ template "init" { ~ destination = "./build/rendered/init.sh" -> "./build/rendered/init-v2.sh" }
# template.welcome will be updated ~ template "welcome" { ~ variables["address"] = "10.42.0.2" -> (known after apply) }
Diff: 0 to create, 1 to update, 2 to replace, 0 to delete, 1 unchanged.The template provider replaces a template whose destination moves, so its
Destroy removes the old file. The container’s init_script changes too,
and it is told that template.init, a dependency that isn’t a network, is
replaced, so it answers replace: the script is mounted when Docker creates the
container. Applying it destroys the container and the template, renders the
script to its new path and creates a new container that mounts it:
9:12AM INFO destroy start source=core operation=destroy phase=start resource=docker.container.web type=container.web file=config/full/main.xcl9:12AM INFO destroyed container source=docker-plugin operation=destroy phase=log resource=docker.container.web type=container.web file=config/full/main.xcl name=web9:12AM INFO destroy start source=core operation=destroy phase=start resource=template.init type=template.init file=config/full/main.xcl9:12AM INFO removed template source=TemplatePlugin operation=destroy phase=log resource=template.init type=template.init file=config/full/main.xcl destination=./build/rendered/init.sh9:12AM INFO create start source=core operation=create phase=start resource=template.init type=template.init file=config/modified-init/main.xcl9:12AM INFO rendered template source=TemplatePlugin operation=create phase=log resource=template.init type=template.init file=config/modified-init/main.xcl destination=./build/rendered/init-v2.sh9:12AM INFO create start source=core operation=create phase=start resource=docker.container.web type=container.web file=config/modified-init/main.xcl9:12AM INFO created container source=docker-plugin operation=create phase=log resource=docker.container.web type=container.web file=config/modified-init/main.xcl image=nginx:1.27-alpine ip_address=10.42.0.3 name=web9:12AM INFO update start source=core operation=update phase=start resource=template.welcome type=template.welcome file=config/modified-init/main.xcl9:12AM INFO rendered template source=TemplatePlugin operation=update phase=log resource=template.welcome type=template.welcome file=config/modified-init/main.xcl destination=./build/rendered/welcome.txt9:12AM INFO apply success source=core operation=apply phase=success duration=557.54905msThe container has a new Docker ID, and with it a new address:
● docker.network.app 10.42.0.0/24 · cf902a871bf4└── ● template.init ./build/rendered/init-v2.sh └── ● docker.container.web nginx:1.27-alpine · 10.42.0.3 · 4eab6d51bcbd └── ● template.welcome ./build/rendered/welcome.txtEditing only the script’s content, as ./testdata/init-content does, is
different. The template renders the file again in place, and the container,
told only that a dependency was updated, is left alone:
# template.init will be updated ~ template "init" { ~ source = "#!/bin/sh\necho \"web joined the {{network}} network\" > /usr/share/nginx/html/init.txt\n" -> "#!/bin/sh\necho \"web is on the {{network}} network\" > /usr/share/nginx/html/init.txt\n" }
Diff: 0 to create, 1 to update, 0 to replace, 0 to delete, 3 unchanged.Removing the network: make remove-network
./config/no-network removes the app network together with every reference to
it: the container’s network block and the templates’ network variables.
make remove-network applies ./config/full, then plans and applies
./config/no-network. The plan:
# docker.container.web will be updated ~ docker "container" "web" { - network[0] = { aliases = ["web"], name = "app" } }
# docker.network.app will be deleted - docker "network" "app" {}
# template.init will be updated ~ template "init" { ~ source = "#!/bin/sh\necho \"web joined the {{network}} network\" > /usr/share/nginx/html/init.txt\n" -> "#!/bin/sh\necho \"web has no network\" > /usr/share/nginx/html/init.txt\n" - variables["network"] = "app" }
# template.welcome will be updated ~ template "welcome" { ~ source = "Welcome to the {{network}} network.\nThe web container answers at http://{{address}}/\n" -> "Welcome, the web container has no network.\nThe web container answers at http://{{address}}/\n" ~ variables["address"] = "10.42.0.2" -> (known after apply) - variables["network"] = "app" }
Diff: 0 to create, 3 to update, 0 to replace, 1 to delete, 0 unchanged.Removing the whole network[0] element is one change holding the whole
block. The container answers update for it, so it keeps running. Applying it
destroys the network first, force-disconnecting the container, then updates
the templates and the container, whose Update disconnects it from app,
already gone, which it ignores:
9:12AM INFO destroy start source=core operation=destroy phase=start resource=docker.network.app type=network.app file=config/full/main.xcl9:12AM INFO disconnected container from network source=docker-plugin operation=destroy phase=log resource=docker.network.app type=network.app file=config/full/main.xcl container=abf23052f240bbeac8d673454442312b57eaf46a2bb42638e8c297889b8443c8 name=app9:12AM INFO destroyed network source=docker-plugin operation=destroy phase=log resource=docker.network.app type=network.app file=config/full/main.xcl id=fc498460f2d0e7a166e7f1afcce09153979d2e62130aba5ba576eae0633bbda3 name=app9:12AM INFO update start source=core operation=update phase=start resource=template.init type=template.init file=config/no-network/main.xcl9:12AM INFO rendered template source=TemplatePlugin operation=update phase=log resource=template.init type=template.init file=config/no-network/main.xcl destination=./build/rendered/init.sh9:12AM INFO update start source=core operation=update phase=start resource=docker.container.web type=container.web file=config/no-network/main.xcl9:12AM INFO disconnected container from network source=docker-plugin operation=update phase=log resource=docker.container.web type=container.web file=config/no-network/main.xcl name=web network=app9:12AM INFO update start source=core operation=update phase=start resource=template.welcome type=template.welcome file=config/no-network/main.xcl9:12AM INFO rendered template source=TemplatePlugin operation=update phase=log resource=template.welcome type=template.welcome file=config/no-network/main.xcl destination=./build/rendered/welcome.txt9:12AM INFO apply success source=core operation=apply phase=success duration=127.714717msThe container keeps its Docker ID and is left on no network, so it has no address any more, and the network is gone from the tree:
● template.init ./build/rendered/init.sh└── ● docker.container.web nginx:1.27-alpine · abf23052f240 └── ● template.welcome ./build/rendered/welcome.txtRemoving only the network block, as ./testdata/dangling does, while the
container and the templates still refer to it, fails validation: the error
says that docker.container.web refers to docker.network.app.meta.name,
which is not defined anywhere in the configuration, and nothing is created,
changed or destroyed.
After destroy, status prints nothing applied.
What to notice
- Computed values flow between plugins. The Docker plugin sets
ip_addressin its own process, and the in-process template provider receives it already resolved in its variables, becausetemplate.welcomereferences the container and so is always created after it. - A block type does not need a subtype.
templateis registered with an empty subtype, so it is writtentemplate "welcome" {}, addressedtemplate.welcomeand looked up withFindByTypeby its type alone. - In-process and external look the same. Both plugins are written
against the same
pluginspackage and log through the sameplugins.Logger(ctx). Only how they are registered, and the one linemainthat hands the external one toplugins.Serve, differ. - Each command is its own run, sharing only the state.
applysaves the state,statusreads it back withLoad, anddestroyworks from it alone, none of them needing what an earlier run held in memory. - Plugins load when first needed. Registering only records a plugin. It
is started and checked by the first operation, and a plugin that fails to
load, such as a Docker plugin binary that was never built, fails it with
xcl.ErrPluginLoad. The Docker plugin’s process runs only while an operation uses it, so the program never stops it. - Providers decide between update and replace from what they are told.
Each Docker provider overrides
Changedto answer replace for what Docker fixes at creation. Network changes never rebuild the container: a network moved, added, removed or replaced is handled by itsUpdate, which keeps the same running container. Only a change to its image, command, environment or init script, or a replaced init-script template, creates a new one. - Update acts on the changes it is given. The container’s
Updatenever asks Docker what it was attached to: it rebuilds its previous networks from each change’sBefore, and reconnects any network it was told was replaced. - Destroy calls the providers in reverse dependency order.
Destroyworks from the saved state alone, and removes the welcome template before the container, and the container before the init template and the network, the reverse of the order they were created in. - The tests run without Docker. The providers hold the
containers.Tasksinterface and the task layer a narrowdocker.Dockerinterface, so the unit tests of each use a generated mock, and the tests that need a real engine skip whendocker.Ping, or the program’spingDocker, finds none. - The host imports only the entities. The plugin is a module of its own
in the standard layout, and the program reads the blocks it applied
through
plugins/docker/entitiesalone, so its build includes no Docker library.