Every xcl plugin uses one layout, whether it runs inside an application or as
a separate program. The plugin template is that layout’s
worked example. Its one entity type, notes "note", writes a note to a file, so
it needs no Docker, no cloud account and no service: a repository made from
it builds, passes its tests and applies its sample straight away.
Plugins are written in Go for now. A plugin built from this template works from both Go and Python applications.
variable "directory" { default = "./out"}
notes "note" "greeting" { directory = variable.directory name = "greeting.txt" content = "Hello from xcl"}The standard layout
<plugin>/ plugin.go the plugin type: Init builds the clients once and registers each type cmd/<plugin>/main.go serves the plugin as a separate program entities/<entity>.go one block type per file, and nothing else providers/<entity>.go one provider per entity type providers/<entity>_test.go strict-double unit tests client/<backend>/ a narrow interface over each backend, its real implementation client/<backend>/mocks/ Mockery-generated doubles examples/<name>/main.xcl sample configurations e2e/ end-to-end tests: apply, plan with no changes, destroy .mockery.yml Makefile build, test, generateentities/holds block types only. It imports nothing else from the plugin and no backend library, so an application that only reads a plugin’s entities from state importsentitiesalone.providers/holds one provider per entity type, each built by a constructor that takes the clients it uses, so tests build it with doubles.client/is the only place a backend is touched. Its interfaces hold only the calls the providers make, and it never imports providers or entities.plugin.gosits in the module’s root package, so an application can import the plugin, andcmd/<plugin>/main.goserves the same type as a separate program.
Every source file lists its exported types and functions first and its
helpers last, and a provider’s methods follow the lifecycle: Init,
Create, Read, Changed, Update, Destroy, Functions. Functions
returns the functions the provider offers to configuration, which calls
them by name like any builtin, or nil when it offers none. The full
write-up is the
plugin layout guide.
Deciding and updating
The provider decides between update and replace itself. The settings that cannot change in place are one list at the top of the provider:
var ReplaceSettings = []entity.Path{ entity.Path{}.Attribute("directory"), entity.Path{}.Attribute("name"),}Changed answers replace for a change within one of them or for a replaced
dependency, update for any other change, and leaves the rest to
DefaultChanged. Update then acts only on the changes it is given: a
content change rewrites the file, a mode change alone only changes its
mode, and it never looks the file up to find out what it was. See
Unchanged, update or replace for how xcl calls them.
Start from the template
Click Use this template on the template repository, or:
gh repo create <you>/xcl-plugin-<name> --template jumppad-labs/xcl-plugin-template --public --cloneName the repository after your plugin: the GitHub registry installs a plugin by its repository name. The template’s README lists the renames that make it yours and walks through adding an entity type of your own.
Build and test
make build # every package, and the plugin program in build/make test # unit and end-to-end tests, nothing else runningmake generate # regenerate the Mockery doublesmake lint # go vet, gofmt and staticcheckThe provider’s unit tests run against a strict double of the files client, so a call the reported changes do not need, such as a lookup of the old file, fails the test. GitHub Actions run the build, the checks and the tests on every push and pull request, and once a week against the latest xcl release.
Run it in-process and as a separate program
make inprocess # the plugin imported and registered inside the applicationmake external # the plugin built and run as a separate programEach applies the sample, plans it again with no changes and destroys it. An application registers the plugin one way or the other on a local registry:
local := registry.NewLocal()local.RegisterPlugin(¬es.Plugin{}) // in-processlocal.RegisterExternalPlugin("./build/xcl-plugin-template") // a separate program# in-process registration has no Python form: Python loads Go plugins as separate programslocal = xcl.LocalRegistry()local.register_external_plugin("./build/xcl-plugin-template") # a separate programRelease
make release VERSION=v0.1.0Pushes the tag. The release workflow runs the tests, builds the plugin for
linux, darwin and windows on amd64 and arm64, signs the checksums with the
key in the repository’s GPG_PRIVATE_KEY and GPG_PASSPHRASE secrets, and
publishes a GitHub release holding:
<name>_<version>_<os>_<arch>.tar.gz,.zipon windows, with the binary at its root;<name>_<version>_checksums.txt, the SHA-256 of every archive;<name>_<version>_checksums.txt.sig, an armoured detached signature over the checksums.
Without the signing key the workflow fails before building, so an unsigned
release is never published. make dist VERSION=v0.0.0-snapshot builds the
same archives locally, unsigned.
Install
An application installs a release with the GitHub registry, trusting the author’s published public key:
gh := registry.NewGitHub(registry.GitHubTrustedKeys(publicKey))gh.RegisterPlugin("<you>/xcl-plugin-<name>", "v0.1.0")
c, err := xcl.NewConfig(xcl.WithRegistry(gh))gh = xcl.GitHubRegistry(trusted_keys=[public_key])gh.register_plugin("<you>/xcl-plugin-<name>", "v0.1.0")
cfg = xcl.Config(registries=[gh])Installing plugins from GitHub covers pinning, private repositories and the cache.
Verifying releases
The template’s own releases are signed with the jumppad-labs release key, published on this site at /keys/jumppad-labs-releases.asc. Its fingerprint is:
15BD A684 3A1F A0EA D1AA 5190 F775 DA00 AFD4 B502Check the key you download against it before trusting it:
curl -fsSLO https://xcl.dev/keys/jumppad-labs-releases.ascgpg --show-keys jumppad-labs-releases.ascThen pass it to the registry:
publicKey, err := os.ReadFile("jumppad-labs-releases.asc")if err != nil { return err}
gh := registry.NewGitHub(registry.GitHubTrustedKeys(string(publicKey)))gh.RegisterPlugin("jumppad-labs/xcl-plugin-template", "v0.1.0")from pathlib import Path
public_key = Path("jumppad-labs-releases.asc").read_text()
gh = xcl.GitHubRegistry(trusted_keys=[public_key])gh.register_plugin("jumppad-labs/xcl-plugin-template", "v0.1.0")