Introdução a Cuelang

Aposto que nesse momento uma frase paira na sua cabeça:

“Mais uma linguagem de programação”?

Calma, calma, vem comigo que vai fazer sentido :)

Diferente de outras linguagens como Go ou Rust, que são de “propósito geral”, a CUE possui alguns propósitos bem específicos. O seu nome na verdade é uma sigla que significa “Configure Unify Execute” e segundo a documentação oficial:

Embora a linguagem não seja uma linguagem de programação de uso geral, ela possui muitas aplicações, como validação e modelagem de dados, configuração, consulta, geração de código e até script.

Ela é descrita como um “superset de JSON” e fortemente inspirada em Go. Ou como eu gosto de pensar:

“imagine que Go e JSON tiveram um tórrido romançe e o fruto dessa união foi CUE” :D

Neste post eu vou apresentar dois cenários onde a linguagem pode ser usada, mas a documentação oficial tem mais exemplos e uma boa quantia de informação importante a ser consultada.

Validando dados

O primeiro cenário onde CUE se destaca é na validação de dados. Ela possui suporte nativo para validar YAML, JSON, Protobuf, entre outros.

Vou usar como case alguns exemplos de arquivos de configuração do projeto Traefik, um API Gateway.

O YAML a seguir define uma rota válida para o Traefik:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: simpleingressroute
  namespace: default
spec:
  entryPoints:
    - web
  routes:
    - match: Host(`your.example.com`) && PathPrefix(`/notls`)
      kind: Rule
      services:
        - name: whoami
          port: 80

Com essa informação é possível definirmos uma nova rota no API Gateway, mas se algo estiver errado podemos causar alguns problemas. Por isso é importante termos uma forma fácil de detectarmos problemas em arquivos de configuração como esse. E é aí que a CUE mostra sua força.

O primeiro passo é termos a linguagem instalada na máquina. Como estou usando macOS bastou executar o comando:

brew install cue-lang/tap/cue

Na documentação oficial é possível ver como fazer a instalação em outros sistemas operacionais.

Agora podemos usar o comando cue para transformar esse YAML em um schema da linguagem CUE:

cue import traefik-simple.yaml

É criado um arquivo chamado traefik-simple.cue com o conteúdo:

apiVersion: "traefik.containo.us/v1alpha1"
kind:       "IngressRoute"
metadata: {
	name:      "simpleingressroute"
	namespace: "default"
}
spec: {
	entryPoints: [
		"web",
	]
	routes: [{
		match: "Host(`your.example.com`) && PathPrefix(`/notls`)"
		kind:  "Rule"
		services: [{
			name: "whoami"
			port: 80
		}]
	}]
}

Ele é uma tradução literal do YAML para CUE, mas vamos editá-lo para criarmos algumas regras de validação. O conteúdo final do traefik-simple.cue ficou desta forma:

apiVersion: "traefik.containo.us/v1alpha1"
kind:       "IngressRoute"
metadata: {
	name:      string
	namespace: string
}
spec: {
	entryPoints: [
		"web",
	]
	routes: [{
		match: string
		kind:  "Rule"
		services: [{
			name: string
			port: >0 & <= 65535
		}]
	}]
}

Alguns dos itens ficaram exatamente iguais, como apiVersion: "traefik.containo.us/v1alpha1" e kind: "IngressRoute". Isso significa que esses são os valores exatos que estão esperados em todos os arquivos que serão validados por esse schema. Qualquer valor diferente destes vai ser considerado um erro. Outras informações foram alteradas, como:

metadata: {
	name:      string
	namespace: string
}

Neste trecho definimos que o conteúdo do name, por exemplo, pode ser qualquer string válida. No trecho port: >0 & <= 65535 estamos fazendo uma validação importante ao definir que este campo só pode aceitar um número que seja maior do que 0 e menor ou igual a 65535.

Agora é possível validar se o conteúdo do YAML está de acordo com o schema usando o comando:

cue vet traefik-simple.cue traefik-simple.yaml

Se tudo estiver correto nada é apresentado na linha de comando. Para demonstrar o funcionamento eu fiz uma alteração no traefik-simple.yaml mudando o valor do port para 0. Ao executar o comando novamente é possível ver o erro:

cue vet traefik-simple.cue traefik-simple.yaml
spec.routes.0.services.0.port: invalid value 0 (out of bound >0):
    ./traefik-simple.cue:16:10
    ./traefik-simple.yaml:14:18

