Plenty of configuration only needs to be read. Nothing is created in the outside world, the application wants the values, with references, variables and modules resolved, as ordinary Go structs, and then acts on them.
That is what this example is: a small program that reads its configuration
into its own types and uses it. The block types are plain Go structs declared
with registry.Register on a local registry, which is given to the Config
when it is created. xcl decodes each block into your type, resolves every reference in
dependency order, and never calls a provider. A declared type needs nothing
else: no plugin, no provider, no schema to write by hand. Nothing is kept between runs either:
without a state option xcl holds the applied configuration in memory only,
which is all a program that only reads its configuration needs.
The configuration is a small Kubernetes-like deployment, with the names a
Kubernetes user would recognise rather than a literal translation of a
manifest. The program follows the links between its ingress, service and
deployment and prints, for every ingress path, where the traffic ends up. The
source is in
example/configonly,
a Go module of its own that can be copied out of the repository and built
against a published xcl. The Python tabs show the same program written against
the Python package; they are not in the repository and are shown for
comparison.
The Go types
A block type is a plain Go struct. The xcl tags map configuration to
fields, and a field called Meta, a types.Meta, is filled with the block’s
metadata, such as its id, which the program uses to say which block it means.
Anything nested inside a block is a struct too, one that is not registered: a
block that appears once is a pointer, a block that repeats is a slice. The secret’s data is a
sensitive value, so it is masked in output and events,
and would be encrypted in state by a program that kept any.
// ConfigMap defines the block type `config_map`, a bag of values the// deployment reads. A map attribute holds values whose names are not known// ahead of time, and a single value can be read from it with// `config_map.api.data.db_host`.type ConfigMap struct { Meta types.Meta
Data map[string]string `xcl:"data" json:"data"`}
// Secret defines the block type `secret`, values that must not be seen. Data// is a types.Sensitive, so it is masked in output and events and encrypted in// state when the state has a key, and only Reveal returns the real values.//// As in Kubernetes, nothing else holds a secret's values: a container reads// one through a secret_key_ref naming the secret and the key, which are plain// strings, so the deployment never becomes sensitive itself.type Secret struct { Meta types.Meta
Data types.Sensitive[map[string]string] `xcl:"data" json:"data"`}
// Deployment defines the block type `deployment`. It holds repeated container// blocks, each of which nests further blocks of its own.type Deployment struct { Meta types.Meta
Replicas int `xcl:"replicas,optional" json:"replicas,omitempty"`
// Containers is a repeated block, one entry per container block in the // configuration. A repeated block is a slice, a block that appears once is // a pointer. Containers []Container `xcl:"container,block" json:"container"`
// Volumes are mounted by the containers above through their name Volumes []Volume `xcl:"volume,block" json:"volume,omitempty"`}
// Container is the nested `container` block of a deployment. It is not a// resource of its own: it is not registered as a block type, so it has no// metadata and can not be referenced by its own id. Its fields are reached// through the deployment that holds it, i.e.// `deployment.api.container[0].port[0].container_port`.type Container struct { Name string `xcl:"name" json:"name"` Image string `xcl:"image" json:"image"`
Ports []Port `xcl:"port,block" json:"port,omitempty"` Env []EnvVar `xcl:"env,block" json:"env,omitempty"`
// Resources appears at most once, so it is a pointer and is nil when the // container does not set it Resources *ResourceRequirements `xcl:"resources,block" json:"resources,omitempty"`
VolumeMounts []VolumeMount `xcl:"volume_mount,block" json:"volume_mount,omitempty"`}from __future__ import annotations
from dataclasses import dataclass
import xcl
# ConfigMap defines the block type `config_map`, a bag of values the# deployment reads. A map attribute holds values whose names are not known# ahead of time, and a single value can be read from it with# `config_map.api.data.db_host`.@dataclassclass ConfigMap: data: dict[str, str] meta: xcl.Meta | None = None
# Secret defines the block type `secret`, values that must not be seen. Data# is an xcl.Sensitive, so it is masked in output and events and encrypted in# state when the state has a key. A secret read back through a lookup holds# no value at all: str() of it is (sensitive) and reveal() returns None.## As in Kubernetes, nothing else holds a secret's values: a container reads# one through a secret_key_ref naming the secret and the key, which are plain# strings, so the deployment never becomes sensitive itself.@dataclassclass Secret: data: xcl.Sensitive[dict[str, str]] meta: xcl.Meta | None = None
# Container is the nested `container` block of a deployment. It is not an# entity of its own, so it is not registered and can not be referenced by# its own id, its fields are reached through the deployment that holds it,# i.e. `deployment.api.container[0].port[0].container_port`.## register_type reads a class's annotations when it is called, so every# class a registered type names must exist by then.@dataclassclass Container: name: str image: str
port: list[Port] = xcl.field(default_factory=list) env: list[EnvVar] = xcl.field(default_factory=list)
# resources appears at most once, so it is optional and is None when the # container does not set it resources: ResourceRequirements | None = None
volume_mount: list[VolumeMount] = xcl.field(default_factory=list)
@dataclassclass Port: name: str container_port: int
@dataclassclass EnvVar: name: str value: str | None = None
# value_from is set in place of value when the value comes from a secret value_from: EnvVarSource | None = None
@dataclassclass EnvVarSource: secret_key_ref: SecretKeySelector | None = None
@dataclassclass SecretKeySelector: name: str key: str
@dataclassclass ResourceRequirements: limits: ResourceQuantities | None = None requests: ResourceQuantities | None = None
@dataclassclass ResourceQuantities: cpu: str | None = None memory: str | None = None
@dataclassclass VolumeMount: name: str path: str
@dataclassclass Volume: name: str config_map: str
# Deployment defines the block type `deployment`. It holds repeated container# blocks, each of which nests further blocks of its own. The attribute name# is the block name used in configuration, so a repeated `container` block# is the `container` list.@dataclassclass Deployment: meta: xcl.Meta | None = None
replicas: int | None = None
# container is a repeated block, one entry per container block in the # configuration. A repeated block is a list, a block that appears once is # an optional field. container: list[Container] = xcl.field(default_factory=list)
# volume blocks are mounted by the containers above through their name volume: list[Volume] = xcl.field(default_factory=list)The service type shows why this is more than a manifest. Kubernetes matches
a service to its pods with a label selector, because a manifest has no way to
point at another object. Configuration parsed by xcl does, so the service
names the deployment by its id and reads the port out of it, and the two can
not drift apart.
// Service defines the block type `service`, it routes traffic to the// containers of a deployment.//// Kubernetes matches a service to its pods with a label selector because a// manifest has no way to point at another object. Configuration parsed by xcl// does, so the service names the deployment by its id and reads the port out// of it, and the two can not drift apart.type Service struct { Meta types.Meta
Deployment string `xcl:"deployment" json:"deployment"` Port int `xcl:"port" json:"port"` TargetPort int `xcl:"target_port" json:"target_port"`}
// Ingress defines the block type `ingress`, it routes a host to a servicetype Ingress struct { Meta types.Meta
Host string `xcl:"host" json:"host"` Rules []Rule `xcl:"rule,block" json:"rule"`}
// Rule is a nested `rule` block of an ingress, sending one path to one// service, named by its idtype Rule struct { Path string `xcl:"path" json:"path"` Service string `xcl:"service" json:"service"` Port int `xcl:"port" json:"port"`}# Service defines the block type `service`, it routes traffic to the# containers of a deployment.## Kubernetes matches a service to its pods with a label selector because a# manifest has no way to point at another object. Configuration parsed by xcl# does, so the service names the deployment by its id and reads the port out# of it, and the two can not drift apart.@dataclassclass Service: deployment: str port: int target_port: int meta: xcl.Meta | None = None
# Rule is a nested `rule` block of an ingress, sending one path to one# service, named by its id@dataclassclass Rule: path: str service: str port: int
# Ingress defines the block type `ingress`, it routes a host to a service@dataclassclass Ingress: host: str rule: list[Rule] = xcl.field(default_factory=list) meta: xcl.Meta | None = NoneThe configuration
Three files in one directory make one configuration: variables, a map attribute, blocks nested inside blocks, blocks that repeat, and references between entities, including a reference into a repeated block by position.
# The configuration only example: a small Kubernetes-like deployment, parsed# into the Go types in ../resources. Nothing here is applied to a cluster, the# blocks are decoded into Go structs and the references between them are# resolved.## Variables can be overridden with environment variables prefixed XCL_VAR_,# i.e. XCL_VAR_image_tag.variable "image_tag" { default = "1.2.0"}
variable "replicas" { default = 3}
# config_map holds values whose names are not known to the Go type, its data# is a map. The deployment reads single values out of it below.config_map "api" { data = { db_host = "postgres.default.svc" log_level = "info" }}
deployment "api" { replicas = variable.replicas
# container is a repeated block, this deployment has two of them container { name = "api" image = "ghcr.io/example/api:${variable.image_tag}"
# port is repeated in turn, a block nested inside a block port { name = "http" container_port = 8080 }
port { name = "metrics" container_port = 9090 }
# env values are read from the config map, so they are declared once env { name = "DB_HOST" value = config_map.api.data.db_host }
env { name = "LOG_LEVEL" value = config_map.api.data.log_level }
# the password comes from a secret, the deployment holds only the # secret's name and the key to read, never the password itself env { name = "DB_PASSWORD"
value_from { secret_key_ref { name = secret.db.meta.name key = "password" } } }
# resources nests two more blocks of its own resources { limits { cpu = "500m" memory = "512Mi" }
requests { cpu = "100m" memory = "128Mi" } }
volume_mount { name = "config" path = "/etc/api" } }
container { name = "proxy" image = "ghcr.io/example/proxy:0.4.1"
port { name = "proxy" container_port = 8081 } }
volume { name = "config" config_map = config_map.api.meta.id }}# The service is linked to the deployment by its id. Kubernetes would match# the two with a label selector, because a manifest can not point at another# object; here the reference does it directly.## The port it sends traffic to is read out of the deployment as well. A# repeated block is referenced by position, container[0] is the first# container block.service "api" { deployment = deployment.api.meta.id port = 80 target_port = deployment.api.container[0].port[0].container_port}
# The ingress is linked to the service the same way, by id and port.# References order the blocks too, so the service is always parsed before the# ingress that reads it.ingress "api" { host = "api.example.com"
rule { path = "/" service = service.api.meta.id port = service.api.port }}
output "api_url" { value = "https://${ingress.api.host}"}# The secret holds the database password. Its data is sensitive, so it is# masked in output and events and encrypted in state. The password is read# from the environment when the configuration is parsed, so it never appears# in this file.secret "db" { data = { password = env("DB_PASSWORD") }}The program
The program does three things: load the configuration into a struct of its own, work out the routes, and print them.
loadConfig declares each Go type under the block type name used in
configuration, on a local registry it gives to the Config, applies the
configuration and gathers it with one Decode call. Each slice field receives
every block of its type, in the order the blocks were written, so the configuration can declare as many of each as it
needs. The struct holds only what the program reads; reading a new block type
is one new field, with no lookup code.
// appConfig is the application's view of the configuration, filled by one// Decode call. A slice field receives every block of its registered type, in// the order the blocks were written, so the configuration can declare as many// of each as it needs. It holds only what the program reads, reading a new// block type needs only a new field.type appConfig struct { Deployments []*resources.Deployment Services []*resources.Service Ingresses []*resources.Ingress}
// loadConfig declares the example's block types, applies the configuration in// dir with the caller's options, and gathers it into an appConfig.func loadConfig(dir string, options ...xcl.ConfigOption) (*appConfig, error) { // Declare each Go type under the block type name used in configuration, // on a local registry. A declared type needs nothing else: no plugin, no // provider, no schema to write by hand. Every type the configuration // declares must be declared here, even the ones the program does not // read. local := registry.NewLocal() registry.Register[resources.ConfigMap](local, "config_map") registry.Register[resources.Secret](local, "secret") registry.Register[resources.Deployment](local, "deployment") registry.Register[resources.Service](local, "service") registry.Register[resources.Ingress](local, "ingress")
c, err := xcl.NewConfig(append([]xcl.ConfigOption{xcl.WithRegistry(local)}, options...)...) if err != nil { return nil, err }
if err := c.Apply(dir); err != nil { return nil, fmt.Errorf("loading configuration from %s: %w", dir, err) }
// gather the configuration into the application's own struct, Decode is // an ordinary method so this works on every supported Go version var cfg appConfig if err := c.Decode(&cfg); err != nil { return nil, fmt.Errorf("reading configuration from %s: %w", dir, err) }
return &cfg, nil}Decode has no Python form, so the Python program fills its own dataclass with one all call per block type it reads.
import sysfrom dataclasses import dataclass
import xcl
import prettylogfrom resources import ConfigMap, Deployment, Ingress, Secret, Service
# AppConfig is the application's view of the configuration. A list field# receives every block of its type, in the order the blocks were written, so# the configuration can declare as many of each as it needs. It holds only# what the program reads, reading a new block type needs only a new field.@dataclassclass AppConfig: deployments: list[Deployment] services: list[Service] ingresses: list[Ingress]
# load_config declares the example's block types, applies the configuration in# path with the caller's options, and gathers it into an AppConfig.def load_config(path: str, **options) -> AppConfig: # Declare each class under the block type name used in configuration, on # a local registry. A declared type needs nothing else: no plugin, no # provider, no schema to write by hand. Every type the configuration # declares must be declared here, even the ones the program does not # read. local = xcl.LocalRegistry() local.register_type(ConfigMap, "config_map") local.register_type(Secret, "secret") local.register_type(Deployment, "deployment") local.register_type(Service, "service") local.register_type(Ingress, "ingress")
with xcl.Config(registries=[local], **options) as cfg: cfg.apply(path)
# gather the configuration into the application's own dataclass, one # lookup per block type the program reads return AppConfig( deployments=cfg.all(Deployment), services=cfg.all(Service), ingresses=cfg.all(Ingress), )loadConfig takes its options from the caller. run sends xcl’s
events to the example’s pretty terminal receiver, and passes no
state option, so nothing is written to disk. A test passes no options at all
and loads the configuration the same way. With the routes worked out, run
prints one line per route:
// run loads the configuration in dir, works out its ingress routes and prints// one line per route.func run(dir string) error { cfg, err := loadConfig(dir, // Every event xcl produces goes to the pretty receiver on stderr, so // stdout holds only the routes xcl.WithEventHandler(prettylog.Handler(os.Stderr, prettylog.LevelFromEnv())), // events carry nothing by default, this asks for each resource as it // is processed, which is what the receiver turns back into // configuration text. Sensitive fields are redacted in events. xcl.WithEventData(xcl.EventDataProcessed), ) if err != nil { return err }
routes, err := ingressRoutes(cfg) if err != nil { return err }
for _, route := range routes { fmt.Println(route) }
return nil}# run loads the configuration in path, works out its ingress routes and prints# one line per route. An error is raised as an exception rather than returned.def run(path: str) -> None: cfg = load_config( path, # Every event xcl produces goes to the pretty receiver on stderr, so # stdout holds only the routes on_event=prettylog.handler(sys.stderr, prettylog.level_from_env()), # events carry nothing by default, this asks for each entity as it # is processed, which is what the receiver turns back into # configuration text. Sensitive fields are redacted in events. event_data="processed", )
for route in ingress_routes(cfg): print(route)xcl writes nothing unless a receiver is set. The receiver needs nothing from
the program but event data at xcl.EventDataProcessed: each event returns the
entity it carries, typed by the Config that delivered it.
ingressRoutes is the program’s business logic, plain Go over the decoded
types with no xcl calls. Because the configuration links the blocks by id, each
hop is a lookup:
// ingressRoutes works out a Route for every rule of every ingress, in the// order they are declared.//// The configuration links the blocks by id: a rule names its service, and a// service names its deployment, so each hop is a lookup rather than a match on// labels as Kubernetes would do. The last hop is the service's target port,// which is found on the first container port, in declaration order, that// listens on it.//// A rule whose service, deployment or target port can not be found is an// error naming the ingress and path, a route is never silently left out.func ingressRoutes(cfg *appConfig) ([]Route, error) { services := map[string]*resources.Service{} for _, service := range cfg.Services { services[service.Meta.ID] = service }
deployments := map[string]*resources.Deployment{} for _, deployment := range cfg.Deployments { deployments[deployment.Meta.ID] = deployment }
routes := []Route{}
for _, ingress := range cfg.Ingresses { for _, rule := range ingress.Rules { service, ok := services[rule.Service] if !ok { return nil, fmt.Errorf("ingress %s path %s: service %q not found", ingress.Meta.ID, rule.Path, rule.Service) }
deployment, ok := deployments[service.Deployment] if !ok { return nil, fmt.Errorf("ingress %s path %s: deployment %q of service %s not found", ingress.Meta.ID, rule.Path, service.Deployment, service.Meta.ID) }
container, port, ok := findContainerPort(deployment, service.TargetPort) if !ok { return nil, fmt.Errorf("ingress %s path %s: no container in %s exposes port %d", ingress.Meta.ID, rule.Path, deployment.Meta.ID, service.TargetPort) }
routes = append(routes, Route{ Host: ingress.Host, Path: rule.Path, Service: service.Meta.ID, ServicePort: rule.Port, Deployment: deployment.Meta.ID, Container: container.Name, PortName: port.Name, Port: port.ContainerPort, }) } }
return routes, nil}
// findContainerPort returns the first container of deployment, and its port,// that listens on containerPort. ok is false when no container does.func findContainerPort(deployment *resources.Deployment, containerPort int) (container resources.Container, port resources.Port, ok bool) { for _, container := range deployment.Containers { for _, port := range container.Ports { if port.ContainerPort == containerPort { return container, port, true } } }
return resources.Container{}, resources.Port{}, false}# ingress_routes works out a Route for every rule of every ingress, in the# order they are declared.## The configuration links the blocks by id: a rule names its service, and a# service names its deployment, so each hop is a lookup rather than a match on# labels as Kubernetes would do. The last hop is the service's target port,# which is found on the first container port, in declaration order, that# listens on it.## A rule whose service, deployment or target port can not be found raises a# ValueError naming the ingress and path, a route is never silently left out.def ingress_routes(cfg: AppConfig) -> list[Route]: services = {service.meta.id: service for service in cfg.services} deployments = {deployment.meta.id: deployment for deployment in cfg.deployments}
routes: list[Route] = []
for ingress in cfg.ingresses: for rule in ingress.rule: service = services.get(rule.service) if service is None: raise ValueError(f'ingress {ingress.meta.id} path {rule.path}: service "{rule.service}" not found')
deployment = deployments.get(service.deployment) if deployment is None: raise ValueError( f'ingress {ingress.meta.id} path {rule.path}: deployment "{service.deployment}" of service {service.meta.id} not found' )
found = find_container_port(deployment, service.target_port) if found is None: raise ValueError( f"ingress {ingress.meta.id} path {rule.path}: no container in {deployment.meta.id} exposes port {service.target_port}" )
container, port = found routes.append( Route( host=ingress.host, path=rule.path, service=service.meta.id, service_port=rule.port, deployment=deployment.meta.id, container=container.name, port_name=port.name, port=port.container_port, ) )
return routes
# find_container_port returns the first container of deployment, and its port,# that listens on container_port, or None when no container does.def find_container_port(deployment: Deployment, container_port: int) -> tuple[Container, Port] | None: for container in deployment.container: for port in container.port: if port.container_port == container_port: return container, port
return NoneTesting it
The route logic is what an application author would test, and it is tested
the way they would test their own configuration-driven code: against a test
configuration kept beside the code, separate from the one the program ships
with, loaded through the same loadConfig. The test configuration has the
example’s shape with different values, two services and an ingress with two
paths, so a passing test proves the links are followed rather than matching
the defaults.
service "web" { deployment = deployment.web.meta.id port = 80 target_port = deployment.web.container[0].port[0].container_port}
service "api" { deployment = deployment.api.meta.id port = 9090 target_port = deployment.api.container[1].port[0].container_port}
ingress "shop" { host = "shop.test"
rule { path = "/" service = service.web.meta.id port = service.web.port }
rule { path = "/api" service = service.api.meta.id port = service.api.port }}func TestIngressRoutesReportsEveryIngressPath(t *testing.T) { cfg := loadTestConfig(t, "routes")
routes, err := ingressRoutes(cfg) require.NoError(t, err)
require.Equal(t, []Route{ { Host: "shop.test", Path: "/", Service: "service.web", ServicePort: 80, Deployment: "deployment.web", Container: "web", PortName: "http", Port: 3000, }, { Host: "shop.test", Path: "/api", Service: "service.api", ServicePort: 9090, Deployment: "deployment.api", Container: "api", PortName: "grpc", Port: 7000, }, }, routes)}def test_ingress_routes_reports_every_ingress_path(): cfg = load_config("testdata/routes")
routes = ingress_routes(cfg)
assert routes == [ Route( host="shop.test", path="/", service="service.web", service_port=80, deployment="deployment.web", container="web", port_name="http", port=3000, ), Route( host="shop.test", path="/api", service="service.api", service_port=9090, deployment="deployment.api", container="api", port_name="grpc", port=7000, ), ]Each way a link can fail to resolve, an unknown service, an unknown deployment or a target port no container exposes, has a small configuration and a test of its own:
func TestIngressRoutesFailsForUnknownService(t *testing.T) { cfg := loadTestConfig(t, "unknown_service")
_, err := ingressRoutes(cfg) require.Error(t, err) require.ErrorContains(t, err, "ingress ingress.shop path /") require.ErrorContains(t, err, `service "service.missing" not found`)}def test_ingress_routes_fails_for_unknown_service(): cfg = load_config("testdata/unknown_service")
with pytest.raises(ValueError, match="ingress ingress.shop path /") as raised: ingress_routes(cfg)
assert 'service "service.missing" not found' in str(raised.value)A smoke test builds the program and runs it against ./config, checking it
prints the default route.
Run it
From the example/configonly directory:
make runThe events go to standard error, trimmed here to a few of each. Every step is
logged at info, core is xcl itself, and each line names the entity and the
file it came from. The level is read from XCL_LOG_LEVEL and defaults to
info. Each created entity is shown as configuration text, with the secret’s
data masked:
3:25PM INFO apply start source=core operation=apply phase=start3:25PM INFO parse success source=core operation=parse phase=success resource=config_map.api type=config_map.api file=config/deployment.xcl3:25PM INFO parse success source=core operation=parse phase=success resource=deployment.api type=deployment.api file=config/deployment.xcl3:25PM INFO parse success source=core operation=parse phase=success resource=service.api type=service.api file=config/ingress.xcl3:25PM INFO parse success source=core operation=parse phase=success resource=secret.db type=secret.db file=config/secret.xcl3:25PM INFO create success source=core operation=create phase=success resource=secret.db type=secret.db file=config/secret.xcl secret "db" { data = "(sensitive)" }3:25PM INFO create success source=core operation=create phase=success resource=service.api type=service.api file=config/ingress.xcl service "api" { deployment = "deployment.api" port = 80 target_port = 8080 }3:25PM INFO create success source=core operation=create phase=success resource=ingress.api type=ingress.api file=config/ingress.xcl ingress "api" { host = "api.example.com"
rule { path = "/" service = "service.api" port = 80 } }3:25PM INFO apply success source=core operation=apply phase=success duration=6.934216msNo declared type has a provider, so each block goes straight to create success with no start in between. The program’s own output, on standard
output, is one line per ingress path:
api.example.com/ -> service.api:80 -> deployment.api container api port http (8080)What to notice
- Routes follow ids. The ingress names the service and the service names the deployment, so the program finds where traffic goes with a lookup per hop, and a link that can not be followed is an error naming the ingress and path.
- Values are declared once. The service’s
target_portis read out of the deployment’s first container port, so changing the container port changes the service too. The environment variables come from the config map the same way. - Entities are linked, not matched. The service names the deployment by id and the ingress names the service by id. There is no selector to keep in sync by hand.
- References order the graph. The ingress reads the service, which reads the deployment, which reads the config map, so they are always parsed in that order, and blocks that do not depend on each other are processed concurrently.
- Nesting is just Go.
container[0].port[0]in configuration isContainers[0].Ports[0]in Go, and an omitted block is nil. - No state is kept. The program passes no state option, so xcl writes nothing to disk and every run reads the configuration afresh. See Registries.
- One call gathers the configuration.
Decodefills the program’s own struct by field type, so there is no lookup per block type, and a new block type needs only a new field.