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 go get github.com/jumppad-labs/xclThe Go module needs no cgo and no C compiler.
pip install xcl-langThe 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.
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:
// 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"`}# PostgreSQL defines the block type `database "postgres"`@dataclassclass 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] = NoneEvery 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:
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:
// a plugin providing database "postgres" and app, with providers that create themlocal := 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)# a plugin providing database "postgres" and app, with providers that create them;# Python loads a Go plugin as a separate programlocal = 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 →The syntax is HCL. These are the features xcl adds on top of it.
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.
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.
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.
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 →
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 →
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 →
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 →
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 →
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.
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.
Find[T] and FindByType[T] return entities as your own Go type, by
address or by kind. No type assertions.
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 →
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 →
Validate checks syntax, schema, references and the graph without
calling a provider or changing anything, and reports every problem it
finds at once.
The examples in the
repository each run on their own, with make run, and each show one way xcl
is used.
xcl is free and open source. Clone the repository and run any example with make run.