Se alterarmos algum dos valores esperados, como por exemplo kind: IngressRoute para algo diferente, como kind: Ingressroute o resultado é um erro de validação:

cue vet traefik-simple.cue traefik-simple.yaml
kind: conflicting values "IngressRoute" and "Ingressroute":
    ./traefik-simple.cue:2:13
    ./traefik-simple.yaml:2:8

Desta forma é muito fácil encontrar algum erro em uma configuração de rotas do Traefik. O mesmo pode ser aplicado para outros formatos como JSON, Protobuf, arquivos do Kubernetes, etc.

Vejo um cenário muito claro de uso desse poder de validação de dados: adicionar um passo em CI/CDs para usar CUE e validar configurações em tempo de build, evitando problemas em deploy e execução de aplicações. Outro cenário é adicionar os comandos em um hook de Git, para validar as configurações ainda em ambiente de desenvolvimento.

Outra característica interessante da CUE é a possibilidade de criarmos packages, que contém uma série de schemas e que podem ser compartilhados entre projetos, da mesma forma que um package de Go. Na documentação oficial é possível ver como user esse recurso, bem como usar alguns packages nativos da linguagem, como strigs, lists, regex etc. Vamos usar um package no próximo exemplo.

Configurando aplicações

Outro cenário de uso da CUE é como linguagem de configuração de aplicações. Quem me conhece sabe que eu não tenho nenhum apreço por YAML (para dizer o mínimo) então qualquer outra opção chama minha atenção. Mas CUE tem algumas vantagens interessantes como:

  • por ser baseado em JSON torna a leitura e escrita muito mais simples (opinião minha)
  • resolve alguns problemas de JSON como a falta de comentários, o que é uma vantagem para YAML
  • por ser uma linguagem completa, é possível usar if, loop, pacotes embutidos na linguagem, herança de tipos, etc.

Para este exemplo o primeiro passo foi criar um pacote para armazenar nossa configuração. Para isso criei um diretório chamado config e dentro dele um arquivo chamado config.cue com o conteúdo:

package config 

db: {
	user:     "db_user"
	password: "password"
	host:     "127.0.0.1"
	port:     3306
}

metric: {
	host: "http://localhost"
	port: 9091
}

langs: [
	"pt_br",
	"en",
	"es",
]

O próximo passo foi criar a aplicação que faz a leitura da configuração:

package main

import (
	"fmt"

	"cuelang.org/go/cue"
	"cuelang.org/go/cue/load"
)

type Config struct {
	DB struct {
		User string
		Password string
		Host string
		Port int
	}
	Metric struct {
		Host string
		Port int
	}
	Langs []string
}

// LoadConfig loads the Cue config files, starting in the dirname directory.
func LoadConfig(dirname string) (*Config, error) {
	cueConfig := &load.Config{
		Dir:        dirname,
	}

	buildInstances := load.Instances([]string{}, cueConfig)
	runtimeInstances := cue.Build(buildInstances)
	instance := runtimeInstances[0]
	
	var config Config
	err := instance.Value().Decode(&config)
	if err != nil {
		return nil, err
	}
	return &config, nil
}

func main() {
	c, err := LoadConfig("config/")
	if err != nil {
		panic("error reading config")
	}
	//a struct foi preenchida com os valores
	fmt.Println(c.DB.Host)
}

Uma vantagem do conceito de package da CUE é que podemos quebrar a nossa configuração em arquivos menores, cada um com sua funcionalidade. Por exemplo, dentro do diretório config eu dividi o config.cue em arquivos distintos:

config/db.cue

package config 

db: {
	user:     "db_user"
	password: "password"
	host:     "127.0.0.1"
	port:     3306
}

config/metric.cue

package config 

metric: {
	host: "http://localhost"
	port: 9091
}

config/lang.cue

package config 

langs: [
	"pt_br",
	"en",
	"es",
]

E não foi necessário alterar nada no arquivo main.go para que as configurações sejam carregadas. Com isso podemos ter uma separação melhor dos conteúdos das configurações, sem impacto no código da aplicação.

Conclusão

Neste post eu apenas “arranhei a superfície” do que é possível fazer com a CUE. Ela vem chamando atenção e sendo adotada em projetos importantes como o Istio, que usa para gerar schemes OpenAPI e CRDs para Kubernetes, e o Dagger. Me parece uma ferramenta que pode ser muito útil para uma série de projetos, em especial devido ao seu poder de validação de dados. E como um substituto para YAML, para minha alegria pessoal :D