GlueKube: Kubernetes integration test with molecule

At GlueOps, we have been working on an internal tool to deploy and manage Kubernetes clusters across cloud providers and datacenters. During development, we ran into a few caveats. For example, if we modify a ‘prepare-node’ role to install an additional package or remove a package that seems unnecessary, it can indirectly affect the subsequent roles.
Example of one of the use cases, in the prepare-nodes playbook, we tend to remove a package that was thought unnecessary or change a package’s version, it can result in the subsequent roles like kubeadm-init to fail.
So we decided to add a couple of tests to help keep our work consistent and maintainable.

Some of the tests we created were:

GlueKube Overview

As we deploy many production-grade clusters across numerous cloud providers (e.g. AWS, GCP, metal) for our customers, we wanted to make our deployment more agnostic. We took a look at a couple of existing tools like Kubespray, but we felt it may be a burden to maintain and modify the existing codebase in case we want to customize it.
Ultimately, we decided to build GlueKube: a platform to create kubernetes clusters agnostic to cloud providers with kubeadm, ansible.

GlueKube capabilities:

Molecule as a Testing tool

We’re using ansible to configure our clusters, we needed a testing tool that’s compatible with ansible and supports managing Hetzner resources for testing.

As our case is more of integration test than unit test, we found Molecule a more suitable option than ansible-test, as it provides a structured way through sequences to create/test/destroy infrastructure.

After our research and experiments, we created this blog to help anyone else considering similar tradeoffs.

In this post, we’re focusing on our journey with molecule. If you’re interested in learning more about the project, check out the link.

Test Case I: Scaling Down Worker Nodes

We started with scaling down worker nodes because it is easier than working with control-plane nodes.

After implementing this workflow in GlueKube scaling down is as easy as removing the node from the inventory file hosts.yaml and applying the sync-resources.yaml file, like the demo below:

