v0.10.0 · Go and Python

XCL: a modern relational configuration language

A configuration language built on the readability of HCL. Blocks relate to each other through references, are processed in dependency order and run through plugins, so configuration can be planned, applied, changed and destroyed. Available for Go and Python.

$ go get github.com/jumppad-labs/xcl

Install

Terminal window
go get github.com/jumppad-labs/xcl

The Go module needs no cgo and no C compiler.

Terminal window
pip install xcl-lang

The package is imported as xcl. It bundles the XCL engine as a small program and runs it as a subprocess, on every platform, so the API and the results are the same everywhere, and no Go or C compiler is needed. Wheels are published for Linux on glibc and on musl (Alpine), macOS and Windows, each on x86-64 and arm64. See xcl-lang on PyPI.

What is XCL?

XCL is a language for configuration that describes things and how they relate. It takes the readable syntax of HCL, the HashiCorp Configuration Language (blocks, attributes and expressions), and builds a language for processing configuration on top of it. A block can refer to any other block, those references decide the order everything happens in, and plugins give the blocks meaning by creating and managing the things they describe.

XCL is its own language. It is not a way to read Terraform configuration, and it does not promise compatibility with other HCL dialects. Today its syntax matches HCL’s, so editors and formatters carry over, but it will grow where configuration needs it to.

You describe a block type once, as a Go struct with xcl tags or as a Python dataclass:

resources.go
// PostgreSQL defines the block type `database "postgres"`
type PostgreSQL struct {
// The block's labels, its subtype and its name: "postgres" and "main"
Engine string `xcl:"engine,label" json:"engine"`
Name string `xcl:"name,label" json:"name"`
Location string `xcl:"location" json:"location"`
Port int `xcl:"port" json:"port"`
Username string `xcl:"username" json:"username"`
// Sensitive: shown only as (sensitive), read with Reveal()
Password types.Sensitive[string] `xcl:"password" json:"password"`
// A nested block, optional because it is a pointer
Timeouts *Timeouts `xcl:"timeouts,block" json:"timeouts,omitempty"`
// Set by a provider, never by configuration
ConnectionString string `xcl:"connection_string,optional,computed" json:"connection_string,omitempty"`
}
resources.py
# PostgreSQL defines the block type `database "postgres"`
@dataclass
class PostgreSQL:
location: str
port: int
username: str
# Sensitive: shown only as (sensitive), read with reveal()
password: xcl.Sensitive[str]
# The block's labels, its subtype and its name: "postgres" and "main"
engine: str = xcl.field(label=True, default="")
name: str = xcl.field(label=True, default="")
# A nested block, optional because it may be None
timeouts: Timeouts | None = None
# Set by a provider, never by configuration
connection_string: xcl.Computed[str] = None

Every block type is registered with a type and, optionally, a subtype, which become the block’s labels. PostgreSQL is registered with the type database and the subtype postgres, so it is written database "postgres" "main" and referred to as database.postgres.main. An App type registered with only the type app is written app "web" and referred to as app.web.

Then write configuration against them. The references to database.postgres.main are resolved for you, and they are what makes XCL process the database before app.web:

main.xcl
variable "db_username" {
default = "admin"
}
database "postgres" "main" {
location = "localhost"
port = 5432
username = variable.db_username
password = env("DB_PASSWORD")
timeouts {
connection = 10
}
}
app "web" {
database_location = database.postgres.main.location
connection_string = database.postgres.main.connection_string
}

And read it back as your own type:

main.go
// a plugin providing database "postgres" and app, with providers that create them
local := registry.NewLocal()
local.RegisterPlugin(&postgres.Plugin{})
c, err := xcl.NewConfig(
xcl.WithRegistry(local),
// optional, xcl writes nothing of its own without a receiver
xcl.WithEventHandler(events.SlogHandler(slog.Default())),
)
if err != nil {
return err
}
if err := c.Apply("./config"); err != nil {
return err
}
db, err := xcl.Find[PostgreSQL](c, "database.postgres.main")
if err != nil {
return err
}
fmt.Println(db.Location, db.Port)
main.py
# a plugin providing database "postgres" and app, with providers that create them;
# Python loads a Go plugin as a separate program
local = xcl.LocalRegistry()
local.register_external_plugin("./bin/xcl-plugin-postgres")
cfg = xcl.Config(
registries=[local],
# optional, xcl writes nothing of its own without a receiver
on_event=lambda event: logging.info("%s %s %s", event.operation, event.phase, event.resource_id),
)
cfg.apply("./config")
db = cfg.find(PostgreSQL, "database.postgres.main")
print(db.location, db.port)

