§ Reference · Language

Modules

A module is a CUE definition or package that contains resources. Instantiating it is enough. pravo finds the resources inside the instance.

View as Markdown

On this page

There is no module block and nothing to wire. A reusable unit is a definition, or a package of definitions. Its resources are written as in a root. The instance sits at a path, and that path is the prefix of the addresses of the resources inside.

Form Written as Resource address
Definition in the root api: #Service & {name: "api"} api.secret
Several instances services: {for n, p in ports {"\(n)": #Service & {...}}} services.web.secret
Package in the module api: svc.#Service & {...} api.secret
Package with a policy $eu: policy.#Random the addresses of the resources built through $eu

Inputs are the fields of the definition. A required input is a field with no default. Constraints on the fields do the work of validation.

Publishing, locking and fetching modules are commands: see mod, Publish a module, Use a module and Share code between roots.

Definitions

Work in an empty directory. Write the module path first. A root is inside a CUE module, and pravo init creates cue.mod/module.cue when none exists.

cue.mod/module.cue
module: "example.com/infra@v0"
language: version: "v0.14.0"
main.cue
package infra

import (
	rnd "pravo.dev/providers/hashicorp/random"
	loc "pravo.dev/providers/hashicorp/local"
)

$random: rnd.#Provider
$local:  loc.#Provider

#Service: {
	name: string
	port: int & >1024

	secret: $random.#Resource.random_password & {length: 24, special: false}
	config: $local.#Resource.local_file & {
		filename: "out/\(name).json"
		content:  "{\"port\": \(port)}"
	}
}

api: #Service & {name: "api", port: 8080}

services: {
	for n, p in {web: 3000, worker: 3001} {
		"\(n)": #Service & {name: n, port: p}
	}
}
terminal
$pravo init > /dev/null
$pravo plan | grep -E '^ #|^Plan'
  # api.secret (random_password) will be created
  # api.config (local_file) will be created
  # services.web.secret (random_password) will be created
  # services.web.config (local_file) will be created
  # services.worker.secret (random_password) will be created
  # services.worker.config (local_file) will be created
Plan: 6 to add, 0 to change, 0 to destroy.

The resources inside are built through the providers of the package, here $random and $local. A reference such as api.config.filename is a reference to the resource.

A constraint on an input is checked when the specification is evaluated:

main.cue
package infra

#Service: {
	name: string
	port: int & >1024
}

api: #Service & {name: "api", port: 80}
terminalexit 6
$pravo validate
Error: evaluating .:
api.port: invalid value 80 (out of bound >1024):
    ./main.cue:5:14
    ./main.cue:8:37

A required input that is left out stays non-concrete. The plan stops at the first value that needs it:

main.cue
package infra

import loc "pravo.dev/providers/hashicorp/local"

$local: loc.#Provider

#Service: {
	name: string
	config: $local.#Resource.local_file & {
		filename: "out/\(name).json"
		content:  "{}"
	}
}

api: #Service
terminalexit 1
$pravo plan
Error: api.config.filename: value depends on #Service.name, which is not concrete (is a -t tag missing?)

Name the loop variable differently from the input it fills. In {name: n, port: port} the right-hand port is the field itself, which is never concrete.

Packages

To share a module between roots, put the definitions in a CUE package and import it. The root must be inside a CUE module. A package cannot see the providers of the root that imports it, so it takes them as inputs and builds its resources through them.

svc/svc.cue
package svc

import (
	rnd "pravo.dev/providers/hashicorp/random"
	loc "pravo.dev/providers/hashicorp/local"
)

#Service: {
	name:    string
	random!: rnd.#Provider
	local!:  loc.#Provider

	secret: random.#Resource.random_password & {length: 16}
	config: local.#Resource.local_file & {
		filename: "\(name).txt"
		content:  secret.result
	}
}
main.cue
package infra

import (
	rnd "pravo.dev/providers/hashicorp/random"
	loc "pravo.dev/providers/hashicorp/local"
	"example.com/infra/svc"
)

$random: rnd.#Provider & {$version: "~> 3.6"}
$local:  loc.#Provider

api: svc.#Service & {name: "api", random: $random, local: $local}
terminal
$pravo init > /dev/null
$pravo plan | grep -E '^ #|^Plan'
  # api.secret (random_password) will be created
  # api.config (local_file) will be created
Plan: 2 to add, 0 to change, 0 to destroy.

Providers in a module

A module that is published or reused declares each provider it needs as a required input of type #Provider, provider!: loc.#Provider, and builds its resources through it. The caller passes a provider struct. The input is an alias of that struct, not a second instance: the resource is recorded under the provider instance of the caller. There is no line on the resource that names the provider. Building it through provider is enough.

A module with several providers declares one input for each, and the caller passes them all, as random: $random, local: $local above. A module written in the same package as its provider may refer to a top-level provider directly. That ties it to the package, so a module meant for other roots takes its providers as inputs.

main.cue
package infra

import loc "pravo.dev/providers/hashicorp/local"

