Escrevendo testes para um Kubernetes Operator
No último post vimos como criar um Kubernetes operator usando o operator-sdk. Como aquele texto ficou bem grande resolvi escrever este segundo post, para poder focar na parte dos testes da aplicação.
Quando usamos o CLI do operator-sdk para criar o esqueleto do operator é criado também uma estrutura básica para escrevermos os seus testes. Para este fim vamos usar alguns componentes importantes:
- envtest: uma biblioteca Go que configura uma instância do etcd e da API do Kubernetes, simulando as principais funcionalidades de um cluster Kubernetes para fins de teste
- Ginkgo: um framework de testes baseado no conceito de "Behavior Driven Development" (BDD)
- Gomega: uma biblioteca de asserções de testes, que é uma dependência importante ao Ginkgo.
O scaffolding do CLI criou o arquivo controllers/suite_test.go, que contém a estrutura básica da suite de testes do Ginkgo e a inicialização do envtest. O que precisamos fazer é adicionar o código que vai inicializar o nosso controller, para que possamos escrever os testes. No diff a seguir é possível ver a alteração feita.
Precisamos instalar as dependências para executar os testes. Para isso vamos usar os comandos:
make envtest
./bin/setup-envtest use --bin-dir ./bin/
export PATH=$PATH:bin/k8s/1.28.0-darwin-arm64/
O primeiro comando vai instalar o binário do setup-envtest
, o segundo faz o download dos executáveis para o diretório do nosso projeto, e o terceiro comando adiciona os novos arquivos no PATH do sistema operacional.
O próximo passo é escrevermos o teste. Para isso o recomendado é criarmos um arquivo kind_controller_test.go dentro do diretório controllers. No nosso caso, o application_controller_test.go. A estrutura básica do arquivo é mostrada abaixo. Nos próximos tópicos vamos criar cada um dos testes.
package controllers
import (
"context"
"time"
minettodevv1alpha1 "github.com/eminetto/k8s-operator-talk/api/v1alpha1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// Define utility constants for object names and testing timeouts/durations and intervals.
const (
ApplicationName = "test-app"
ApplicationNamespace = "test-app"
timeout = time.Second * 10
duration = time.Second * 10
interval = time.Millisecond * 250
)
var _ = Describe("Application controller", func() {
Context("When creating an Application", func() {
It("Should create a Deployment and a Service", func() {
})
})
Context("When updating an Application", func() {
It("Should update the Deployment", func() {
})
})
Context("When deleting an Application", func() {
It("Should delete the Deployment and Service", func() {
})
})
})
Teste da criação da Application
O primeiro teste que vamos preencher é que verifica se, ao criar um Application é criado também um Deployment e um Service, conforme o código do nosso controller. Para isso adicionamos o seguinte código:
Context("When creating an Application", func() {
It("Should create a Deployment and a Service", func() {
ctx := context.Background()
// precisamos criar uma namespace no cluster
ns := corev1.Namespace{
ObjectMeta: v1.ObjectMeta{Name: ApplicationNamespace},
}
Expect(k8sClient.Create(ctx, &ns)).Should(Succeed())
//definimos uma Application
app := minettodevv1alpha1.Application{
TypeMeta: v1.TypeMeta{
Kind: "Application",
APIVersion: "v1alpha1",
},
ObjectMeta: v1.ObjectMeta{
Name: ApplicationName,
Namespace: ApplicationNamespace,
},
Spec: minettodevv1alpha1.ApplicationSpec{
Image: "nginx:latest",
Replicas: 1,
Port: 80,
},
}
//adicionamos o finalizer, conforme descrito no post anterior
controllerutil.AddFinalizer(&app, finalizer)
//garantimos que a criação não teve erros
Expect(k8sClient.Create(ctx, &app)).Should(Succeed())
//garantimos que o finalizer foi criado com sucesso
Expect(controllerutil.ContainsFinalizer(&app, finalizer)).Should(BeTrue())
// vamos agora verificar se o deployment foi criado com sucesso
deplName := types.NamespacedName{Name: app.ObjectMeta.Name + "-deployment", Namespace: app.ObjectMeta.Name}
createdDepl := &appsv1.Deployment{}
//devido a natureza assíncrona do Kubernetes vamos fazer uso da função Eventually do Ginkgo
//ele vai executar a função de acordo com o valor do intervalo, até que o timeout tenha terminado,
//ou o resultado seja true
Eventually(func() bool {
err := k8sClient.Get(ctx, deplName, createdDepl)
if err != nil {
return false
}
return true
}, timeout, interval).Should(BeTrue())
//vamos verificar se os dados do Deployment foram criados de acordo com o esperado
Expect(createdDepl.Spec.Template.Spec.Containers[0].Image).Should(Equal(app.Spec.Image))
//o Application deve ser o Owner do Deployment
Expect(createdDepl.ObjectMeta.OwnerReferences[0].Name).Should(Equal(app.Name))
// vamos fazer o mesmo com o Service, garantindo que o controller criou conforme o esperado
srvName := types.NamespacedName{Name: app.ObjectMeta.Name + "-service", Namespace: app.ObjectMeta.Name}
createdSrv := &corev1.Service{}
Eventually(func() bool {
err := k8sClient.Get(ctx, srvName, createdSrv)
if err != nil {
return false
}
return true
}, timeout, interval).Should(BeTrue())
Expect(createdSrv.Spec.Ports[0].TargetPort).Should(Equal(intstr.FromInt(int(app.Spec.Port))))
Expect(createdDepl.ObjectMeta.OwnerReferences[0].Name).Should(Equal(app.Name))
})
})
Adicionei comentários no código para descrever o que está sendo testado.
Teste da atualização da Application
O próximo teste verifica se ao alterar uma Application a modificação é refletida nos demais objetos:
Context("When updating an Application", func() {
It("Should update the Deployment", func() {
ctx := context.Background()
//vamos primeiro buscar a Application no cluster
appName := types.NamespacedName{Name: ApplicationName, Namespace: ApplicationNamespace}
app := minettodevv1alpha1.Application{}
Eventually(func() bool {
err := k8sClient.Get(ctx, appName, &app)
if err != nil {
return false
}
return true
}, timeout, interval).Should(BeTrue())
// vamos buscar o Deployment para garantir que os dados estão iguais aos do Application
deplName := types.NamespacedName{Name: app.ObjectMeta.Name + "-deployment", Namespace: app.ObjectMeta.Name}
createdDepl := &appsv1.Deployment{}
Eventually(func() bool {
err := k8sClient.Get(ctx, deplName, createdDepl)
if err != nil {
return false
}
return true
}, timeout, interval).Should(BeTrue())
Expect(createdDepl.Spec.Template.Spec.Containers[0].Image).Should(Equal(app.Spec.Image))
//vamos alterar a Application
app.Spec.Image = "caddy:latest"
Expect(k8sClient.Update(ctx, &app)).Should(Succeed())
//vamos conferir se a alteração no Application se refletiu no Deployment
Eventually(func() bool {
err := k8sClient.Get(ctx, deplName, createdDepl)
if err != nil {
return false
}
if createdDepl.Spec.Template.Spec.Containers[0].Image == "caddy:latest" {
return true
}
return false
}, timeout, interval).Should(BeTrue())
})
})
Novamente, os comentários descrevem o que está sendo testado.
Testando a exclusão de uma Application
Context("When deleting an Application", func() {
It("Should delete the Deployment and Service", func() {
appName := types.NamespacedName{Name: ApplicationName, Namespace: ApplicationNamespace}
//verifica se a exclusão aconteceu com sucesso
Eventually(func() error {
a := &minettodevv1alpha1.Application{}
k8sClient.Get(context.Background(), appName, a)
return k8sClient.Delete(context.Background(), a)
}, timeout, interval).Should(Succeed())
//garante que o Application não existe mais no cluster
//este teste não é realmente necessário, pois o Delete aconteceu com sucesso
//mantive este teste aqui apenas para fins didáticos
Eventually(func() error {
a := &minettodevv1alpha1.Application{}
return k8sClient.Get(context.Background(), appName, a)
}, timeout, interval).ShouldNot(Succeed())
// de acordo com esta documentação : https://book.kubebuilder.io/reference/envtest.html#testing-considerations
// não podemos testar o garbage collection do cluster, para garantir que o Deployment e o Service criados foram removidos
// mas no primeiro teste nós verificamos o ownership, então eles serão removidos de acordo com o esperado em um cluster real
})
})
Conclusões
Novamente é possível ver como o operator-sdk facilita o processo de desenvolvimento de operators pois ele cria uma estrutura para que os testes sejam facilmente escritos. O uso do envtest também é muito útil pois nos permite testar a funcionalidade de um cluster Kubernetes sem a necessidade de instalarmos um, o que é bem importante em ambientes de CI/CD. Outro ponto interessante é o uso do Ginkgo que torna os testes bem legíveis e de fácil entendimento. É a primeira vez que uso o Ginkgo e gostei bastante do resultado, devo adicionar ele na minha caixa de ferramentas para próximos projetos.
Espero que este post sirva de introdução aos testes de operators. Na documentação oficial é possível encontrar links com tópicos e exemplos mais avançados e recomendo a leitura como próximos passos.
O código pode ser encontrado neste repositório.