(demo)[https://drive.google.com/file/d/1RU4s2q8Gv-ef3Y7tRKoHvARPFxGUmAhF/view?usp=sharing]

Now how do we test that with Molecule ? after we created the scenario(test suite) using: molecule init scenario scaler-cluster.
We changed the scenario property in molecule.yaml to the following:

scenario:
  name: scale-cluster
  test_sequence:
    - create
    - converge
    - verify tests/test_cluster_healthy.yaml

To explain it :

---
- name: Verify Kubernetes Nodes Status
  hosts: master-node-1
  become: true
  gather_facts: true
  tasks:
    - name: Get Kubernetes Nodes Status
      ansible.builtin.shell: |
        export KUBECONFIG=/opt/kubernetes/.kube/config
        kubectl get nodes --no-headers
      register: kube_nodes
      changed_when: false

    - name: Debug Node Status Output
      ansible.builtin.debug:
        msg: "{{ kube_nodes.stdout_lines }}"

    - name: Fail if any node is not Ready
      ansible.builtin.fail:
        msg: "One or more Kubernetes nodes are NOT in Ready state!"
      when: "' Ready ' not in item"
      loop: "{{ kube_nodes.stdout_lines }}"

GlueKube relies heavily on inventory/hosts.yaml to know the desired state of the cluster, think of it the same as terraform state and for our tests to run we will need one.

And this will lead us into Create.yaml that’s responsible for generating the hosts.yaml file from the created test resources. So we modified molecule.yaml to use the generated hosts.yaml with the following code:

provisioner:
 name: ansible
 inventory:
   links:
     hosts: inventory/hosts.yaml

Note: group_vars contains ansible configurations for each group in hosts.yaml.
For running a basic verification, we used the following command:

molecule test -s scale-cluster

This will trigger all the sequences we declared on molecule.yaml

Testing the scale down

To scale resources down, we need to remove the desired node from hosts.yaml, our initial thought of the process was creating a python script, give it the desired node, remove it from hosts.yaml and then refresh the inventory cache. However, we wanted to keep our test more Ansible oriented.
We found a better solution (at least for us) by creating two initial hosts.yamlfiles: the first one with all the nodes in and the second one without one of the worker nodes.
We used slicing to pick the [:1] from worker_nodes list, here is a code snippet from create.yaml:

- name: Store inventory(for scale down worker node)
      ansible.builtin.copy:
        content: |
          all:
            children:
              workers:
                hosts:
                  {% for node in worker_nodes.results[:1] -%}
                    worker-node-{{ node.item + 1 }}:
                      ansible_host: {{ node.hcloud_server.ipv4_address }}
                      ip: 10.0.0.3{{ node.item + 1 }}
                      ansible_user: cluster
                      ansible_ssh_private_key_file: keys/k8s_cluster
                      extra:
                        taints:
                          - node-role.kubernetes.io/control-plane:NoSchedule-
                  {% endfor +%}
        dest: "./inventory/scale-down-worker.yaml"
        mode: "0644"

Molecule has another sequence called side_effect, which we used to replace the hosts.yaml with scale-down-worker.yaml contents, refresh the inventory cache and do the syncing process, here is the code.

remove_worker_node.yaml

- name: Copy and rename a file
  hosts: localhost
  tasks:
    - name: Copy current host
      ansible.builtin.copy:
        src: ../inventory/hosts.yaml
        dest: ../inventory/hosts.old.yaml
        mode: "0644"
    - name: Add new host file
      ansible.builtin.copy:
        src: ../inventory/scale-down-worker.yaml
        dest: ../inventory/hosts.yaml
        mode: "0644"
        force: true


    - name: Refresh inventory to ensure new instances exist
      ansible.builtin.meta: refresh_inventory

- name: Sync Cluster
  ansible.builtin.import_playbook: ../../../playbooks/sync-resources.yaml

For Molecule to recognize the side_effect, we added it alongside the other sequences.

scenario:
  name: scale-cluster
  test_sequence:
    - create
    - converge
    - verify tests/test_cluster_healthy.yaml
    - side_effect side_effect/remove_worker_node.yaml
    ...

After the side_effect sequence gets executed, we should verify the expected state of the cluster, in our case the side_effect remove_worker_node should reduce the number of worker nodes by 1, so our test will count how many worker nodes we currently have.
Here is a code example:

---
- name: Verify Kubernetes Nodes Status
  hosts: masters[0]
  become: true
  gather_facts: true
  vars:
    desired_node: 1
  tasks:
    - name: Get Kubernetes nodes
      ansible.builtin.shell: |
        export KUBECONFIG=/opt/kubernetes/.kube/config
        kubectl get nodes --no-headers | grep worker
      register: kube_nodes
      changed_when: false

    - name: Count the number of nodes
      ansible.builtin.set_fact:
        node_count: "{{ kube_nodes.stdout_lines | length }}"

    - name: Assert that the number of nodes is equal to {{ desired_node }}
      ansible.builtin.assert:
        that:
          - node_count | int == desired_node | int
        fail_msg: "Expected {{ desired_node }} nodes, but found {{ node_count }}."
        success_msg: "Cluster has the expected number of nodes: {{ node_count }}"

Then we add the following test in molecule.yaml:

scenario:
  name: scale-cluster
  test_sequence:
    - create
    - converge
    - verify tests/test_cluster_healthy.yaml
    - side_effect side_effect/remove_worker_node.yaml
    - verify tests/test_scale_down_worker.yaml 
    ...

Summary

In this post, we shared our experience setting up integration tests for Kubernetes cluster management using Molecule and Ansible. We focused on a specific test case: scaling down worker nodes, illustrating how Molecule’s sequences like `create`, `converge`, `verify`, and `side_effect` can be orchestrated to achieve this. We also highlighted the importance of the Ansible inventory in defining the desired state of the cluster and how Molecule facilitates testing changes to this inventory. This approach allows us to maintain the reliability and consistency of our GlueKube platform as we continue to develop and enhance its capabilities for deploying and managing Kubernetes clusters across diverse environments.