diff --git a/bare-metal/ansible/.github/workflows/python-app.yml b/bare-metal/ansible/.github/workflows/python-app.yml new file mode 100644 index 00000000..d25d7e2e --- /dev/null +++ b/bare-metal/ansible/.github/workflows/python-app.yml @@ -0,0 +1,47 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Run PyTest + +on: + workflow_dispatch: + inputs: + Cluster_IP: + description: 'Cluster_IP' + required: true + default: '192.168.10.91' + Cluster_ID: + description: 'Cluster_ID' + required: true + default: 'aa2f255e-edbc-41d2-a937-a97c9c25537c ' + Cluster_Secret: + description: 'Cluster_Secret' + required: true + default: '557oiprmStClDUUeaXOM' + Test_Name: + description: 'Name of test to run. Empty to run all' + required: false + default: 'softdelete' + +permissions: + contents: read + +jobs: + build: + + runs-on: self-hosted + + steps: + - uses: actions/checkout@v4 + + - name: Test with pytest + run: | + python -m venv myenv + source myenv/bin/activate + pip install pytest + pip install -r test/requirements.txt + pytest test/api/test_snapshot.py \ + --entrypoint=${{ github.event.inputs.Cluster_IP }} \ + --cluster=${{ github.event.inputs.Cluster_ID }} \ + --secret=${{ github.event.inputs.Cluster_Secret }} \ + -k ${{ github.event.inputs.Test_Name }} diff --git a/bare-metal/ansible/README.md b/bare-metal/ansible/README.md new file mode 100644 index 00000000..200b51ec --- /dev/null +++ b/bare-metal/ansible/README.md @@ -0,0 +1,43 @@ +# Simplyblock Ansible +Ansible replacement for `bootstrap-cluster.sh` that installs sbcli, configures nodes, creates the cluster, adds storage/management nodes, and writes the cluster secret locally. + +## Inventory +Example inventory: +``` +[management_nodes] +192.168.10.111 +192.168.10.115 + +[storage_nodes] +192.168.10.112 ssds="['0000:00:02.0', '0000:00:03.0', '0000:00:04.0', '0000:00:05.0']" +192.168.10.113 ssds="['0000:00:02.0', '0000:00:03.0', '0000:00:04.0', '0000:00:05.0']" +192.168.10.114 ssds="['0000:00:02.0', '0000:00:03.0', '0000:00:04.0', '0000:00:05.0']" +``` +Defaults such as `management_nic`, `data_nic`, proxy settings, and storage wipe parameters live in `group_vars/all`. + +## Running +``` +ansible-playbook -i inventory/ \ + -e "pool=pool1 log_del_interval=60 metrics_retention_period=24h nr_hugepages=0 data_nics=eth1" \ + cluster.yml +``` + +## Key variables (covering bootstrap-cluster.sh flags) +- Install/command: `sbcli_package` (e.g. `sbcli-dev` or git URL), `sbcli_command_name`, `sb_image`, `ultra_image`, `debug=true`. +- Safety: `cleanup=true` (runs deploy-cleaner, docker prune; reboots storage nodes), `wipe_storage_devices=true` plus `storage_wipe_devices`/`storage_wipe_partitions` to mirror partition cleanup, `nr_hugepages=`. +- Storage configure: `max_lvol`, `max_size`, `nodes_per_socket`, `sockets_to_use`, `pci_allowed`, `pci_blocked`. +- Cluster create: `log_del_interval`, `metrics_retention_period`, `contact_point`, `grafana_endpoint`, `data_chunks_per_stripe`, `parity_chunks_per_stripe`, `distr_chunk_bs`, `cap_warn`, `cap_crit`, `prov_cap_warn`, `prov_cap_crit`, `ha_type`, `enable_node_affinity`, `qpair_count`, `mode` (`docker`|`kubernetes`). +- Storage add: `max_snapshot`, `iobuf_small_bufsize`, `iobuf_large_bufsize`, `journal_partition`, `data_nics`, `spdk_image`, `id_device_by_nqn`, `disable_ha_jm`, `enable_test_device`, `full_page_unmap`, `spdk_debug`, `ha_jm_count`, `jm_percent`, `partition_size`. +- Behaviour toggles: `k8s_snode=true` skips storage deploy/add on bare metal; `pool` names the pool (defaults to `testing1`). + +## SSH bootstrap +Use `ssh.yml` to accept host keys and deploy your SSH public key: +``` +ansible-playbook -i inventory/ -k -e "public_key=''" ssh.yml +``` + +## Testing +`test/` contains API smoke tests: +``` +pytest --entrypoint= --cluster= --secret= +``` diff --git a/bare-metal/ansible/ansible.cfg b/bare-metal/ansible/ansible.cfg new file mode 100644 index 00000000..20bfb16d --- /dev/null +++ b/bare-metal/ansible/ansible.cfg @@ -0,0 +1,3 @@ +[defaults] +interpreter_python=auto_silent +callback_result_format=yaml diff --git a/bare-metal/ansible/cluster.yml b/bare-metal/ansible/cluster.yml new file mode 100644 index 00000000..10d8ac46 --- /dev/null +++ b/bare-metal/ansible/cluster.yml @@ -0,0 +1,417 @@ +- hosts: storage_nodes + gather_facts: false + remote_user: root + any_errors_fatal: true + + vars: + cleanup: "{{ cleanup | default(false) }}" + + tasks: + - block: + - name: Stop firewalls + systemd_service: + name: "{{ item }}" + state: stopped + enabled: false + loop: + - firewalld + - ufw + ignore_errors: true + + - name: Disable IPv6 during cleanup + sysctl: + name: "{{ item }}" + value: "1" + sysctl_set: true + state: present + reload: yes + loop: + - net.ipv6.conf.all.disable_ipv6 + - net.ipv6.conf.default.disable_ipv6 + ignore_errors: true + + - name: Run simplyblock cleanup if sbcli is present + command: "{{ sbcli_command_name | default('sbcli') }} sn deploy-cleaner" + ignore_errors: true + + - name: Stop and prune docker + shell: | + docker stop $(docker ps -aq) 2>/dev/null || true + docker rm -f $(docker ps -aq) 2>/dev/null || true + docker builder prune --all -f + docker system prune -af + docker volume prune -f + docker rmi -f $(docker images -aq) 2>/dev/null || true + args: + executable: /bin/bash + + - name: Remove sbcli package if present + pip: + name: "{{ sbcli_package | default(sbcli_command_name | default('sbcli')) }}" + state: absent + ignore_errors: true + + - name: Remove sbcli binaries + file: + path: "{{ item }}" + state: absent + loop: + - /usr/local/bin/sbc + - /usr/local/bin/sbctl + - /usr/local/bin/sbcli + - /usr/local/lib/python3.9/site-packages/simplyblock_core + - /usr/local/lib/python3.9/site-packages/simplyblock_cli + - /usr/local/lib/python3.9/site-packages/simplyblock + - /usr/local/lib/python3.9/site-packages/simplyblock_cli_dev + ignore_errors: true + + - name: Reboot storage node after cleanup + reboot: + reboot_timeout: 600 + when: cleanup | bool + + +- hosts: management_nodes + gather_facts: false + remote_user: root + any_errors_fatal: true + + vars: + cleanup: "{{ cleanup | default(false) }}" + + tasks: + - block: + - name: Stop firewalls + systemd_service: + name: "{{ item }}" + state: stopped + enabled: false + loop: + - firewalld + - ufw + ignore_errors: true + + - name: Disable IPv6 during cleanup + sysctl: + name: "{{ item }}" + value: "1" + sysctl_set: true + state: present + reload: yes + loop: + - net.ipv6.conf.all.disable_ipv6 + - net.ipv6.conf.default.disable_ipv6 + ignore_errors: true + + - name: Run simplyblock cleanup if sbcli is present + command: "{{ sbcli_command_name | default('sbcli') }} sn deploy-cleaner" + ignore_errors: true + + - name: Stop and prune docker + shell: | + docker stop $(docker ps -aq) 2>/dev/null || true + docker rm -f $(docker ps -aq) 2>/dev/null || true + docker builder prune --all -f + docker system prune -af + docker volume prune -f + docker rmi -f $(docker images -aq) 2>/dev/null || true + args: + executable: /bin/bash + + - name: Remove sbcli package if present + pip: + name: "{{ sbcli_package | default(sbcli_command_name | default('sbcli')) }}" + state: absent + ignore_errors: true + + - name: Remove sbcli binaries + file: + path: "{{ item }}" + state: absent + loop: + - /usr/local/bin/sbc + - /usr/local/bin/sbctl + - /usr/local/bin/sbcli + - /usr/local/lib/python3.9/site-packages/simplyblock_core + - /usr/local/lib/python3.9/site-packages/simplyblock_cli + - /usr/local/lib/python3.9/site-packages/simplyblock + - /usr/local/lib/python3.9/site-packages/simplyblock_cli_dev + ignore_errors: true + when: cleanup | bool + + +- hosts: all + remote_user: root + any_errors_fatal: true + + vars: + docker_proxy_url: "{{ docker_proxy_url | default('http://34.1.171.127:5000') }}" + docker_insecure_registry: "{{ docker_insecure_registry | default('34.1.171.127:5000') }}" + + handlers: + - name: Restart docker + systemd_service: + name: docker + state: restarted + + tasks: + - name: Ensure management interface is present + assert: + that: + - management_nic in ansible_facts.interfaces + + - name: Ensure data interface is present on storage nodes + assert: + that: + - data_nic in ansible_facts.interfaces + when: "'storage_nodes' in group_names" + + - name: Enable docker repo + get_url: + url: "https://download.docker.com/linux/rhel/docker-ce.repo" + dest: /etc/yum.repos.d + + - name: Ensure required packages are present + package: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + - python3-requests + - python3-pip + - tmux + - git + - parted + state: present + + - name: Ensure docker daemon is running + systemd_service: + name: docker + state: started + enabled: yes + daemon-reload: yes + + - name: Ensure docker is authenticated + docker_login: + username: "{{ docker_user }}" + password: "{{ docker_password }}" + when: (docker_user is defined) and (docker_password is defined) + + - name: Check docker configuration existence + stat: + path: /etc/docker/daemon.json + register: docker_daemon_config_stat + + - name: Read docker configuration + slurp: + src: /etc/docker/daemon.json + register: docker_daemon_config + when: docker_daemon_config_stat.stat.exists + + - name: Merge docker proxy configuration + copy: + dest: /etc/docker/daemon.json + content: | + {% set config = docker_daemon_config.content | default('{}' | b64encode) | b64decode | from_json %} + {% set mirrors = (config.get('registry-mirrors', []) + [docker_proxy_url]) | unique %} + {% set insecure = (config.get('insecure-registries', []) + [docker_insecure_registry]) | unique %} + {{ config | combine({'registry-mirrors': mirrors, 'insecure-registries': insecure}) | to_nice_json }} + notify: Restart docker + + - name: Ensure sbcli is present and up-to-date + pip: + name: "{{ sbcli_package | default('sbcli-dev') }}" + state: "{{ 'forcereinstall' if (sbcli_package | default('sbcli-dev')) is url else 'latest' }}" + + - name: Read sbcli details + community.general.read_csv: + path: '/usr/local/lib/python3.9/site-packages/simplyblock_core/env_var' + fieldnames: ['key', 'value'] + delimiter: '=' + register: sbcli_details + + - name: Determine sbcli command + set_fact: + sbcli_base_command: >- + {{ sbcli_command_name | default( + (sbcli_details.list + | selectattr('key', 'equalto', 'SIMPLY_BLOCK_COMMAND_NAME') + | map(attribute='value') + | list + | first) | default('sbcli')) }} + sbcli: "{{ sbcli_base_command }}{{ ' -d' if (debug | default(False)) else ''}}" + + - name: Override sbcli image + community.general.ini_file: + path: '/usr/local/lib/python3.9/site-packages/simplyblock_core/env_var' + option: 'SIMPLY_BLOCK_DOCKER_IMAGE' + value: "{{ sb_image }}" + when: sb_image is defined + + - name: Override ultra image + community.general.ini_file: + path: '/usr/local/lib/python3.9/site-packages/simplyblock_core/env_var' + option: 'SIMPLY_BLOCK_SPDK_ULTRA_IMAGE' + value: "{{ ultra_image }}" + when: ultra_image is defined + + - name: Clean previous install + command: "{{ sbcli }} sn deploy-cleaner" + + - name: Configure hugepages on storage nodes + sysctl: + name: vm.nr_hugepages + value: "{{ nr_hugepages }}" + state: present + sysctl_set: true + when: + - "'storage_nodes' in group_names" + - nr_hugepages is defined + + - name: Remove partitions on storage devices + command: "parted /dev/{{ item.0 }} rm {{ item.1 }}" + loop: "{{ storage_wipe_devices | default([]) | product(storage_wipe_partitions | default([1, 2])) | list }}" + when: + - "'storage_nodes' in group_names" + - wipe_storage_devices | default(false) + ignore_errors: true + + - name: Configure storage node + command: > + {{ sbcli }} --dev -d storage-node configure + {% if max_lvol is defined %} --max-lvol {{ max_lvol }}{% endif %} + {% if max_size is defined %} --max-size {{ max_size }}{% endif %} + {% if nodes_per_socket is defined %} --nodes-per-socket {{ nodes_per_socket }}{% endif %} + {% if sockets_to_use is defined %} --sockets-to-use {{ sockets_to_use }}{% endif %} + {% if pci_allowed is defined %} --pci-allowed {{ pci_allowed }}{% endif %} + {% if pci_blocked is defined %} --pci-blocked {{ pci_blocked }}{% endif %} + when: "'storage_nodes' in group_names" + + - name: Deploy storage node + command: "{{ sbcli }} sn deploy --ifname {{ management_nic }}" + when: + - "'storage_nodes' in group_names" + - not (k8s_snode | default(false)) + + +- hosts: management_nodes[0] + remote_user: root + any_errors_fatal: true + vars: + high_availability: "{{ (groups['storage_nodes'] | length) > 1 }}" + primary_management_ip: "{{ ansible_facts['ansible_' + management_nic]['ipv4']['address'] }}" + + tasks: + - name: Create cluster + command: > + {{ sbcli }} --dev -d cluster create + --ha-type {{ 'ha' if high_availability else 'single' }} + {% if log_del_interval is defined %} --log-del-interval {{ log_del_interval }}{% endif %} + {% if metrics_retention_period is defined %} --metrics-retention-period {{ metrics_retention_period }}{% endif %} + {% if contact_point is defined %} --contact-point {{ contact_point }}{% endif %} + {% if grafana_endpoint is defined %} --grafana-endpoint {{ grafana_endpoint }}{% endif %} + {% if data_chunks_per_stripe is defined %} --data-chunks-per-stripe {{ data_chunks_per_stripe }}{% endif %} + {% if parity_chunks_per_stripe is defined %} --parity-chunks-per-stripe {{ parity_chunks_per_stripe }}{% endif %} + {% if distr_chunk_bs is defined %} --distr-chunk-bs {{ distr_chunk_bs }}{% endif %} + {% if cap_warn is defined %} --cap-warn {{ cap_warn }}{% endif %} + {% if cap_crit is defined %} --cap-crit {{ cap_crit }}{% endif %} + {% if prov_cap_warn is defined %} --prov-cap-warn {{ prov_cap_warn }}{% endif %} + {% if prov_cap_crit is defined %} --prov-cap-crit {{ prov_cap_crit }}{% endif %} + {% if ha_type is defined %} --ha-type {{ ha_type }}{% endif %} + {% if enable_node_affinity | default(False) %} --enable-node-affinity{% endif %} + {% if qpair_count is defined %} --qpair-count {{ qpair_count }}{% endif %} + {% if mode is defined %} --mode {{ mode }}{% endif %} + {% if mode | default('docker') == 'kubernetes' %} + --mgmt-ip {{ primary_management_ip }} + {% else %} + --ifname {{ management_nic }} + {% endif %} + register: cluster_creation + + - name: Store Cluster ID + set_fact: + cluster_id: "{{ cluster_creation.stdout.splitlines()[-1] }}" + delegate_to: "{{ item }}" + delegate_facts: true + loop: "{{ ['localhost'] + groups['management_nodes'] + groups['storage_nodes'] }}" + + - name: Add storage nodes + command: > + {{ sbcli }} --dev -d storage-node add-node + {% if max_snapshot is defined %} --max-snap {{ max_snapshot }}{% endif %} + {% if iobuf_small_bufsize is defined %} --iobuf_small_bufsize {{ iobuf_small_bufsize }}{% endif %} + {% if journal_partition is defined %} --journal-partition {{ journal_partition }}{% endif %} + {% if iobuf_large_bufsize is defined %} --iobuf_large_bufsize {{ iobuf_large_bufsize }}{% endif %} + {% if data_nics is defined %} + --data-nics {{ data_nics | join(',') if (data_nics is iterable and data_nics is not string) else data_nics }} + {% endif %} + {% if id_device_by_nqn is defined %} --id-device-by-nqn {{ id_device_by_nqn }}{% endif %} + {% if spdk_image is defined %} --spdk-image {{ spdk_image }}{% endif %} + {% if disable_ha_jm | default(False) %} --disable-ha-jm{% endif %} + {% if enable_test_device | default(False) %} --enable-test-device{% endif %} + {% if full_page_unmap | default(False) %} --full-page-unmap{% endif %} + {% if spdk_debug | default(False) %} --spdk-debug{% endif %} + {% if ha_jm_count is defined %} --ha-jm-count {{ ha_jm_count }}{% endif %} + {% if jm_percent is defined %} --jm-percent {{ jm_percent }}{% endif %} + {% if partition_size is defined %} --size-of-device {{ partition_size }}{% endif %} + {{ cluster_id }} {{ hostvars[item]['ansible_' + management_nic]['ipv4']['address'] }}:5000 {{ management_nic }} + loop: "{{ groups['storage_nodes'] }}" + when: not (k8s_snode | default(false)) + + - name: Activate cluster + command: "{{ sbcli }} cluster activate {{ cluster_id }}" + + - name: Create storage pool + command: "{{ sbcli }} pool add {{ pool | default('testing1') }} {{ cluster_id }}" + + - name: Query cluster secret + command: "{{ sbcli }} cluster get-secret {{ cluster_id }}" + register: cluster_details + + - name: Parse cluster secret + set_fact: + cluster_secret: "{{ cluster_details.stdout.splitlines()[-1] }}" + delegate_to: "{{ item }}" + delegate_facts: true + loop: "{{ ['localhost'] + groups['management_nodes'] + groups['storage_nodes'] }}" + + - name: Print cluster information + debug: + msg: "{{ cluster_id }} {{ cluster_secret }}" + + - name: Write cluster information + copy: + content: | + CLUSTER_ID={{ cluster_id }} + CLUSTER_SECRET={{ cluster_secret }} + dest: cluster.sh + delegate_to: localhost + + +- hosts: management_nodes[1:] + remote_user: root + any_errors_fatal: true + + vars: + primary_mgmt: "{{ groups['management_nodes'][0] }}" + primary_management_ip: "{{ hostvars[primary_mgmt]['ansible_' + management_nic]['ipv4']['address'] }}" + + tasks: + - name: Wait for primary cluster services + wait_for: + timeout: 180 + + - name: Add management node to cluster + command: > + {{ sbcli }} mgmt add {{ primary_management_ip }} {{ cluster_id }} {{ cluster_secret }} + {% if mode is defined %} --mode {{ mode }}{% endif %} + {% if mode | default('docker') == 'kubernetes' %} + --mgmt-ip {{ hostvars[inventory_hostname]['ansible_' + management_nic]['ipv4']['address'] }} + {% else %} + --ifname {{ management_nic }} + {% endif %} + delegate_to: "{{ primary_mgmt if (mode | default('docker')) == 'kubernetes' else inventory_hostname }}" + ignore_errors: true # Management join may be retried manually if the service restarts. + +... diff --git a/bare-metal/ansible/group_vars/all b/bare-metal/ansible/group_vars/all new file mode 100644 index 00000000..3802f983 --- /dev/null +++ b/bare-metal/ansible/group_vars/all @@ -0,0 +1,15 @@ +management_nic: eth0 +data_nic: eth1 +docker_proxy_url: http://34.1.171.127:5000 +docker_insecure_registry: 34.1.171.127:5000 +k8s_snode: false +cleanup: false +wipe_storage_devices: false +storage_wipe_devices: + - nvme0n1 + - nvme1n1 + - nvme2n1 + - nvme3n1 +storage_wipe_partitions: + - 1 + - 2 diff --git a/bare-metal/ansible/inventory/c111 b/bare-metal/ansible/inventory/c111 new file mode 100644 index 00000000..2204ec50 --- /dev/null +++ b/bare-metal/ansible/inventory/c111 @@ -0,0 +1,8 @@ +[management_nodes] +192.168.10.111 +192.168.10.115 + +[storage_nodes] +192.168.10.112 ssds="['0000:00:02.0', '0000:00:03.0', '0000:00:04.0', '0000:00:05.0']" +192.168.10.113 ssds="['0000:00:02.0', '0000:00:03.0', '0000:00:04.0', '0000:00:05.0']" +192.168.10.114 ssds="['0000:00:02.0', '0000:00:03.0', '0000:00:04.0', '0000:00:05.0']" diff --git a/bare-metal/ansible/ssh.yml b/bare-metal/ansible/ssh.yml new file mode 100644 index 00000000..18c1aadf --- /dev/null +++ b/bare-metal/ansible/ssh.yml @@ -0,0 +1,21 @@ +--- +- hosts: localhost + gather_facts: no + remote_user: root + tasks: + - name: Set known host keys + known_hosts: + name: "{{ item }}" + key: "{{ item }} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID8FriTYrjakrvsjKp3RfuzZiNs0RBt+T4RvOB4jWZ/j" + loop: "{{ groups['all'] }}" + +- hosts: all + remote_user: root + tasks: + - name: Deploy authorized key + authorized_key: + user: root + key: "{{ lookup('file', public_key) }}" + state: present + when: public_key is defined +... diff --git a/bare-metal/ansible/test/api/test_lvol.py b/bare-metal/ansible/test/api/test_lvol.py new file mode 100644 index 00000000..9f5c1115 --- /dev/null +++ b/bare-metal/ansible/test/api/test_lvol.py @@ -0,0 +1,91 @@ +import re + +import pytest +from requests.exceptions import HTTPError + +import util + + +@pytest.mark.timeout(120) +def test_lvol(call, cluster, pool): + pool_name = call('GET', f'/pool/{pool}')[0]['pool_name'] + lvol_uuid = call('POST', '/lvol', data={ + 'name': 'lvolX', + 'size': '1G', + 'pool': pool_name} + ) + assert re.match(util.uuid_regex, lvol_uuid) + + assert call('GET', f'/lvol/{lvol_uuid}')[0]['uuid'] == lvol_uuid + assert lvol_uuid in util.list(call, 'lvol') + + call('DELETE', f'/lvol/{lvol_uuid}') + + util.await_deletion(call, f'/lvol/{lvol_uuid}') + + assert lvol_uuid not in util.list(call, 'lvol') + + with pytest.raises(HTTPError): + call('GET', f'/lvol/{lvol_uuid}') + + +def test_lvol_get(call, cluster, pool, lvol): + pool_name = call('GET', f'/pool/{pool}')[0]['pool_name'] + lvol_details = call('GET', f'/lvol/{lvol}') + + assert len(lvol_details) == 1 + assert lvol_details[0]['lvol_name'] == 'lvolX' + assert lvol_details[0]['lvol_type'] == 'lvol' + assert lvol_details[0]['uuid'] == lvol + assert lvol_details[0]['pool_name'] == pool_name + assert lvol_details[0]['pool_uuid'] == pool + assert lvol_details[0]['size'] == 10 ** 9 + # TODO assert schema + + +def test_lvol_update(call, cluster, pool, lvol): + call('PUT', f'/lvol/{lvol}', data={ + 'name': 'lvol2', + 'max-rw-iops': 1, + 'max-rw-mbytes': 1, + 'max-r-mbytes': 1, + 'max-w-mbytes': 1 + }) + lvol_details = call('GET', f'/lvol/{lvol}') + assert lvol_details[0]['rw_ios_per_sec'] == 1 + assert lvol_details[0]['rw_mbytes_per_sec'] == 1 + assert lvol_details[0]['r_mbytes_per_sec'] == 1 + assert lvol_details[0]['w_mbytes_per_sec'] == 1 + + +def test_resize(call, cluster, pool, lvol): + call('PUT', f'/lvol/resize/{lvol}', data={'size': '2G'}) + call('GET', f'/lvol/{lvol}')[0]['size'] == (2 * 2 ** 30) + + with pytest.raises(ValueError): + call('PUT', f'/lvol/resize/{lvol}', data={'size': '1G'}) + + +def test_iostats(call, cluster, pool, lvol): + call('GET', f'/lvol/iostats/{lvol}') + # TODO check schema + + +def test_iostats_history(call, cluster, pool, lvol): + call('GET', f'/lvol/iostats/{lvol}/history/1h') + # TODO check schema + + +def test_capacity(call, cluster, pool, lvol): + call('GET', f'/lvol/capacity/{lvol}') + # TODO check schema + + +def test_capacity_history(call, cluster, pool, lvol): + call('GET', f'/lvol/capacity/{lvol}/history/1h') + # TODO check schema + + +def test_get_connection_strings(call, cluster, pool, lvol): + call('GET', f'/lvol/connect/{lvol}') + # TODO check schema diff --git a/bare-metal/ansible/test/api/test_pool.py b/bare-metal/ansible/test/api/test_pool.py new file mode 100644 index 00000000..bdba0c08 --- /dev/null +++ b/bare-metal/ansible/test/api/test_pool.py @@ -0,0 +1,80 @@ +import re + +import pytest +from requests.exceptions import HTTPError + +import util + + +def test_api(call): + assert call('GET', '/') == "Live" + + +def test_pool(call, cluster): + pool_uuid = call('POST', '/pool', data={'name': 'poolX', 'cluster_id': cluster, 'no_secret': True}) + assert re.match(util.uuid_regex, pool_uuid) + + assert call('GET', f'/pool/{pool_uuid}')[0]['uuid'] == pool_uuid + assert pool_uuid in util.list(call, 'pool') + + call('DELETE', f'/pool/{pool_uuid}') + + assert pool_uuid not in util.list(call, 'pool') + + with pytest.raises(HTTPError): + call('GET', f'/pool/{pool_uuid}') + + +def test_pool_duplicate(call, cluster, pool): + with pytest.raises(HTTPError): + call('POST', '/pool', data={'name': 'poolX', 'cluster_id': cluster, 'no_secret': True}) + + +def test_pool_delete_missing(call): + with pytest.raises(HTTPError): + call('DELETE', '/pool/invalid_uuid') + + +def test_pool_update(call, cluster, pool): + values = [ + ('name', 'pool_name', 'poolY'), + ('pool_max', 'pool_max_size', 1), + ('lvol_max', 'lvol_max_size', 1), + ('max_rw_iops', 'max_rw_ios_per_sec', 1), + ('max_rw_mbytes', 'max_rw_mbytes_per_sec', 1), + ('max_r_mbytes', 'max_r_mbytes_per_sec', 1), + ('max_w_mbytes', 'max_w_mbytes_per_sec', 1), + ] + + call('PUT', f'/pool/{pool}', data={ + parameter: value + for parameter, _, value + in values + }) + + pool = call('GET', f'/pool/{pool}')[0] + for _, field, value in values: + assert pool[field] == value + + +def test_pool_io_stats(call, pool): + io_stats = call('GET', f'/pool/iostats/{pool}') + assert io_stats['object_data']['uuid'] == pool + # TODO match expected schema + + +def test_pool_io_stats_history(call, pool): + io_stats = call('GET', f'/pool/iostats/{pool}/history/10m') + assert io_stats['object_data']['uuid'] == pool + # TODO match expected schema + + +def test_pool_capacity(call, pool): + call('GET', f'/pool/capacity/{pool}') + # TODO match expected schema + + +@pytest.mark.skip(reason="Known faulty") +def test_pool_capacity_history(call, pool): + call('GET', f'/pool/capacity/{pool}/history/10m') + # TODO match expected schema diff --git a/bare-metal/ansible/test/api/test_snapshot.py b/bare-metal/ansible/test/api/test_snapshot.py new file mode 100644 index 00000000..5b78d702 --- /dev/null +++ b/bare-metal/ansible/test/api/test_snapshot.py @@ -0,0 +1,46 @@ +import util + +import pytest + + +@pytest.mark.timeout(120) +def test_snapshot_delete(call, cluster, pool): + pool_name = call('GET', f'/pool/{pool}')[0]['pool_name'] + lvol_uuid = call('POST', '/lvol', data={'name': 'lvolX', 'size': '1G', 'pool': pool_name}) + + snapshot_uuid = call('POST', '/snapshot', data={'lvol_id': lvol_uuid, 'snapshot_name': 'snapX'}) + + call('DELETE', f'/lvol/{lvol_uuid}') + util.await_deletion(call, f'/lvol/{lvol_uuid}') + assert lvol_uuid not in util.list(call, 'lvol') + + clone_uuid = call('POST', '/snapshot/clone', data={'snapshot_id': snapshot_uuid, 'clone_name': 'cloneX'}) + + call('DELETE', f'/lvol/{clone_uuid}') + util.await_deletion(call, f'/lvol/{clone_uuid}') + assert clone_uuid not in util.list(call, 'lvol') + + call('DELETE', f'/snapshot/{snapshot_uuid}') + assert snapshot_uuid not in util.list(call, 'snapshot') + + +@pytest.mark.timeout(120) +def test_snapshot_softdelete(call, cluster, pool): + pool_name = call('GET', f'/pool/{pool}')[0]['pool_name'] + lvol_uuid = call('POST', '/lvol', data={'name': 'lvolX', 'size': '1G', 'pool': pool_name}) + + snapshot_uuid = call('POST', '/snapshot', data={'lvol_id': lvol_uuid, 'snapshot_name': 'snapX'}) + + call('DELETE', f'/lvol/{lvol_uuid}') + util.await_deletion(call, f'/lvol/{lvol_uuid}') + assert lvol_uuid not in util.list(call, 'lvol') + + clone_uuid = call('POST', '/snapshot/clone', data={'snapshot_id': snapshot_uuid, 'clone_name': 'cloneX'}) + + call('DELETE', f'/snapshot/{snapshot_uuid}') + # Snapshot still present due to existing clone + + call('DELETE', f'/lvol/{clone_uuid}') + util.await_deletion(call, f'/lvol/{clone_uuid}') + assert clone_uuid not in util.list(call, 'lvol') + assert snapshot_uuid not in util.list(call, 'snapshot') diff --git a/bare-metal/ansible/test/conftest.py b/bare-metal/ansible/test/conftest.py new file mode 100644 index 00000000..fedf0f60 --- /dev/null +++ b/bare-metal/ansible/test/conftest.py @@ -0,0 +1,49 @@ +import functools + +import pytest + +import util + + +def pytest_addoption(parser): + parser.addoption("--entrypoint", action="store", required=True) + parser.addoption("--cluster", action="store", required=True) + parser.addoption("--secret", action="store", required=True) + + +def pytest_generate_tests(metafunc): + for opt in ['entrypoint', 'cluster', 'secret']: + if opt in metafunc.fixturenames: + metafunc.parametrize(opt, [metafunc.config.getoption(opt)], scope='session') + + +@pytest.fixture(scope='session') +def call(request): + options = request.config.option + return functools.partial( + util.api_call, + options.entrypoint, + options.cluster, + options.secret, + log_func=print, + ) + + +@pytest.fixture(scope='module') +def pool(call, cluster): + pool_uuid = call('POST', '/pool', data={'name': 'poolX', 'cluster_id': cluster, 'no_secret': True}) + yield pool_uuid + call('DELETE', f'/pool/{pool_uuid}') + + +@pytest.fixture(scope='module') +def lvol(call, cluster, pool): + pool_name = call('GET', f'/pool/{pool}')[0]['pool_name'] + lvol_uuid = call('POST', '/lvol', data={ + 'name': 'lvolX', + 'size': '1G', + 'pool': pool_name} + ) + yield lvol_uuid + call('DELETE', f'/lvol/{lvol_uuid}') + util.await_deletion(call, f'/lvol/{lvol_uuid}') diff --git a/bare-metal/ansible/test/pytest.ini b/bare-metal/ansible/test/pytest.ini new file mode 100644 index 00000000..1eab761e --- /dev/null +++ b/bare-metal/ansible/test/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +timeout = 10 +timeout_func_only = true diff --git a/bare-metal/ansible/test/requirements.txt b/bare-metal/ansible/test/requirements.txt new file mode 100644 index 00000000..d4603c2b --- /dev/null +++ b/bare-metal/ansible/test/requirements.txt @@ -0,0 +1,3 @@ +pytest +pytest-timeout +requests diff --git a/bare-metal/ansible/test/util.py b/bare-metal/ansible/test/util.py new file mode 100644 index 00000000..170fb903 --- /dev/null +++ b/bare-metal/ansible/test/util.py @@ -0,0 +1,55 @@ +import re +import time + +import requests + + +uuid_regex = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + + +def api_call(entrypoint, cluster, secret, method, path, *, fail=True, data=None, log_func=lambda msg: None): + response = requests.request( + method, + f'http://{entrypoint}{path}', + headers={'Authorization': f'{cluster} {secret}'}, + json=data, + ) + + if fail: + response.raise_for_status() + + try: + result = response.json() + except requests.exceptions.JSONDecodeError: + log_func("Failed to decode content as JSON:") + log_func(response.text) + if fail: + raise + + if not result['status']: + raise ValueError(result.get('error', 'Request failed')) + + log_func(f'{method} {path}' + (f" -> {result['results']}" if method == 'POST' else '')) + + return result['results'] + + +def await_deletion(call, resource, timeout=120): + for i in range(timeout): + try: + call('GET', resource) + time.sleep(1) + except ValueError: + return + except requests.exceptions.HTTPError: + return + + raise TimeoutError('Failed to await deletion') + + +def list(call, type): + return [ + obj['uuid'] + for obj + in call('GET', f'/{type}/') + ]