From: Alfonso Martínez Date: Mon, 19 Jul 2021 07:57:26 +0000 (+0200) Subject: mgr/dashboard: run cephadm-backend e2e tests with KCLI X-Git-Tag: v15.2.14~29^2 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=0aa480556a6ccb5dc4ccbdebc619371b7a0f7ff6;p=ceph.git mgr/dashboard: run cephadm-backend e2e tests with KCLI Fixes: https://tracker.ceph.com/issues/51300 Signed-off-by: Alfonso Martínez (cherry picked from commit 5c03b49c4da55cf8d0c679ecb2c58182e4d3361a) Conflicts: - Added content in HACKING.rst as dash-devel.rst does not exist in octopus: doc/dev/developer_guide/dash-devel.rst src/pybind/mgr/dashboard/HACKING.rst - Adapted code to octopus branch in the following files due to branch divergence: src/pybind/mgr/dashboard/frontend/cypress.json src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/configuration.e2e-spec.ts src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.e2e-spec.ts src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts src/pybind/mgr/dashboard/frontend/cypress/integration/page-helper.po.ts src/pybind/mgr/dashboard/frontend/cypress/integration/ui/dashboard.e2e-spec.ts --- diff --git a/src/pybind/mgr/dashboard/HACKING.rst b/src/pybind/mgr/dashboard/HACKING.rst index 66e42009be360..cd841d20797ef 100644 --- a/src/pybind/mgr/dashboard/HACKING.rst +++ b/src/pybind/mgr/dashboard/HACKING.rst @@ -183,6 +183,26 @@ Note: When using docker, as your device, you might need to run the script with sudo permissions. +run-cephadm-e2e-tests.sh +........................ + +``run-cephadm-e2e-tests.sh`` runs a subset of E2E tests to verify that the Dashboard and cephadm as +Orchestrator backend behave correctly. + +Prerequisites: you need to install `KCLI +`_ in your local machine. + +Note: + This script is aimed to be run as jenkins job so the cleanup is triggered only in a jenkins + environment. In local, the user will shutdown the cluster when desired (i.e. after debugging). + +Start E2E tests by running:: + + $ cd + $ sudo chown -R $(id -un) src/pybind/mgr/dashboard/frontend/dist src/pybind/mgr/dashboard/frontend/node_modules + $ ./src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh + $ kcli delete plan -y ceph # After tests finish. + Other running options ..................... diff --git a/src/pybind/mgr/dashboard/ci/cephadm/bootstrap-cluster.sh b/src/pybind/mgr/dashboard/ci/cephadm/bootstrap-cluster.sh new file mode 100755 index 0000000000000..fd836f7378e92 --- /dev/null +++ b/src/pybind/mgr/dashboard/ci/cephadm/bootstrap-cluster.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +export PATH=/root/bin:$PATH +mkdir /root/bin +{% if ceph_dev_folder is defined %} + cp /mnt/{{ ceph_dev_folder }}/src/cephadm/cephadm /root/bin/cephadm +{% else %} + cd /root/bin + curl --silent --remote-name --location https://raw.githubusercontent.com/ceph/ceph/octopus/src/cephadm/cephadm +{% endif %} +chmod +x /root/bin/cephadm +mkdir -p /etc/ceph +mon_ip=$(ifconfig eth0 | grep 'inet ' | awk '{ print $2}') +{% if ceph_dev_folder is defined %} + cephadm bootstrap --mon-ip $mon_ip --initial-dashboard-password {{ admin_password }} --allow-fqdn-hostname --dashboard-password-noupdate --shared_ceph_folder /mnt/{{ ceph_dev_folder }} +{% else %} + cephadm bootstrap --mon-ip $mon_ip --initial-dashboard-password {{ admin_password }} --allow-fqdn-hostname --dashboard-password-noupdate +{% endif %} +fsid=$(cat /etc/ceph/ceph.conf | grep fsid | awk '{ print $3}') +{% for number in range(1, nodes) %} + ssh-copy-id -f -i /etc/ceph/ceph.pub -o StrictHostKeyChecking=no root@{{ prefix }}-node-0{{ number }}.{{ domain }} +{% endfor %} diff --git a/src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml b/src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml new file mode 100755 index 0000000000000..80273bbfe5ace --- /dev/null +++ b/src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml @@ -0,0 +1,40 @@ +parameters: + nodes: 3 + pool: default + network: default + domain: cephlab.com + prefix: ceph + numcpus: 1 + memory: 2048 + image: fedora34 + notify: false + admin_password: password + disks: + - 15 + - 5 + +{% for number in range(0, nodes) %} +{{ prefix }}-node-0{{ number }}: + image: {{ image }} + numcpus: {{ numcpus }} + memory: {{ memory }} + reserveip: true + reservedns: true + sharedkey: true + domain: {{ domain }} + nets: + - {{ network }} + disks: {{ disks }} + pool: {{ pool }} + {% if ceph_dev_folder is defined %} + sharedfolders: [{{ ceph_dev_folder }}] + {% endif %} + cmds: + - dnf -y install python3 chrony lvm2 podman + - sed -i "s/SELINUX=enforcing/SELINUX=permissive/" /etc/selinux/config + - setenforce 0 + {% if number == 0 %} + scripts: + - bootstrap-cluster.sh + {% endif %} +{% endfor %} diff --git a/src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh b/src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh new file mode 100755 index 0000000000000..90bfa8d9ebb12 --- /dev/null +++ b/src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -ex + +cleanup() { + if [[ -n "$JENKINS_HOME" ]]; then + printf "\n\nStarting cleanup...\n\n" + kcli delete plan -y ceph || true + sudo podman container prune -f + printf "\n\nCleanup completed.\n\n" + fi +} + +on_error() { + if [ "$1" != "0" ]; then + printf "\n\nERROR $1 thrown on line $2\n\n" + printf "\n\nCollecting info...\n\n" + for vm_id in 0 1 2 + do + local vm="ceph-node-0${vm_id}" + printf "\n\nDisplaying journalctl from VM ${vm}:\n\n" + kcli ssh -u root -- ${vm} 'journalctl --no-tail --no-pager -t cloud-init' || true + printf "\n\nEnd of journalctl from VM ${vm}\n\n" + printf "\n\nDisplaying podman logs:\n\n" + kcli ssh -u root -- ${vm} 'podman logs --names --since 30s $(podman ps -aq)' || true + done + printf "\n\nTEST FAILED.\n\n" + fi +} + +trap 'on_error $? $LINENO' ERR +trap 'cleanup $? $LINENO' EXIT + +sed -i '/ceph-node-/d' $HOME/.ssh/known_hosts + +: ${CEPH_DEV_FOLDER:=${PWD}} + +# Required to start dashboard. +cd ${CEPH_DEV_FOLDER}/src/pybind/mgr/dashboard/frontend +NG_CLI_ANALYTICS=false npm ci +npm run build + +cd ${CEPH_DEV_FOLDER} +kcli delete plan -y ceph || true +kcli create plan -f ./src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml -P ceph_dev_folder=${CEPH_DEV_FOLDER} ceph + +while [[ -z $(kcli ssh -u root -- ceph-node-00 'journalctl --no-tail --no-pager -t cloud-init' | grep "Dashboard is now available") ]]; do + sleep 30 + kcli list vm + # Uncomment for debugging purposes. + #kcli ssh -u root -- ceph-node-00 'podman ps -a' + #kcli ssh -u root -- ceph-node-00 'podman logs --names --since 30s $(podman ps -aq)' + kcli ssh -u root -- ceph-node-00 'journalctl -n 100 --no-pager -t cloud-init' +done + +cd ${CEPH_DEV_FOLDER}/src/pybind/mgr/dashboard/frontend +npx cypress info + +: ${CYPRESS_BASE_URL:=''} +: ${CYPRESS_LOGIN_USER:='admin'} +: ${CYPRESS_LOGIN_PWD:='password'} +: ${CYPRESS_ARGS:=''} + +if [[ -z "${CYPRESS_BASE_URL}" ]]; then + CYPRESS_BASE_URL="https://$(kcli info vm ceph-node-00 -f ip -v | sed -e 's/[^0-9.]//'):8443" +fi + +export CYPRESS_BASE_URL CYPRESS_LOGIN_USER CYPRESS_LOGIN_PWD + +cypress_run () { + local specs="$1" + local timeout="$2" + local override_config="ignoreTestFiles=*.po.ts,retries=0,testFiles=${specs}" + + if [[ -n "$timeout" ]]; then + override_config="${override_config},defaultCommandTimeout=${timeout}" + fi + npx cypress run ${CYPRESS_ARGS} --browser chrome --headless --config "$override_config" +} + +cypress_run "orchestrator/workflow/*-spec.ts" diff --git a/src/pybind/mgr/dashboard/frontend/cypress.json b/src/pybind/mgr/dashboard/frontend/cypress.json index 4f604241edb98..72b5b850f67ee 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress.json +++ b/src/pybind/mgr/dashboard/frontend/cypress.json @@ -1,7 +1,8 @@ { "baseUrl": "http://localhost:4200/", "ignoreTestFiles": [ - "*.po.ts" + "*.po.ts", + "**/orchestrator/**" ], "supportFile": "cypress/support/index.ts", "video": false, diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/configuration.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/configuration.e2e-spec.ts index 7caf321885be1..c7d84146f44ec 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/configuration.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/configuration.e2e-spec.ts @@ -52,12 +52,12 @@ describe('Configuration page', () => { it('should show only modified configurations', () => { configuration.filterTable('Modified', 'yes'); - configuration.getTableFoundCount().should('eq', 2); + configuration.getTableCount('found').should('eq', 2); }); it('should hide all modified configurations', () => { configuration.filterTable('Modified', 'no'); - configuration.getTableFoundCount().should('gt', 1); + configuration.getTableCount('found').should('gt', 1); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts index d3e75400bba5a..792759f0780a0 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts @@ -1,10 +1,20 @@ import { PageHelper } from '../page-helper.po'; +const pages = { + index: { url: '#/hosts', id: 'cd-hosts' }, + create: { url: '#/hosts/create', id: 'cd-host-form' } +}; + export class HostsPageHelper extends PageHelper { - pages = { index: { url: '#/hosts', id: 'cd-hosts' } }; + pages = pages; + + columnIndex = { + hostname: 2, + labels: 4 + }; check_for_host() { - this.getTableTotalCount().should('not.be.eq', 0); + this.getTableCount('total').should('not.be.eq', 0); } // function that checks all services links work for first @@ -28,4 +38,82 @@ export class HostsPageHelper extends PageHelper { expect(links_tested).gt(0); }); } + + @PageHelper.restrictTo(pages.index.url) + clickHostTab(hostname: string, tabName: string) { + this.getExpandCollapseElement(hostname).click(); + cy.get('cd-host-details').within(() => { + this.getTab(tabName).click(); + }); + } + + @PageHelper.restrictTo(pages.create.url) + add(hostname: string, exist?: boolean) { + cy.get(`${this.pages.create.id}`).within(() => { + cy.get('#hostname').type(hostname); + cy.get('cd-submit-button').click(); + }); + if (exist) { + cy.get('#hostname').should('have.class', 'ng-invalid'); + } else { + // back to host list + cy.get(`${this.pages.index.id}`); + } + } + + @PageHelper.restrictTo(pages.index.url) + checkExist(hostname: string, exist: boolean) { + this.getTableCell(this.columnIndex.hostname, hostname).should(($elements) => { + const hosts = $elements.map((_, el) => el.textContent).get(); + if (exist) { + expect(hosts).to.include(hostname); + } else { + expect(hosts).to.not.include(hostname); + } + }); + } + + @PageHelper.restrictTo(pages.index.url) + delete(hostname: string) { + super.delete(hostname, this.columnIndex.hostname); + } + + // Add or remove labels on a host, then verify labels in the table + @PageHelper.restrictTo(pages.index.url) + editLabels(hostname: string, labels: string[], add: boolean) { + this.getTableCell(this.columnIndex.hostname, hostname).click(); + this.clickActionButton('edit'); + + // add or remove label badges + if (add) { + cy.get('cd-modal').find('.select-menu-edit').click(); + for (const label of labels) { + cy.contains('cd-modal .badge', new RegExp(`^${label}$`)).should('not.exist'); + cy.get('.popover-body input').type(`${label}{enter}`); + } + } else { + for (const label of labels) { + cy.contains('cd-modal .badge', new RegExp(`^${label}$`)) + .find('.badge-remove') + .click(); + } + } + cy.get('cd-modal cd-submit-button').click(); + + // Verify labels are added or removed from Labels column + // First find row with hostname, then find labels in the row + this.getTableCell(this.columnIndex.hostname, hostname) + .parent() + .find(`datatable-body-cell:nth-child(${this.columnIndex.labels})`) + .should(($ele) => { + const newLabels = $ele.text().split(' '); + for (const label of labels) { + if (add) { + expect(newLabels).to.include(label); + } else { + expect(newLabels).to.not.include(label); + } + } + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.e2e-spec.ts index 1fb76e4d019b3..b8c184ce70bc7 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.e2e-spec.ts @@ -23,7 +23,7 @@ describe('OSDs page', () => { describe('check existence of fields on OSD page', () => { it('should check that number of rows and count in footer match', () => { - osds.getTableTotalCount().then((text) => { + osds.getTableCount('total').then((text) => { osds.getTableRows().its('length').should('equal', text); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts new file mode 100644 index 0000000000000..3734f55ff3a0b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts @@ -0,0 +1,53 @@ +import { HostsPageHelper } from '../../cluster/hosts.po'; + +describe('Hosts page', () => { + const hosts = new HostsPageHelper(); + const hostnames = ['ceph-node-00.cephlab.com', 'ceph-node-02.cephlab.com']; + const addHost = (hostname: string, exist?: boolean) => { + hosts.navigateTo('create'); + hosts.add(hostname, exist); + hosts.checkExist(hostname, true); + }; + + beforeEach(() => { + cy.login(); + Cypress.Cookies.preserveOnce('token'); + hosts.navigateTo(); + }); + + describe('when Orchestrator is available', () => { + it('should display inventory', function () { + hosts.clickHostTab(hostnames[0], 'Inventory'); + cy.get('cd-host-details').within(() => { + hosts.getTableCount('total').should('be.gte', 0); + }); + }); + + it('should display daemons', function () { + hosts.clickHostTab(hostnames[0], 'Daemons'); + cy.get('cd-host-details').within(() => { + hosts.getTableCount('total').should('be.gte', 0); + }); + }); + + it('should edit host labels', function () { + const labels = ['foo', 'bar']; + hosts.editLabels(hostnames[0], labels, true); + hosts.editLabels(hostnames[0], labels, false); + }); + + it('should not add an existing host', function () { + hosts.navigateTo('create'); + hosts.add(hostnames[0], true); + }); + + it('should add a host', function () { + addHost(hostnames[1], false); + }); + + it('should delete a host and add it back', function () { + hosts.delete(hostnames[1]); + addHost(hostnames[1], false); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/page-helper.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/page-helper.po.ts index 07d772cc2188d..b9212442b1172 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/page-helper.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/page-helper.po.ts @@ -57,6 +57,7 @@ export abstract class PageHelper { this.navigateTo(); this.getFirstTableCell(name).click(); } + cy.contains('Creating...').should('not.exist'); cy.contains('button', 'Edit').click(); this.expectBreadcrumbText('Edit'); } @@ -68,12 +69,20 @@ export abstract class PageHelper { cy.get('.breadcrumb-item.active').should('have.text', text); } + getTabs() { + return cy.get('.nav.nav-tabs li'); + } + + getTab(tabName: string) { + return cy.contains('.nav.nav-tabs li', new RegExp(`^${tabName}$`)); + } + getTabText(index: number) { - return cy.get('.nav.nav-tabs li').its(index).text(); + return this.getTabs().its(index).text(); } getTabsCount(): any { - return cy.get('.nav.nav-tabs li').its('length'); + return this.getTabs().its('length'); } /** @@ -118,42 +127,29 @@ export abstract class PageHelper { return cy.get('cd-table .dataTables_wrapper'); } - getTableTotalCount() { - this.waitDataTableToLoad(); - - return cy.get('.datatable-footer-inner .page-count span').then(($elem) => { - const text = $elem - .filter((_i, e) => e.innerText.includes('total')) - .first() - .text(); - - return Number(text.match(/(\d+)\s+total/)[1]); - }); + private getTableCountSpan(spanType: 'selected' | 'found' | 'total') { + return cy.contains('.datatable-footer-inner .page-count span', spanType); } - getTableSelectedCount() { + // Get 'selected', 'found', or 'total' row count of a table. + getTableCount(spanType: 'selected' | 'found' | 'total') { this.waitDataTableToLoad(); - - return cy.get('.datatable-footer-inner .page-count span').then(($elem) => { + return this.getTableCountSpan(spanType).then(($elem) => { const text = $elem - .filter((_i, e) => e.innerText.includes('selected')) + .filter((_i, e) => e.innerText.includes(spanType)) .first() .text(); - return Number(text.match(/(\d+)\s+selected/)[1]); + return Number(text.match(/(\d+)\s+\w*/)[1]); }); } - getTableFoundCount() { + // Wait until selected', 'found', or 'total' row count of a table equal to a number. + expectTableCount(spanType: 'selected' | 'found' | 'total', count: number) { this.waitDataTableToLoad(); - - return cy.get('.datatable-footer-inner .page-count span').then(($elem) => { - const text = $elem - .filter((_i, e) => e.innerText.includes('found')) - .first() - .text(); - - return Number(text.match(/(\d+)\s+found/)[1]); + this.getTableCountSpan(spanType).should(($elem) => { + const text = $elem.first().text(); + expect(Number(text.match(/(\d+)\s+\w*/)[1])).to.equal(count); }); } @@ -185,6 +181,15 @@ export abstract class PageHelper { } } + getTableCell(columnIndex: number, exactContent: string) { + this.waitDataTableToLoad(); + this.seachTable(exactContent); + return cy.contains( + `datatable-body-row datatable-body-cell:nth-child(${columnIndex})`, + new RegExp(`^${exactContent}$`) + ); + } + getExpandCollapseElement(content?: string) { this.waitDataTableToLoad(); @@ -194,6 +199,7 @@ export abstract class PageHelper { return cy.get('.tc_expand-collapse').first(); } } + /** * Gets column headers of table */ @@ -220,10 +226,14 @@ export abstract class PageHelper { cy.contains(`.tc_filter_option .dropdown-item`, option).click(); } + setPageSize(size: string) { + cy.get('cd-table .dataTables_paginate input').first().clear().type(size); + } + seachTable(text: string) { this.waitDataTableToLoad(); - cy.get('cd-table .dataTables_paginate input').first().clear().type('10'); + this.setPageSize('10'); cy.get('cd-table .search input').first().clear().type(text); } @@ -233,27 +243,37 @@ export abstract class PageHelper { return cy.get('cd-table .search button').click(); } + // Click the action button + clickActionButton(action: string) { + cy.get('.table-actions button.dropdown-toggle').first().click(); // open submenu + cy.get(`.table-actions li.${action}`).click(); // click on "action" menu item + } + /** * This is a generic method to delete table rows. * It will select the first row that contains the provided name and delete it. * After that it will wait until the row is no longer displayed. + * @param name The string to search in table cells. + * @param columnIndex If provided, search string in columnIndex column. */ - delete(name: string) { + delete(name: string, columnIndex?: number) { // Selects row - this.getFirstTableCell(name).click(); + const getRow = columnIndex + ? this.getTableCell.bind(this, columnIndex) + : this.getFirstTableCell.bind(this); + getRow(name).click(); // Clicks on table Delete button - cy.get('.table-actions button.dropdown-toggle').first().click(); // open submenu - cy.get('li.delete a').click(); // click on "delete" menu item + this.clickActionButton('delete'); // Confirms deletion - cy.get('.custom-control-label').click(); - cy.contains('button', 'Delete').click(); + cy.get('cd-modal .custom-control-label').click(); + cy.contains('cd-modal button', 'Delete').click(); // Wait for modal to close cy.get('cd-modal').should('not.exist'); // Waits for item to be removed from table - this.getFirstTableCell(name).should('not.exist'); + getRow(name).should('not.exist'); } } diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/dashboard.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/dashboard.e2e-spec.ts index 2d604db34394e..9cb84480b6432 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/dashboard.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/ui/dashboard.e2e-spec.ts @@ -111,7 +111,7 @@ describe('Dashboard Main Page', () => { } spec.pageObject.navigateTo(); - spec.pageObject.getTableTotalCount().then((tableCount) => { + spec.pageObject.getTableCount('total').then((tableCount) => { expect(tableCount).to.eq( dashCount, `Text of card "${spec.cardName}" and regex "${spec.regexMatcher}" resulted in ${dashCount} ` +