Introduction to the cue tooling layer
So you have used cue
to create your configuration, but how to you actually do
something with it? Write a bash script to wrap calls to cue
?
One special feature of cue
compared to other configuration languages is
be ability to execute things from your configuration. After all, cue
means
Configure, Unify, Execute and in this article we'll explore the execute part of
cue
.
We call this execution part of cue
the tooling layer or scripting layer.
This tooling layer gives to the user an easy way to actually do something with
the configuration directly in cue
without relying on shell scripting. For
example you might want to write your configuration in a file and
run a cli tool with it or even apply kubernetes
resource manifests you have
defined to a cluster.
Instead of wrapping the configuration in code or scripts, we wrap the code that
exploit the configuration inside the configuration itself (oh my that's meta!)
and use cue
to run the code.
While a normal cue
configuration evaluation is hermetic, the tooling layer
allows to interact with the outside world by allowing side effects such as
writing files or making http calls. Theses kind of side effects can only be
declared in <something>_tool.cue
files and be run with the cue cmd
subcommand. When using cue eval
or cue export
the tool files won't be
evaluated at all.
Let's dive in by defining a very simple cue
command.
Defining commands and tasks
cue
will look for a command
field at the root of _tool.cue
files to
determine the list of available commands.
Let's create our first command in a file that ends with _tool.cue
:
package foo
import "tool/cli"
command: "hello-world": cli.Print & {
text: "Hello!"
}
We have defined an hello-world
command which uses the task cli.Print
. This
task simply output text on stdout.
Let's run it!
cue cmd hello-world
Hello!
# The command is also exposed directly as a subcommand of `cue`
cue hello-world
Hello!
A command can contain multiple tasks and you can organize them as you want. There is no particular structure to follow when defining tasks inside commands. For example, this is fine:
package foo
import "tool/cli"
command: "hello-world": {
print: something: on: screen: cli.Print & {
text: "Hello!"
}
another: {
task: cli.Print & {
text: "Woot!"
}
}
}
A task consists of a cue
schema (cli.Print
in this example) and some go
code builtin in the cue
cli that will be executed when you run the command.
The definition of a task is just some data like your configuration. It is not a
function call or something like that. Because it is just data, you can use any
cue
construct to generate or define tasks.
Task types
There aren't many builtin tasks provided by cue
but the ones that are available
are pretty generic and allows you to derive specific tasks you might need in your
project.
The different tasks you can use in the tooling layer can be found here.
Currently you can find tasks to manipulate files, execute commands, make HTTP calls.
Discovering commands
cue help cmd
can list the different commands you have defined:
[...]
Available Commands:
hello-world
oops
[...]
It is possible to provide some more "special" fields or a comment on the command to improve the output:
command: {
// Greeting command
//
// Greets you on the command line
"hello-world": cli.Print & {
text: "Hello!"
}
"hello-world-2": cli.Print & {
$short: "Gretting command"
$long: "Greets you on the command line"
text: "Hello!"
}
}
cue help cmd
[...]
Available Commands:
hello-world Greeting command
hello-world-2 Gretting command
[...]
cue help cmd hello-world
Greets you on the command line
Usage:
cue cmd hello-world [flags]
[...]
Command options
In some cases a command might need some dynamic input provided by the user. This could be an HTTP API endpoint URL, an optional flag to trigger some specific tasks etc...
The cue
tooling layer doesn't provide any specific feature for this but we can
use the generic injection mechanism of cue
to handle this (cue help injection
).
For example in our greeting command let's add field that can customize the greeting output:
package foo
import "tool/cli"
who: *"world" | string @tag(who)
command: "hello-world": cli.Print & {
text: "Hello \(who)!"
}
The field who
has a default value of world
but it can be changed using the
tag who
on the cli:
cue cmd hello-world
Hello world!
cue cmd -t who=cue hello-world
Hello cue!
With injection all cue
unification constraint rules still applies.
Note: because of some parsing limitation the -t k=v
option must be written
before the name of the command.
Handling dependencies between tasks
It is common to have multiple tasks that must be run in a specific order.
To handle this situation cue
resolve dependencies between tasks much like
terraform
does it between resources.
If a task references some field from another task, cue
will automatically
treat this as a dependency:
package foo
import (
"tool/file"
"tool/cli"
)
command: foo: {
cmd1: file.Read & {
filename: "file.txt"
contents: string
}
cmd2: cli.Print & {
text: cmd1.contents
}
}
In this example we can see that cmd2.text
field has a reference to
cmd1.contents
field. Because of this cmd2
will be run after cmd1
has been
run successfully.
We can also see here a very powerful feature of cue
tasks. The
cmd1.contents
isn't concrete and will be resolved at runtime when the file is
actually read. The value will be filled by cue
in the document and must respect
unification constraint rules. In this case the value must be a valid string
.
Once cmd1.contents
is filled with a concrete value the cmd2
can be run.
Much like terraform
depends_on
, if a task needs to be run after some other
but doesn't reference any field of the other task you can simply
add a field that references the other task:
package foo
import (
"tool/file"
"tool/exec"
)
command: foo: {
cmd1: exec.Run & {
cmd: "mkdir -p generated"
}
cmd2: file.Create & {
$after: cmd1
filename: "./generated/file.txt"
contents: "foo"
}
}
In this example we introduce an $after
field in cmd2
that references
cmd1
to materialize the dependency between the two tasks.
Since file.Create
is not a definition you can define any field name you'd
like, it just needs to not clash with file.Create
fields in this example.
$after
has no particular meaning for the cue
tooling layer.
Dynamic tasks
Until now we defined tasks that didn't use any real configuration. Let's see how tasks can refer to and use some configuration.
Imagine we manage a list of users in a cue
configuration and we want to
provision them in some API.
First, we want first a clear schema of what a user is and define some:
package users
// This is what a user look like
#User: {
username: string
first_name: string
last_name: string
email: =~"@example.com$"
role: *"developer" | "admin"
}
users: [Username=string]: #User & {username: Username}
// Our list of users
users: {
jdoe: {
first_name: "John"
last_name: "Doe"
email: "jdoe@example.com"
role: "admin"
}
fday: {
first_name: "Francis"
last_name: "Day"
email: "francis@example.com"
}
}
This is a pretty simple cue
configuration. We have a #User
definition which constraints
the users
struct values. Two users are defined.
Next we want to create these users in some API. We can create a cue
command for this
by using the tool/http
package.
I'm using https://requestbin.com to post the data.
package users
import (
"tool/http"
"encoding/json"
)
command: create: {
for u in users {
// Generate a task for every user
"\(u.username)": http.Post & {
// Our dummy API
url: "https://enelnux7735ki.x.pipedream.net/users"
request: {
header: "Content-Type": "application/json"
// Marshal the user info to JSON
body: json.Marshal(u)
}
// We expect a 200 HTTP code from the API
// Other codes will make the task fail.
response: statusCode: 200
}
}
}
What's interesting here is that we use normal cue
constructs to generate
the tasks (one per user). With a simple comprehension we can generate a task
for every user we need to provision.
We can also transform the data that is sent to the API. Here we marshal it to JSON. But we could imagine also filtering out some fields or adding other that would be required by the API.
And finally the success of the tasks is determined by unification. I've defined
response.statusCode
to 200
so that if the API respond with some other code
the unification will fail because cue
will try to unify 200
with some other
value and, thus, the task will fail.
Going further
With a classic REST API this probably won't work as once a user is created in the system a POST request on an existing user will most likely trigger an HTTP 400 response.
Can cue
handle this ? Well yes since cue
is injecting HTTP calls response
data and status in the document it should be possible to specialize tasks based
on some field result, or even inject new tasks.
In our imaginary API, to determine if we need to create or update a user we first need to get the user and then based on the status code proceed with the appropriate HTTP call (POST or PUT).
package users
import (
"tool/http"
"encoding/json"
)
users_api_base_url: "https://my-user-api.example.com/users"
command: create: {
for u in users {
// Get user from the API
"\(u.username)": http.Get & {
url: "\(users_api_base_url)/\(u.username)"
// We handle only these status codes
response: statusCode: 200 | 404
}
// Common data for creating / updating a user
"create_or_update_\(u.username)": http.Do & {
request: {
header: "Content-Type": "application/json"
body: json.Marshal(u)
}
}
// User doesn't exists, do a POST on the users/ url
if create[u.username].response.statusCode == 404 {
"create_or_update_\(u.username)": http.Post & {
url: users_api_base_url
}
}
// User exists, do a PUT on users/username url
if create[u.username].response.statusCode == 200 {
"create_or_update_\(u.username)": http.Put & {
url: "\(users_api_base_url)/\(u.username)"
}
}
}
}
It looks like we are doing imperative code but it's not. We're still defining
data and specializing the create_or_update
task based on the result of the
get
task.
As you can see, a lot can be done using the cue
tooling layer, but don't get
too crazy!
Under the hood
So we've seen that tasks are just data like any cue
configuration but the difference
is that cue
will run some code associated with each particular task. So how
does it know which code has to be run ?
If we look at cli.Print
documentation
we see:
// Print sends text to the stdout of the current process.
Print: {
$id: *"tool/cli.Print" | "print" // for backwards compatibility
// text is the text to be printed.
text: string
}
Every task as an $id
field which is unique between tasks. When cue
evaluates the command
it will walk all values and find all tasks denoted by this $id
field. This is an
implementation detail that may change in the future.
The value of the $id
field is used to know which go code has to be run for a
particular task.
Note also that tasks are not proper cue
definitions as they should be. This
is for historic reasons because they were introduced in cue
before definitions.
Let's give cue
an unknown task to run and see what happens:
package foo
command: oops: {
$id: "tool/cli.Oops"
text: "Hello!"
}
cue cmd oops
runner of kind "tool/cli.Oops" not found:
./oops_tool.cue:3:10
Right, cue
has found that this is a potential task but it has no go code registered
to run it so it exits with an error.
Currently there is no way to provide additional tasks to the cue
cli but that
may be possible in the future.
Tooling layer caveats
I noticed that if something is wrong in a _tool.cue
file the error reporting
is not very good when using cue cmd my-command
. In such cases I try to put
a maximum of code outside of the _tool.cue
files (but no tasks obviously), and
debug the issue with cue eval
.
In some cases the tasks dependencies are not properly discovered when using comprehensions or guards, related issue: https://github.com/cue-lang/cue/issues/1088
You can't control error handling. If some task fails you cannot control what needs
to happen next, cue
will stop the command right there and exit.
You can't run commands that are declared in imported modules/packages. This would be a neat feature to allow package authors to distribute associated commands easily.
Conclusion
In conclusion the cue
tooling layer is just a way to describe side effects as
data to exploit your configuration.
Because tasks are just structured data you benefit of all cue
constructs
and unification guarantees.
No need to export the data and importing it back in some script or other tool to run actions, everything is contained and driven by the configuration itself!
In a next article we will explore the
tool/flow
API from the
cue
go lib that is used by cue cmd
.