From 415ba28997785a024f8c735dadc476286a9bd66e Mon Sep 17 00:00:00 2001 From: theprocess Date: Sat, 30 May 2020 14:47:46 +0800 Subject: [PATCH 1/4] To ensure which branch to update. --- oec-hardware.spec | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 oec-hardware.spec diff --git a/oec-hardware.spec b/oec-hardware.spec new file mode 100644 index 0000000..00750f0 --- /dev/null +++ b/oec-hardware.spec @@ -0,0 +1,76 @@ +%define version 1.0.0 +%define release h1 +%define debug_package %{nil} +%global _build_id_links none +%undefine __brp_mangle_shebangs + +Name: oec-hardware +Summary: openEuler Hardware Compatibility Test Suite +Version: %{version} +Release: %{release} +Group: Development/Tools +License: Mulan PSL v2 +URL: https://gitee.com/openeuler/oec-hardware +Source0: %{name}-%{version}-%{release}.tar.bz2 + +Buildroot: %{_tmppath}/%{name}-%{version}-root +BuildRequires: gcc +Requires: kernel-devel, kernel-headers, dmidecode +Requires: qperf, fio, memtester +Requires: kernel >= 4 +Requires: python3 + +# server subpackage +%package server +Summary: openEuler Hardware Compatibility Test Server +Group: Development/Tools +Requires: python3, python3-devel, nginx, qperf, psmisc + +%description +openEuler Hardware Compatibility Test Suite + +%description server +openEuler Hardware Compatibility Test Server + +%prep +%setup -q -c + +%build +[ "$RPM_BUILD_ROOT" != "/" ] && [ -d $RPM_BUILD_ROOT ] && rm -rf $RPM_BUILD_ROOT; +DESTDIR=$RPM_BUILD_ROOT VERSION_RELEASE=%{version} make + +%install +DESTDIR=$RPM_BUILD_ROOT make install + +%clean +[ "$RPM_BUILD_ROOT" != "/" ] && [ -d $RPM_BUILD_ROOT ] && rm -rf $RPM_BUILD_ROOT; + +%pre + +%post + +%files +%defattr(-,root,root) +/usr/bin/oech +/usr/share/oech/kernelrelease.json +/usr/share/oech/lib/hwcompatible +/usr/share/oech/lib/tests +/usr/lib/systemd/system/oech.service +%dir /var/oech +%dir /usr/share/oech/lib +%dir /usr/share/oech + +%files server +%defattr(-,root,root) +/usr/share/oech/lib/server +/usr/share/oech/lib/server/uwsgi.ini +/usr/share/oech/lib/server/uwsgi.conf +/usr/lib/systemd/system/oech-server.service + +%postun +rm -rf /var/lock/oech.lock + +%changelog +* Fri Jul 26 2019 Lu Tianxiong - 1.0.0-h1 +- Initial spec + -- Gitee From 5f067d2bb54976cc963820177f03e1fbb11a3fda Mon Sep 17 00:00:00 2001 From: theprocess Date: Sat, 30 May 2020 14:56:59 +0800 Subject: [PATCH 2/4] compatibility test tools --- License/LICENSE | 127 +++++++++++ Makefile | 34 +++ README.en.md | 36 --- README.md | 383 +++++++++++++++++++++++++++++--- docs/result-qemu.png | Bin 0 -> 10773 bytes docs/results.png | Bin 0 -> 5369 bytes docs/test-flow.png | Bin 0 -> 50083 bytes docs/test-network.png | Bin 0 -> 24850 bytes docs/user-flow.png | Bin 0 -> 9047 bytes hwcompatible/Makefile | 37 ++++ hwcompatible/__init__.py | 10 + hwcompatible/client.py | 74 +++++++ hwcompatible/command.py | 215 ++++++++++++++++++ hwcompatible/commandUI.py | 97 ++++++++ hwcompatible/compatibility.py | 403 ++++++++++++++++++++++++++++++++++ hwcompatible/device.py | 93 ++++++++ hwcompatible/document.py | 205 +++++++++++++++++ hwcompatible/env.py | 29 +++ hwcompatible/job.py | 215 ++++++++++++++++++ hwcompatible/log.py | 67 ++++++ hwcompatible/reboot.py | 97 ++++++++ hwcompatible/sysinfo.py | 63 ++++++ hwcompatible/test.py | 28 +++ scripts/Makefile | 40 ++++ scripts/kernelrelease.json | 4 + scripts/oech | 83 +++++++ scripts/oech-server.service | 11 + scripts/oech.service | 13 ++ server/Makefile | 38 ++++ server/__init__.py | 10 + server/oech-server-pre.sh | 17 ++ server/results/README.md | 1 + server/server.py | 311 ++++++++++++++++++++++++++ server/static/favicon.ico | Bin 0 -> 553 bytes server/templates/base.html | 37 ++++ server/templates/device.html | 22 ++ server/templates/devices.html | 24 ++ server/templates/error.html | 9 + server/templates/files.html | 14 ++ server/templates/flash.html | 10 + server/templates/index.html | 10 + server/templates/job.html | 64 ++++++ server/templates/log.html | 13 ++ server/templates/results.html | 42 ++++ server/templates/upload.html | 35 +++ server/uwsgi.conf | 13 ++ server/uwsgi.ini | 6 + tests/Makefile | 27 +++ tests/__init__.py | 10 + tests/acpi/Makefile | 22 ++ tests/acpi/acpi.py | 32 +++ tests/cdrom/Makefile | 22 ++ tests/cdrom/cdrom.py | 232 +++++++++++++++++++ tests/clock/Makefile | 34 +++ tests/clock/clock.c | 94 ++++++++ tests/clock/clock.py | 31 +++ tests/cpufreq/Makefile | 22 ++ tests/cpufreq/cal.py | 33 +++ tests/cpufreq/cpufreq.py | 380 ++++++++++++++++++++++++++++++++ tests/disk/Makefile | 22 ++ tests/disk/disk.py | 225 +++++++++++++++++++ tests/ipmi/Makefile | 22 ++ tests/ipmi/ipmi.py | 54 +++++ tests/kdump/Makefile | 22 ++ tests/kdump/kdump.py | 99 +++++++++ tests/memory/Makefile | 30 +++ tests/memory/eatmem_test.c | 176 +++++++++++++++ tests/memory/hugetlb_test.c | 73 ++++++ tests/memory/memory.py | 266 ++++++++++++++++++++++ tests/network/Makefile | 22 ++ tests/network/__init__.py | 10 + tests/network/ethernet.py | 80 +++++++ tests/network/infiniband.py | 64 ++++++ tests/network/network.py | 388 ++++++++++++++++++++++++++++++++ tests/network/rdma.py | 228 +++++++++++++++++++ tests/nvme/Makefile | 22 ++ tests/nvme/nvme.py | 101 +++++++++ tests/perf/Makefile | 22 ++ tests/perf/perf.py | 65 ++++++ tests/system/Makefile | 22 ++ tests/system/system.py | 276 +++++++++++++++++++++++ tests/tape/Makefile | 22 ++ tests/tape/tape.py | 70 ++++++ tests/usb/Makefile | 22 ++ tests/usb/usb.py | 106 +++++++++ tests/watchdog/Makefile | 27 +++ tests/watchdog/watchdog.c | 116 ++++++++++ tests/watchdog/watchdog.py | 64 ++++++ 88 files changed, 6634 insertions(+), 61 deletions(-) create mode 100644 License/LICENSE create mode 100755 Makefile delete mode 100644 README.en.md create mode 100644 docs/result-qemu.png create mode 100644 docs/results.png create mode 100644 docs/test-flow.png create mode 100644 docs/test-network.png create mode 100644 docs/user-flow.png create mode 100755 hwcompatible/Makefile create mode 100755 hwcompatible/__init__.py create mode 100755 hwcompatible/client.py create mode 100755 hwcompatible/command.py create mode 100755 hwcompatible/commandUI.py create mode 100755 hwcompatible/compatibility.py create mode 100755 hwcompatible/device.py create mode 100755 hwcompatible/document.py create mode 100755 hwcompatible/env.py create mode 100755 hwcompatible/job.py create mode 100755 hwcompatible/log.py create mode 100755 hwcompatible/reboot.py create mode 100755 hwcompatible/sysinfo.py create mode 100755 hwcompatible/test.py create mode 100755 scripts/Makefile create mode 100644 scripts/kernelrelease.json create mode 100644 scripts/oech create mode 100644 scripts/oech-server.service create mode 100644 scripts/oech.service create mode 100755 server/Makefile create mode 100755 server/__init__.py create mode 100755 server/oech-server-pre.sh create mode 100644 server/results/README.md create mode 100755 server/server.py create mode 100755 server/static/favicon.ico create mode 100644 server/templates/base.html create mode 100644 server/templates/device.html create mode 100644 server/templates/devices.html create mode 100644 server/templates/error.html create mode 100644 server/templates/files.html create mode 100644 server/templates/flash.html create mode 100644 server/templates/index.html create mode 100644 server/templates/job.html create mode 100644 server/templates/log.html create mode 100644 server/templates/results.html create mode 100644 server/templates/upload.html create mode 100644 server/uwsgi.conf create mode 100644 server/uwsgi.ini create mode 100755 tests/Makefile create mode 100755 tests/__init__.py create mode 100755 tests/acpi/Makefile create mode 100755 tests/acpi/acpi.py create mode 100755 tests/cdrom/Makefile create mode 100755 tests/cdrom/cdrom.py create mode 100755 tests/clock/Makefile create mode 100644 tests/clock/clock.c create mode 100755 tests/clock/clock.py create mode 100755 tests/cpufreq/Makefile create mode 100755 tests/cpufreq/cal.py create mode 100755 tests/cpufreq/cpufreq.py create mode 100755 tests/disk/Makefile create mode 100755 tests/disk/disk.py create mode 100755 tests/ipmi/Makefile create mode 100755 tests/ipmi/ipmi.py create mode 100755 tests/kdump/Makefile create mode 100755 tests/kdump/kdump.py create mode 100755 tests/memory/Makefile create mode 100644 tests/memory/eatmem_test.c create mode 100644 tests/memory/hugetlb_test.c create mode 100755 tests/memory/memory.py create mode 100755 tests/network/Makefile create mode 100755 tests/network/__init__.py create mode 100755 tests/network/ethernet.py create mode 100755 tests/network/infiniband.py create mode 100755 tests/network/network.py create mode 100755 tests/network/rdma.py create mode 100755 tests/nvme/Makefile create mode 100755 tests/nvme/nvme.py create mode 100755 tests/perf/Makefile create mode 100755 tests/perf/perf.py create mode 100755 tests/system/Makefile create mode 100755 tests/system/system.py create mode 100755 tests/tape/Makefile create mode 100755 tests/tape/tape.py create mode 100755 tests/usb/Makefile create mode 100755 tests/usb/usb.py create mode 100755 tests/watchdog/Makefile create mode 100644 tests/watchdog/watchdog.c create mode 100755 tests/watchdog/watchdog.py diff --git a/License/LICENSE b/License/LICENSE new file mode 100644 index 0000000..a53de70 --- /dev/null +++ b/License/LICENSE @@ -0,0 +1,127 @@ + 木兰宽松许可证, 第2版 + + 木兰宽松许可证, 第2版 + 2020年1月 http://license.coscl.org.cn/MulanPSL2 + + + 您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束: + + 0. 定义 + + “软件”是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。 + + “贡献”是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。 + + “贡献者”是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。 + + “法人实体”是指提交贡献的机构及其“关联实体”。 + + “关联实体”是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 + + 1. 授予版权许可 + + 每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其“贡献”,不论修改与否。 + + 2. 授予专利许可 + + 每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权行动之日终止。 + + 3. 无商标许可 + + “本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定的声明义务而必须使用除外。 + + 4. 分发限制 + + 您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。 + + 5. 免责声明与责任限制 + + “软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。 + + 6. 语言 + “本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。 + + 条款结束 + + 如何将木兰宽松许可证,第2版,应用到您的软件 + + 如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步: + + 1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字; + + 2, 请您在软件包的一级目录下创建以“LICENSE”为名的文件,将整个许可证文本放入该文件中; + + 3, 请将如下声明文本放入每个源文件的头部注释中。 + + Copyright (c) [Year] [name of copyright holder] + oec-hardware is licensed under Mulan PSL v2. + You can use this software according to the terms and conditions of the Mulan PSL v2. + You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 + THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + See the Mulan PSL v2 for more details. + + + Mulan Permissive Software License,Version 2 + + Mulan Permissive Software License,Version 2 (Mulan PSL v2) + January 2020 http://license.coscl.org.cn/MulanPSL2 + + Your reproduction, use, modification and distribution of the Software shall be subject to Mulan PSL v2 (this License) with the following terms and conditions: + + 0. Definition + + Software means the program and related documents which are licensed under this License and comprise all Contribution(s). + + Contribution means the copyrightable work licensed by a particular Contributor under this License. + + Contributor means the Individual or Legal Entity who licenses its copyrightable work under this License. + + Legal Entity means the entity making a Contribution and all its Affiliates. + + Affiliates means entities that control, are controlled by, or are under common control with the acting entity under this License, ‘control’ means direct or indirect ownership of at least fifty percent (50%) of the voting power, capital or other securities of controlled or commonly controlled entity. + + 1. Grant of Copyright License + + Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable copyright license to reproduce, use, modify, or distribute its Contribution, with modification or not. + + 2. Grant of Patent License + + Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (except for revocation under this Section) patent license to make, have made, use, offer for sale, sell, import or otherwise transfer its Contribution, where such patent license is only limited to the patent claims owned or controlled by such Contributor now or in future which will be necessarily infringed by its Contribution alone, or by combination of the Contribution with the Software to which the Contribution was contributed. The patent license shall not apply to any modification of the Contribution, and any other combination which includes the Contribution. If you or your Affiliates directly or indirectly institute patent litigation (including a cross claim or counterclaim in a litigation) or other patent enforcement activities against any individual or entity by alleging that the Software or any Contribution in it infringes patents, then any patent license granted to you under this License for the Software shall terminate as of the date such litigation or activity is filed or taken. + + 3. No Trademark License + + No trademark license is granted to use the trade names, trademarks, service marks, or product names of Contributor, except as required to fulfill notice requirements in Section 4. + + 4. Distribution Restriction + + You may distribute the Software in any medium with or without modification, whether in source or executable forms, provided that you provide recipients with a copy of this License and retain copyright, patent, trademark and disclaimer statements in the Software. + + 5. Disclaimer of Warranty and Limitation of Liability + + THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + + 6. Language + + THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION SHALL PREVAIL. + + END OF THE TERMS AND CONDITIONS + + How to Apply the Mulan Permissive Software License,Version 2 (Mulan PSL v2) to Your Software + + To apply the Mulan PSL v2 to your work, for easy identification by recipients, you are suggested to complete following three steps: + + i Fill in the blanks in following statement, including insert your software name, the year of the first publication of your software, and your name identified as the copyright owner; + + ii Create a file named “LICENSE” which contains the whole context of this License in the first directory of your software package; + + iii Attach the statement to the appropriate annotated syntax at the beginning of each source file. + + + Copyright (c) [Year] [name of copyright holder] + oec-hardware is licensed under Mulan PSL v2. + You can use this software according to the terms and conditions of the Mulan PSL v2. + You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 + THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + See the Mulan PSL v2 for more details. diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..76c9b3b --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +NAME := oec-hardware +VERSION_PY := hwcompatible/version.py + +.PHONY: all clean install + +SUBDIRS := hwcompatible tests server scripts + +all: $(VERSION_PY) + for i in $(SUBDIRS); do $(MAKE) -C $$i DESTDIR=$(DESTDIR); done + +$(VERSION_PY): + @echo "# $(VERSION_PY) is automatically-generated" > $(VERSION_PY) + @echo "version = '$(VERSION_RELEASE)'" >> $(VERSION_PY) + @echo "name = '$(NAME)'" >> $(VERSION_PY) + +install: + mkdir -p $(DESTDIR)/usr/share/oech + mkdir -p $(DESTDIR)/var/oech + for i in $(SUBDIRS); do $(MAKE) -C $$i DESTDIR=$(DESTDIR) install; done + +clean: + for i in $(SUBDIRS); do $(MAKE) -C $$i DESTDIR=$(DESTDIR) clean; done + rm -f $(VERSION_PY) diff --git a/README.en.md b/README.en.md deleted file mode 100644 index bed5276..0000000 --- a/README.en.md +++ /dev/null @@ -1,36 +0,0 @@ -# oec-hardware - -#### Description -Use for check hardware compatibiltiy with openEuler - -#### Software Architecture -Software architecture description - -#### Installation - -1. xxxx -2. xxxx -3. xxxx - -#### Instructions - -1. xxxx -2. xxxx -3. xxxx - -#### Contribution - -1. Fork the repository -2. Create Feat_xxx branch -3. Commit your code -4. Create Pull Request - - -#### Gitee Feature - -1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md -2. Gitee blog [blog.gitee.com](https://blog.gitee.com) -3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore) -4. The most valuable open source project [GVP](https://gitee.com/gvp) -5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help) -6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/README.md b/README.md index 73b86d3..d8a85b7 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,370 @@ -# oec-hardware + -#### 介绍 -Use for check hardware compatibiltiy with openEuler +- [概述](#概述) + - [背景介绍](#背景介绍) + - [原理简介](#原理简介) + - [框架概览](#框架概览) + - [测试流程](#测试流程) + - [使用流程](#使用流程) + - [用户使用流程](#用户使用流程) + - [组网图](#组网图) +- [安装测试框架](#安装测试框架) + - [前提条件](#前提条件) + - [获取安装包](#获取安装包) + - [安装过程](#安装过程) + - [客户端](#客户端) + - [服务端](#服务端) + - [验证安装正确性](#验证安装正确性) +- [使用指导](#使用指导) + - [前提条件](#前提条件-1) + - [使用步骤](#使用步骤) +- [查看结果](#查看结果) + - [如何查看](#如何查看) + - [结果说明&建议](#结果说明建议) +- [附录:测试项说明](#附录测试项说明) + - [已有测试项](#已有测试项) + - [新增测试项](#新增测试项) -#### 软件架构 -软件架构说明 + +# 概述 -#### 安装教程 +## 背景介绍 -1. xxxx -2. xxxx -3. xxxx +OS 厂商为了扩大自己产品的兼容性范围,常常寻求与硬件厂商的合作,进行兼容性测试。OS 厂商制定一个测试标准,并提供测试用例,硬件厂商进行实际的测试,测试通过后,OS 厂商和硬件厂商将共同对结果负责。这是一个双赢的合作,双方都可以藉此推销自己的产品。 -#### 使用说明 +认证目的就是保证 OS 与硬件平台的兼容性,认证仅限于基本功能验证,不包括性能测试等其它测试。 -1. xxxx -2. xxxx -3. xxxx +欧拉硬件兼容性认证测试框架有如下特点: -#### 参与贡献 +1. 为满足可信要求,必须使用欧拉操作系统,不能随意重编/插入内核模块。 +2. 通过扫描机制自适应发现硬件列表,来确定要运行的测试用例集合。 +3. 面向对象抽象各种硬件类型以及测试用例类,用于扩展开发。 -1. Fork 本仓库 -2. 新建 Feat_xxx 分支 -3. 提交代码 -4. 新建 Pull Request +## 原理简介 +### 框架概览 -#### 码云特技 +``` +. +├── hwcompatible 框架主功能 +│ ├── compatibility.py 框架核心功能 +│ ├── client.py 上传测试结果到服务端 +│ ├── command.py bash命令执行封装 +│ ├── commandUI.py 命令行交互工具 +│ ├── device.py 扫描设备信息 +│ ├── document.py 收集配置信息 +│ ├── env.py 全局变量,主要是各个配置文件或目录的路径 +│ ├── job.py 测试任务管理 +│ ├── log.py 日志模块 +│ ├── reboot.py 重启类任务专用,便于机器重启后仍能继续执行测试 +│ ├── sysinfo.py 收集系统信息 +│ └── test.py 测试套模板 +├── scripts 工具脚本 +│ ├── oech 框架命令行工具 +│ ├── oech-server.service 框架服务端 service 文件,用于启动 web 服务器 +│ ├── oech.service 框架客户端 service 文件,用于接管 reboot 用例 +│ └── kernelrelease.json 规范可用于认证的系统和内核版本 +├── server 服务端 +│ ├── oech-server-pre.sh 服务预执行脚本 +│ ├── results/ 测试结果存放目录 +│ ├── server.py 服务端主程序 +│ ├── static/ 图片存放目录 +│ ├── templates/ 网页模板存放目录 +│ ├── uwsgi.conf nginx 服务配置 +│ └── uwsgi.ini uwsgi 服务配置 +└── tests 测试套 +``` -1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md -2. 码云官方博客 [blog.gitee.com](https://blog.gitee.com) -3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解码云上的优秀开源项目 -4. [GVP](https://gitee.com/gvp) 全称是码云最有价值开源项目,是码云综合评定出的优秀开源项目 -5. 码云官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help) -6. 码云封面人物是一档用来展示码云会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) + + +### 测试流程 + +![test-flow](docs/test-flow.png) + +## 使用流程 + +### 用户使用流程 + +![user-flow](docs/user-flow.png) + +### 组网图 + +![test-network](docs/test-network.png) + +# 安装测试框架 + +## 前提条件 + +安装了 EulerOS 2.0 (SP8) 及更高版本,或 openEuler 20.03 (LTS) 及更高版本。 + +## 获取安装包 + +* 安装包从 openEuler 官方网站下载。 + +* 校验安装包的完整性。 + + 1. 获取校验文件中的校验值: + + ``` + cat oec-hardware-*.rpm.sha256sum + ``` + + 2. 计算文件的sha256校验值: + + ``` + sha256sum oec-hardware-*.rpm + ``` + + 命令执行完成后,输出校验值。 + + 3. 对比步骤1和步骤2计算的校验值是否一致。 + + 如果校验值一致说明安装文件完整性没有破坏,如果校验值不一致则可以确认文件完整性已被破坏,需要重新获取。 + +## 安装过程 + +### 客户端 + +1. 部分基础用例依赖 fio 和 memtester 工具,需要提前安装依赖(可以用 tools/ 里的源码包编译)。 + + ``` + rpm -ivh fio-3.7-2.aarch64.rpm memtester-4.3.0-13.aarch64.rpm + ``` + +2. 安装 oec-hardware-1.0.0-h1.aarch64.rpm。 + + ``` + dnf install oec-hardware-1.0.0-h1.aarch64.rpm + ``` + + +### 服务端 + +1. 安装服务端子包。 + + ``` + dnf install oec-hardware-server-1.0.0-h1.aarch64.rpm + ``` + +2. 服务端 web 展示页面部分组件系统本身不提供,需要使用 pip 安装(请自行配置可用 pip 源)。 + + ``` + pip3 install Flask Flask-bootstrap uwsgi + ``` + +3. 启动服务。本服务默认使用 8080 端口,同时搭配 nginx(默认端口 80)提供 web 服务,请保证这些端口未被占用。 + + ``` + systemctl start oech-server.service + systemctl start nginx.service + ``` + +4. 关闭防火墙和 SElinux。 + + ``` + systemctl stop firewalld + iptables -F + setenforce 0 + ``` + +## 验证安装正确性 + +客户端输入 `oech` 命令,可正常运行,则表示安装成功。如果安装有任何问题,可反馈至该邮箱:oecompatibility@openeuler.org 。 + +# 使用指导 + +## 前提条件 + +* `/usr/share/oech/kernelrelease.json`文件中列出了当前支持的所有系统版本,使用`uname -a` 命令确认当前系统内核版本是否属于框架支持的版本。 + +* 框架默认会扫描所有网卡,对网卡进行测试前,请自行筛选被测网卡,并给它配上能`ping`通服务端的 ip ;如果是测试客户端 `InfiniBand`网卡,服务端也必须有一个 `InfiniBand`网卡并提前配好 ip 。 + +## 使用步骤 + +1. 在客户端启动测试框架。在客户端启动 `oech`,其中 `ID` 和 `URL` 可以按需填写,`Server` 必须填写为客户端可以直接访问的服务器域名或 ip,用于展示测试报告和作网络测试的服务端。 + + ``` + # oech + The openEuler Hardware Compatibility Test Suite + Please provide your Compatibility Test ID: + Please provide your Product URL: + Please provide the Compatibility Test Server (Hostname or Ipaddr): + ``` + +2. 进入测试套选择界面。在用例选择界面,框架将自动扫描硬件并选取当前环境可供测试的测试套,输入 `edit` 可以进入测试套选择界面。 + + ``` + These tests are recommended to complete the compatibility test: + No. Run-Now? Status Class Device + 1 yes NotRun acpi + 2 yes NotRun clock + 3 yes NotRun cpufreq + 4 yes NotRun disk + 5 yes NotRun ethernet enp3s0 + 6 yes NotRun ethernet enp4s0 + 7 yes NotRun ethernet enp5s0 + 8 yes NotRun kdump + 9 yes NotRun memory + 10 yes NotRun perf + 11 yes NotRun system + 12 yes NotRun usb + 13 yes NotRun watchdog + Ready to begin testing? (run|edit|quit) + ``` + +3. 选择测试套。`all|none` 分别用于 `全选|全取消`(必测项 `system` 不可取消);数字编号可选择测试套,每次只能选择一个数字,按回车符之后 `no` 变为 `yes`,表示已选择该测试套。 + + ``` + Select tests to run: + No. Run-Now? Status Class Device + 1 no NotRun acpi + 2 no NotRun clock + 3 no NotRun cpufreq + 4 no NotRun disk + 5 yes NotRun ethernet enp3s0 + 6 no NotRun ethernet enp4s0 + 7 no NotRun ethernet enp5s0 + 8 no NotRun kdump + 9 no NotRun memory + 10 no NotRun perf + 11 yes NotRun system + 12 no NotRun usb + 13 no NotRun watchdog + Selection (|all|none|quit|run): + ``` + +4. 开始测试。选择完成后输入 `run` 开始测试。 + +5. 上传测试结果。测试完成后可以上传测试结果到服务器,便于结果展示和日志分析。如果上传失败,请检查网络配置,然后重新上传测试结果。 + + ``` + ... + ------------- Summary ------------- + ethernet-enp3s0 PASS + system FAIL + Log saved to /usr/share/oech/logs/oech-20200228210118-TnvUJxFb50.tar succ. + Do you want to submit last result? (y|n) y + Uploading... + Successfully uploaded result to server X.X.X.X. + ``` + + + +# 查看结果 + +## 如何查看 + +1. 浏览器打开服务端 IP 地址,点击导航栏 `Results` 界面,找到对应的测试 id 进入。 + + ![results](docs/results.png) + +2. 进入单个任务页可以看到具体的测试结果展示,包括环境信息和执行结果等。 + + - `Submit` 表示将结果上传到欧拉官方认证服务器(**当前尚未开放**)。 + + - `Devices` 查看所有测试设备信息。 + + - `Runtime` 查看测试运行日志。 + + - `Attachment` 下载测试附件。 + + ![result-qemu](docs/result-qemu.png) + + + +## 结果说明&建议 + +在 **Result** 列展示测试结果,结果有两种:**PASS** 或者 **FAIL**。如果结果为**FAIL**,可以直接点击结果来查看执行日志,根据报错对照用例代码进行排查。 + +# 附录:测试项说明 + +## 已有测试项 + +1. **system** + + - 检查 OS 版本和 kernel 版本是否匹配。 + - 检查安装的认证工具是否有被修改。 + - 检查内核是否被感染。 + - 检查 selinux 是否正常。 + - 使用 dmidecode 工具读取硬件信息。 + +2. **cpufreq** + + - 测试 cpu 在不同调频策略下运行频率是否同预期。 + - 测试 cpu 在不同频率下完全同规格计算量所需时间是否与频率值反相关。 + +3. **clock** + + - 测试时间矢量性,不会倒回。 + - 测试 RTC 硬件时钟基本稳定性。 + +4. **memory** + + - 使用 memtester 工具进行内存读写测试。 + - mmap 全部系统可用内存,触发 swap,进行 120s 读写测试。 + - 测试 hugetlb。 + - 内存热插拔测试。 + +5. **network** + + - 使用 ethtool 获取网卡信息和 ifconfig 对网卡进行 down/up 测试。 + - 使用 qperf 测试以太网卡tcp/udp延迟和带宽,以及 http 上传、下载速率。 + - 使用 perftest 测试 InfiniBand 或 RoCE 网卡延迟和带宽。 + - **注意** 进行网络带宽测试时,请提前确认服务端网卡速率不小于客户端,并保证测试网络无其他流量干扰。 + +6. **disk** + + 使用 fio 工具进行裸盘/文件系统的顺序/随机读写测试。 + +7. **kdump** + + 触发 kdump,测试能否正常生成 vmcore 文件并解析。 + +8. **watchdog** + + 触发 watchdog,测试系统是否可以正常复位。 + +9. **perf** + + 测试 perf 工具是否能正常使用。 + +10. **cdrom** + + 使用 mkisofs 和 cdrecord 对光驱进行刻录和读取测试。 + +11. **ipmi** + + 使用 ipmitool 查询 IPMI 信息。 + +12. **nvme** + + 使用 nvme-cli 工具对盘进行格式化、读写、查询测试。 + +13. **tape** + + 测试磁带是否正常读写。 + +14. **usb** + + 插拔 usb 设备,测试 usb 接口能否正常识别。 + +15. **acpi** + + 利用 acpidump 工具读取数据。 + +## 新增测试项 + +1. 在 `tests/` 添加自己的测试模板,实现自己的测试类继承框架 `Test`。 + +2. 重要成员变量或函数。 + + - 函数 `test` - **必选**,测试主流程。 + + - 函数 `setup` - 测试开始前环境准备,主要用于初始化被测设备相关信息,可以参考 network 测试。 + + - 函数 `teardown` - 测试完成后环境清理,主要用于确保无论测试成功失败都能正确恢复环境,可以参考 network 测试。 + + - 变量 `requirements` - 以数组形式存放测试依赖的 rpm 包名,测试开始前框架自动安装。 + + - 变量 `reboot` 和 `rebootup` - 若 `reboot = True` 表示该测试套/测试用例会重启系统,且在重启后继续执行 `rebootup` 指定的函数,可以参考 kdump 测试。 diff --git a/docs/result-qemu.png b/docs/result-qemu.png new file mode 100644 index 0000000000000000000000000000000000000000..2023cef8a0242b8981920cf9c9b262146054a0d1 GIT binary patch literal 10773 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 NfY5ynZy!JVKLJvT);ItF literal 0 HcmV?d00001 diff --git a/docs/results.png b/docs/results.png new file mode 100644 index 0000000000000000000000000000000000000000..b339492095b3ff17cf6380ffaeb5aa1eb2a15344 GIT binary patch literal 5369 zcmeHLeNb9w*1y!QX}3mpJ4rVhh3>d*)3{Bx5~D;xx=FPrHDcmN6%E}OF{=@i5LN~G z*!Jz3vM#oacP~&b@>`nzZ%jp+5%zU~BR_hmHZjvjPBsz5QGu^hUo}w;KSq-Az99 zR(g5U(o8ZrOcnZ38eAS+^5?#7f>+8<-W?iyDPDIG5kjhqM9p$9Hg2s5e*A5A`>S1{ zFSfpL*di-6D&$8l|0WrF+L&?fXE#@uZ9OR5#;eP}dHpKvuz!0r2=8BfOk(>N$Myw$ z=0E?p5P^TOZ3K8_lkvvsz+HX`Z~q`_>h*weBx`*|Nt47-^m4Pk$uhb3^G(LF@VEC8~d(InYs22U_uw+{WeOu-RS=~Asma#*uxJI(Do|zK=k=+ zQ&`92rgt_rZgt0Lsy4$z4kT3adN*gK=)zR555!jI%v2Y+w7Q89Chox^A{m2~XGW&Gui!5q)> zXwMORGrh!3b8S=Qw|zpgP;FjBCa zRZ{FQLYlSHsw3feiQKQ+Sl;~4GT3#!}z6P|C1+xoo$W#4`F)S<-J{yq2oKSsj8 zDQZb(WcfUKe#alS>*Ju<2c8Xq5&-}%M&V!(`9%Ty8Hl_*2>c8pxX)l48N=Qcuni(W zTHvk?^0y%T208Q-j)LoT^Z`R-Qc>HDW>#%Fi*FmCTT}cV2I6{KP)I5 z3SQJI%F^n*?EEbN*EEuY=( zxJtHgU0V$fiC8!1F6NIg7enN}!P1U&PMW8tzD}3YWS#Ixz3V2XkZZoELb(hCbW6Jn ztMAj)x(RmFtd>eMBt)`WZ0ljCuYr>ZL*435nrDJu;GhQpz(6ci^_cj@wX;-5=NNK| zhAk)OWio;ykcQ0MGD~Hlx`(P1!@kveZ1Ei^MCw+Dqv8jyG7G-H6u{rBSZX5Vbmu{)2T{q~o4AY+jfIBS*16#+d%%)HOclJHH zQ+M3raUja(6`9PlBCL0mvg~nL(HVP;aT6-vOkc|C%I)PYXLK%le>;tnwD*X&`U*x_ zrx_LU1DX@6Xv5y>Mn+9$Jk-Bl2Jgd!v}?8cn;fkbcISYiJ(j$nAdz*w$_7wCQ-~=i z*5WJIr#=Rk2UK0Ny(njAQP^bP89N0MdB2cCJm)A+|QuKH(kR`dX4AF;Xo)PmJWvW%Q?*!;)Bg> zxwlTvdlMC!j(A-7N@$26%1LC*KsBrQcQ~p+4Mel_+mWBs$z=}#>cm`|&;S;O3OKYH zD8w;Da8E$G8M_rKXJ04_p4Gm!7{5eQ0h(5!nv3$NNo&oNn%TmHb=hqD9Xk2BPyaC=XLb!Q@ zC562H+Br_VL?dgIuR{rCy-v4b;%G-jdv|(5I!*2imUWUw?0Q01Q_Qr;8-~cp6G#Af zP+1j>w^*d`=LyJFT0-t{n8>+mfIrs4333%?w$su!{iper(iyT1bD!>>U^sJ`_vW%O zi0Wyl&^a|wfJ%rEN=6MOwB`JVXrVW(b$vLyU>=R^VJ?6O=#s;$;P~y>LjZlfdI#78 znZh~6eug}xxp$%4vjO88eu5uEUTDtz1ddlSJ$_}!YI1%+msOch zQRSoS+NZ#jb~^egqCxyfN0*KTjO+D!pQ%S}r4nbB*RVM{@w93J?U-gdVjtyK@1uX2 z0a9#pSobuI{I$!wsrnxvYjcZs5^<Tmst>ILhA-Ambc=?lv2#+wL@77 zEWJJpVP2%t5rtH?kW-G*xChQ{B1oc0OGe|Q%;WQmFk#doTX2dTLeV=2&KOin2A{i~ zTVd-l_APz4n90Z^S?CWdkZ8^ZFiqqs8Xjyk=CQBn%z zuDFK7itT_jhL%aJ-yV=|m$f8vtRA;>MCAqfto@p_jSIM-fgBwDmuC65&1JwhJKJa) zX>?^ex3kO;p=)cRK*|Oe$+`KC7y-}t0^M?{A?>l&!>vwkKByD@a`4Hf3H(0o9thG` zp0)HSCt*eNO?2cDdoEoKUW65q+Ot<&xh3q-iaep@pQ0gIC}PF%M8MNcO!%D3D>G_%~c2SyLz1QqdpwcTD0k%g`mN6Pth-PHs zNvCPmFt^OO%L_@_4UW@iY?KLWQzjJH7V$lIS6O<`91!h7ot@7eGbTXITry2!n>+%K zDF(w52@4a!C_~t63+htxKe3 ztClL`RcR%xh0dCwWK_goHxSF6Pf}61r949%t|l!1h~~NbZWzt%6-7*lu8XN0448&~ zXjl-tS>7Ct`8~`n-dlgLVMghV_b68@E#w*1u)B+PqRw)2(5$2)>iEB=6HD{<)Ae$F zIuhzacdLhj@C3$}YLQ;L>|9gIyfSbH3OP&HSmyWyWg8yz@H;D-&M$+oQm!E?>h>8q z5<((Nj3pryj1{++DWvy{@(k84ig~kF-L2h&!bue;)&%mH_oacYrswvrUJ*Jmr~z@9 zEk~DN?PnIT-&^A?_ApIo`7DqTl9B^Tokp$BLJmmJSa9D`agho8`JmG45>+xG1wmU| zU@H&aDuxNccsh1pmPQ61B+jmtQ`dwl+3c099RkNNwop8j1;2ra2Q@Wzct%yG}x&f`x%n7xFR}I|5{c?Yvgz6uo)P+G*Uq#7h-qZ{#Vb#L7eo&2?x z=5(#hRjoV^0OgPN2aGFO-Y4Id&aY0KOE9R0w{%S~5{QMCP8;vY!rGOP?a+Nuvo#AC za9`AK9lYaqN0(+YP4j5bNnW5HQT&RQbZb(_GRJ3PL5aExWXcQ)a*cL?4}tx}+>`7N zFjzpdvVN`K3gOtt$bfD2;D_sB8Y4R10>!WIP29N`u*2MyFT*8H$u?+Iu6*UEq4=ji i22g1H$N!4Bwg>~N4tGCnyaZ_jB)@(1P{;4z|KML+gNi)> literal 0 HcmV?d00001 diff --git a/docs/test-flow.png b/docs/test-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..7568fbcfc95aafa244ad98ec848d221c2509d946 GIT binary patch literal 50083 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 U9yip}2IMTGqb5g~ha4~e6MZU?2><{9 literal 0 HcmV?d00001 diff --git a/docs/test-network.png b/docs/test-network.png new file mode 100644 index 0000000000000000000000000000000000000000..9e540849b00a9e67946bb9f950b5e96382514477 GIT binary patch literal 24850 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 OAT(8VR7$WG0sjMm7q#yI literal 0 HcmV?d00001 diff --git a/docs/user-flow.png b/docs/user-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..8f8879b61a2a9b904db55c27b2b0ce298cd6337d GIT binary patch literal 9047 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 ut&uyP^6wkDf8WiK|KzgC{ 0: + self.print_errors() + # raise CertCommandError(self, "has output on stderr") + + def run_quiet(self): + self._run() + if self.returncode != 0: + raise CertCommandError(self, "returned %d" % self.returncode) + + def echo(self, ignore_errors=False): + self.run(ignore_errors) + self.print_output() + return + + def print_output(self): + if self.output: + for line in self.output: + sys.stdout.write( line ) + sys.stdout.write("\n") + sys.stdout.flush() + + def print_errors(self): + if self.errors: + for line in self.errors: + sys.stderr.write( line ) + sys.stderr.write("\n") + sys.stderr.flush() + + def pid(self): + if self.pipe: + return self.pipe.pid + + def readline(self): + if self.pipe: + return self.pipe.stdout.readline() + + def read(self): + self.pipe = subprocess.Popen(self.command, shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + if self.pipe: + return self.pipe.stdout.read().decode('utf-8', 'ignore').rstrip() + + def poll(self): + if self.pipe: + return self.pipe.poll() + + def _get_str(self, regex=None, regex_group=None, single_line=True, return_list=False, ignore_errors=False): + self.regex = regex + self.single_line = single_line + self.regex_group = regex_group + + self._run() + + if self.single_line: + if self.output and len(self.output) > 1: + raise CertCommandError(self, "Found %u lines of output, expected 1" % len(self.output)) + + if self.output: + line = self.output[0].strip() + if not self.regex: + return line + # otherwise, try the regex + pattern = re.compile(self.regex) + match = pattern.match(line) + if match: + if self.regex_group: + return match.group(self.regex_group) + # otherwise, no group, return the whole line + return line + + # no regex match try a grep-style match + if not self.regex_group: + match = pattern.search(line) + if match: + return match.group() + + # otherwise + raise CertCommandError(self, "no match for regular expression %s" % self.regex) + + #otherwise, multi-line or single-line regex + if not self.regex: + raise CertCommandError(self, "no regular expression set for multi-line command") + pattern = re.compile(self.regex) + result = None + if return_list: + result = list() + if self.output: + for line in self.output: + if self.regex_group: + match = pattern.match(line) + if match: + if self.regex_group: + if return_list: + result.append(match.group(self.regex_group)) + else: + return match.group(self.regex_group) + else: + # otherwise, return the matching line + match = pattern.search(line) + if match: + if return_list: + result.append(match.group()) + else: + return match.group() + if result: + return result + + raise CertCommandError(self, "no match for regular expression %s" % self.regex) + + def get_str(self, regex=None, regex_group=None, single_line=True, return_list=False, ignore_errors=False): + result = self._get_str(regex, regex_group, single_line, return_list) + if not ignore_errors: + if self.returncode != 0: + self.print_output() + self.print_errors() + raise CertCommandError(self, "returned %d" % self.returncode) + + # if self.errors and len(self.errors) > 0: + # raise CertCommandError(self, "has output on stderr") + + return result + + +class CertCommandError(Exception): + def __init__(self, command, message): + self.message = message + self.command = command + + def __str__(self): + return "\"%s\" %s" % (self.command.command, self.message) + + def _get_message(self): return self.__message + def _set_message(self, value): self.__message = value + message = property(_get_message, _set_message) + diff --git a/hwcompatible/commandUI.py b/hwcompatible/commandUI.py new file mode 100755 index 0000000..40ec3b1 --- /dev/null +++ b/hwcompatible/commandUI.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import sys +import readline + + +class CommandUI: + + def __init__(self, echoResponses=False): + self.echo = echoResponses + + def printPipe(self, pipe): + while 1: + line = pipe.readline() + if line: + print(line) + else: + return pipe.close() + + def prompt(self, question, choices=None): + while True: + sys.stdout.write(question) + if choices: + sys.stdout.write(" (") + sys.stdout.write("|".join(choices)) + sys.stdout.write(") ") + sys.stdout.flush() + reply = sys.stdin.readline() + if reply.strip() and self.echo: + sys.stdout.write("reply: %s" % reply) + if not choices or reply.strip(): + return reply.strip() + sys.stdout.write("Please enter a choice\n") + + def prompt_integer(self, question, choices=None): + while True: + sys.stdout.write(question) + if choices: + sys.stdout.write(" (") + sys.stdout.write("|".join(choices)) + sys.stdout.write(") ") + sys.stdout.flush() + reply = sys.stdin.readline() + try: + value = int(reply.strip()) + if self.echo: + sys.stdout.write("reply: %u\n" % value) + return value + except ValueError: + sys.stdout.write("Please enter an integer.\n") + + def prompt_confirm(self, question): + YES = "y" + SAMEASYES = ["y", "yes"] + NO = "n" + SAMEASNO = ["n", "no"] + while True: + reply = self.prompt(question, (YES, NO)) + if reply.lower() in SAMEASYES: + return True + if reply.lower() in SAMEASNO: + return False + sys.stdout.write("Please reply %s or %s.\n" %(YES, NO)) + + def prompt_edit(self, label, value, choices=None): + if not value: + value = "" + if choices: + label += " (" + label += "|".join(choices) + label += ") " + while True: + readline.set_startup_hook(lambda: readline.insert_text(value)) + try: + input = raw_input + except NameError: + from builtins import input + + try: + reply = input(label).strip() + if not choices or reply in choices: + return reply + print("Please enter one of the following: %s" % " | ".join(choices)) + finally: + readline.set_startup_hook() diff --git a/hwcompatible/compatibility.py b/hwcompatible/compatibility.py new file mode 100755 index 0000000..fa63987 --- /dev/null +++ b/hwcompatible/compatibility.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import time +import argparse +import shutil +import datetime +import re + +from .document import CertDocument, DeviceDocument, FactoryDocument +from .env import CertEnv +from .device import CertDevice, Device +from .command import Command, CertCommandError +from .commandUI import CommandUI +from .job import Job +from .reboot import Reboot +from .client import Client + + +class EulerCertification(): + + def __init__(self): + self.certification = None + self.test_factory = list() + self.devices = None + self.ui = CommandUI() + self.client = None + + def run(self): + print("The openEuler Hardware Compatibility Test Suite") + self.load() + certdevice = CertDevice() + + while True: + self.submit() + + if self.check_result(): + print("All cases are passed, test end.") + return True + + devices = certdevice.get_devices() + self.devices = DeviceDocument(CertEnv.devicefile, devices) + self.devices.save() + + # test_factory format example: [{"name":"nvme", "device":device, "run":True, "status":"PASS", "reboot":False}] + test_factory = self.get_tests(devices) + self.update_factory(test_factory) + if not self.choose_tests(): + return True + + args = argparse.Namespace(test_factory=self.test_factory) + job = Job(args) + job.run() + self.save(job) + + def run_rebootup(self): + try: + self.load() + args = argparse.Namespace(test_factory=self.test_factory) + job = Job(args) + reboot = Reboot(None, job, None) + if reboot.check(): + job.run() + reboot.clean() + self.save(job) + return True + except Exception as e: + print(e) + return False + + def clean(self): + if self.ui.prompt_confirm("Are you sure to clean all compatibility test data?"): + try: + Command("rm -rf %s" % CertEnv.certificationfile).run() + Command("rm -rf %s" % CertEnv.factoryfile).run() + Command("rm -rf %s" % CertEnv.devicefile).run() + except Exception as e: + print(e) + return False + return True + + def load(self): + if not os.path.exists(CertEnv.datadirectory): + os.mkdir(CertEnv.datadirectory) + + if not self.certification: + self.certification = CertDocument(CertEnv.certificationfile) + if not self.certification.document: + self.certification.new() + self.certification.save() + if not self.test_factory: + factory_doc = FactoryDocument(CertEnv.factoryfile) + self.test_factory = factory_doc.get_factory() + + cert_id = self.certification.get_certify() + hardware_info = self.certification.get_hardware() + self.client = Client(hardware_info, cert_id) + print(" Compatibility Test ID: ".ljust(30) + cert_id) + print(" Hardware Info: ".ljust(30) + hardware_info) + print(" Product URL: ".ljust(30) + self.certification.get_url()) + print(" OS Info: ".ljust(30) + self.certification.get_os()) + print(" Kernel Info: ".ljust(30) + self.certification.get_kernel()) + print(" Test Server: ".ljust(30) + self.certification.get_server()) + print("") + + def save(self, job): + doc_dir = os.path.join(CertEnv.logdirectoy, job.job_id) + if not os.path.exists(doc_dir): + return + FactoryDocument(CertEnv.factoryfile, self.test_factory).save() + shutil.copy(CertEnv.certificationfile, doc_dir) + shutil.copy(CertEnv.devicefile, doc_dir) + shutil.copy(CertEnv.factoryfile, doc_dir) + + cwd = os.getcwd() + os.chdir(os.path.dirname(doc_dir)) + dir_name = "oech-" + datetime.datetime.now().strftime("%Y%m%d%H%M%S") + "-" + job.job_id + pack_name = dir_name +".tar" + cmd = Command("tar -cf %s %s" % (pack_name, dir_name)) + try: + os.rename(job.job_id, dir_name) + cmd.run() + except CertCommandError: + print("Error:Job log collect failed.") + return + print("Log saved to %s succ." % os.path.join(os.getcwd(), pack_name)) + shutil.copy(pack_name, CertEnv.datadirectory) + for (rootdir, dirs, filenams) in os.walk("./"): + for dirname in dirs: + shutil.rmtree(dirname) + break + os.chdir(cwd) + + def submit(self): + packages = list() + pattern = re.compile("^oech-[0-9]{14}-[0-9a-zA-Z]{10}.tar$") + for (root, dirs, files) in os.walk(CertEnv.datadirectory): + break + packages.extend(filter(pattern.search, files)) + if len(packages) == 0: + return + packages.sort() + + if self.ui.prompt_confirm("Do you want to submit last result?"): + server = self.certification.get_server() + path = os.path.join(CertEnv.datadirectory, packages[-1]) + if not self.upload(path, server): + print("Upload failed.") + else: + print("Successfully uploaded result to server %s." % server) + time.sleep(2) + + for filename in packages: + os.remove(os.path.join(CertEnv.datadirectory, filename)) + + def upload(self, path, server): + print("Uploading...") + if not self.client: + cert_id = self.certification.get_certify() + hardware_info = self.certification.get_hardware() + self.client = Client(hardware_info, cert_id) + return self.client.upload(path, server) + + def get_tests(self, devices): + nodevice = ["cpufreq", "memory", "clock", "profiler", "system", "stress", "kdump", "perf", "acpi", "watchdog"] + ethernet = ["ethernet"] + infiniband = ["infiniband"] + storage = ["nvme", "disk", "nvdimm"] + cdrom = ["cdrom"] + sort_devices = self.sort_tests(devices) + empty_device = Device() + test_factory = list() + casenames = [] + for (dirpath, dirs, filenames) in os.walk(CertEnv.testdirectoy): + dirs.sort() + for filename in filenames: + if filename.endswith(".py") and not filename.startswith("__init__"): + casenames.append(filename.split(".")[0]) + + for testname in casenames: + if sort_devices.get(testname): + for device in sort_devices[testname]: + test = dict() + test["name"] = testname + test["device"] = device + test["run"] = True + test["status"] = "NotRun" + test["reboot"] = False + test_factory.append(test) + elif testname in nodevice: + test = dict() + test["name"] = testname + test["device"] = empty_device + test["run"] = True + test["status"] = "NotRun" + test["reboot"] = False + test_factory.append(test) + return test_factory + + def sort_tests(self, devices): + sort_devices = dict() + empty_device = Device() + for device in devices: + if device.get_property("SUBSYSTEM") == "usb" and \ + device.get_property("ID_VENDOR_FROM_DATABASE") == "Linux Foundation" and \ + ("2." in device.get_property("ID_MODEL_FROM_DATABASE") or \ + "3." in device.get_property("ID_MODEL_FROM_DATABASE")): + sort_devices["usb"] = [empty_device] + continue + if device.get_property("PCI_CLASS") == "30000" or device.get_property("PCI_CLASS") == "38000": + sort_devices["video"] = [device] + continue + if device.get_property("SUBSYSTEM") == "tape" and "/dev/st" in device.get_property("DEVNAME"): + try: + sort_devices["tape"].extend([device]) + except KeyError: + sort_devices["tape"] = [device] + continue + if (device.get_property("DEVTYPE") == "disk" and not device.get_property("ID_TYPE")) or \ + device.get_property("ID_TYPE") == "disk": + if "nvme" in device.get_property("DEVPATH"): + sort_devices["disk"] = [empty_device] + try: + sort_devices["nvme"].extend([device]) + except KeyError: + sort_devices["nvme"] = [device] + continue + elif "/host" in device.get_property("DEVPATH"): + sort_devices["disk"] = [empty_device] + continue + if device.get_property("SUBSYSTEM") == "net" and device.get_property("INTERFACE"): + interface = device.get_property("INTERFACE") + nmcli = Command("nmcli device") + nmcli.start() + while True: + line = nmcli.readline() + if line: + if interface in line and "infiniband" in line: + try: + sort_devices["infiniband"].extend([device]) + except KeyError: + sort_devices["infiniband"] = [device] + elif interface in line and "ethernet" in line: + try: + sort_devices["ethernet"].extend([device]) + except KeyError: + sort_devices["ethernet"] = [device] + elif interface in line and "wifi" in line: + try: + sort_devices["wlan"].extend([device]) + except KeyError: + sort_devices["wlan"] = [device] + else: + break + continue + if device.get_property("ID_CDROM") == "1": + types = ["DVD_RW", "DVD_PLUS_RW", "DVD_R", "DVD_PLUS_R", "DVD", \ + "BD_RE", "BD_R", "BD", "CD_RW", "CD_R", "CD"] + for type in types: + if device.get_property("ID_CDROM_" + type) == "1": + try: + sort_devices["cdrom"].extend([device]) + except KeyError: + sort_devices["cdrom"] = [device] + break + if device.get_property("SUBSYSTEM") == "ipmi": + sort_devices["ipmi"] = [empty_device] + try: + Command("dmidecode").get_str("IPMI Device Information", single_line=False) + sort_devices["ipmi"] = [empty_device] + except: + pass + + return sort_devices + + def edit_tests(self): + while True: + for test in self.test_factory: + if test["name"] == "system": + test["run"] = True + if test["status"] == "PASS": + test["status"] = "Force" + + os.system("clear") + print("Select tests to run:") + self.show_tests() + reply = self.ui.prompt("Selection (|all|none|quit|run): ") + reply = reply.lower() + if reply in ["r", "run"]: + return True + if reply in ["q", "quit"]: + return False + if reply in ["n", "none"]: + for test in self.test_factory: + test["run"] = False + continue + if reply in ["a", "all"]: + for test in self.test_factory: + test["run"] = True + continue + + try: + num = int(reply) + except: + continue + + if num > 0 and num <= len(self.test_factory): + self.test_factory[num-1]["run"] = not self.test_factory[num-1]["run"] + continue + + def show_tests(self): + print("\033[1;35m" + "No.".ljust(4) + "Run-Now?".ljust(10) \ + + "Status".ljust(8) + "Class".ljust(14) + "Device\033[0m") + num = 0 + for test in self.test_factory: + name = test["name"] + if name == "system": + test["run"] = True + if test["status"] == "PASS": + test["status"] = "Force" + + status = test["status"] + device = test["device"].get_name() + run = "no" + if test["run"] == True: + run = "yes" + + num = num + 1 + if status == "PASS": + print("%-6d"%num + run.ljust(8) + "\033[0;32mPASS \033[0m" \ + + name.ljust(14) + "%s"%device) + elif status == "FAIL": + print("%-6d"%num + run.ljust(8) + "\033[0;31mFAIL \033[0m" \ + + name.ljust(14) + "%s"%device) + elif status == "Force": + print("%-6d"%num + run.ljust(8) + "\033[0;33mForce \033[0m" \ + + name.ljust(14) + "%s"%device) + else: + print("%-6d"%num + run.ljust(8) + "\033[0;34mNotRun \033[0m" \ + + name.ljust(14) + "%s"%device) + + def choose_tests(self): + for test in self.test_factory: + if test["status"] == "PASS": + test["run"] = False + else: + test["run"] = True + os.system("clear") + print("These tests are recommended to complete the compatibility test:") + self.show_tests() + action = self.ui.prompt("Ready to begin testing?", ["run", "edit", "quit"]) + action = action.lower() + if action in ["r", "run"]: + return True + elif action in ["q", "quit"]: + return False + elif action in ["e", "edit"]: + return self.edit_tests() + else: + print("Invalid choice!") + return self.choose_tests() + + def check_result(self): + if len(self.test_factory) == 0: + return False + for test in self.test_factory: + if test["status"] != "PASS": + return False + return True + + def update_factory(self, test_factory): + if not self.test_factory: + self.test_factory = test_factory + else: + factory_changed = False + for test in self.test_factory: + if not self.search_factory(test, test_factory): + self.test_factory.remove(test) + print("delete %s test %s" % (test["name"], test["device"].get_name())) + for test in test_factory: + if not self.search_factory(test, self.test_factory): + self.test_factory.append(test) + print("add %s test %s" % (test["name"], test["device"].get_name())) + self.test_factory.sort(key=lambda k: k["name"]) + FactoryDocument(CertEnv.factoryfile, self.test_factory).save() + + def search_factory(self, obj_test, test_factory): + for test in test_factory: + if test["name"] == obj_test["name"] and test["device"].path == obj_test["device"].path: + return True + return False diff --git a/hwcompatible/device.py b/hwcompatible/device.py new file mode 100755 index 0000000..4355e6b --- /dev/null +++ b/hwcompatible/device.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +from .command import Command, CertCommandError + + +def filter_char(str): + ascii_blacklist = map(chr, range(9) + range(11,13) + range(14,32)) + filtered = u'' + start = 0 + for i in range(len(str)): + c = str[i] + if c in ascii_blacklist or (type(str) != unicode and ord(c) >= 128): + if start < i: + filtered += str[start:i] + start = i+1 + filtered += str[start:] + return filtered + +class CertDevice: + def __init__(self): + self.devices = None + + def get_devices(self): + self.devices = list() + try: + pipe = Command("udevadm info --export-db") + pipe.start() + properties = dict() + while True: + line = pipe.readline() + if line: + if line == "\n": + if len(properties) > 0: + device = Device(properties) + if device.path != "": + self.devices.append(device) + properties = dict() + else: + property = line.split(":", 1) + if len(property) == 2: + type = property[0].strip('\ \'\n') + attribute = property[1].strip('\ \'\n') + if type == "E": + keyvalue = attribute.split("=", 1) + if len(keyvalue) == 2: + properties[keyvalue[0]]= keyvalue[1] + elif type == "P": + properties["INFO"] = attribute + else: + break + except Exception as e: + print("Warning: get devices fail") + print(e) + self.devices.sort(key=lambda k: k.path) + return self.devices + +class Device: + def __init__(self, properties=None): + self.path = "" + if properties: + self.properties = properties + self.path = properties["DEVPATH"] + else: + self.properties = dict() + + def get_property(self, property): + try: + return self.properties[property] + except KeyError: + return "" + + def get_name(self): + if "INTERFACE" in self.properties.keys(): + return self.properties["INTERFACE"] + elif "DEVNAME" in self.properties.keys(): + return self.properties["DEVNAME"].split("/")[-1] + elif self.path: + return self.path.split("/")[-1] + else: + return "" + diff --git a/hwcompatible/document.py b/hwcompatible/document.py new file mode 100755 index 0000000..3e2a6af --- /dev/null +++ b/hwcompatible/document.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import json + +from .commandUI import CommandUI +from .command import Command, CertCommandError +from .device import Device +from .sysinfo import SysInfo +from .env import CertEnv + + +class Document(): + def __init__(self, filename, document=dict()): + self.document = document + self.filename = filename + + def new(self): + print("doc new") + + def save(self): + try: + with open(self.filename, "w+") as save_f: + json.dump(self.document, save_f, indent=4) + save_f.close() + except Exception as e: + print("Error: doc save fail.") + print(e) + return False + return True + + def load(self): + try: + with open(self.filename, "r") as load_f: + self.document = json.load(load_f) + load_f.close() + return True + except: + return False + +class CertDocument(Document): + def __init__(self, filename, document=dict()): + self.document = dict() + self.filename = filename + if not document: + self.load() + else: + self.documemt = document + + def new(self): + try: + pipe = Command("/usr/sbin/dmidecode -t 1") + pipe.start() + self.document = dict() + while True: + line = pipe.readline() + if line: + property = line.split(":", 1) + if len(property) == 2: + key = property[0].strip() + value = property[1].strip() + if key in ["Manufacturer", "Product Name", "Version"]: + self.document[key] = value + else: + break + except Exception as e: + print("Error: get hardware info fail.") + print(e) + + sysinfo = SysInfo(CertEnv.releasefile) + self.document["OS"] = sysinfo.product + " " + sysinfo.get_version() + self.document["kernel"] = sysinfo.kernel + self.document["tester"] = CommandUI().prompt("Please provide your Compatibility Test ID:") + self.document["Product URL"] = CommandUI().prompt("Please provide your Product URL:") + self.document["server"] = CommandUI().prompt("Please provide the Compatibility Test Server (Hostname or Ipaddr):") + + def get_hardware(self): + return self.document["Manufacturer"] + " " + self.document["Product Name"] + " " + self.document["Version"] + + def get_os(self): + return self.document["OS"] + + def get_server(self): + return self.document["server"] + + def get_url(self): + return self.document["Product URL"] + + def get_certify(self): + return self.document["tester"] + + def get_kernel(self): + return self.document["kernel"] + +class DeviceDocument(Document): + def __init__(self, filename, devices=list()): + self.filename = filename + self.document = list() + if not devices: + self.load() + else: + for device in devices: + self.document.append(device.properties) + +class FactoryDocument(Document): + def __init__(self, filename, factory=list()): + self.document = list() + self.filename = filename + if not factory: + self.load() + else: + for member in factory: + element = dict() + element["name"] = member["name"] + element["device"] = member["device"].properties + element["run"] = member["run"] + element["status"] = member["status"] + self.document.append(element) + + def get_factory(self): + factory = list() + for element in self.document: + test = dict() + device = Device(element["device"]) + test["device"] = device + test["name"] = element["name"] + test["run"] = element["run"] + test["status"] = element["status"] + factory.append(test) + return factory + + +class ConfigFile: + def __init__(self, filename): + self.filename = filename + self.parameters = dict() + self.config = list() + self.load() + + def load(self): + file = open(self.filename) + self.config = file.readlines() + for line in self.config: + if line.strip() and line.strip()[0] == "#": + continue + words = line.strip().split(" ") + if words[0]: + self.parameters[words[0]] = " ".join(words[1:]) + file.close() + + def get_parameter(self, name): + if self.parameters: + try: + return self.parameters[name] + except KeyError: + pass + return None + + def dump(self): + for line in self.config: + string = line.strip() + if not string or string[0] == "#": + continue + print(string) + + def add_parameter(self, name, value): + if not self.getParameter(name): + self.parameters[name] = value + self.config.append("%s %s\n" % (name, value)) + self.save() + return True + return False + + def remove_parameter(self, name): + if self.getParameter(name): + del self.parameters[name] + newconfig = list() + for line in self.config: + if line.strip() and line.strip()[0] == "#": + newconfig.append(line) + continue + words = line.strip().split(" ") + if words and words[0] == name: + continue + else: + newconfig.append(line) + self.config = newconfig + self.save() + + def save(self): + file = open(self.filename, "w") + for line in self.config: + file.write(line) + file.close() diff --git a/hwcompatible/env.py b/hwcompatible/env.py new file mode 100755 index 0000000..9a6f1fa --- /dev/null +++ b/hwcompatible/env.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + + +class CertEnv: + environmentfile = "/etc/oech.json" + releasefile = "/etc/os-release" + datadirectory = "/var/oech" + certificationfile = datadirectory + "/compatibility.json" + devicefile = datadirectory + "/device.json" + factoryfile = datadirectory + "/factory.json" + rebootfile = datadirectory + "/reboot.json" + testdirectoy = "/usr/share/oech/lib/tests" + logdirectoy = "/usr/share/oech/logs" + resultdirectoy = "/usr/share/oech/lib/server/results" + kernelinfo = "/usr/share/oech/kernelrelease.json" + + diff --git a/hwcompatible/job.py b/hwcompatible/job.py new file mode 100755 index 0000000..1944362 --- /dev/null +++ b/hwcompatible/job.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import sys +import string +import random +import argparse + +from .test import Test +from .env import CertEnv +from .command import Command, CertCommandError +from .commandUI import CommandUI +from .log import Logger +from .document import FactoryDocument +from .reboot import Reboot + + +class Job(object): + + def __init__(self, args=None): + """ + Creates an instance of Job class. + + :param args: the job configuration, usually set by command + line options and argument parsing + :type args: :class:`argparse.Namespace` + """ + self.args = args or argparse.Namespace() + self.test_factory = getattr(args, "test_factory", []) + self.test_suite = [] + self.job_id = ''.join(random.sample(string.ascii_letters + string.digits, 10)) + self.ui = CommandUI() + self.subtests_filter = getattr(args, "subtests_filter", None) + + self.test_parameters = None + if "test_parameters" in self.args: + self.test_parameters = {} + for parameter_name, parameter_value in self.args.test_parameters: + self.test_parameters[parameter_name] = parameter_value + + def discover(self, testname, device, subtests_filter=None): + if not testname: + print("testname not specified, discover test failed") + return None + + filename = testname + ".py" + for (dirpath, dirs, files) in os.walk(CertEnv.testdirectoy): + if filename in files: + break + pth = os.path.join(dirpath, filename) + if not os.access(pth, os.R_OK): + return None + + sys.path.insert(0, dirpath) + try: + module = __import__(testname, globals(), locals()) + except Exception as e: + print("Error: module import failed for %s" % testname) + print(e) + return None + + for thing in dir(module): + test_class = getattr(module, thing) + try: + from types import ClassType as ct + except ImportError: + ct = type + if isinstance(test_class, ct) and issubclass(test_class, Test): + if "test" not in dir(test_class): + continue + if (subtests_filter and not subtests_filter in dir(test_class)): + continue + test = test_class() + if "pri" not in dir(test): + continue + return test + + return None + + def create_test_suite(self, subtests_filter=None): + if self.test_suite: + return + + self.test_suite = [] + for test in self.test_factory: + if test["run"]: + testclass = self.discover(test["name"], test["device"], subtests_filter) + if testclass: + testcase = dict() + testcase["test"] = testclass + testcase["name"] = test["name"] + testcase["device"] = test["device"] + testcase["status"] = "FAIL" + self.test_suite.append(testcase) + else: + if not subtests_filter: + test["status"] = "FAIL" + print("not found %s" % test["name"]) + + if not len(self.test_suite): + print("No test found") + + def check_test_depends(self): + required_rpms = [] + for tests in self.test_suite: + for pkg in tests["test"].requirements: + try: + Command("rpm -q " + pkg).run_quiet() + except CertCommandError: + if not pkg in required_rpms: + required_rpms.append(pkg) + + if len(required_rpms): + print("Installing required packages: %s" % ", ".join(required_rpms)) + try: + cmd = Command("yum install -y " + " ".join(required_rpms)) + cmd.echo() + except CertCommandError as e: + print(e) + print("Fail to install required packages.") + return False + + return True + + def _run_test(self, testcase, subtests_filter=None): + name = testcase["name"] + if testcase["device"].get_name(): + name = testcase["name"] + "-" + testcase["device"].get_name() + logname = name + ".log" + reboot = None + try: + test = testcase["test"] + logger = Logger(logname, self.job_id, sys.stdout, sys.stderr) + logger.start() + if subtests_filter: + return_code = getattr(test, subtests_filter)() + else: + print("---- start to run test %s ----" % name) + args = argparse.Namespace(device=testcase["device"], logdir=logger.log.dir) + test.setup(args) + if test.reboot: + reboot = Reboot(testcase["name"], self, test.rebootup) + return_code = False + if reboot.setup(): + return_code = test.test() + else: + return_code = test.test() + except Exception as e: + print(e) + return_code = False + + if reboot: + reboot.clean() + if not subtests_filter: + test.teardown() + logger.stop() + print("") + return return_code + + def run_tests(self, subtests_filter=None): + if not len(self.test_suite): + print("No test to run.") + return + + self.test_suite.sort(key=lambda k: k["test"].pri) + for testcase in self.test_suite: + if self._run_test(testcase, subtests_filter): + testcase["status"] = "PASS" + else: + testcase["status"] = "FAIL" + + def run(self): + logger = Logger("job.log", self.job_id, sys.stdout, sys.stderr) + logger.start() + self.create_test_suite(self.subtests_filter) + if not self.check_test_depends(): + print("Required rpm package not installed, test stopped.") + logger.stop() + return + self.run_tests(self.subtests_filter) + self.save_result() + logger.stop() + self.show_summary() + + def show_summary(self): + print("------------- Summary -------------") + for test in self.test_factory: + if test["run"]: + name = test["name"] + if test["device"].get_name(): + name = test["name"] + "-" + test["device"].get_name() + if test["status"] == "PASS": + print(name.ljust(33) + "\033[0;32mPASS\033[0m") + else: + print(name.ljust(33) + "\033[0;31mFAIL\033[0m") + print("") + + def save_result(self): + for test in self.test_factory: + for testcase in self.test_suite: + if test["name"] == testcase["name"] and test["device"].path == testcase["device"].path: + test["status"] = testcase["status"] + diff --git a/hwcompatible/log.py b/hwcompatible/log.py new file mode 100755 index 0000000..cae0add --- /dev/null +++ b/hwcompatible/log.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import sys +import datetime + +from .env import CertEnv + + +class Log(object): + + def __init__(self, logname='oech.log', logdir='__temp__'): + if not logdir: + curtime = datetime.datetime.now().isoformat() + logdir = os.path.join(CertEnv.logdirectoy, curtime) + if not logdir.startswith(os.path.sep): + logdir = os.path.join(CertEnv.logdirectoy, logdir) + if not os.path.exists(logdir): + os.makedirs(logdir) + + self.dir = logdir + logfile = os.path.join(logdir, logname) + sys.stdout.flush() + self.terminal = sys.stdout + self.log = open(logfile, "a+") + + def write(self, message): + self.terminal.write(message) + if self.log: + self.log.write(message) + + def flush(self): + self.terminal.flush() + if self.log: + self.log.flush() + + def close(self): + self.log.close() + self.log = None + +class Logger(): + def __init__(self, logname, logdir, out, err): + self.log = Log(logname, logdir) + self.stdout = out + self.stderr = err + + def start(self): + sys.stdout = self.log + sys.stderr = sys.stdout + + def stop(self): + sys.stdout.close() + sys.stdout = self.stdout + sys.stderr = self.stderr + diff --git a/hwcompatible/reboot.py b/hwcompatible/reboot.py new file mode 100755 index 0000000..1cf0875 --- /dev/null +++ b/hwcompatible/reboot.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import datetime + +from .document import Document, FactoryDocument +from .env import CertEnv +from .command import Command, CertCommandError + + +class Reboot: + + def __init__(self, testname, job, rebootup): + self.testname = testname + self.rebootup = rebootup + self.job = job + self.reboot = dict() + + def clean(self): + if not (self.job and self.testname): + return + + for test in self.job.test_factory: + if test["run"] and self.testname == test["name"]: + test["reboot"] = False + + Command("rm -rf %s" % CertEnv.rebootfile).run(ignore_errors=True) + Command("systemctl disable oech").run(ignore_errors=True) + + def setup(self): + if not (self.job and self.testname): + print("Error: invalid reboot input.") + return False + + self.job.save_result() + for test in self.job.test_factory: + if test["run"] and self.testname == test["name"]: + test["reboot"] = True + test["status"] = "FAIL" + if not FactoryDocument(CertEnv.factoryfile, self.job.test_factory).save(): + print("Error: save testfactory doc fail before reboot.") + return False + + self.reboot["job_id"] = self.job.job_id + self.reboot["time"] = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + self.reboot["test"] = self.testname + self.reboot["rebootup"] = self.rebootup + if not Document(CertEnv.rebootfile, self.reboot).save(): + print("Error: save reboot doc fail.") + return False + + try: + Command("systemctl daemon-reload").run_quiet() + Command("systemctl enable oech").run_quiet() + except: + print("Error: enable oech.service fail.") + return False + + return True + + def check(self): + doc = Document(CertEnv.rebootfile) + if not doc.load(): + print("Error: reboot file load fail.") + return False + + try: + self.testname = doc.document["test"] + self.reboot = doc.document + self.job.job_id = self.reboot["job_id"] + self.job.subtests_filter = self.reboot["rebootup"] + time_reboot = datetime.datetime.strptime(self.reboot["time"], "%Y%m%d%H%M%S") + except: + print("Error: reboot file format not as expect.") + return False + + time_now = datetime.datetime.now() + time_delta = (time_now - time_reboot).seconds + cmd = Command("last reboot -s '%s seconds ago'" % time_delta) + reboot_list = cmd.get_str("^reboot .*$", single_line=False, return_list=True) + if len(reboot_list) != 1: + print("Errot:reboot times check fail.") + return False + + return True + diff --git a/hwcompatible/sysinfo.py b/hwcompatible/sysinfo.py new file mode 100755 index 0000000..458d90c --- /dev/null +++ b/hwcompatible/sysinfo.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import re + + +class SysInfo: + def __init__(self, file): + self.product = None + self.version = None + self.update = None + self.valid = False + self.kernel = None + self.arch = None + self.kernel_rpm = None + self.kerneldevel_rpm = None + self.kernel_version = None + self.debug_kernel = False + self.load(file) + + def load(self, file): + try: + f = open(file) + text = f.read() + f.close() + except: + print("Release file not found.") + return + + if text: + pattern = re.compile('NAME="(\w+)"') + results = pattern.findall(text) + self.product = results[0].strip() if results else "" + + pattern = re.compile('VERSION="(.+)"') + results = pattern.findall(text) + self.version = results[0].strip() if results else "" + + with os.popen('uname -m') as p: + self.arch = p.readline().strip() + self.debug_kernel = "debug" in self.arch + + with os.popen('uname -r') as p: + self.kernel = p.readline().strip() + self.kernel_rpm = "kernel-{}".format(self.kernel) + self.kerneldevel_rpm = "kernel-devel-{}".format(self.kernel) + self.kernel_version = self.kernel.split('-')[0] + + def get_version(self): + return self.version + diff --git a/hwcompatible/test.py b/hwcompatible/test.py new file mode 100755 index 0000000..07c5c2b --- /dev/null +++ b/hwcompatible/test.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + + +class Test: + + def __init__(self, name=None): + self.pri = 0 + self.requirements = list() + self.reboot = False + self.rebootup = None + + def setup(self, args=None): + pass + + def teardown(self): + pass diff --git a/scripts/Makefile b/scripts/Makefile new file mode 100755 index 0000000..15a6fbe --- /dev/null +++ b/scripts/Makefile @@ -0,0 +1,40 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +all: ; + +install: + rm -rf $(DESTDIR)/usr/bin/oech + mkdir -p $(DESTDIR)/usr/bin + cp oech $(DESTDIR)/usr/bin + chmod u+x $(DESTDIR)/usr/bin/oech + mkdir -p $(DESTDIR)/usr/share/oech/lib + cp kernelrelease.json $(DESTDIR)/usr/share/oech/ + mkdir -p $(DESTDIR)/usr/lib/systemd/system/ + cp *.service $(DESTDIR)/usr/lib/systemd/system/ + +clean: + rm -rf $(DESTDIR)/usr/bin/oech + diff --git a/scripts/kernelrelease.json b/scripts/kernelrelease.json new file mode 100644 index 0000000..beb05a6 --- /dev/null +++ b/scripts/kernelrelease.json @@ -0,0 +1,4 @@ +{ + "EulerOS 2.0 (SP8)": "4.19.36", + "openEuler 20.03 (LTS)": "4.19.90" +} diff --git a/scripts/oech b/scripts/oech new file mode 100644 index 0000000..d6420c8 --- /dev/null +++ b/scripts/oech @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import sys +import fcntl +import argparse + +sys.path.append("/usr/share/oech/lib/") +os.putenv("PYTHONPATH", "/usr/share/oech/lib/") + +from hwcompatible.compatibility import EulerCertification +import hwcompatible.version + + +class CertLock: + def __init__(self, filename): + self.filename = filename + self.fd = open(filename, 'w') + + def acquire(self): + try: + fcntl.flock(self.fd, fcntl.LOCK_EX|fcntl.LOCK_NB) + return True + except IOError: + return False + + def release(self): + fcntl.flock(self.fd, fcntl.LOCK_UN) + + def __del__(self): + self.fd.close() + + +if __name__ == '__main__': + if os.getuid() > 0: + sys.stderr.write("You need to be root to run this program.\n") + sys.exit(1) + + parser = argparse.ArgumentParser(description="Run openEuler Hardware Compatibility Test Suite") + parser.add_argument('--clean', action='store_true', + help='Clean saved testsuite.') + parser.add_argument('--rebootup', action='store_true', + help='Continue run testsuite after reboot system.') + parser.add_argument('--version', action='store_true', + help='Show testsuite version.') + args = parser.parse_args() + + lock = CertLock("/var/lock/oech.lock") + if not lock.acquire(): + sys.stderr.write("The oech may be running already, you should not run it repeated.\n") + sys.exit(1) + + cert = EulerCertification() + if args.clean: + if not cert.clean(): + lock.release() + sys.exit(1) + elif args.rebootup: + if not cert.run_rebootup(): + lock.release() + sys.exit(1) + elif args.version: + print("version: %s" % hwcompatible.version.version) + else: + if not cert.run(): + lock.release() + sys.exit(1) + + lock.release() + sys.exit(0) + diff --git a/scripts/oech-server.service b/scripts/oech-server.service new file mode 100644 index 0000000..56c41e1 --- /dev/null +++ b/scripts/oech-server.service @@ -0,0 +1,11 @@ +[Unit] +Description=openEuler Hardware Compatibility Test Server +After=network.target + +[Service] +Type=notify +ExecStartPre=/usr/share/oech/lib/server/oech-server-pre.sh +ExecStart=/usr/local/bin/uwsgi --ini /usr/share/oech/lib/server/uwsgi.ini + +[Install] +WantedBy=multi-user.target diff --git a/scripts/oech.service b/scripts/oech.service new file mode 100644 index 0000000..d2d0dec --- /dev/null +++ b/scripts/oech.service @@ -0,0 +1,13 @@ +[Unit] +Description=openEuler Hardware Compatibility Test Suite +After=basic.target network.target +DefaultDependencies=no + +[Service] +Type=oneshot +ExecStart=/usr/bin/oech --rebootup +RemainAfterExit=yes +TimeoutSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/server/Makefile b/server/Makefile new file mode 100755 index 0000000..58d330b --- /dev/null +++ b/server/Makefile @@ -0,0 +1,38 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +HWCERT_CLASS_LIB := /usr/share/oech/lib + +all: ; + +install: + rm -rf $(DESTDIR)$(HWCERT_CLASS_LIB)/server + mkdir -p $(DESTDIR)$(HWCERT_CLASS_LIB)/server + cp -raf `ls | grep -v Makefile` $(DESTDIR)$(HWCERT_CLASS_LIB)/server/ + chmod u+x $(DESTDIR)$(HWCERT_CLASS_LIB)/server/* + +clean: + rm -rf $(DESTDIR)$(HWCERT_CLASS_LIB)/server + diff --git a/server/__init__.py b/server/__init__.py new file mode 100755 index 0000000..b7c8821 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 diff --git a/server/oech-server-pre.sh b/server/oech-server-pre.sh new file mode 100755 index 0000000..220ae03 --- /dev/null +++ b/server/oech-server-pre.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +dir_nginx=/etc/nginx/default.d +dir_cert=/usr/share/oech/lib/server + +test -f ${dir_nginx}/uwsgi.conf || cp -af ${dir_cert}/uwsgi.conf ${dir_nginx} diff --git a/server/results/README.md b/server/results/README.md new file mode 100644 index 0000000..724c432 --- /dev/null +++ b/server/results/README.md @@ -0,0 +1 @@ +Devices diff --git a/server/server.py b/server/server.py new file mode 100755 index 0000000..121cdb5 --- /dev/null +++ b/server/server.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import json +import time +import subprocess +import base64 +try: + from urllib.parse import urlencode + from urllib.request import urlopen, Request + from urllib.error import HTTPError +except ImportError: + from urllib import urlencode + from urllib2 import urlopen, Request, HTTPError + +from flask import Flask, render_template, redirect, url_for, abort, request, \ + make_response, send_from_directory, flash +from flask_bootstrap import Bootstrap + + +app = Flask(__name__) +app.secret_key = os.urandom(24) +bootstrap = Bootstrap(app) + +dir_server = os.path.dirname(os.path.realpath(__file__)) +dir_results = os.path.join(dir_server, 'results') +dir_files = os.path.join(dir_server, 'files') + + +@app.errorhandler(400) +def bad_request(e): + return render_template('error.html', error='400 - Bad Request'), 400 + + +@app.errorhandler(404) +def page_not_found(e): + return render_template('error.html', error='404 - Page Not Found'), 404 + + +@app.errorhandler(500) +def internal_server_error(e): + return render_template('error.html', error='500 - Internal Server Error'), 500 + + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/results') +def get_results(): + results = {} + for host in next(os.walk(dir_results))[1]: + dir_host = os.path.join(dir_results, host) + results[host] = {} + for id in next(os.walk(dir_host))[1]: + dir_id = os.path.join(dir_host, id) + results[host][id] = next(os.walk(dir_id))[1] + return render_template('results.html', results=results) + + +@app.route('/results///') +def get_job(host, id, job): + dir_job = os.path.join(dir_results, host, id, job) + json_info = os.path.join(dir_job, 'compatibility.json') + json_results = os.path.join(dir_job, 'factory.json') + try: + with open(json_info, 'r') as f: + info = json.load(f) + with open(json_results, 'r') as f: + results = json.load(f) + except Exception as e: + abort(404) + return render_template('job.html', host=host, id=id, job=job, info=info, results=results) + + +@app.route('/results////devices/') +def get_device(host, id, job, interface): + dir_job = os.path.join(dir_results, host, id, job) + json_results = os.path.join(dir_job, 'factory.json') + try: + with open(json_results, 'r') as f: + results = json.load(f) + except Exception as e: + abort(404) + for testcase in results: + device = testcase.get('device') + if device and device.get('INTERFACE') == interface: + return render_template('device.html', device=device, interface=interface) + else: + abort(404) + + +@app.route('/results////devices') +def get_devices(host, id, job): + dir_job = os.path.join(dir_results, host, id, job) + json_devices = os.path.join(dir_job, 'device.json') + try: + with open(json_devices, 'r') as f: + devices = json.load(f) + except Exception as e: + abort(404) + return render_template('devices.html', devices=devices) + + +@app.route('/results////attachment') +def get_attachment(host, id, job): + dir_job = os.path.join(dir_results, host, id, job) + attachment = dir_job + '.tar.gz' + filedir = os.path.dirname(attachment) + filename = os.path.basename(attachment) + return send_from_directory(filedir, filename, as_attachment=True) + + +@app.route('/results////logs/') +def get_log(host, id, job, name): + dir_job = os.path.join(dir_results, host, id, job) + logpath = os.path.join(dir_job, name + '.log') + try: + with open(logpath, 'r') as f: + log = f.read().split('\n') + except Exception as e: + abort(404) + return render_template('log.html', name=name, log=log) + + +@app.route('/results////submit') +def submit(host, id, job): + dir_job = os.path.join(dir_results, host, id, job) + tar_job = dir_job + '.tar.gz' + json_cert = os.path.join(dir_job, 'compatibility.json') + try: + with open(json_cert, 'r') as f: + cert = json.load(f) + with open(tar_job, 'rb') as f: + attachment = base64.b64encode(f.read()) + except Exception as e: + print(e) + abort(500) + + form = {} + form['certid'] = cert.get('certid') + form['attachment'] = attachment + + server = cert.get('server') + url = 'http://{}/api/job/upload'.format(server) + data = urlencode(form).encode('utf8') + headers = { + 'Content-type': 'application/x-www-form-urlencoded', + 'Accept': 'text/plain' + } + try: + req = Request(url, data=data, headers=headers) + res = urlopen(req) + except HTTPError as e: + print(e) + res = e + + if res.code == 200: + flash('Submit Successful', 'success') + else: + flash('Submit Failed - {} {}'.format(res.code, res.msg), + 'danger') + return redirect(request.referrer or url_for('get_job', host=host, id=id, job=job)) + + +@app.route('/api/job/upload', methods=['GET', 'POST']) +def upload_job(): + host = request.values.get('host', '').strip().replace(' ', '-') + id = request.values.get('id', '').strip().replace(' ', '-') + job = request.values.get('job', '').strip().replace(' ', '-') + filetext = request.values.get('filetext', '') + + if not(all([host, id, job, filetext])): + return render_template('upload.html', host=host, id=id, job=job, + filetext=filetext, ret='Failed'), 400 + + dir_job = os.path.join(dir_results, host, id, job) + tar_job = dir_job + '.tar.gz' + if not os.path.exists(dir_job): + os.makedirs(dir_job) + try: + with open(tar_job, 'wb') as f: + f.write(base64.b64decode(filetext)) + os.system("tar xf %s -C %s" % (tar_job, os.path.dirname(dir_job))) + except Exception as e: + print(e) + abort(400) + return render_template('upload.html', host=host, id=id, job=job, + filetext=filetext, ret='Successful') + + +@app.route('/files') +def get_files(): + files = os.listdir(dir_files) + return render_template('files.html', files=files) + + +@app.route('/files/') +def download_file(path): + return send_from_directory('files', path, as_attachment=True) + + +@app.route('/api/file/upload', methods=['GET', 'POST']) +def upload_file(): + filename = request.values.get('filename', '') + filetext = request.values.get('filetext', '') + if not(all([filename, filetext])): + return render_template('upload.html', filename=filename, filetext=filetext, + ret='Failed'), 400 + + filepath = os.path.join(dir_files, filename) + if not os.path.exists(dir_files): + os.makedirs(dir_files) + try: + with open(filepath, 'wb') as f: + f.write(base64.b64decode(filetext)) + except Exception as e: + print(e) + abort(400) + return render_template('upload.html', filename=filename, filetext=filetext, + ret='Successful') + + +@app.route('/api/', methods=['GET', 'POST']) +def test_server(act): + valid_commands = ['rping', 'rcopy', 'ib_read_bw', 'ib_write_bw', 'ib_send_bw', + 'qperf'] + cmd = request.values.get('cmd', '') + cmd = cmd.split() + if (not cmd) or (cmd[0] not in valid_commands + ['all']): + print("Invalid command: {0}".format(cmd)) + abort(400) + + if act == 'start': + if 'rping' == cmd[0]: + cmd = ['rping', '-s'] + + if 'ib_' in cmd[0]: + ib_server_ip = request.values.get('ib_server_ip', '') + if not ib_server_ip: + print("No ib_server_ip assigned.") + abort(400) + ibdev, ibport = __get_ib_dev_port(ib_server_ip) + if not all([ibdev, ibport]): + print("No ibdev or ibport found.") + abort(400) + cmd.extend(['-d', ibdev, '-i', ibport]) + elif act == 'stop': + if 'all' == cmd[0]: + cmd = ['killall', '-9'] + valid_commands + else: + cmd = ['killall', '-9', cmd[0]] + else: + abort(404) + + print(' '.join(cmd)) + # pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + pipe = subprocess.Popen(cmd) + time.sleep(3) + if pipe.poll(): ## supposed to be 0(foreground) or None(background) + abort(400) + else: + return render_template('index.html') + + +def __get_ib_dev_port(ib_server_ip): + try: + cmd = "ip -o a | grep -w %s | awk '{print $2}'" % ib_server_ip + # print(cmd) + netdev = os.popen(cmd).read().strip() + + cmd = "udevadm info --export-db | grep DEVPATH | grep -w %s | awk -F= '{print $2}'" % netdev + # print(cmd) + path_netdev = ''.join(['/sys', os.popen(cmd).read().strip()]) + path_pci = path_netdev.split('net')[0] + path_ibdev = 'infiniband_verbs/uverb*/ibdev' + path_ibdev = ''.join([path_pci, path_ibdev]) + + cmd = "cat %s" % path_ibdev + # print(cmd) + ibdev = os.popen(cmd).read().strip() + + path_ibport = '/sys/class/net/%s/dev_id' % netdev + cmd = "cat %s" % path_ibport + # print(cmd) + ibport = os.popen(cmd).read().strip() + ibport = int(ibport, 16) + 1 + ibport = str(ibport) + + return ibdev, ibport + except Exception as e: + print(e) + return None, None + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=80) + diff --git a/server/static/favicon.ico b/server/static/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..4c7a21e37bfcf9a2909669de58ad2a4b2e7ddf02 GIT binary patch literal 553 zcmV+^0@nSBP)-zT1^5}fs!*treY}mWQ@8cEG*iirg01R|ePE!DY^VX;f z=)ZDOBNpRI+yDRo0dh%1K~y-)omJNkgD?;)tI!hXGMEMa|2GRHY6euO^-H3|isf6} zLOiRy%+fR~^Xhr~iaN=dhb*a2X+@H<2x(Frd5=3g!Tk~Q67~g2D4Vmd$bEUo>)VkF z!N<0D%oE)g#|Qj%P=DBfYyhq%NhBx5fkxU7)=^>o&H{sGALs#Mw%!Y0cm@_?9%un( zPrOczu7FyPfT|#jdRn^&8!Vd}$$Ttm+>@E{i-FcBIu?Ki81LEIFDAsHHz{HvG`P7u zZH@Mwl?}XKB4~NY()meMFSt){V)c z0PREwO`&fHlgWT+(5U&0=%h6SEJSydc4i3UhY1~<@zc``o$JOpJ$}M9v}00~@SudF zpk8rHj7k_Z1r40mtCMz35w#gLqLaIP7qwa3xTK8nHg4SGD3ESdC*x80VYq%7*XsrF r<4U<+Qh%(y>qUCKn*Uz**T0W9#HB$Wf#pz600000NkvXXu0mjfP$&pc literal 0 HcmV?d00001 diff --git a/server/templates/base.html b/server/templates/base.html new file mode 100644 index 0000000..a46edd0 --- /dev/null +++ b/server/templates/base.html @@ -0,0 +1,37 @@ +{% extends "bootstrap/base.html" %} +{% include "flash.html" %} + +{% block title %}OECH{% endblock %} + +{% block head %} +{{ super() }} + + +{% endblock %} + +{% block navbar %} + +{% endblock %} + +{% block content %} +
+ {% block page_content %}{% endblock %} +
+{% endblock %} diff --git a/server/templates/device.html b/server/templates/device.html new file mode 100644 index 0000000..9893997 --- /dev/null +++ b/server/templates/device.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + + + + + + + {% for key in device %} + + + + + {% endfor %} + +
{{ interface }}
{{ key }}{{ device.get(key) }}
+{% endblock %} diff --git a/server/templates/devices.html b/server/templates/devices.html new file mode 100644 index 0000000..d04ebcc --- /dev/null +++ b/server/templates/devices.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + + {% for device in devices %} + + + + + + {% for key in device %} + + + + + {% endfor %} + +
{{ device.get("DEVPATH") }}
{{ key }}{{ device.get(key) }}
+ {% endfor %} +{% endblock %} diff --git a/server/templates/error.html b/server/templates/error.html new file mode 100644 index 0000000..17c8d2b --- /dev/null +++ b/server/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}OECH - {{ error }}{% endblock %} + +{% block page_content %} + +{% endblock %} diff --git a/server/templates/files.html b/server/templates/files.html new file mode 100644 index 0000000..c87a593 --- /dev/null +++ b/server/templates/files.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + +
    + {% for file in files %} +
  • {{ file }}
  • + {% endfor %} +
