Testes impuros de Rust em Nix com máquinas virtuais
Por Eric Rodrigues Pires • 5 minutos de leitura •
Nix é um ótimo sistema não só para distribuir pacotes, mas também para criar ambientes de desenvolvimento reproduzíveis e até mesmo um sistema operacional inteiro. Baseando-se em disciplinas de memória e princípios de programação funcional, ele vem com várias garantias além de limitações.
Uma dessas limitações envolve rodar testes, que costumam ser declarados como derivações (os blocos de montagem de Nix). Embora isso seja conveniente, a sandbox padrão acaba sendo muito difícil de se utilizar, com restrições inesperadas. Este post propõe uma maneira diferente de rodar testes que requerem um ambiente de usuário completo, sem perdermos as funcionalidades que Nix oferece.
Se está com pressa, pule para a solução.
Nosso programa de exemplo
Vamos criar um resolvedor de domínios simples para a linha de comando em Rust, chamado mydns. Ele terá duas partes: uma que serve como ponto de partida da CLI, e outro com a lógica principal da aplicação.
use std::{
io::{self},
net::{IpAddr, ToSocketAddrs},
};
/// Interface da linha de comando do mydns.
fn main() {
let mut args = std::env::args();
args.next(); // Pular argumento com o nome do programa
let host = args.next().expect("argumento deve ser providenciado");
if args.next().is_some() {
panic!("Argumentos demais!");
}
let ip_list = lista_de_ips_para_host(&host).expect("deve retornar uma lista de IPs para host");
if ip_list.is_empty() {
eprintln!("Nenhum IP encontrado!");
} else {
for ip in ip_list {
println!("{ip}");
}
}
}
/// Dado um host, retorna a lista de endereços IP resolvidos.
fn lista_de_ips_para_host(host: &str) -> io::Result<Vec<IpAddr>> {
let socket_addrs = (host, 443).to_socket_addrs()?;
Ok(socket_addrs.map(|addr| addr.ip()).collect())
}E também vamos escrever alguns testes para garantir o funcionamento básico de lista_de_ips_para_host:
// ...
#[()]
mod mydns_tests {
use std::net::{IpAddr, Ipv4Addr};
use crate::lista_de_ips_para_host;
#[]
/// Testa que um endereço IP retorna si mesmo.
fn local_ip() {
assert_eq!(
lista_de_ips_para_host("127.0.0.1").unwrap(),
vec![IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))],
);
}
#[]
/// Testa que um subdomínio nip.io (que resolve a si mesmo)
/// retorna o endereço esperado.
fn ip_for_nip_io() {
assert_eq!(
lista_de_ips_para_host("52-0-56-137.nip.io").unwrap(),
vec![IpAddr::V4(Ipv4Addr::new(52, 0, 56, 137))],
);
}
}Se rodarmos cargo test, os testes passarão. Perfeito!
Distribuindo com Nix
Agora que nosso programa simples está pronto, vamos distribuí-lo com Nix. Aqui, eu uso crane e um flake como exemplo, mas os conceitos podem ser transferidos para qualquer outra configuração.
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
crane,
rust-overlay,
...
}:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
eachSystem =
f:
(builtins.foldl' (
acc: system:
let
fSystem = f system;
in
builtins.foldl' (
acc': attr:
acc'
// {
${attr} = (acc'.${attr} or { }) // fSystem.${attr};
}
) acc (builtins.attrNames fSystem)
) { } systems);
in
eachSystem (
system:
let
rustChannel = "stable";
rustVersion = "latest";
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
inherit (pkgs) lib;
craneLib = (crane.mkLib pkgs).overrideToolchain (
p: p.rust-bin.${rustChannel}.${rustVersion}.default
);
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
mydns = craneLib.buildPackage (
commonArgs
// {
inherit cargoArtifacts;
doCheck = false;
meta.mainProgram = "mydns";
}
);
in
{
packages.${system} = {
inherit mydns;
default = mydns;
};
apps.${system}.default = {
type = "app";
program = lib.getExe mydns;
};
devShells.${system}.default = craneLib.devShell {
checks = self.checks.${system};
packages = [ pkgs.cargo-nextest ];
};
checks.${system} = { };
}
);
}Tudo certo até aqui... mas o que nós colocaremos em checks? Bem, já que tivémos todo o esforço de escrever uma suite de testes, vamos colocar uma derivação com cargo nextest:
checks.${system} = {
mydns-tests = craneLib.cargoNextest (
commonArgs
// {
inherit cargoArtifacts;
}
);Agora, vamos tentar rodar nossos testes.
$ nix flake check
error: Cannot build '/nix/store/5w1hiyys609bqvmsl4zcqf4w5qpagl6i-mydns-nextest-0.1.0.drv'.
Reason: builder failed with exit code 100.
Output paths:
/nix/store/m6g8yd7wyv8kqgmr29h6chwnvn4b6m10-mydns-nextest-0.1.0
Last 25 log lines:
> stdout ---
>
> running 1 test
> test mydns_tests::ip_for_nip_io ... FAILED
>
> failures:
>
> failures:
> mydns_tests::ip_for_nip_io
>
> test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
>
> stderr ---
>
> thread 'mydns_tests::ip_for_nip_io' (152) panicked at src/main.rs:47:56:
> called `Result::unwrap()` on an `Err` value: Custom { kind: Uncategorized, error: "failed to lookup address information: Temporary failure in name resolution" }
> note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
>
> Cancelling due to test failure:
> ------------
> Summary [ 0.004s] 2 tests run: 1 passed, 1 failed, 0 skipped
> FAIL [ 0.003s] mydns::bin/mydns mydns_tests::ip_for_nip_io
> Cancelling [ 00:00:00] ============== 2/2: 2 running, 0 passed, 0 skipped
> [ 00:00:00] mydns::bin/mydns mydns_tests::local_ip
> error: test run failedn/mydns mydns_tests::ip_for_nip_ioOps! Por essa eu não esperava...
Vamos olhar os logs de teste mais a fundo, destacados acima. A parte mais importante é "failed to lookup address information: Temporary failure in name resolution".
Derivações de Nix, por padrão, são construídas em um ambiente de sandbox. Isso limita acessos ao sistema de arquivos do host que podem trazer impurezas, além de restringir o acesso à rede para entradas que possam ser verificadas criptograficamente via um hash.
Mas isso acaba sendo um problema! Por baixo dos panos, a pesquisa de endereços usa /etc/hosts para resolução estática de nomes e, em caso negativo, usa um resolvedor DNS que potencialmente fará uma busca num nameserver externo. E nenhuma dessas ações é permitida numa derivação!
Tipicamente, em um lugar como nixpkgs, o normal é simplesmente pular os testes impuros e garantir na mão que o binário funciona. Mas isso quer dizer que nossa aplicação acaba não sendo testada, e perdemos outras vantagens como usar o Garnix CI. Isso pode ser adequado para seu caso de uso, mas podemos fazer algo melhor do que isso?
A solução
Felizmente, nós podemos usar máquinas de NixOS via QEMU para rodar nossos testes em um ambiente controlado! Aqui está uma maneira de fazer isso:
checks.${system} = {
mydns-tests =
let
nextest-archive = craneLib.mkCargoDerivation (
commonArgs
// {
inherit cargoArtifacts;
doCheck = false;
pname = "mydns-nextest-archive";
nativeBuildInputs = (commonArgs.nativeBuildInputs or [ ]) ++ [ pkgs.cargo-nextest ];
buildPhaseCargoCommand = ''
cargo nextest archive --archive-format tar-zst --archive-file archive.tar.zst
'';
installPhaseCommand = ''
mkdir -p $out
cp archive.tar.zst $out
'';
}
);
in
pkgs.testers.runNixOSTest {
name = "mydns-nextest";
nodes = {
machine =
{ ... }:
{
virtualisation.diskSize = 4096;
networking.hosts = {
"52.0.56.137" = [ "52-0-56-137.nip.io" ];
};
systemd.services.cargo-nextest = {
description = "Integration Tests for mydns";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
path = [
pkgs.cargo
pkgs.cargo-nextest
];
script = ''
cp -r ${src}/* .
cargo nextest run \
--archive-file ${nextest-archive}/archive.tar.zst \
--workspace-remap .
'';
serviceConfig = {
StateDirectory = "cargo-nextest";
StateDirectoryMode = "0750";
WorkingDirectory = "/var/lib/cargo-nextest";
Type = "oneshot";
RemainAfterExit = "yes";
Restart = "no";
};
};
};
};
testScript = ''
machine.start()
machine.wait_for_unit("cargo-nextest.service")
'';
};
};Essa parte é bem densa, então vamos passar por ela passo-a-passo.
A ideia principal é que nossos testes sejam compilados em uma derivação no primeiro passo, para então serem rodadas como um serviço do systemd dentro de uma máquina virtual do NixOS no próximo passo, onde cada uma das dependências e pré-requisitos sejam definidos apropriadamente. Por fim, garantimos que esse serviço do systemd concluiu com sucesso.
- No primeiro passo, nós poderíamos utilizar
cargo test --no-run, que criaria um binário de teste para nós. Porém, nós precisaríamos extrair o nome exato do binário a partir da derivação. Ao invés disso, vamos usar a funcionalidade de arquivamento do cargo-nextest - que é muito mais simples de configurar -, salvando o arquivo na saída da derivação.
let
nextest-archive = craneLib.mkCargoDerivation (
commonArgs
// {
inherit cargoArtifacts;
doCheck = false;
pname = "mydns-nextest-archive";
nativeBuildInputs = (commonArgs.nativeBuildInputs or [ ]) ++ [ pkgs.cargo-nextest ];
buildPhaseCargoCommand = ''
cargo nextest archive --archive-format tar-zst --archive-file archive.tar.zst
'';
installPhaseCommand = ''
mkdir -p $out
cp archive.tar.zst $out
'';
}
);
in
# ...- Depois, nós criamos um serviço do systemd
oneshotque recupera o código-fonte em Rust (necessário para a funcionalidade de arquivamento do nextest) e roda os testes. Nós precisamos copiar os arquivos ao invés de criar um link, já quecargo nextestprecisa de permissões de escrita ao diretóriotarget/no workspace do Cargo (e o/nix/storeé apenas para leitura). Fora isso, é um serviço systemd que vai direto ao ponto. Eu adicionei comentários abaixo às partes mais importantes.
systemd.services.cargo-nextest = {
description = "Integration Tests for mydns";
wantedBy = [ "multi-user.target" ]; # Faz com que este serviço seja parte do processo de boot normal
after = [ "network-online.target" ]; # Aguarda até termos acesso à rede
wants = [ "network-online.target" ]; # Igual ao comentário acima
path = [
pkgs.cargo
pkgs.cargo-nextest
# Adicione quaisquer outras dependências ao PATH
];
# Desempacote os arquivos fonte e rode cargo nextest
script = ''
cp -r ${src}/* .
cargo nextest run \
--archive-file ${nextest-archive}/archive.tar.zst \
--workspace-remap .
'';
serviceConfig = {
StateDirectory = "cargo-nextest"; # Crie o diretório /var/lib/cargo-nextest
StateDirectoryMode = "0750";
WorkingDirectory = "/var/lib/cargo-nextest"; # Rode nosso script dentro do 'state directory'
Type = "oneshot";
RemainAfterExit = "yes";
Restart = "no";
};
};- Agora que nosso arquivo é criado em uma derivação separada, e os testes são realizados no serviço do systemd, tudo que falta é iniciar a máquina virtual que executa e aguarda o serviço. Aqui, nós podemos declarar quaisquer configurações normalmente disponíveis para uma máquina NixOS, e talvez até outras máquinas para conectarmos durante os testes, como um banco de dados. No nosso
testScript, é essencial que nós aguardemos que ocargo-nextest.serviceconclua e sinalize que o teste foi bem-sucedido.
pkgs.testers.runNixOSTest {
name = "mydns-nextest";
nodes = {
machine =
{ ... }:
{
# Aumente ou diminua o espaço de disco conforme a necessidade
virtualisation.diskSize = 4096;
# Adicione configurações adicionais, por exemplo, hosts que devem ser resolvidos
networking.hosts = {
"52.0.56.137" = [ "52-0-56-137.nip.io" ];
};
systemd.services.cargo-nextest = {
# -snip-
};
};
};
# Nosso teste do NixOS consiste em esperar que nosso serviço conclua com sucesso
testScript = ''
machine.start()
machine.wait_for_unit("cargo-nextest.service")
'';
};Agora rode nix flake check novamente, que tudo magicamente irá funcionar! Ufa.
Você pode encontrar a demonstração completa no GitHub.
Bônus: Interagindo com a VM
Várias das ideias desse post foram tiradas deste post em inglês por Blake Smith. Uma coisa legal que ele menciona no artigo é como podemos acessar a máquina virtual interativamente:
$ nix run .#checks.x86_64-linux.mydns-tests.driverInteractive
In[1]: machine.start()Uma janela deve aparecer com um shell para a máquina virtual NixOS, onde você pode explorar e inspecionar os resultados do teste:

Precisa de ajuda? Me mande uma mensagem no Mastodon ou envie um e-mail.