The language reference describes every part of XCL, with the Go and Python types beside each shape of configuration.

Learn the language →

Everything HCL has, and more

The syntax is HCL. These are the features xcl adds on top of it.

🧱

Go structs as the schema

Block types are Go structs. xcl tags mark attributes as required or optional, nest blocks (a pointer is optional, a slice repeats), and flag computed fields that only a provider can set.

🔗

References between blocks

Any attribute can read another block, like database.postgres.main.location, or take a whole block as a value. References are resolved in order, so a value always exists before it is read.

🕸️

Dependency graph

References and depends_on build a graph. Blocks are applied in dependency order, independent blocks run concurrently, and destroy runs in reverse. Cycles are reported as errors.

🎛️

Variables and outputs

variable blocks take defaults that can be overridden from Go with WithVariables, or from the environment with XCL_VAR_<name>. output blocks expose values, including lists and maps. In Go an output is an entity like any other: Find[types.Output] returns it, and .Value holds what it publishes. Variables and outputs →

📦

Modules

A module block loads a directory of .xcl files as one scoped unit. Pass values in with variables, and read its outputs with module.<name>.output.<name>. Outputs are the only way in: anything else inside a module is rejected by validation. Modules can nest, and a nested module’s value is exposed by re-exporting it as an output. Modules →

ƒ

Functions

57 built in: the HCL standard library (format, join, jsonencode, merge, regex and more) plus env, file, template_file, home, dir, len and element. And your own: register a Go function with the application, or offer one from a plugin, and configuration calls it by name like any builtin. Functions →

🔌

Plugins and providers

A provider gives a block type a lifecycle: Create, Read, Changed, Update and Destroy. Changed answers no change, update or replace, told which of its settings and dependencies are changing, and Update is told the same. Plugins run in-process, or as a separate binary over gRPC. Unchanged, update or replace →

🌐

Remote plugins

Install plugins straight from GitHub releases with one line, RegisterPlugin("jumppad-labs/xcl-plugin-docker", "v0.3.0"). Versions are pinned, every download is checked against its checksum and signature, and the plugin is cached for the next run. Private repositories work with a token. Start your own from the plugin template. Plugins from GitHub →

📝

Configuration only

Not everything needs a lifecycle. registry.Register declares a plain Go struct as a block type on a local registry, with no plugin and no state needed. It still takes part in references and modules, and no provider is ever called.

💾

State, apply and destroy

Apply saves what it applied, and a block removed from configuration is destroyed on the next run. Destroy needs only the saved state, and an interrupted destroy picks up where it stopped.

🔎

Typed queries

Find[T] and FindByType[T] return entities as your own Go type, by address or by kind. No type assertions.

📡

Events and logging

xcl writes no output of its own. Every lifecycle step, plugin load, warning, error and plugin log message is one ordered stream of events to WithEventHandler, and one line connects it to log/slog. Events and logging →

🔒

Sensitive values

Declare a field types.Sensitive[T] and its value shows only as (sensitive) in logs, events, errors and your own JSON. State keeps the real value, and Reveal() unwraps it. Sensitive values →

✅

Validation

Validate checks syntax, schema, references and the graph without calling a provider or changing anything, and reports every problem it finds at once.

Two examples

The examples in the repository each run on their own, with make run, and each show one way xcl is used.

  • Plugins and the lifecycle: an external Docker plugin, run over gRPC, that creates a real network and container, and an in-process template plugin that renders a file from the container’s address. Their providers create everything on apply and remove it on destroy.
  • Configuration only: a Kubernetes-like deployment read into Go types, with entities linked to each other by reference, and reported as the route each ingress path takes. No plugins.

Documentation

  • Language reference: files, entities, attributes and blocks, references, expressions, variables and outputs, modules and functions, each shape of configuration beside its Go and Python types.

Start with an example

xcl is free and open source. Clone the repository and run any example with make run.