+{% endblock %} diff --git a/server/templates/flash.html b/server/templates/flash.html new file mode 100644 index 0000000..b02c7e0 --- /dev/null +++ b/server/templates/flash.html @@ -0,0 +1,10 @@ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ + {{ message }} +
+ {% endfor %} + {% endif %} +{% endwith %} diff --git a/server/templates/index.html b/server/templates/index.html new file mode 100644 index 0000000..2001e7d --- /dev/null +++ b/server/templates/index.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + +

Welcome to the openEuler Hardware Compatibility Test.

+{% endblock %} diff --git a/server/templates/job.html b/server/templates/job.html new file mode 100644 index 0000000..19566e7 --- /dev/null +++ b/server/templates/job.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + + + + + +{% endblock %} diff --git a/server/templates/log.html b/server/templates/log.html new file mode 100644 index 0000000..5001724 --- /dev/null +++ b/server/templates/log.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + +

{{ testcase }}

+ {% for line in log %} + {{ line }}
+ {% endfor %} +{% endblock %} diff --git a/server/templates/results.html b/server/templates/results.html new file mode 100644 index 0000000..d621eb4 --- /dev/null +++ b/server/templates/results.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + + + + + {% for host, id_dict in results.items() %} + + + + {% endfor %} + +
+ +
+{% endblock %} diff --git a/server/templates/upload.html b/server/templates/upload.html new file mode 100644 index 0000000..a8e4e67 --- /dev/null +++ b/server/templates/upload.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}OECH{% endblock %} + +{% block page_content %} + + + + + + + + + + + + + + + + {% if filename %} + + + + + {% endif %} + + + + + +
id{{ id }}
host{{ host }}
job{{ job }}
filename{{ filename }}
filetext(base64){{ filetext }}
+{% endblock %} diff --git a/server/uwsgi.conf b/server/uwsgi.conf new file mode 100644 index 0000000..b1a7c55 --- /dev/null +++ b/server/uwsgi.conf @@ -0,0 +1,13 @@ +charset utf-8; + +client_max_body_size 10G; + +location ~ ^/ { + + include uwsgi_params; + + uwsgi_pass 127.0.0.1:8080; + uwsgi_param UWSGI_PYTHON /usr/bin/python3; + uwsgi_param UWSGI_CHDIR /usr/share/oech/lib/server; + uwsgi_param UWSGI_SCRIPT run:app; +} diff --git a/server/uwsgi.ini b/server/uwsgi.ini new file mode 100644 index 0000000..4f74329 --- /dev/null +++ b/server/uwsgi.ini @@ -0,0 +1,6 @@ +[uwsgi] +socket = 127.0.0.1:8080 +chdir = /usr/share/oech/lib/server +wsgi-file = server.py +callable = app +processes = 4 diff --git a/tests/Makefile b/tests/Makefile new file mode 100755 index 0000000..403fc4b --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,27 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + + +.PHONY: all clean install + +HWCERT_TEST_LIB := $(DESTDIR)/usr/share/oech/lib/tests/ +SUBDIRS := $(shell ls | grep -v Makefile) + +all: + for i in $(SUBDIRS); do $(MAKE) -C $$i; done + +clean: + for i in $(SUBDIRS); do $(MAKE) -C $$i DEST=$(HWCERT_TEST_LIB)/$$i clean; done + rm -rf $(HWCERT_TEST_LIB) + +install: + mkdir -p $(HWCERT_TEST_LIB) + for i in $(SUBDIRS); do $(MAKE) -C $$i DEST=$(HWCERT_TEST_LIB)/$$i install; done diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100755 index 0000000..b7c8821 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 diff --git a/tests/acpi/Makefile b/tests/acpi/Makefile new file mode 100755 index 0000000..84bdeb2 --- /dev/null +++ b/tests/acpi/Makefile @@ -0,0 +1,22 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +all: ; + +install: + mkdir -p $(DEST) + cp -a *.py $(DEST) + chmod a+x $(DEST)/*.py + +clean: + rm -rf $(DEST) diff --git a/tests/acpi/acpi.py b/tests/acpi/acpi.py new file mode 100755 index 0000000..10bacfb --- /dev/null +++ b/tests/acpi/acpi.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +from hwcompatible.test import Test +from hwcompatible.command import Command + + +class AcpiTest(Test): + + def __init__(self): + Test.__init__(self) + self.requirements = ["acpica-tools"] + + def test(self): + try: + Command("acpidump").echo() + return True + except Exception as e: + print(e) + return False + diff --git a/tests/cdrom/Makefile b/tests/cdrom/Makefile new file mode 100755 index 0000000..84bdeb2 --- /dev/null +++ b/tests/cdrom/Makefile @@ -0,0 +1,22 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +all: ; + +install: + mkdir -p $(DEST) + cp -a *.py $(DEST) + chmod a+x $(DEST)/*.py + +clean: + rm -rf $(DEST) diff --git a/tests/cdrom/cdrom.py b/tests/cdrom/cdrom.py new file mode 100755 index 0000000..dc104c7 --- /dev/null +++ b/tests/cdrom/cdrom.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import sys +import time +import shutil +import argparse + +from hwcompatible.test import Test +from hwcompatible.commandUI import CommandUI +from hwcompatible.command import Command, CertCommandError + + +class CDRomTest(Test): + + def __init__(self): + Test.__init__(self) + self.requirements = ["dvd+rw-tools", "genisoimage", "wodim", "util-linux"] + self.method = None + self.device = None + self.type = None + self.ui = CommandUI() + self.test_dir = "/usr/share/doc" + + def setup(self, args=None): + self.args = args or argparse.Namespace() + self.device = getattr(args, "device", None) + self.type = self.get_type(self.device) + self.get_mode(self.type) + + def test(self): + if not (self.method and self.device and self.type): + return False + + if self.method not in dir(self): + return False + + devname = self.device.get_property("DEVNAME") + Command("eject %s" % devname).run(ignore_errors=True) + while True: + print("Please insert %s disc into %s, then close the tray manually." % (self.type.lower(), devname)) + if self.method == "write_test": + print(" tips:disc should be new.") + elif self.method == "read_test": + print(" tips:disc should not be blank.") + if self.ui.prompt_confirm("Done well?"): + break + Command("eject -t %s" % devname).run(ignore_errors=True) + print("Waiting media..).") + time.sleep(20) + + if not getattr(self, self.method)(): + return False + return True + + def get_type(self, device): + if not device: + return None + + bd_types = ["BD_RE", "BD_R", "BD"] + dvd_types = ["DVD_RW", "DVD_PLUS_RW", "DVD_R", "DVD_PLUS_R", "DVD"] + cd_types = ["CD_RW", "CD_R", "CD"] + for type in bd_types: + if device.get_property("ID_CDROM_" + type) == "1": + return type + for type in dvd_types: + if device.get_property("ID_CDROM_" + type) == "1": + return type + for type in cd_types: + if device.get_property("ID_CDROM_" + type) == "1": + return type + + print("Can not find pr)oper test-type for %s." % device.get_name()) + return None + + def get_mode(self, type): + if not type: + return + + if "RW" in type or "RE" in type: + self.method = "rw_test" + elif "_R" in type: + self.method = "write_test" + else: + self.method = "read_test" + + def rw_test(self): + try: + devname = self.device.get_property("DEVNAME") + Command("umount %s" % devname).run(ignore_errors=True) + if "BD" in self.type: + print("Formatting ...") + sys.stdout.flush() + Command("dvd+rw-format -format=full %s 2>/dev/null" % devname).echo() + self.reload_disc(devname) + sys.stdout.flush() + return self.write_test() + elif "DVD_PLUS" in self.type: + print("Formatting ...") + sys.stdout.flush() + Command("dvd+rw-format -force %s 2>/dev/null" % devname).echo() + self.reload_disc(devname) + sys.stdout.flush() + return self.write_test() + else: + print("Blanking ...") + sys.stdout.flush() + blankCommand = Command("cdrecord -v dev=%s blank=fast" % devname).echo() + self.reload_disc(devname) + sys.stdout.flush() + return self.write_test() + except CertCommandError as e: + return False + + def write_test(self): + try: + devname = self.device.get_property("DEVNAME") + Command("umount %s" % devname).run(ignore_errors=True) + if "BD" in self.type or "DVD_PLUS" in self.type: + Command("growisofs -Z %s -quiet -R %s" % (devname, self.test_dir)).echo() + self.reload_disc(devname) + sys.stdout.flush() + return True + else: + write_opts ="-sao" + try: + command = Command("cdrecord dev=%s -checkdrive" % devname) + modes = command.get_str(regex="^Supported modes[^:]*:(?P.*$)", regex_group="modes", single_line=False, ignore_errors=True) + if "TAO" in modes: + write_opts="-tao" + if "SAO" in modes: + write_opts="-sao" + flags = command.get_str(regex="^Driver flags[^:]*:(?P.*$)", regex_group="flags", single_line=False, ignore_errors=True) + if "BURNFREE" in flags: + write_opts += " driveropts=burnfree" + except CertCommandError as e: + print(e) + + size = Command("mkisofs -quiet -R -print-size %s " % self.test_dir).get_str() + blocks = int(size) + + Command("mkisofs -quiet -R %s | cdrecord -v %s dev=%s fs=32M tsize=%ss -" % (self.test_dir, write_opts, devname, blocks)).echo() + self.reload_disc(devname) + sys.stdout.flush() + return True + except CertCommandError as e: + return False + + def read_test(self): + try: + devname = self.device.get_property("DEVNAME") + if os.path.exists("mnt_cdrom"): + shutil.rmtree("mnt_cdrom") + os.mkdir("mnt_cdrom") + + print("Mounting media ...") + Command("umount %s" % devname).echo(ignore_errors=True) + Command("mount -o ro %s ./mnt_cdrom" % devname).echo() + + size = Command("df %s | tail -n1 | awk '{print $3}'" % devname).get_str() + size = int(size) + if size == 0: + print("Error: blank disc.") + Command("umount ./mnt_cdrom").run(ignore_errors=True) + Command("rm -rf ./mnt_cdrom").run(ignore_errors=True) + return False + + if os.path.exists("device_dir"): + shutil.rmtree("device_dir") + os.mkdir("device_dir") + + print("Copying files ...") + sys.stdout.flush() + Command("cp -dpRf ./mnt_cdrom/. ./device_dir/").run() + + print("Comparing files ...") + sys.stdout.flush() + return_code = self.cmp_tree("mnt_cdrom", "device_dir") + Command("umount ./mnt_cdrom").run(ignore_errors=True) + Command("rm -rf ./mnt_cdrom ./device_dir").run(ignore_errors=True) + return return_code + except CertCommandError as e: + print(e) + return False + + def cmp_tree(self, dir1, dir2): + if not (dir1 and dir2): + print("Error: invalid input dir.") + return False + try: + Command("diff -r %s %s" % (dir1, dir2)).run() + return True + except CertCommandError as e: + print("Error: file comparison failed.") + return False + + def reload_disc(self, device): + if not device: + return False + + print("Reloading the media ... ") + sys.stdout.flush() + try: + Command("eject %s" % device).run() + print("tray ejected.") + sys.stdout.flush() + except: + pass + + try: + Command("eject -t %s" % device).run() + print("tray auto-closed.\n") + sys.stdout.flush() + except: + print("Could not auto-close the tray, please close the tray manually.") + self.ui.prompt_confirm("Done well?") + + time.sleep(20) + return True + diff --git a/tests/clock/Makefile b/tests/clock/Makefile new file mode 100755 index 0000000..610ffbc --- /dev/null +++ b/tests/clock/Makefile @@ -0,0 +1,34 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: all install clean + +all: clock + +CFLAGS+=-Wall +CFLAGS+=-DCPU_ALLOC +# sched_setaffinity has no size_t argument on older systems. +ifeq ($(shell grep 'sched_setaffinity.*size_t' /usr/include/sched.h),) +CFLAGS+=-DOLD_SCHED_SETAFFINITY +endif + +clock: clock.c + $(CC) $(CFLAGS) -lrt $< -o $@ + +install: + mkdir -p $(DEST) + cp -a clock *.py $(DEST) + chmod a+x $(DEST)/*.py + +clean: + rm -rf $(DEST) + rm -rf clock + diff --git a/tests/clock/clock.c b/tests/clock/clock.c new file mode 100644 index 0000000..7178882 --- /dev/null +++ b/tests/clock/clock.c @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2020 Huawei Technologies Co., Ltd. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public + * License as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA + */ + +#define _GNU_SOURCE 1 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int test_clock_direction() +{ + time_t starttime = 0; + time_t stoptime = 0; + int sleeptime = 60; + int delta = 0; + + printf("clock direction test start\n"); + time(&starttime); + sleep(sleeptime); + time(&stoptime); + + delta = (int)stoptime - (int)starttime - sleeptime; + if (delta != 0) { + printf("clock direction test fail\n"); + return 1; + } else { + printf("clock direction test complete\n"); + return 0; + } +} + +int test_rtc_clock() +{ + int rtc, delta; + struct tm rtc_tm1, rtc_tm2; + int sleeptime = 120; + + printf("rtc_clock test start\n"); + if ((rtc = open("/dev/rtc", O_WRONLY)) < 0) { + perror("could not open RTC device"); + return 1; + } + + if (ioctl(rtc, RTC_RD_TIME, &rtc_tm1) < 0) { + perror("could not get the RTC time"); + close(rtc); + return 1; + } + sleep(sleeptime); + if (ioctl(rtc, RTC_RD_TIME, &rtc_tm2) < 0) { + perror("could not get the RTC time"); + close(rtc); + return 1; + } + + close(rtc); + delta = (int)mktime(&rtc_tm2) - (int)mktime(&rtc_tm1) - sleeptime; + if (delta != 0) { + printf("rtc_clock test fail\n"); + return 1; + } else { + printf("rtc_clock test complete\n"); + return 0; + } +} + +int main() +{ + int ret = 0; + ret += test_clock_direction(); + ret += test_rtc_clock(); + return ret; +} diff --git a/tests/clock/clock.py b/tests/clock/clock.py new file mode 100755 index 0000000..c91cf9d --- /dev/null +++ b/tests/clock/clock.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os + +from hwcompatible.test import Test +from hwcompatible.command import Command + +clock_dir = os.path.dirname(os.path.realpath(__file__)) + + +class ClockTest(Test): + def test(self): + return 0 == os.system("cd %s; ./clock" % clock_dir) + + +if __name__ == '__main__': + t = ClockTest() + t.setup() + t.test() diff --git a/tests/cpufreq/Makefile b/tests/cpufreq/Makefile new file mode 100755 index 0000000..84bdeb2 --- /dev/null +++ b/tests/cpufreq/Makefile @@ -0,0 +1,22 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +all: ; + +install: + mkdir -p $(DEST) + cp -a *.py $(DEST) + chmod a+x $(DEST)/*.py + +clean: + rm -rf $(DEST) diff --git a/tests/cpufreq/cal.py b/tests/cpufreq/cal.py new file mode 100755 index 0000000..018322c --- /dev/null +++ b/tests/cpufreq/cal.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import decimal +import time + + +def cal(): + decimal.getcontext().prec = 1000 + one = decimal.Decimal(1) + for i in range(1000): + j = (i * one).sqrt() + + +if __name__ == '__main__': + time_start = time.time() + while 1: + cal() + time_delta = time.time() - time_start + if time_delta >= 2: + print(time_delta) + break diff --git a/tests/cpufreq/cpufreq.py b/tests/cpufreq/cpufreq.py new file mode 100755 index 0000000..832e943 --- /dev/null +++ b/tests/cpufreq/cpufreq.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +from random import randint +from time import sleep + +from hwcompatible.env import CertEnv +from hwcompatible.test import Test +from hwcompatible.command import Command + + +class CPU: + def __init__(self): + self.requirements = ['util-linux', 'kernel-tools'] + self.cpu = None + self.nums = None + self.list = None + self.numa_nodes = None + self.governors = None + self.original_governor = None + self.max_freq = None + self.min_freq = None + + def get_info(self): + cmd = Command("lscpu") + try: + nums = cmd.get_str('CPU\(s\):\s+(?P\d+)', 'cpus', False) + except: + return False + self.nums = int(nums) + self.list = range(self.nums) + + cmd = Command("cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq") + try: + max_freq = cmd.get_str() + except: + return False + self.max_freq = int(max_freq) + + cmd = Command("cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_min_freq") + try: + min_freq = cmd.get_str() + except: + return False + self.min_freq = int(min_freq) + + return True + + def set_freq(self, freq, cpu='all'): + cmd = Command("cpupower -c %s frequency-set --freq %s" % (cpu, freq)) + try: + cmd.run() + return cmd.returncode + except: + return False + + def get_freq(self, cpu): + cmd = Command("cpupower -c %s frequency-info -w" % cpu) + try: + return int(cmd.get_str('.* frequency: (?P\d+) .*', 'freq', False)) + except: + return False + + def set_governor(self, governor, cpu='all'): + cmd = Command("cpupower -c %s frequency-set --governor %s" % (cpu, governor)) + try: + cmd.run() + return cmd.returncode + except: + return False + + def get_governor(self, cpu): + cmd = Command("cpupower -c %s frequency-info -p" % cpu) + try: + return cmd.get_str('.* governor "(?P\w+)".*', 'governor', False) + except: + return False + + def find_path(self, parent_dir, target_name): + cmd = Command("find %s -name %s" % (parent_dir, target_name)) + try: + cmd.run() + return cmd.returncode + except: + return False + + +class Load: + def __init__(self, cpu): + self.cpu = cpu + self.process = Command("taskset -c {} python -u {}/cpufreq/cal.py".format(self.cpu, CertEnv.testdirectoy)) + self.returncode = None + + def run(self): + self.process.start() ## background + + def get_runtime(self): + if not self.process: + return None + + while self.returncode is None: + self.returncode = self.process.poll() + if self.returncode == 0: + line = self.process.readline() + return float(line) + else: + return False + + +class CPUFreqTest(Test): + def test_userspace(self): + target_cpu = randint(0, self.cpu.nums-1) + target_freq = randint(self.cpu.min_freq, self.cpu.max_freq) + if self.cpu.set_freq(target_freq, cpu=target_cpu) != 0: + print("[X] Set CPU%s to freq %d failed." % (target_cpu, target_freq)) + return False + print("[.] Set CPU%s to freq %d." % (target_cpu, target_freq)) + target_cpu_freq = self.cpu.get_freq(target_cpu) + print("[.] Current freq of CPU%s is %d." % (target_cpu, target_cpu_freq)) + + target_cpu_governor = self.cpu.get_governor(target_cpu) + if target_cpu_governor != 'userspace': + print("[X] The governor of CPU%s(%s) is not userspace." % + (target_cpu, target_cpu_governor)) + return False + print("[.] The governor of CPU%s is %s." % + (target_cpu, target_cpu_governor)) + + ## min_freq -> max_runtime + self.cpu.set_freq(self.cpu.min_freq) + load_list = [] + runtime_list = [] + for cpu in self.cpu.list: + load_test = Load(cpu) + load_test.run() + load_list.append(load_test) + for cpu in self.cpu.list: + runtime = load_list[cpu].get_runtime() + runtime_list.append(runtime) + max_average_runtime = 1.0 * sum(runtime_list) / len(runtime_list) + if max_average_runtime == 0: + print("[X] Max average time is 0.") + return False + print("[.] Max average time of all CPUs userspace load test: %.2f" % + max_average_runtime) + + ## max_freq -> min_runtime + self.cpu.set_freq(self.cpu.max_freq) + load_list = [] + runtime_list = [] + for cpu in self.cpu.list: + load_test = Load(cpu) + load_test.run() + load_list.append(load_test) + for cpu in self.cpu.list: + runtime = load_list[cpu].get_runtime() + runtime_list.append(runtime) + min_average_runtime = 1.0 * sum(runtime_list) / len(runtime_list) + if min_average_runtime == 0: + print("[X] Min average time is 0.") + return False + print("[.] Min average time of all CPUs userspace load test: %.2f" % + min_average_runtime) + + measured_speedup = 1.0 * max_average_runtime / min_average_runtime + expected_speedup = 1.0 * self.cpu.max_freq / self.cpu.min_freq + tolerance = 1.0 + min_speedup = expected_speedup - (expected_speedup - 1.0) * tolerance + max_speedup = expected_speedup + (expected_speedup - 1.0) * tolerance + if not min_speedup < measured_speedup < max_speedup: + print("[X] The speedup(%.2f) is not between %.2f and %.2f" % + (measured_speedup, min_speedup, max_speedup)) + return False + print("[.] The speedup(%.2f) is between %.2f and %.2f" % + (measured_speedup, min_speedup, max_speedup)) + + return True + + def test_ondemand(self): + if self.cpu.set_governor('powersave') != 0: + print("[X] Set governor of all CPUs to powersave failed.") + return False + print("[.] Set governor of all CPUs to powersave.") + + if self.cpu.set_governor('ondemand') != 0: + print("[X] Set governor of all CPUs to ondemand failed.") + return False + print("[.] Set governor of all CPUs to ondemand.") + + target_cpu = randint(0, self.cpu.nums) + target_cpu_governor = self.cpu.get_governor(target_cpu) + if target_cpu_governor != 'ondemand': + print("[X] The governor of CPU%s(%s) is not ondemand." % + (target_cpu, target_cpu_governor)) + return False + print("[.] The governor of CPU%s is %s." % + (target_cpu, target_cpu_governor)) + + load_test = Load(target_cpu) + load_test.run() + sleep(1) + target_cpu_freq = self.cpu.get_freq(target_cpu) + if target_cpu_freq != self.cpu.max_freq: + print("[X] The freq of CPU%s(%d) is not scaling_max_freq(%d)." % + (target_cpu, target_cpu_freq, self.cpu.max_freq)) + return False + print("[.] The freq of CPU%s is scaling_max_freq(%d)." % + (target_cpu, target_cpu_freq)) + + load_test_time = load_test.get_runtime() + print("[.] Time of CPU%s ondemand load test: %.2f" % + (target_cpu, load_test_time)) + target_cpu_freq = self.cpu.get_freq(target_cpu) + if not target_cpu_freq <= self.cpu.max_freq: + print("[X] The freq of CPU%s(%d) is not less equal than %d." % + (target_cpu, target_cpu_freq, self.cpu.max_freq)) + return False + print("[.] The freq of CPU%s(%d) is less equal than %d." % + (target_cpu, target_cpu_freq, self.cpu.max_freq)) + + return True + + def test_conservative(self): + if self.cpu.set_governor('powersave') != 0: + print("[X] Set governor of all CPUs to powersave failed.") + return False + print("[.] Set governor of all CPUs to powersave.") + + if self.cpu.set_governor('conservative') != 0: + print("[X] Set governor of all CPUs to conservative failed.") + return False + print("[.] Set governor of all CPUs to conservative.") + + target_cpu = randint(0, self.cpu.nums) + target_cpu_governor = self.cpu.get_governor(target_cpu) + if target_cpu_governor != 'conservative': + print("[X] The governor of CPU%s(%s) is not conservative." % + (target_cpu, target_cpu_governor)) + return False + print("[.] The governor of CPU%s is %s." % + (target_cpu, target_cpu_governor)) + + load_test = Load(target_cpu) + load_test.run() + sleep(1) + target_cpu_freq = self.cpu.get_freq(target_cpu) + if not self.cpu.min_freq < target_cpu_freq < self.cpu.max_freq: + print("[X] The freq of CPU%s(%d) is not between %d~%d." % + (target_cpu, target_cpu_freq, self.cpu.min_freq, self.cpu.max_freq)) + return False + print("[.] The freq of CPU%s(%d) is between %d~%d." % + (target_cpu, target_cpu_freq, self.cpu.min_freq, self.cpu.max_freq)) + + load_test_time = load_test.get_runtime() + print("[.] Time of CPU%s conservative load test: %.2f" % + (target_cpu, load_test_time)) + target_cpu_freq = self.cpu.get_freq(target_cpu) + print("[.] Current freq of CPU%s is %d." % (target_cpu, target_cpu_freq)) + + return True + + def test_powersave(self): + if self.cpu.set_governor('powersave') != 0: + print("[X] Set governor of all CPUs to powersave failed.") + return False + print("[.] Set governor of all CPUs to powersave.") + + target_cpu = randint(0, self.cpu.nums) + target_cpu_governor = self.cpu.get_governor(target_cpu) + if target_cpu_governor != 'powersave': + print("[X] The governor of CPU%s(%s) is not powersave." % + (target_cpu, target_cpu_governor)) + return False + print("[.] The governor of CPU%s is %s." % + (target_cpu, target_cpu_governor)) + + target_cpu_freq = self.cpu.get_freq(target_cpu) + if target_cpu_freq != self.cpu.min_freq: + print("[X] The freq of CPU%s(%d) is not scaling_min_freq(%d)." % + (target_cpu, target_cpu_freq, self.cpu.min_freq)) + return False + print("[.] The freq of CPU%s is %d." % (target_cpu, target_cpu_freq)) + + load_test = Load(target_cpu) + load_test.run() + load_test_time = load_test.get_runtime() + print("[.] Time of CPU%s powersave load test: %.2f" % + (target_cpu, load_test_time)) + target_cpu_freq = self.cpu.get_freq(target_cpu) + print("[.] Current freq of CPU%s is %d." % (target_cpu, target_cpu_freq)) + + return True + + def test_performance(self): + if self.cpu.set_governor('performance') != 0: + print("[X] Set governor of all CPUs to performance failed.") + return False + print("[.] Set governor of all CPUs to performance.") + + target_cpu = randint(0, self.cpu.nums) + target_cpu_governor = self.cpu.get_governor(target_cpu) + if target_cpu_governor != 'performance': + print("[X] The governor of CPU%s(%s) is not performance." % + (target_cpu, target_cpu_governor)) + return False + print("[.] The governor of CPU%s is %s." % + (target_cpu, target_cpu_governor)) + + target_cpu_freq = self.cpu.get_freq(target_cpu) + if target_cpu_freq != self.cpu.max_freq: + print("[X] The freq of CPU%s(%d) is not scaling_max_freq(%d)." % + (target_cpu, target_cpu_freq, self.cpu.max_freq)) + return False + print("[.] The freq of CPU%s is %d." % (target_cpu, target_cpu_freq)) + + load_test = Load(target_cpu) + load_test.run() + load_test_time = load_test.get_runtime() + print("[.] Time of CPU%s performance load test: %.2f" % + (target_cpu, load_test_time)) + target_cpu_freq = self.cpu.get_freq(target_cpu) + print("[.] Current freq of CPU%s is %d." % (target_cpu, target_cpu_freq)) + + return True + + def test(self): + self.cpu = CPU() + self.original_governor = self.cpu.get_governor(0) + + if not self.cpu.get_info(): + print("[X] Fail to get CPU info." \ + " Please check if the CPU supports cpufreq.") + return False + + ret = True + print("") + print("[.] Test userspace") + if not self.test_userspace(): + print("[X] Test userspace FAILED") + ret = False + print("") + print("[.] Test ondemand") + if not self.test_ondemand(): + print("[X] Test ondemand FAILED") + ret = False + print("") + print("[.] Test conservative") + if not self.test_conservative(): + print("[X] Test conservative FAILED") + ret = False + print("") + print("[.] Test powersave") + if not self.test_powersave(): + print("[X] Test powersave FAILED") + ret = False + print("") + print("[.] Test performance") + if not self.test_performance(): + print("[X] Test performance FAILED") + ret = False + + self.cpu.set_governor(self.original_governor) + return ret + + +if __name__ == "__main__": + t = CPUFreqTest() + t.setup() + t.test() diff --git a/tests/disk/Makefile b/tests/disk/Makefile new file mode 100755 index 0000000..84bdeb2 --- /dev/null +++ b/tests/disk/Makefile @@ -0,0 +1,22 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +all: ; + +install: + mkdir -p $(DEST) + cp -a *.py $(DEST) + chmod a+x $(DEST)/*.py + +clean: + rm -rf $(DEST) diff --git a/tests/disk/disk.py b/tests/disk/disk.py new file mode 100755 index 0000000..6efd442 --- /dev/null +++ b/tests/disk/disk.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import sys +import time +import shutil +import string + +from hwcompatible.test import Test +from hwcompatible.command import Command, CertCommandError +from hwcompatible.commandUI import CommandUI +from hwcompatible.device import CertDevice, Device + + +class DiskTest(Test): + + def __init__(self): + Test.__init__(self) + self.disks = list() + self.filesystems = ["ext4"] + self.ui = CommandUI() + + def setup(self, args=None): + try: + print("Disk Info:") + Command("fdisk -l").echo(ignore_errors=True) + print("\nPartition Info:") + Command("df -h").echo(ignore_errors=True) + print("\nMount Info:") + Command("mount").echo(ignore_errors=True) + print("\nSwap Info:") + Command("cat /proc/swaps").echo(ignore_errors=True) + print("\nLVM Info:") + Command("pvdisplay").echo(ignore_errors=True) + Command("vgdisplay").echo(ignore_errors=True) + Command("lvdisplay").echo(ignore_errors=True) + print("Md Info:") + Command("cat /proc/mdstat").echo(ignore_errors=True) + sys.stdout.flush() + print("\n") + except Exception as e: + print("Warning: could not get disk info") + print(e) + + def test(self): + self.get_disk() + if len(self.disks) == 0: + print("No suite disk found to test.") + return False + + self.disks.append("all") + disk = self.ui.prompt_edit("Which disk would you like to test: ", self.disks[0], self.disks) + return_code = True + if disk == "all": + for disk in self.disks[:-1]: + if not self.raw_test(disk): + return_code = False + if not self.vfs_test(disk): + return_code = False + else: + if not self.raw_test(disk): + return_code = False + if not self.vfs_test(disk): + return_code = False + return return_code + + def get_disk(self): + self.disks = list() + disks = list() + devices = CertDevice().get_devices() + for device in devices: + if (device.get_property("DEVTYPE") == "disk" and not device.get_property("ID_TYPE")) or \ + device.get_property("ID_TYPE") == "disk": + if "/host" in device.get_property("DEVPATH"): + disks.append(device.get_name()) + + partition_file = open("/proc/partitions", "r") + partition = partition_file.read() + partition_file.close() + + os.system("swapon -a 2>/dev/null") + swap_file = open("/proc/swaps", "r") + swap = swap_file.read() + swap_file.close() + + mdstat_file = open("/proc/mdstat", "r") + mdstat = mdstat_file.read() + mdstat_file.close() + + mtab_file = open("/etc/mtab", "r") + mtab = mtab_file.read() + mtab_file.close() + + mount_file = open("/proc/mounts", "r") + mounts = mount_file.read() + mount_file.close() + + for disk in disks: + if disk not in partition or ("/dev/%s" % disk) in swap: + continue + if ("/dev/%s" % disk) in mounts or ("/dev/%s" % disk) in mtab: + continue + if disk in mdstat or os.system("pvs 2>/dev/null | grep -q '/dev/%s'" % disk) == 0: + continue + self.disks.append(disk) + + un_suitable = list(set(disks).difference(set(self.disks))) + if len(un_suitable) > 0: + print("These disks %s are in use now, skip them." % "|".join(un_suitable)) + + def raw_test(self, disk): + print("\n#############") + print("%s raw IO test" % disk) + device = "/dev/" + disk + if not os.path.exists(device): + print("Error: device %s not exists." % device) + proc_path="/sys/block/" + disk + if not os.path.exists(proc_path): + proc_path="/sys/block/*/" + disk + size = Command("cat %s/size" % proc_path).get_str() + size = int(size)/2 + if size <= 0: + print("Error: device %s size not suitable to do test." % device) + return False + elif size > 1048576: + size = 1048576 + + print("\nStarting sequential raw IO test...") + opts = "-direct=1 -iodepth 4 -rw=rw -rwmixread=50 -group_reporting -name=file -runtime=300" + if not self.do_fio(device, size, opts): + print("%s sequential raw IO test fail." % device) + print("#############") + return False + + print("\nStarting rand raw IO test...") + opts = "-direct=1 -iodepth 4 -rw=randrw -rwmixread=50 -group_reporting -name=file -runtime=300" + if not self.do_fio(device, size, opts): + print("%s rand raw IO test fail." % device) + print("#############") + return False + + print("#############") + return True + + def vfs_test(self, disk): + print("\n#############") + print("%s vfs test" % disk) + device = "/dev/" + disk + if not os.path.exists(device): + print("Error: device %s not exists." % device) + proc_path="/sys/block/" + disk + if not os.path.exists(proc_path): + proc_path="/sys/block/*/" + disk + size = Command("cat %s/size" % proc_path).get_str() + size = int(size)/2/2 + if size <= 0: + print("Error: device %s size not suitable to do test." % device) + return False + elif size > 1048576: + size = 1048576 + + if os.path.exists("vfs_test"): + shutil.rmtree("vfs_test") + os.mkdir("vfs_test") + path = os.path.join(os.getcwd(), "vfs_test") + + return_code = True + for fs in self.filesystems: + try: + print("\nFormatting %s to %s ..." % (device, fs)) + Command("umount %s" % device).echo(ignore_errors=True) + Command("mkfs -t %s -F %s 2>/dev/null" % (fs, device)).echo() + Command("mount -t %s %s %s" % (fs, device, "vfs_test")).echo() + + print("\nStarting sequential vfs IO test...") + opts = "-direct=1 -iodepth 4 -rw=rw -rwmixread=50 -name=directoy -runtime=300" + if not self.do_fio(path, size, opts): + return_code = False + break + + print("\nStarting rand vfs IO test...") + opts = "-direct=1 -iodepth 4 -rw=randrw -rwmixread=50 -name=directoy -runtime=300" + if not self.do_fio(path, size, opts): + return_code = False + break + except Exception as e: + print(e) + return_code = False + break + + Command("umount %s" % device).echo(ignore_errors=True) + Command("rm -rf vfs_test").echo(ignore_errors=True) + print("#############") + return return_code + + def do_fio(self, filepath, size, option): + if os.path.isdir(filepath): + file_opt = "-directory=%s" % filepath + else: + file_opt = "-filename=%s" % filepath + max_bs = 64 + bs = 4 + while bs <= max_bs: + if os.system("fio %s -size=%dK -bs=%dK %s" % (file_opt, size, bs, option)) != 0: + print("Error: %s fio failed." % filepath) + return False + print("\n") + sys.stdout.flush() + bs = bs *2 + return True + + + diff --git a/tests/ipmi/Makefile b/tests/ipmi/Makefile new file mode 100755 index 0000000..84bdeb2 --- /dev/null +++ b/tests/ipmi/Makefile @@ -0,0 +1,22 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +all: ; + +install: + mkdir -p $(DEST) + cp -a *.py $(DEST) + chmod a+x $(DEST)/*.py + +clean: + rm -rf $(DEST) diff --git a/tests/ipmi/ipmi.py b/tests/ipmi/ipmi.py new file mode 100755 index 0000000..19be5d5 --- /dev/null +++ b/tests/ipmi/ipmi.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +from hwcompatible.test import Test +from hwcompatible.command import Command + + +class IpmiTest(Test): + + def __init__(self): + Test.__init__(self) + self.requirements = ["OpenIPMI", "ipmitool"] + + def start_ipmi(self): + try: + Command("systemctl start ipmi").run() + Command("systemctl status ipmi.service").get_str(regex="Active: active", single_line=False) + except: + print("ipmi service cant't be started") + return False + return True + + def ipmitool(self): + cmd_list = ["ipmitool fru","ipmitool sensor"] + for cmd in cmd_list: + try: + Command(cmd).echo() + except: + print("%s return error." % cmd) + return False + return True + + def test(self): + if not self.start_ipmi(): + return False + if not self.ipmitool(): + return False + return True + +if __name__ == "__main__": + i = IpmiTest() + i.test() + diff --git a/tests/kdump/Makefile b/tests/kdump/Makefile new file mode 100755 index 0000000..84bdeb2 --- /dev/null +++ b/tests/kdump/Makefile @@ -0,0 +1,22 @@ +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +.PHONY: install clean + +all: ; + +install: + mkdir -p $(DEST) + cp -a *.py $(DEST) + chmod a+x $(DEST)/*.py + +clean: + rm -rf $(DEST) diff --git a/tests/kdump/kdump.py b/tests/kdump/kdump.py new file mode 100755 index 0000000..1bcc8a3 --- /dev/null +++ b/tests/kdump/kdump.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2020 Huawei Technologies Co., Ltd. +# oec-hardware is licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# Create: 2020-04-01 + +import os +import sys +import time +import re +from hwcompatible.test import Test +from hwcompatible.commandUI import CommandUI +from hwcompatible.document import ConfigFile +from hwcompatible.command import Command, CertCommandError + + +class KdumpTest(Test): + + def __init__(self): + Test.__init__(self) + self.pri = 9 + self.reboot = True + self.rebootup = "verify_vmcore" + self.kdump_conf = "/etc/kdump.conf" + self.vmcore_path = "/var/crash" + self.requirements = ["crash", "kernel-debuginfo", "kexec-tools"] + + def test(self): + try: + Command("cat /proc/cmdline").get_str("crashkernel=[^\ ]*") + except: + print("Error: no crashkernel found.") + return False + + config = ConfigFile(self.kdump_conf) + if not config.get_parameter("path"): + config.add_parameter("path", self.vmcore_path) + else: + self.vmcore_path = config.get_parameter("path") + + if config.get_parameter("kdump_obj") == "kbox": + config.remove_parameter("kdump_obj") + config.add_parameter("kdump_obj", "all") + + try: + Command("systemctl restart kdump").run() + Command("systemctl status kdump").get_str(regex="Active: active", single_line=False) + except: + print("Error: kdump service not working.") + return False + + print("kdump config:") + print("#############") + config.dump() + print("#############") + + ui = CommandUI() + if ui.prompt_confirm("System will reboot, are you ready?"): + print("\ntrigger crash...") + sys.stdout.flush() + os.system("sync") + os.system("echo c > /proc/sysrq-trigger") + time.sleep(30) + return False + else: + print("") + return False + + def verify_vmcore(self): + config = ConfigFile(self.kdump_conf) + if config.get_parameter("path"): + self.vmcore_path = config.get_parameter("path") + + dir_pattern = re.compile("(?P[0-9]+\.[0-9]+\.[0-9]+)-(?P[0-9]+(-|\.)[0-9]+(-|\.)[0-9]+)-(?P