#Note: {
	provider!: loc.#Provider
	text:      string
	file: provider.#Resource.local_file & {
		filename: "out/\(text).txt"
		content:  "\(text)\n"
	}
}

$primary:   loc.#Provider & {$version: "~> 2.5"}
$secondary: loc.#Provider

one: #Note & {provider: $primary, text: "one"}
two: #Note & {provider: $secondary, text: "two"}
terminal
$pravo apply --auto-approve | tail -n 1
$pravo state query '{for a, r in resources {"\(a)": r.provider_key}}'
$pravo destroy --auto-approve | tail -n 1
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]
{
	"one.file": "local.$primary"
	"two.file": "local.$secondary"
}
Destroy complete! Resources: 0 added, 0 changed, 2 destroyed. [txn <id>]

An instance that passes no provider

A required input that an instance leaves out is an error when the specification loads. The message names the instance and the field. No provider starts, and no resource is built through a provider that nobody configured.

main.cuerejected
package infra

import loc "pravo.dev/providers/hashicorp/local"

#Note: {
	provider!: loc.#Provider
	text:      string
	file: provider.#Resource.local_file & {
		filename: "out/\(text).txt"
		content:  "\(text)\n"
	}
}

$primary: loc.#Provider

one: #Note & {provider: $primary, text: "one"}
two: #Note & {text: "two"}
terminalexit 6
$pravo validate
Error: provider two.provider was not supplied: two requires a hashicorp/local provider in its field provider. Pass one, for example
	two: ... & {provider: $local}

A field without the ! is a provider struct of its own. An instance that leaves it out builds its resources through that struct, a second instance of the provider with no configuration, and nothing warns. Pitfalls shows the run. A required input has no such default.

Configuration in the module and in the caller

The instance of a passed-in provider is configured from the caller's struct. The input is another name for that struct, so what the module writes on it is checked against what the caller wrote.

The module writes The caller's struct Result
nothing any The caller's configuration.
a value the same value The caller's configuration.
a value another value for the same field CUE reports conflicting values, with the path of the field in the instance.
a value nothing for that field An error that names the field. The module cannot configure a provider it was given.

Neither side wins a conflict: the specification does not load. The provider tls takes its proxy in configuration, so it can show both errors.

main.cuerejected
package infra

import tlsp "pravo.dev/providers/hashicorp/tls"

#Key: {
	tls!: tlsp.#Provider & {proxy: [{url: "http://module.internal:3128"}]}
	key:  tls.#Resource.tls_private_key & {algorithm: "ECDSA", ecdsa_curve: "P256"}
}

$tls: tlsp.#Provider & {proxy: [{url: "http://caller.internal:3128"}]}

a: #Key & {tls: $tls}
terminalexit 6
$pravo init > /dev/null
$pravo validate
Error: evaluating .:
a.tls.proxy.0.url: conflicting values "http://caller.internal:3128" and "http://module.internal:3128":
    ./main.cue:6:40
    ./main.cue:10:39
    ./main.cue:12:4
    ./main.cue:12:17

When the caller's struct leaves the field out, nothing conflicts, and the value would never reach the provider. pravo reports an error.

main.cuerejected
package infra

import tlsp "pravo.dev/providers/hashicorp/tls"

#Key: {
	tls!: tlsp.#Provider & {proxy: [{url: "http://module.internal:3128"}]}
	key:  tls.#Resource.tls_private_key & {algorithm: "ECDSA", ecdsa_curve: "P256"}
}

$tls: tlsp.#Provider

a: #Key & {tls: $tls}
terminalexit 6
$pravo validate
Error: a.tls sets proxy, but a.tls is another name for the provider $tls, which does not set it. The provider is configured from $tls alone: set proxy there

The same rule holds for the $ fields, such as $version, and for a provider struct that is built from another one at the top level, such as $other: $tls & {proxy: [...]}. Configure a provider where it is declared.

Policy packages

Unification is commutative, so a package can constrain resources it did not create. A team ships a provider definition that fixes what the resources built through it may look like, and each root builds its provider from it.

policy/policy.cue
package policy

import rnd "pravo.dev/providers/hashicorp/random"

#Random: rnd.#Provider & {
	#Resource: random_pet: prefix: =~"^eu-"
}
main.cue
package infra

import "example.com/infra/policy"

$eu: policy.#Random

a: $eu.#Resource.random_pet & {length: 2, prefix: "us-"}
terminalexit 6
$pravo validate
Error: evaluating .:
a.prefix: invalid value "us-" (out of bound =~"^eu-"):
    ./policy/policy.cue:6:33
    ./cue.mod/gen/pravo.dev/providers/hashicorp/random/random_gen.cue:217:13
    ./main.cue:7:51

With prefix: "eu-" the same root validates.

A constraint rejects a value that is set and wrong. It does not reject an attribute that is left out. An unset attribute with no reference to another resource is sent to the provider as null. A rule that an attribute is present is a rule for the plan policy. See Providers and Policies.

See also