Fala galera, tudo beleza?!
Recentemente precisei integrar o BeyondTrust Secret Safe ao Terraform e, ao pesquisar sobre como fazer isso, descobri que o ecossistema de providers é praticamente todo em Go. O problema é que o restante do projeto já estava em C#, e eu não queria trazer uma segunda linguagem só para isso. Então tomei uma decisão: vou fazer esse provider em C# mesmo, usando .NET 10 com Native AOT.
Spoiler: funcionou, e foi uma aventura e tanto!
Nesse artigo vou compartilhar as principais coisas que aprendi — inclusive as que me custaram horas de debug e que eu não encontrei documentadas em lugar nenhum.
O Terraform Plugin Protocol, ou: leia a spec com muito cuidado
O Terraform se comunica com os providers via gRPC, implementando o Terraform Plugin Protocol v5.2. Isso eu já sabia antes de começar. O que eu não sabia é que existe uma etapa de handshake que acontece antes de qualquer chamada gRPC.
Quando o Terraform inicializa o provider, ele espera receber uma linha específica no stdout:
1|5|tcp|{host}:{port}|grpc|{cert_base64}
Simples assim. O provider precisa escrever essa linha e só depois o Terraform vai tentar conectar via gRPC. Se qualquer outra coisa aparecer no stdout antes dessa linha, o Terraform simplesmente não consegue fazer o parse e o provider morre.
O detalhe que me custou horas de debug
O certificado TLS que vai no final dessa linha precisa estar em base64 sem padding — sem os = no final. Isso porque a biblioteca go-plugin da HashiCorp usa internamente base64.RawStdEncoding, que omite o padding.
Se você colocar o padding, o Terraform vai falhar com um erro de "certificate parse error" que não te diz absolutamente nada útil. Eu fiquei batendo cabeça nesse erro por horas até achar a causa!!!
var cert = Convert.ToBase64String(certificate.RawData)
.Replace("\r", "")
.Replace("\n", "")
.TrimEnd('='); // <- essa linha é a chave de tudo
var line = $"1|5|tcp|{address}|grpc|{cert}\n";
var bytes = Encoding.ASCII.GetBytes(line);
using var stdout = Console.OpenStandardOutput();
stdout.Write(bytes);
stdout.Flush();
Esse .TrimEnd('=') não está em nenhuma documentação oficial. É o tipo de coisa que você só descobre lendo o código fonte da lib do HashiCorp ou... sofrendo.
Native AOT: adeus reflexão, olá disciplina
Compilar com PublishAot=true e TrimMode=full muda bastante a forma como você escreve código. Basicamente, tudo que depende de reflexão em tempo de execução vai quebrar:
-
Type.GetType()— não vai funcionar -
MethodInfo.Invoke()— não vai funcionar - Serialização JSON "mágica" — não vai funcionar
Para tudo isso, você precisa de source generators. Para JSON usei o JsonSourceGenerationContext do System.Text.Json, e para a injeção de dependência tudo tem que ser registrado explicitamente na inicialização. E aqui entra um terceiro serializer que merece atenção especial.
MessagePack: o protocolo binário do Terraform
O Terraform Plugin Protocol define um tipo chamado DynamicValue no protobuf, que pode chegar como JSON ou como MessagePack. Isso significa que o provider precisa saber desserializar os dois formatos. Para o MessagePack usei a lib MessagePack-CSharp, que é muito performática — mas que, por padrão, usa reflexão para descobrir como serializar cada tipo.
Adivinha: reflexão e AOT não combinam.
A solução é o [GeneratedMessagePackResolver], um source generator que gera todo o código de serialização em tempo de compilação. Você cria uma classe parcial vazia com o atributo e o generator cuida do resto!!!
[GeneratedMessagePackResolver]
partial class MessagePackResolverPlaceholder
{
}
E nos modelos, você anota com [MessagePackObject] e [Key] para mapear as propriedades:
[MessagePackObject]
public class ProviderConfiguration
{
[Key("runas")]
public required string RunAs { get; set; }
[Key("key")]
public required string Key { get; set; }
[Key("baseUrl")]
public required string BaseUrl { get; set; }
[Key("pwd")]
public string? Pwd { get; set; }
}
Com isso, o SmartSerializer consegue tratar os dois formatos de forma transparente — se o DynamicValue que chegou tem dados em msgpack, desserializa com MessagePack; se veio em JSON, usa o System.Text.Json. Nenhuma reflexão em runtime, zero problema com AOT:
public static T? Deserialize<T>(DynamicValue dynamicValue)
{
return dynamicValue switch
{
{ Msgpack.IsEmpty: false } => MessagePackSerializer.Deserialize<T>(dynamicValue.Msgpack.Memory),
{ Json.IsEmpty: false } => JsonSerializer.Deserialize<T>(dynamicValue.Json.Span, Json.Default.Options)!,
_ => default
};
}
Isso é muito elegante!!! O provider não precisa saber o que o Terraform vai enviar — ele simplesmente trata os dois casos.
No começo parece trabalhoso, mas a recompensa é muito boa:
- Binário final de ~15 MB, completamente self-contained
- Startup sub-segundo (sem JIT, sem carregamento de assembly)
- Zero dependências de runtime no servidor
Para um provider que o Terraform spawna e mata várias vezes durante uma execução de terraform apply, isso faz uma diferença real!!!
Uma coisa legal que vale mencionar: em debug uso WebApplication.CreateBuilder() para ter mais logs e facilidade de debug, mas em release uso WebApplication.CreateSlimBuilder() que é compatível com AOT e gera um binário menor. Fica assim:
#if DEBUG
var builder = WebApplication.CreateBuilder(args);
#else
var builder = WebApplication.CreateSlimBuilder(args);
#endif
mTLS com certificado gerado na hora — e por que isso faz sentido
O Terraform usa mutual TLS (mTLS) para se comunicar com o provider. A primeira ideia que vem à cabeça é usar certificados de uma CA, mas nesse caso não precisa — e seria até um exagero.
O provider gera um certificado auto-assinado a cada inicialização, embute ele no handshake e descarta quando termina. Funciona porque:
- O Terraform spawna o provider na mesma máquina — a relação de confiança é implícita
- Não precisa de PKI, CA ou rotação de certificados
- Cada instância tem identidade própria e efêmera
No servidor, configurei AllowAnyClientCertificate() porque o Terraform também apresenta um certificado cliente como parte do mTLS, e não precisamos validar esse certificado — ele é o único cliente possível. Isso simplifica muito o gerenciamento, sem abrir mão da criptografia!!!
Testes com Aspire + WireMock: vale cada byte de complexidade
Testar um provider Terraform é diferente de testar uma API comum. Você precisa testar o comportamento gRPC completo, a autenticação com o BeyondTrust, o fluxo de sessão — tudo integrado.
A solução que encontrei foi usar o Aspire (DistributedApplicationFactory) para orquestrar o ambiente de testes, subindo:
- Um container WireMock mockando a API HTTP do BeyondTrust Secret Safe
- O provider server rodando de verdade
- Um test client que chama os métodos gRPC e valida as respostas
O WireMock usa arquivos JSON como mappings para definir os mocks — sem código C# misturado com infra de teste, sem acoplamento. É muito mais fácil de manter e de visualizar o que está sendo mockado.
Mas aprendi uma lição cara sobre o WireMock: um typo no formato do campo headers dentro do mapping fazia ele silenciosamente ignorar o mapeamento inteiro, resultando em um 404 que eu não conseguia entender de forma alguma. Fiquei um bom tempo depurando isso até descobrir que o problema era a estrutura do JSON do mapping.
Depois disso, passei a sempre validar os mappings contra o schema do WireMock antes de confiar neles. Parece bobo, mas salvou muito tempo depois!!!
O que ficou de aprendizado
Algumas coisas que ficaram gravadas depois de tudo isso:
Documente o não óbvio. O padding do base64 é o melhor exemplo: não está em documentação nenhuma, não dá nenhum erro claro, mas quebra tudo. Se você descobrir uma dessas, documenta na hora.
AOT desde o início. Tentar adaptar código que usa reflexão para AOT depois é bem mais doloroso do que já começar com as restrições em mente. Se sabe que vai usar AOT, já escreva pensando nisso — e mapeie quais libs precisam de source generators (JSON, MessagePack, DI).
Testes de integração valem o custo. A complexidade de subir WireMock + Aspire parece exagerada no começo, mas é exatamente esse tipo de teste que vai pegar bugs que testes unitários jamais encontrariam — bugs no serialização, no fluxo de autenticação, em comportamentos do gRPC.
Entenda os contratos do protocolo. O Terraform Plugin Protocol é estrito. Não dá para ir no feeling; você precisa ler o .proto, entender cada RPC e implementar exatamente o que está definido.
O código está aberto no GitHub. Se você trabalha com BeyondTrust Secret Safe, quer entender como implementar um Terraform provider fora do ecossistema Go, ou está curioso sobre Native AOT no .NET 10, dá uma olhada!
👉 github.com/AlbertoMonteiro/terraform-provider-beyondtrust-secretsafe
Se você já passou por algo parecido ou tem alguma dúvida, deixa nos comentários, vai ser muito legal trocar uma ideia!!!
Vou ficando por aqui, um grande abraço!
#Terraform #CSharp #DotNet #NativeAOT #DevOps #InfrastructureAsCode #BeyondTrust #gRPC #DotNet10
Top comments (0)