From f215e02bf85f68d3a6106c2a1f4f7f063f819064 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Thu, 11 Apr 2024 10:17:27 +0200 Subject: Adding upstream version 7.0.14-dfsg. Signed-off-by: Daniel Baumann --- .../ValidationKit/testmanager/core/Makefile.kmk | 46 + .../ValidationKit/testmanager/core/__init__.py | 40 + src/VBox/ValidationKit/testmanager/core/base.py | 1514 ++++++++++ src/VBox/ValidationKit/testmanager/core/build.py | 891 ++++++ .../testmanager/core/buildblacklist.py | 324 +++ .../ValidationKit/testmanager/core/buildsource.py | 524 ++++ .../ValidationKit/testmanager/core/coreconsts.py | 100 + src/VBox/ValidationKit/testmanager/core/db.py | 745 +++++ .../ValidationKit/testmanager/core/dbobjcache.py | 200 ++ .../testmanager/core/failurecategory.py | 392 +++ .../testmanager/core/failurereason.py | 580 ++++ .../testmanager/core/globalresource.pgsql | 118 + .../testmanager/core/globalresource.py | 328 +++ src/VBox/ValidationKit/testmanager/core/report.py | 1307 +++++++++ .../testmanager/core/restdispatcher.py | 455 +++ .../ValidationKit/testmanager/core/schedgroup.py | 1352 +++++++++ .../ValidationKit/testmanager/core/schedqueue.py | 153 + .../testmanager/core/schedulerbase.py | 1570 +++++++++++ .../testmanager/core/schedulerbeci.py | 128 + .../testmanager/core/systemchangelog.py | 202 ++ .../ValidationKit/testmanager/core/systemlog.py | 186 ++ .../ValidationKit/testmanager/core/testbox.pgsql | 635 +++++ src/VBox/ValidationKit/testmanager/core/testbox.py | 1286 +++++++++ .../testmanager/core/testboxcontroller.py | 954 +++++++ .../testmanager/core/testboxstatus.py | 317 +++ .../ValidationKit/testmanager/core/testcase.pgsql | 275 ++ .../ValidationKit/testmanager/core/testcase.py | 1467 ++++++++++ .../ValidationKit/testmanager/core/testcaseargs.py | 416 +++ .../ValidationKit/testmanager/core/testgroup.py | 771 ++++++ .../testmanager/core/testresultfailures.py | 529 ++++ .../ValidationKit/testmanager/core/testresults.py | 2926 ++++++++++++++++++++ src/VBox/ValidationKit/testmanager/core/testset.py | 869 ++++++ .../testmanager/core/useraccount.pgsql | 178 ++ .../ValidationKit/testmanager/core/useraccount.py | 302 ++ .../testmanager/core/vcsbugreference.py | 251 ++ .../ValidationKit/testmanager/core/vcsrevisions.py | 254 ++ .../testmanager/core/webservergluebase.py | 717 +++++ .../testmanager/core/webservergluecgi.py | 100 + 38 files changed, 23402 insertions(+) create mode 100644 src/VBox/ValidationKit/testmanager/core/Makefile.kmk create mode 100644 src/VBox/ValidationKit/testmanager/core/__init__.py create mode 100755 src/VBox/ValidationKit/testmanager/core/base.py create mode 100755 src/VBox/ValidationKit/testmanager/core/build.py create mode 100755 src/VBox/ValidationKit/testmanager/core/buildblacklist.py create mode 100755 src/VBox/ValidationKit/testmanager/core/buildsource.py create mode 100644 src/VBox/ValidationKit/testmanager/core/coreconsts.py create mode 100755 src/VBox/ValidationKit/testmanager/core/db.py create mode 100755 src/VBox/ValidationKit/testmanager/core/dbobjcache.py create mode 100755 src/VBox/ValidationKit/testmanager/core/failurecategory.py create mode 100755 src/VBox/ValidationKit/testmanager/core/failurereason.py create mode 100644 src/VBox/ValidationKit/testmanager/core/globalresource.pgsql create mode 100755 src/VBox/ValidationKit/testmanager/core/globalresource.py create mode 100755 src/VBox/ValidationKit/testmanager/core/report.py create mode 100755 src/VBox/ValidationKit/testmanager/core/restdispatcher.py create mode 100755 src/VBox/ValidationKit/testmanager/core/schedgroup.py create mode 100755 src/VBox/ValidationKit/testmanager/core/schedqueue.py create mode 100755 src/VBox/ValidationKit/testmanager/core/schedulerbase.py create mode 100755 src/VBox/ValidationKit/testmanager/core/schedulerbeci.py create mode 100755 src/VBox/ValidationKit/testmanager/core/systemchangelog.py create mode 100755 src/VBox/ValidationKit/testmanager/core/systemlog.py create mode 100644 src/VBox/ValidationKit/testmanager/core/testbox.pgsql create mode 100755 src/VBox/ValidationKit/testmanager/core/testbox.py create mode 100755 src/VBox/ValidationKit/testmanager/core/testboxcontroller.py create mode 100755 src/VBox/ValidationKit/testmanager/core/testboxstatus.py create mode 100644 src/VBox/ValidationKit/testmanager/core/testcase.pgsql create mode 100755 src/VBox/ValidationKit/testmanager/core/testcase.py create mode 100755 src/VBox/ValidationKit/testmanager/core/testcaseargs.py create mode 100755 src/VBox/ValidationKit/testmanager/core/testgroup.py create mode 100755 src/VBox/ValidationKit/testmanager/core/testresultfailures.py create mode 100755 src/VBox/ValidationKit/testmanager/core/testresults.py create mode 100755 src/VBox/ValidationKit/testmanager/core/testset.py create mode 100644 src/VBox/ValidationKit/testmanager/core/useraccount.pgsql create mode 100755 src/VBox/ValidationKit/testmanager/core/useraccount.py create mode 100755 src/VBox/ValidationKit/testmanager/core/vcsbugreference.py create mode 100755 src/VBox/ValidationKit/testmanager/core/vcsrevisions.py create mode 100755 src/VBox/ValidationKit/testmanager/core/webservergluebase.py create mode 100755 src/VBox/ValidationKit/testmanager/core/webservergluecgi.py (limited to 'src/VBox/ValidationKit/testmanager/core') diff --git a/src/VBox/ValidationKit/testmanager/core/Makefile.kmk b/src/VBox/ValidationKit/testmanager/core/Makefile.kmk new file mode 100644 index 00000000..74d882cc --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/Makefile.kmk @@ -0,0 +1,46 @@ +# $Id: Makefile.kmk $ +## @file +# VirtualBox Validation Kit. +# + +# +# Copyright (C) 2006-2023 Oracle and/or its affiliates. +# +# This file is part of VirtualBox base platform packages, as +# available from https://www.virtualbox.org. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation, in version 3 of the +# License. +# +# 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, see . +# +# The contents of this file may alternatively be used under the terms +# of the Common Development and Distribution License Version 1.0 +# (CDDL), a copy of it is provided in the "COPYING.CDDL" file included +# in the VirtualBox distribution, in which case the provisions of the +# CDDL are applicable instead of those of the GPL. +# +# You may elect to license modified versions of this file under the +# terms and conditions of either the GPL or the CDDL or both. +# +# SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +# + +SUB_DEPTH = ../../../../.. +include $(KBUILD_PATH)/subheader.kmk + + +VBOX_VALIDATIONKIT_PYTHON_SOURCES += $(wildcard $(PATH_SUB_CURRENT)/*.py) + +$(evalcall def_vbox_validationkit_process_python_sources) +$(evalcall def_vbox_validationkit_process_js_sources) +include $(FILE_KBUILD_SUB_FOOTER) + diff --git a/src/VBox/ValidationKit/testmanager/core/__init__.py b/src/VBox/ValidationKit/testmanager/core/__init__.py new file mode 100644 index 00000000..bfcc7f8b --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/__init__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# $Id: __init__.py $ + +""" +TestBox Script - Core Logic. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + diff --git a/src/VBox/ValidationKit/testmanager/core/base.py b/src/VBox/ValidationKit/testmanager/core/base.py new file mode 100755 index 00000000..eac3d921 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/base.py @@ -0,0 +1,1514 @@ +# -*- coding: utf-8 -*- +# $Id: base.py $ +# pylint: disable=too-many-lines + +""" +Test Manager Core - Base Class(es). +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import copy; +import datetime; +import json; +import re; +import socket; +import sys; +import uuid; +import unittest; + +# Validation Kit imports. +from common import utils; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + long = int # pylint: disable=redefined-builtin,invalid-name + + +class TMExceptionBase(Exception): + """ + For exceptions raised by any TestManager component. + """ + pass; # pylint: disable=unnecessary-pass + + +class TMTooManyRows(TMExceptionBase): + """ + Too many rows in the result. + Used by ModelLogicBase decendants. + """ + pass; # pylint: disable=unnecessary-pass + + +class TMRowNotFound(TMExceptionBase): + """ + Database row not found. + Used by ModelLogicBase decendants. + """ + pass; # pylint: disable=unnecessary-pass + + +class TMRowAlreadyExists(TMExceptionBase): + """ + Database row already exists (typically raised by addEntry). + Used by ModelLogicBase decendants. + """ + pass; # pylint: disable=unnecessary-pass + + +class TMInvalidData(TMExceptionBase): + """ + Data validation failed. + Used by ModelLogicBase decendants. + """ + pass; # pylint: disable=unnecessary-pass + + +class TMRowInUse(TMExceptionBase): + """ + Database row is in use and cannot be deleted. + Used by ModelLogicBase decendants. + """ + pass; # pylint: disable=unnecessary-pass + + +class TMInFligthCollision(TMExceptionBase): + """ + Database update failed because someone else had already made changes to + the data there. + Used by ModelLogicBase decendants. + """ + pass; # pylint: disable=unnecessary-pass + + +class ModelBase(object): # pylint: disable=too-few-public-methods + """ + Something all classes in the logical model inherits from. + + Not sure if 'logical model' is the right term here. + Will see if it has any purpose later on... + """ + + def __init__(self): + pass; + + +class ModelDataBase(ModelBase): # pylint: disable=too-few-public-methods + """ + Something all classes in the data classes in the logical model inherits from. + """ + + ## Child classes can use this to list array attributes which should use + # an empty array ([]) instead of None as database NULL value. + kasAltArrayNull = []; + + ## validate + ## @{ + ksValidateFor_Add = 'add'; + ksValidateFor_AddForeignId = 'add-foreign-id'; + ksValidateFor_Edit = 'edit'; + ksValidateFor_Other = 'other'; + ## @} + + + ## List of internal attributes which should be ignored by + ## getDataAttributes and related machinery + kasInternalAttributes = []; + + def __init__(self): + ModelBase.__init__(self); + + + # + # Standard methods implemented by combining python magic and hungarian prefixes. + # + + def getDataAttributes(self): + """ + Returns a list of data attributes. + """ + asRet = []; + asAttrs = dir(self); + for sAttr in asAttrs: + if sAttr[0] == '_' or sAttr[0] == 'k': + continue; + if sAttr in self.kasInternalAttributes: + continue; + oValue = getattr(self, sAttr); + if callable(oValue): + continue; + asRet.append(sAttr); + return asRet; + + def initFromOther(self, oOther): + """ + Initialize this object with the values from another instance (child + class instance is accepted). + + This serves as a kind of copy constructor. + + Returns self. May raise exception if the type of other object differs + or is damaged. + """ + for sAttr in self.getDataAttributes(): + setattr(self, sAttr, getattr(oOther, sAttr)); + return self; + + @staticmethod + def getHungarianPrefix(sName): + """ + Returns the hungarian prefix of the given name. + """ + for i, _ in enumerate(sName): + if sName[i] not in ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']: + assert re.search('^[A-Z][a-zA-Z0-9]*$', sName[i:]) is not None; + return sName[:i]; + return sName; + + def getAttributeParamNullValues(self, sAttr): + """ + Returns a list of parameter NULL values, with the preferred one being + the first element. + + Child classes can override this to handle one or more attributes specially. + """ + sPrefix = self.getHungarianPrefix(sAttr); + if sPrefix in ['id', 'uid', 'i', 'off', 'pct']: + return [-1, '', '-1',]; + if sPrefix in ['l', 'c',]: + return [long(-1), '', '-1',]; + if sPrefix == 'f': + return ['',]; + if sPrefix in ['enm', 'ip', 's', 'ts', 'uuid']: + return ['',]; + if sPrefix in ['ai', 'aid', 'al', 'as']: + return [[], '', None]; ## @todo ?? + if sPrefix == 'bm': + return ['', [],]; ## @todo bitmaps. + raise TMExceptionBase('Unable to classify "%s" (prefix %s)' % (sAttr, sPrefix)); + + def isAttributeNull(self, sAttr, oValue): + """ + Checks if the specified attribute value indicates NULL. + Return True/False. + + Note! This isn't entirely kosher actually. + """ + if oValue is None: + return True; + aoNilValues = self.getAttributeParamNullValues(sAttr); + return oValue in aoNilValues; + + def _convertAttributeFromParamNull(self, sAttr, oValue): + """ + Converts an attribute from parameter NULL to database NULL value. + Returns the new attribute value. + """ + aoNullValues = self.getAttributeParamNullValues(sAttr); + if oValue in aoNullValues: + oValue = None if sAttr not in self.kasAltArrayNull else []; + # + # Perform deep conversion on ModelDataBase object and lists of them. + # + elif isinstance(oValue, list) and oValue and isinstance(oValue[0], ModelDataBase): + oValue = copy.copy(oValue); + for i, _ in enumerate(oValue): + assert isinstance(oValue[i], ModelDataBase); + oValue[i] = copy.copy(oValue[i]); + oValue[i].convertFromParamNull(); + + elif isinstance(oValue, ModelDataBase): + oValue = copy.copy(oValue); + oValue.convertFromParamNull(); + + return oValue; + + def convertFromParamNull(self): + """ + Converts from parameter NULL values to database NULL values (None). + Returns self. + """ + for sAttr in self.getDataAttributes(): + oValue = getattr(self, sAttr); + oNewValue = self._convertAttributeFromParamNull(sAttr, oValue); + if oValue != oNewValue: + setattr(self, sAttr, oNewValue); + return self; + + def _convertAttributeToParamNull(self, sAttr, oValue): + """ + Converts an attribute from database NULL to a sepcial value we can pass + thru parameter list. + Returns the new attribute value. + """ + if oValue is None: + oValue = self.getAttributeParamNullValues(sAttr)[0]; + # + # Perform deep conversion on ModelDataBase object and lists of them. + # + elif isinstance(oValue, list) and oValue and isinstance(oValue[0], ModelDataBase): + oValue = copy.copy(oValue); + for i, _ in enumerate(oValue): + assert isinstance(oValue[i], ModelDataBase); + oValue[i] = copy.copy(oValue[i]); + oValue[i].convertToParamNull(); + + elif isinstance(oValue, ModelDataBase): + oValue = copy.copy(oValue); + oValue.convertToParamNull(); + + return oValue; + + def convertToParamNull(self): + """ + Converts from database NULL values (None) to special values we can + pass thru parameters list. + Returns self. + """ + for sAttr in self.getDataAttributes(): + oValue = getattr(self, sAttr); + oNewValue = self._convertAttributeToParamNull(sAttr, oValue); + if oValue != oNewValue: + setattr(self, sAttr, oNewValue); + return self; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + """ + Validates and convert one attribute. + Returns the converted value. + + Child classes can override this to handle one or more attributes specially. + Note! oDb can be None. + """ + sPrefix = self.getHungarianPrefix(sAttr); + + if sPrefix in ['id', 'uid']: + (oNewValue, sError) = self.validateInt( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull); + elif sPrefix in ['i', 'off', 'pct']: + (oNewValue, sError) = self.validateInt( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + iMin = getattr(self, 'kiMin_' + sAttr, 0), + iMax = getattr(self, 'kiMax_' + sAttr, 0x7ffffffe)); + elif sPrefix in ['l', 'c']: + (oNewValue, sError) = self.validateLong(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + lMin = getattr(self, 'klMin_' + sAttr, 0), + lMax = getattr(self, 'klMax_' + sAttr, None)); + elif sPrefix == 'f': + if not oValue and not fAllowNull: oValue = '0'; # HACK ALERT! Checkboxes are only added when checked. + (oNewValue, sError) = self.validateBool(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull); + elif sPrefix == 'ts': + (oNewValue, sError) = self.validateTs( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull); + elif sPrefix == 'ip': + (oNewValue, sError) = self.validateIp( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull); + elif sPrefix == 'uuid': + (oNewValue, sError) = self.validateUuid(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull); + elif sPrefix == 'enm': + (oNewValue, sError) = self.validateWord(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + asValid = getattr(self, 'kasValidValues_' + sAttr)); # The list is required. + elif sPrefix == 's': + (oNewValue, sError) = self.validateStr( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + cchMin = getattr(self, 'kcchMin_' + sAttr, 0), + cchMax = getattr(self, 'kcchMax_' + sAttr, 4096), + fAllowUnicodeSymbols = getattr(self, 'kfAllowUnicode_' + sAttr, False) ); + ## @todo al. + elif sPrefix == 'aid': + (oNewValue, sError) = self.validateListOfInts(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + iMin = 1, iMax = 0x7ffffffe); + elif sPrefix == 'as': + (oNewValue, sError) = self.validateListOfStr(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + asValidValues = getattr(self, 'kasValidValues_' + sAttr, None), + cchMin = getattr(self, 'kcchMin_' + sAttr, 0 if fAllowNull else 1), + cchMax = getattr(self, 'kcchMax_' + sAttr, 4096)); + + elif sPrefix == 'bm': + ## @todo figure out bitfields. + (oNewValue, sError) = self.validateListOfStr(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull); + else: + raise TMExceptionBase('Unable to classify "%s" (prefix %s)' % (sAttr, sPrefix)); + + _ = sParam; _ = oDb; + return (oNewValue, sError); + + def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ksValidateFor_Other): + """ + Worker for implementing validateAndConvert(). + """ + dErrors = {}; + for sAttr in self.getDataAttributes(): + oValue = getattr(self, sAttr); + sParam = getattr(self, 'ksParam_' + sAttr); + aoNilValues = self.getAttributeParamNullValues(sAttr); + aoNilValues.append(None); + + (oNewValue, sError) = self._validateAndConvertAttribute(sAttr, sParam, oValue, aoNilValues, + sAttr in asAllowNullAttributes, oDb); + if oValue != oNewValue: + setattr(self, sAttr, oNewValue); + if sError is not None: + dErrors[sParam] = sError; + + # Check the NULL requirements of the primary ID(s) for the 'add' and 'edit' actions. + if enmValidateFor in (ModelDataBase.ksValidateFor_Add, + ModelDataBase.ksValidateFor_AddForeignId, + ModelDataBase.ksValidateFor_Edit,): + fMustBeNull = enmValidateFor == ModelDataBase.ksValidateFor_Add; + sAttr = getattr(self, 'ksIdAttr', None); + if sAttr is not None: + oValue = getattr(self, sAttr); + if self.isAttributeNull(sAttr, oValue) != fMustBeNull: + sParam = getattr(self, 'ksParam_' + sAttr); + sErrMsg = 'Must be NULL!' if fMustBeNull else 'Must not be NULL!' + if sParam in dErrors: + dErrors[sParam] += ' ' + sErrMsg; + else: + dErrors[sParam] = sErrMsg; + + return dErrors; + + def validateAndConvert(self, oDb, enmValidateFor = ksValidateFor_Other): + """ + Validates the input and converts valid fields to their right type. + Returns a dictionary with per field reports, only invalid fields will + be returned, so an empty dictionary means that the data is valid. + + The dictionary keys are ksParam_*. + + Child classes can override _validateAndConvertAttribute to handle + selected fields specially. There are also a few class variables that + can be used to advice the validation: kcchMin_sAttr, kcchMax_sAttr, + kiMin_iAttr, kiMax_iAttr, klMin_lAttr, klMax_lAttr, + kasValidValues_enmAttr, and kasAllowNullAttributes. + """ + return self._validateAndConvertWorker(getattr(self, 'kasAllowNullAttributes', []), oDb, + enmValidateFor = enmValidateFor); + + def validateAndConvertEx(self, asAllowNullAttributes, oDb, enmValidateFor = ksValidateFor_Other): + """ + Same as validateAndConvert but with custom allow-null list. + """ + return self._validateAndConvertWorker(asAllowNullAttributes, oDb, enmValidateFor = enmValidateFor); + + def convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict): + """ + Calculate the attribute value when initialized from a parameter. + + Returns the new value, with parameter NULL values. Raises exception on + invalid parameter value. + + Child classes can override to do special parameter conversion jobs. + """ + sPrefix = self.getHungarianPrefix(sAttr); + asValidValues = getattr(self, 'kasValidValues_' + sAttr, None); + fAllowNull = sAttr in getattr(self, 'kasAllowNullAttributes', []); + if fStrict: + if sPrefix == 'f': + # HACK ALERT! Checkboxes are only present when checked, so we always have to provide a default. + oNewValue = oDisp.getStringParam(sParam, asValidValues, '0'); + elif sPrefix[0] == 'a': + # HACK ALERT! Lists are not present if empty. + oNewValue = oDisp.getListOfStrParams(sParam, []); + else: + oNewValue = oDisp.getStringParam(sParam, asValidValues, None, fAllowNull = fAllowNull); + else: + if sPrefix[0] == 'a': + oNewValue = oDisp.getListOfStrParams(sParam, []); + else: + assert oValue is not None, 'sAttr=%s' % (sAttr,); + oNewValue = oDisp.getStringParam(sParam, asValidValues, oValue, fAllowNull = fAllowNull); + return oNewValue; + + def initFromParams(self, oDisp, fStrict = True): + """ + Initialize the object from parameters. + The input is not validated at all, except that all parameters must be + present when fStrict is True. + + Returns self. Raises exception on invalid parameter value. + + Note! The returned object has parameter NULL values, not database ones! + """ + + self.convertToParamNull() + for sAttr in self.getDataAttributes(): + oValue = getattr(self, sAttr); + oNewValue = self.convertParamToAttribute(sAttr, getattr(self, 'ksParam_' + sAttr), oValue, oDisp, fStrict); + if oNewValue != oValue: + setattr(self, sAttr, oNewValue); + return self; + + def areAttributeValuesEqual(self, sAttr, sPrefix, oValue1, oValue2): + """ + Called to compare two attribute values and python thinks differs. + + Returns True/False. + + Child classes can override this to do special compares of things like arrays. + """ + # Just in case someone uses it directly. + if oValue1 == oValue2: + return True; + + # + # Timestamps can be both string (param) and object (db) + # depending on the data source. Compare string values to make + # sure we're doing the right thing here. + # + if sPrefix == 'ts': + return str(oValue1) == str(oValue2); + + # + # Some generic code handling ModelDataBase children. + # + if isinstance(oValue1, list) and isinstance(oValue2, list): + if len(oValue1) == len(oValue2): + for i, _ in enumerate(oValue1): + if not isinstance(oValue1[i], ModelDataBase) \ + or type(oValue1) is not type(oValue2): + return False; + if not oValue1[i].isEqual(oValue2[i]): + return False; + return True; + + elif isinstance(oValue1, ModelDataBase) \ + and type(oValue1) is type(oValue2): + return oValue1[i].isEqual(oValue2[i]); + + _ = sAttr; + return False; + + def isEqual(self, oOther): + """ Compares two instances. """ + for sAttr in self.getDataAttributes(): + if getattr(self, sAttr) != getattr(oOther, sAttr): + # Delegate the final decision to an overridable method. + if not self.areAttributeValuesEqual(sAttr, self.getHungarianPrefix(sAttr), + getattr(self, sAttr), getattr(oOther, sAttr)): + return False; + return True; + + def isEqualEx(self, oOther, asExcludeAttrs): + """ Compares two instances, omitting the given attributes. """ + for sAttr in self.getDataAttributes(): + if sAttr not in asExcludeAttrs \ + and getattr(self, sAttr) != getattr(oOther, sAttr): + # Delegate the final decision to an overridable method. + if not self.areAttributeValuesEqual(sAttr, self.getHungarianPrefix(sAttr), + getattr(self, sAttr), getattr(oOther, sAttr)): + return False; + return True; + + def reinitToNull(self): + """ + Reinitializes the object to (database) NULL values. + Returns self. + """ + for sAttr in self.getDataAttributes(): + setattr(self, sAttr, None); + return self; + + def toString(self): + """ + Stringifies the object. + Returns string representation. + """ + + sMembers = ''; + for sAttr in self.getDataAttributes(): + oValue = getattr(self, sAttr); + sMembers += ', %s=%s' % (sAttr, oValue); + + oClass = type(self); + if sMembers == '': + return '<%s>' % (oClass.__name__); + return '<%s: %s>' % (oClass.__name__, sMembers[2:]); + + def __str__(self): + return self.toString(); + + + + # + # New validation helpers. + # + # These all return (oValue, sError), where sError is None when the value + # is valid and an error message when not. On success and in case of + # range errors, oValue is converted into the requested type. + # + + @staticmethod + def validateInt(sValue, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([-1, None, '']), fAllowNull = True): + """ Validates an integer field. """ + if sValue in aoNilValues: + if fAllowNull: + return (None if sValue is None else aoNilValues[0], None); + return (sValue, 'Mandatory.'); + + try: + if utils.isString(sValue): + iValue = int(sValue, 0); + else: + iValue = int(sValue); + except: + return (sValue, 'Not an integer'); + + if iValue in aoNilValues: + return (aoNilValues[0], None if fAllowNull else 'Mandatory.'); + + if iValue < iMin: + return (iValue, 'Value too small (min %d)' % (iMin,)); + if iValue > iMax: + return (iValue, 'Value too high (max %d)' % (iMax,)); + return (iValue, None); + + @staticmethod + def validateLong(sValue, lMin = 0, lMax = None, aoNilValues = tuple([long(-1), None, '']), fAllowNull = True): + """ Validates an long integer field. """ + if sValue in aoNilValues: + if fAllowNull: + return (None if sValue is None else aoNilValues[0], None); + return (sValue, 'Mandatory.'); + try: + if utils.isString(sValue): + lValue = long(sValue, 0); + else: + lValue = long(sValue); + except: + return (sValue, 'Not a long integer'); + + if lValue in aoNilValues: + return (aoNilValues[0], None if fAllowNull else 'Mandatory.'); + + if lMin is not None and lValue < lMin: + return (lValue, 'Value too small (min %d)' % (lMin,)); + if lMax is not None and lValue > lMax: + return (lValue, 'Value too high (max %d)' % (lMax,)); + return (lValue, None); + + kdTimestampRegex = { + len('2012-10-08 01:54:06'): r'(\d{4})-([01]\d)-([0123]\d)[ Tt]([012]\d):[0-5]\d:([0-6]\d)$', + len('2012-10-08 01:54:06.00'): r'(\d{4})-([01]\d)-([0123]\d)[ Tt]([012]\d):[0-5]\d:([0-6]\d).\d{2}$', + len('2012-10-08 01:54:06.000'): r'(\d{4})-([01]\d)-([0123]\d)[ Tt]([012]\d):[0-5]\d:([0-6]\d).\d{3}$', + len('999999-12-31 00:00:00.00'): r'(\d{6})-([01]\d)-([0123]\d)[ Tt]([012]\d):[0-5]\d:([0-6]\d).\d{2}$', + len('9999-12-31 23:59:59.999999'): r'(\d{4})-([01]\d)-([0123]\d)[ Tt]([012]\d):[0-5]\d:([0-6]\d).\d{6}$', + len('9999-12-31T23:59:59.999999999'): r'(\d{4})-([01]\d)-([0123]\d)[ Tt]([012]\d):[0-5]\d:([0-6]\d).\d{9}$', + }; + + @staticmethod + def validateTs(sValue, aoNilValues = tuple([None, '']), fAllowNull = True, fRelative = False): + """ Validates a timestamp field. """ + if sValue in aoNilValues: + return (sValue, None if fAllowNull else 'Mandatory.'); + if not utils.isString(sValue): + return (sValue, None); + + # Validate and strip off the timezone stuff. + if sValue[-1] in 'Zz': + sStripped = sValue[:-1]; + sValue = sStripped + 'Z'; + elif len(sValue) >= 19 + 3: + oRes = re.match(r'^.*[+-](\d\d):(\d\d)$', sValue); + if oRes is not None: + if int(oRes.group(1)) > 12 or int(oRes.group(2)) >= 60: + return (sValue, 'Invalid timezone offset.'); + sStripped = sValue[:-6]; + else: + sStripped = sValue; + else: + sStripped = sValue; + + # Used the stripped value length to find regular expression for validating and parsing the timestamp. + sError = None; + sRegExp = ModelDataBase.kdTimestampRegex.get(len(sStripped), None); + if sRegExp: + oRes = re.match(sRegExp, sStripped); + if oRes is not None: + iYear = int(oRes.group(1)); + if iYear % 4 == 0 and (iYear % 100 != 0 or iYear % 400 == 0): + acDaysOfMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + else: + acDaysOfMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + iMonth = int(oRes.group(2)); + iDay = int(oRes.group(3)); + iHour = int(oRes.group(4)); + iSec = int(oRes.group(5)); + if iMonth > 12 or (iMonth <= 0 and not fRelative): + sError = 'Invalid timestamp month.'; + elif iDay > acDaysOfMonth[iMonth - 1]: + sError = 'Invalid timestamp day-of-month (%02d has %d days).' % (iMonth, acDaysOfMonth[iMonth - 1]); + elif iHour > 23: + sError = 'Invalid timestamp hour.' + elif iSec >= 61: + sError = 'Invalid timestamp second.' + elif iSec >= 60: + sError = 'Invalid timestamp: no leap seconds, please.' + else: + sError = 'Invalid timestamp (validation regexp: %s).' % (sRegExp,); + else: + sError = 'Invalid timestamp length.'; + return (sValue, sError); + + @staticmethod + def validateIp(sValue, aoNilValues = tuple([None, '']), fAllowNull = True): + """ Validates an IP address field. """ + if sValue in aoNilValues: + return (sValue, None if fAllowNull else 'Mandatory.'); + + if sValue == '::1': + return (sValue, None); + + try: + socket.inet_pton(socket.AF_INET, sValue); # pylint: disable=no-member + except: + try: + socket.inet_pton(socket.AF_INET6, sValue); # pylint: disable=no-member + except: + return (sValue, 'Not a valid IP address.'); + + return (sValue, None); + + @staticmethod + def validateBool(sValue, aoNilValues = tuple([None, '']), fAllowNull = True): + """ Validates a boolean field. """ + if sValue in aoNilValues: + return (sValue, None if fAllowNull else 'Mandatory.'); + + if sValue in ('True', 'true', '1', True): + return (True, None); + if sValue in ('False', 'false', '0', False): + return (False, None); + return (sValue, 'Invalid boolean value.'); + + @staticmethod + def validateUuid(sValue, aoNilValues = tuple([None, '']), fAllowNull = True): + """ Validates an UUID field. """ + if sValue in aoNilValues: + return (sValue, None if fAllowNull else 'Mandatory.'); + + try: + sValue = str(uuid.UUID(sValue)); + except: + return (sValue, 'Invalid UUID value.'); + return (sValue, None); + + @staticmethod + def validateWord(sValue, cchMin = 1, cchMax = 64, asValid = None, aoNilValues = tuple([None, '']), fAllowNull = True): + """ Validates a word field. """ + if sValue in aoNilValues: + return (sValue, None if fAllowNull else 'Mandatory.'); + + if re.search('[^a-zA-Z0-9_-]', sValue) is not None: + sError = 'Single word ([a-zA-Z0-9_-]), please.'; + elif cchMin is not None and len(sValue) < cchMin: + sError = 'Too short, min %s chars' % (cchMin,); + elif cchMax is not None and len(sValue) > cchMax: + sError = 'Too long, max %s chars' % (cchMax,); + elif asValid is not None and sValue not in asValid: + sError = 'Invalid value "%s", must be one of: %s' % (sValue, asValid); + else: + sError = None; + return (sValue, sError); + + @staticmethod + def validateStr(sValue, cchMin = 0, cchMax = 4096, aoNilValues = tuple([None, '']), fAllowNull = True, + fAllowUnicodeSymbols = False): + """ Validates a string field. """ + if sValue in aoNilValues: + return (sValue, None if fAllowNull else 'Mandatory.'); + + if cchMin is not None and len(sValue) < cchMin: + sError = 'Too short, min %s chars' % (cchMin,); + elif cchMax is not None and len(sValue) > cchMax: + sError = 'Too long, max %s chars' % (cchMax,); + elif fAllowUnicodeSymbols is False and utils.hasNonAsciiCharacters(sValue): + sError = 'Non-ascii characters not allowed' + else: + sError = None; + return (sValue, sError); + + @staticmethod + def validateEmail(sValue, aoNilValues = tuple([None, '']), fAllowNull = True): + """ Validates a email field.""" + if sValue in aoNilValues: + return (sValue, None if fAllowNull else 'Mandatory.'); + + if re.match(r'.+@.+\..+', sValue) is None: + return (sValue,'Invalid e-mail format.'); + return (sValue, None); + + @staticmethod + def validateListOfSomething(asValues, aoNilValues = tuple([[], None]), fAllowNull = True): + """ Validate a list of some uniform values. Returns a copy of the list (if list it is). """ + if asValues in aoNilValues or (not asValues and not fAllowNull): + return (asValues, None if fAllowNull else 'Mandatory.') + + if not isinstance(asValues, list): + return (asValues, 'Invalid data type (%s).' % (type(asValues),)); + + asValues = list(asValues); # copy the list. + if asValues: + oType = type(asValues[0]); + for i in range(1, len(asValues)): + if type(asValues[i]) is not oType: # pylint: disable=unidiomatic-typecheck + return (asValues, 'Invalid entry data type ([0]=%s vs [%d]=%s).' % (oType, i, type(asValues[i])) ); + + return (asValues, None); + + @staticmethod + def validateListOfStr(asValues, cchMin = None, cchMax = None, asValidValues = None, + aoNilValues = tuple([[], None]), fAllowNull = True): + """ Validates a list of text items.""" + (asValues, sError) = ModelDataBase.validateListOfSomething(asValues, aoNilValues, fAllowNull); + + if sError is None and asValues not in aoNilValues and asValues: + if not utils.isString(asValues[0]): + return (asValues, 'Invalid item data type.'); + + if not fAllowNull and cchMin is None: + cchMin = 1; + + for sValue in asValues: + if asValidValues is not None and sValue not in asValidValues: + sThisErr = 'Invalid value "%s".' % (sValue,); + elif cchMin is not None and len(sValue) < cchMin: + sThisErr = 'Value "%s" is too short, min length is %u chars.' % (sValue, cchMin); + elif cchMax is not None and len(sValue) > cchMax: + sThisErr = 'Value "%s" is too long, max length is %u chars.' % (sValue, cchMax); + else: + continue; + + if sError is None: + sError = sThisErr; + else: + sError += ' ' + sThisErr; + + return (asValues, sError); + + @staticmethod + def validateListOfInts(asValues, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([[], None]), fAllowNull = True): + """ Validates a list of integer items.""" + (asValues, sError) = ModelDataBase.validateListOfSomething(asValues, aoNilValues, fAllowNull); + + if sError is None and asValues not in aoNilValues and asValues: + for i, _ in enumerate(asValues): + sValue = asValues[i]; + + sThisErr = ''; + try: + iValue = int(sValue); + except: + sThisErr = 'Invalid integer value "%s".' % (sValue,); + else: + asValues[i] = iValue; + if iValue < iMin: + sThisErr = 'Value %d is too small (min %d)' % (iValue, iMin,); + elif iValue > iMax: + sThisErr = 'Value %d is too high (max %d)' % (iValue, iMax,); + else: + continue; + + if sError is None: + sError = sThisErr; + else: + sError += ' ' + sThisErr; + + return (asValues, sError); + + + + # + # Old validation helpers. + # + + @staticmethod + def _validateInt(dErrors, sName, sValue, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([-1, None, ''])): + """ Validates an integer field. """ + (sValue, sError) = ModelDataBase.validateInt(sValue, iMin, iMax, aoNilValues, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateIntNN(dErrors, sName, sValue, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([-1, None, ''])): + """ Validates an integer field, not null. """ + (sValue, sError) = ModelDataBase.validateInt(sValue, iMin, iMax, aoNilValues, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateLong(dErrors, sName, sValue, lMin = 0, lMax = None, aoNilValues = tuple([long(-1), None, ''])): + """ Validates an long integer field. """ + (sValue, sError) = ModelDataBase.validateLong(sValue, lMin, lMax, aoNilValues, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateLongNN(dErrors, sName, sValue, lMin = 0, lMax = None, aoNilValues = tuple([long(-1), None, ''])): + """ Validates an long integer field, not null. """ + (sValue, sError) = ModelDataBase.validateLong(sValue, lMin, lMax, aoNilValues, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateTs(dErrors, sName, sValue): + """ Validates a timestamp field. """ + (sValue, sError) = ModelDataBase.validateTs(sValue, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateTsNN(dErrors, sName, sValue): + """ Validates a timestamp field, not null. """ + (sValue, sError) = ModelDataBase.validateTs(sValue, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateIp(dErrors, sName, sValue): + """ Validates an IP address field. """ + (sValue, sError) = ModelDataBase.validateIp(sValue, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateIpNN(dErrors, sName, sValue): + """ Validates an IP address field, not null. """ + (sValue, sError) = ModelDataBase.validateIp(sValue, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateBool(dErrors, sName, sValue): + """ Validates a boolean field. """ + (sValue, sError) = ModelDataBase.validateBool(sValue, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateBoolNN(dErrors, sName, sValue): + """ Validates a boolean field, not null. """ + (sValue, sError) = ModelDataBase.validateBool(sValue, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateUuid(dErrors, sName, sValue): + """ Validates an UUID field. """ + (sValue, sError) = ModelDataBase.validateUuid(sValue, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateUuidNN(dErrors, sName, sValue): + """ Validates an UUID field, not null. """ + (sValue, sError) = ModelDataBase.validateUuid(sValue, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateWord(dErrors, sName, sValue, cchMin = 1, cchMax = 64, asValid = None): + """ Validates a word field. """ + (sValue, sError) = ModelDataBase.validateWord(sValue, cchMin, cchMax, asValid, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateWordNN(dErrors, sName, sValue, cchMin = 1, cchMax = 64, asValid = None): + """ Validates a boolean field, not null. """ + (sValue, sError) = ModelDataBase.validateWord(sValue, cchMin, cchMax, asValid, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateStr(dErrors, sName, sValue, cchMin = 0, cchMax = 4096): + """ Validates a string field. """ + (sValue, sError) = ModelDataBase.validateStr(sValue, cchMin, cchMax, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateStrNN(dErrors, sName, sValue, cchMin = 0, cchMax = 4096): + """ Validates a string field, not null. """ + (sValue, sError) = ModelDataBase.validateStr(sValue, cchMin, cchMax, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateEmail(dErrors, sName, sValue): + """ Validates a email field.""" + (sValue, sError) = ModelDataBase.validateEmail(sValue, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateEmailNN(dErrors, sName, sValue): + """ Validates a email field.""" + (sValue, sError) = ModelDataBase.validateEmail(sValue, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateListOfStr(dErrors, sName, asValues, asValidValues = None): + """ Validates a list of text items.""" + (sValue, sError) = ModelDataBase.validateListOfStr(asValues, asValidValues = asValidValues, fAllowNull = True); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + @staticmethod + def _validateListOfStrNN(dErrors, sName, asValues, asValidValues = None): + """ Validates a list of text items, not null and len >= 1.""" + (sValue, sError) = ModelDataBase.validateListOfStr(asValues, asValidValues = asValidValues, fAllowNull = False); + if sError is not None: + dErrors[sName] = sError; + return sValue; + + # + # Various helpers. + # + + @staticmethod + def formatSimpleNowAndPeriod(oDb, tsNow = None, sPeriodBack = None, + sTablePrefix = '', sExpCol = 'tsExpire', sEffCol = 'tsEffective'): + """ + Formats a set of tsNow and sPeriodBack arguments for a standard testmanager + table. + + If sPeriodBack is given, the query is effective for the period + (tsNow - sPeriodBack) thru (tsNow). + + If tsNow isn't given, it defaults to current time. + + Returns the final portion of a WHERE query (start with AND) and maybe an + ORDER BY and LIMIT bit if sPeriodBack is given. + """ + if tsNow is not None: + if sPeriodBack is not None: + sRet = oDb.formatBindArgs(' AND ' + sTablePrefix + sExpCol + ' > (%s::timestamp - %s::interval)\n' + ' AND tsEffective <= %s\n' + 'ORDER BY ' + sTablePrefix + sExpCol + ' DESC\n' + 'LIMIT 1\n' + , ( tsNow, sPeriodBack, tsNow)); + else: + sRet = oDb.formatBindArgs(' AND ' + sTablePrefix + sExpCol + ' > %s\n' + ' AND ' + sTablePrefix + sEffCol + ' <= %s\n' + , ( tsNow, tsNow, )); + else: + if sPeriodBack is not None: + sRet = oDb.formatBindArgs(' AND ' + sTablePrefix + sExpCol + ' > (CURRENT_TIMESTAMP - %s::interval)\n' + ' AND ' + sTablePrefix + sEffCol + ' <= CURRENT_TIMESTAMP\n' + 'ORDER BY ' + sTablePrefix + sExpCol + ' DESC\n' + 'LIMIT 1\n' + , ( sPeriodBack, )); + else: + sRet = ' AND ' + sTablePrefix + sExpCol + ' = \'infinity\'::timestamp\n'; + return sRet; + + @staticmethod + def formatSimpleNowAndPeriodQuery(oDb, sQuery, aBindArgs, tsNow = None, sPeriodBack = None, + sTablePrefix = '', sExpCol = 'tsExpire', sEffCol = 'tsEffective'): + """ + Formats a simple query for a standard testmanager table with optional + tsNow and sPeriodBack arguments. + + The sQuery and sBindArgs are passed along to oDb.formatBindArgs to form + the first part of the query. Must end with an open WHERE statement as + we'll be adding the time part starting with 'AND something...'. + + See formatSimpleNowAndPeriod for tsNow and sPeriodBack description. + + Returns the final portion of a WHERE query (start with AND) and maybe an + ORDER BY and LIMIT bit if sPeriodBack is given. + + """ + return oDb.formatBindArgs(sQuery, aBindArgs) \ + + ModelDataBase.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix, sExpCol, sEffCol); + + + # + # JSON + # + + @staticmethod + def stringToJson(sString): + """ Converts a string to a JSON value string. """ + if not utils.isString(sString): + sString = utils.toUnicode(sString); + if not utils.isString(sString): + sString = str(sString); + return json.dumps(sString); + + @staticmethod + def dictToJson(dDict, dOptions = None): + """ Converts a dictionary to a JSON string. """ + sJson = u'{ '; + for i, oKey in enumerate(dDict): + if i > 0: + sJson += ', '; + sJson += '%s: %s' % (ModelDataBase.stringToJson(oKey), + ModelDataBase.genericToJson(dDict[oKey], dOptions)); + return sJson + ' }'; + + @staticmethod + def listToJson(aoList, dOptions = None): + """ Converts list of something to a JSON string. """ + sJson = u'[ '; + for i, oValue in enumerate(aoList): + if i > 0: + sJson += u', '; + sJson += ModelDataBase.genericToJson(oValue, dOptions); + return sJson + u' ]'; + + @staticmethod + def datetimeToJson(oDateTime): + """ Converts a datetime instance to a JSON string. """ + return '"%s"' % (oDateTime,); + + + @staticmethod + def genericToJson(oValue, dOptions = None): + """ Converts a generic object to a JSON string. """ + if isinstance(oValue, ModelDataBase): + return oValue.toJson(); + if isinstance(oValue, dict): + return ModelDataBase.dictToJson(oValue, dOptions); + if isinstance(oValue, (list, tuple, set, frozenset)): + return ModelDataBase.listToJson(oValue, dOptions); + if isinstance(oValue, datetime.datetime): + return ModelDataBase.datetimeToJson(oValue) + return json.dumps(oValue); + + def attribValueToJson(self, sAttr, oValue, dOptions = None): + """ + Converts the attribute value to JSON. + Returns JSON (string). + """ + _ = sAttr; + return self.genericToJson(oValue, dOptions); + + def toJson(self, dOptions = None): + """ + Converts the object to JSON. + Returns JSON (string). + """ + sJson = u'{ '; + for iAttr, sAttr in enumerate(self.getDataAttributes()): + oValue = getattr(self, sAttr); + if iAttr > 0: + sJson += ', '; + sJson += u'"%s": ' % (sAttr,); + sJson += self.attribValueToJson(sAttr, oValue, dOptions); + return sJson + u' }'; + + + # + # Sub-classes. + # + + class DispWrapper(object): + """Proxy object.""" + def __init__(self, oDisp, sAttrFmt): + self.oDisp = oDisp; + self.sAttrFmt = sAttrFmt; + def getStringParam(self, sName, asValidValues = None, sDefault = None, fAllowNull = False): + """See WuiDispatcherBase.getStringParam.""" + return self.oDisp.getStringParam(self.sAttrFmt % (sName,), asValidValues, sDefault, fAllowNull = fAllowNull); + def getListOfStrParams(self, sName, asDefaults = None): + """See WuiDispatcherBase.getListOfStrParams.""" + return self.oDisp.getListOfStrParams(self.sAttrFmt % (sName,), asDefaults); + def getListOfIntParams(self, sName, iMin = None, iMax = None, aiDefaults = None): + """See WuiDispatcherBase.getListOfIntParams.""" + return self.oDisp.getListOfIntParams(self.sAttrFmt % (sName,), iMin, iMax, aiDefaults); + + + + +# pylint: disable=no-member,missing-docstring,too-few-public-methods +class ModelDataBaseTestCase(unittest.TestCase): + """ + Base testcase for ModelDataBase decendants. + Derive from this and override setUp. + """ + + def setUp(self): + """ + Override this! Don't call super! + The subclasses are expected to set aoSamples to an array of instance + samples. The first entry must be a default object, the subsequent ones + are optional and their contents freely choosen. + """ + self.aoSamples = [ModelDataBase(),]; + + def testEquality(self): + for oSample in self.aoSamples: + self.assertEqual(oSample.isEqual(copy.copy(oSample)), True); + self.assertIsNotNone(oSample.isEqual(self.aoSamples[0])); + + def testNullConversion(self): + if not self.aoSamples[0].getDataAttributes(): + return; + for oSample in self.aoSamples: + oCopy = copy.copy(oSample); + self.assertEqual(oCopy.convertToParamNull(), oCopy); + self.assertEqual(oCopy.isEqual(oSample), False); + self.assertEqual(oCopy.convertFromParamNull(), oCopy); + self.assertEqual(oCopy.isEqual(oSample), True, '\ngot : %s\nexpected: %s' % (oCopy, oSample,)); + + oCopy = copy.copy(oSample); + self.assertEqual(oCopy.convertToParamNull(), oCopy); + oCopy2 = copy.copy(oCopy); + self.assertEqual(oCopy.convertToParamNull(), oCopy); + self.assertEqual(oCopy.isEqual(oCopy2), True); + self.assertEqual(oCopy.convertToParamNull(), oCopy); + self.assertEqual(oCopy.isEqual(oCopy2), True); + + oCopy = copy.copy(oSample); + self.assertEqual(oCopy.convertFromParamNull(), oCopy); + oCopy2 = copy.copy(oCopy); + self.assertEqual(oCopy.convertFromParamNull(), oCopy); + self.assertEqual(oCopy.isEqual(oCopy2), True); + self.assertEqual(oCopy.convertFromParamNull(), oCopy); + self.assertEqual(oCopy.isEqual(oCopy2), True); + + def testReinitToNull(self): + oFirst = copy.copy(self.aoSamples[0]); + self.assertEqual(oFirst.reinitToNull(), oFirst); + for oSample in self.aoSamples: + oCopy = copy.copy(oSample); + self.assertEqual(oCopy.reinitToNull(), oCopy); + self.assertEqual(oCopy.isEqual(oFirst), True); + + def testValidateAndConvert(self): + for oSample in self.aoSamples: + oCopy = copy.copy(oSample); + oCopy.convertToParamNull(); + dError1 = oCopy.validateAndConvert(None); + + oCopy2 = copy.copy(oCopy); + self.assertEqual(oCopy.validateAndConvert(None), dError1); + self.assertEqual(oCopy.isEqual(oCopy2), True); + + def testInitFromParams(self): + class DummyDisp(object): + def getStringParam(self, sName, asValidValues = None, sDefault = None, fAllowNull = False): + _ = sName; _ = asValidValues; _ = fAllowNull; + return sDefault; + def getListOfStrParams(self, sName, asDefaults = None): + _ = sName; + return asDefaults; + def getListOfIntParams(self, sName, iMin = None, iMax = None, aiDefaults = None): + _ = sName; _ = iMin; _ = iMax; + return aiDefaults; + + for oSample in self.aoSamples: + oCopy = copy.copy(oSample); + self.assertEqual(oCopy.initFromParams(DummyDisp(), fStrict = False), oCopy); + + def testToString(self): + for oSample in self.aoSamples: + self.assertIsNotNone(oSample.toString()); + + +class FilterCriterionValueAndDescription(object): + """ + A filter criterion value and its description. + """ + + def __init__(self, oValue, sDesc, cTimes = None, sHover = None, fIrrelevant = False): + self.oValue = oValue; ##< Typically the ID of something in the database. + self.sDesc = sDesc; ##< What to display. + self.cTimes = cTimes; ##< Number of times the value occurs in the result set. None if not given. + self.sHover = sHover; ##< Optional hover/title string. + self.fIrrelevant = fIrrelevant; ##< Irrelevant filter option, only present because it's selected + self.aoSubs = []; ##< References to FilterCriterion.oSub.aoPossible. + + +class FilterCriterion(object): + """ + A filter criterion. + """ + + ## @name The state. + ## @{ + ksState_NotSelected = 'not-selected'; + ksState_Selected = 'selected'; + ## @} + + ## @name The kind of filtering. + ## @{ + ## 'Element of' by default, 'not an element of' when fInverted is False. + ksKind_ElementOfOrNot = 'element-of-or-not'; + ## The criterion is a special one and cannot be inverted. + ksKind_Special = 'special'; + ## @} + + ## @name The value type. + ## @{ + ksType_UInt = 'uint'; ##< unsigned integer value. + ksType_UIntNil = 'uint-nil'; ##< unsigned integer value, with nil. + ksType_String = 'string'; ##< string value. + ksType_Ranges = 'ranges'; ##< List of (unsigned) integer ranges. + ## @} + + def __init__(self, sName, sVarNm = None, sType = ksType_UInt, # pylint: disable=too-many-arguments + sState = ksState_NotSelected, sKind = ksKind_ElementOfOrNot, + sTable = None, sColumn = None, asTables = None, oSub = None): + assert len(sVarNm) == 2; # required by wuimain.py for filtering. + self.sName = sName; + self.sState = sState; + self.sType = sType; + self.sKind = sKind; + self.sVarNm = sVarNm; + self.aoSelected = []; ##< User input from sVarNm. Single value, type according to sType. + self.sInvVarNm = 'i' + sVarNm if sKind == self.ksKind_ElementOfOrNot else None; + self.fInverted = False; ##< User input from sInvVarNm. Inverts the operation (-> not an element of). + self.aoPossible = []; ##< type: list[FilterCriterionValueAndDescription] + assert (sTable is None and asTables is None) or ((sTable is not None) != (asTables is not None)), \ + '%s %s' % (sTable, asTables); + self.asTables = [sTable,] if sTable is not None else asTables; + assert sColumn is None or len(self.asTables) == 1, '%s %s' % (self.asTables, sColumn); + self.sColumn = sColumn; ##< Normally only applicable if one table. + self.fExpanded = None; ##< Tristate (None, False, True) + self.oSub = oSub; ##< type: FilterCriterion + + +class ModelFilterBase(ModelBase): + """ + Base class for filters. + + Filters are used to narrow down data that is displayed in a list or + report. This class differs a little from ModelDataBase in that it is not + tied to a database table, but one or more database queries that are + typically rather complicated. + + The filter object has two roles: + + 1. It is used by a ModelLogicBase descendant to store the available + filtering options for data begin displayed. + + 2. It decodes and stores the filtering options submitted by the user so + a ModeLogicBase descendant can use it to construct WHERE statements. + + The ModelFilterBase class is related to the ModelDataBase class in that it + decodes user parameters and stores data, however it is not a descendant. + + Note! In order to reduce URL lengths, we use very very brief parameter + names for the filters. + """ + + def __init__(self): + ModelBase.__init__(self); + self.aCriteria = [] # type: list[FilterCriterion] + + def _initFromParamsWorker(self, oDisp, oCriterion): # (,FilterCriterion) + """ Worker for initFromParams. """ + if oCriterion.sType == FilterCriterion.ksType_UInt: + oCriterion.aoSelected = oDisp.getListOfIntParams(oCriterion.sVarNm, iMin = 0, aiDefaults = []); + elif oCriterion.sType == FilterCriterion.ksType_UIntNil: + oCriterion.aoSelected = oDisp.getListOfIntParams(oCriterion.sVarNm, iMin = -1, aiDefaults = []); + elif oCriterion.sType == FilterCriterion.ksType_String: + oCriterion.aoSelected = oDisp.getListOfStrParams(oCriterion.sVarNm, asDefaults = []); + if len(oCriterion.aoSelected) > 100: + raise TMExceptionBase('Variable %s has %u value, max allowed is 100!' + % (oCriterion.sVarNm, len(oCriterion.aoSelected))); + for sValue in oCriterion.aoSelected: + if len(sValue) > 64 \ + or '\'' in sValue \ + or sValue[-1] == '\\': + raise TMExceptionBase('Variable %s has an illegal value "%s"!' % (oCriterion.sVarNm, sValue)); + elif oCriterion.sType == FilterCriterion.ksType_Ranges: + def convertRangeNumber(sValue): + """ Helper """ + sValue = sValue.strip(); + if sValue and sValue not in ('inf', 'Inf', 'INf', 'INF', 'InF', 'iNf', 'iNF', 'inF',): + try: return int(sValue); + except: pass; + return None; + + for sRange in oDisp.getStringParam(oCriterion.sVarNm, sDefault = '').split(','): + sRange = sRange.strip(); + if sRange and sRange != '-' and any(ch.isdigit() for ch in sRange): + asValues = sRange.split('-'); + if len(asValues) == 1: + asValues = [asValues[0], asValues[0]]; + elif len(asValues) > 2: + asValues = [asValues[0], asValues[-1]]; + tTuple = (convertRangeNumber(asValues[0]), convertRangeNumber(asValues[1])); + if tTuple[0] is not None and tTuple[1] is not None and tTuple[0] > tTuple[1]: + tTuple = (tTuple[1], tTuple[0]); + oCriterion.aoSelected.append(tTuple); + else: + assert False; + if oCriterion.aoSelected: + oCriterion.sState = FilterCriterion.ksState_Selected; + else: + oCriterion.sState = FilterCriterion.ksState_NotSelected; + + if oCriterion.sKind == FilterCriterion.ksKind_ElementOfOrNot: + oCriterion.fInverted = oDisp.getBoolParam(oCriterion.sInvVarNm, fDefault = False); + + if oCriterion.oSub is not None: + self._initFromParamsWorker(oDisp, oCriterion.oSub); + return; + + def initFromParams(self, oDisp): # type: (WuiDispatcherBase) -> self + """ + Initialize the object from parameters. + + Returns self. Raises exception on invalid parameter value. + """ + + for oCriterion in self.aCriteria: + self._initFromParamsWorker(oDisp, oCriterion); + return self; + + def strainParameters(self, dParams, aAdditionalParams = None): + """ Filters just the parameters relevant to this filter, returning a copy. """ + + # Collect the parameter names. + dWanted = {}; + for oCrit in self.aCriteria: + dWanted[oCrit.sVarNm] = 1; + if oCrit.sInvVarNm: + dWanted[oCrit.sInvVarNm] = 1; + + # Add additional stuff. + if aAdditionalParams: + for sParam in aAdditionalParams: + dWanted[sParam] = 1; + + # To the straining. + dRet = {}; + for sKey in dParams: + if sKey in dWanted: + dRet[sKey] = dParams[sKey]; + return dRet; + + +class ModelLogicBase(ModelBase): # pylint: disable=too-few-public-methods + """ + Something all classes in the logic classes the logical model inherits from. + """ + + def __init__(self, oDb): + ModelBase.__init__(self); + + # + # Note! Do not create a connection here if None, we need to DB share + # connection with all other logic objects so we can perform half + # complex transactions involving several logic objects. + # + self._oDb = oDb; + + def getDbConnection(self): + """ + Gets the database connection. + This should only be used for instantiating other ModelLogicBase children. + """ + return self._oDb; + + def _dbRowsToModelDataList(self, oModelDataType, aaoRows = None): + """ + Helper for conerting a simple fetch into a list of ModelDataType python objects. + + If aaoRows is None, we'll fetchAll from the database ourselves. + + The oModelDataType must be a class derived from ModelDataBase and implement + the initFormDbRow method. + + Returns a list of oModelDataType instances. + """ + assert issubclass(oModelDataType, ModelDataBase); + aoRet = []; + if aaoRows is None: + aaoRows = self._oDb.fetchAll(); + for aoRow in aaoRows: + aoRet.append(oModelDataType().initFromDbRow(aoRow)); + return aoRet; + + + +class AttributeChangeEntry(object): # pylint: disable=too-few-public-methods + """ + Data class representing the changes made to one attribute. + """ + + def __init__(self, sAttr, oNewRaw, oOldRaw, sNewText, sOldText): + self.sAttr = sAttr; + self.oNewRaw = oNewRaw; + self.oOldRaw = oOldRaw; + self.sNewText = sNewText; + self.sOldText = sOldText; + +class AttributeChangeEntryPre(AttributeChangeEntry): # pylint: disable=too-few-public-methods + """ + AttributeChangeEntry for preformatted values. + """ + + def __init__(self, sAttr, oNewRaw, oOldRaw, sNewText, sOldText): + AttributeChangeEntry.__init__(self, sAttr, oNewRaw, oOldRaw, sNewText, sOldText); + +class ChangeLogEntry(object): # pylint: disable=too-few-public-methods + """ + A change log entry returned by the fetchChangeLog method typically + implemented by ModelLogicBase child classes. + """ + + def __init__(self, uidAuthor, sAuthor, tsEffective, tsExpire, oNewRaw, oOldRaw, aoChanges): + self.uidAuthor = uidAuthor; + self.sAuthor = sAuthor; + self.tsEffective = tsEffective; + self.tsExpire = tsExpire; + self.oNewRaw = oNewRaw; + self.oOldRaw = oOldRaw; # Note! NULL for the last entry. + self.aoChanges = aoChanges; + diff --git a/src/VBox/ValidationKit/testmanager/core/build.py b/src/VBox/ValidationKit/testmanager/core/build.py new file mode 100755 index 00000000..bfe50ca5 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/build.py @@ -0,0 +1,891 @@ +# -*- coding: utf-8 -*- +# $Id: build.py $ + +""" +Test Manager - Builds. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import os; +import unittest; + +# Validation Kit imports. +from testmanager import config; +from testmanager.core import coreconsts; +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMExceptionBase, \ + TMTooManyRows, TMInvalidData, TMRowNotFound, TMRowInUse; + + +class BuildCategoryData(ModelDataBase): + """ + A build category. + """ + + ksIdAttr = 'idBuildCategory'; + + ksParam_idBuildCategory = 'BuildCategory_idBuildCategory'; + ksParam_sProduct = 'BuildCategory_sProduct'; + ksParam_sRepository = 'BuildCategory_sRepository'; + ksParam_sBranch = 'BuildCategory_sBranch'; + ksParam_sType = 'BuildCategory_sType'; + ksParam_asOsArches = 'BuildCategory_asOsArches'; + + kasAllowNullAttributes = ['idBuildCategory', ]; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idBuildCategory = None; + self.sProduct = None; + self.sRepository = None; + self.sBranch = None; + self.sType = None; + self.asOsArches = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the object from a SELECT * FROM BuildCategories row. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMRowNotFound('BuildCategory not found.'); + + self.idBuildCategory = aoRow[0]; + self.sProduct = aoRow[1]; + self.sRepository = aoRow[2]; + self.sBranch = aoRow[3]; + self.sType = aoRow[4]; + self.asOsArches = sorted(aoRow[5]); + return self; + + def initFromDbWithId(self, oDb, idBuildCategory, tsNow = None, sPeriodBack = None): + """ + Initialize from the database, given the ID of a row. + """ + _ = tsNow; _ = sPeriodBack; # No history in this table. + oDb.execute('SELECT * FROM BuildCategories WHERE idBuildCategory = %s', (idBuildCategory,)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idBuildCategory=%s not found' % (idBuildCategory, )); + return self.initFromDbRow(aoRow); + + def initFromValues(self, sProduct, sRepository, sBranch, sType, asOsArches, idBuildCategory = None): + """ + Reinitializes form a set of values. + return self. + """ + self.idBuildCategory = idBuildCategory; + self.sProduct = sProduct; + self.sRepository = sRepository; + self.sBranch = sBranch; + self.sType = sType; + self.asOsArches = asOsArches; + return self; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + # Handle sType and asOsArches specially. + if sAttr == 'sType': + (oNewValue, sError) = ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, + aoNilValues, fAllowNull, oDb); + if sError is None and self.sType.lower() != self.sType: + sError = 'Invalid build type value'; + + elif sAttr == 'asOsArches': + (oNewValue, sError) = self.validateListOfStr(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + asValidValues = coreconsts.g_kasOsDotCpusAll); + if sError is not None and oNewValue is not None: + oNewValue = sorted(oNewValue); # Must be sorted! + + else: + return ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + return (oNewValue, sError); + + def matchesOsArch(self, sOs, sArch): + """ Checks if the build matches the given OS and architecture. """ + if sOs + '.' + sArch in self.asOsArches: + return True; + if sOs + '.noarch' in self.asOsArches: + return True; + if 'os-agnostic.' + sArch in self.asOsArches: + return True; + if 'os-agnostic.noarch' in self.asOsArches: + return True; + return False; + + +class BuildCategoryLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Build categories database logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches testboxes for listing. + + Returns an array (list) of UserAccountData items, empty list if none. + Raises exception on error. + """ + _ = tsNow; _ = aiSortColumns; + self._oDb.execute('SELECT *\n' + 'FROM BuildCategories\n' + 'ORDER BY sProduct, sRepository, sBranch, sType, idBuildCategory\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + + aoRows = []; + for _ in range(self._oDb.getRowCount()): + aoRows.append(BuildCategoryData().initFromDbRow(self._oDb.fetchOne())); + return aoRows; + + def fetchForCombo(self): + """ + Gets the list of Build Categories for a combo box. + Returns an array of (value [idBuildCategory], drop-down-name [info], + hover-text [info]) tuples. + """ + self._oDb.execute('SELECT *\n' + 'FROM BuildCategories\n' + 'ORDER BY sProduct, sBranch, sType, asOsArches') + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + oData = BuildCategoryData().initFromDbRow(aoRow) + + sInfo = '%s / %s / %s / %s' % \ + (oData.sProduct, + oData.sBranch, + oData.sType, + ', '.join(oData.asOsArches)) + + # Make short info string if necessary + sInfo = sInfo if len(sInfo) < 70 else (sInfo[:70] + '...') + + oInfoItem = (oData.idBuildCategory, sInfo, sInfo) + aoRet.append(oInfoItem) + + return aoRet + + def addEntry(self, oData, uidAuthor = None, fCommit = False): + """ + Standard method for adding a build category. + """ + + # Lazy bird warning! Reuse the soft addBuildCategory method. + self.addBuildCategory(oData, fCommit); + _ = uidAuthor; + return True; + + def removeEntry(self, uidAuthor, idBuildCategory, fCascade = False, fCommit = False): + """ + Tries to delete the build category. + Note! Does not implement cascading. This is intentional! + """ + + # + # Check that the build category isn't used by anyone. + # + self._oDb.execute('SELECT COUNT(idBuild)\n' + 'FROM Builds\n' + 'WHERE idBuildCategory = %s\n' + , (idBuildCategory,)); + cBuilds = self._oDb.fetchOne()[0]; + if cBuilds > 0: + raise TMRowInUse('Build category #%d is used by %d builds and can therefore not be deleted.' + % (idBuildCategory, cBuilds,)); + + # + # Ok, it's not used, so just delete it. + # (No history on this table. This code is for typos.) + # + self._oDb.execute('DELETE FROM Builds\n' + 'WHERE idBuildCategory = %s\n' + , (idBuildCategory,)); + + self._oDb.maybeCommit(fCommit); + _ = uidAuthor; _ = fCascade; + return True; + + def cachedLookup(self, idBuildCategory): + """ + Looks up the most recent BuildCategoryData object for idBuildCategory + via an object cache. + + Returns a shared BuildCategoryData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('BuildCategoryData'); + oEntry = self.dCache.get(idBuildCategory, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM BuildCategories\n' + 'WHERE idBuildCategory = %s\n' + , (idBuildCategory, )); + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = BuildCategoryData(); + oEntry.initFromDbRow(aaoRow); + self.dCache[idBuildCategory] = oEntry; + return oEntry; + + # + # Other methods. + # + + def tryFetch(self, idBuildCategory): + """ + Try fetch the build category with the given ID. + Returns BuildCategoryData instance if found, None if not found. + May raise exception on database error. + """ + self._oDb.execute('SELECT *\n' + 'FROM BuildCategories\n' + 'WHERE idBuildCategory = %s\n' + , (idBuildCategory,)) + aaoRows = self._oDb.fetchAll() + if not aaoRows: + return None; + if len(aaoRows) != 1: + raise self._oDb.integrityException('Duplicates in BuildCategories: %s' % (aaoRows,)); + return BuildCategoryData().initFromDbRow(aaoRows[0]) + + def tryFindByData(self, oData): + """ + Tries to find the matching build category from the sProduct, sBranch, + sType and asOsArches members of oData. + + Returns a valid build category ID and an updated oData object if found. + Returns None and unmodified oData object if not found. + May raise exception on database error. + """ + self._oDb.execute('SELECT *\n' + 'FROM BuildCategories\n' + 'WHERE sProduct = %s\n' + ' AND sRepository = %s\n' + ' AND sBranch = %s\n' + ' AND sType = %s\n' + ' AND asOsArches = %s\n' + , ( oData.sProduct, + oData.sRepository, + oData.sBranch, + oData.sType, + sorted(oData.asOsArches), + )); + aaoRows = self._oDb.fetchAll(); + if not aaoRows: + return None; + if len(aaoRows) > 1: + raise self._oDb.integrityException('Duplicates in BuildCategories: %s' % (aaoRows,)); + + oData.initFromDbRow(aaoRows[0]); + return oData.idBuildCategory; + + def addBuildCategory(self, oData, fCommit = False): + """ + Add Build Category record into the database if needed, returning updated oData. + Raises exception on input and database errors. + """ + + # Check BuildCategoryData before do anything + dDataErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dDataErrors: + raise TMInvalidData('Invalid data passed to addBuildCategory(): %s' % (dDataErrors,)); + + # Does it already exist? + if self.tryFindByData(oData) is None: + # No, We'll have to add it. + self._oDb.execute('INSERT INTO BuildCategories (sProduct, sRepository, sBranch, sType, asOsArches)\n' + 'VALUES (%s, %s, %s, %s, %s)\n' + 'RETURNING idBuildCategory' + , ( oData.sProduct, + oData.sRepository, + oData.sBranch, + oData.sType, + sorted(oData.asOsArches), + )); + oData.idBuildCategory = self._oDb.fetchOne()[0]; + + self._oDb.maybeCommit(fCommit); + return oData; + + +class BuildData(ModelDataBase): + """ + A build. + """ + + ksIdAttr = 'idBuild'; + + ksParam_idBuild = 'Build_idBuild'; + ksParam_tsCreated = 'Build_tsCreated'; + ksParam_tsEffective = 'Build_tsEffective'; + ksParam_tsExpire = 'Build_tsExpire'; + ksParam_uidAuthor = 'Build_uidAuthor'; + ksParam_idBuildCategory = 'Build_idBuildCategory'; + ksParam_iRevision = 'Build_iRevision'; + ksParam_sVersion = 'Build_sVersion'; + ksParam_sLogUrl = 'Build_sLogUrl'; + ksParam_sBinaries = 'Build_sBinaries'; + ksParam_fBinariesDeleted = 'Build_fBinariesDeleted'; + + kasAllowNullAttributes = ['idBuild', 'tsCreated', 'tsEffective', 'tsExpire', 'uidAuthor', 'tsCreated', 'sLogUrl']; + + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idBuild = None; + self.tsCreated = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.idBuildCategory = None; + self.iRevision = None; + self.sVersion = None; + self.sLogUrl = None; + self.sBinaries = None; + self.fBinariesDeleted = False; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the object from a SELECT * FROM Builds row. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMRowNotFound('Build not found.'); + + self.idBuild = aoRow[0]; + self.tsCreated = aoRow[1]; + self.tsEffective = aoRow[2]; + self.tsExpire = aoRow[3]; + self.uidAuthor = aoRow[4]; + self.idBuildCategory = aoRow[5]; + self.iRevision = aoRow[6]; + self.sVersion = aoRow[7]; + self.sLogUrl = aoRow[8]; + self.sBinaries = aoRow[9]; + self.fBinariesDeleted = aoRow[10]; + return self; + + def initFromDbWithId(self, oDb, idBuild, tsNow = None, sPeriodBack = None): + """ + Initialize from the database, given the ID of a row. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM Builds\n' + 'WHERE idBuild = %s\n' + , ( idBuild,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idBuild=%s not found (tsNow=%s sPeriodBack=%s)' % (idBuild, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def areFilesStillThere(self): + """ + Try check if the build files are still there. + + Returns True if they are, None if we cannot tell, and False if one or + more are missing. + """ + if self.fBinariesDeleted: + return False; + + for sBinary in self.sBinaries.split(','): + sBinary = sBinary.strip(); + if not sBinary: + continue; + # Same URL tests as in webutils.downloadFile(). + if sBinary.startswith('http://') \ + or sBinary.startswith('https://') \ + or sBinary.startswith('ftp://'): + # URL - don't bother trying to verify that (we don't use it atm). + fRc = None; + else: + # File. + if config.g_ksBuildBinRootDir is not None: + sFullPath = os.path.join(config.g_ksBuildBinRootDir, sBinary); + fRc = os.path.isfile(sFullPath); + if not fRc \ + and not os.path.isfile(os.path.join(config.g_ksBuildBinRootDir, config.g_ksBuildBinRootFile)): + fRc = None; # Root file missing, so the share might not be mounted correctly. + else: + fRc = None; + if fRc is not True: + return fRc; + + return True; + + +class BuildDataEx(BuildData): + """ + Complete data set. + """ + + kasInternalAttributes = [ 'oCat', ]; + + def __init__(self): + BuildData.__init__(self); + self.oCat = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT Builds.*, BuildCategories.* FROM Builds, BuildCategories query. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMRowNotFound('Build not found.'); + BuildData.initFromDbRow(self, aoRow); + self.oCat = BuildCategoryData().initFromDbRow(aoRow[11:]); + return self; + + def initFromDbWithId(self, oDb, idBuild, tsNow = None, sPeriodBack = None): + """ + Reinitialize from database given a row ID. + Returns self. Raises exception on database error or if the ID is invalid. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories\n' + 'WHERE idBuild = %s\n' + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + , ( idBuild,), tsNow, sPeriodBack, 'Builds.')); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idBuild=%s not found (tsNow=%s sPeriodBack=%s)' % (idBuild, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def convertFromParamNull(self): + raise TMExceptionBase('Not implemented'); + + def isEqual(self, oOther): + raise TMExceptionBase('Not implemented'); + + + +class BuildLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Build database logic (covers build categories as well as builds). + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + # + # Standard methods. + # + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches builds for listing. + + Returns an array (list) of BuildDataEx items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + ' AND Builds.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY tsCreated DESC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + ' AND Builds.tsExpire > %s\n' + ' AND Builds.tsEffective <= %s\n' + 'ORDER BY tsCreated DESC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = []; + for _ in range(self._oDb.getRowCount()): + aoRows.append(BuildDataEx().initFromDbRow(self._oDb.fetchOne())); + return aoRows; + + def addEntry(self, oBuildData, uidAuthor = None, fCommit = False): + """ + Adds the build to the database, optionally adding the build category if + a BuildDataEx object used and it's necessary. + + Returns updated data object. Raises exception on failure. + """ + + # Find/Add the build category if specified. + if isinstance(oBuildData, BuildDataEx) \ + and oBuildData.idBuildCategory is None: + BuildCategoryLogic(self._oDb).addBuildCategory(oBuildData.oCat, fCommit = False); + oBuildData.idBuildCategory = oBuildData.oCat.idBuildCategory; + + # Add the build. + self._oDb.execute('INSERT INTO Builds (uidAuthor,\n' + ' idBuildCategory,\n' + ' iRevision,\n' + ' sVersion,\n' + ' sLogUrl,\n' + ' sBinaries,\n' + ' fBinariesDeleted)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s)\n' + 'RETURNING idBuild, tsCreated\n' + , ( uidAuthor, + oBuildData.idBuildCategory, + oBuildData.iRevision, + oBuildData.sVersion, + oBuildData.sLogUrl, + oBuildData.sBinaries, + oBuildData.fBinariesDeleted, + )); + aoRow = self._oDb.fetchOne(); + oBuildData.idBuild = aoRow[0]; + oBuildData.tsCreated = aoRow[1]; + + self._oDb.maybeCommit(fCommit); + return oBuildData; + + def editEntry(self, oData, uidAuthor = None, fCommit = False): + """Modify database record""" + + # + # Validate input and get current data. + # + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + oOldData = BuildData().initFromDbWithId(self._oDb, oData.idBuild); + + # + # Do the work. + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor' ]): + self._historizeBuild(oData.idBuild); + self._oDb.execute('INSERT INTO Builds (uidAuthor,\n' + ' idBuild,\n' + ' tsCreated,\n' + ' idBuildCategory,\n' + ' iRevision,\n' + ' sVersion,\n' + ' sLogUrl,\n' + ' sBinaries,\n' + ' fBinariesDeleted)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)\n' + 'RETURNING idBuild, tsCreated\n' + , ( uidAuthor, + oData.idBuild, + oData.tsCreated, + oData.idBuildCategory, + oData.iRevision, + oData.sVersion, + oData.sLogUrl, + oData.sBinaries, + oData.fBinariesDeleted, + )); + + self._oDb.maybeCommit(fCommit); + return True; + + def removeEntry(self, uidAuthor, idBuild, fCascade = False, fCommit = False): + """ + Historize record + """ + + # + # No non-historic refs here, so just go ahead and expire the build. + # + _ = fCascade; + _ = uidAuthor; ## @todo record deleter. + + self._historizeBuild(idBuild, None); + + self._oDb.maybeCommit(fCommit); + return True; + + def cachedLookup(self, idBuild): + """ + Looks up the most recent BuildDataEx object for idBuild + via an object cache. + + Returns a shared BuildDataEx object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('BuildDataEx'); + oEntry = self.dCache.get(idBuild, None); + if oEntry is None: + self._oDb.execute('SELECT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.idBuild = %s\n' + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idBuild, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.idBuild = %s\n' + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idBuild, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idBuild)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = BuildDataEx(); + oEntry.initFromDbRow(aaoRow); + self.dCache[idBuild] = oEntry; + return oEntry; + + + # + # Other methods. + # + + def tryFindSameBuildForOsArch(self, oBuildEx, sOs, sCpuArch): + """ + Attempts to find a matching build for the given OS.ARCH. May return + the input build if if matches. + + Returns BuildDataEx instance if found, None if none. May raise + exception on database error. + """ + + if oBuildEx.oCat.matchesOsArch(sOs, sCpuArch): + return oBuildEx; + + self._oDb.execute('SELECT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories\n' + 'WHERE BuildCategories.sProduct = %s\n' + ' AND BuildCategories.sBranch = %s\n' + ' AND BuildCategories.sType = %s\n' + ' AND ( %s = ANY(BuildCategories.asOsArches)\n' + ' OR %s = ANY(BuildCategories.asOsArches)\n' + ' OR %s = ANY(BuildCategories.asOsArches))\n' + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + ' AND Builds.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND Builds.iRevision = %s\n' + ' AND Builds.sRelease = %s\n' + ' AND Builds.fBinariesDeleted IS FALSE\n' + 'ORDER BY tsCreated DESC\n' + 'LIMIT 4096\n' # stay sane. + , (oBuildEx.oCat.sProduct, + oBuildEx.oCat.sBranch, + oBuildEx.oCat.sType, + '%s.%s' % (sOs, sCpuArch), + '%s.noarch' % (sOs,), + 'os-agnostic.%s' % (sCpuArch,), + 'os-agnostic.noarch', + oBuildEx.iRevision, + oBuildEx.sRelease, + ) ); + aaoRows = self._oDb.fetchAll(); + + for aoRow in aaoRows: + oBuildExRet = BuildDataEx().initFromDbRow(aoRow); + if not self.isBuildBlacklisted(oBuildExRet): + return oBuildExRet; + + return None; + + def isBuildBlacklisted(self, oBuildEx): + """ + Checks if the given build is blacklisted + Returns True/False. May raise exception on database error. + """ + + asOsAgnosticArch = []; + asOsNoArch = []; + for sOsArch in oBuildEx.oCat.asOsArches: + asParts = sOsArch.split('.'); + if len(asParts) != 2 or not asParts[0] or not asParts[1]: + raise self._oDb.integrityException('Bad build asOsArches value: %s (idBuild=%s idBuildCategory=%s)' + % (sOsArch, oBuildEx.idBuild, oBuildEx.idBuildCategory)); + asOsNoArch.append(asParts[0] + '.noarch'); + asOsNoArch.append('os-agnostic.' + asParts[1]); + + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM BuildBlacklist\n' + 'WHERE BuildBlacklist.tsExpire > CURRENT_TIMESTAMP\n' + ' AND BuildBlacklist.tsEffective <= CURRENT_TIMESTAMP\n' + ' AND BuildBlacklist.sProduct = %s\n' + ' AND BuildBlacklist.sBranch = %s\n' + ' AND ( BuildBlacklist.asTypes is NULL\n' + ' OR %s = ANY(BuildBlacklist.asTypes))\n' + ' AND ( BuildBlacklist.asOsArches is NULL\n' + ' OR %s && BuildBlacklist.asOsArches\n' ## @todo check array rep! Need overload? + ' OR %s && BuildBlacklist.asOsArches\n' + ' OR %s && BuildBlacklist.asOsArches\n' + ' OR %s = ANY(BuildBlacklist.asOsArches))\n' + ' AND BuildBlacklist.iFirstRevision <= %s\n' + ' AND BuildBlacklist.iLastRevision >= %s\n' + , (oBuildEx.oCat.sProduct, + oBuildEx.oCat.sBranch, + oBuildEx.oCat.sType, + oBuildEx.oCat.asOsArches, + asOsAgnosticArch, + asOsNoArch, + 'os-agnostic.noarch', + oBuildEx.iRevision, + oBuildEx.iRevision, + ) ); + return self._oDb.fetchOne()[0] > 0; + + + def getById(self, idBuild): + """ + Get build record by its id + """ + self._oDb.execute('SELECT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.idBuild=%s\n' + ' AND Builds.idBuildCategory=BuildCategories.idBuildCategory\n' + ' AND Builds.tsExpire = \'infinity\'::TIMESTAMP\n', (idBuild,)) + + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise TMTooManyRows('Found more than one build with the same credentials. Database structure is corrupted.') + try: + return BuildDataEx().initFromDbRow(aRows[0]) + except IndexError: + return None + + + def getAll(self, tsEffective = None): + """ + Gets the list of all builds. + Returns an array of BuildDataEx instances. + """ + if tsEffective is None: + self._oDb.execute('SELECT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND Builds.idBuildCategory=BuildCategories.idBuildCategory') + else: + self._oDb.execute('SELECT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.tsExpire > %s\n' + ' AND Builds.tsEffective <= %s' + ' AND Builds.idBuildCategory=BuildCategories.idBuildCategory' + , (tsEffective, tsEffective)) + aoRet = [] + for aoRow in self._oDb.fetchAll(): + aoRet.append(BuildDataEx().initFromDbRow(aoRow)) + return aoRet + + + def markDeletedByBinaries(self, sBinaries, fCommit = False): + """ + Marks zero or more builds deleted given the build binaries. + + Returns the number of affected builds. + """ + # Fetch a list of affected build IDs (generally 1 build), and used the + # editEntry method to do the rest. This isn't 100% optimal, but it's + # short and simple, the main effort is anyway the first query. + self._oDb.execute('SELECT idBuild\n' + 'FROM Builds\n' + 'WHERE sBinaries = %s\n' + ' AND fBinariesDeleted = FALSE\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (sBinaries,)); + aaoRows = self._oDb.fetchAll(); + for aoRow in aaoRows: + oData = BuildData().initFromDbWithId(self._oDb, aoRow[0]); + assert not oData.fBinariesDeleted; + oData.fBinariesDeleted = True; + self.editEntry(oData, fCommit = False); + self._oDb.maybeCommit(fCommit); + return len(aaoRows); + + + + # + # Internal helpers. + # + + def _historizeBuild(self, idBuild, tsExpire = None): + """ Historizes the current entry for the specified build. """ + if tsExpire is None: + self._oDb.execute('UPDATE Builds\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idBuild = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idBuild,)); + else: + self._oDb.execute('UPDATE Builds\n' + 'SET tsExpire = %s\n' + 'WHERE idBuild = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (tsExpire, idBuild,)); + return True; + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class BuildCategoryDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [BuildCategoryData(),]; + +class BuildDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [BuildData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/buildblacklist.py b/src/VBox/ValidationKit/testmanager/core/buildblacklist.py new file mode 100755 index 00000000..487fb9f0 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/buildblacklist.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +# $Id: buildblacklist.py $ + +""" +Test Manager - Builds Blacklist. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelLogicBase, TMInvalidData, TMRowNotFound; + + +class BuildBlacklistData(ModelDataBase): + """ + Build Blacklist Data. + """ + + ksIdAttr = 'idBlacklisting'; + + ksParam_idBlacklisting = 'BuildBlacklist_idBlacklisting' + ksParam_tsEffective = 'BuildBlacklist_tsEffective' + ksParam_tsExpire = 'BuildBlacklist_tsExpire' + ksParam_uidAuthor = 'BuildBlacklist_uidAuthor' + ksParam_idFailureReason = 'BuildBlacklist_idFailureReason' + ksParam_sProduct = 'BuildBlacklist_sProduct' + ksParam_sBranch = 'BuildBlacklist_sBranch' + ksParam_asTypes = 'BuildBlacklist_asTypes' + ksParam_asOsArches = 'BuildBlacklist_asOsArches' + ksParam_iFirstRevision = 'BuildBlacklist_iFirstRevision' + ksParam_iLastRevision = 'BuildBlacklist_iLastRevision' + + kasAllowNullAttributes = [ 'idBlacklisting', + 'tsEffective', + 'tsExpire', + 'uidAuthor', + 'asTypes', + 'asOsArches' ]; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idBlacklisting = None + self.tsEffective = None + self.tsExpire = None + self.uidAuthor = None + self.idFailureReason = None + self.sProduct = None + self.sBranch = None + self.asTypes = None + self.asOsArches = None + self.iFirstRevision = None + self.iLastRevision = None + + def initFromDbRow(self, aoRow): + """ + Re-initializes the data with a row from a SELECT * FROM BuildBlacklist. + + Returns self. Raises exception if the row is None or otherwise invalid. + """ + + if aoRow is None: + raise TMRowNotFound('Build Blacklist item not found.') + + self.idBlacklisting = aoRow[0] + self.tsEffective = aoRow[1] + self.tsExpire = aoRow[2] + self.uidAuthor = aoRow[3] + self.idFailureReason = aoRow[4] + self.sProduct = aoRow[5] + self.sBranch = aoRow[6] + self.asTypes = aoRow[7] + self.asOsArches = aoRow[8] + self.iFirstRevision = aoRow[9] + self.iLastRevision = aoRow[10] + + return self; + + def initFromDbWithId(self, oDb, idBlacklisting, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM BuildBlacklist\n' + 'WHERE idBlacklisting = %s\n' + , ( idBlacklisting,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idBlacklisting=%s not found (tsNow=%s sPeriodBack=%s)' + % (idBlacklisting, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + +class BuildBlacklistLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Build Back List logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches Build Blacklist records. + + Returns an array (list) of BuildBlacklistData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM BuildBlacklist\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idBlacklisting DESC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM BuildBlacklist\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY idBlacklisting DESC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = [] + for aoRow in self._oDb.fetchAll(): + aoRows.append(BuildBlacklistData().initFromDbRow(aoRow)) + return aoRows + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Adds a blacklisting to the database. + """ + self._oDb.execute('INSERT INTO BuildBlacklist (\n' + ' uidAuthor,\n' + ' idFailureReason,\n' + ' sProduct,\n' + ' sBranch,\n' + ' asTypes,\n' + ' asOsArches,\n' + ' iFirstRevision,\n' + ' iLastRevision)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s)' + , ( uidAuthor, + oData.idFailureReason, + oData.sProduct, + oData.sBranch, + oData.asTypes, + oData.asOsArches, + oData.iFirstRevision, + oData.iLastRevision,) ); + self._oDb.maybeCommit(fCommit); + return True + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Modifies a blacklisting. + """ + + # + # Validate inputs and read in the old(/current) data. + # + assert isinstance(oData, BuildBlacklistData); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + + oOldData = BuildBlacklistData().initFromDbWithId(self._oDb, oData.idBlacklisting); + + # + # Update the data that needs updating. + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', ]): + self._historizeEntry(oData.idBlacklisting, None); + self._readdEntry(uidAuthor, oData, None); + self._oDb.maybeCommit(fCommit); + return True; + + + def removeEntry(self, uidAuthor, idBlacklisting, fCascade = False, fCommit = False): + """ + Deletes a test group. + """ + _ = fCascade; # Not applicable. + + oData = BuildBlacklistData().initFromDbWithId(self._oDb, idBlacklisting); + + (tsCur, tsCurMinusOne) = self._oDb.getCurrentTimestamps(); + if oData.tsEffective not in (tsCur, tsCurMinusOne): + self._historizeEntry(idBlacklisting, tsCurMinusOne); + self._readdEntry(uidAuthor, oData, tsCurMinusOne); + self._historizeEntry(idBlacklisting); + self._oDb.execute('UPDATE BuildBlacklist\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idBlacklisting = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idBlacklisting,)); + self._oDb.maybeCommit(fCommit); + return True; + + + def cachedLookup(self, idBlacklisting): + """ + Looks up the most recent BuildBlacklistData object for idBlacklisting + via an object cache. + + Returns a shared BuildBlacklistData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('BuildBlacklistData'); + oEntry = self.dCache.get(idBlacklisting, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM BuildBlacklist\n' + 'WHERE idBlacklisting = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idBlacklisting, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM BuildBlacklist\n' + 'WHERE idBlacklisting = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idBlacklisting, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idBlacklisting)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = BuildBlacklistData(); + oEntry.initFromDbRow(aaoRow); + self.dCache[idBlacklisting] = oEntry; + return oEntry; + + + # + # Helpers. + # + + def _historizeEntry(self, idBlacklisting, tsExpire = None): + """ + Historizes the current entry for the given backlisting. + """ + if tsExpire is None: + tsExpire = self._oDb.getCurrentTimestamp(); + self._oDb.execute('UPDATE BuildBlacklist\n' + 'SET tsExpire = %s\n' + 'WHERE idBlacklisting = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( tsExpire, idBlacklisting, )); + return True; + + def _readdEntry(self, uidAuthor, oData, tsEffective = None): + """ + Re-adds the BuildBlacklist entry. Used by editEntry and removeEntry. + """ + if tsEffective is None: + tsEffective = self._oDb.getCurrentTimestamp(); + self._oDb.execute('INSERT INTO BuildBlacklist (\n' + ' uidAuthor,\n' + ' tsEffective,\n' + ' idBlacklisting,\n' + ' idFailureReason,\n' + ' sProduct,\n' + ' sBranch,\n' + ' asTypes,\n' + ' asOsArches,\n' + ' iFirstRevision,\n' + ' iLastRevision)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)\n' + , ( uidAuthor, + tsEffective, + oData.idBlacklisting, + oData.idFailureReason, + oData.sProduct, + oData.sBranch, + oData.asTypes, + oData.asOsArches, + oData.iFirstRevision, + oData.iLastRevision,) ); + return True; + diff --git a/src/VBox/ValidationKit/testmanager/core/buildsource.py b/src/VBox/ValidationKit/testmanager/core/buildsource.py new file mode 100755 index 00000000..782b5ee0 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/buildsource.py @@ -0,0 +1,524 @@ +# -*- coding: utf-8 -*- +# $Id: buildsource.py $ + +""" +Test Manager - Build Sources. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from common import utils; +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMRowAlreadyExists, \ + TMRowInUse, TMInvalidData, TMRowNotFound; +from testmanager.core import coreconsts; + + +class BuildSourceData(ModelDataBase): + """ + A build source. + """ + + ksIdAttr = 'idBuildSrc'; + + ksParam_idBuildSrc = 'BuildSource_idBuildSrc'; + ksParam_tsEffective = 'BuildSource_tsEffective'; + ksParam_tsExpire = 'BuildSource_tsExpire'; + ksParam_uidAuthor = 'BuildSource_uidAuthor'; + ksParam_sName = 'BuildSource_sName'; + ksParam_sDescription = 'BuildSource_sDescription'; + ksParam_sProduct = 'BuildSource_sProduct'; + ksParam_sBranch = 'BuildSource_sBranch'; + ksParam_asTypes = 'BuildSource_asTypes'; + ksParam_asOsArches = 'BuildSource_asOsArches'; + ksParam_iFirstRevision = 'BuildSource_iFirstRevision'; + ksParam_iLastRevision = 'BuildSource_iLastRevision'; + ksParam_cSecMaxAge = 'BuildSource_cSecMaxAge'; + + kasAllowNullAttributes = [ 'idBuildSrc', 'tsEffective', 'tsExpire', 'uidAuthor', 'sDescription', 'asTypes', + 'asOsArches', 'iFirstRevision', 'iLastRevision', 'cSecMaxAge' ]; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idBuildSrc = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.sName = None; + self.sDescription = None; + self.sProduct = None; + self.sBranch = None; + self.asTypes = None; + self.asOsArches = None; + self.iFirstRevision = None; + self.iLastRevision = None; + self.cSecMaxAge = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the object from a SELECT * FROM BuildSources row. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMRowNotFound('Build source not found.'); + + self.idBuildSrc = aoRow[0]; + self.tsEffective = aoRow[1]; + self.tsExpire = aoRow[2]; + self.uidAuthor = aoRow[3]; + self.sName = aoRow[4]; + self.sDescription = aoRow[5]; + self.sProduct = aoRow[6]; + self.sBranch = aoRow[7]; + self.asTypes = aoRow[8]; + self.asOsArches = aoRow[9]; + self.iFirstRevision = aoRow[10]; + self.iLastRevision = aoRow[11]; + self.cSecMaxAge = aoRow[12]; + return self; + + def initFromDbWithId(self, oDb, idBuildSrc, tsNow = None, sPeriodBack = None): + """ + Initialize from the database, given the ID of a row. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM BuildSources\n' + 'WHERE idBuildSrc = %s\n' + , ( idBuildSrc,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idBuildSrc=%s not found (tsNow=%s sPeriodBack=%s)' % (idBuildSrc, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + # Handle asType and asOsArches specially. + if sAttr == 'sType': + (oNewValue, sError) = ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, + aoNilValues, fAllowNull, oDb); + if sError is None: + if not self.asTypes: + oNewValue = None; + else: + for sType in oNewValue: + if len(sType) < 2 or sType.lower() != sType: + if sError is None: sError = ''; + else: sError += ', '; + sError += 'invalid value "%s"' % (sType,); + + elif sAttr == 'asOsArches': + (oNewValue, sError) = self.validateListOfStr(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + asValidValues = coreconsts.g_kasOsDotCpusAll); + if sError is not None and oNewValue is not None: + oNewValue = sorted(oNewValue); # Must be sorted! + + elif sAttr == 'cSecMaxAge' and oValue not in aoNilValues: # Allow human readable interval formats. + (oNewValue, sError) = utils.parseIntervalSeconds(oValue); + else: + return ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + return (oNewValue, sError); + +class BuildSourceLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Build source database logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + # + # Standard methods. + # + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches build sources. + + Returns an array (list) of BuildSourceData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM BuildSources\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idBuildSrc DESC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM BuildSources\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY idBuildSrc DESC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = [] + for aoRow in self._oDb.fetchAll(): + aoRows.append(BuildSourceData().initFromDbRow(aoRow)) + return aoRows + + def fetchForCombo(self): + """Fetch data which is aimed to be passed to HTML form""" + self._oDb.execute('SELECT idBuildSrc, sName, sProduct\n' + 'FROM BuildSources\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idBuildSrc DESC\n') + asRet = self._oDb.fetchAll(); + asRet.insert(0, (-1, 'None', 'None')); + return asRet; + + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Add a new build source to the database. + """ + + # + # Validate the input. + # + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dErrors: + raise TMInvalidData('addEntry invalid input: %s' % (dErrors,)); + self._assertUnique(oData, None); + + # + # Add it. + # + self._oDb.execute('INSERT INTO BuildSources (\n' + ' uidAuthor,\n' + ' sName,\n' + ' sDescription,\n' + ' sProduct,\n' + ' sBranch,\n' + ' asTypes,\n' + ' asOsArches,\n' + ' iFirstRevision,\n' + ' iLastRevision,\n' + ' cSecMaxAge)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)\n' + , ( uidAuthor, + oData.sName, + oData.sDescription, + oData.sProduct, + oData.sBranch, + oData.asTypes, + oData.asOsArches, + oData.iFirstRevision, + oData.iLastRevision, + oData.cSecMaxAge, )); + + self._oDb.maybeCommit(fCommit); + return True; + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Modifies a build source. + """ + + # + # Validate the input and read the old entry. + # + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('addEntry invalid input: %s' % (dErrors,)); + self._assertUnique(oData, oData.idBuildSrc); + oOldData = BuildSourceData().initFromDbWithId(self._oDb, oData.idBuildSrc); + + # + # Make the changes (if something actually changed). + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', ]): + self._historizeBuildSource(oData.idBuildSrc); + self._oDb.execute('INSERT INTO BuildSources (\n' + ' uidAuthor,\n' + ' idBuildSrc,\n' + ' sName,\n' + ' sDescription,\n' + ' sProduct,\n' + ' sBranch,\n' + ' asTypes,\n' + ' asOsArches,\n' + ' iFirstRevision,\n' + ' iLastRevision,\n' + ' cSecMaxAge)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)\n' + , ( uidAuthor, + oData.idBuildSrc, + oData.sName, + oData.sDescription, + oData.sProduct, + oData.sBranch, + oData.asTypes, + oData.asOsArches, + oData.iFirstRevision, + oData.iLastRevision, + oData.cSecMaxAge, )); + self._oDb.maybeCommit(fCommit); + return True; + + def removeEntry(self, uidAuthor, idBuildSrc, fCascade = False, fCommit = False): + """ + Deletes a build sources. + """ + + # + # Check cascading. + # + if fCascade is not True: + self._oDb.execute('SELECT idSchedGroup, sName\n' + 'FROM SchedGroups\n' + 'WHERE idBuildSrc = %s\n' + ' OR idBuildSrcTestSuite = %s\n' + , (idBuildSrc, idBuildSrc,)); + if self._oDb.getRowCount() > 0: + asGroups = []; + for aoRow in self._oDb.fetchAll(): + asGroups.append('%s (#%d)' % (aoRow[1], aoRow[0])); + raise TMRowInUse('Build source #%d is used by one or more scheduling groups: %s' + % (idBuildSrc, ', '.join(asGroups),)); + else: + self._oDb.execute('UPDATE SchedGroups\n' + 'SET idBuildSrc = NULL\n' + 'WHERE idBuildSrc = %s' + , ( idBuildSrc,)); + self._oDb.execute('UPDATE SchedGroups\n' + 'SET idBuildSrcTestSuite = NULL\n' + 'WHERE idBuildSrcTestSuite = %s' + , ( idBuildSrc,)); + + # + # Do the job. + # + self._historizeBuildSource(idBuildSrc, None); + _ = uidAuthor; ## @todo record deleter. + + self._oDb.maybeCommit(fCommit); + return True; + + def cachedLookup(self, idBuildSrc): + """ + Looks up the most recent BuildSourceData object for idBuildSrc + via an object cache. + + Returns a shared BuildSourceData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('BuildSourceData'); + oEntry = self.dCache.get(idBuildSrc, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM BuildSources\n' + 'WHERE idBuildSrc = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idBuildSrc, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM BuildSources\n' + 'WHERE idBuildSrc = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idBuildSrc, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idBuildSrc)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = BuildSourceData(); + oEntry.initFromDbRow(aaoRow); + self.dCache[idBuildSrc] = oEntry; + return oEntry; + + # + # Other methods. + # + + def openBuildCursor(self, oBuildSource, sOs, sCpuArch, tsNow): + """ + Opens a cursor (SELECT) using the criteria found in the build source + and the given OS.CPUARCH. + + Returns database cursor. May raise exception on bad input or logic error. + + Used by SchedulerBase. + """ + + oCursor = self._oDb.openCursor(); + + # + # Construct the extra conditionals. + # + sExtraConditions = ''; + + # Types + if oBuildSource.asTypes is not None and oBuildSource.asTypes: + if len(oBuildSource.asTypes) == 1: + sExtraConditions += oCursor.formatBindArgs(' AND BuildCategories.sType = %s', (oBuildSource.asTypes[0],)); + else: + sExtraConditions += oCursor.formatBindArgs(' AND BuildCategories.sType IN (%s', (oBuildSource.asTypes[0],)) + for i in range(1, len(oBuildSource.asTypes) - 1): + sExtraConditions += oCursor.formatBindArgs(', %s', (oBuildSource.asTypes[i],)); + sExtraConditions += oCursor.formatBindArgs(', %s)\n', (oBuildSource.asTypes[-1],)); + + # BuildSource OSes.ARCHes. (Paranoia: use a dictionary to avoid duplicate values.) + if oBuildSource.asOsArches is not None and oBuildSource.asOsArches: + sExtraConditions += oCursor.formatBindArgs(' AND BuildCategories.asOsArches && %s', (oBuildSource.asOsArches,)); + + # TestBox OSes.ARCHes. (Paranoia: use a dictionary to avoid duplicate values.) + dOsDotArches = {}; + dOsDotArches[sOs + '.' + sCpuArch] = 1; + dOsDotArches[sOs + '.' + coreconsts.g_ksCpuArchAgnostic] = 1; + dOsDotArches[coreconsts.g_ksOsAgnostic + '.' + sCpuArch] = 1; + dOsDotArches[coreconsts.g_ksOsDotArchAgnostic] = 1; + sExtraConditions += oCursor.formatBindArgs(' AND BuildCategories.asOsArches && %s', (list(dOsDotArches.keys()),)); + + # Revision range. + if oBuildSource.iFirstRevision is not None: + sExtraConditions += oCursor.formatBindArgs(' AND Builds.iRevision >= %s\n', (oBuildSource.iFirstRevision,)); + if oBuildSource.iLastRevision is not None: + sExtraConditions += oCursor.formatBindArgs(' AND Builds.iRevision <= %s\n', (oBuildSource.iLastRevision,)); + + # Max age. + if oBuildSource.cSecMaxAge is not None: + sExtraConditions += oCursor.formatBindArgs(' AND Builds.tsCreated >= (%s - \'%s seconds\'::INTERVAL)\n', + (tsNow, oBuildSource.cSecMaxAge,)); + + # + # Execute the query. + # + oCursor.execute('SELECT Builds.*, BuildCategories.*,\n' + ' EXISTS( SELECT tsExpire\n' + ' FROM BuildBlacklist\n' + ' WHERE BuildBlacklist.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND BuildBlacklist.sProduct = %s\n' + ' AND BuildBlacklist.sBranch = %s\n' + ' AND BuildBlacklist.iFirstRevision <= Builds.iRevision\n' + ' AND BuildBlacklist.iLastRevision >= Builds.iRevision ) AS fMaybeBlacklisted\n' + 'FROM Builds, BuildCategories\n' + 'WHERE Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + ' AND Builds.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND Builds.tsEffective <= %s\n' + ' AND Builds.fBinariesDeleted is FALSE\n' + ' AND BuildCategories.sProduct = %s\n' + ' AND BuildCategories.sBranch = %s\n' + + sExtraConditions + + 'ORDER BY Builds.idBuild DESC\n' + 'LIMIT 256\n' + , ( oBuildSource.sProduct, oBuildSource.sBranch, + tsNow, oBuildSource.sProduct, oBuildSource.sBranch,)); + + return oCursor; + + + def getById(self, idBuildSrc): + """Get Build Source data by idBuildSrc""" + + self._oDb.execute('SELECT *\n' + 'FROM BuildSources\n' + 'WHERE tsExpire = \'infinity\'::timestamp\n' + ' AND idBuildSrc = %s;', (idBuildSrc,)) + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise self._oDb.integrityException( + 'Found more than one build sources with the same credentials. Database structure is corrupted.') + try: + return BuildSourceData().initFromDbRow(aRows[0]) + except IndexError: + return None + + # + # Internal helpers. + # + + def _assertUnique(self, oData, idBuildSrcIgnore): + """ Checks that the build source name is unique, raises exception if it isn't. """ + self._oDb.execute('SELECT idBuildSrc\n' + 'FROM BuildSources\n' + 'WHERE sName = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + + ('' if idBuildSrcIgnore is None else ' AND idBuildSrc <> %d\n' % (idBuildSrcIgnore,)) + , ( oData.sName, )) + if self._oDb.getRowCount() > 0: + raise TMRowAlreadyExists('A build source with name "%s" already exist.' % (oData.sName,)); + return True; + + + def _historizeBuildSource(self, idBuildSrc, tsExpire = None): + """ Historizes the current build source entry. """ + if tsExpire is None: + self._oDb.execute('UPDATE BuildSources\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idBuildSrc = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( idBuildSrc, )); + else: + self._oDb.execute('UPDATE BuildSources\n' + 'SET tsExpire = %s\n' + 'WHERE idBuildSrc = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( tsExpire, idBuildSrc, )); + return True; + + + + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class BuildSourceDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [BuildSourceData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/coreconsts.py b/src/VBox/ValidationKit/testmanager/core/coreconsts.py new file mode 100644 index 00000000..bbdc7712 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/coreconsts.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# $Id: coreconsts.py $ + +""" +Test Manager - Test Manager Constants (without a more appropriate home). +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + +## OS agnostic. +g_ksOsAgnostic = 'os-agnostic'; +## All known OSes, except the agnostic one. +# See KBUILD_OSES in kBuild/header.kmk for reference. +g_kasOses = ['darwin', 'dos', 'dragonfly', 'freebsd', 'haiku', 'l4', 'linux', 'netbsd', 'nt', 'openbsd', 'os2', + 'solaris', 'win']; +## All known OSes, including the agnostic one. +# See KBUILD_OSES in kBuild/header.kmk for reference. +g_kasOsesAll = g_kasOses + [g_ksOsAgnostic,]; + + +## Architecture agnostic. +g_ksCpuArchAgnostic = 'noarch'; +## All known CPU architectures, except the agnostic one. +# See KBUILD_ARCHES in kBuild/header.kmk for reference. +g_kasCpuArches = ['amd64', 'x86', 'sparc32', 'sparc64', 's390', 's390x', 'ppc32', 'ppc64', 'mips32', 'mips64', 'ia64', + 'hppa32', 'hppa64', 'arm', 'alpha']; +## All known CPU architectures, except the agnostic one. +# See KBUILD_ARCHES in kBuild/header.kmk for reference. +g_kasCpuArchesAll = g_kasCpuArches + [g_ksCpuArchAgnostic,]; + +## All known build types +# See KBUILD_TYPE in kBuild/header.kmk for reference. +# @note 'blessed' is a special type used for release builds that has been notarized +# or attestation signed by the OS vendor. +g_kasBuildTypesAll = [ 'release', 'strict', 'profile', 'debug', 'asan', 'blessed' ]; + +## OS and CPU architecture agnostic. +g_ksOsDotArchAgnostic = 'os-agnostic.noarch'; +## Combinations of all OSes and CPU architectures, except the two agnostic ones. +# We do some of them by hand to avoid offering too many choices. +g_kasOsDotCpus = \ +[ + 'darwin.amd64', 'darwin.x86', 'darwin.ppc32', 'darwin.ppc64', 'darwin.arm', + 'dos.x86', + 'dragonfly.amd64', 'dragonfly.x86', + 'freebsd.amd64', 'freebsd.x86', 'freebsd.sparc64', 'freebsd.ia64', 'freebsd.ppc32', 'freebsd.ppc64', 'freebsd.arm', + 'freebsd.mips32', 'freebsd.mips64', + 'haiku.amd64', 'haiku.x86', + 'l4.amd64', 'l4.x86', 'l4.ppc32', 'l4.ppc64', 'l4.arm', + 'nt.amd64', 'nt.x86', 'nt.arm', 'nt.ia64', 'nt.mips32', 'nt.ppc32', 'nt.alpha', + 'win.amd64', 'win.x86', 'win.arm', 'win.ia64', 'win.mips32', 'win.ppc32', 'win.alpha', + 'os2.x86', + 'solaris.amd64', 'solaris.x86', 'solaris.sparc32', 'solaris.sparc64', +]; +for sOs in g_kasOses: + if sOs not in ['darwin', 'dos', 'dragonfly', 'freebsd', 'haiku', 'l4', 'nt', 'win', 'os2', 'solaris']: + for sArch in g_kasCpuArches: + g_kasOsDotCpus.append(sOs + '.' + sArch); +g_kasOsDotCpus.sort(); + +## Combinations of all OSes and CPU architectures, including the two agnostic ones. +g_kasOsDotCpusAll = [g_ksOsDotArchAgnostic] +g_kasOsDotCpusAll.extend(g_kasOsDotCpus); +for sOs in g_kasOsesAll: + g_kasOsDotCpusAll.append(sOs + '.' + g_ksCpuArchAgnostic); +for sArch in g_kasCpuArchesAll: + g_kasOsDotCpusAll.append(g_ksOsAgnostic + '.' + sArch); +g_kasOsDotCpusAll.sort(); + diff --git a/src/VBox/ValidationKit/testmanager/core/db.py b/src/VBox/ValidationKit/testmanager/core/db.py new file mode 100755 index 00000000..915336c3 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/db.py @@ -0,0 +1,745 @@ +# -*- coding: utf-8 -*- +# $Id: db.py $ + +""" +Test Manager - Database Interface. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import datetime; +import os; +import sys; +import psycopg2; # pylint: disable=import-error +import psycopg2.extensions; # pylint: disable=import-error + +# Validation Kit imports. +from common import utils, webutils; +from testmanager import config; + +# Fix psycho unicode handling in psycopg2 with python 2.x. +if sys.version_info[0] < 3: + psycopg2.extensions.register_type(psycopg2.extensions.UNICODE); + psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY); +else: + unicode = str; # pylint: disable=redefined-builtin,invalid-name + + + +def isDbTimestampInfinity(tsValue): + """ + Checks if tsValue is an infinity timestamp. + """ + ## @todo improve this test... + return tsValue.year >= 9999; + +def isDbTimestamp(oValue): + """ + Checks if oValue is a DB timestamp object. + """ + if isinstance(oValue, datetime.datetime): + return True; + if utils.isString(oValue): + ## @todo detect strings as well. + return False; + return getattr(oValue, 'pydatetime', None) is not None; + +def dbTimestampToDatetime(oValue): + """ + Converts a database timestamp to a datetime instance. + """ + if isinstance(oValue, datetime.datetime): + return oValue; + if utils.isString(oValue): + return utils.parseIsoTimestamp(oValue); + return oValue.pydatetime(); + +def dbTimestampToZuluDatetime(oValue): + """ + Converts a database timestamp to a zulu datetime instance. + """ + tsValue = dbTimestampToDatetime(oValue); + + class UTC(datetime.tzinfo): + """UTC TZ Info Class""" + def utcoffset(self, _): + return datetime.timedelta(0); + def tzname(self, _): + return "UTC"; + def dst(self, _): + return datetime.timedelta(0); + if tsValue.tzinfo is not None: + tsValue = tsValue.astimezone(UTC()); + else: + tsValue = tsValue.replace(tzinfo=UTC()); + return tsValue; + +def dbTimestampPythonNow(): + """ + Gets the current python timestamp in a database compatible way. + """ + return dbTimestampToZuluDatetime(datetime.datetime.utcnow()); + +def dbOneTickIntervalString(): + """ + Returns the interval string for one tick. + + Mogrify the return value into the SQL: + "... %s::INTERVAL ..." + or + "INTERVAL %s" + The completed SQL will contain the necessary ticks. + """ + return '1 microsecond'; + +def dbTimestampMinusOneTick(oValue): + """ + Returns a new timestamp that's one tick before the given one. + """ + oValue = dbTimestampToZuluDatetime(oValue); + return oValue - datetime.timedelta(microseconds = 1); + +def dbTimestampPlusOneTick(oValue): + """ + Returns a new timestamp that's one tick after the given one. + """ + oValue = dbTimestampToZuluDatetime(oValue); + return oValue + datetime.timedelta(microseconds = 1); + +def isDbInterval(oValue): + """ + Checks if oValue is a DB interval object. + """ + if isinstance(oValue, datetime.timedelta): + return True; + return False; + + +class TMDatabaseIntegrityException(Exception): + """ + Herolds a database integrity error up the callstack. + + Do NOT use directly, only thru TMDatabaseConnection.integrityException. + Otherwise, we won't be able to log the issue. + """ + pass; # pylint: disable=unnecessary-pass + + +class TMDatabaseCursor(object): + """ Cursor wrapper class. """ + + def __init__(self, oDb, oCursor): + self._oDb = oDb; + self._oCursor = oCursor; + + def execute(self, sOperation, aoArgs = None): + """ See TMDatabaseConnection.execute()""" + return self._oDb.executeInternal(self._oCursor, sOperation, aoArgs, utils.getCallerName()); + + def callProc(self, sProcedure, aoArgs = None): + """ See TMDatabaseConnection.callProc()""" + return self._oDb.callProcInternal(self._oCursor, sProcedure, aoArgs, utils.getCallerName()); + + def insertList(self, sInsertSql, aoList, fnEntryFmt): + """ See TMDatabaseConnection.insertList. """ + return self._oDb.insertListInternal(self._oCursor, sInsertSql, aoList, fnEntryFmt, utils.getCallerName()); + + def fetchOne(self): + """Wrapper around Psycopg2.cursor.fetchone.""" + return self._oCursor.fetchone(); + + def fetchMany(self, cRows = None): + """Wrapper around Psycopg2.cursor.fetchmany.""" + return self._oCursor.fetchmany(cRows if cRows is not None else self._oCursor.arraysize); + + def fetchAll(self): + """Wrapper around Psycopg2.cursor.fetchall.""" + return self._oCursor.fetchall(); + + def getRowCount(self): + """Wrapper around Psycopg2.cursor.rowcount.""" + return self._oCursor.rowcount; + + def formatBindArgs(self, sStatement, aoArgs): + """Wrapper around Psycopg2.cursor.mogrify.""" + oRet = self._oCursor.mogrify(sStatement, aoArgs); + if sys.version_info[0] >= 3 and not isinstance(oRet, str): + oRet = oRet.decode('utf-8'); + return oRet; + + def copyExpert(self, sSqlCopyStmt, oFile, cbBuf = 8192): + """ See TMDatabaseConnection.copyExpert()""" + return self._oCursor.copy_expert(sSqlCopyStmt, oFile, cbBuf); + + @staticmethod + def isTsInfinity(tsValue): + """ Checks if tsValue is an infinity timestamp. """ + return isDbTimestampInfinity(tsValue); + + +class TMDatabaseConnection(object): + """ + Test Manager Database Access class. + + This class contains no logic, just raw access abstraction and utilities, + as well as some debug help and some statistics. + """ + + def __init__(self, fnDPrint = None, oSrvGlue = None): + """ + Database connection wrapper. + The fnDPrint is for debug logging of all database activity. + + Raises an exception on failure. + """ + + sAppName = '%s-%s' % (os.getpid(), os.path.basename(sys.argv[0]),) + if len(sAppName) >= 64: + sAppName = sAppName[:64]; + os.environ['PGAPPNAME'] = sAppName; + + dArgs = \ + { \ + 'database': config.g_ksDatabaseName, + 'user': config.g_ksDatabaseUser, + 'password': config.g_ksDatabasePassword, + # 'application_name': sAppName, - Darn stale debian! :/ + }; + if config.g_ksDatabaseAddress is not None: + dArgs['host'] = config.g_ksDatabaseAddress; + if config.g_ksDatabasePort is not None: + dArgs['port'] = config.g_ksDatabasePort; + self._oConn = psycopg2.connect(**dArgs); # pylint: disable=star-args + self._oConn.set_client_encoding('UTF-8'); + self._oCursor = self._oConn.cursor(); + self._oExplainConn = None; + self._oExplainCursor = None; + if config.g_kfWebUiSqlTraceExplain and config.g_kfWebUiSqlTrace: + self._oExplainConn = psycopg2.connect(**dArgs); # pylint: disable=star-args + self._oExplainConn.set_client_encoding('UTF-8'); + self._oExplainCursor = self._oExplainConn.cursor(); + self._fTransaction = False; + self._tsCurrent = None; + self._tsCurrentMinusOne = None; + + assert self.isAutoCommitting() is False; + + # Debug and introspection. + self._fnDPrint = fnDPrint; + self._aoTraceBack = []; + + # Exception class handles. + self.oXcptError = psycopg2.Error; + + if oSrvGlue is not None: + oSrvGlue.registerDebugInfoCallback(self.debugInfoCallback); + + # Object caches (used by database logic classes). + self.ddCaches = {}; + + def isAutoCommitting(self): + """ Work around missing autocommit attribute in older versions.""" + return getattr(self._oConn, 'autocommit', False); + + def close(self): + """ + Closes the connection and renders all cursors useless. + """ + if self._oCursor is not None: + self._oCursor.close(); + self._oCursor = None; + + if self._oConn is not None: + self._oConn.close(); + self._oConn = None; + + if self._oExplainCursor is not None: + self._oExplainCursor.close(); + self._oExplainCursor = None; + + if self._oExplainConn is not None: + self._oExplainConn.close(); + self._oExplainConn = None; + + + def _startedTransaction(self): + """ + Called to work the _fTransaction and related variables when starting + a transaction. + """ + self._fTransaction = True; + self._tsCurrent = None; + self._tsCurrentMinusOne = None; + return None; + + def _endedTransaction(self): + """ + Called to work the _fTransaction and related variables when ending + a transaction. + """ + self._fTransaction = False; + self._tsCurrent = None; + self._tsCurrentMinusOne = None; + return None; + + def begin(self): + """ + Currently just for marking where a transaction starts in the code. + """ + assert self._oConn is not None; + assert self.isAutoCommitting() is False; + self._aoTraceBack.append([utils.timestampNano(), 'START TRANSACTION', 0, 0, utils.getCallerName(), None]); + self._startedTransaction(); + return True; + + def commit(self, sCallerName = None): + """ Wrapper around Psycopg2.connection.commit.""" + assert self._fTransaction is True; + + nsStart = utils.timestampNano(); + oRc = self._oConn.commit(); + cNsElapsed = utils.timestampNano() - nsStart; + + if sCallerName is None: + sCallerName = utils.getCallerName(); + self._aoTraceBack.append([nsStart, 'COMMIT', cNsElapsed, 0, sCallerName, None]); + self._endedTransaction(); + return oRc; + + def maybeCommit(self, fCommit): + """ + Commits if fCommit is True. + Returns True if committed, False if not. + """ + if fCommit is True: + self.commit(utils.getCallerName()); + return True; + return False; + + def rollback(self): + """ Wrapper around Psycopg2.connection.rollback.""" + nsStart = utils.timestampNano(); + oRc = self._oConn.rollback(); + cNsElapsed = utils.timestampNano() - nsStart; + + self._aoTraceBack.append([nsStart, 'ROLLBACK', cNsElapsed, 0, utils.getCallerName(), None]); + self._endedTransaction(); + return oRc; + + # + # Internal cursor workers. + # + + def executeInternal(self, oCursor, sOperation, aoArgs, sCallerName): + """ + Execute a query or command. + + Mostly a wrapper around the psycopg2 cursor method with the same name, + but collect data for traceback. + """ + if aoArgs is not None: + sBound = oCursor.mogrify(unicode(sOperation), aoArgs); + elif sOperation.find('%') < 0: + sBound = oCursor.mogrify(unicode(sOperation), []); + else: + sBound = unicode(sOperation); + + if sys.version_info[0] >= 3 and not isinstance(sBound, str): + sBound = sBound.decode('utf-8'); # pylint: disable=redefined-variable-type + + aasExplain = None; + if self._oExplainCursor is not None and not sBound.startswith('DROP'): + try: + if config.g_kfWebUiSqlTraceExplainTiming: + self._oExplainCursor.execute('EXPLAIN (ANALYZE, BUFFERS, COSTS, VERBOSE, TIMING) ' + sBound); + else: + self._oExplainCursor.execute('EXPLAIN (ANALYZE, BUFFERS, COSTS, VERBOSE) ' + sBound); + except Exception as oXcpt: + aasExplain = [ ['Explain exception: '], [str(oXcpt)]]; + try: self._oExplainConn.rollback(); + except: pass; + else: + aasExplain = self._oExplainCursor.fetchall(); + + nsStart = utils.timestampNano(); + try: + oRc = oCursor.execute(sBound); + except Exception as oXcpt: + cNsElapsed = utils.timestampNano() - nsStart; + self._aoTraceBack.append([nsStart, 'oXcpt=%s; Statement: %s' % (oXcpt, sBound), cNsElapsed, 0, sCallerName, None]); + if self._fnDPrint is not None: + self._fnDPrint('db::execute %u ns, caller %s: oXcpt=%s; Statement: %s' + % (cNsElapsed, sCallerName, oXcpt, sBound)); + raise; + cNsElapsed = utils.timestampNano() - nsStart; + + if self._fTransaction is False and not self.isAutoCommitting(): # Even SELECTs starts transactions with psycopg2, see FAQ. + self._aoTraceBack.append([nsStart, '[START TRANSACTION]', 0, 0, sCallerName, None]); + self._startedTransaction(); + self._aoTraceBack.append([nsStart, sBound, cNsElapsed, oCursor.rowcount, sCallerName, aasExplain]); + if self._fnDPrint is not None: + self._fnDPrint('db::execute %u ns, caller %s: "\n%s"' % (cNsElapsed, sCallerName, sBound)); + if self.isAutoCommitting(): + self._aoTraceBack.append([nsStart, '[AUTO COMMIT]', 0, 0, sCallerName, None]); + + return oRc; + + def callProcInternal(self, oCursor, sProcedure, aoArgs, sCallerName): + """ + Call a stored procedure. + + Mostly a wrapper around the psycopg2 cursor method 'callproc', but + collect data for traceback. + """ + if aoArgs is None: + aoArgs = []; + + nsStart = utils.timestampNano(); + try: + oRc = oCursor.callproc(sProcedure, aoArgs); + except Exception as oXcpt: + cNsElapsed = utils.timestampNano() - nsStart; + self._aoTraceBack.append([nsStart, 'oXcpt=%s; Calling: %s(%s)' % (oXcpt, sProcedure, aoArgs), + cNsElapsed, 0, sCallerName, None]); + if self._fnDPrint is not None: + self._fnDPrint('db::callproc %u ns, caller %s: oXcpt=%s; Calling: %s(%s)' + % (cNsElapsed, sCallerName, oXcpt, sProcedure, aoArgs)); + raise; + cNsElapsed = utils.timestampNano() - nsStart; + + if self._fTransaction is False and not self.isAutoCommitting(): # Even SELECTs starts transactions with psycopg2, see FAQ. + self._aoTraceBack.append([nsStart, '[START TRANSACTION]', 0, 0, sCallerName, None]); + self._startedTransaction(); + self._aoTraceBack.append([nsStart, '%s(%s)' % (sProcedure, aoArgs), cNsElapsed, oCursor.rowcount, sCallerName, None]); + if self._fnDPrint is not None: + self._fnDPrint('db::callproc %u ns, caller %s: "%s(%s)"' % (cNsElapsed, sCallerName, sProcedure, aoArgs)); + if self.isAutoCommitting(): + self._aoTraceBack.append([nsStart, '[AUTO COMMIT]', 0, 0, sCallerName, sCallerName, None]); + + return oRc; + + def insertListInternal(self, oCursor, sInsertSql, aoList, fnEntryFmt, sCallerName): + """ + Optimizes the insertion of a list of values. + """ + oRc = None; + asValues = []; + for aoEntry in aoList: + asValues.append(fnEntryFmt(aoEntry)); + if len(asValues) > 256: + oRc = self.executeInternal(oCursor, sInsertSql + 'VALUES' + ', '.join(asValues), None, sCallerName); + asValues = []; + if asValues: + oRc = self.executeInternal(oCursor, sInsertSql + 'VALUES' + ', '.join(asValues), None, sCallerName); + return oRc + + def _fetchOne(self, oCursor): + """Wrapper around Psycopg2.cursor.fetchone.""" + oRow = oCursor.fetchone() + if self._fnDPrint is not None: + self._fnDPrint('db:fetchOne returns: %s' % (oRow,)); + return oRow; + + def _fetchMany(self, oCursor, cRows): + """Wrapper around Psycopg2.cursor.fetchmany.""" + return oCursor.fetchmany(cRows if cRows is not None else oCursor.arraysize); + + def _fetchAll(self, oCursor): + """Wrapper around Psycopg2.cursor.fetchall.""" + return oCursor.fetchall() + + def _getRowCountWorker(self, oCursor): + """Wrapper around Psycopg2.cursor.rowcount.""" + return oCursor.rowcount; + + + # + # Default cursor access. + # + + def execute(self, sOperation, aoArgs = None): + """ + Execute a query or command. + + Mostly a wrapper around the psycopg2 cursor method with the same name, + but collect data for traceback. + """ + return self.executeInternal(self._oCursor, sOperation, aoArgs, utils.getCallerName()); + + def callProc(self, sProcedure, aoArgs = None): + """ + Call a stored procedure. + + Mostly a wrapper around the psycopg2 cursor method 'callproc', but + collect data for traceback. + """ + return self.callProcInternal(self._oCursor, sProcedure, aoArgs, utils.getCallerName()); + + def insertList(self, sInsertSql, aoList, fnEntryFmt): + """ + Optimizes the insertion of a list of values. + """ + return self.insertListInternal(self._oCursor, sInsertSql, aoList, fnEntryFmt, utils.getCallerName()); + + def fetchOne(self): + """Wrapper around Psycopg2.cursor.fetchone.""" + return self._oCursor.fetchone(); + + def fetchMany(self, cRows = None): + """Wrapper around Psycopg2.cursor.fetchmany.""" + return self._oCursor.fetchmany(cRows if cRows is not None else self._oCursor.arraysize); + + def fetchAll(self): + """Wrapper around Psycopg2.cursor.fetchall.""" + return self._oCursor.fetchall(); + + def getRowCount(self): + """Wrapper around Psycopg2.cursor.rowcount.""" + return self._oCursor.rowcount; + + def formatBindArgs(self, sStatement, aoArgs): + """Wrapper around Psycopg2.cursor.mogrify.""" + oRet = self._oCursor.mogrify(sStatement, aoArgs); + if sys.version_info[0] >= 3 and not isinstance(oRet, str): + oRet = oRet.decode('utf-8'); + return oRet; + + def copyExpert(self, sSqlCopyStmt, oFile, cbBuf = 8192): + """ Wrapper around Psycopg2.cursor.copy_expert. """ + return self._oCursor.copy_expert(sSqlCopyStmt, oFile, cbBuf); + + def getCurrentTimestamps(self): + """ + Returns the current timestamp and the current timestamp minus one tick. + This will start a transaction if necessary. + """ + if self._tsCurrent is None: + self.execute('SELECT CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - INTERVAL \'1 microsecond\''); + (self._tsCurrent, self._tsCurrentMinusOne) = self.fetchOne(); + return (self._tsCurrent, self._tsCurrentMinusOne); + + def getCurrentTimestamp(self): + """ + Returns the current timestamp. + This will start a transaction if necessary. + """ + if self._tsCurrent is None: + self.getCurrentTimestamps(); + return self._tsCurrent; + + def getCurrentTimestampMinusOne(self): + """ + Returns the current timestamp minus one tick. + This will start a transaction if necessary. + """ + if self._tsCurrentMinusOne is None: + self.getCurrentTimestamps(); + return self._tsCurrentMinusOne; + + + # + # Additional cursors. + # + def openCursor(self): + """ + Opens a new cursor (TMDatabaseCursor). + """ + oCursor = self._oConn.cursor(); + return TMDatabaseCursor(self, oCursor); + + # + # Cache support. + # + def getCache(self, sType): + """ Returns the cache dictionary for this data type. """ + dRet = self.ddCaches.get(sType, None); + if dRet is None: + dRet = {}; + self.ddCaches[sType] = dRet; + return dRet; + + + # + # Utilities. + # + + @staticmethod + def isTsInfinity(tsValue): + """ Checks if tsValue is an infinity timestamp. """ + return isDbTimestampInfinity(tsValue); + + # + # Error stuff. + # + def integrityException(self, sMessage): + """ + Database integrity reporter and exception factory. + Returns an TMDatabaseIntegrityException which the caller can raise. + """ + ## @todo Create a new database connection and log the issue in the SystemLog table. + ## Alternatively, rollback whatever is going on and do it using the current one. + return TMDatabaseIntegrityException(sMessage); + + + # + # Debugging. + # + + def dprint(self, sText): + """ + Debug output. + """ + if not self._fnDPrint: + return False; + self._fnDPrint(sText); + return True; + + def debugHtmlReport(self, tsStart = 0): + """ + Used to get a SQL activity dump as HTML, usually for WuiBase._sDebug. + """ + cNsElapsed = 0; + for aEntry in self._aoTraceBack: + cNsElapsed += aEntry[2]; + + sDebug = '

SQL Debug Log (total time %s ns):

\n' \ + '\n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + % (utils.formatNumber(cNsElapsed, ' '),); + + iEntry = 0; + for aEntry in self._aoTraceBack: + iEntry += 1; + sDebug += ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + ' \n' \ + % (iEntry, + utils.formatNumber(aEntry[0] - tsStart, ' '), + utils.formatNumber(aEntry[2], ' '), + utils.formatNumber(aEntry[3], ' '), + webutils.escapeElem(aEntry[1]), + webutils.escapeElem(aEntry[4]), + ); + if aEntry[5] is not None: + sDebug += ' \n' \ + ' \n' \ + ' \n' \ + % (webutils.escapeElem('\n'.join([aoRow[0] for aoRow in aEntry[5]])),); + + sDebug += '
No.Timestamp (ns)Elapsed (ns)Rows ReturnedCommandCaller
%s%s%s%s
%s
%s
%s
'; + return sDebug; + + def debugTextReport(self, tsStart = 0): + """ + Used to get a SQL activity dump as text. + """ + cNsElapsed = 0; + for aEntry in self._aoTraceBack: + cNsElapsed += aEntry[2]; + + sHdr = 'SQL Debug Log (total time %s ns)' % (utils.formatNumber(cNsElapsed),); + sDebug = sHdr + '\n' + '-' * len(sHdr) + '\n'; + + iEntry = 0; + for aEntry in self._aoTraceBack: + iEntry += 1; + sHdr = 'Query #%s Timestamp: %s ns Elapsed: %s ns Rows: %s Caller: %s' \ + % ( iEntry, + utils.formatNumber(aEntry[0] - tsStart), + utils.formatNumber(aEntry[2]), + utils.formatNumber(aEntry[3]), + aEntry[4], ); + sDebug += '\n' + sHdr + '\n' + '-' * len(sHdr) + '\n'; + + sDebug += aEntry[1]; + if sDebug[-1] != '\n': + sDebug += '\n'; + + if aEntry[5] is not None: + sDebug += 'Explain:\n' \ + ' %s\n' \ + % ( '\n'.join([aoRow[0] for aoRow in aEntry[5]]),); + + return sDebug; + + def debugInfoCallback(self, oGlue, fHtml): + """ Called back by the glue code on error. """ + oGlue.write('\n'); + if not fHtml: oGlue.write(self.debugTextReport()); + else: oGlue.write(self.debugHtmlReport()); + oGlue.write('\n'); + return True; + + def debugEnableExplain(self): + """ Enabled explain. """ + if self._oExplainConn is None: + dArgs = \ + { \ + 'database': config.g_ksDatabaseName, + 'user': config.g_ksDatabaseUser, + 'password': config.g_ksDatabasePassword, + # 'application_name': sAppName, - Darn stale debian! :/ + }; + if config.g_ksDatabaseAddress is not None: + dArgs['host'] = config.g_ksDatabaseAddress; + if config.g_ksDatabasePort is not None: + dArgs['port'] = config.g_ksDatabasePort; + self._oExplainConn = psycopg2.connect(**dArgs); # pylint: disable=star-args + self._oExplainCursor = self._oExplainConn.cursor(); + return True; + + def debugDisableExplain(self): + """ Disables explain. """ + self._oExplainCursor = None; + self._oExplainConn = None + return True; + + def debugIsExplainEnabled(self): + """ Check if explaining of SQL statements is enabled. """ + return self._oExplainConn is not None; + diff --git a/src/VBox/ValidationKit/testmanager/core/dbobjcache.py b/src/VBox/ValidationKit/testmanager/core/dbobjcache.py new file mode 100755 index 00000000..b059dccd --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/dbobjcache.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# $Id: dbobjcache.py $ + +""" +Test Manager - Database object cache. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Validation Kit imports. +from testmanager.core.base import ModelLogicBase; + + +class DatabaseObjCache(ModelLogicBase): + """ + Database object cache. + + This is mainly for reports and test results where we wish to get further + information on a data series or similar. The cache should reduce database + lookups as well as pyhon memory footprint. + + Note! Dependecies are imported when needed to avoid potential cylic dependency issues. + """ + + ## @name Cache object types. + ## @{ + ksObjType_TestResultStrTab_idStrName = 0; + ksObjType_BuildCategory_idBuildCategory = 1; + ksObjType_TestBox_idTestBox = 2; + ksObjType_TestBox_idGenTestBox = 3; + ksObjType_TestCase_idTestCase = 4; + ksObjType_TestCase_idGenTestCase = 5; + ksObjType_TestCaseArgs_idTestCaseArgs = 6; + ksObjType_TestCaseArgs_idGenTestCaseArgs = 7; + ksObjType_VcsRevision_sRepository_iRevision = 8; + ksObjType_End = 9; + ## @} + + def __init__(self, oDb, tsNow = None, sPeriodBack = None, cHoursBack = None): + ModelLogicBase.__init__(self, oDb); + + self.tsNow = tsNow; + self.sPeriodBack = sPeriodBack; + if sPeriodBack is None and cHoursBack is not None: + self.sPeriodBack = '%u hours' % cHoursBack; + + self._adCache = ( + {}, {}, {}, {}, + {}, {}, {}, {}, + {}, + ); + assert(len(self._adCache) == self.ksObjType_End); + + def _handleDbException(self): + """ Deals with database exceptions. """ + #self._oDb.rollback(); + return False; + + def getTestResultString(self, idStrName): + """ Gets a string from the TestResultStrTab. """ + sRet = self._adCache[self.ksObjType_TestResultStrTab_idStrName].get(idStrName); + if sRet is None: + # Load cache entry. + self._oDb.execute('SELECT sValue FROM TestResultStrTab WHERE idStr = %s', (idStrName,)); + sRet = self._oDb.fetchOne()[0]; + self._adCache[self.ksObjType_TestResultStrTab_idStrName][idStrName] = sRet + return sRet; + + def getBuildCategory(self, idBuildCategory): + """ Gets the corresponding BuildCategoryData object. """ + oRet = self._adCache[self.ksObjType_BuildCategory_idBuildCategory].get(idBuildCategory); + if oRet is None: + # Load cache entry. + from testmanager.core.build import BuildCategoryData; + oRet = BuildCategoryData(); + try: oRet.initFromDbWithId(self._oDb, idBuildCategory); + except: self._handleDbException(); raise; + self._adCache[self.ksObjType_BuildCategory_idBuildCategory][idBuildCategory] = oRet; + return oRet; + + def getTestBox(self, idTestBox): + """ Gets the corresponding TestBoxData object. """ + oRet = self._adCache[self.ksObjType_TestBox_idTestBox].get(idTestBox); + if oRet is None: + # Load cache entry. + from testmanager.core.testbox import TestBoxData; + oRet = TestBoxData(); + try: oRet.initFromDbWithId(self._oDb, idTestBox, self.tsNow, self.sPeriodBack); + except: self._handleDbException(); raise; + else: self._adCache[self.ksObjType_TestBox_idGenTestBox][oRet.idGenTestBox] = oRet; + self._adCache[self.ksObjType_TestBox_idTestBox][idTestBox] = oRet; + return oRet; + + def getTestCase(self, idTestCase): + """ Gets the corresponding TestCaseData object. """ + oRet = self._adCache[self.ksObjType_TestCase_idTestCase].get(idTestCase); + if oRet is None: + # Load cache entry. + from testmanager.core.testcase import TestCaseData; + oRet = TestCaseData(); + try: oRet.initFromDbWithId(self._oDb, idTestCase, self.tsNow, self.sPeriodBack); + except: self._handleDbException(); raise; + else: self._adCache[self.ksObjType_TestCase_idGenTestCase][oRet.idGenTestCase] = oRet; + self._adCache[self.ksObjType_TestCase_idTestCase][idTestCase] = oRet; + return oRet; + + def getTestCaseArgs(self, idTestCaseArgs): + """ Gets the corresponding TestCaseArgsData object. """ + oRet = self._adCache[self.ksObjType_TestCaseArgs_idTestCaseArgs].get(idTestCaseArgs); + if oRet is None: + # Load cache entry. + from testmanager.core.testcaseargs import TestCaseArgsData; + oRet = TestCaseArgsData(); + try: oRet.initFromDbWithId(self._oDb, idTestCaseArgs, self.tsNow, self.sPeriodBack); + except: self._handleDbException(); raise; + else: self._adCache[self.ksObjType_TestCaseArgs_idGenTestCaseArgs][oRet.idGenTestCaseArgs] = oRet; + self._adCache[self.ksObjType_TestCaseArgs_idTestCaseArgs][idTestCaseArgs] = oRet; + return oRet; + + def preloadVcsRevInfo(self, sRepository, aiRevisions): + """ + Preloads VCS revision information. + ASSUMES aiRevisions does not contain duplicate keys. + """ + from testmanager.core.vcsrevisions import VcsRevisionData; + dRepo = self._adCache[self.ksObjType_VcsRevision_sRepository_iRevision].get(sRepository); + if dRepo is None: + dRepo = {}; + self._adCache[self.ksObjType_VcsRevision_sRepository_iRevision][sRepository] = dRepo; + aiFiltered = aiRevisions; + else: + aiFiltered = []; + for iRevision in aiRevisions: + if iRevision not in dRepo: + aiFiltered.append(iRevision); + if aiFiltered: + self._oDb.execute('SELECT *\n' + 'FROM VcsRevisions\n' + 'WHERE sRepository = %s\n' + ' AND iRevision IN (' + ','.join([str(i) for i in aiFiltered]) + ')' + , ( sRepository, )); + for aoRow in self._oDb.fetchAll(): + oInfo = VcsRevisionData().initFromDbRow(aoRow); + dRepo[oInfo.iRevision] = oInfo; + return True; + + def getVcsRevInfo(self, sRepository, iRevision): + """ + Gets the corresponding VcsRevisionData object. + May return a default (all NULLs) VcsRevisionData object if the revision + information isn't available in the database yet. + """ + dRepo = self._adCache[self.ksObjType_VcsRevision_sRepository_iRevision].get(sRepository); + if dRepo is not None: + oRet = dRepo.get(iRevision); + else: + dRepo = {}; + self._adCache[self.ksObjType_VcsRevision_sRepository_iRevision][sRepository] = dRepo; + oRet = None; + if oRet is None: + from testmanager.core.vcsrevisions import VcsRevisionLogic; + oRet = VcsRevisionLogic(self._oDb).tryFetch(sRepository, iRevision); + if oRet is None: + from testmanager.core.vcsrevisions import VcsRevisionData; + oRet = VcsRevisionData(); + dRepo[iRevision] = oRet; + return oRet; + diff --git a/src/VBox/ValidationKit/testmanager/core/failurecategory.py b/src/VBox/ValidationKit/testmanager/core/failurecategory.py new file mode 100755 index 00000000..3da0f84b --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/failurecategory.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +# $Id: failurecategory.py $ + +""" +Test Manager - Failure Categories. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard Python imports. +import sys; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelLogicBase, TMRowInUse, TMInvalidData, TMRowNotFound, \ + ChangeLogEntry, AttributeChangeEntry; +from testmanager.core.useraccount import UserAccountLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + xrange = range; # pylint: disable=redefined-builtin,invalid-name + + +class FailureCategoryData(ModelDataBase): + """ + Failure Category Data. + """ + + ksIdAttr = 'idFailureCategory'; + + ksParam_idFailureCategory = 'FailureCategory_idFailureCategory' + ksParam_tsEffective = 'FailureCategory_tsEffective' + ksParam_tsExpire = 'FailureCategory_tsExpire' + ksParam_uidAuthor = 'FailureCategory_uidAuthor' + ksParam_sShort = 'FailureCategory_sShort' + ksParam_sFull = 'FailureCategory_sFull' + + kasAllowNullAttributes = [ 'idFailureCategory', 'tsEffective', 'tsExpire', 'uidAuthor' ] + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + + self.idFailureCategory = None + self.tsEffective = None + self.tsExpire = None + self.uidAuthor = None + self.sShort = None + self.sFull = None + + def initFromDbRow(self, aoRow): + """ + Re-initializes the data with a row from a SELECT * FROM FailureCategoryes. + + Returns self. Raises exception if the row is None or otherwise invalid. + """ + + if aoRow is None: + raise TMRowNotFound('Failure Category not found.'); + + self.idFailureCategory = aoRow[0] + self.tsEffective = aoRow[1] + self.tsExpire = aoRow[2] + self.uidAuthor = aoRow[3] + self.sShort = aoRow[4] + self.sFull = aoRow[5] + + return self + + def initFromDbWithId(self, oDb, idFailureCategory, tsNow = None, sPeriodBack = None): + """ + Initialize from the database, given the ID of a row. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM FailureCategories\n' + 'WHERE idFailureCategory = %s\n' + , ( idFailureCategory,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idFailureCategory=%s not found (tsNow=%s sPeriodBack=%s)' + % (idFailureCategory, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + +class FailureCategoryLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Failure Category logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches Failure Category records. + + Returns an array (list) of FailureCategoryData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM FailureCategories\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idFailureCategory ASC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM FailureCategories\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY idFailureCategory ASC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = [] + for aoRow in self._oDb.fetchAll(): + aoRows.append(FailureCategoryData().initFromDbRow(aoRow)) + return aoRows + + + def fetchForChangeLog(self, idFailureCategory, iStart, cMaxRows, tsNow): # pylint: disable=too-many-locals + """ + Fetches change log entries for a failure reason. + + Returns an array of ChangeLogEntry instance and an indicator whether + there are more entries. + Raises exception on error. + """ + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + + # 1. Get a list of the relevant changes. + self._oDb.execute('SELECT * FROM FailureCategories WHERE idFailureCategory = %s AND tsEffective <= %s\n' + 'ORDER BY tsEffective DESC\n' + 'LIMIT %s OFFSET %s\n' + , ( idFailureCategory, tsNow, cMaxRows + 1, iStart, )); + aoRows = []; + for aoChange in self._oDb.fetchAll(): + aoRows.append(FailureCategoryData().initFromDbRow(aoChange)); + + # 2. Calculate the changes. + aoEntries = []; + for i in xrange(0, len(aoRows) - 1): + oNew = aoRows[i]; + oOld = aoRows[i + 1]; + + aoChanges = []; + for sAttr in oNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', ]: + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr != oNewAttr: + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + + aoEntries.append(ChangeLogEntry(oNew.uidAuthor, None, oNew.tsEffective, oNew.tsExpire, oNew, oOld, aoChanges)); + + # If we're at the end of the log, add the initial entry. + if len(aoRows) <= cMaxRows and aoRows: + oNew = aoRows[-1]; + aoEntries.append(ChangeLogEntry(oNew.uidAuthor, None, oNew.tsEffective, oNew.tsExpire, oNew, None, [])); + + return (UserAccountLogic(self._oDb).resolveChangeLogAuthors(aoEntries), len(aoRows) > cMaxRows); + + + def getFailureCategoriesForCombo(self, tsEffective = None): + """ + Gets the list of Failure Categories for a combo box. + Returns an array of (value [idFailureCategory], drop-down-name [sShort], + hover-text [sFull]) tuples. + """ + if tsEffective is None: + self._oDb.execute('SELECT idFailureCategory, sShort, sFull\n' + 'FROM FailureCategories\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sShort') + else: + self._oDb.execute('SELECT idFailureCategory, sShort, sFull\n' + 'FROM FailureCategories\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sShort' + , (tsEffective, tsEffective)) + return self._oDb.fetchAll() + + + def getById(self, idFailureCategory): + """Get Failure Category data by idFailureCategory""" + + self._oDb.execute('SELECT *\n' + 'FROM FailureCategories\n' + 'WHERE tsExpire = \'infinity\'::timestamp\n' + ' AND idFailureCategory = %s;', (idFailureCategory,)) + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise self._oDb.integrityException( + 'Found more than one failure categories with the same credentials. Database structure is corrupted.') + try: + return FailureCategoryData().initFromDbRow(aRows[0]) + except IndexError: + return None + + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Add a failure reason category. + """ + # + # Validate inputs and read in the old(/current) data. + # + assert isinstance(oData, FailureCategoryData); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + + # + # Add the record. + # + self._readdEntry(uidAuthor, oData); + self._oDb.maybeCommit(fCommit); + return True; + + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Modifies a failure reason category. + """ + + # + # Validate inputs and read in the old(/current) data. + # + assert isinstance(oData, FailureCategoryData); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + + oOldData = FailureCategoryData().initFromDbWithId(self._oDb, oData.idFailureCategory); + + # + # Update the data that needs updating. + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', ]): + self._historizeEntry(oData.idFailureCategory); + self._readdEntry(uidAuthor, oData); + self._oDb.maybeCommit(fCommit); + return True; + + + def removeEntry(self, uidAuthor, idFailureCategory, fCascade = False, fCommit = False): + """ + Deletes a failure reason category. + """ + _ = fCascade; # too complicated for now. + + # + # Check whether it's being used by other tables and bitch if it is . + # We currently do not implement cascading. + # + self._oDb.execute('SELECT CONCAT(idFailureReason, \' - \', sShort)\n' + 'FROM FailureReasons\n' + 'WHERE idFailureCategory = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idFailureCategory,)); + aaoRows = self._oDb.fetchAll(); + if aaoRows: + raise TMRowInUse('Cannot remove failure reason category %u because its being used by: %s' + % (idFailureCategory, ', '.join(aoRow[0] for aoRow in aaoRows),)); + + # + # Do the job. + # + oData = FailureCategoryData().initFromDbWithId(self._oDb, idFailureCategory); + (tsCur, tsCurMinusOne) = self._oDb.getCurrentTimestamps(); + if oData.tsEffective not in (tsCur, tsCurMinusOne): + self._historizeEntry(idFailureCategory, tsCurMinusOne); + self._readdEntry(uidAuthor, oData, tsCurMinusOne); + self._historizeEntry(idFailureCategory); + self._oDb.maybeCommit(fCommit); + return True; + + + def cachedLookup(self, idFailureCategory): + """ + Looks up the most recent FailureCategoryData object for idFailureCategory + via an object cache. + + Returns a shared FailureCategoryData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('FailureCategory'); + + oEntry = self.dCache.get(idFailureCategory, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM FailureCategories\n' + 'WHERE idFailureCategory = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idFailureCategory, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM FailureCategories\n' + 'WHERE idFailureCategory = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idFailureCategory, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idFailureCategory)); + + if self._oDb.getRowCount() == 1: + oEntry = FailureCategoryData().initFromDbRow(self._oDb.fetchOne()); + self.dCache[idFailureCategory] = oEntry; + return oEntry; + + + # + # Helpers. + # + + def _readdEntry(self, uidAuthor, oData, tsEffective = None): + """ + Re-adds the FailureCategories entry. Used by addEntry, editEntry and removeEntry. + """ + if tsEffective is None: + tsEffective = self._oDb.getCurrentTimestamp(); + self._oDb.execute('INSERT INTO FailureCategories (\n' + ' uidAuthor,\n' + ' tsEffective,\n' + ' idFailureCategory,\n' + ' sShort,\n' + ' sFull)\n' + 'VALUES (%s, %s, ' + + ('DEFAULT' if oData.idFailureCategory is None else str(oData.idFailureCategory)) + + ', %s, %s)\n' + , ( uidAuthor, + tsEffective, + oData.sShort, + oData.sFull,) ); + return True; + + + def _historizeEntry(self, idFailureCategory, tsExpire = None): + """ Historizes the current entry. """ + if tsExpire is None: + tsExpire = self._oDb.getCurrentTimestamp(); + self._oDb.execute('UPDATE FailureCategories\n' + 'SET tsExpire = %s\n' + 'WHERE idFailureCategory = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (tsExpire, idFailureCategory,)); + return True; + diff --git a/src/VBox/ValidationKit/testmanager/core/failurereason.py b/src/VBox/ValidationKit/testmanager/core/failurereason.py new file mode 100755 index 00000000..0d9294b0 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/failurereason.py @@ -0,0 +1,580 @@ +# -*- coding: utf-8 -*- +# $Id: failurereason.py $ + +""" +Test Manager - Failure Reasons. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard Python imports. +import sys; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelLogicBase, TMRowNotFound, TMInvalidData, TMRowInUse, \ + AttributeChangeEntry, ChangeLogEntry; +from testmanager.core.useraccount import UserAccountLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + xrange = range; # pylint: disable=redefined-builtin,invalid-name + + +class FailureReasonData(ModelDataBase): + """ + Failure Reason Data. + """ + + ksIdAttr = 'idFailureReason'; + + ksParam_idFailureReason = 'FailureReasonData_idFailureReason' + ksParam_tsEffective = 'FailureReasonData_tsEffective' + ksParam_tsExpire = 'FailureReasonData_tsExpire' + ksParam_uidAuthor = 'FailureReasonData_uidAuthor' + ksParam_idFailureCategory = 'FailureReasonData_idFailureCategory' + ksParam_sShort = 'FailureReasonData_sShort' + ksParam_sFull = 'FailureReasonData_sFull' + ksParam_iTicket = 'FailureReasonData_iTicket' + ksParam_asUrls = 'FailureReasonData_asUrls' + + kasAllowNullAttributes = [ 'idFailureReason', 'tsEffective', 'tsExpire', + 'uidAuthor', 'iTicket', 'asUrls' ] + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + + self.idFailureReason = None + self.tsEffective = None + self.tsExpire = None + self.uidAuthor = None + self.idFailureCategory = None + self.sShort = None + self.sFull = None + self.iTicket = None + self.asUrls = None + + def initFromDbRow(self, aoRow): + """ + Re-initializes the data with a row from a SELECT * FROM FailureReasons. + + Returns self. Raises exception if the row is None or otherwise invalid. + """ + + if aoRow is None: + raise TMRowNotFound('Failure Reason not found.'); + + self.idFailureReason = aoRow[0] + self.tsEffective = aoRow[1] + self.tsExpire = aoRow[2] + self.uidAuthor = aoRow[3] + self.idFailureCategory = aoRow[4] + self.sShort = aoRow[5] + self.sFull = aoRow[6] + self.iTicket = aoRow[7] + self.asUrls = aoRow[8] + + return self; + + def initFromDbWithId(self, oDb, idFailureReason, tsNow = None, sPeriodBack = None): + """ + Initialize from the database, given the ID of a row. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM FailureReasons\n' + 'WHERE idFailureReason = %s\n' + , ( idFailureReason,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idFailureReason=%s not found (tsNow=%s sPeriodBack=%s)' + % (idFailureReason, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + +class FailureReasonDataEx(FailureReasonData): + """ + Failure Reason Data, extended version that includes the category. + """ + + def __init__(self): + FailureReasonData.__init__(self); + self.oCategory = None; + self.oAuthor = None; + + def initFromDbRowEx(self, aoRow, oCategoryLogic, oUserAccountLogic): + """ + Re-initializes the data with a row from a SELECT * FROM FailureReasons. + + Returns self. Raises exception if the row is None or otherwise invalid. + """ + + self.initFromDbRow(aoRow); + self.oCategory = oCategoryLogic.cachedLookup(self.idFailureCategory); + self.oAuthor = oUserAccountLogic.cachedLookup(self.uidAuthor); + + return self; + + +class FailureReasonLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Failure Reason logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + self.dCacheNameAndCat = None; + self.oCategoryLogic = None; + self.oUserAccountLogic = None; + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches Failure Category records. + + Returns an array (list) of FailureReasonDataEx items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + self._ensureCachesPresent(); + + if tsNow is None: + self._oDb.execute('SELECT FailureReasons.*,\n' + ' FailureCategories.sShort AS sCategory\n' + 'FROM FailureReasons,\n' + ' FailureCategories\n' + 'WHERE FailureReasons.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND FailureCategories.idFailureCategory = FailureReasons.idFailureCategory\n' + ' AND FailureCategories.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sCategory ASC, sShort ASC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT FailureReasons.*,\n' + ' FailureCategories.sShort AS sCategory\n' + 'FROM FailureReasons,\n' + ' FailureCategories\n' + 'WHERE FailureReasons.tsExpire > %s\n' + ' AND FailureReasons.tsEffective <= %s\n' + ' AND FailureCategories.idFailureCategory = FailureReasons.idFailureCategory\n' + ' AND FailureReasons.tsExpire > %s\n' + ' AND FailureReasons.tsEffective <= %s\n' + 'ORDER BY sCategory ASC, sShort ASC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = [] + for aoRow in self._oDb.fetchAll(): + aoRows.append(FailureReasonDataEx().initFromDbRowEx(aoRow, self.oCategoryLogic, self.oUserAccountLogic)); + return aoRows + + def fetchForListingInCategory(self, iStart, cMaxRows, tsNow, idFailureCategory, aiSortColumns = None): + """ + Fetches Failure Category records. + + Returns an array (list) of FailureReasonDataEx items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + self._ensureCachesPresent(); + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM FailureReasons\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND idFailureCategory = %s\n' + 'ORDER BY sShort ASC\n' + 'LIMIT %s OFFSET %s\n' + , ( idFailureCategory, cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM FailureReasons\n' + 'WHERE idFailureCategory = %s\n' + ' AND tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sShort ASC\n' + 'LIMIT %s OFFSET %s\n' + , ( idFailureCategory, tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = [] + for aoRow in self._oDb.fetchAll(): + aoRows.append(FailureReasonDataEx().initFromDbRowEx(aoRow, self.oCategoryLogic, self.oUserAccountLogic)); + return aoRows + + + def fetchForSheriffByNamedCategory(self, sFailureCategory): + """ + Fetches the short names of the reasons in the named category. + + Returns array of strings. + Raises exception on error. + """ + self._oDb.execute('SELECT FailureReasons.sShort\n' + 'FROM FailureReasons,\n' + ' FailureCategories\n' + 'WHERE FailureReasons.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND FailureReasons.idFailureCategory = FailureCategories.idFailureCategory\n' + ' AND FailureCategories.sShort = %s\n' + 'ORDER BY FailureReasons.sShort ASC\n' + , ( sFailureCategory,)); + return [aoRow[0] for aoRow in self._oDb.fetchAll()]; + + + def fetchForCombo(self, sFirstEntry = 'Select a failure reason', tsEffective = None): + """ + Gets the list of Failure Reasons for a combo box. + Returns an array of (value [idFailureReason], drop-down-name [sShort], + hover-text [sFull]) tuples. + """ + if tsEffective is None: + self._oDb.execute('SELECT fr.idFailureReason, CONCAT(fc.sShort, \' / \', fr.sShort) as sComboText, fr.sFull\n' + 'FROM FailureReasons fr,\n' + ' FailureCategories fc\n' + 'WHERE fr.idFailureCategory = fc.idFailureCategory\n' + ' AND fr.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND fc.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sComboText') + else: + self._oDb.execute('SELECT fr.idFailureReason, CONCAT(fc.sShort, \' / \', fr.sShort) as sComboText, fr.sFull\n' + 'FROM FailureReasons fr,\n' + ' FailureCategories fc\n' + 'WHERE fr.idFailureCategory = fc.idFailureCategory\n' + ' AND fr.tsExpire > %s\n' + ' AND fr.tsEffective <= %s\n' + ' AND fc.tsExpire > %s\n' + ' AND fc.tsEffective <= %s\n' + 'ORDER BY sComboText' + , (tsEffective, tsEffective, tsEffective, tsEffective)); + aoRows = self._oDb.fetchAll(); + return [(-1, sFirstEntry, '')] + aoRows; + + + def fetchForChangeLog(self, idFailureReason, iStart, cMaxRows, tsNow): # pylint: disable=too-many-locals + """ + Fetches change log entries for a failure reason. + + Returns an array of ChangeLogEntry instance and an indicator whether + there are more entries. + Raises exception on error. + """ + self._ensureCachesPresent(); + + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + + # 1. Get a list of the relevant changes. + self._oDb.execute('SELECT * FROM FailureReasons WHERE idFailureReason = %s AND tsEffective <= %s\n' + 'ORDER BY tsEffective DESC\n' + 'LIMIT %s OFFSET %s\n' + , ( idFailureReason, tsNow, cMaxRows + 1, iStart, )); + aoRows = []; + for aoChange in self._oDb.fetchAll(): + aoRows.append(FailureReasonData().initFromDbRow(aoChange)); + + # 2. Calculate the changes. + aoEntries = []; + for i in xrange(0, len(aoRows) - 1): + oNew = aoRows[i]; + oOld = aoRows[i + 1]; + + aoChanges = []; + for sAttr in oNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', ]: + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr != oNewAttr: + if sAttr == 'idFailureCategory': + oCat = self.oCategoryLogic.cachedLookup(oOldAttr); + if oCat is not None: + oOldAttr = '%s (%s)' % (oOldAttr, oCat.sShort, ); + oCat = self.oCategoryLogic.cachedLookup(oNewAttr); + if oCat is not None: + oNewAttr = '%s (%s)' % (oNewAttr, oCat.sShort, ); + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + + aoEntries.append(ChangeLogEntry(oNew.uidAuthor, None, oNew.tsEffective, oNew.tsExpire, oNew, oOld, aoChanges)); + + # If we're at the end of the log, add the initial entry. + if len(aoRows) <= cMaxRows and aoRows: + oNew = aoRows[-1]; + aoEntries.append(ChangeLogEntry(oNew.uidAuthor, None, oNew.tsEffective, oNew.tsExpire, oNew, None, [])); + + return (UserAccountLogic(self._oDb).resolveChangeLogAuthors(aoEntries), len(aoRows) > cMaxRows); + + + def getById(self, idFailureReason): + """Get Failure Reason data by idFailureReason""" + + self._oDb.execute('SELECT *\n' + 'FROM FailureReasons\n' + 'WHERE tsExpire = \'infinity\'::timestamp\n' + ' AND idFailureReason = %s;', (idFailureReason,)) + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise self._oDb.integrityException( + 'Found more than one failure reasons with the same credentials. Database structure is corrupted.') + try: + return FailureReasonData().initFromDbRow(aRows[0]) + except IndexError: + return None + + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Add a failure reason. + """ + # + # Validate. + # + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dErrors: + raise TMInvalidData('addEntry invalid input: %s' % (dErrors,)); + + # + # Add the record. + # + self._readdEntry(uidAuthor, oData); + self._oDb.maybeCommit(fCommit); + return True; + + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Modifies a failure reason. + """ + + # + # Validate inputs and read in the old(/current) data. + # + assert isinstance(oData, FailureReasonData); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + + oOldData = FailureReasonData().initFromDbWithId(self._oDb, oData.idFailureReason); + + # + # Update the data that needs updating. + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', ]): + self._historizeEntry(oData.idFailureReason); + self._readdEntry(uidAuthor, oData); + self._oDb.maybeCommit(fCommit); + return True; + + + def removeEntry(self, uidAuthor, idFailureReason, fCascade = False, fCommit = False): + """ + Deletes a failure reason. + """ + _ = fCascade; # too complicated for now. + + # + # Check whether it's being used by other tables and bitch if it is . + # We currently do not implement cascading. + # + self._oDb.execute('SELECT CONCAT(idBlacklisting, \' - blacklisting\')\n' + 'FROM BuildBlacklist\n' + 'WHERE idFailureReason = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + 'UNION\n' + 'SELECT CONCAT(idTestResult, \' - test result failure reason\')\n' + 'FROM TestResultFailures\n' + 'WHERE idFailureReason = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idFailureReason, idFailureReason,)); + aaoRows = self._oDb.fetchAll(); + if aaoRows: + raise TMRowInUse('Cannot remove failure reason %u because its being used by: %s' + % (idFailureReason, ', '.join(aoRow[0] for aoRow in aaoRows),)); + + # + # Do the job. + # + oData = FailureReasonData().initFromDbWithId(self._oDb, idFailureReason); + assert oData.idFailureReason == idFailureReason; + (tsCur, tsCurMinusOne) = self._oDb.getCurrentTimestamps(); + if oData.tsEffective not in (tsCur, tsCurMinusOne): + self._historizeEntry(idFailureReason, tsCurMinusOne); + self._readdEntry(uidAuthor, oData, tsCurMinusOne); + self._historizeEntry(idFailureReason); + self._oDb.maybeCommit(fCommit); + return True; + + + def cachedLookup(self, idFailureReason): + """ + Looks up the most recent FailureReasonDataEx object for idFailureReason + via an object cache. + + Returns a shared FailureReasonData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('FailureReasonDataEx'); + oEntry = self.dCache.get(idFailureReason, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM FailureReasons\n' + 'WHERE idFailureReason = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idFailureReason, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM FailureReasons\n' + 'WHERE idFailureReason = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idFailureReason, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idFailureReason)); + + if self._oDb.getRowCount() == 1: + self._ensureCachesPresent(); + oEntry = FailureReasonDataEx().initFromDbRowEx(self._oDb.fetchOne(), self.oCategoryLogic, + self.oUserAccountLogic); + self.dCache[idFailureReason] = oEntry; + return oEntry; + + + def cachedLookupByNameAndCategory(self, sName, sCategory): + """ + Looks up a failure reason by it's name and category. + + Should the request be ambigiuos, we'll return the oldest one. + + Returns a shared FailureReasonData object. None if not found. + Raises exception on DB error. + """ + if self.dCacheNameAndCat is None: + self.dCacheNameAndCat = self._oDb.getCache('FailureReasonDataEx-By-Name-And-Category'); + sKey = '%s:::%s' % (sName, sCategory,); + oEntry = self.dCacheNameAndCat.get(sKey, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM FailureReasons,\n' + ' FailureCategories\n' + 'WHERE FailureReasons.sShort = %s\n' + ' AND FailureReasons.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND FailureReasons.idFailureCategory = FailureCategories.idFailureCategory ' + ' AND FailureCategories.sShort = %s\n' + ' AND FailureCategories.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY FailureReasons.tsEffective\n' + , ( sName, sCategory)); + if self._oDb.getRowCount() == 0: + sLikeSucks = self._oDb.formatBindArgs( + 'SELECT *\n' + 'FROM FailureReasons,\n' + ' FailureCategories\n' + 'WHERE ( FailureReasons.sShort ILIKE @@@@@@@! %s !@@@@@@@\n' + ' OR FailureReasons.sFull ILIKE @@@@@@@! %s !@@@@@@@)\n' + ' AND FailureCategories.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND FailureReasons.idFailureCategory = FailureCategories.idFailureCategory\n' + ' AND ( FailureCategories.sShort = %s\n' + ' OR FailureCategories.sFull = %s)\n' + ' AND FailureReasons.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY FailureReasons.tsEffective\n' + , ( sName, sName, sCategory, sCategory )); + sLikeSucks = sLikeSucks.replace('LIKE @@@@@@@! \'', 'LIKE \'%').replace('\' !@@@@@@@', '%\''); + self._oDb.execute(sLikeSucks); + if self._oDb.getRowCount() > 0: + self._ensureCachesPresent(); + oEntry = FailureReasonDataEx().initFromDbRowEx(self._oDb.fetchOne(), self.oCategoryLogic, + self.oUserAccountLogic); + self.dCacheNameAndCat[sKey] = oEntry; + if sName != oEntry.sShort or sCategory != oEntry.oCategory.sShort: + sKey2 = '%s:::%s' % (oEntry.sShort, oEntry.oCategory.sShort,); + self.dCacheNameAndCat[sKey2] = oEntry; + return oEntry; + + + # + # Helpers. + # + + def _readdEntry(self, uidAuthor, oData, tsEffective = None): + """ + Re-adds the FailureReasons entry. Used by addEntry, editEntry and removeEntry. + """ + if tsEffective is None: + tsEffective = self._oDb.getCurrentTimestamp(); + self._oDb.execute('INSERT INTO FailureReasons (\n' + ' uidAuthor,\n' + ' tsEffective,\n' + ' idFailureReason,\n' + ' idFailureCategory,\n' + ' sShort,\n' + ' sFull,\n' + ' iTicket,\n' + ' asUrls)\n' + 'VALUES (%s, %s, ' + + ( 'DEFAULT' if oData.idFailureReason is None else str(oData.idFailureReason) ) + + ', %s, %s, %s, %s, %s)\n' + , ( uidAuthor, + tsEffective, + oData.idFailureCategory, + oData.sShort, + oData.sFull, + oData.iTicket, + oData.asUrls,) ); + return True; + + + def _historizeEntry(self, idFailureReason, tsExpire = None): + """ Historizes the current entry. """ + if tsExpire is None: + tsExpire = self._oDb.getCurrentTimestamp(); + self._oDb.execute('UPDATE FailureReasons\n' + 'SET tsExpire = %s\n' + 'WHERE idFailureReason = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (tsExpire, idFailureReason,)); + return True; + + + def _ensureCachesPresent(self): + """ Ensures we've got the cache references resolved. """ + if self.oCategoryLogic is None: + from testmanager.core.failurecategory import FailureCategoryLogic; + self.oCategoryLogic = FailureCategoryLogic(self._oDb); + if self.oUserAccountLogic is None: + self.oUserAccountLogic = UserAccountLogic(self._oDb); + return True; + diff --git a/src/VBox/ValidationKit/testmanager/core/globalresource.pgsql b/src/VBox/ValidationKit/testmanager/core/globalresource.pgsql new file mode 100644 index 00000000..44b5c473 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/globalresource.pgsql @@ -0,0 +1,118 @@ +-- $Id: globalresource.pgsql $ +--- @file +-- VBox Test Manager Database Stored Procedures. +-- + +-- +-- Copyright (C) 2006-2023 Oracle and/or its affiliates. +-- +-- This file is part of VirtualBox base platform packages, as +-- available from https://www.virtualbox.org. +-- +-- This program is free software; you can redistribute it and/or +-- modify it under the terms of the GNU General Public License +-- as published by the Free Software Foundation, in version 3 of the +-- License. +-- +-- 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, see . +-- +-- The contents of this file may alternatively be used under the terms +-- of the Common Development and Distribution License Version 1.0 +-- (CDDL), a copy of it is provided in the "COPYING.CDDL" file included +-- in the VirtualBox distribution, in which case the provisions of the +-- CDDL are applicable instead of those of the GPL. +-- +-- You may elect to license modified versions of this file under the +-- terms and conditions of either the GPL or the CDDL or both. +-- +-- SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +-- + +\set ON_ERROR_STOP 1 +\connect testmanager; + +-- Args: uidAuthor, sName, sDescription, fEnabled +CREATE OR REPLACE function add_globalresource(integer, text, text, bool) RETURNS integer AS $$ + DECLARE + _idGlobalRsrc integer; + _uidAuthor ALIAS FOR $1; + _sName ALIAS FOR $2; + _sDescription ALIAS FOR $3; + _fEnabled ALIAS FOR $4; + BEGIN + -- Check if Global Resource name is unique + IF EXISTS(SELECT * FROM GlobalResources + WHERE sName=_sName AND + tsExpire='infinity'::timestamp) THEN + RAISE EXCEPTION 'Duplicate Global Resource name'; + END IF; + INSERT INTO GlobalResources (uidAuthor, sName, sDescription, fEnabled) + VALUES (_uidAuthor, _sName, _sDescription, _fEnabled) RETURNING idGlobalRsrc INTO _idGlobalRsrc; + RETURN _idGlobalRsrc; + END; +$$ LANGUAGE plpgsql; + +-- Args: uidAuthor, idGlobalRsrc +CREATE OR REPLACE function del_globalresource(integer, integer) RETURNS VOID AS $$ + DECLARE + _uidAuthor ALIAS FOR $1; + _idGlobalRsrc ALIAS FOR $2; + BEGIN + + -- Check if record exist + IF NOT EXISTS(SELECT * FROM GlobalResources WHERE idGlobalRsrc=_idGlobalRsrc AND tsExpire='infinity'::timestamp) THEN + RAISE EXCEPTION 'Global resource (%) does not exist', _idGlobalRsrc; + END IF; + + -- Historize record: GlobalResources + UPDATE GlobalResources + SET tsExpire=CURRENT_TIMESTAMP, + uidAuthor=_uidAuthor + WHERE idGlobalRsrc=_idGlobalRsrc AND + tsExpire='infinity'::timestamp; + + + -- Delete record: GlobalResourceStatuses + DELETE FROM GlobalResourceStatuses WHERE idGlobalRsrc=_idGlobalRsrc; + + -- Historize record: TestCaseGlobalRsrcDeps + UPDATE TestCaseGlobalRsrcDeps + SET tsExpire=CURRENT_TIMESTAMP, + uidAuthor=_uidAuthor + WHERE idGlobalRsrc=_idGlobalRsrc AND + tsExpire='infinity'::timestamp; + + END; +$$ LANGUAGE plpgsql; + +-- Args: uidAuthor, idGlobalRsrc, sName, sDescription, fEnabled +CREATE OR REPLACE function update_globalresource(integer, integer, text, text, bool) RETURNS VOID AS $$ + DECLARE + _uidAuthor ALIAS FOR $1; + _idGlobalRsrc ALIAS FOR $2; + _sName ALIAS FOR $3; + _sDescription ALIAS FOR $4; + _fEnabled ALIAS FOR $5; + BEGIN + -- Hostorize record + UPDATE GlobalResources + SET tsExpire=CURRENT_TIMESTAMP + WHERE idGlobalRsrc=_idGlobalRsrc AND + tsExpire='infinity'::timestamp; + -- Check if Global Resource name is unique + IF EXISTS(SELECT * FROM GlobalResources + WHERE sName=_sName AND + tsExpire='infinity'::timestamp) THEN + RAISE EXCEPTION 'Duplicate Global Resource name'; + END IF; + -- Add new record + INSERT INTO GlobalResources(uidAuthor, idGlobalRsrc, sName, sDescription, fEnabled) + VALUES (_uidAuthor, _idGlobalRsrc, _sName, _sDescription, _fEnabled); + END; +$$ LANGUAGE plpgsql; diff --git a/src/VBox/ValidationKit/testmanager/core/globalresource.py b/src/VBox/ValidationKit/testmanager/core/globalresource.py new file mode 100755 index 00000000..bd6f0e9e --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/globalresource.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +# $Id: globalresource.py $ + +""" +Test Manager - Global Resources. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMRowNotFound; + + +class GlobalResourceData(ModelDataBase): + """ + Global resource data + """ + + ksIdAttr = 'idGlobalRsrc'; + + ksParam_idGlobalRsrc = 'GlobalResource_idGlobalRsrc' + ksParam_tsEffective = 'GlobalResource_tsEffective' + ksParam_tsExpire = 'GlobalResource_tsExpire' + ksParam_uidAuthor = 'GlobalResource_uidAuthor' + ksParam_sName = 'GlobalResource_sName' + ksParam_sDescription = 'GlobalResource_sDescription' + ksParam_fEnabled = 'GlobalResource_fEnabled' + + kasAllowNullAttributes = ['idGlobalRsrc', 'tsEffective', 'tsExpire', 'uidAuthor', 'sDescription' ]; + kcchMin_sName = 2; + kcchMax_sName = 64; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idGlobalRsrc = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.sName = None; + self.sDescription = None; + self.fEnabled = False + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM GlobalResources row. + Returns self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Global resource not found.') + + self.idGlobalRsrc = aoRow[0] + self.tsEffective = aoRow[1] + self.tsExpire = aoRow[2] + self.uidAuthor = aoRow[3] + self.sName = aoRow[4] + self.sDescription = aoRow[5] + self.fEnabled = aoRow[6] + return self + + def initFromDbWithId(self, oDb, idGlobalRsrc, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE idGlobalRsrc = %s\n' + , ( idGlobalRsrc,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idGlobalRsrc=%s not found (tsNow=%s sPeriodBack=%s)' % (idGlobalRsrc, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def isEqual(self, oOther): + """ + Compares two instances. + """ + return self.idGlobalRsrc == oOther.idGlobalRsrc \ + and str(self.tsEffective) == str(oOther.tsEffective) \ + and str(self.tsExpire) == str(oOther.tsExpire) \ + and self.uidAuthor == oOther.uidAuthor \ + and self.sName == oOther.sName \ + and self.sDescription == oOther.sDescription \ + and self.fEnabled == oOther.fEnabled + + +class GlobalResourceLogic(ModelLogicBase): + """ + Global resource logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Returns an array (list) of FailureReasonData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idGlobalRsrc DESC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY idGlobalRsrc DESC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)) + + aoRows = [] + for aoRow in self._oDb.fetchAll(): + aoRows.append(GlobalResourceData().initFromDbRow(aoRow)) + return aoRows + + + def cachedLookup(self, idGlobalRsrc): + """ + Looks up the most recent GlobalResourceData object for idGlobalRsrc + via an object cache. + + Returns a shared GlobalResourceData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('GlobalResourceData'); + oEntry = self.dCache.get(idGlobalRsrc, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE idGlobalRsrc = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idGlobalRsrc, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE idGlobalRsrc = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idGlobalRsrc, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idGlobalRsrc)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = GlobalResourceData(); + oEntry.initFromDbRow(aaoRow); + self.dCache[idGlobalRsrc] = oEntry; + return oEntry; + + + def getAll(self, tsEffective = None): + """ + Gets all global resources. + + Returns an array of GlobalResourceData instances on success (can be + empty). Raises exception on database error. + """ + if tsEffective is not None: + self._oDb.execute('SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + , (tsEffective, tsEffective)); + else: + self._oDb.execute('SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n'); + aaoRows = self._oDb.fetchAll(); + aoRet = []; + for aoRow in aaoRows: + aoRet.append(GlobalResourceData().initFromDbRow(aoRow)); + + return aoRet; + + def addGlobalResource(self, uidAuthor, oData): + """Add Global Resource DB record""" + self._oDb.execute('SELECT * FROM add_globalresource(%s, %s, %s, %s);', + (uidAuthor, + oData.sName, + oData.sDescription, + oData.fEnabled)) + self._oDb.commit() + return True + + def editGlobalResource(self, uidAuthor, idGlobalRsrc, oData): + """Modify Global Resource DB record""" + # Check if anything has been changed + oGlobalResourcesDataOld = self.getById(idGlobalRsrc) + if oGlobalResourcesDataOld.isEqual(oData): + # Nothing has been changed, do nothing + return True + + self._oDb.execute('SELECT * FROM update_globalresource(%s, %s, %s, %s, %s);', + (uidAuthor, + idGlobalRsrc, + oData.sName, + oData.sDescription, + oData.fEnabled)) + self._oDb.commit() + return True + + def remove(self, uidAuthor, idGlobalRsrc): + """Delete Global Resource DB record""" + self._oDb.execute('SELECT * FROM del_globalresource(%s, %s);', + (uidAuthor, idGlobalRsrc)) + self._oDb.commit() + return True + + def getById(self, idGlobalRsrc): + """ + Get global resource record by its id + """ + self._oDb.execute('SELECT *\n' + 'FROM GlobalResources\n' + 'WHERE tsExpire = \'infinity\'::timestamp\n' + ' AND idGlobalRsrc=%s;', (idGlobalRsrc,)) + + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise self._oDb.integrityException('Duplicate global resource entry with ID %u (current)' % (idGlobalRsrc,)); + try: + return GlobalResourceData().initFromDbRow(aRows[0]) + except IndexError: + raise TMRowNotFound('Global resource not found.') + + def allocateResources(self, idTestBox, aoGlobalRsrcs, fCommit = False): + """ + Allocates the given global resource. + + Returns True of successfully allocated the resources, False if not. + May raise exception on DB error. + """ + # Quit quickly if there is nothing to alloocate. + if not aoGlobalRsrcs: + return True; + + # + # Note! Someone else might have allocated the resources since the + # scheduler check that they were available. In such case we + # need too quietly rollback and return FALSE. + # + self._oDb.execute('SAVEPOINT allocateResources'); + + for oGlobalRsrc in aoGlobalRsrcs: + try: + self._oDb.execute('INSERT INTO GlobalResourceStatuses (idGlobalRsrc, idTestBox)\n' + 'VALUES (%s, %s)', (oGlobalRsrc.idGlobalRsrc, idTestBox, ) ); + except self._oDb.oXcptError: + self._oDb.execute('ROLLBACK TO SAVEPOINT allocateResources'); + return False; + + self._oDb.execute('RELEASE SAVEPOINT allocateResources'); + self._oDb.maybeCommit(fCommit); + return True; + + def freeGlobalResourcesByTestBox(self, idTestBox, fCommit = False): + """ + Frees all global resources own by the given testbox. + Returns True. May raise exception on DB error. + """ + self._oDb.execute('DELETE FROM GlobalResourceStatuses\n' + 'WHERE idTestBox = %s\n', (idTestBox, ) ); + self._oDb.maybeCommit(fCommit); + return True; + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class GlobalResourceDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [GlobalResourceData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/report.py b/src/VBox/ValidationKit/testmanager/core/report.py new file mode 100755 index 00000000..f07e75e8 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/report.py @@ -0,0 +1,1307 @@ +# -*- coding: utf-8 -*- +# $Id: report.py $ + +""" +Test Manager - Report models. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard Python imports. +import sys; + +# Validation Kit imports. +from testmanager.core.base import ModelLogicBase, TMExceptionBase; +from testmanager.core.build import BuildCategoryData; +from testmanager.core.dbobjcache import DatabaseObjCache; +from testmanager.core.failurereason import FailureReasonLogic; +from testmanager.core.testbox import TestBoxLogic, TestBoxData; +from testmanager.core.testcase import TestCaseLogic; +from testmanager.core.testcaseargs import TestCaseArgsLogic; +from testmanager.core.testresults import TestResultLogic, TestResultFilter; +from common import constants; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + xrange = range; # pylint: disable=redefined-builtin,invalid-name + + + +class ReportFilter(TestResultFilter): + """ + Same as TestResultFilter for now. + """ + + def __init__(self): + TestResultFilter.__init__(self); + + + +class ReportModelBase(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Something all report logic(/miner) classes inherit from. + """ + + ## @name Report subjects + ## @{ + ksSubEverything = 'Everything'; + ksSubSchedGroup = 'SchedGroup'; + ksSubTestGroup = 'TestGroup'; + ksSubTestCase = 'TestCase'; + ksSubTestCaseArgs = 'TestCaseArgs'; + ksSubTestBox = 'TestBox'; + ksSubBuild = 'Build'; + ## @} + kasSubjects = [ ksSubEverything, ksSubSchedGroup, ksSubTestGroup, ksSubTestCase, ksSubTestBox, ksSubBuild, ]; + + + ## @name TestStatus_T + # @{ + ksTestStatus_Running = 'running'; + ksTestStatus_Success = 'success'; + ksTestStatus_Skipped = 'skipped'; + ksTestStatus_BadTestBox = 'bad-testbox'; + ksTestStatus_Aborted = 'aborted'; + ksTestStatus_Failure = 'failure'; + ksTestStatus_TimedOut = 'timed-out'; + ksTestStatus_Rebooted = 'rebooted'; + ## @} + + + def __init__(self, oDb, tsNow, cPeriods, cHoursPerPeriod, sSubject, aidSubjects, oFilter): + ModelLogicBase.__init__(self, oDb); + # Public so the report generator can easily access them. + self.tsNow = tsNow; # (Can be None.) + self.__tsNowDateTime = None; + self.cPeriods = cPeriods; + self.cHoursPerPeriod = cHoursPerPeriod; + self.sSubject = sSubject; + self.aidSubjects = aidSubjects; + self.oFilter = oFilter; + if self.oFilter is None: + class DummyFilter(object): + """ Dummy """ + def getTableJoins(self, sExtraIndent = '', iOmit = -1, dOmitTables = None): + """ Dummy """ + _ = sExtraIndent; _ = iOmit; _ = dOmitTables; # pylint: disable=redefined-variable-type + return ''; + def getWhereConditions(self, sExtraIndent = '', iOmit = -1): + """ Dummy """ + _ = sExtraIndent; _ = iOmit; # pylint: disable=redefined-variable-type + return ''; + def isJoiningWithTable(self, sTable): + """ Dummy """; + _ = sTable; + return False; + self.oFilter = DummyFilter(); + + def getExtraSubjectTables(self): + """ + Returns a list of additional tables needed by the subject. + """ + return []; + + def getExtraSubjectWhereExpr(self): + """ + Returns additional WHERE expression relating to the report subject. It starts + with an AND so that it can simply be appended to the WHERE clause. + """ + if self.sSubject == self.ksSubEverything: + return ''; + + if self.sSubject == self.ksSubSchedGroup: + sWhere = ' AND TestSets.idSchedGroup'; + elif self.sSubject == self.ksSubTestGroup: + sWhere = ' AND TestSets.idTestGroup'; + elif self.sSubject == self.ksSubTestCase: + sWhere = ' AND TestSets.idTestCase'; + elif self.sSubject == self.ksSubTestCaseArgs: + sWhere = ' AND TestSets.idTestCaseArgs'; + elif self.sSubject == self.ksSubTestBox: + sWhere = ' AND TestSets.idTestBox'; + elif self.sSubject == self.ksSubBuild: + sWhere = ' AND TestSets.idBuild'; + else: + raise TMExceptionBase(self.sSubject); + + if len(self.aidSubjects) == 1: + sWhere += self._oDb.formatBindArgs(' = %s\n', (self.aidSubjects[0],)); + else: + assert self.aidSubjects; + sWhere += self._oDb.formatBindArgs(' IN (%s', (self.aidSubjects[0],)); + for i in range(1, len(self.aidSubjects)): + sWhere += self._oDb.formatBindArgs(', %s', (self.aidSubjects[i],)); + sWhere += ')\n'; + + return sWhere; + + def getNowAsDateTime(self): + """ Returns a datetime instance corresponding to tsNow. """ + if self.__tsNowDateTime is None: + if self.tsNow is None: + self.__tsNowDateTime = self._oDb.getCurrentTimestamp(); + else: + self._oDb.execute('SELECT %s::TIMESTAMP WITH TIME ZONE', (self.tsNow,)); + self.__tsNowDateTime = self._oDb.fetchOne()[0]; + return self.__tsNowDateTime; + + def getPeriodStart(self, iPeriod): + """ Gets the python timestamp for the start of the given period. """ + from datetime import timedelta; + cHoursStart = (self.cPeriods - iPeriod ) * self.cHoursPerPeriod; + return self.getNowAsDateTime() - timedelta(hours = cHoursStart); + + def getPeriodEnd(self, iPeriod): + """ Gets the python timestamp for the end of the given period. """ + from datetime import timedelta; + cHoursEnd = (self.cPeriods - iPeriod - 1) * self.cHoursPerPeriod; + return self.getNowAsDateTime() - timedelta(hours = cHoursEnd); + + def getExtraWhereExprForPeriod(self, iPeriod): + """ + Returns additional WHERE expression for getting test sets for the + specified period. It starts with an AND so that it can simply be + appended to the WHERE clause. + """ + if self.tsNow is None: + sNow = 'CURRENT_TIMESTAMP'; + else: + sNow = self._oDb.formatBindArgs('%s::TIMESTAMP', (self.tsNow,)); + + cHoursStart = (self.cPeriods - iPeriod ) * self.cHoursPerPeriod; + cHoursEnd = (self.cPeriods - iPeriod - 1) * self.cHoursPerPeriod; + if cHoursEnd == 0: + return ' AND TestSets.tsDone >= (%s - interval \'%u hours\')\n' \ + ' AND TestSets.tsDone < %s\n' \ + % (sNow, cHoursStart, sNow); + return ' AND TestSets.tsDone >= (%s - interval \'%u hours\')\n' \ + ' AND TestSets.tsDone < (%s - interval \'%u hours\')\n' \ + % (sNow, cHoursStart, sNow, cHoursEnd); + + def getPeriodDesc(self, iPeriod): + """ + Returns the period description, usually for graph data. + """ + if iPeriod == 0: + return 'now' if self.tsNow is None else 'then'; + sTerm = 'ago' if self.tsNow is None else 'earlier'; + if self.cHoursPerPeriod == 24: + return '%dd %s' % (iPeriod, sTerm, ); + if (iPeriod * self.cHoursPerPeriod) % 24 == 0: + return '%dd %s' % (iPeriod * self.cHoursPerPeriod / 24, sTerm, ); + return '%dh %s' % (iPeriod * self.cHoursPerPeriod, sTerm); + + def getStraightPeriodDesc(self, iPeriod): + """ + Returns the period description, usually for graph data. + """ + iWickedPeriod = self.cPeriods - iPeriod - 1; + return self.getPeriodDesc(iWickedPeriod); + + +# +# Data structures produced and returned by the ReportLazyModel. +# + +class ReportTransientBase(object): + """ Details on the test where a problem was first/last seen. """ + def __init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone, # pylint: disable=too-many-arguments + iPeriod, fEnter, idSubject, oSubject): + self.idBuild = idBuild; # Build ID. + self.iRevision = iRevision; # SVN revision for build. + self.sRepository = sRepository; # SVN repository for build. + self.idTestSet = idTestSet; # Test set. + self.idTestResult = idTestResult; # Test result. + self.tsDone = tsDone; # When the test set was done. + self.iPeriod = iPeriod; # Data set period. + self.fEnter = fEnter; # True if enter event, False if leave event. + self.idSubject = idSubject; + self.oSubject = oSubject; + +class ReportFailureReasonTransient(ReportTransientBase): + """ Details on the test where a failure reason was first/last seen. """ + def __init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone, # pylint: disable=too-many-arguments + iPeriod, fEnter, oReason): + ReportTransientBase.__init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone, iPeriod, fEnter, + oReason.idFailureReason, oReason); + self.oReason = oReason; # FailureReasonDataEx + + +class ReportHitRowBase(object): + """ A row in a period. """ + def __init__(self, idSubject, oSubject, cHits, tsMin = None, tsMax = None): + self.idSubject = idSubject; + self.oSubject = oSubject; + self.cHits = cHits; + self.tsMin = tsMin; + self.tsMax = tsMax; + +class ReportHitRowWithTotalBase(ReportHitRowBase): + """ A row in a period. """ + def __init__(self, idSubject, oSubject, cHits, cTotal, tsMin = None, tsMax = None): + ReportHitRowBase.__init__(self, idSubject, oSubject, cHits, tsMin, tsMax) + self.cTotal = cTotal; + self.uPct = cHits * 100 / cTotal; + +class ReportFailureReasonRow(ReportHitRowBase): + """ The account of one failure reason for a period. """ + def __init__(self, aoRow, oReason): + ReportHitRowBase.__init__(self, aoRow[0], oReason, aoRow[1], aoRow[2], aoRow[3]); + self.idFailureReason = aoRow[0]; + self.oReason = oReason; # FailureReasonDataEx + + +class ReportPeriodBase(object): + """ A period in ReportFailureReasonSet. """ + def __init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo): + self.oSet = oSet # Reference to the parent ReportSetBase derived object. + self.iPeriod = iPeriod; # Period number in the set. + self.sDesc = sDesc; # Short period description. + self.tsStart = tsFrom; # Start of the period. + self.tsEnd = tsTo; # End of the period (exclusive). + self.tsMin = tsTo; # The earlierst hit of the period (only valid for cHits > 0). + self.tsMax = tsFrom; # The latest hit of the period (only valid for cHits > 0). + self.aoRows = []; # Rows in order the database returned them (ReportHitRowBase descendant). + self.dRowsById = {}; # Same as aoRows but indexed by object ID (see ReportSetBase::sIdAttr). + self.dFirst = {}; # The subjects seen for the first time - data object, keyed by ID. + self.dLast = {}; # The subjects seen for the last time - data object, keyed by ID. + self.cHits = 0; # Total number of hits in this period. + self.cMaxHits = 0; # Max hits in a row. + self.cMinHits = 99999999; # Min hits in a row (only valid for cHits > 0). + + def appendRow(self, oRow, idRow, oData): + """ Adds a row. """ + assert isinstance(oRow, ReportHitRowBase); + self.aoRows.append(oRow); + self.dRowsById[idRow] = oRow; + if idRow not in self.oSet.dSubjects: + self.oSet.dSubjects[idRow] = oData; + self._doStatsForRow(oRow, idRow, oData); + + def _doStatsForRow(self, oRow, idRow, oData): + """ Does the statistics for a row. Helper for appendRow as well as helpRecalcStats. """ + if oRow.tsMin is not None and oRow.tsMin < self.tsMin: + self.tsMin = oRow.tsMin; + if oRow.tsMax is not None and oRow.tsMax < self.tsMax: + self.tsMax = oRow.tsMax; + + self.cHits += oRow.cHits; + if oRow.cHits > self.cMaxHits: + self.cMaxHits = oRow.cHits; + if oRow.cHits < self.cMinHits: + self.cMinHits = oRow.cHits; + + if idRow in self.oSet.dcHitsPerId: + self.oSet.dcHitsPerId[idRow] += oRow.cHits; + else: + self.oSet.dcHitsPerId[idRow] = oRow.cHits; + + if oRow.cHits > 0: + if idRow not in self.oSet.diPeriodFirst: + self.dFirst[idRow] = oData; + self.oSet.diPeriodFirst[idRow] = self.iPeriod; + self.oSet.diPeriodLast[idRow] = self.iPeriod; + + def helperSetRecalcStats(self): + """ Recalc the statistics (do resetStats first on set). """ + for idRow, oRow in self.dRowsById.items(): + self._doStatsForRow(oRow, idRow, self.oSet.dSubjects[idRow]); + + def helperSetResetStats(self): + """ Resets the statistics. """ + self.tsMin = self.tsEnd; + self.tsMax = self.tsStart; + self.cHits = 0; + self.cMaxHits = 0; + self.cMinHits = 99999999; + self.dFirst = {}; + self.dLast = {}; + + def helperSetDeleteKeyFromSet(self, idKey): + """ Helper for ReportPeriodSetBase::deleteKey """ + if idKey in self.dRowsById: + oRow = self.dRowsById[idKey]; + self.aoRows.remove(oRow); + del self.dRowsById[idKey] + self.cHits -= oRow.cHits; + if idKey in self.dFirst: + del self.dFirst[idKey]; + if idKey in self.dLast: + del self.dLast[idKey]; + +class ReportPeriodWithTotalBase(ReportPeriodBase): + """ In addition to the cHits, we also have a total to relate it too. """ + def __init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo): + ReportPeriodBase.__init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo); + self.cTotal = 0; + self.cMaxTotal = 0; + self.cMinTotal = 99999999; + self.uMaxPct = 0; # Max percentage in a row (100 = 100%). + + def _doStatsForRow(self, oRow, idRow, oData): + assert isinstance(oRow, ReportHitRowWithTotalBase); + super(ReportPeriodWithTotalBase, self)._doStatsForRow(oRow, idRow, oData); + self.cTotal += oRow.cTotal; + if oRow.cTotal > self.cMaxTotal: + self.cMaxTotal = oRow.cTotal; + if oRow.cTotal < self.cMinTotal: + self.cMinTotal = oRow.cTotal; + + if oRow.uPct > self.uMaxPct: + self.uMaxPct = oRow.uPct; + + if idRow in self.oSet.dcTotalPerId: + self.oSet.dcTotalPerId[idRow] += oRow.cTotal; + else: + self.oSet.dcTotalPerId[idRow] = oRow.cTotal; + + def helperSetResetStats(self): + super(ReportPeriodWithTotalBase, self).helperSetResetStats(); + self.cTotal = 0; + self.cMaxTotal = 0; + self.cMinTotal = 99999999; + self.uMaxPct = 0; + +class ReportFailureReasonPeriod(ReportPeriodBase): + """ A period in ReportFailureReasonSet. """ + def __init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo): + ReportPeriodBase.__init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo); + self.cWithoutReason = 0; # Number of failed test sets without any assigned reason. + + + +class ReportPeriodSetBase(object): + """ Period data set base class. """ + def __init__(self, sIdAttr): + self.sIdAttr = sIdAttr; # The name of the key attribute. Mainly for documentation purposes. + self.aoPeriods = []; # Periods (ReportPeriodBase descendant) in ascending order (time wise). + self.dSubjects = {}; # The subject data objects, keyed by the subject ID. + self.dcHitsPerId = {}; # Sum hits per subject ID (key). + self.cHits = 0; # Sum number of hits in all periods and all reasons. + self.cMaxHits = 0; # Max hits in a row. + self.cMinHits = 99999999; # Min hits in a row. + self.cMaxRows = 0; # Max number of rows in a period. + self.cMinRows = 99999999; # Min number of rows in a period. + self.diPeriodFirst = {}; # The period number a reason was first seen (keyed by subject ID). + self.diPeriodLast = {}; # The period number a reason was last seen (keyed by subject ID). + self.aoEnterInfo = []; # Array of ReportTransientBase children order by iRevision. Excludes + # the first period of course. (Child class populates this.) + self.aoLeaveInfo = []; # Array of ReportTransientBase children order in descending order by + # iRevision. Excludes the last priod. (Child class populates this.) + + def appendPeriod(self, oPeriod): + """ Appends a period to the set. """ + assert isinstance(oPeriod, ReportPeriodBase); + self.aoPeriods.append(oPeriod); + self._doStatsForPeriod(oPeriod); + + def _doStatsForPeriod(self, oPeriod): + """ Worker for appendPeriod and recalcStats. """ + self.cHits += oPeriod.cHits; + if oPeriod.cMaxHits > self.cMaxHits: + self.cMaxHits = oPeriod.cMaxHits; + if oPeriod.cMinHits < self.cMinHits: + self.cMinHits = oPeriod.cMinHits; + + if len(oPeriod.aoRows) > self.cMaxRows: + self.cMaxRows = len(oPeriod.aoRows); + if len(oPeriod.aoRows) < self.cMinRows: + self.cMinRows = len(oPeriod.aoRows); + + def recalcStats(self): + """ Recalculates the statistics. ASSUMES finalizePass1 hasn't been done yet. """ + self.cHits = 0; + self.cMaxHits = 0; + self.cMinHits = 99999999; + self.cMaxRows = 0; + self.cMinRows = 99999999; + self.diPeriodFirst = {}; + self.diPeriodLast = {}; + self.dcHitsPerId = {}; + for oPeriod in self.aoPeriods: + oPeriod.helperSetResetStats(); + + for oPeriod in self.aoPeriods: + oPeriod.helperSetRecalcStats(); + self._doStatsForPeriod(oPeriod); + + def deleteKey(self, idKey): + """ Deletes a key from the set. May leave cMaxHits and cMinHits with outdated values. """ + self.cHits -= self.dcHitsPerId[idKey]; + del self.dcHitsPerId[idKey]; + if idKey in self.diPeriodFirst: + del self.diPeriodFirst[idKey]; + if idKey in self.diPeriodLast: + del self.diPeriodLast[idKey]; + if idKey in self.aoEnterInfo: + del self.aoEnterInfo[idKey]; + if idKey in self.aoLeaveInfo: + del self.aoLeaveInfo[idKey]; + del self.dSubjects[idKey]; + for oPeriod in self.aoPeriods: + oPeriod.helperSetDeleteKeyFromSet(idKey); + + def pruneRowsWithZeroSumHits(self): + """ Discards rows with zero sum hits across all periods. Works around lazy selects counting both totals and hits. """ + cDeleted = 0; + aidKeys = list(self.dcHitsPerId); + for idKey in aidKeys: + if self.dcHitsPerId[idKey] == 0: + self.deleteKey(idKey); + cDeleted += 1; + if cDeleted > 0: + self.recalcStats(); + return cDeleted; + + def finalizePass1(self): + """ Finished all but aoEnterInfo and aoLeaveInfo. """ + # All we need to do here is to populate the dLast members. + for idKey, iPeriod in self.diPeriodLast.items(): + self.aoPeriods[iPeriod].dLast[idKey] = self.dSubjects[idKey]; + return self; + + def finalizePass2(self): + """ Called after aoEnterInfo and aoLeaveInfo has been populated to sort them. """ + self.aoEnterInfo = sorted(self.aoEnterInfo, key = lambda oTrans: oTrans.iRevision); + self.aoLeaveInfo = sorted(self.aoLeaveInfo, key = lambda oTrans: oTrans.iRevision, reverse = True); + return self; + +class ReportPeriodSetWithTotalBase(ReportPeriodSetBase): + """ In addition to the cHits, we also have a total to relate it too. """ + def __init__(self, sIdAttr): + ReportPeriodSetBase.__init__(self, sIdAttr); + self.dcTotalPerId = {}; # Sum total per subject ID (key). + self.cTotal = 0; # Sum number of total in all periods and all reasons. + self.cMaxTotal = 0; # Max total in a row. + self.cMinTotal = 0; # Min total in a row. + self.uMaxPct = 0; # Max percentage in a row (100 = 100%). + + def _doStatsForPeriod(self, oPeriod): + assert isinstance(oPeriod, ReportPeriodWithTotalBase); + super(ReportPeriodSetWithTotalBase, self)._doStatsForPeriod(oPeriod); + self.cTotal += oPeriod.cTotal; + if oPeriod.cMaxTotal > self.cMaxTotal: + self.cMaxTotal = oPeriod.cMaxTotal; + if oPeriod.cMinTotal < self.cMinTotal: + self.cMinTotal = oPeriod.cMinTotal; + + if oPeriod.uMaxPct > self.uMaxPct: + self.uMaxPct = oPeriod.uMaxPct; + + def recalcStats(self): + self.dcTotalPerId = {}; + self.cTotal = 0; + self.cMaxTotal = 0; + self.cMinTotal = 0; + self.uMaxPct = 0; + super(ReportPeriodSetWithTotalBase, self).recalcStats(); + + def deleteKey(self, idKey): + self.cTotal -= self.dcTotalPerId[idKey]; + del self.dcTotalPerId[idKey]; + super(ReportPeriodSetWithTotalBase, self).deleteKey(idKey); + +class ReportFailureReasonSet(ReportPeriodSetBase): + """ What ReportLazyModel.getFailureReasons returns. """ + def __init__(self): + ReportPeriodSetBase.__init__(self, 'idFailureReason'); + + + +class ReportLazyModel(ReportModelBase): # pylint: disable=too-few-public-methods + """ + The 'lazy bird' report model class. + + We may want to have several classes, maybe one for each report even. But, + I'm thinking that's a bit overkill so we'll start with this and split it + if/when it becomes necessary. + """ + + kdsStatusSimplificationMap = { + ReportModelBase.ksTestStatus_Running: ReportModelBase.ksTestStatus_Running, + ReportModelBase.ksTestStatus_Success: ReportModelBase.ksTestStatus_Success, + ReportModelBase.ksTestStatus_Skipped: ReportModelBase.ksTestStatus_Skipped, + ReportModelBase.ksTestStatus_BadTestBox: ReportModelBase.ksTestStatus_Skipped, + ReportModelBase.ksTestStatus_Aborted: ReportModelBase.ksTestStatus_Skipped, + ReportModelBase.ksTestStatus_Failure: ReportModelBase.ksTestStatus_Failure, + ReportModelBase.ksTestStatus_TimedOut: ReportModelBase.ksTestStatus_Failure, + ReportModelBase.ksTestStatus_Rebooted: ReportModelBase.ksTestStatus_Failure, + }; + + def getSuccessRates(self): + """ + Gets the success rates of the subject in the specified period. + + Returns an array of data per period (0 is the oldes, self.cPeriods-1 is + the latest) where each entry is a status (TestStatus_T) dictionary with + the number of occurences of each final status (i.e. not running). + """ + + sBaseQuery = 'SELECT TestSets.enmStatus, COUNT(TestSets.idTestSet)\n' \ + 'FROM TestSets\n' \ + + self.oFilter.getTableJoins(); + for sTable in self.getExtraSubjectTables(): + sBaseQuery = sBaseQuery[:-1] + ',\n ' + sTable + '\n'; + sBaseQuery += 'WHERE enmStatus <> \'running\'\n' \ + + self.oFilter.getWhereConditions() \ + + self.getExtraSubjectWhereExpr(); + + adPeriods = []; + for iPeriod in xrange(self.cPeriods): + self._oDb.execute(sBaseQuery + self.getExtraWhereExprForPeriod(iPeriod) + 'GROUP BY enmStatus\n'); + + dRet = \ + { + self.ksTestStatus_Skipped: 0, + self.ksTestStatus_Failure: 0, + self.ksTestStatus_Success: 0, + }; + + for aoRow in self._oDb.fetchAll(): + sKey = self.kdsStatusSimplificationMap[aoRow[0]] + if sKey in dRet: + dRet[sKey] += aoRow[1]; + else: + dRet[sKey] = aoRow[1]; + + assert len(dRet) == 3; + + adPeriods.insert(0, dRet); + + return adPeriods; + + + def getFailureReasons(self): + """ + Gets the failure reasons of the subject in the specified period. + + Returns a ReportFailureReasonSet instance. + """ + + oFailureReasonLogic = FailureReasonLogic(self._oDb); + + # + # Create a temporary table + # + sTsNow = 'CURRENT_TIMESTAMP' if self.tsNow is None else self._oDb.formatBindArgs('%s::TIMESTAMP', (self.tsNow,)); + sTsFirst = '(%s - interval \'%s hours\')' \ + % (sTsNow, self.cHoursPerPeriod * self.cPeriods,); + sQuery = 'CREATE TEMPORARY TABLE TmpReasons ON COMMIT DROP AS\n' \ + 'SELECT TestResultFailures.idFailureReason AS idFailureReason,\n' \ + ' TestResultFailures.idTestResult AS idTestResult,\n' \ + ' TestSets.idTestSet AS idTestSet,\n' \ + ' TestSets.tsDone AS tsDone,\n' \ + ' TestSets.tsCreated AS tsCreated,\n' \ + ' TestSets.idBuild AS idBuild\n' \ + 'FROM TestResultFailures,\n' \ + ' TestResults,\n' \ + ' TestSets\n' \ + + self.oFilter.getTableJoins(dOmitTables = {'TestResults': True, 'TestResultFailures': True}); + for sTable in self.getExtraSubjectTables(): + if sTable not in [ 'TestResults', 'TestResultFailures' ] and not self.oFilter.isJoiningWithTable(sTable): + sQuery = sQuery[:-1] + ',\n ' + sTable + '\n'; + sQuery += 'WHERE TestResultFailures.idTestResult = TestResults.idTestResult\n' \ + ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n' \ + ' AND TestResultFailures.tsEffective >= ' + sTsFirst + '\n' \ + ' AND TestResults.enmStatus <> \'running\'\n' \ + ' AND TestResults.enmStatus <> \'success\'\n' \ + ' AND TestResults.tsCreated >= ' + sTsFirst + '\n' \ + ' AND TestResults.tsCreated < ' + sTsNow + '\n' \ + ' AND TestResults.idTestSet = TestSets.idTestSet\n' \ + ' AND TestSets.tsDone >= ' + sTsFirst + '\n' \ + ' AND TestSets.tsDone < ' + sTsNow + '\n' \ + + self.oFilter.getWhereConditions() \ + + self.getExtraSubjectWhereExpr(); + self._oDb.execute(sQuery); + self._oDb.execute('SELECT idFailureReason FROM TmpReasons;'); + + # + # Retrieve the period results. + # + oSet = ReportFailureReasonSet(); + for iPeriod in xrange(self.cPeriods): + self._oDb.execute('SELECT idFailureReason,\n' + ' COUNT(idTestResult),\n' + ' MIN(tsDone),\n' + ' MAX(tsDone)\n' + 'FROM TmpReasons\n' + 'WHERE TRUE\n' + + self.getExtraWhereExprForPeriod(iPeriod).replace('TestSets.', '') + + 'GROUP BY idFailureReason\n'); + aaoRows = self._oDb.fetchAll() + + oPeriod = ReportFailureReasonPeriod(oSet, iPeriod, self.getStraightPeriodDesc(iPeriod), + self.getPeriodStart(iPeriod), self.getPeriodEnd(iPeriod)); + + for aoRow in aaoRows: + oReason = oFailureReasonLogic.cachedLookup(aoRow[0]); + oPeriodRow = ReportFailureReasonRow(aoRow, oReason); + oPeriod.appendRow(oPeriodRow, oReason.idFailureReason, oReason); + + # Count how many test sets we've got without any reason associated with them. + self._oDb.execute('SELECT COUNT(TestSets.idTestSet)\n' + 'FROM TestSets\n' + ' LEFT OUTER JOIN TestResultFailures\n' + ' ON TestSets.idTestSet = TestResultFailures.idTestSet\n' + ' AND TestResultFailures.tsEffective = \'infinity\'::TIMESTAMP\n' + 'WHERE TestSets.enmStatus <> \'running\'\n' + ' AND TestSets.enmStatus <> \'success\'\n' + + self.getExtraWhereExprForPeriod(iPeriod) + + ' AND TestResultFailures.idTestSet IS NULL\n'); + oPeriod.cWithoutReason = self._oDb.fetchOne()[0]; + + oSet.appendPeriod(oPeriod); + + + # + # For reasons entering after the first period, look up the build and + # test set it first occured with. + # + oSet.finalizePass1(); + + for iPeriod in xrange(1, self.cPeriods): + oPeriod = oSet.aoPeriods[iPeriod]; + for oReason in oPeriod.dFirst.values(): + oSet.aoEnterInfo.append(self._getEdgeFailureReasonOccurence(oReason, iPeriod, fEnter = True)); + + # Ditto for reasons leaving before the last. + for iPeriod in xrange(self.cPeriods - 1): + oPeriod = oSet.aoPeriods[iPeriod]; + for oReason in oPeriod.dLast.values(): + oSet.aoLeaveInfo.append(self._getEdgeFailureReasonOccurence(oReason, iPeriod, fEnter = False)); + + oSet.finalizePass2(); + + self._oDb.execute('DROP TABLE TmpReasons\n'); + return oSet; + + + def _getEdgeFailureReasonOccurence(self, oReason, iPeriod, fEnter = True): + """ + Helper for the failure reason report that finds the oldest or newest build + (SVN rev) and test set (start time) it occured with. + + If fEnter is set the oldest occurence is return, if fEnter clear the newest + is is returned. + + Returns ReportFailureReasonTransient instant. + + """ + + + sSorting = 'ASC' if fEnter else 'DESC'; + self._oDb.execute('SELECT TmpReasons.idTestResult,\n' + ' TmpReasons.idTestSet,\n' + ' TmpReasons.tsDone,\n' + ' TmpReasons.idBuild,\n' + ' Builds.iRevision,\n' + ' BuildCategories.sRepository\n' + 'FROM TmpReasons,\n' + ' Builds,\n' + ' BuildCategories\n' + 'WHERE TmpReasons.idFailureReason = %s\n' + ' AND TmpReasons.idBuild = Builds.idBuild\n' + ' AND Builds.tsExpire > TmpReasons.tsCreated\n' + ' AND Builds.tsEffective <= TmpReasons.tsCreated\n' + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + 'ORDER BY Builds.iRevision ' + sSorting + ',\n' + ' TmpReasons.tsCreated ' + sSorting + '\n' + 'LIMIT 1\n' + , ( oReason.idFailureReason, )); + aoRow = self._oDb.fetchOne(); + if aoRow is None: + return ReportFailureReasonTransient(-1, -1, 'internal-error', -1, -1, self._oDb.getCurrentTimestamp(), + iPeriod, fEnter, oReason); + return ReportFailureReasonTransient(idBuild = aoRow[3], iRevision = aoRow[4], sRepository = aoRow[5], + idTestSet = aoRow[1], idTestResult = aoRow[0], tsDone = aoRow[2], + iPeriod = iPeriod, fEnter = fEnter, oReason = oReason); + + + def getTestCaseFailures(self): + """ + Gets the test case failures of the subject in the specified period. + + Returns a ReportPeriodSetWithTotalBase instance. + + """ + return self._getSimpleFailures('idTestCase', TestCaseLogic); + + + def getTestCaseVariationFailures(self): + """ + Gets the test case failures of the subject in the specified period. + + Returns a ReportPeriodSetWithTotalBase instance. + + """ + return self._getSimpleFailures('idTestCaseArgs', TestCaseArgsLogic); + + + def getTestBoxFailures(self): + """ + Gets the test box failures of the subject in the specified period. + + Returns a ReportPeriodSetWithTotalBase instance. + + """ + return self._getSimpleFailures('idTestBox', TestBoxLogic); + + + def _getSimpleFailures(self, sIdColumn, oCacheLogicType, sIdAttr = None): + """ + Gets the test box failures of the subject in the specified period. + + Returns a ReportPeriodSetWithTotalBase instance. + + """ + + oLogic = oCacheLogicType(self._oDb); + oSet = ReportPeriodSetWithTotalBase(sIdColumn if sIdAttr is None else sIdAttr); + + # Construct base query. + sBaseQuery = 'SELECT TestSets.' + sIdColumn + ',\n' \ + ' COUNT(CASE WHEN TestSets.enmStatus >= \'failure\' THEN 1 END),\n' \ + ' MIN(TestSets.tsDone),\n' \ + ' MAX(TestSets.tsDone),\n' \ + ' COUNT(TestSets.idTestResult)\n' \ + 'FROM TestSets\n' \ + + self.oFilter.getTableJoins(); + for sTable in self.getExtraSubjectTables(): + sBaseQuery = sBaseQuery[:-1] + ',\n ' + sTable + '\n'; + sBaseQuery += 'WHERE TRUE\n' \ + + self.oFilter.getWhereConditions() \ + + self.getExtraSubjectWhereExpr() + '\n'; + + # Retrieve the period results. + for iPeriod in xrange(self.cPeriods): + self._oDb.execute(sBaseQuery + self.getExtraWhereExprForPeriod(iPeriod) + 'GROUP BY TestSets.' + sIdColumn + '\n'); + aaoRows = self._oDb.fetchAll() + + oPeriod = ReportPeriodWithTotalBase(oSet, iPeriod, self.getStraightPeriodDesc(iPeriod), + self.getPeriodStart(iPeriod), self.getPeriodEnd(iPeriod)); + + for aoRow in aaoRows: + oSubject = oLogic.cachedLookup(aoRow[0]); + oPeriodRow = ReportHitRowWithTotalBase(aoRow[0], oSubject, aoRow[1], aoRow[4], aoRow[2], aoRow[3]); + oPeriod.appendRow(oPeriodRow, aoRow[0], oSubject); + + oSet.appendPeriod(oPeriod); + oSet.pruneRowsWithZeroSumHits(); + + + + # + # For reasons entering after the first period, look up the build and + # test set it first occured with. + # + oSet.finalizePass1(); + + for iPeriod in xrange(1, self.cPeriods): + oPeriod = oSet.aoPeriods[iPeriod]; + for idSubject, oSubject in oPeriod.dFirst.items(): + oSet.aoEnterInfo.append(self._getEdgeSimpleFailureOccurence(idSubject, sIdColumn, oSubject, + iPeriod, fEnter = True)); + + # Ditto for reasons leaving before the last. + for iPeriod in xrange(self.cPeriods - 1): + oPeriod = oSet.aoPeriods[iPeriod]; + for idSubject, oSubject in oPeriod.dLast.items(): + oSet.aoLeaveInfo.append(self._getEdgeSimpleFailureOccurence(idSubject, sIdColumn, oSubject, + iPeriod, fEnter = False)); + + oSet.finalizePass2(); + + return oSet; + + def _getEdgeSimpleFailureOccurence(self, idSubject, sIdColumn, oSubject, iPeriod, fEnter = True): + """ + Helper for the failure reason report that finds the oldest or newest build + (SVN rev) and test set (start time) it occured with. + + If fEnter is set the oldest occurence is return, if fEnter clear the newest + is is returned. + + Returns ReportTransientBase instant. + + """ + sSorting = 'ASC' if fEnter else 'DESC'; + sQuery = 'SELECT TestSets.idTestResult,\n' \ + ' TestSets.idTestSet,\n' \ + ' TestSets.tsDone,\n' \ + ' TestSets.idBuild,\n' \ + ' Builds.iRevision,\n' \ + ' BuildCategories.sRepository\n' \ + 'FROM TestSets\n' \ + + self.oFilter.getTableJoins(dOmitTables = {'Builds': True, 'BuildCategories': True}); + sQuery = sQuery[:-1] + ',\n' \ + ' Builds,\n' \ + ' BuildCategories\n'; + for sTable in self.getExtraSubjectTables(): + if sTable not in [ 'Builds', 'BuildCategories' ] and not self.oFilter.isJoiningWithTable(sTable): + sQuery = sQuery[:-1] + ',\n ' + sTable + '\n'; + sQuery += 'WHERE TestSets.' + sIdColumn + ' = ' + str(idSubject) + '\n' \ + ' AND TestSets.idBuild = Builds.idBuild\n' \ + ' AND TestSets.enmStatus >= \'failure\'\n' \ + + self.getExtraWhereExprForPeriod(iPeriod) + \ + ' AND Builds.tsExpire > TestSets.tsCreated\n' \ + ' AND Builds.tsEffective <= TestSets.tsCreated\n' \ + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' \ + + self.oFilter.getWhereConditions() \ + + self.getExtraSubjectWhereExpr() + '\n' \ + 'ORDER BY Builds.iRevision ' + sSorting + ',\n' \ + ' TestSets.tsCreated ' + sSorting + '\n' \ + 'LIMIT 1\n'; + self._oDb.execute(sQuery); + aoRow = self._oDb.fetchOne(); + if aoRow is None: + return ReportTransientBase(-1, -1, 'internal-error', -1, -1, self._oDb.getCurrentTimestamp(), + iPeriod, fEnter, idSubject, oSubject); + return ReportTransientBase(idBuild = aoRow[3], iRevision = aoRow[4], sRepository = aoRow[5], + idTestSet = aoRow[1], idTestResult = aoRow[0], tsDone = aoRow[2], + iPeriod = iPeriod, fEnter = fEnter, idSubject = idSubject, oSubject = oSubject); + + def fetchPossibleFilterOptions(self, oFilter, tsNow, sPeriod): + """ + Fetches possible filtering options. + """ + return TestResultLogic(self._oDb).fetchPossibleFilterOptions(oFilter, tsNow, sPeriod, oReportModel = self); + + + +class ReportGraphModel(ReportModelBase): # pylint: disable=too-few-public-methods + """ + Extended report model used when generating the more complicated graphs + detailing results, time elapsed and values over time. + """ + + ## @name Subject ID types. + ## These prefix the values in the aidSubjects array. The prefix is + ## followed by a colon and then a list of string IDs. Following the prefix + ## is one or more string table IDs separated by colons. These are used to + ## drill down the exact test result we're looking for, by matching against + ## TestResult::idStrName (in the db). + ## @{ + ksTypeResult = 'result'; + ksTypeElapsed = 'elapsed'; + ## The last string table ID gives the name of the value. + ksTypeValue = 'value'; + ## List of types. + kasTypes = (ksTypeResult, ksTypeElapsed, ksTypeValue); + ## @} + + class SampleSource(object): + """ A sample source. """ + def __init__(self, sType, aidStrTests, idStrValue): + self.sType = sType; + self.aidStrTests = aidStrTests; + self.idStrValue = idStrValue; + + def getTestResultTables(self): + """ Retrieves the list of TestResults tables to join with.""" + sRet = ''; + for i in range(len(self.aidStrTests)): + sRet += ' TestResults TR%u,\n' % (i,); + return sRet; + + def getTestResultConditions(self): + """ Retrieves the join conditions for the TestResults tables.""" + sRet = ''; + cItems = len(self.aidStrTests); + for i in range(cItems - 1): + sRet += ' AND TR%u.idStrName = %u\n' \ + ' AND TR%u.idTestResultParent = TR%u.idTestResult\n' \ + % ( i, self.aidStrTests[cItems - i - 1], i, i + 1 ); + sRet += ' AND TR%u.idStrName = %u\n' % (cItems - 1, self.aidStrTests[0]); + return sRet; + + class DataSeries(object): + """ A data series. """ + def __init__(self, oCache, idBuildCategory, idTestBox, idTestCase, idTestCaseArgs, iUnit): + _ = oCache; + self.idBuildCategory = idBuildCategory; + self.oBuildCategory = oCache.getBuildCategory(idBuildCategory); + self.idTestBox = idTestBox; + self.oTestBox = oCache.getTestBox(idTestBox); + self.idTestCase = idTestCase; + self.idTestCaseArgs = idTestCaseArgs; + if idTestCase is not None: + self.oTestCase = oCache.getTestCase(idTestCase); + self.oTestCaseArgs = None; + else: + self.oTestCaseArgs = oCache.getTestCaseArgs(idTestCaseArgs); + self.oTestCase = oCache.getTestCase(self.oTestCaseArgs.idTestCase); + self.iUnit = iUnit; + # Six parallel arrays. + self.aiRevisions = []; # The X values. + self.aiValues = []; # The Y values. + self.aiErrorBarBelow = []; # The Y value minimum errorbars, relative to the Y value (positive). + self.aiErrorBarAbove = []; # The Y value maximum errorbars, relative to the Y value (positive). + self.acSamples = []; # The number of samples at this X value. + self.aoRevInfo = []; # VcsRevisionData objects for each revision. Empty/SQL-NULL objects if no info. + + class DataSeriesCollection(object): + """ A collection of data series corresponding to one input sample source. """ + def __init__(self, oSampleSrc, asTests, sValue = None): + self.sType = oSampleSrc.sType; + self.aidStrTests = oSampleSrc.aidStrTests; + self.asTests = list(asTests); + self.idStrValue = oSampleSrc.idStrValue; + self.sValue = sValue; + self.aoSeries = []; + + def addDataSeries(self, oDataSeries): + """ Appends a data series to the collection. """ + self.aoSeries.append(oDataSeries); + return oDataSeries; + + + def __init__(self, oDb, tsNow, cPeriods, cHoursPerPeriod, sSubject, aidSubjects, # pylint: disable=too-many-arguments + aidTestBoxes, aidBuildCats, aidTestCases, fSepTestVars): + assert(sSubject == self.ksSubEverything); # dummy + ReportModelBase.__init__(self, oDb, tsNow, cPeriods, cHoursPerPeriod, sSubject, aidSubjects, oFilter = None); + self.aidTestBoxes = aidTestBoxes; + self.aidBuildCats = aidBuildCats; + self.aidTestCases = aidTestCases; + self.fOnTestCase = not fSepTestVars; # (Separates testcase variations into separate data series.) + self.oCache = DatabaseObjCache(self._oDb, self.tsNow, None, self.cPeriods * self.cHoursPerPeriod); + + + # Quickly validate and convert the subject "IDs". + self.aoLookups = []; + for sCur in self.aidSubjects: + asParts = sCur.split(':'); + if len(asParts) < 2: + raise TMExceptionBase('Invalid graph value "%s"' % (sCur,)); + + sType = asParts[0]; + if sType not in ReportGraphModel.kasTypes: + raise TMExceptionBase('Invalid graph value type "%s" (full: "%s")' % (sType, sCur,)); + + aidStrTests = []; + for sIdStr in asParts[1:]: + try: idStr = int(sIdStr); + except: raise TMExceptionBase('Invalid graph value id "%s" (full: "%s")' % (sIdStr, sCur,)); + if idStr < 0: + raise TMExceptionBase('Invalid graph value id "%u" (full: "%s")' % (idStr, sCur,)); + aidStrTests.append(idStr); + + idStrValue = None; + if sType == ReportGraphModel.ksTypeValue: + idStrValue = aidStrTests.pop(); + self.aoLookups.append(ReportGraphModel.SampleSource(sType, aidStrTests, idStrValue)); + + # done + + + def getExtraWhereExprForTotalPeriod(self, sTimestampField): + """ + Returns additional WHERE expression for getting test sets for the + specified period. It starts with an AND so that it can simply be + appended to the WHERE clause. + """ + return self.getExtraWhereExprForTotalPeriodEx(sTimestampField, sTimestampField, True); + + def getExtraWhereExprForTotalPeriodEx(self, sStartField = 'tsCreated', sEndField = 'tsDone', fLeadingAnd = True): + """ + Returns additional WHERE expression for getting test sets for the + specified period. + """ + if self.tsNow is None: + sNow = 'CURRENT_TIMESTAMP'; + else: + sNow = self._oDb.formatBindArgs('%s::TIMESTAMP', (self.tsNow,)); + + sRet = ' AND %s >= (%s - interval \'%u hours\')\n' \ + ' AND %s <= %s\n' \ + % ( sStartField, sNow, self.cPeriods * self.cHoursPerPeriod, + sEndField, sNow); + + if not fLeadingAnd: + assert sRet[8] == ' ' and sRet[7] == 'D'; + return sRet[9:]; + return sRet; + + def _getEligibleTestSetPeriod(self, sPrefix = 'TestSets.', fLeadingAnd = False): + """ + Returns additional WHERE expression for getting TestSets rows + potentially relevant for the selected period. + """ + if self.tsNow is None: + sNow = 'CURRENT_TIMESTAMP'; + else: + sNow = self._oDb.formatBindArgs('%s::TIMESTAMP', (self.tsNow,)); + + # The 2nd line is a performance hack on TestSets. It nudges postgresql + # into useing the TestSetsCreatedDoneIdx index instead of doing a table + # scan when we look for eligible bits there. + # ASSUMES no relevant test runs longer than 7 days! + sRet = ' AND %stsCreated <= %s\n' \ + ' AND %stsCreated >= (%s - interval \'%u hours\' - interval \'%u days\')\n' \ + ' AND %stsDone >= (%s - interval \'%u hours\')\n' \ + % ( sPrefix, sNow, + sPrefix, sNow, self.cPeriods * self.cHoursPerPeriod, 7, + sPrefix, sNow, self.cPeriods * self.cHoursPerPeriod, ); + + if not fLeadingAnd: + assert sRet[8] == ' ' and sRet[7] == 'D'; + return sRet[9:]; + return sRet; + + + def _getNameStrings(self, aidStrTests): + """ Returns an array of names corresponding to the array of string table entries. """ + return [self.oCache.getTestResultString(idStr) for idStr in aidStrTests]; + + def fetchGraphData(self): + """ returns data """ + sWantedTestCaseId = 'idTestCase' if self.fOnTestCase else 'idTestCaseArgs'; + + aoRet = []; + for oLookup in self.aoLookups: + # + # Set up the result collection. + # + if oLookup.sType == self.ksTypeValue: + oCollection = self.DataSeriesCollection(oLookup, self._getNameStrings(oLookup.aidStrTests), + self.oCache.getTestResultString(oLookup.idStrValue)); + else: + oCollection = self.DataSeriesCollection(oLookup, self._getNameStrings(oLookup.aidStrTests)); + + # + # Construct the query. + # + sQuery = 'SELECT Builds.iRevision,\n' \ + ' TestSets.idBuildCategory,\n' \ + ' TestSets.idTestBox,\n' \ + ' TestSets.' + sWantedTestCaseId + ',\n'; + if oLookup.sType == self.ksTypeValue: + sQuery += ' TestResultValues.iUnit as iUnit,\n' \ + ' MIN(TestResultValues.lValue),\n' \ + ' CAST(ROUND(AVG(TestResultValues.lValue)) AS BIGINT),\n' \ + ' MAX(TestResultValues.lValue),\n' \ + ' COUNT(TestResultValues.lValue)\n'; + elif oLookup.sType == self.ksTypeElapsed: + sQuery += ' %u as iUnit,\n' \ + ' CAST((EXTRACT(EPOCH FROM MIN(TR0.tsElapsed)) * 1000) AS INTEGER),\n' \ + ' CAST((EXTRACT(EPOCH FROM AVG(TR0.tsElapsed)) * 1000) AS INTEGER),\n' \ + ' CAST((EXTRACT(EPOCH FROM MAX(TR0.tsElapsed)) * 1000) AS INTEGER),\n' \ + ' COUNT(TR0.tsElapsed)\n' \ + % (constants.valueunit.MS,); + else: + sQuery += ' %u as iUnit,\n'\ + ' MIN(TR0.cErrors),\n' \ + ' CAST(ROUND(AVG(TR0.cErrors)) AS INTEGER),\n' \ + ' MAX(TR0.cErrors),\n' \ + ' COUNT(TR0.cErrors)\n' \ + % (constants.valueunit.OCCURRENCES,); + + if oLookup.sType == self.ksTypeValue: + sQuery += 'FROM TestResultValues,\n'; + sQuery += ' TestSets,\n' + sQuery += oLookup.getTestResultTables(); + else: + sQuery += 'FROM ' + oLookup.getTestResultTables().lstrip(); + sQuery += ' TestSets,\n'; + sQuery += ' Builds\n'; + + if oLookup.sType == self.ksTypeValue: + sQuery += 'WHERE TestResultValues.idStrName = %u\n' % ( oLookup.idStrValue, ); + sQuery += self.getExtraWhereExprForTotalPeriod('TestResultValues.tsCreated'); + sQuery += ' AND TestResultValues.idTestSet = TestSets.idTestSet\n'; + sQuery += self._getEligibleTestSetPeriod(fLeadingAnd = True); + else: + sQuery += 'WHERE ' + (self.getExtraWhereExprForTotalPeriod('TR0.tsCreated').lstrip()[4:]).lstrip(); + sQuery += ' AND TR0.idTestSet = TestSets.idTestSet\n'; + + if len(self.aidTestBoxes) == 1: + sQuery += ' AND TestSets.idTestBox = %u\n' % (self.aidTestBoxes[0],); + elif self.aidTestBoxes: + sQuery += ' AND TestSets.idTestBox IN (' + ','.join([str(i) for i in self.aidTestBoxes]) + ')\n'; + + if len(self.aidBuildCats) == 1: + sQuery += ' AND TestSets.idBuildCategory = %u\n' % (self.aidBuildCats[0],); + elif self.aidBuildCats: + sQuery += ' AND TestSets.idBuildCategory IN (' + ','.join([str(i) for i in self.aidBuildCats]) + ')\n'; + + if len(self.aidTestCases) == 1: + sQuery += ' AND TestSets.idTestCase = %u\n' % (self.aidTestCases[0],); + elif self.aidTestCases: + sQuery += ' AND TestSets.idTestCase IN (' + ','.join([str(i) for i in self.aidTestCases]) + ')\n'; + + if oLookup.sType == self.ksTypeElapsed: + sQuery += ' AND TestSets.enmStatus = \'%s\'::TestStatus_T\n' % (self.ksTestStatus_Success,); + + if oLookup.sType == self.ksTypeValue: + sQuery += ' AND TestResultValues.idTestResult = TR0.idTestResult\n' + sQuery += self.getExtraWhereExprForTotalPeriod('TR0.tsCreated'); # For better index matching in some cases. + + if oLookup.sType != self.ksTypeResult: + sQuery += ' AND TR0.enmStatus = \'%s\'::TestStatus_T\n' % (self.ksTestStatus_Success,); + + sQuery += oLookup.getTestResultConditions(); + sQuery += ' AND TestSets.idBuild = Builds.idBuild\n'; + + sQuery += 'GROUP BY TestSets.idBuildCategory,\n' \ + ' TestSets.idTestBox,\n' \ + ' TestSets.' + sWantedTestCaseId + ',\n' \ + ' iUnit,\n' \ + ' Builds.iRevision\n'; + sQuery += 'ORDER BY TestSets.idBuildCategory,\n' \ + ' TestSets.idTestBox,\n' \ + ' TestSets.' + sWantedTestCaseId + ',\n' \ + ' iUnit,\n' \ + ' Builds.iRevision\n'; + + # + # Execute it and collect the result. + # + sCurRepository = None; + dRevisions = {}; + oLastSeries = None; + idLastBuildCat = -1; + idLastTestBox = -1; + idLastTestCase = -1; + iLastUnit = -1; + self._oDb.execute(sQuery); + for aoRow in self._oDb.fetchAll(): # Fetching all here so we can make cache queries below. + if aoRow[1] != idLastBuildCat \ + or aoRow[2] != idLastTestBox \ + or aoRow[3] != idLastTestCase \ + or aoRow[4] != iLastUnit: + idLastBuildCat = aoRow[1]; + idLastTestBox = aoRow[2]; + idLastTestCase = aoRow[3]; + iLastUnit = aoRow[4]; + if self.fOnTestCase: + oLastSeries = self.DataSeries(self.oCache, idLastBuildCat, idLastTestBox, + idLastTestCase, None, iLastUnit); + else: + oLastSeries = self.DataSeries(self.oCache, idLastBuildCat, idLastTestBox, + None, idLastTestCase, iLastUnit); + oCollection.addDataSeries(oLastSeries); + if oLastSeries.oBuildCategory.sRepository != sCurRepository: + if sCurRepository is not None: + self.oCache.preloadVcsRevInfo(sCurRepository, dRevisions.keys()); + sCurRepository = oLastSeries.oBuildCategory.sRepository + dRevisions = {}; + oLastSeries.aiRevisions.append(aoRow[0]); + oLastSeries.aiValues.append(aoRow[6]); + oLastSeries.aiErrorBarBelow.append(aoRow[6] - aoRow[5]); + oLastSeries.aiErrorBarAbove.append(aoRow[7] - aoRow[6]); + oLastSeries.acSamples.append(aoRow[8]); + dRevisions[aoRow[0]] = 1; + + if sCurRepository is not None: + self.oCache.preloadVcsRevInfo(sCurRepository, dRevisions.keys()); + del dRevisions; + + # + # Look up the VCS revision details. + # + for oSeries in oCollection.aoSeries: + for iRevision in oSeries.aiRevisions: + oSeries.aoRevInfo.append(self.oCache.getVcsRevInfo(sCurRepository, iRevision)); + aoRet.append(oCollection); + + return aoRet; + + def getEligibleTestBoxes(self): + """ + Returns a list of TestBoxData objects with eligible testboxes for + the total period of time defined for this graph. + """ + + # Taking the simple way out now, getting all active testboxes at the + # time without filtering out on sample sources. + + # 1. Collect the relevant testbox generation IDs. + self._oDb.execute('SELECT DISTINCT idTestBox, idGenTestBox\n' + 'FROM TestSets\n' + 'WHERE ' + self._getEligibleTestSetPeriod(fLeadingAnd = False) + + 'ORDER BY idTestBox, idGenTestBox DESC'); + idPrevTestBox = -1; + asIdGenTestBoxes = []; + for _ in range(self._oDb.getRowCount()): + aoRow = self._oDb.fetchOne(); + if aoRow[0] != idPrevTestBox: + idPrevTestBox = aoRow[0]; + asIdGenTestBoxes.append(str(aoRow[1])); + + # 2. Query all the testbox data in one go. + aoRet = []; + if asIdGenTestBoxes: + self._oDb.execute('SELECT *\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE idGenTestBox IN (' + ','.join(asIdGenTestBoxes) + ')\n' + 'ORDER BY sName'); + for _ in range(self._oDb.getRowCount()): + aoRet.append(TestBoxData().initFromDbRow(self._oDb.fetchOne())); + + return aoRet; + + def getEligibleBuildCategories(self): + """ + Returns a list of BuildCategoryData objects with eligible build + categories for the total period of time defined for this graph. In + addition it will add any currently selected categories that aren't + really relevant to the period, just to simplify the WUI code. + + """ + + # Taking the simple way out now, getting all used build cat without + # any testbox or testcase filtering. + + sSelectedBuildCats = ''; + if self.aidBuildCats: + sSelectedBuildCats = ' OR idBuildCategory IN (' + ','.join([str(i) for i in self.aidBuildCats]) + ')\n'; + + self._oDb.execute('SELECT DISTINCT *\n' + 'FROM BuildCategories\n' + 'WHERE idBuildCategory IN (\n' + ' SELECT DISTINCT idBuildCategory\n' + ' FROM TestSets\n' + ' WHERE ' + self._getEligibleTestSetPeriod(fLeadingAnd = False) + + ')\n' + + sSelectedBuildCats + + 'ORDER BY sProduct,\n' + ' sBranch,\n' + ' asOsArches,\n' + ' sType\n'); + aoRet = []; + for _ in range(self._oDb.getRowCount()): + aoRet.append(BuildCategoryData().initFromDbRow(self._oDb.fetchOne())); + + return aoRet; + diff --git a/src/VBox/ValidationKit/testmanager/core/restdispatcher.py b/src/VBox/ValidationKit/testmanager/core/restdispatcher.py new file mode 100755 index 00000000..75a1aa7c --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/restdispatcher.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- +# $Id: restdispatcher.py $ + +""" +Test Manager Core - REST cgi handler. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import os; +import sys; + +# Validation Kit imports. +#from common import constants; +from common import utils; +from testmanager import config; +#from testmanager.core import coreconsts; +from testmanager.core.db import TMDatabaseConnection; +from testmanager.core.base import TMExceptionBase, ModelDataBase; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + long = int; # pylint: disable=redefined-builtin,invalid-name + + +# +# Exceptions +# + +class RestDispException(TMExceptionBase): + """ + Exception class for the REST dispatcher. + """ + def __init__(self, sMsg, iStatus): + TMExceptionBase.__init__(self, sMsg); + self.iStatus = iStatus; + +# 400 +class RestDispException400(RestDispException): + """ A 400 error """ + def __init__(self, sMsg): + RestDispException.__init__(self, sMsg, 400); + +class RestUnknownParameters(RestDispException400): + """ Unknown parameter(s). """ + pass; # pylint: disable=unnecessary-pass + +# 404 +class RestDispException404(RestDispException): + """ A 404 error """ + def __init__(self, sMsg): + RestDispException.__init__(self, sMsg, 404); + +class RestBadPathException(RestDispException404): + """ We've got a bad path. """ + pass; # pylint: disable=unnecessary-pass + +class RestBadParameter(RestDispException404): + """ Bad parameter. """ + pass; # pylint: disable=unnecessary-pass + +class RestMissingParameter(RestDispException404): + """ Missing parameter. """ + pass; # pylint: disable=unnecessary-pass + + + +class RestMain(object): # pylint: disable=too-few-public-methods + """ + REST main dispatcher class. + """ + + ksParam_sPath = 'sPath'; + + + def __init__(self, oSrvGlue): + self._oSrvGlue = oSrvGlue; + self._oDb = TMDatabaseConnection(oSrvGlue.dprint); + self._iFirstHandlerPath = 0; + self._iNextHandlerPath = 0; + self._sPath = None; # _getStandardParams / dispatchRequest sets this later on. + self._asPath = None; # _getStandardParams / dispatchRequest sets this later on. + self._sMethod = None; # _getStandardParams / dispatchRequest sets this later on. + self._dParams = None; # _getStandardParams / dispatchRequest sets this later on. + self._asCheckedParams = []; + self._dGetTree = { + 'vcs': { + 'changelog': self._handleVcsChangelog_Get, + 'bugreferences': self._handleVcsBugReferences_Get, + }, + }; + self._dMethodTrees = { + 'GET': self._dGetTree, + } + + # + # Helpers. + # + + def _getStringParam(self, sName, asValidValues = None, fStrip = False, sDefValue = None): + """ + Gets a string parameter (stripped). + + Raises exception if not found and no default is provided, or if the + value isn't found in asValidValues. + """ + if sName not in self._dParams: + if sDefValue is None: + raise RestMissingParameter('%s parameter %s is missing' % (self._sPath, sName)); + return sDefValue; + sValue = self._dParams[sName]; + if isinstance(sValue, list): + if len(sValue) == 1: + sValue = sValue[0]; + else: + raise RestBadParameter('%s parameter %s value is not a string but list: %s' + % (self._sPath, sName, sValue)); + if fStrip: + sValue = sValue.strip(); + + if sName not in self._asCheckedParams: + self._asCheckedParams.append(sName); + + if asValidValues is not None and sValue not in asValidValues: + raise RestBadParameter('%s parameter %s value "%s" not in %s ' + % (self._sPath, sName, sValue, asValidValues)); + return sValue; + + def _getBoolParam(self, sName, fDefValue = None): + """ + Gets a boolean parameter. + + Raises exception if not found and no default is provided, or if not a + valid boolean. + """ + sValue = self._getStringParam(sName, [ 'True', 'true', '1', 'False', 'false', '0'], sDefValue = str(fDefValue)); + return sValue in ('True', 'true', '1',); + + def _getIntParam(self, sName, iMin = None, iMax = None): + """ + Gets a string parameter. + Raises exception if not found, not a valid integer, or if the value + isn't in the range defined by iMin and iMax. + """ + sValue = self._getStringParam(sName); + try: + iValue = int(sValue, 0); + except: + raise RestBadParameter('%s parameter %s value "%s" cannot be convert to an integer' + % (self._sPath, sName, sValue)); + + if (iMin is not None and iValue < iMin) \ + or (iMax is not None and iValue > iMax): + raise RestBadParameter('%s parameter %s value %d is out of range [%s..%s]' + % (self._sPath, sName, iValue, iMin, iMax)); + return iValue; + + def _getLongParam(self, sName, lMin = None, lMax = None, lDefValue = None): + """ + Gets a string parameter. + Raises exception if not found, not a valid long integer, or if the value + isn't in the range defined by lMin and lMax. + """ + sValue = self._getStringParam(sName, sDefValue = (str(lDefValue) if lDefValue is not None else None)); + try: + lValue = long(sValue, 0); + except Exception as oXcpt: + raise RestBadParameter('%s parameter %s value "%s" cannot be convert to an integer (%s)' + % (self._sPath, sName, sValue, oXcpt)); + + if (lMin is not None and lValue < lMin) \ + or (lMax is not None and lValue > lMax): + raise RestBadParameter('%s parameter %s value %d is out of range [%s..%s]' + % (self._sPath, sName, lValue, lMin, lMax)); + return lValue; + + def _checkForUnknownParameters(self): + """ + Check if we've handled all parameters, raises exception if anything + unknown was found. + """ + + if len(self._asCheckedParams) != len(self._dParams): + sUnknownParams = ''; + for sKey in self._dParams: + if sKey not in self._asCheckedParams: + sUnknownParams += ' ' + sKey + '=' + self._dParams[sKey]; + raise RestUnknownParameters('Unknown parameters: ' + sUnknownParams); + + return True; + + def writeToMainLog(self, oTestSet, sText, fIgnoreSizeCheck = False): + """ Writes the text to the main log file. """ + + # Calc the file name and open the file. + sFile = os.path.join(config.g_ksFileAreaRootDir, oTestSet.sBaseFilename + '-main.log'); + if not os.path.exists(os.path.dirname(sFile)): + os.makedirs(os.path.dirname(sFile), 0o755); + + with open(sFile, 'ab') as oFile: + # Check the size. + fSizeOk = True; + if not fIgnoreSizeCheck: + oStat = os.fstat(oFile.fileno()); + fSizeOk = oStat.st_size / (1024 * 1024) < config.g_kcMbMaxMainLog; + + # Write the text. + if fSizeOk: + if sys.version_info[0] >= 3: + oFile.write(bytes(sText, 'utf-8')); + else: + oFile.write(sText); + + return fSizeOk; + + def _getNextPathElementString(self, sName, oDefault = None): + """ + Gets the next handler specific path element. + Returns unprocessed string. + Throws exception + """ + i = self._iNextHandlerPath; + if i < len(self._asPath): + self._iNextHandlerPath = i + 1; + return self._asPath[i]; + if oDefault is None: + raise RestBadPathException('Requires a "%s" element after "%s"' % (sName, self._sPath,)); + return oDefault; + + def _getNextPathElementInt(self, sName, iDefault = None, iMin = None, iMax = None): + """ + Gets the next handle specific path element as an integer. + Returns integer value. + Throws exception if not found or not a valid integer. + """ + sValue = self._getNextPathElementString(sName, oDefault = iDefault); + try: + iValue = int(sValue); + except: + raise RestBadPathException('Not an integer "%s" (%s)' % (sValue, sName,)); + if iMin is not None and iValue < iMin: + raise RestBadPathException('Integer "%s" value (%s) is too small, min %s' % (sValue, sName, iMin)); + if iMax is not None and iValue > iMax: + raise RestBadPathException('Integer "%s" value (%s) is too large, max %s' % (sValue, sName, iMax)); + return iValue; + + def _getNextPathElementLong(self, sName, iDefault = None, iMin = None, iMax = None): + """ + Gets the next handle specific path element as a long integer. + Returns integer value. + Throws exception if not found or not a valid integer. + """ + sValue = self._getNextPathElementString(sName, oDefault = iDefault); + try: + iValue = long(sValue); + except: + raise RestBadPathException('Not an integer "%s" (%s)' % (sValue, sName,)); + if iMin is not None and iValue < iMin: + raise RestBadPathException('Integer "%s" value (%s) is too small, min %s' % (sValue, sName, iMin)); + if iMax is not None and iValue > iMax: + raise RestBadPathException('Integer "%s" value (%s) is too large, max %s' % (sValue, sName, iMax)); + return iValue; + + def _checkNoMorePathElements(self): + """ + Checks that there are no more path elements. + Throws exception if there are. + """ + i = self._iNextHandlerPath; + if i < len(self._asPath): + raise RestBadPathException('Unknown subpath "%s" below "%s"' % + ('/'.join(self._asPath[i:]), '/'.join(self._asPath[:i]),)); + return True; + + def _doneParsingArguments(self): + """ + Checks that there are no more path elements or unhandled parameters. + Throws exception if there are. + """ + self._checkNoMorePathElements(); + self._checkForUnknownParameters(); + return True; + + def _dataArrayToJsonReply(self, aoData, sName = 'aoData', dExtraFields = None, iStatus = 200): + """ + Converts aoData into an array objects + return True. + """ + self._oSrvGlue.setContentType('application/json'); + self._oSrvGlue.setStatus(iStatus); + self._oSrvGlue.write(u'{\n'); + if dExtraFields: + for sKey in dExtraFields: + self._oSrvGlue.write(u' "%s": %s,\n' % (sKey, ModelDataBase.genericToJson(dExtraFields[sKey]),)); + self._oSrvGlue.write(u' "c%s": %u,\n' % (sName[2:],len(aoData),)); + self._oSrvGlue.write(u' "%s": [\n' % (sName,)); + for i, oData in enumerate(aoData): + if i > 0: + self._oSrvGlue.write(u',\n'); + self._oSrvGlue.write(ModelDataBase.genericToJson(oData)); + self._oSrvGlue.write(u' ]\n'); + ## @todo if config.g_kfWebUiSqlDebug: + self._oSrvGlue.write(u'}\n'); + self._oSrvGlue.flush(); + return True; + + + # + # Handlers. + # + + def _handleVcsChangelog_Get(self): + """ GET /vcs/changelog/{sRepository}/{iStartRev}[/{cEntriesBack}] """ + # Parse arguments + sRepository = self._getNextPathElementString('sRepository'); + iStartRev = self._getNextPathElementInt('iStartRev', iMin = 0); + cEntriesBack = self._getNextPathElementInt('cEntriesBack', iDefault = 32, iMin = 0, iMax = 8192); + self._checkNoMorePathElements(); + self._checkForUnknownParameters(); + + # Execute it. + from testmanager.core.vcsrevisions import VcsRevisionLogic; + oLogic = VcsRevisionLogic(self._oDb); + return self._dataArrayToJsonReply(oLogic.fetchTimeline(sRepository, iStartRev, cEntriesBack), 'aoCommits', + { 'sTracChangesetUrlFmt': + config.g_ksTracChangsetUrlFmt.replace('%(sRepository)s', sRepository), } ); + + def _handleVcsBugReferences_Get(self): + """ GET /vcs/bugreferences/{sTrackerId}/{lBugId} """ + # Parse arguments + sTrackerId = self._getNextPathElementString('sTrackerId'); + lBugId = self._getNextPathElementLong('lBugId', iMin = 0); + self._checkNoMorePathElements(); + self._checkForUnknownParameters(); + + # Execute it. + from testmanager.core.vcsbugreference import VcsBugReferenceLogic; + oLogic = VcsBugReferenceLogic(self._oDb); + oLogic.fetchForBug(sTrackerId, lBugId) + return self._dataArrayToJsonReply(oLogic.fetchForBug(sTrackerId, lBugId), 'aoCommits', + { 'sTracChangesetUrlFmt': config.g_ksTracChangsetUrlFmt, } ); + + + # + # Dispatching. + # + + def _dispatchRequestCommon(self): + """ + Dispatches the incoming request after have gotten the path and parameters. + + Will raise RestDispException on failure. + """ + + # + # Split up the path. + # + asPath = self._sPath.split('/'); + self._asPath = asPath; + + # + # Get the method and the corresponding handler tree. + # + try: + sMethod = self._oSrvGlue.getMethod(); + except Exception as oXcpt: + raise RestDispException('Error retriving request method: %s' % (oXcpt,), 400); + self._sMethod = sMethod; + + try: + dTree = self._dMethodTrees[sMethod]; + except KeyError: + raise RestDispException('Unsupported method %s' % (sMethod,), 405); + + # + # Walk the path till we find a handler for it. + # + iPath = 0; + while iPath < len(asPath): + try: + oTreeOrHandler = dTree[asPath[iPath]]; + except KeyError: + raise RestBadPathException('Path element #%u "%s" not found (path="%s")' % (iPath, asPath[iPath], self._sPath)); + iPath += 1; + if isinstance(oTreeOrHandler, dict): + dTree = oTreeOrHandler; + else: + # + # Call the handler. + # + self._iFirstHandlerPath = iPath; + self._iNextHandlerPath = iPath; + return oTreeOrHandler(); + + raise RestBadPathException('Empty path (%s)' % (self._sPath,)); + + def dispatchRequest(self): + """ + Dispatches the incoming request where the path is given as an argument. + + Will raise RestDispException on failure. + """ + + # + # Get the parameters. + # + try: + dParams = self._oSrvGlue.getParameters(); + except Exception as oXcpt: + raise RestDispException('Error retriving parameters: %s' % (oXcpt,), 500); + self._dParams = dParams; + + # + # Get the path parameter. + # + if self.ksParam_sPath not in dParams: + raise RestDispException('No "%s" parameter in request (params: %s)' % (self.ksParam_sPath, dParams,), 500); + self._sPath = self._getStringParam(self.ksParam_sPath); + assert utils.isString(self._sPath); + + return self._dispatchRequestCommon(); + diff --git a/src/VBox/ValidationKit/testmanager/core/schedgroup.py b/src/VBox/ValidationKit/testmanager/core/schedgroup.py new file mode 100755 index 00000000..a586a6bf --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/schedgroup.py @@ -0,0 +1,1352 @@ +# -*- coding: utf-8 -*- +# $Id: schedgroup.py $ + +""" +Test Manager - Scheduling Group. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMExceptionBase, \ + TMRowInUse, TMInvalidData, TMRowAlreadyExists, TMRowNotFound, \ + ChangeLogEntry, AttributeChangeEntry, AttributeChangeEntryPre; +from testmanager.core.buildsource import BuildSourceData; +from testmanager.core import db; +from testmanager.core.testcase import TestCaseData; +from testmanager.core.testcaseargs import TestCaseArgsData; +from testmanager.core.testbox import TestBoxLogic, TestBoxDataForSchedGroup; +from testmanager.core.testgroup import TestGroupData; +from testmanager.core.useraccount import UserAccountLogic; + + + +class SchedGroupMemberData(ModelDataBase): + """ + SchedGroupMember Data. + """ + + ksIdAttr = 'idSchedGroup'; + + ksParam_idSchedGroup = 'SchedGroupMember_idSchedGroup'; + ksParam_idTestGroup = 'SchedGroupMember_idTestGroup'; + ksParam_tsEffective = 'SchedGroupMember_tsEffective'; + ksParam_tsExpire = 'SchedGroupMember_tsExpire'; + ksParam_uidAuthor = 'SchedGroupMember_uidAuthor'; + ksParam_iSchedPriority = 'SchedGroupMember_iSchedPriority'; + ksParam_bmHourlySchedule = 'SchedGroupMember_bmHourlySchedule'; + ksParam_idTestGroupPreReq = 'SchedGroupMember_idTestGroupPreReq'; + + kasAllowNullAttributes = [ 'idSchedGroup', 'idTestGroup', 'tsEffective', 'tsExpire', + 'uidAuthor', 'bmHourlySchedule', 'idTestGroupPreReq' ]; + kiMin_iSchedPriority = 0; + kiMax_iSchedPriority = 32; + + kcDbColumns = 8 + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idSchedGroup = None; + self.idTestGroup = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.iSchedPriority = 16; + self.bmHourlySchedule = None; + self.idTestGroupPreReq = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the data with a row from a SELECT * FROM SchedGroupMembers. + + Returns self. Raises exception if the row is None or otherwise invalid. + """ + + if aoRow is None: + raise TMRowNotFound('SchedGroupMember not found.'); + + self.idSchedGroup = aoRow[0]; + self.idTestGroup = aoRow[1]; + self.tsEffective = aoRow[2]; + self.tsExpire = aoRow[3]; + self.uidAuthor = aoRow[4]; + self.iSchedPriority = aoRow[5]; + self.bmHourlySchedule = aoRow[6]; ## @todo figure out how bitmaps are returned... + self.idTestGroupPreReq = aoRow[7]; + return self; + + +class SchedGroupMemberDataEx(SchedGroupMemberData): + """ + Extended SchedGroupMember data class. + This adds the testgroups. + """ + + def __init__(self): + SchedGroupMemberData.__init__(self); + self.oTestGroup = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the data with a row from a query like this: + + SELECT SchedGroupMembers.*, TestGroups.* + FROM SchedGroupMembers + JOIN TestGroups + ON (SchedGroupMembers.idTestGroup = TestGroups.idTestGroup); + + Returns self. Raises exception if the row is None or otherwise invalid. + """ + SchedGroupMemberData.initFromDbRow(self, aoRow); + self.oTestGroup = TestGroupData().initFromDbRow(aoRow[SchedGroupMemberData.kcDbColumns:]); + return self; + + def getDataAttributes(self): + asAttributes = SchedGroupMemberData.getDataAttributes(self); + asAttributes.remove('oTestGroup'); + return asAttributes; + + def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ModelDataBase.ksValidateFor_Other): + dErrors = SchedGroupMemberData._validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor); + if self.ksParam_idTestGroup not in dErrors: + self.oTestGroup = TestGroupData(); + try: + self.oTestGroup.initFromDbWithId(oDb, self.idTestGroup); + except Exception as oXcpt: + self.oTestGroup = TestGroupData() + dErrors[self.ksParam_idTestGroup] = str(oXcpt); + return dErrors; + + + + +class SchedGroupData(ModelDataBase): + """ + SchedGroup Data. + """ + + ## @name TestBoxState_T + # @{ + ksScheduler_BestEffortContinuousIntegration = 'bestEffortContinousItegration'; # sic*2 + ksScheduler_Reserved = 'reserved'; + ## @} + + + ksIdAttr = 'idSchedGroup'; + + ksParam_idSchedGroup = 'SchedGroup_idSchedGroup'; + ksParam_tsEffective = 'SchedGroup_tsEffective'; + ksParam_tsExpire = 'SchedGroup_tsExpire'; + ksParam_uidAuthor = 'SchedGroup_uidAuthor'; + ksParam_sName = 'SchedGroup_sName'; + ksParam_sDescription = 'SchedGroup_sDescription'; + ksParam_fEnabled = 'SchedGroup_fEnabled'; + ksParam_enmScheduler = 'SchedGroup_enmScheduler'; + ksParam_idBuildSrc = 'SchedGroup_idBuildSrc'; + ksParam_idBuildSrcTestSuite = 'SchedGroup_idBuildSrcTestSuite'; + ksParam_sComment = 'SchedGroup_sComment'; + + kasAllowNullAttributes = ['idSchedGroup', 'tsEffective', 'tsExpire', 'uidAuthor', 'sDescription', + 'idBuildSrc', 'idBuildSrcTestSuite', 'sComment' ]; + kasValidValues_enmScheduler = [ ksScheduler_BestEffortContinuousIntegration, ]; + + kcDbColumns = 11; + + # Scheduler types + kasSchedulerDesc = \ + [ + ( ksScheduler_BestEffortContinuousIntegration, 'Best-Effort-Continuous-Integration (BECI) scheduler.', ''), + ] + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idSchedGroup = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.sName = None; + self.sDescription = None; + self.fEnabled = None; + self.enmScheduler = SchedGroupData.ksScheduler_BestEffortContinuousIntegration; + self.idBuildSrc = None; + self.idBuildSrcTestSuite = None; + self.sComment = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the data with a row from a SELECT * FROM SchedGroups. + + Returns self. Raises exception if the row is None or otherwise invalid. + """ + + if aoRow is None: + raise TMRowNotFound('SchedGroup not found.'); + + self.idSchedGroup = aoRow[0]; + self.tsEffective = aoRow[1]; + self.tsExpire = aoRow[2]; + self.uidAuthor = aoRow[3]; + self.sName = aoRow[4]; + self.sDescription = aoRow[5]; + self.fEnabled = aoRow[6]; + self.enmScheduler = aoRow[7]; + self.idBuildSrc = aoRow[8]; + self.idBuildSrcTestSuite = aoRow[9]; + self.sComment = aoRow[10]; + return self; + + def initFromDbWithId(self, oDb, idSchedGroup, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE idSchedGroup = %s\n' + , ( idSchedGroup,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idSchedGroup=%s not found (tsNow=%s, sPeriodBack=%s)' % (idSchedGroup, tsNow, sPeriodBack)); + return self.initFromDbRow(aoRow); + + +class SchedGroupDataEx(SchedGroupData): + """ + Extended scheduling group data. + + Note! Similar to TestGroupDataEx. + """ + + ksParam_aoMembers = 'SchedGroup_aoMembers'; + ksParam_aoTestBoxes = 'SchedGroup_aoTestboxes'; + kasAltArrayNull = [ 'aoMembers', 'aoTestboxes' ]; + + ## Helper parameter containing the comma separated list with the IDs of + # potential members found in the parameters. + ksParam_aidTestGroups = 'TestGroupDataEx_aidTestGroups'; + ## Ditto for testbox meembers. + ksParam_aidTestBoxes = 'TestGroupDataEx_aidTestBoxes'; + + + def __init__(self): + SchedGroupData.__init__(self); + self.aoMembers = [] # type: list[SchedGroupMemberDataEx] + self.aoTestBoxes = [] # type: list[TestBoxDataForSchedGroup] + + # The two build sources for the sake of convenience. + self.oBuildSrc = None # type: BuildSourceData + self.oBuildSrcValidationKit = None # type: BuildSourceData + + def _initExtraMembersFromDb(self, oDb, tsNow = None, sPeriodBack = None): + """ + Worker shared by the initFromDb* methods. + Returns self. Raises exception if no row or database error. + """ + # + # Clear all members upfront so the object has some kind of consistency + # if anything below raises exceptions. + # + self.oBuildSrc = None; + self.oBuildSrcValidationKit = None; + self.aoTestBoxes = []; + self.aoMembers = []; + + # + # Build source. + # + if self.idBuildSrc: + self.oBuildSrc = BuildSourceData().initFromDbWithId(oDb, self.idBuildSrc, tsNow, sPeriodBack); + + if self.idBuildSrcTestSuite: + self.oBuildSrcValidationKit = BuildSourceData().initFromDbWithId(oDb, self.idBuildSrcTestSuite, + tsNow, sPeriodBack); + + # + # Test Boxes. + # + self.aoTestBoxes = TestBoxLogic(oDb).fetchForSchedGroup(self.idSchedGroup, tsNow); + + # + # Test groups. + # The fetchForChangeLog method makes ASSUMPTIONS about sorting! + # + oDb.execute('SELECT SchedGroupMembers.*, TestGroups.*\n' + 'FROM SchedGroupMembers\n' + 'LEFT OUTER JOIN TestGroups ON (SchedGroupMembers.idTestGroup = TestGroups.idTestGroup)\n' + 'WHERE SchedGroupMembers.idSchedGroup = %s\n' + + self.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix = 'SchedGroupMembers.') + + self.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix = 'TestGroups.') + + 'ORDER BY SchedGroupMembers.idTestGroupPreReq ASC NULLS FIRST,\n' + ' TestGroups.sName,\n' + ' SchedGroupMembers.idTestGroup\n' + , (self.idSchedGroup,)); + for aoRow in oDb.fetchAll(): + self.aoMembers.append(SchedGroupMemberDataEx().initFromDbRow(aoRow)); + return self; + + def initFromDbRowEx(self, aoRow, oDb, tsNow = None): + """ + Reinitialize from a SELECT * FROM SchedGroups row. Will query the + necessary additional data from oDb using tsNow. + Returns self. Raises exception if no row or database error. + """ + SchedGroupData.initFromDbRow(self, aoRow); + return self._initExtraMembersFromDb(oDb, tsNow); + + def initFromDbWithId(self, oDb, idSchedGroup, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + SchedGroupData.initFromDbWithId(self, oDb, idSchedGroup, tsNow, sPeriodBack); + return self._initExtraMembersFromDb(oDb, tsNow, sPeriodBack); + + def getDataAttributes(self): + asAttributes = SchedGroupData.getDataAttributes(self); + asAttributes.remove('oBuildSrc'); + asAttributes.remove('oBuildSrcValidationKit'); + return asAttributes; + + def getAttributeParamNullValues(self, sAttr): + if sAttr not in [ 'aoMembers', 'aoTestBoxes' ]: + return SchedGroupData.getAttributeParamNullValues(self, sAttr); + return ['', [], None]; + + def convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict): + aoNewValue = []; + if sAttr == 'aoMembers': + aidSelected = oDisp.getListOfIntParams(sParam, iMin = 1, iMax = 0x7ffffffe, aiDefaults = []) + sIds = oDisp.getStringParam(self.ksParam_aidTestGroups, sDefault = ''); + for idTestGroup in sIds.split(','): + try: idTestGroup = int(idTestGroup); + except: pass; + oDispWrapper = self.DispWrapper(oDisp, '%s[%s][%%s]' % (SchedGroupDataEx.ksParam_aoMembers, idTestGroup,)) + oMember = SchedGroupMemberDataEx().initFromParams(oDispWrapper, fStrict = False); + if idTestGroup in aidSelected: + oMember.idTestGroup = idTestGroup; + aoNewValue.append(oMember); + elif sAttr == 'aoTestBoxes': + aidSelected = oDisp.getListOfIntParams(sParam, iMin = 1, iMax = 0x7ffffffe, aiDefaults = []) + sIds = oDisp.getStringParam(self.ksParam_aidTestBoxes, sDefault = ''); + for idTestBox in sIds.split(','): + try: idTestBox = int(idTestBox); + except: pass; + oDispWrapper = self.DispWrapper(oDisp, '%s[%s][%%s]' % (SchedGroupDataEx.ksParam_aoTestBoxes, idTestBox,)) + oBoxInGrp = TestBoxDataForSchedGroup().initFromParams(oDispWrapper, fStrict = False); + if idTestBox in aidSelected: + oBoxInGrp.idTestBox = idTestBox; + aoNewValue.append(oBoxInGrp); + else: + return SchedGroupData.convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict); + return aoNewValue; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + if sAttr not in [ 'aoMembers', 'aoTestBoxes' ]: + return SchedGroupData._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + if oValue in aoNilValues: + return ([], None); + + asErrors = []; + aoNewMembers = []; + if sAttr == 'aoMembers': + asAllowNulls = ['bmHourlySchedule', 'idTestGroupPreReq', 'tsEffective', 'tsExpire', 'uidAuthor', ]; + if self.idSchedGroup in [None, '-1', -1]: + asAllowNulls.append('idSchedGroup'); # Probably new group, so allow null scheduling group. + + for oOldMember in oValue: + oNewMember = SchedGroupMemberDataEx().initFromOther(oOldMember); + aoNewMembers.append(oNewMember); + + dErrors = oNewMember.validateAndConvertEx(asAllowNulls, oDb, ModelDataBase.ksValidateFor_Other); + if dErrors: + asErrors.append(str(dErrors)); + + if not asErrors: + for i, _ in enumerate(aoNewMembers): + idTestGroup = aoNewMembers[i]; + for j in range(i + 1, len(aoNewMembers)): + if aoNewMembers[j].idTestGroup == idTestGroup: + asErrors.append('Duplicate test group #%d!' % (idTestGroup, )); + break; + else: + asAllowNulls = list(TestBoxDataForSchedGroup.kasAllowNullAttributes); + if self.idSchedGroup in [None, '-1', -1]: + asAllowNulls.append('idSchedGroup'); # Probably new group, so allow null scheduling group. + + for oOldMember in oValue: + oNewMember = TestBoxDataForSchedGroup().initFromOther(oOldMember); + aoNewMembers.append(oNewMember); + + dErrors = oNewMember.validateAndConvertEx(asAllowNulls, oDb, ModelDataBase.ksValidateFor_Other); + if dErrors: + asErrors.append(str(dErrors)); + + if not asErrors: + for i, _ in enumerate(aoNewMembers): + idTestBox = aoNewMembers[i]; + for j in range(i + 1, len(aoNewMembers)): + if aoNewMembers[j].idTestBox == idTestBox: + asErrors.append('Duplicate test box #%d!' % (idTestBox, )); + break; + + return (aoNewMembers, None if not asErrors else '
\n'.join(asErrors)); + + def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ModelDataBase.ksValidateFor_Other): + dErrors = SchedGroupData._validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor); + + # + # Fetch the extended build source bits. + # + if self.ksParam_idBuildSrc not in dErrors: + if self.idBuildSrc in self.getAttributeParamNullValues('idBuildSrc') \ + or self.idBuildSrc is None: + self.oBuildSrc = None; + else: + try: + self.oBuildSrc = BuildSourceData().initFromDbWithId(oDb, self.idBuildSrc); + except Exception as oXcpt: + self.oBuildSrc = BuildSourceData(); + dErrors[self.ksParam_idBuildSrc] = str(oXcpt); + + if self.ksParam_idBuildSrcTestSuite not in dErrors: + if self.idBuildSrcTestSuite in self.getAttributeParamNullValues('idBuildSrcTestSuite') \ + or self.idBuildSrcTestSuite is None: + self.oBuildSrcValidationKit = None; + else: + try: + self.oBuildSrcValidationKit = BuildSourceData().initFromDbWithId(oDb, self.idBuildSrcTestSuite); + except Exception as oXcpt: + self.oBuildSrcValidationKit = BuildSourceData(); + dErrors[self.ksParam_idBuildSrcTestSuite] = str(oXcpt); + + return dErrors; + + + +class SchedGroupLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + SchedGroup logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb); + self.dCache = None; + + # + # Standard methods. + # + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches build sources. + + Returns an array (list) of BuildSourceData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY fEnabled DESC, sName DESC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY fEnabled DESC, sName DESC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)); + + aoRet = []; + for aoRow in self._oDb.fetchAll(): + aoRet.append(SchedGroupDataEx().initFromDbRowEx(aoRow, self._oDb, tsNow)); + return aoRet; + + def fetchForChangeLog(self, idSchedGroup, iStart, cMaxRows, tsNow): # pylint: disable=too-many-locals,too-many-statements + """ + Fetches change log entries for a scheduling group. + + Returns an array of ChangeLogEntry instance and an indicator whether + there are more entries. + Raises exception on error. + """ + + ## @todo calc changes to scheduler group! + + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + + # + # First gather the change log timeline using the effective dates. + # (ASSUMES that we'll always have a separate delete entry, rather + # than just setting tsExpire.) + # + self._oDb.execute(''' +( +SELECT tsEffective, + uidAuthor +FROM SchedGroups +WHERE idSchedGroup = %s + AND tsEffective <= %s +ORDER BY tsEffective DESC +) UNION ( +SELECT CASE WHEN tsEffective + %s::INTERVAL = tsExpire THEN tsExpire ELSE tsEffective END, + uidAuthor +FROM SchedGroupMembers +WHERE idSchedGroup = %s + AND tsEffective <= %s +ORDER BY tsEffective DESC +) UNION ( +SELECT CASE WHEN tsEffective + %s::INTERVAL = tsExpire THEN tsExpire ELSE tsEffective END, + uidAuthor +FROM TestBoxesInSchedGroups +WHERE idSchedGroup = %s + AND tsEffective <= %s +ORDER BY tsEffective DESC +) +ORDER BY tsEffective DESC +LIMIT %s OFFSET %s +''', (idSchedGroup, tsNow, + db.dbOneTickIntervalString(), idSchedGroup, tsNow, + db.dbOneTickIntervalString(), idSchedGroup, tsNow, + cMaxRows + 1, iStart, )); + + aoEntries = [] # type: list[ChangeLogEntry] + tsPrevious = tsNow; + for aoDbRow in self._oDb.fetchAll(): + (tsEffective, uidAuthor) = aoDbRow; + aoEntries.append(ChangeLogEntry(uidAuthor, None, tsEffective, tsPrevious, None, None, [])); + tsPrevious = db.dbTimestampPlusOneTick(tsEffective); + + if True: # pylint: disable=using-constant-test + # + # Fetch data for each for each change log entry point. + # + # We add one tick to the timestamp here to skip past delete records + # that only there to record the user doing the deletion. + # + for iEntry, oEntry in enumerate(aoEntries): + oEntry.oNewRaw = SchedGroupDataEx().initFromDbWithId(self._oDb, idSchedGroup, oEntry.tsEffective); + if iEntry > 0: + aoEntries[iEntry - 1].oOldRaw = oEntry.oNewRaw; + + # Chop off the +1 entry, if any. + fMore = len(aoEntries) > cMaxRows; + if fMore: + aoEntries = aoEntries[:-1]; + + # Figure out the changes. + for oEntry in aoEntries: + oOld = oEntry.oOldRaw; + if not oOld: + break; + oNew = oEntry.oNewRaw; + aoChanges = oEntry.aoChanges; + for sAttr in oNew.getDataAttributes(): + if sAttr in [ 'tsEffective', 'tsExpire', 'uidAuthor', ]: + continue; + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr == oNewAttr: + continue; + if sAttr in [ 'aoMembers', 'aoTestBoxes', ]: + iNew = 0; + iOld = 0; + asNewAttr = []; + asOldAttr = []; + if sAttr == 'aoMembers': + # ASSUMES aoMembers is sorted by idTestGroupPreReq (nulls first), oTestGroup.sName, idTestGroup! + while iNew < len(oNewAttr) and iOld < len(oOldAttr): + if oNewAttr[iNew].idTestGroup == oOldAttr[iOld].idTestGroup: + if oNewAttr[iNew].idTestGroupPreReq != oOldAttr[iOld].idTestGroupPreReq: + if oNewAttr[iNew].idTestGroupPreReq is None: + asOldAttr.append('Dropped test group #%s (%s) dependency on #%s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName, + oOldAttr[iOld].idTestGroupPreReq)); + elif oOldAttr[iOld].idTestGroupPreReq is None: + asNewAttr.append('Added test group #%s (%s) dependency on #%s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName, + oNewAttr[iOld].idTestGroupPreReq)); + else: + asNewAttr.append('Test group #%s (%s) dependency on #%s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName, + oNewAttr[iNew].idTestGroupPreReq)); + asOldAttr.append('Test group #%s (%s) dependency on #%s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName, + oOldAttr[iOld].idTestGroupPreReq)); + if oNewAttr[iNew].iSchedPriority != oOldAttr[iOld].iSchedPriority: + asNewAttr.append('Test group #%s (%s) priority %s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName, + oNewAttr[iNew].iSchedPriority)); + asOldAttr.append('Test group #%s (%s) priority %s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName, + oOldAttr[iOld].iSchedPriority)); + iNew += 1; + iOld += 1; + elif oNewAttr[iNew].oTestGroup.sName < oOldAttr[iOld].oTestGroup.sName \ + or ( oNewAttr[iNew].oTestGroup.sName == oOldAttr[iOld].oTestGroup.sName + and oNewAttr[iNew].idTestGroup < oOldAttr[iOld].idTestGroup): + asNewAttr.append('New test group #%s - %s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName)); + iNew += 1; + else: + asOldAttr.append('Removed test group #%s - %s' + % (oOldAttr[iOld].idTestGroup, oOldAttr[iOld].oTestGroup.sName)); + iOld += 1; + while iNew < len(oNewAttr): + asNewAttr.append('New test group #%s - %s' + % (oNewAttr[iNew].idTestGroup, oNewAttr[iNew].oTestGroup.sName)); + iNew += 1; + while iOld < len(oOldAttr): + asOldAttr.append('Removed test group #%s - %s' + % (oOldAttr[iOld].idTestGroup, oOldAttr[iOld].oTestGroup.sName)); + iOld += 1; + else: + dNewIds = { oBoxInGrp.idTestBox: oBoxInGrp for oBoxInGrp in oNewAttr }; + dOldIds = { oBoxInGrp.idTestBox: oBoxInGrp for oBoxInGrp in oOldAttr }; + hCommonIds = set(dNewIds.keys()) & set(dOldIds.keys()); + for idTestBox in hCommonIds: + oNewBoxInGrp = dNewIds[idTestBox]; + oOldBoxInGrp = dOldIds[idTestBox]; + if oNewBoxInGrp.iSchedPriority != oOldBoxInGrp.iSchedPriority: + asNewAttr.append('Test box \'%s\' (#%s) priority %s' + % (getattr(oNewBoxInGrp.oTestBox, 'sName', '[Partial DB]'), + oNewBoxInGrp.idTestBox, oNewBoxInGrp.iSchedPriority)); + asOldAttr.append('Test box \'%s\' (#%s) priority %s' + % (getattr(oOldBoxInGrp.oTestBox, 'sName', '[Partial DB]'), + oOldBoxInGrp.idTestBox, oOldBoxInGrp.iSchedPriority)); + asNewAttr = sorted(asNewAttr); + asOldAttr = sorted(asOldAttr); + for idTestBox in set(dNewIds.keys()) - hCommonIds: + oNewBoxInGrp = dNewIds[idTestBox]; + asNewAttr.append('New test box \'%s\' (#%s) priority %s' + % (getattr(oNewBoxInGrp.oTestBox, 'sName', '[Partial DB]'), + oNewBoxInGrp.idTestBox, oNewBoxInGrp.iSchedPriority)); + for idTestBox in set(dOldIds.keys()) - hCommonIds: + oOldBoxInGrp = dOldIds[idTestBox]; + asOldAttr.append('Removed test box \'%s\' (#%s) priority %s' + % (getattr(oOldBoxInGrp.oTestBox, 'sName', '[Partial DB]'), + oOldBoxInGrp.idTestBox, oOldBoxInGrp.iSchedPriority)); + + if asNewAttr or asOldAttr: + aoChanges.append(AttributeChangeEntryPre(sAttr, oNewAttr, oOldAttr, + '\n'.join(asNewAttr), '\n'.join(asOldAttr))); + else: + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + + else: + ## + ## @todo Incomplete: A more complicate apporach, probably faster though. + ## + def findEntry(tsEffective, iPrev = 0): + """ Find entry with matching effective + expiration time """ + self._oDb.dprint('findEntry: iPrev=%s len(aoEntries)=%s tsEffective=%s' % (iPrev, len(aoEntries), tsEffective)); + while iPrev < len(aoEntries): + self._oDb.dprint('%s iPrev=%u' % (aoEntries[iPrev].tsEffective, iPrev, )); + if aoEntries[iPrev].tsEffective > tsEffective: + iPrev += 1; + elif aoEntries[iPrev].tsEffective == tsEffective: + self._oDb.dprint('hit %u' % (iPrev,)); + return iPrev; + else: + break; + self._oDb.dprint('%s not found!' % (tsEffective,)); + return -1; + + fMore = True; + + # + # Track scheduling group changes. Not terribly efficient for large cMaxRows + # values, but not in the mood for figure out if there is any way to optimize that. + # + self._oDb.execute(''' +SELECT * +FROM SchedGroups +WHERE idSchedGroup = %s + AND tsEffective <= %s +ORDER BY tsEffective DESC +LIMIT %s''', (idSchedGroup, aoEntries[0].tsEffective, cMaxRows + 1,)); + + iEntry = 0; + aaoRows = self._oDb.fetchAll(); + for iRow, oRow in enumerate(aaoRows): + oNew = SchedGroupDataEx().initFromDbRow(oRow); + iEntry = findEntry(oNew.tsEffective, iEntry); + self._oDb.dprint('iRow=%s iEntry=%s' % (iRow, iEntry)); + if iEntry < 0: + break; + oEntry = aoEntries[iEntry]; + aoChanges = oEntry.aoChanges; + oEntry.oNewRaw = oNew; + if iRow + 1 < len(aaoRows): + oOld = SchedGroupDataEx().initFromDbRow(aaoRows[iRow + 1]); + self._oDb.dprint('oOld=%s' % (oOld,)); + for sAttr in oNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', ]: + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr != oNewAttr: + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + else: + self._oDb.dprint('New'); + + # + # ... + # + + # FInally + UserAccountLogic(self._oDb).resolveChangeLogAuthors(aoEntries); + return (aoEntries, fMore); + + + def addEntry(self, oData, uidAuthor, fCommit = False): + """Add Scheduling Group record""" + + # + # Validate. + # + dDataErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dDataErrors: + raise TMInvalidData('Invalid data passed to addEntry: %s' % (dDataErrors,)); + if self.exists(oData.sName): + raise TMRowAlreadyExists('Scheduling group "%s" already exists.' % (oData.sName,)); + + # + # Add it. + # + self._oDb.execute('INSERT INTO SchedGroups (\n' + ' uidAuthor,\n' + ' sName,\n' + ' sDescription,\n' + ' fEnabled,\n' + ' enmScheduler,\n' + ' idBuildSrc,\n' + ' idBuildSrcTestSuite,\n' + ' sComment)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s)\n' + 'RETURNING idSchedGroup\n' + , ( uidAuthor, + oData.sName, + oData.sDescription, + oData.fEnabled, + oData.enmScheduler, + oData.idBuildSrc, + oData.idBuildSrcTestSuite, + oData.sComment )); + idSchedGroup = self._oDb.fetchOne()[0]; + oData.idSchedGroup = idSchedGroup; + + for oBoxInGrp in oData.aoTestBoxes: + oBoxInGrp.idSchedGroup = idSchedGroup; + self._addSchedGroupTestBox(uidAuthor, oBoxInGrp); + + for oMember in oData.aoMembers: + oMember.idSchedGroup = idSchedGroup; + self._addSchedGroupMember(uidAuthor, oMember); + + self._oDb.maybeCommit(fCommit); + return True; + + def editEntry(self, oData, uidAuthor, fCommit = False): + """Edit Scheduling Group record""" + + # + # Validate input and retrieve the old data. + # + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('editEntry got invalid data: %s' % (dErrors,)); + self._assertUnique(oData.sName, oData.idSchedGroup); + oOldData = SchedGroupDataEx().initFromDbWithId(self._oDb, oData.idSchedGroup); + + # + # Make the changes. + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', 'aoMembers', 'aoTestBoxes', + 'oBuildSrc', 'oBuildSrcValidationKit', ]): + self._historizeEntry(oData.idSchedGroup); + self._readdEntry(uidAuthor, oData); + + # Remove groups. + for oOld in oOldData.aoMembers: + fRemove = True; + for oNew in oData.aoMembers: + if oNew.idTestGroup == oOld.idTestGroup: + fRemove = False; + break; + if fRemove: + self._removeSchedGroupMember(uidAuthor, oOld); + + # Add / modify groups. + for oMember in oData.aoMembers: + oOldMember = None; + for oOld in oOldData.aoMembers: + if oOld.idTestGroup == oMember.idTestGroup: + oOldMember = oOld; + break; + + oMember.idSchedGroup = oData.idSchedGroup; + if oOldMember is None: + self._addSchedGroupMember(uidAuthor, oMember); + elif not oMember.isEqualEx(oOldMember, ['tsEffective', 'tsExpire', 'uidAuthor', 'oTestGroup']): + self._historizeSchedGroupMember(oMember); + self._addSchedGroupMember(uidAuthor, oMember); + + # Remove testboxes. + for oOld in oOldData.aoTestBoxes: + fRemove = True; + for oNew in oData.aoTestBoxes: + if oNew.idTestBox == oOld.idTestBox: + fRemove = False; + break; + if fRemove: + self._removeSchedGroupTestBox(uidAuthor, oOld); + + # Add / modify testboxes. + for oBoxInGrp in oData.aoTestBoxes: + oOldBoxInGrp = None; + for oOld in oOldData.aoTestBoxes: + if oOld.idTestBox == oBoxInGrp.idTestBox: + oOldBoxInGrp = oOld; + break; + + oBoxInGrp.idSchedGroup = oData.idSchedGroup; + if oOldBoxInGrp is None: + self._addSchedGroupTestBox(uidAuthor, oBoxInGrp); + elif not oBoxInGrp.isEqualEx(oOldBoxInGrp, ['tsEffective', 'tsExpire', 'uidAuthor', 'oTestBox']): + self._historizeSchedGroupTestBox(oBoxInGrp); + self._addSchedGroupTestBox(uidAuthor, oBoxInGrp); + + self._oDb.maybeCommit(fCommit); + return True; + + def removeEntry(self, uidAuthor, idSchedGroup, fCascade = False, fCommit = False): + """ + Deletes a scheduling group. + """ + _ = fCascade; + + # + # Input validation and retrival of current data. + # + if idSchedGroup == 1: + raise TMRowInUse('Cannot remove the default scheduling group (id 1).'); + oData = SchedGroupDataEx().initFromDbWithId(self._oDb, idSchedGroup); + + # + # Remove the test box member records. + # + for oBoxInGrp in oData.aoTestBoxes: + self._removeSchedGroupTestBox(uidAuthor, oBoxInGrp); + self._oDb.execute('UPDATE TestBoxesInSchedGroups\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idSchedGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idSchedGroup,)); + + # + # Remove the test group member records. + # + for oMember in oData.aoMembers: + self._removeSchedGroupMember(uidAuthor, oMember); + self._oDb.execute('UPDATE SchedGroupMembers\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idSchedGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idSchedGroup,)); + + # + # Now the SchedGroups entry. + # + (tsCur, tsCurMinusOne) = self._oDb.getCurrentTimestamps(); + if oData.tsEffective not in (tsCur, tsCurMinusOne): + self._historizeEntry(idSchedGroup, tsCurMinusOne); + self._readdEntry(uidAuthor, oData, tsCurMinusOne); + self._historizeEntry(idSchedGroup); + self._oDb.execute('UPDATE SchedGroups\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idSchedGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idSchedGroup,)) + + self._oDb.maybeCommit(fCommit) + return True; + + + def cachedLookup(self, idSchedGroup): + """ + Looks up the most recent SchedGroupData object for idSchedGroup + via an object cache. + + Returns a shared SchedGroupData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('SchedGroup'); + + oEntry = self.dCache.get(idSchedGroup, None); + if oEntry is None: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE idSchedGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idSchedGroup, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE idSchedGroup = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idSchedGroup, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idSchedGroup)); + + if self._oDb.getRowCount() == 1: + oEntry = SchedGroupData().initFromDbRow(self._oDb.fetchOne()); + self.dCache[idSchedGroup] = oEntry; + return oEntry; + + + # + # Other methods. + # + + def fetchOrderedByName(self, tsNow = None): + """ + Return list of objects of type SchedGroups ordered by name. + May raise exception on database error. + """ + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sName ASC\n'); + else: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sName ASC\n' + , (tsNow, tsNow,)); + aoRet = [] + for _ in range(self._oDb.getRowCount()): + aoRet.append(SchedGroupData().initFromDbRow(self._oDb.fetchOne())); + return aoRet; + + + def getAll(self, tsEffective = None): + """ + Gets the list of all scheduling groups. + Returns an array of SchedGroupData instances. + """ + if tsEffective is None: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n'); + else: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + , (tsEffective, tsEffective)); + aoRet = []; + for aoRow in self._oDb.fetchAll(): + aoRet.append(SchedGroupData().initFromDbRow(aoRow)); + return aoRet; + + def getSchedGroupsForCombo(self, tsEffective = None): + """ + Gets the list of active scheduling groups for a combo box. + Returns an array of (value [idSchedGroup], drop-down-name [sName], + hover-text [sDescription]) tuples. + """ + if tsEffective is None: + self._oDb.execute('SELECT idSchedGroup, sName, sDescription\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sName'); + else: + self._oDb.execute('SELECT idSchedGroup, sName, sDescription\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sName' + , (tsEffective, tsEffective)); + return self._oDb.fetchAll(); + + + def getMembers(self, idSchedGroup, tsEffective = None): + """ + Gets the scheduling groups members for the given scheduling group. + + Returns an array of SchedGroupMemberDataEx instances (sorted by + priority (descending) and idTestGroup). May raise exception DB error. + """ + + if tsEffective is None: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroupMembers, TestGroups\n' + 'WHERE SchedGroupMembers.idSchedGroup = %s\n' + ' AND SchedGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestGroups.idTestGroup = SchedGroupMembers.idTestGroup\n' + ' AND TestGroups.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY SchedGroupMembers.iSchedPriority DESC, SchedGroupMembers.idTestGroup\n' + , (idSchedGroup,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM SchedGroupMembers, TestGroups\n' + 'WHERE SchedGroupMembers.idSchedGroup = %s\n' + ' AND SchedGroupMembers.tsExpire < %s\n' + ' AND SchedGroupMembers.tsEffective >= %s\n' + ' AND TestGroups.idTestGroup = SchedGroupMembers.idTestGroup\n' + ' AND TestGroups.tsExpire < %s\n' + ' AND TestGroups.tsEffective >= %s\n' + 'ORDER BY SchedGroupMembers.iSchedPriority DESC, SchedGroupMembers.idTestGroup\n' + , (idSchedGroup, tsEffective, tsEffective, tsEffective, tsEffective, )); + aaoRows = self._oDb.fetchAll(); + aoRet = []; + for aoRow in aaoRows: + aoRet.append(SchedGroupMemberDataEx().initFromDbRow(aoRow)); + return aoRet; + + def getTestCasesForGroup(self, idSchedGroup, cMax = None): + """ + Gets the enabled testcases w/ testgroup+priority for the given scheduling group. + + Returns an array of TestCaseData instances (ordered by group id, descending + testcase priority, and testcase IDs) with an extra iSchedPriority member. + May raise exception on DB error or if the result exceeds cMax. + """ + + self._oDb.execute('SELECT TestGroupMembers.idTestGroup, TestGroupMembers.iSchedPriority, TestCases.*\n' + 'FROM SchedGroupMembers, TestGroups, TestGroupMembers, TestCases\n' + 'WHERE SchedGroupMembers.idSchedGroup = %s\n' + ' AND SchedGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestGroups.idTestGroup = SchedGroupMembers.idTestGroup\n' + ' AND TestGroups.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestGroupMembers.idTestGroup = TestGroups.idTestGroup\n' + ' AND TestGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestCases.idTestCase = TestGroupMembers.idTestCase\n' + ' AND TestCases.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestCases.fEnabled = TRUE\n' + 'ORDER BY TestGroupMembers.idTestGroup, TestGroupMembers.iSchedPriority DESC, TestCases.idTestCase\n' + , (idSchedGroup,)); + + if cMax is not None and self._oDb.getRowCount() > cMax: + raise TMExceptionBase('Too many testcases for scheduling group %s: %s, max %s' + % (idSchedGroup, cMax, self._oDb.getRowCount(),)); + + aoRet = []; + for aoRow in self._oDb.fetchAll(): + oTestCase = TestCaseData().initFromDbRow(aoRow[2:]); + oTestCase.idTestGroup = aoRow[0]; + oTestCase.iSchedPriority = aoRow[1]; + aoRet.append(oTestCase); + return aoRet; + + def getTestCaseArgsForGroup(self, idSchedGroup, cMax = None): + """ + Gets the testcase argument variation w/ testgroup+priority for the given scheduling group. + + Returns an array TestCaseArgsData instance (sorted by group and + variation id) with an extra iSchedPriority member. + May raise exception on DB error or if the result exceeds cMax. + """ + + self._oDb.execute('SELECT TestGroupMembers.idTestGroup, TestGroupMembers.iSchedPriority, TestCaseArgs.*\n' + 'FROM SchedGroupMembers, TestGroups, TestGroupMembers, TestCaseArgs, TestCases\n' + 'WHERE SchedGroupMembers.idSchedGroup = %s\n' + ' AND SchedGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestGroups.idTestGroup = SchedGroupMembers.idTestGroup\n' + ' AND TestGroups.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestGroupMembers.idTestGroup = TestGroups.idTestGroup\n' + ' AND TestGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestCaseArgs.idTestCase = TestGroupMembers.idTestCase\n' + ' AND TestCaseArgs.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND ( TestGroupMembers.aidTestCaseArgs is NULL\n' + ' OR TestCaseArgs.idTestCaseArgs = ANY(TestGroupMembers.aidTestCaseArgs) )\n' + ' AND TestCases.idTestCase = TestCaseArgs.idTestCase\n' + ' AND TestCases.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestCases.fEnabled = TRUE\n' + 'ORDER BY TestGroupMembers.idTestGroup, TestGroupMembers.idTestCase, TestCaseArgs.idTestCaseArgs\n' + , (idSchedGroup,)); + + if cMax is not None and self._oDb.getRowCount() > cMax: + raise TMExceptionBase('Too many argument variations for scheduling group %s: %s, max %s' + % (idSchedGroup, cMax, self._oDb.getRowCount(),)); + + aoRet = []; + for aoRow in self._oDb.fetchAll(): + oVariation = TestCaseArgsData().initFromDbRow(aoRow[2:]); + oVariation.idTestGroup = aoRow[0]; + oVariation.iSchedPriority = aoRow[1]; + aoRet.append(oVariation); + return aoRet; + + def exists(self, sName): + """Checks if a group with the given name exists.""" + self._oDb.execute('SELECT idSchedGroup\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND sName = %s\n' + 'LIMIT 1\n' + , (sName,)); + return self._oDb.getRowCount() > 0; + + def getById(self, idSchedGroup): + """Get Scheduling Group data by idSchedGroup""" + self._oDb.execute('SELECT *\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::timestamp\n' + ' AND idSchedGroup = %s;', (idSchedGroup,)) + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise self._oDb.integrityException( + 'Found more than one scheduling groups with the same credentials. Database structure is corrupted.') + try: + return SchedGroupData().initFromDbRow(aRows[0]) + except IndexError: + return None + + + # + # Internal helpers. + # + + def _assertUnique(self, sName, idSchedGroupIgnore = None): + """ + Checks that the scheduling group name is unique. + Raises exception if the name is already in use. + """ + if idSchedGroupIgnore is None: + self._oDb.execute('SELECT idSchedGroup\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND sName = %s\n' + , ( sName, ) ); + else: + self._oDb.execute('SELECT idSchedGroup\n' + 'FROM SchedGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND sName = %s\n' + ' AND idSchedGroup <> %s\n' + , ( sName, idSchedGroupIgnore, ) ); + if self._oDb.getRowCount() > 0: + raise TMRowInUse('Scheduling group name (%s) is already in use.' % (sName,)); + return True; + + def _readdEntry(self, uidAuthor, oData, tsEffective = None): + """ + Re-adds the SchedGroups entry. Used by editEntry and removeEntry. + """ + if tsEffective is None: + tsEffective = self._oDb.getCurrentTimestamp(); + self._oDb.execute('INSERT INTO SchedGroups (\n' + ' uidAuthor,\n' + ' tsEffective,\n' + ' idSchedGroup,\n' + ' sName,\n' + ' sDescription,\n' + ' fEnabled,\n' + ' enmScheduler,\n' + ' idBuildSrc,\n' + ' idBuildSrcTestSuite,\n' + ' sComment )\n' + 'VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s )\n' + , ( uidAuthor, + tsEffective, + oData.idSchedGroup, + oData.sName, + oData.sDescription, + oData.fEnabled, + oData.enmScheduler, + oData.idBuildSrc, + oData.idBuildSrcTestSuite, + oData.sComment, )); + return True; + + def _historizeEntry(self, idSchedGroup, tsExpire = None): + """ + Historizes the current entry for the given scheduling group. + """ + if tsExpire is None: + tsExpire = self._oDb.getCurrentTimestamp(); + self._oDb.execute('UPDATE SchedGroups\n' + 'SET tsExpire = %s\n' + 'WHERE idSchedGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( tsExpire, idSchedGroup, )); + return True; + + def _addSchedGroupMember(self, uidAuthor, oMember, tsEffective = None): + """ + addEntry worker for adding a scheduling group member. + """ + if tsEffective is None: + tsEffective = self._oDb.getCurrentTimestamp(); + self._oDb.execute('INSERT INTO SchedGroupMembers(\n' + ' idSchedGroup,\n' + ' idTestGroup,\n' + ' tsEffective,\n' + ' uidAuthor,\n' + ' iSchedPriority,\n' + ' bmHourlySchedule,\n' + ' idTestGroupPreReq)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s)\n' + , ( oMember.idSchedGroup, + oMember.idTestGroup, + tsEffective, + uidAuthor, + oMember.iSchedPriority, + oMember.bmHourlySchedule, + oMember.idTestGroupPreReq, )); + return True; + + def _removeSchedGroupMember(self, uidAuthor, oMember): + """ + Removes a scheduling group member. + """ + + # Try record who removed it by adding an dummy entry that expires immediately. + (tsCur, tsCurMinusOne) = self._oDb.getCurrentTimestamps(); + if oMember.tsEffective not in (tsCur, tsCurMinusOne): + self._historizeSchedGroupMember(oMember, tsCurMinusOne); + self._addSchedGroupMember(uidAuthor, oMember, tsCurMinusOne); # lazy bird. + self._historizeSchedGroupMember(oMember); + else: + self._historizeSchedGroupMember(oMember); + return True; + + def _historizeSchedGroupMember(self, oMember, tsExpire = None): + """ + Historizes the current entry for the given scheduling group. + """ + if tsExpire is None: + tsExpire = self._oDb.getCurrentTimestamp(); + self._oDb.execute('UPDATE SchedGroupMembers\n' + 'SET tsExpire = %s\n' + 'WHERE idSchedGroup = %s\n' + ' AND idTestGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( tsExpire, oMember.idSchedGroup, oMember.idTestGroup, )); + return True; + + # + def _addSchedGroupTestBox(self, uidAuthor, oBoxInGroup, tsEffective = None): + """ + addEntry worker for adding a test box to a scheduling group. + """ + if tsEffective is None: + tsEffective = self._oDb.getCurrentTimestamp(); + self._oDb.execute('INSERT INTO TestBoxesInSchedGroups(\n' + ' idSchedGroup,\n' + ' idTestBox,\n' + ' tsEffective,\n' + ' uidAuthor,\n' + ' iSchedPriority)\n' + 'VALUES (%s, %s, %s, %s, %s)\n' + , ( oBoxInGroup.idSchedGroup, + oBoxInGroup.idTestBox, + tsEffective, + uidAuthor, + oBoxInGroup.iSchedPriority, )); + return True; + + def _removeSchedGroupTestBox(self, uidAuthor, oBoxInGroup): + """ + Removes a testbox from a scheduling group. + """ + + # Try record who removed it by adding an dummy entry that expires immediately. + (tsCur, tsCurMinusOne) = self._oDb.getCurrentTimestamps(); + if oBoxInGroup.tsEffective not in (tsCur, tsCurMinusOne): + self._historizeSchedGroupTestBox(oBoxInGroup, tsCurMinusOne); + self._addSchedGroupTestBox(uidAuthor, oBoxInGroup, tsCurMinusOne); # lazy bird. + self._historizeSchedGroupTestBox(oBoxInGroup); + else: + self._historizeSchedGroupTestBox(oBoxInGroup); + return True; + + def _historizeSchedGroupTestBox(self, oBoxInGroup, tsExpire = None): + """ + Historizes the current entry for the given scheduling group. + """ + if tsExpire is None: + tsExpire = self._oDb.getCurrentTimestamp(); + self._oDb.execute('UPDATE TestBoxesInSchedGroups\n' + 'SET tsExpire = %s\n' + 'WHERE idSchedGroup = %s\n' + ' AND idTestBox = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( tsExpire, oBoxInGroup.idSchedGroup, oBoxInGroup.idTestBox, )); + return True; + + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class SchedGroupMemberDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [SchedGroupMemberData(),]; + +class SchedGroupDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [SchedGroupData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/schedqueue.py b/src/VBox/ValidationKit/testmanager/core/schedqueue.py new file mode 100755 index 00000000..f49c4ad3 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/schedqueue.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# "$Id: schedqueue.py $" + +""" +Test Manager - Test Case Queue. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + +## Standard python imports. +#import unittest + +from testmanager.core.base import ModelDataBase, ModelLogicBase, TMExceptionBase #, ModelDataBaseTestCase + + +class SchedQueueEntry(ModelDataBase): + """ + SchedQueue listing entry + + Note! This could be turned into a SchedQueueDataEx class if we start + fetching all the fields from the scheduing queue. + """ + + def __init__(self): + ModelDataBase.__init__(self) + + self.idItem = None + self.tsLastScheduled = None + self.sSchedGroup = None + self.sTestGroup = None + self.sTestCase = None + self.fUpToDate = None + self.iPerSchedGroupRowNumber = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the object from a SchedQueueLogic::fetchForListing select. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMExceptionBase('TestCaseQueue row not found.') + + self.idItem = aoRow[0] + self.tsLastScheduled = aoRow[1] + self.sSchedGroup = aoRow[2] + self.sTestGroup = aoRow[3] + self.sTestCase = aoRow[4] + self.fUpToDate = aoRow[5] + self.iPerSchedGroupRowNumber = aoRow[6]; + return self + + +class SchedQueueLogic(ModelLogicBase): + """ + SchedQueues logic. + """ + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches SchedQueues entries. + + Returns an array (list) of SchedQueueEntry items, empty list if none. + Raises exception on error. + """ + _, _ = tsNow, aiSortColumns + self._oDb.execute(''' +SELECT SchedQueues.idItem, + SchedQueues.tsLastScheduled, + SchedGroups.sName, + TestGroups.sName, + TestCases.sName, + SchedGroups.tsExpire = 'infinity'::TIMESTAMP + AND TestGroups.tsExpire = 'infinity'::TIMESTAMP + AND TestGroups.tsExpire = 'infinity'::TIMESTAMP + AND TestCaseArgs.tsExpire = 'infinity'::TIMESTAMP + AND TestCases.tsExpire = 'infinity'::TIMESTAMP AS fUpToDate, + ROW_NUMBER() OVER (PARTITION BY SchedQueues.idSchedGroup + ORDER BY SchedQueues.tsLastScheduled, + SchedQueues.idItem) AS iPerSchedGroupRowNumber +FROM SchedQueues + INNER JOIN SchedGroups + ON SchedGroups.idSchedGroup = SchedQueues.idSchedGroup + AND SchedGroups.tsExpire > SchedQueues.tsConfig + AND SchedGroups.tsEffective <= SchedQueues.tsConfig + INNER JOIN TestGroups + ON TestGroups.idTestGroup = SchedQueues.idTestGroup + AND TestGroups.tsExpire > SchedQueues.tsConfig + AND TestGroups.tsEffective <= SchedQueues.tsConfig + INNER JOIN TestCaseArgs + ON TestCaseArgs.idGenTestCaseArgs = SchedQueues.idGenTestCaseArgs + INNER JOIN TestCases + ON TestCases.idTestCase = TestCaseArgs.idTestCase + AND TestCases.tsExpire > SchedQueues.tsConfig + AND TestCases.tsEffective <= SchedQueues.tsConfig +ORDER BY iPerSchedGroupRowNumber, + SchedGroups.sName DESC +LIMIT %s OFFSET %s''' % (cMaxRows, iStart,)) + aoRows = [] + for _ in range(self._oDb.getRowCount()): + aoRows.append(SchedQueueEntry().initFromDbRow(self._oDb.fetchOne())) + return aoRows + +# +# Unit testing. +# + +## @todo SchedQueueEntry isn't a typical ModelDataBase child (not fetching all +## fields; is an extended data class mixing data from multiple tables), so +## this won't work yet. +# +## pylint: disable=missing-docstring +#class TestCaseQueueDataTestCase(ModelDataBaseTestCase): +# def setUp(self): +# self.aoSamples = [SchedQueueEntry(),] +# +# +#if __name__ == '__main__': +# unittest.main() +# # not reached. +# diff --git a/src/VBox/ValidationKit/testmanager/core/schedulerbase.py b/src/VBox/ValidationKit/testmanager/core/schedulerbase.py new file mode 100755 index 00000000..c28b43cf --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/schedulerbase.py @@ -0,0 +1,1570 @@ +# -*- coding: utf-8 -*- +# $Id: schedulerbase.py $ +# pylint: disable=too-many-lines + + +""" +Test Manager - Base class and utilities for the schedulers. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import sys; +import unittest; + +# Validation Kit imports. +from common import utils, constants; +from testmanager import config; +from testmanager.core.build import BuildDataEx, BuildLogic; +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, TMExceptionBase; +from testmanager.core.buildsource import BuildSourceData, BuildSourceLogic; +from testmanager.core.globalresource import GlobalResourceLogic; +from testmanager.core.schedgroup import SchedGroupData, SchedGroupLogic; +from testmanager.core.systemlog import SystemLogData, SystemLogLogic; +from testmanager.core.testbox import TestBoxData, TestBoxDataEx; +from testmanager.core.testboxstatus import TestBoxStatusData, TestBoxStatusLogic; +from testmanager.core.testcase import TestCaseLogic; +from testmanager.core.testcaseargs import TestCaseArgsDataEx, TestCaseArgsLogic; +from testmanager.core.testset import TestSetData, TestSetLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + xrange = range; # pylint: disable=redefined-builtin,invalid-name + + + +class ReCreateQueueData(object): + """ + Data object for recreating a scheduling queue. + + It's mostly a storage object, but has a few data checking operation + associated with it. + """ + + def __init__(self, oDb, idSchedGroup): + # + # Load data from the database. + # + oSchedGroupLogic = SchedGroupLogic(oDb); + self.oSchedGroup = oSchedGroupLogic.cachedLookup(idSchedGroup); + + # Will extend the entries with aoTestCases and dTestCases members + # further down (SchedGroupMemberDataEx). checkForGroupDepCycles + # will add aidTestGroupPreReqs. + self.aoTestGroups = oSchedGroupLogic.getMembers(idSchedGroup); + + # aoTestCases entries are TestCaseData instance with iSchedPriority + # and idTestGroup added for our purposes. + # We will add oTestGroup and aoArgsVariations members to each further down. + self.aoTestCases = oSchedGroupLogic.getTestCasesForGroup(idSchedGroup, cMax = 4096); + + # Load dependencies. + oTestCaseLogic = TestCaseLogic(oDb) + for oTestCase in self.aoTestCases: + oTestCase.aidPreReqs = oTestCaseLogic.getTestCasePreReqIds(oTestCase.idTestCase, cMax = 4096); + + # aoTestCases entries are TestCaseArgsData instance with iSchedPriority + # and idTestGroup added for our purposes. + # We will add oTestGroup and oTestCase members to each further down. + self.aoArgsVariations = oSchedGroupLogic.getTestCaseArgsForGroup(idSchedGroup, cMax = 65536); + + # + # Generate global lookups. + # + + # Generate a testcase lookup dictionary for use when working on + # argument variations. + self.dTestCases = {}; + for oTestCase in self.aoTestCases: + self.dTestCases[oTestCase.idTestCase] = oTestCase; + assert len(self.dTestCases) <= len(self.aoTestCases); # Note! Can be shorter! + + # Generate a testgroup lookup dictionary. + self.dTestGroups = {}; + for oTestGroup in self.aoTestGroups: + self.dTestGroups[oTestGroup.idTestGroup] = oTestGroup; + assert len(self.dTestGroups) == len(self.aoTestGroups); + + # + # Associate extra members with the base data. + # + if self.aoTestGroups: + # Prep the test groups. + for oTestGroup in self.aoTestGroups: + oTestGroup.aoTestCases = []; + oTestGroup.dTestCases = {}; + + # Link testcases to their group, both directions. Prep testcases for + # argument varation association. + oTestGroup = self.aoTestGroups[0]; + for oTestCase in self.aoTestCases: + if oTestGroup.idTestGroup != oTestCase.idTestGroup: + oTestGroup = self.dTestGroups[oTestCase.idTestGroup]; + + assert oTestCase.idTestCase not in oTestGroup.dTestCases; + oTestGroup.dTestCases[oTestCase.idTestCase] = oTestCase; + oTestGroup.aoTestCases.append(oTestCase); + oTestCase.oTestGroup = oTestGroup; + oTestCase.aoArgsVariations = []; + + # Associate testcase argument variations with their testcases (group) + # in both directions. + oTestGroup = self.aoTestGroups[0]; + oTestCase = self.aoTestCases[0] if self.aoTestCases else None; + for oArgVariation in self.aoArgsVariations: + if oTestGroup.idTestGroup != oArgVariation.idTestGroup: + oTestGroup = self.dTestGroups[oArgVariation.idTestGroup]; + if oTestCase.idTestCase != oArgVariation.idTestCase or oTestCase.idTestGroup != oArgVariation.idTestGroup: + oTestCase = oTestGroup.dTestCases[oArgVariation.idTestCase]; + + oTestCase.aoArgsVariations.append(oArgVariation); + oArgVariation.oTestCase = oTestCase; + oArgVariation.oTestGroup = oTestGroup; + + else: + assert not self.aoTestCases; + assert not self.aoArgsVariations; + # done. + + @staticmethod + def _addPreReqError(aoErrors, aidChain, oObj, sMsg): + """ Returns a chain of IDs error entry. """ + + sMsg += ' Dependency chain: %s' % (aidChain[0],); + for i in range(1, len(aidChain)): + sMsg += ' -> %s' % (aidChain[i],); + + aoErrors.append([sMsg, oObj]); + return aoErrors; + + def checkForGroupDepCycles(self): + """ + Checks for testgroup depencency cycles and any missing testgroup + dependencies. + Returns array of errors (see SchedulderBase.recreateQueue()). + """ + aoErrors = []; + for oTestGroup in self.aoTestGroups: + idPreReq = oTestGroup.idTestGroupPreReq; + if idPreReq is None: + oTestGroup.aidTestGroupPreReqs = []; + continue; + + aidChain = [oTestGroup.idTestGroup,]; + while idPreReq is not None: + aidChain.append(idPreReq); + if len(aidChain) >= 10: + self._addPreReqError(aoErrors, aidChain, oTestGroup, + 'TestGroup #%s prerequisite chain is too long!' + % (oTestGroup.idTestGroup,)); + break; + + oDep = self.dTestGroups.get(idPreReq, None); + if oDep is None: + self._addPreReqError(aoErrors, aidChain, oTestGroup, + 'TestGroup #%s prerequisite #%s is not in the scheduling group!' + % (oTestGroup.idTestGroup, idPreReq,)); + break; + + idPreReq = oDep.idTestGroupPreReq; + oTestGroup.aidTestGroupPreReqs = aidChain[1:]; + + return aoErrors; + + + def checkForMissingTestCaseDeps(self): + """ + Checks that testcase dependencies stays within bounds. We do not allow + dependencies outside a testgroup, no dependency cycles or even remotely + long dependency chains. + + Returns array of errors (see SchedulderBase.recreateQueue()). + """ + aoErrors = []; + for oTestGroup in self.aoTestGroups: + for oTestCase in oTestGroup.aoTestCases: + if not oTestCase.aidPreReqs: + continue; + + # Stupid recursion code using special stack(s). + aiIndexes = [[oTestCase, 0], ]; + aidChain = [oTestCase.idTestGroup,]; + while aiIndexes: + (oCur, i) = aiIndexes[-1]; + if i >= len(oCur.aidPreReqs): + aiIndexes.pop(); + aidChain.pop(); + else: + aiIndexes[-1][1] = i + 1; # whatever happens, we'll advance on the current level. + + idPreReq = oTestCase.aidPreReqs[i]; + oDep = oTestGroup.dTestCases.get(idPreReq, None); + if oDep is None: + self._addPreReqError(aoErrors, aidChain, oTestCase, + 'TestCase #%s prerequisite #%s is not in the scheduling group!' + % (oTestCase.idTestCase, idPreReq)); + elif idPreReq in aidChain: + self._addPreReqError(aoErrors, aidChain, oTestCase, + 'TestCase #%s prerequisite #%s creates a cycle!' + % (oTestCase.idTestCase, idPreReq)); + elif not oDep.aiPreReqs: + pass; + elif len(aidChain) >= 10: + self._addPreReqError(aoErrors, aidChain, oTestCase, + 'TestCase #%s prerequisite chain is too long!' % (oTestCase.idTestCase,)); + else: + aiIndexes.append([oDep, 0]); + aidChain.append(idPreReq); + + return aoErrors; + + def deepTestGroupSort(self): + """ + Sorts the testgroups and their testcases by priority and dependencies. + Note! Don't call this before checking for dependency cycles! + """ + if not self.aoTestGroups: + return; + + # + # ASSUMES groups as well as testcases are sorted by priority by the + # database. So we only have to concern ourselves with the dependency + # sorting. + # + iGrpPrio = self.aoTestGroups[0].iSchedPriority; + for iTestGroup, oTestGroup in enumerate(self.aoTestGroups): + if oTestGroup.iSchedPriority > iGrpPrio: + raise TMExceptionBase('Incorrectly sorted testgroups returned by database: iTestGroup=%s prio=%s %s' + % ( iTestGroup, iGrpPrio, + ', '.join(['(%s: %s)' % (oCur.idTestGroup, oCur.iSchedPriority) + for oCur in self.aoTestGroups]), ) ); + iGrpPrio = oTestGroup.iSchedPriority; + + if oTestGroup.aoTestCases: + iTstPrio = oTestGroup.aoTestCases[0].iSchedPriority; + for iTestCase, oTestCase in enumerate(oTestGroup.aoTestCases): + if oTestCase.iSchedPriority > iTstPrio: + raise TMExceptionBase('Incorrectly sorted testcases returned by database: i=%s prio=%s idGrp=%s %s' + % ( iTestCase, iTstPrio, oTestGroup.idTestGroup, + ', '.join(['(%s: %s)' % (oCur.idTestCase, oCur.iSchedPriority) + for oCur in oTestGroup.aoTestCases]),)); + + # + # Sort the testgroups by dependencies. + # + i = 0; + while i < len(self.aoTestGroups): + oTestGroup = self.aoTestGroups[i]; + if oTestGroup.idTestGroupPreReq is not None: + iPreReq = self.aoTestGroups.index(self.dTestGroups[oTestGroup.idTestGroupPreReq]); + if iPreReq > i: + # The prerequisite is after the current entry. Move the + # current entry so that it's following it's prereq entry. + self.aoTestGroups.insert(iPreReq + 1, oTestGroup); + self.aoTestGroups.pop(i); + continue; + assert iPreReq < i; + i += 1; # Advance. + + # + # Sort the testcases by dependencies. + # Same algorithm as above, just more prerequisites. + # + for oTestGroup in self.aoTestGroups: + i = 0; + while i < len(oTestGroup.aoTestCases): + oTestCase = oTestGroup.aoTestCases[i]; + if oTestCase.aidPreReqs: + for idPreReq in oTestCase.aidPreReqs: + iPreReq = oTestGroup.aoTestCases.index(oTestGroup.dTestCases[idPreReq]); + if iPreReq > i: + # The prerequisite is after the current entry. Move the + # current entry so that it's following it's prereq entry. + oTestGroup.aoTestGroups.insert(iPreReq + 1, oTestCase); + oTestGroup.aoTestGroups.pop(i); + i -= 1; # Don't advance. + break; + assert iPreReq < i; + i += 1; # Advance. + + + +class SchedQueueData(ModelDataBase): + """ + Scheduling queue data item. + """ + + ksIdAttr = 'idSchedGroup'; + + ksParam_idSchedGroup = 'SchedQueueData_idSchedGroup'; + ksParam_idItem = 'SchedQueueData_idItem'; + ksParam_offQueue = 'SchedQueueData_offQueue'; + ksParam_idGenTestCaseArgs = 'SchedQueueData_idGenTestCaseArgs'; + ksParam_idTestGroup = 'SchedQueueData_idTestGroup'; + ksParam_aidTestGroupPreReqs = 'SchedQueueData_aidTestGroupPreReqs'; + ksParam_bmHourlySchedule = 'SchedQueueData_bmHourlySchedule'; + ksParam_tsConfig = 'SchedQueueData_tsConfig'; + ksParam_tsLastScheduled = 'SchedQueueData_tsLastScheduled'; + ksParam_idTestSetGangLeader = 'SchedQueueData_idTestSetGangLeader'; + ksParam_cMissingGangMembers = 'SchedQueueData_cMissingGangMembers'; + + kasAllowNullAttributes = [ 'idItem', 'offQueue', 'aidTestGroupPreReqs', 'bmHourlySchedule', 'idTestSetGangLeader', + 'tsConfig', 'tsLastScheduled' ]; + + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idSchedGroup = None; + self.idItem = None; + self.offQueue = None; + self.idGenTestCaseArgs = None; + self.idTestGroup = None; + self.aidTestGroupPreReqs = None; + self.bmHourlySchedule = None; + self.tsConfig = None; + self.tsLastScheduled = None; + self.idTestSetGangLeader = None; + self.cMissingGangMembers = 1; + + def initFromValues(self, idSchedGroup, idGenTestCaseArgs, idTestGroup, aidTestGroupPreReqs, # pylint: disable=too-many-arguments + bmHourlySchedule, cMissingGangMembers, + idItem = None, offQueue = None, tsConfig = None, tsLastScheduled = None, idTestSetGangLeader = None): + """ + Reinitialize with all attributes potentially given as inputs. + Return self. + """ + self.idSchedGroup = idSchedGroup; + self.idItem = idItem; + self.offQueue = offQueue; + self.idGenTestCaseArgs = idGenTestCaseArgs; + self.idTestGroup = idTestGroup; + self.aidTestGroupPreReqs = aidTestGroupPreReqs; + self.bmHourlySchedule = bmHourlySchedule; + self.tsConfig = tsConfig; + self.tsLastScheduled = tsLastScheduled; + self.idTestSetGangLeader = idTestSetGangLeader; + self.cMissingGangMembers = cMissingGangMembers; + return self; + + def initFromDbRow(self, aoRow): + """ + Initialize from database row (SELECT * FROM SchedQueues). + Returns self. + Raises exception if no row is specfied. + """ + if aoRow is None: + raise TMExceptionBase('SchedQueueData not found.'); + + self.idSchedGroup = aoRow[0]; + self.idItem = aoRow[1]; + self.offQueue = aoRow[2]; + self.idGenTestCaseArgs = aoRow[3]; + self.idTestGroup = aoRow[4]; + self.aidTestGroupPreReqs = aoRow[5]; + self.bmHourlySchedule = aoRow[6]; + self.tsConfig = aoRow[7]; + self.tsLastScheduled = aoRow[8]; + self.idTestSetGangLeader = aoRow[9]; + self.cMissingGangMembers = aoRow[10]; + return self; + + + + + + +class SchedulerBase(object): + """ + The scheduler base class. + + The scheduler classes have two functions: + 1. Recreate the scheduling queue. + 2. Pick the next task from the queue. + + The first is scheduler specific, the latter isn't. + """ + + class BuildCache(object): + """ Build cache. """ + + class BuildCacheIterator(object): + """ Build class iterator. """ + def __init__(self, oCache): + self.oCache = oCache; + self.iCur = 0; + + def __iter__(self): + """Returns self, required by the language.""" + return self; + + def __next__(self): + """Returns the next build, raises StopIteration when the end has been reached.""" + while True: + if self.iCur >= len(self.oCache.aoEntries): + oEntry = self.oCache.fetchFromCursor(); + if oEntry is None: + raise StopIteration; + else: + oEntry = self.oCache.aoEntries[self.iCur]; + self.iCur += 1; + if not oEntry.fRemoved: + return oEntry; + return None; # not reached, but make pylint happy (for now). + + def next(self): + """ For python 2.x. """ + return self.__next__(); + + class BuildCacheEntry(object): + """ Build cache entry. """ + + def __init__(self, oBuild, fMaybeBlacklisted): + self.oBuild = oBuild; + self._fBlacklisted = None if fMaybeBlacklisted is True else False; + self.fRemoved = False; + self._dPreReqDecisions = {}; + + def remove(self): + """ + Marks the cache entry as removed. + This doesn't actually remove it from the cache array, only marks + it as removed. It has no effect on open iterators. + """ + self.fRemoved = True; + + def getPreReqDecision(self, sPreReqSet): + """ + Retrieves a cached prerequisite decision. + Returns boolean if found, None if not. + """ + return self._dPreReqDecisions.get(sPreReqSet); + + def setPreReqDecision(self, sPreReqSet, fDecision): + """ + Caches a prerequistie decision. + """ + self._dPreReqDecisions[sPreReqSet] = fDecision; + return fDecision; + + def isBlacklisted(self, oDb): + """ Checks if the build is blacklisted. """ + if self._fBlacklisted is None: + self._fBlacklisted = BuildLogic(oDb).isBuildBlacklisted(self.oBuild); + return self._fBlacklisted; + + + def __init__(self): + self.aoEntries = []; + self.oCursor = None; + + def setupSource(self, oDb, idBuildSrc, sOs, sCpuArch, tsNow): + """ Configures the build cursor for the cache. """ + if not self.aoEntries and self.oCursor is None: + oBuildSource = BuildSourceData().initFromDbWithId(oDb, idBuildSrc, tsNow); + self.oCursor = BuildSourceLogic(oDb).openBuildCursor(oBuildSource, sOs, sCpuArch, tsNow); + return True; + + def __iter__(self): + """Return an iterator.""" + return self.BuildCacheIterator(self); + + def fetchFromCursor(self): + """ Fetches a build from the cursor and adds it to the cache.""" + if self.oCursor is None: + return None; + + try: + aoRow = self.oCursor.fetchOne(); + except: + return None; + if aoRow is None: + return None; + + oBuild = BuildDataEx().initFromDbRow(aoRow); + oEntry = self.BuildCacheEntry(oBuild, aoRow[-1]); + self.aoEntries.append(oEntry); + return oEntry; + + def __init__(self, oDb, oSchedGrpData, iVerbosity = 0, tsSecStart = None): + self._oDb = oDb; + self._oSchedGrpData = oSchedGrpData; + self._iVerbosity = iVerbosity; + self._asMessages = []; + self._tsSecStart = tsSecStart if tsSecStart is not None else utils.timestampSecond(); + self.oBuildCache = self.BuildCache(); + self.dTestGroupMembers = {}; + + @staticmethod + def _instantiate(oDb, oSchedGrpData, iVerbosity = 0, tsSecStart = None): + """ + Instantiate the scheduler specified by the scheduling group. + Returns scheduler child class instance. May raise exception if + the input is invalid. + """ + if oSchedGrpData.enmScheduler == SchedGroupData.ksScheduler_BestEffortContinuousIntegration: + from testmanager.core.schedulerbeci import SchdulerBeci; + oScheduler = SchdulerBeci(oDb, oSchedGrpData, iVerbosity, tsSecStart); + else: + raise oDb.integrityException('Invalid scheduler "%s", idSchedGroup=%d' \ + % (oSchedGrpData.enmScheduler, oSchedGrpData.idSchedGroup)); + return oScheduler; + + + # + # Misc. + # + + def msgDebug(self, sText): + """Debug printing.""" + if self._iVerbosity > 1: + self._asMessages.append('debug:' + sText); + return None; + + def msgInfo(self, sText): + """Info printing.""" + if self._iVerbosity > 1: + self._asMessages.append('info: ' + sText); + return None; + + def dprint(self, sMsg): + """Prints a debug message to the srv glue log (see config.py). """ + if config.g_kfSrvGlueDebugScheduler: + self._oDb.dprint(sMsg); + return None; + + def getElapsedSecs(self): + """ Returns the number of seconds this scheduling task has been running. """ + tsSecNow = utils.timestampSecond(); + if tsSecNow < self._tsSecStart: # paranoia + self._tsSecStart = tsSecNow; + return tsSecNow - self._tsSecStart; + + + # + # Create schedule. + # + + def _recreateQueueCancelGatherings(self): + """ + Cancels all pending gang gatherings on the current queue. + """ + self._oDb.execute('SELECT idTestSetGangLeader\n' + 'FROM SchedQueues\n' + 'WHERE idSchedGroup = %s\n' + ' AND idTestSetGangLeader is not NULL\n' + , (self._oSchedGrpData.idSchedGroup,)); + if self._oDb.getRowCount() > 0: + oTBStatusLogic = TestBoxStatusLogic(self._oDb); + for aoRow in self._oDb.fetchAll(): + idTestSetGangLeader = aoRow[0]; + oTBStatusLogic.updateGangStatus(idTestSetGangLeader, + TestBoxStatusData.ksTestBoxState_GangGatheringTimedOut, + fCommit = False); + return True; + + def _recreateQueueItems(self, oData): + """ + Returns an array of queue items (SchedQueueData). + Child classes must override this. + """ + _ = oData; + return []; + + def recreateQueueWorker(self): + """ + Worker for recreateQueue. + """ + + # + # Collect the necessary data and validate it. + # + oData = ReCreateQueueData(self._oDb, self._oSchedGrpData.idSchedGroup); + aoErrors = oData.checkForGroupDepCycles(); + aoErrors.extend(oData.checkForMissingTestCaseDeps()); + if not aoErrors: + oData.deepTestGroupSort(); + + # + # The creation of the scheduling queue is done by the child class. + # + # We will try guess where in queue we're currently at and rotate + # the items such that we will resume execution in the approximately + # same position. The goal of the scheduler is to provide a 100% + # deterministic result so that if we regenerate the queue when there + # are no changes to the testcases, testgroups or scheduling groups + # involved, test execution will be unchanged (save for maybe just a + # little for gang gathering). + # + aoItems = []; + if not oData.oSchedGroup.fEnabled: + self.msgInfo('Disabled.'); + elif not oData.aoArgsVariations: + self.msgInfo('Found no test case argument variations.'); + else: + aoItems = self._recreateQueueItems(oData); + self.msgDebug('len(aoItems)=%s' % (len(aoItems),)); + #for i in range(len(aoItems)): + # self.msgDebug('aoItems[%2d]=%s' % (i, aoItems[i])); + if aoItems: + self._oDb.execute('SELECT offQueue FROM SchedQueues WHERE idSchedGroup = %s ORDER BY idItem LIMIT 1' + , (self._oSchedGrpData.idSchedGroup,)); + if self._oDb.getRowCount() > 0: + offQueue = self._oDb.fetchOne()[0]; + self._oDb.execute('SELECT COUNT(*) FROM SchedQueues WHERE idSchedGroup = %s' + , (self._oSchedGrpData.idSchedGroup,)); + cItems = self._oDb.fetchOne()[0]; + offQueueNew = (offQueue * cItems) // len(aoItems); + if offQueueNew != 0: + aoItems = aoItems[offQueueNew:] + aoItems[:offQueueNew]; + + # + # Replace the scheduling queue. + # Care need to be take to first timeout/abort any gangs in the + # gathering state since these use the queue to set up the date. + # + self._recreateQueueCancelGatherings(); + self._oDb.execute('DELETE FROM SchedQueues WHERE idSchedGroup = %s\n', (self._oSchedGrpData.idSchedGroup,)); + if aoItems: + self._oDb.insertList('INSERT INTO SchedQueues (\n' + ' idSchedGroup,\n' + ' offQueue,\n' + ' idGenTestCaseArgs,\n' + ' idTestGroup,\n' + ' aidTestGroupPreReqs,\n' + ' bmHourlySchedule,\n' + ' cMissingGangMembers )\n', + aoItems, self._formatItemForInsert); + return (aoErrors, self._asMessages); + + def _formatItemForInsert(self, oItem): + """ + Used by recreateQueueWorker together with TMDatabaseConnect::insertList + """ + return self._oDb.formatBindArgs('(%s,%s,%s,%s,%s,%s,%s)' + , ( oItem.idSchedGroup, + oItem.offQueue, + oItem.idGenTestCaseArgs, + oItem.idTestGroup, + oItem.aidTestGroupPreReqs if oItem.aidTestGroupPreReqs else None, + oItem.bmHourlySchedule, + oItem.cMissingGangMembers + )); + + @staticmethod + def recreateQueue(oDb, uidAuthor, idSchedGroup, iVerbosity = 1): + """ + (Re-)creates the scheduling queue for the given group. + + Returns (asMessages, asMessages). On success the array with the error + will be empty, on failure it will contain (sError, oRelatedObject) + entries. The messages is for debugging and are simple strings. + + Raises exception database error. + """ + + aoExtraMsgs = []; + if oDb.debugIsExplainEnabled(): + aoExtraMsgs += ['Warning! Disabling SQL explain to avoid deadlocking against locked tables.']; + oDb.debugDisableExplain(); + + aoErrors = []; + asMessages = []; + try: + # + # To avoid concurrency issues (SchedQueues) and inconsistent data (*), + # we lock quite a few tables while doing this work. We access more + # data than scheduleNewTask so we lock some additional tables. + # + oDb.rollback(); + oDb.begin(); + oDb.execute('LOCK TABLE SchedGroups, SchedGroupMembers, TestGroups, TestGroupMembers IN SHARE MODE'); + oDb.execute('LOCK TABLE TestBoxes, TestCaseArgs, TestCases IN SHARE MODE'); + oDb.execute('LOCK TABLE TestBoxStatuses, SchedQueues IN EXCLUSIVE MODE'); + + # + # Instantiate the scheduler and call the worker function. + # + oSchedGrpData = SchedGroupData().initFromDbWithId(oDb, idSchedGroup); + oScheduler = SchedulerBase._instantiate(oDb, oSchedGrpData, iVerbosity); + + (aoErrors, asMessages) = oScheduler.recreateQueueWorker(); + if not aoErrors: + SystemLogLogic(oDb).addEntry(SystemLogData.ksEvent_SchedQueueRecreate, + 'User #%d recreated sched queue #%d.' % (uidAuthor, idSchedGroup,)); + oDb.commit(); + else: + oDb.rollback(); + + except: + oDb.rollback(); + raise; + + return (aoErrors, aoExtraMsgs + asMessages); + + + @staticmethod + def cleanUpOrphanedQueues(oDb): + """ + Removes orphan scheduling queues from the SchedQueues table. + + Queues becomes orphaned when the scheduling group they belongs to has been deleted. + + Returns number of orphaned queues. + Raises exception database error. + """ + cRet = 0; + try: + oDb.rollback(); + oDb.begin(); + oDb.execute(''' +SELECT SchedQueues.idSchedGroup +FROM SchedQueues + LEFT OUTER JOIN SchedGroups + ON SchedGroups.idSchedGroup = SchedQueues.idSchedGroup + AND SchedGroups.tsExpire = 'infinity'::TIMESTAMP +WHERE SchedGroups.idSchedGroup is NULL +GROUP BY SchedQueues.idSchedGroup'''); + aaoOrphanRows = oDb.fetchAll(); + cRet = len(aaoOrphanRows); + if cRet > 0: + oDb.execute('DELETE FROM SchedQueues WHERE idSchedGroup IN (%s)' + % (','.join([str(aoRow[0]) for aoRow in aaoOrphanRows]),)); + oDb.commit(); + except: + oDb.rollback(); + raise; + return cRet; + + + # + # Schedule Task. + # + + def _composeGangArguments(self, idTestSet): + """ + Composes the gang specific testdriver arguments. + Returns command line string, including a leading space. + """ + + oTestSet = TestSetData().initFromDbWithId(self._oDb, idTestSet); + aoGangMembers = TestSetLogic(self._oDb).getGang(oTestSet.idTestSetGangLeader); + + sArgs = ' --gang-member-no %s --gang-members %s' % (oTestSet.iGangMemberNo, len(aoGangMembers)); + for i, _ in enumerate(aoGangMembers): + sArgs = ' --gang-ipv4-%s %s' % (i, aoGangMembers[i].ip); ## @todo IPv6 + + return sArgs; + + + def composeExecResponseWorker(self, idTestSet, oTestEx, oTestBox, oBuild, oValidationKitBuild, sBaseUrl): + """ + Given all the bits of data, compose an EXEC command response to the testbox. + """ + sScriptZips = oTestEx.oTestCase.sValidationKitZips; + if sScriptZips is None or sScriptZips.find('@VALIDATIONKIT_ZIP@') >= 0: + assert oValidationKitBuild; + if sScriptZips is None: + sScriptZips = oValidationKitBuild.sBinaries; + else: + sScriptZips = sScriptZips.replace('@VALIDATIONKIT_ZIP@', oValidationKitBuild.sBinaries); + sScriptZips = sScriptZips.replace('@DOWNLOAD_BASE_URL@', sBaseUrl + config.g_ksTmDownloadBaseUrlRel); + + sCmdLine = oTestEx.oTestCase.sBaseCmd + ' ' + oTestEx.sArgs; + sCmdLine = sCmdLine.replace('@BUILD_BINARIES@', oBuild.sBinaries); + sCmdLine = sCmdLine.strip(); + if oTestEx.cGangMembers > 1: + sCmdLine += ' ' + self._composeGangArguments(idTestSet); + + cSecTimeout = oTestEx.cSecTimeout if oTestEx.cSecTimeout is not None else oTestEx.oTestCase.cSecTimeout; + cSecTimeout = cSecTimeout * oTestBox.pctScaleTimeout // 100; + + dResponse = \ + { + constants.tbresp.ALL_PARAM_RESULT: constants.tbresp.CMD_EXEC, + constants.tbresp.EXEC_PARAM_RESULT_ID: idTestSet, + constants.tbresp.EXEC_PARAM_SCRIPT_ZIPS: sScriptZips, + constants.tbresp.EXEC_PARAM_SCRIPT_CMD_LINE: sCmdLine, + constants.tbresp.EXEC_PARAM_TIMEOUT: cSecTimeout, + }; + return dResponse; + + @staticmethod + def composeExecResponse(oDb, idTestSet, sBaseUrl, iVerbosity = 0): + """ + Composes an EXEC response for a gang member (other than the last). + Returns a EXEC response or raises an exception (DB/input error). + """ + # + # Gather the necessary data. + # + oTestSet = TestSetData().initFromDbWithId(oDb, idTestSet); + oTestBox = TestBoxData().initFromDbWithGenId(oDb, oTestSet.idGenTestBox); + oTestEx = TestCaseArgsDataEx().initFromDbWithGenIdEx(oDb, oTestSet.idGenTestCaseArgs, + tsConfigEff = oTestSet.tsConfig, + tsRsrcEff = oTestSet.tsConfig); + oBuild = BuildDataEx().initFromDbWithId(oDb, oTestSet.idBuild); + oValidationKitBuild = None; + if oTestSet.idBuildTestSuite is not None: + oValidationKitBuild = BuildDataEx().initFromDbWithId(oDb, oTestSet.idBuildTestSuite); + + # + # Instantiate the specified scheduler and let it do the rest. + # + oSchedGrpData = SchedGroupData().initFromDbWithId(oDb, oTestSet.idSchedGroup, oTestSet.tsCreated); + assert oSchedGrpData.fEnabled is True; + assert oSchedGrpData.idBuildSrc is not None; + oScheduler = SchedulerBase._instantiate(oDb, oSchedGrpData, iVerbosity); + + return oScheduler.composeExecResponseWorker(idTestSet, oTestEx, oTestBox, oBuild, oValidationKitBuild, sBaseUrl); + + + def _updateTask(self, oTask, tsNow): + """ + Updates a gang schedule task. + """ + assert oTask.cMissingGangMembers >= 1; + assert oTask.idTestSetGangLeader is not None; + assert oTask.idTestSetGangLeader >= 1; + if tsNow is not None: + self._oDb.execute('UPDATE SchedQueues\n' + ' SET idTestSetGangLeader = %s,\n' + ' cMissingGangMembers = %s,\n' + ' tsLastScheduled = %s\n' + 'WHERE idItem = %s\n' + , (oTask.idTestSetGangLeader, oTask.cMissingGangMembers, tsNow, oTask.idItem,) ); + else: + self._oDb.execute('UPDATE SchedQueues\n' + ' SET cMissingGangMembers = %s\n' + 'WHERE idItem = %s\n' + , (oTask.cMissingGangMembers, oTask.idItem,) ); + return True; + + def _moveTaskToEndOfQueue(self, oTask, cGangMembers, tsNow): + """ + The task has been scheduled successfully, reset it's data move it to + the end of the queue. + """ + if cGangMembers > 1: + self._oDb.execute('UPDATE SchedQueues\n' + ' SET idItem = NEXTVAL(\'SchedQueueItemIdSeq\'),\n' + ' idTestSetGangLeader = NULL,\n' + ' cMissingGangMembers = %s\n' + 'WHERE idItem = %s\n' + , (cGangMembers, oTask.idItem,) ); + else: + self._oDb.execute('UPDATE SchedQueues\n' + ' SET idItem = NEXTVAL(\'SchedQueueItemIdSeq\'),\n' + ' idTestSetGangLeader = NULL,\n' + ' cMissingGangMembers = 1,\n' + ' tsLastScheduled = %s\n' + 'WHERE idItem = %s\n' + , (tsNow, oTask.idItem,) ); + return True; + + + + + def _createTestSet(self, oTask, oTestEx, oTestBoxData, oBuild, oValidationKitBuild, tsNow): + # type: (SchedQueueData, TestCaseArgsDataEx, TestBoxData, BuildDataEx, BuildDataEx, datetime.datetime) -> int + """ + Creates a test set for using the given data. + Will not commit, someone up the callstack will that later on. + + Returns the test set ID, may raise an exception on database error. + """ + # Lazy bird doesn't want to write testset.py and does it all here. + + # + # We're getting the TestSet ID first in order to include it in the base + # file name (that way we can directly relate files on the disk to the + # test set when doing batch work), and also for idTesetSetGangLeader. + # + self._oDb.execute('SELECT NEXTVAL(\'TestSetIdSeq\')'); + idTestSet = self._oDb.fetchOne()[0]; + + sBaseFilename = '%04d/%02d/%02d/%02d/TestSet-%s' \ + % (tsNow.year, tsNow.month, tsNow.day, (tsNow.hour // 6) * 6, idTestSet); + + # + # Gang scheduling parameters. Changes the oTask data for updating by caller. + # + iGangMemberNo = 0; + + if oTestEx.cGangMembers <= 1: + assert oTask.idTestSetGangLeader is None; + assert oTask.cMissingGangMembers <= 1; + elif oTask.idTestSetGangLeader is None: + assert oTask.cMissingGangMembers == oTestEx.cGangMembers; + oTask.cMissingGangMembers = oTestEx.cGangMembers - 1; + oTask.idTestSetGangLeader = idTestSet; + else: + assert oTask.cMissingGangMembers > 0 and oTask.cMissingGangMembers < oTestEx.cGangMembers; + oTask.cMissingGangMembers -= 1; + + # + # Do the database stuff. + # + self._oDb.execute('INSERT INTO TestSets (\n' + ' idTestSet,\n' + ' tsConfig,\n' + ' tsCreated,\n' + ' idBuild,\n' + ' idBuildCategory,\n' + ' idBuildTestSuite,\n' + ' idGenTestBox,\n' + ' idTestBox,\n' + ' idSchedGroup,\n' + ' idTestGroup,\n' + ' idGenTestCase,\n' + ' idTestCase,\n' + ' idGenTestCaseArgs,\n' + ' idTestCaseArgs,\n' + ' sBaseFilename,\n' + ' iGangMemberNo,\n' + ' idTestSetGangLeader )\n' + 'VALUES ( %s,\n' # idTestSet + ' %s,\n' # tsConfig + ' %s,\n' # tsCreated + ' %s,\n' # idBuild + ' %s,\n' # idBuildCategory + ' %s,\n' # idBuildTestSuite + ' %s,\n' # idGenTestBox + ' %s,\n' # idTestBox + ' %s,\n' # idSchedGroup + ' %s,\n' # idTestGroup + ' %s,\n' # idGenTestCase + ' %s,\n' # idTestCase + ' %s,\n' # idGenTestCaseArgs + ' %s,\n' # idTestCaseArgs + ' %s,\n' # sBaseFilename + ' %s,\n' # iGangMemberNo + ' %s)\n' # idTestSetGangLeader + , ( idTestSet, + oTask.tsConfig, + tsNow, + oBuild.idBuild, + oBuild.idBuildCategory, + oValidationKitBuild.idBuild if oValidationKitBuild is not None else None, + oTestBoxData.idGenTestBox, + oTestBoxData.idTestBox, + oTask.idSchedGroup, + oTask.idTestGroup, + oTestEx.oTestCase.idGenTestCase, + oTestEx.oTestCase.idTestCase, + oTestEx.idGenTestCaseArgs, + oTestEx.idTestCaseArgs, + sBaseFilename, + iGangMemberNo, + oTask.idTestSetGangLeader, + )); + + self._oDb.execute('INSERT INTO TestResults (\n' + ' idTestResultParent,\n' + ' idTestSet,\n' + ' tsCreated,\n' + ' idStrName,\n' + ' cErrors,\n' + ' enmStatus,\n' + ' iNestingDepth)\n' + 'VALUES ( NULL,\n' # idTestResultParent + ' %s,\n' # idTestSet + ' %s,\n' # tsCreated + ' 0,\n' # idStrName + ' 0,\n' # cErrors + ' \'running\'::TestStatus_T,\n' + ' 0)\n' # iNestingDepth + 'RETURNING idTestResult' + , ( idTestSet, tsNow, )); + idTestResult = self._oDb.fetchOne()[0]; + + self._oDb.execute('UPDATE TestSets\n' + ' SET idTestResult = %s\n' + 'WHERE idTestSet = %s\n' + , (idTestResult, idTestSet, )); + + return idTestSet; + + def _tryFindValidationKitBit(self, oTestBoxData, tsNow): + """ + Tries to find the most recent validation kit build suitable for the given testbox. + Returns BuildDataEx or None. Raise exception on database error. + + Can be overridden by child classes to change the default build requirements. + """ + oBuildLogic = BuildLogic(self._oDb); + oBuildSource = BuildSourceData().initFromDbWithId(self._oDb, self._oSchedGrpData.idBuildSrcTestSuite, tsNow); + oCursor = BuildSourceLogic(self._oDb).openBuildCursor(oBuildSource, oTestBoxData.sOs, oTestBoxData.sCpuArch, tsNow); + for _ in range(oCursor.getRowCount()): + oBuild = BuildDataEx().initFromDbRow(oCursor.fetchOne()); + if not oBuildLogic.isBuildBlacklisted(oBuild): + return oBuild; + return None; + + def _tryFindBuild(self, oTask, oTestEx, oTestBoxData, tsNow): + """ + Tries to find a fitting build. + Returns BuildDataEx or None. Raise exception on database error. + + Can be overridden by child classes to change the default build requirements. + """ + + # + # Gather the set of prerequisites we have and turn them into a value + # set for use in the loop below. + # + # Note! We're scheduling on testcase level and ignoring argument variation + # selections in TestGroupMembers is intentional. + # + dPreReqs = {}; + + # Direct prerequisites. We assume they're all enabled as this can be + # checked at queue creation time. + for oPreReq in oTestEx.aoTestCasePreReqs: + dPreReqs[oPreReq.idTestCase] = 1; + + # Testgroup dependencies from the scheduling group config. + if oTask.aidTestGroupPreReqs is not None: + for iTestGroup in oTask.aidTestGroupPreReqs: + # Make sure the _active_ test group members are in the cache. + if iTestGroup not in self.dTestGroupMembers: + self._oDb.execute('SELECT DISTINCT TestGroupMembers.idTestCase\n' + 'FROM TestGroupMembers, TestCases\n' + 'WHERE TestGroupMembers.idTestGroup = %s\n' + ' AND TestGroupMembers.tsExpire > %s\n' + ' AND TestGroupMembers.tsEffective <= %s\n' + ' AND TestCases.idTestCase = TestGroupMembers.idTestCase\n' + ' AND TestCases.tsExpire > %s\n' + ' AND TestCases.tsEffective <= %s\n' + ' AND TestCases.fEnabled is TRUE\n' + , (iTestGroup, oTask.tsConfig, oTask.tsConfig, oTask.tsConfig, oTask.tsConfig,)); + aidTestCases = []; + for aoRow in self._oDb.fetchAll(): + aidTestCases.append(aoRow[0]); + self.dTestGroupMembers[iTestGroup] = aidTestCases; + + # Add the testgroup members to the prerequisites. + for idTestCase in self.dTestGroupMembers[iTestGroup]: + dPreReqs[idTestCase] = 1; + + # Create a SQL values table out of them. + sPreReqSet = '' + if dPreReqs: + for idPreReq in sorted(dPreReqs): + sPreReqSet += ', (' + str(idPreReq) + ')'; + sPreReqSet = sPreReqSet[2:]; # drop the leading ', '. + + # + # Try the builds. + # + self.oBuildCache.setupSource(self._oDb, self._oSchedGrpData.idBuildSrc, oTestBoxData.sOs, oTestBoxData.sCpuArch, tsNow); + for oEntry in self.oBuildCache: + # + # Check build requirements set by the test. + # + if not oTestEx.matchesBuildProps(oEntry.oBuild): + continue; + + if oEntry.isBlacklisted(self._oDb): + oEntry.remove(); + continue; + + # + # Check prerequisites. The default scheduler is satisfied if one + # argument variation has been executed successfully. It is not + # satisfied if there are any failure runs. + # + if sPreReqSet: + fDecision = oEntry.getPreReqDecision(sPreReqSet); + if fDecision is None: + # Check for missing prereqs. + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM (VALUES ' + sPreReqSet + ') AS PreReqs(idTestCase)\n' + 'LEFT OUTER JOIN (SELECT idTestSet\n' + ' FROM TestSets\n' + ' WHERE enmStatus IN (%s, %s)\n' + ' AND idBuild = %s\n' + ' ) AS TestSets\n' + ' ON (PreReqs.idTestCase = TestSets.idTestCase)\n' + 'WHERE TestSets.idTestSet is NULL\n' + , ( TestSetData.ksTestStatus_Success, TestSetData.ksTestStatus_Skipped, + oEntry.oBuild.idBuild, )); + cMissingPreReqs = self._oDb.fetchOne()[0]; + if cMissingPreReqs > 0: + self.dprint('build %s is missing %u prerequisites (out of %s)' + % (oEntry.oBuild.idBuild, cMissingPreReqs, sPreReqSet,)); + oEntry.setPreReqDecision(sPreReqSet, False); + continue; + + # Check for failed prereq runs. + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM (VALUES ' + sPreReqSet + ') AS PreReqs(idTestCase),\n' + ' TestSets\n' + 'WHERE PreReqs.idTestCase = TestSets.idTestCase\n' + ' AND TestSets.idBuild = %s\n' + ' AND TestSets.enmStatus IN (%s, %s, %s)\n' + , ( oEntry.oBuild.idBuild, + TestSetData.ksTestStatus_Failure, + TestSetData.ksTestStatus_TimedOut, + TestSetData.ksTestStatus_Rebooted, + ) + ); + cFailedPreReqs = self._oDb.fetchOne()[0]; + if cFailedPreReqs > 0: + self.dprint('build %s is has %u prerequisite failures (out of %s)' + % (oEntry.oBuild.idBuild, cFailedPreReqs, sPreReqSet,)); + oEntry.setPreReqDecision(sPreReqSet, False); + continue; + + oEntry.setPreReqDecision(sPreReqSet, True); + elif not fDecision: + continue; + + # + # If we can, check if the build files still exist. + # + if oEntry.oBuild.areFilesStillThere() is False: + self.dprint('build %s no longer exists' % (oEntry.oBuild.idBuild,)); + oEntry.remove(); + continue; + + self.dprint('found oBuild=%s' % (oEntry.oBuild,)); + return oEntry.oBuild; + return None; + + def _tryFindMatchingBuild(self, oLeaderBuild, oTestBoxData, idBuildSrc): + """ + Tries to find a matching build for gang scheduling. + Returns BuildDataEx or None. Raise exception on database error. + + Can be overridden by child classes to change the default build requirements. + """ + # + # Note! Should probably check build prerequisites if we get a different + # build back, so that we don't use a build which hasn't passed + # the smoke test. + # + _ = idBuildSrc; + return BuildLogic(self._oDb).tryFindSameBuildForOsArch(oLeaderBuild, oTestBoxData.sOs, oTestBoxData.sCpuArch); + + + def _tryAsLeader(self, oTask, oTestEx, oTestBoxData, tsNow, sBaseUrl): + """ + Try schedule the task as a gang leader (can be a gang of one). + Returns response or None. May raise exception on DB error. + """ + + # We don't wait for busy resources, we just try the next test. + oTestArgsLogic = TestCaseArgsLogic(self._oDb); + if not oTestArgsLogic.areResourcesFree(oTestEx): + self.dprint('Cannot get global test resources!'); + return None; + + # + # Find a matching build (this is the difficult bit). + # + oBuild = self._tryFindBuild(oTask, oTestEx, oTestBoxData, tsNow); + if oBuild is None: + self.dprint('No build!'); + return None; + if oTestEx.oTestCase.needValidationKitBit(): + oValidationKitBuild = self._tryFindValidationKitBit(oTestBoxData, tsNow); + if oValidationKitBuild is None: + self.dprint('No validation kit build!'); + return None; + else: + oValidationKitBuild = None; + + # + # Create a testset, allocate the resources and update the state. + # Note! Since resource allocation may still fail, we create a nested + # transaction so we can roll back. (Heed lock warning in docs!) + # + self._oDb.execute('SAVEPOINT tryAsLeader'); + idTestSet = self._createTestSet(oTask, oTestEx, oTestBoxData, oBuild, oValidationKitBuild, tsNow); + + if GlobalResourceLogic(self._oDb).allocateResources(oTestBoxData.idTestBox, oTestEx.aoGlobalRsrc, fCommit = False) \ + is not True: + self._oDb.execute('ROLLBACK TO SAVEPOINT tryAsLeader'); + self.dprint('Failed to allocate global resources!'); + return False; + + if oTestEx.cGangMembers <= 1: + # We're alone, put the task back at the end of the queue and issue EXEC cmd. + self._moveTaskToEndOfQueue(oTask, oTestEx.cGangMembers, tsNow); + dResponse = self.composeExecResponseWorker(idTestSet, oTestEx, oTestBoxData, oBuild, oValidationKitBuild, sBaseUrl); + sTBState = TestBoxStatusData.ksTestBoxState_Testing; + else: + # We're missing gang members, issue WAIT cmd. + self._updateTask(oTask, tsNow if idTestSet == oTask.idTestSetGangLeader else None); + dResponse = { constants.tbresp.ALL_PARAM_RESULT: constants.tbresp.CMD_WAIT, }; + sTBState = TestBoxStatusData.ksTestBoxState_GangGathering; + + TestBoxStatusLogic(self._oDb).updateState(oTestBoxData.idTestBox, sTBState, idTestSet, fCommit = False); + self._oDb.execute('RELEASE SAVEPOINT tryAsLeader'); + return dResponse; + + def _tryAsGangMember(self, oTask, oTestEx, oTestBoxData, tsNow, sBaseUrl): + """ + Try schedule the task as a gang member. + Returns response or None. May raise exception on DB error. + """ + + # + # The leader has choosen a build, we need to find a matching one for our platform. + # (It's up to the scheduler decide upon how strict dependencies are to be enforced + # upon subordinate group members.) + # + oLeaderTestSet = TestSetData().initFromDbWithId(self._oDb, oTestBoxData.idTestSetGangLeader); + + oLeaderBuild = BuildDataEx().initFromDbWithId(self._oDb, oLeaderTestSet.idBuild); + oBuild = self._tryFindMatchingBuild(oLeaderBuild, oTestBoxData, self._oSchedGrpData.idBuildSrc); + if oBuild is None: + return None; + + oValidationKitBuild = None; + if oLeaderTestSet.idBuildTestSuite is not None: + oLeaderValidationKitBit = BuildDataEx().initFromDbWithId(self._oDb, oLeaderTestSet.idBuildTestSuite); + oValidationKitBuild = self._tryFindMatchingBuild(oLeaderValidationKitBit, oTestBoxData, + self._oSchedGrpData.idBuildSrcTestSuite); + + # + # Create a testset and update the state(s). + # + idTestSet = self._createTestSet(oTask, oTestEx, oTestBoxData, oBuild, oValidationKitBuild, tsNow); + + oTBStatusLogic = TestBoxStatusLogic(self._oDb); + if oTask.cMissingGangMembers < 1: + # The whole gang is there, move the task to the end of the queue + # and update the status on the other gang members. + self._moveTaskToEndOfQueue(oTask, oTestEx.cGangMembers, tsNow); + dResponse = self.composeExecResponseWorker(idTestSet, oTestEx, oTestBoxData, oBuild, oValidationKitBuild, sBaseUrl); + sTBState = TestBoxStatusData.ksTestBoxState_GangTesting; + oTBStatusLogic.updateGangStatus(oTask.idTestSetGangLeader, sTBState, fCommit = False); + else: + # We're still missing some gang members, issue WAIT cmd. + self._updateTask(oTask, tsNow if idTestSet == oTask.idTestSetGangLeader else None); + dResponse = { constants.tbresp.ALL_PARAM_RESULT: constants.tbresp.CMD_WAIT, }; + sTBState = TestBoxStatusData.ksTestBoxState_GangGathering; + + oTBStatusLogic.updateState(oTestBoxData.idTestBox, sTBState, idTestSet, fCommit = False); + return dResponse; + + + def scheduleNewTaskWorker(self, oTestBoxData, tsNow, sBaseUrl): + """ + Worker for schduling a new task. + """ + + # + # Iterate the scheduler queue (fetch all to avoid having to concurrent + # queries), trying out each task to see if the testbox can execute it. + # + dRejected = {}; # variations we've already checked out and rejected. + self._oDb.execute('SELECT *\n' + 'FROM SchedQueues\n' + 'WHERE idSchedGroup = %s\n' + ' AND ( bmHourlySchedule IS NULL\n' + ' OR get_bit(bmHourlySchedule, %s) = 1 )\n' + 'ORDER BY idItem ASC\n' + , (self._oSchedGrpData.idSchedGroup, utils.getLocalHourOfWeek()) ); + aaoRows = self._oDb.fetchAll(); + for aoRow in aaoRows: + # Don't loop forever. + if self.getElapsedSecs() >= config.g_kcSecMaxNewTask: + break; + + # Unpack the data and check if we've rejected the testcasevar/group variation already (they repeat). + oTask = SchedQueueData().initFromDbRow(aoRow); + if config.g_kfSrvGlueDebugScheduler: + self.dprint('** Considering: idItem=%s idGenTestCaseArgs=%s idTestGroup=%s Deps=%s last=%s cfg=%s\n' + % ( oTask.idItem, oTask.idGenTestCaseArgs, oTask.idTestGroup, oTask.aidTestGroupPreReqs, + oTask.tsLastScheduled, oTask.tsConfig,)); + + sRejectNm = '%s:%s' % (oTask.idGenTestCaseArgs, oTask.idTestGroup,); + if sRejectNm in dRejected: + self.dprint('Duplicate, already rejected! (%s)' % (sRejectNm,)); + continue; + dRejected[sRejectNm] = 1; + + # Fetch all the test case info (too much, but who cares right now). + oTestEx = TestCaseArgsDataEx().initFromDbWithGenIdEx(self._oDb, oTask.idGenTestCaseArgs, + tsConfigEff = oTask.tsConfig, + tsRsrcEff = oTask.tsConfig); + if config.g_kfSrvGlueDebugScheduler: + self.dprint('TestCase "%s": %s %s' % (oTestEx.oTestCase.sName, oTestEx.oTestCase.sBaseCmd, oTestEx.sArgs,)); + + # This shouldn't happen, but just in case it does... + if oTestEx.oTestCase.fEnabled is not True: + self.dprint('Testcase is not enabled!!'); + continue; + + # Check if the testbox properties matches the test. + if not oTestEx.matchesTestBoxProps(oTestBoxData): + self.dprint('Testbox mismatch!'); + continue; + + # Try schedule it. + if oTask.idTestSetGangLeader is None or oTestEx.cGangMembers <= 1: + dResponse = self._tryAsLeader(oTask, oTestEx, oTestBoxData, tsNow, sBaseUrl); + elif oTask.cMissingGangMembers > 1: + dResponse = self._tryAsGangMember(oTask, oTestEx, oTestBoxData, tsNow, sBaseUrl); + else: + dResponse = None; # Shouldn't happen! + if dResponse is not None: + self.dprint('Found a task! dResponse=%s' % (dResponse,)); + return dResponse; + + # Found no suitable task. + return None; + + @staticmethod + def _pickSchedGroup(oTestBoxDataEx, iWorkItem, dIgnoreSchedGroupIds): + """ + Picks the next scheduling group for the given testbox. + """ + if len(oTestBoxDataEx.aoInSchedGroups) == 1: + oSchedGroup = oTestBoxDataEx.aoInSchedGroups[0].oSchedGroup; + if oSchedGroup.fEnabled \ + and oSchedGroup.idBuildSrc is not None \ + and oSchedGroup.idSchedGroup not in dIgnoreSchedGroupIds: + return (oSchedGroup, 0); + iWorkItem = 0; + + elif oTestBoxDataEx.aoInSchedGroups: + # Construct priority table of currently enabled scheduling groups. + aaoList1 = []; + for oInGroup in oTestBoxDataEx.aoInSchedGroups: + oSchedGroup = oInGroup.oSchedGroup; + if oSchedGroup.fEnabled and oSchedGroup.idBuildSrc is not None: + iSchedPriority = oInGroup.iSchedPriority; + if iSchedPriority > 31: # paranoia + iSchedPriority = 31; + elif iSchedPriority < 0: # paranoia + iSchedPriority = 0; + + for iSchedPriority in xrange(min(iSchedPriority, len(aaoList1))): + aaoList1[iSchedPriority].append(oSchedGroup); + while len(aaoList1) <= iSchedPriority: + aaoList1.append([oSchedGroup,]); + + # Flatten it into a single list, mixing the priorities a little so it doesn't + # take forever before low priority stuff is executed. + aoFlat = []; + iLo = 0; + iHi = len(aaoList1) - 1; + while iHi >= iLo: + aoFlat += aaoList1[iHi]; + if iLo < iHi: + aoFlat += aaoList1[iLo]; + iLo += 1; + iHi -= 1; + + # Pick the next one. + cLeft = len(aoFlat); + while cLeft > 0: + cLeft -= 1; + iWorkItem += 1; + if iWorkItem >= len(aoFlat) or iWorkItem < 0: + iWorkItem = 0; + if aoFlat[iWorkItem].idSchedGroup not in dIgnoreSchedGroupIds: + return (aoFlat[iWorkItem], iWorkItem); + else: + iWorkItem = 0; + + # No active group. + return (None, iWorkItem); + + @staticmethod + def scheduleNewTask(oDb, oTestBoxData, iWorkItem, sBaseUrl, iVerbosity = 0): + # type: (TMDatabaseConnection, TestBoxData, int, str, int) -> None + """ + Schedules a new task for a testbox. + """ + oTBStatusLogic = TestBoxStatusLogic(oDb); + + try: + # + # To avoid concurrency issues in SchedQueues we lock all the rows + # related to our scheduling queue. Also, since this is a very + # expensive operation we lock the testbox status row to fend of + # repeated retires by faulty testbox scripts. + # + tsSecStart = utils.timestampSecond(); + oDb.rollback(); + oDb.begin(); + oDb.execute('SELECT idTestBox FROM TestBoxStatuses WHERE idTestBox = %s FOR UPDATE NOWAIT' + % (oTestBoxData.idTestBox,)); + oDb.execute('SELECT SchedQueues.idSchedGroup\n' + ' FROM SchedQueues, TestBoxesInSchedGroups\n' + 'WHERE TestBoxesInSchedGroups.idTestBox = %s\n' + ' AND TestBoxesInSchedGroups.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestBoxesInSchedGroups.idSchedGroup = SchedQueues.idSchedGroup\n' + ' FOR UPDATE' + % (oTestBoxData.idTestBox,)); + + # We need the current timestamp. + tsNow = oDb.getCurrentTimestamp(); + + # Re-read the testbox data with scheduling group relations. + oTestBoxDataEx = TestBoxDataEx().initFromDbWithId(oDb, oTestBoxData.idTestBox, tsNow); + if oTestBoxDataEx.fEnabled \ + and oTestBoxDataEx.idGenTestBox == oTestBoxData.idGenTestBox: + + # We may have to skip scheduling groups that are out of work (e.g. 'No build'). + iInitialWorkItem = iWorkItem; + dIgnoreSchedGroupIds = {}; + while True: + # Now, pick the scheduling group. + (oSchedGroup, iWorkItem) = SchedulerBase._pickSchedGroup(oTestBoxDataEx, iWorkItem, dIgnoreSchedGroupIds); + if oSchedGroup is None: + break; + assert oSchedGroup.fEnabled and oSchedGroup.idBuildSrc is not None; + + # Instantiate the specified scheduler and let it do the rest. + oScheduler = SchedulerBase._instantiate(oDb, oSchedGroup, iVerbosity, tsSecStart); + dResponse = oScheduler.scheduleNewTaskWorker(oTestBoxDataEx, tsNow, sBaseUrl); + if dResponse is not None: + oTBStatusLogic.updateWorkItem(oTestBoxDataEx.idTestBox, iWorkItem); + oDb.commit(); + return dResponse; + + # Check out the next work item? + if oScheduler.getElapsedSecs() > config.g_kcSecMaxNewTask: + break; + dIgnoreSchedGroupIds[oSchedGroup.idSchedGroup] = oSchedGroup; + + # No luck, but best if we update the work item if we've made progress. + # Note! In case of a config.g_kcSecMaxNewTask timeout, this may accidentally skip + # a work item with actually work to do. But that's a small price to pay. + if iWorkItem != iInitialWorkItem: + oTBStatusLogic.updateWorkItem(oTestBoxDataEx.idTestBox, iWorkItem); + oDb.commit(); + return None; + except: + oDb.rollback(); + raise; + + # Not enabled, rollback and return no task. + oDb.rollback(); + return None; + + @staticmethod + def tryCancelGangGathering(oDb, oStatusData): + """ + Try canceling a gang gathering. + + Returns True if successfully cancelled. + Returns False if not (someone raced us to the SchedQueue table). + + Note! oStatusData is re-initialized. + """ + assert oStatusData.enmState == TestBoxStatusData.ksTestBoxState_GangGathering; + try: + # + # Lock the tables we're updating so we don't run into concurrency + # issues (we're racing both scheduleNewTask and other callers of + # this method). + # + oDb.rollback(); + oDb.begin(); + oDb.execute('LOCK TABLE TestBoxStatuses, SchedQueues IN EXCLUSIVE MODE'); + + # + # Re-read the testbox data and check that we're still in the same state. + # + oStatusData.initFromDbWithId(oDb, oStatusData.idTestBox); + if oStatusData.enmState == TestBoxStatusData.ksTestBoxState_GangGathering: + # + # Get the leader thru the test set and change the state of the whole gang. + # + oTestSetData = TestSetData().initFromDbWithId(oDb, oStatusData.idTestSet); + + oTBStatusLogic = TestBoxStatusLogic(oDb); + oTBStatusLogic.updateGangStatus(oTestSetData.idTestSetGangLeader, + TestBoxStatusData.ksTestBoxState_GangGatheringTimedOut, + fCommit = False); + + # + # Move the scheduling queue item to the end. + # + oDb.execute('SELECT *\n' + 'FROM SchedQueues\n' + 'WHERE idTestSetGangLeader = %s\n' + , (oTestSetData.idTestSetGangLeader,) ); + oTask = SchedQueueData().initFromDbRow(oDb.fetchOne()); + oTestEx = TestCaseArgsDataEx().initFromDbWithGenIdEx(oDb, oTask.idGenTestCaseArgs, + tsConfigEff = oTask.tsConfig, + tsRsrcEff = oTask.tsConfig); + oDb.execute('UPDATE SchedQueues\n' + ' SET idItem = NEXTVAL(\'SchedQueueItemIdSeq\'),\n' + ' idTestSetGangLeader = NULL,\n' + ' cMissingGangMembers = %s\n' + 'WHERE idItem = %s\n' + , (oTestEx.cGangMembers, oTask.idItem,) ); + + oDb.commit(); + return True; + + if oStatusData.enmState == TestBoxStatusData.ksTestBoxState_GangGatheringTimedOut: + oDb.rollback(); + return True; + except: + oDb.rollback(); + raise; + + # Not enabled, rollback and return no task. + oDb.rollback(); + return False; + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class SchedQueueDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [SchedQueueData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/schedulerbeci.py b/src/VBox/ValidationKit/testmanager/core/schedulerbeci.py new file mode 100755 index 00000000..5cd800d5 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/schedulerbeci.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# $Id: schedulerbeci.py $ + +""" +Test Manager - Best-Effort-Continuous-Integration (BECI) scheduler. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Validation Kit imports. +from testmanager.core.schedulerbase import SchedulerBase, SchedQueueData; + + +class SchdulerBeci(SchedulerBase): # pylint: disable=too-few-public-methods + """ + The best-effort-continuous-integration scheduler, BECI for short. + """ + + def __init__(self, oDb, oSchedGrpData, iVerbosity, tsSecStart): + SchedulerBase.__init__(self, oDb, oSchedGrpData, iVerbosity, tsSecStart); + + def _recreateQueueItems(self, oData): + # + # Prepare the input data for the loop below. We compress the priority + # to reduce the number of loops we need to executes below. + # + # Note! For BECI test group priority only applies to the ordering of + # test groups, which has been resolved by the done sorting in the + # base class. + # + iMinPriority = 0x7fff; + iMaxPriority = 0; + for oTestGroup in oData.aoTestGroups: + for oTestCase in oTestGroup.aoTestCases: + iPrio = oTestCase.iSchedPriority; + assert iPrio in range(32); + iPrio = iPrio // 4; + assert iPrio in range(8); + if iPrio > iMaxPriority: + iMaxPriority = iPrio; + if iPrio < iMinPriority: + iMinPriority = iPrio; + + oTestCase.iBeciPrio = iPrio; + oTestCase.iNextVariation = -1; + + assert iMinPriority in range(8); + assert iMaxPriority in range(8); + assert iMinPriority <= iMaxPriority; + + # + # Generate the + # + cMaxItems = len(oData.aoArgsVariations) * 64; + cMaxItems = min(cMaxItems, 1048576); + + aoItems = []; + cNotAtEnd = len(oData.aoTestCases); + while len(aoItems) < cMaxItems: + self.msgDebug('outer loop: %s items' % (len(aoItems),)); + for iPrio in range(iMaxPriority, iMinPriority - 1, -1): + #self.msgDebug('prio loop: %s' % (iPrio,)); + for oTestGroup in oData.aoTestGroups: + #self.msgDebug('testgroup loop: %s' % (oTestGroup,)); + for oTestCase in oTestGroup.aoTestCases: + #self.msgDebug('testcase loop: idTestCase=%s' % (oTestCase.idTestCase,)); + if iPrio <= oTestCase.iBeciPrio and oTestCase.aoArgsVariations: + # Get variation. + iNext = oTestCase.iNextVariation; + if iNext != 0: + if iNext == -1: iNext = 0; + cNotAtEnd -= 1; + oArgsVariation = oTestCase.aoArgsVariations[iNext]; + + # Update next variation. + iNext = (iNext + 1) % len(oTestCase.aoArgsVariations); + cNotAtEnd += iNext != 0; + oTestCase.iNextVariation = iNext; + + # Create queue item and append it. + oItem = SchedQueueData(); + oItem.initFromValues(idSchedGroup = self._oSchedGrpData.idSchedGroup, + idGenTestCaseArgs = oArgsVariation.idGenTestCaseArgs, + idTestGroup = oTestGroup.idTestGroup, + aidTestGroupPreReqs = oTestGroup.aidTestGroupPreReqs, + bmHourlySchedule = oTestGroup.bmHourlySchedule, + cMissingGangMembers = oArgsVariation.cGangMembers, + offQueue = len(aoItems)); + aoItems.append(oItem); + + # Done? + if cNotAtEnd == 0: + self.msgDebug('returns %s items' % (len(aoItems),)); + return aoItems; + return aoItems; + diff --git a/src/VBox/ValidationKit/testmanager/core/systemchangelog.py b/src/VBox/ValidationKit/testmanager/core/systemchangelog.py new file mode 100755 index 00000000..7c85c76a --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/systemchangelog.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +# $Id: systemchangelog.py $ + +""" +Test Manager - System changelog compilation. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Validation Kit imports. +from testmanager.core.base import ModelLogicBase; +from testmanager.core.useraccount import UserAccountLogic; +from testmanager.core.systemlog import SystemLogData; + + +class SystemChangelogEntry(object): # pylint: disable=too-many-instance-attributes + """ + System changelog entry. + """ + + def __init__(self, tsEffective, oAuthor, sEvent, idWhat, sDesc): + self.tsEffective = tsEffective; + self.oAuthor = oAuthor; + self.sEvent = sEvent; + self.idWhat = idWhat; + self.sDesc = sDesc; + + +class SystemChangelogLogic(ModelLogicBase): + """ + System changelog compilation logic. + """ + + ## @name What kind of change. + ## @{ + ksWhat_TestBox = 'chlog::TestBox'; + ksWhat_TestCase = 'chlog::TestCase'; + ksWhat_Blacklisting = 'chlog::Blacklisting'; + ksWhat_Build = 'chlog::Build'; + ksWhat_BuildSource = 'chlog::BuildSource'; + ksWhat_FailureCategory = 'chlog::FailureCategory'; + ksWhat_FailureReason = 'chlog::FailureReason'; + ksWhat_GlobalRsrc = 'chlog::GlobalRsrc'; + ksWhat_SchedGroup = 'chlog::SchedGroup'; + ksWhat_TestGroup = 'chlog::TestGroup'; + ksWhat_User = 'chlog::User'; + ksWhat_TestResult = 'chlog::TestResult'; + ## @} + + ## Mapping a changelog entry kind to a table, key and clue. + kdWhatToTable = dict({ # pylint: disable=star-args + ksWhat_TestBox: ( 'TestBoxes', 'idTestBox', None, ), + ksWhat_TestCase: ( 'TestCasees', 'idTestCase', None, ), + ksWhat_Blacklisting: ( 'Blacklist', 'idBlacklisting', None, ), + ksWhat_Build: ( 'Builds', 'idBuild', None, ), + ksWhat_BuildSource: ( 'BuildSources', 'idBuildSrc', None, ), + ksWhat_FailureCategory: ( 'FailureCategories', 'idFailureCategory', None, ), + ksWhat_FailureReason: ( 'FailureReasons', 'idFailureReason', None, ), + ksWhat_GlobalRsrc: ( 'GlobalResources', 'idGlobalRsrc', None, ), + ksWhat_SchedGroup: ( 'SchedGroups', 'idSchedGroup', None, ), + ksWhat_TestGroup: ( 'TestGroups', 'idTestGroup', None, ), + ksWhat_User: ( 'Users', 'idUser', None, ), + ksWhat_TestResult: ( 'TestResults', 'idTestResult', None, ), + }, **{sEvent: ( 'SystemLog', 'tsCreated', 'TimestampId', ) for sEvent in SystemLogData.kasEvents}); + + ## The table key is the effective timestamp. (Can't be used above for some weird scoping reason.) + ksClue_TimestampId = 'TimestampId'; + + ## @todo move to config.py? + ksVSheriffLoginName = 'vsheriff'; + + + ## @name for kaasChangelogTables + ## @internal + ## @{ + ksTweak_None = ''; + ksTweak_NotNullAuthor = 'uidAuthorNotNull'; + ksTweak_NotNullAuthorOrVSheriff = 'uidAuthorNotNullOrVSheriff'; + ## @} + + ## @internal + kaasChangelogTables = ( + # [0]: change name, [1]: Table name, [2]: key column, [3]:later, [4]: tweak + ( ksWhat_TestBox, 'TestBoxes', 'idTestBox', None, ksTweak_NotNullAuthor, ), + ( ksWhat_TestBox, 'TestBoxesInSchedGroups', 'idTestBox', None, ksTweak_None, ), + ( ksWhat_TestCase, 'TestCases', 'idTestCase', None, ksTweak_None, ), + ( ksWhat_TestCase, 'TestCaseArgs', 'idTestCase', None, ksTweak_None, ), + ( ksWhat_TestCase, 'TestCaseDeps', 'idTestCase', None, ksTweak_None, ), + ( ksWhat_TestCase, 'TestCaseGlobalRsrcDeps', 'idTestCase', None, ksTweak_None, ), + ( ksWhat_Blacklisting, 'BuildBlacklist', 'idBlacklisting', None, ksTweak_None, ), + ( ksWhat_Build, 'Builds', 'idBuild', None, ksTweak_NotNullAuthor, ), + ( ksWhat_BuildSource, 'BuildSources', 'idBuildSrc', None, ksTweak_None, ), + ( ksWhat_FailureCategory, 'FailureCategories', 'idFailureCategory', None, ksTweak_None, ), + ( ksWhat_FailureReason, 'FailureReasons', 'idFailureReason', None, ksTweak_None, ), + ( ksWhat_GlobalRsrc, 'GlobalResources', 'idGlobalRsrc', None, ksTweak_None, ), + ( ksWhat_SchedGroup, 'SchedGroups', 'idSchedGroup', None, ksTweak_None, ), + ( ksWhat_SchedGroup, 'SchedGroupMembers', 'idSchedGroup', None, ksTweak_None, ), + ( ksWhat_TestGroup, 'TestGroups', 'idTestGroup', None, ksTweak_None, ), + ( ksWhat_TestGroup, 'TestGroupMembers', 'idTestGroup', None, ksTweak_None, ), + ( ksWhat_User, 'Users', 'uid', None, ksTweak_None, ), + ( ksWhat_TestResult, 'TestResultFailures', 'idTestResult', None, ksTweak_NotNullAuthorOrVSheriff, ), + ); + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb); + + + def fetchForListingEx(self, iStart, cMaxRows, tsNow, cDaysBack, aiSortColumns = None): + """ + Fetches SystemLog entries. + + Returns an array (list) of SystemLogData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + + # + # Construct the query. + # + oUserAccountLogic = UserAccountLogic(self._oDb); + oVSheriff = oUserAccountLogic.tryFetchAccountByLoginName(self.ksVSheriffLoginName); + uidVSheriff = oVSheriff.uid if oVSheriff is not None else -1; + + if tsNow is None: + sWhereTime = self._oDb.formatBindArgs(' WHERE tsEffective >= CURRENT_TIMESTAMP - \'%s days\'::interval\n', + (cDaysBack,)); + else: + sWhereTime = self._oDb.formatBindArgs(' WHERE tsEffective >= (%s::timestamptz - \'%s days\'::interval)\n' + ' AND tsEffective <= %s\n', + (tsNow, cDaysBack, tsNow)); + + # Special entry for the system log. + sQuery = '(\n' + sQuery += ' SELECT NULL AS uidAuthor,\n'; + sQuery += ' tsCreated AS tsEffective,\n'; + sQuery += ' sEvent AS sEvent,\n'; + sQuery += ' NULL AS idWhat,\n'; + sQuery += ' sLogText AS sDesc\n'; + sQuery += ' FROM SystemLog\n'; + sQuery += sWhereTime.replace('tsEffective', 'tsCreated'); + sQuery += ' ORDER BY tsCreated DESC\n' + sQuery += ')' + + for asEntry in self.kaasChangelogTables: + sQuery += ' UNION (\n' + sQuery += ' SELECT uidAuthor, tsEffective, \'' + asEntry[0] + '\', ' + asEntry[2] + ', \'\'\n'; + sQuery += ' FROM ' + asEntry[1] + '\n' + sQuery += sWhereTime; + if asEntry[4] == self.ksTweak_NotNullAuthor or asEntry[4] == self.ksTweak_NotNullAuthorOrVSheriff: + sQuery += ' AND uidAuthor IS NOT NULL\n'; + if asEntry[4] == self.ksTweak_NotNullAuthorOrVSheriff: + sQuery += ' AND uidAuthor <> %u\n' % (uidVSheriff,); + sQuery += ' ORDER BY tsEffective DESC\n' + sQuery += ')'; + sQuery += ' ORDER BY 2 DESC\n'; + sQuery += ' LIMIT %u OFFSET %u\n' % (cMaxRows, iStart, ); + + + # + # Execute the query and construct the return data. + # + self._oDb.execute(sQuery); + aoRows = []; + for aoRow in self._oDb.fetchAll(): + aoRows.append(SystemChangelogEntry(aoRow[1], oUserAccountLogic.cachedLookup(aoRow[0]), + aoRow[2], aoRow[3], aoRow[4])); + + + return aoRows; + diff --git a/src/VBox/ValidationKit/testmanager/core/systemlog.py b/src/VBox/ValidationKit/testmanager/core/systemlog.py new file mode 100755 index 00000000..85929c51 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/systemlog.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# $Id: systemlog.py $ + +""" +Test Manager - SystemLog. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMExceptionBase; + + +class SystemLogData(ModelDataBase): # pylint: disable=too-many-instance-attributes + """ + SystemLog Data. + """ + + ## @name Event Constants + # @{ + ksEvent_CmdNacked = 'CmdNack '; + ksEvent_TestBoxUnknown = 'TBoxUnkn'; + ksEvent_TestSetAbandoned = 'TSetAbdd'; + ksEvent_UserAccountUnknown = 'TAccUnkn'; + ksEvent_XmlResultMalformed = 'XmlRMalf'; + ksEvent_SchedQueueRecreate = 'SchQRecr'; + ## @} + + ## Valid event types. + kasEvents = \ + [ \ + ksEvent_CmdNacked, + ksEvent_TestBoxUnknown, + ksEvent_TestSetAbandoned, + ksEvent_UserAccountUnknown, + ksEvent_XmlResultMalformed, + ksEvent_SchedQueueRecreate, + ]; + + ksParam_tsCreated = 'tsCreated'; + ksParam_sEvent = 'sEvent'; + ksParam_sLogText = 'sLogText'; + + kasValidValues_sEvent = kasEvents; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.tsCreated = None; + self.sEvent = None; + self.sLogText = None; + + def initFromDbRow(self, aoRow): + """ + Internal worker for initFromDbWithId and initFromDbWithGenId as well as + SystemLogLogic. + """ + + if aoRow is None: + raise TMExceptionBase('SystemLog row not found.'); + + self.tsCreated = aoRow[0]; + self.sEvent = aoRow[1]; + self.sLogText = aoRow[2]; + return self; + + +class SystemLogLogic(ModelLogicBase): + """ + SystemLog logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb); + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches SystemLog entries. + + Returns an array (list) of SystemLogData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM SystemLog\n' + 'ORDER BY tsCreated DESC\n' + 'LIMIT %s OFFSET %s\n', + (cMaxRows, iStart)); + else: + self._oDb.execute('SELECT *\n' + 'FROM SystemLog\n' + 'WHERE tsCreated <= %s\n' + 'ORDER BY tsCreated DESC\n' + 'LIMIT %s OFFSET %s\n', + (tsNow, cMaxRows, iStart)); + aoRows = []; + for _ in range(self._oDb.getRowCount()): + oData = SystemLogData(); + oData.initFromDbRow(self._oDb.fetchOne()); + aoRows.append(oData); + return aoRows; + + def addEntry(self, sEvent, sLogText, cHoursRepeat = 0, fCommit = False): + """ + Adds an entry to the SystemLog table. + Raises exception on problem. + """ + if sEvent not in SystemLogData.kasEvents: + raise TMExceptionBase('Unknown event type "%s"' % (sEvent,)); + + # Check the repeat restriction first. + if cHoursRepeat > 0: + self._oDb.execute('SELECT COUNT(*) as Stuff\n' + 'FROM SystemLog\n' + 'WHERE tsCreated >= (current_timestamp - interval \'%s hours\')\n' + ' AND sEvent = %s\n' + ' AND sLogText = %s\n', + (cHoursRepeat, + sEvent, + sLogText)); + aRow = self._oDb.fetchOne(); + if aRow[0] > 0: + return None; + + # Insert it. + self._oDb.execute('INSERT INTO SystemLog (sEvent, sLogText)\n' + 'VALUES (%s, %s)\n', + (sEvent, sLogText)); + + if fCommit: + self._oDb.commit(); + return True; + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class SystemLogDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [SystemLogData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testbox.pgsql b/src/VBox/ValidationKit/testmanager/core/testbox.pgsql new file mode 100644 index 00000000..a6a085b8 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testbox.pgsql @@ -0,0 +1,635 @@ +-- $Id: testbox.pgsql $ +--- @file +-- VBox Test Manager Database Stored Procedures - TestBoxes. +-- + +-- +-- Copyright (C) 2012-2023 Oracle and/or its affiliates. +-- +-- This file is part of VirtualBox base platform packages, as +-- available from https://www.virtualbox.org. +-- +-- This program is free software; you can redistribute it and/or +-- modify it under the terms of the GNU General Public License +-- as published by the Free Software Foundation, in version 3 of the +-- License. +-- +-- 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, see . +-- +-- The contents of this file may alternatively be used under the terms +-- of the Common Development and Distribution License Version 1.0 +-- (CDDL), a copy of it is provided in the "COPYING.CDDL" file included +-- in the VirtualBox distribution, in which case the provisions of the +-- CDDL are applicable instead of those of the GPL. +-- +-- You may elect to license modified versions of this file under the +-- terms and conditions of either the GPL or the CDDL or both. +-- +-- SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +-- + + +-- +-- Old type signatures. +-- +DROP FUNCTION IF EXISTS TestBoxLogic_addEntry(a_uidAuthor INTEGER, + a_ip inet, + a_uuidSystem uuid, + a_sName TEXT, + a_sDescription TEXT, + a_idSchedGroup INTEGER, + a_fEnabled BOOLEAN, + a_enmLomKind LomKind_T, + a_ipLom inet, + a_pctScaleTimeout INTEGER, -- Actually smallint, but default typing fun. + a_sComment TEXT, + a_enmPendingCmd TestBoxCmd_T, + OUT r_idTestBox INTEGER, + OUT r_idGenTestBox INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE); +DROP FUNCTION IF EXISTS TestBoxLogic_editEntry(a_uidAuthor INTEGER, + a_idTestBox INTEGER, + a_ip inet, + a_uuidSystem uuid, + a_sName TEXT, + a_sDescription TEXT, + a_idSchedGroup INTEGER, + a_fEnabled BOOLEAN, + a_enmLomKind LomKind_T, + a_ipLom inet, + a_pctScaleTimeout INTEGER, -- Actually smallint, but default typing fun. + a_sComment TEXT, + a_enmPendingCmd TestBoxCmd_T, + OUT r_idGenTestBox INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE); +DROP FUNCTION IF EXISTS TestBoxLogic_removeEntry(INTEGER, INTEGER, BOOLEAN); +DROP FUNCTION IF EXISTS TestBoxLogic_addGroupEntry(a_uidAuthor INTEGER, + a_idTestBox INTEGER, + a_idSchedGroup INTEGER, + a_iSchedPriority INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE); +DROP FUNCTION IF EXISTS TestBoxLogic_editGroupEntry(a_uidAuthor INTEGER, + a_idTestBox INTEGER, + a_idSchedGroup INTEGER, + a_iSchedPriority INTEGER, + OUT r_tsEffective INTEGER); + + +--- +-- Checks if the test box name is unique, ignoring a_idTestCaseIgnore. +-- Raises exception if duplicates are found. +-- +-- @internal +-- +CREATE OR REPLACE FUNCTION TestBoxLogic_checkUniqueName(a_sName TEXT, a_idTestBoxIgnore INTEGER) + RETURNS VOID AS $$ + DECLARE + v_cRows INTEGER; + BEGIN + SELECT COUNT(*) INTO v_cRows + FROM TestBoxes + WHERE sName = a_sName + AND tsExpire = 'infinity'::TIMESTAMP + AND idTestBox <> a_idTestBoxIgnore; + IF v_cRows <> 0 THEN + RAISE EXCEPTION 'Duplicate test box name "%" (% times)', a_sName, v_cRows; + END IF; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Checks that the given scheduling group exists. +-- Raises exception if it doesn't. +-- +-- @internal +-- +CREATE OR REPLACE FUNCTION TestBoxLogic_checkSchedGroupExists(a_idSchedGroup INTEGER) + RETURNS VOID AS $$ + DECLARE + v_cRows INTEGER; + BEGIN + SELECT COUNT(*) INTO v_cRows + FROM SchedGroups + WHERE idSchedGroup = a_idSchedGroup + AND tsExpire = 'infinity'::TIMESTAMP; + IF v_cRows <> 1 THEN + IF v_cRows = 0 THEN + RAISE EXCEPTION 'Scheduling group with ID % was not found', a_idSchedGroup; + END IF; + RAISE EXCEPTION 'Integrity error in SchedGroups: % current rows with idSchedGroup=%', v_cRows, a_idSchedGroup; + END IF; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Checks that the given testbxo + scheduling group pair does not currently exists. +-- Raises exception if it does. +-- +-- @internal +-- +CREATE OR REPLACE FUNCTION TestBoxLogic_checkTestBoxNotInSchedGroup(a_idTestBox INTEGER, a_idSchedGroup INTEGER) + RETURNS VOID AS $$ + DECLARE + v_cRows INTEGER; + BEGIN + SELECT COUNT(*) INTO v_cRows + FROM TestBoxesInSchedGroups + WHERE idTestBox = a_idTestBox + AND idSchedGroup = a_idSchedGroup + AND tsExpire = 'infinity'::TIMESTAMP; + IF v_cRows <> 0 THEN + RAISE EXCEPTION 'TestBox % is already a member of scheduling group %', a_idTestBox, a_idSchedGroup; + END IF; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Historize a row. +-- @internal +-- +CREATE OR REPLACE FUNCTION TestBoxLogic_historizeEntry(a_idGenTestBox INTEGER, a_tsExpire TIMESTAMP WITH TIME ZONE) + RETURNS VOID AS $$ + DECLARE + v_cUpdatedRows INTEGER; + BEGIN + UPDATE TestBoxes + SET tsExpire = a_tsExpire + WHERE idGenTestBox = a_idGenTestBox + AND tsExpire = 'infinity'::TIMESTAMP; + GET DIAGNOSTICS v_cUpdatedRows = ROW_COUNT; + IF v_cUpdatedRows <> 1 THEN + IF v_cUpdatedRows = 0 THEN + RAISE EXCEPTION 'Test box generation ID % is no longer valid', a_idGenTestBox; + END IF; + RAISE EXCEPTION 'Integrity error in TestBoxes: % current rows with idGenTestBox=%', v_cUpdatedRows, a_idGenTestBox; + END IF; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Historize a in-scheduling-group row. +-- @internal +-- +CREATE OR REPLACE FUNCTION TestBoxLogic_historizeGroupEntry(a_idTestBox INTEGER, + a_idSchedGroup INTEGER, + a_tsExpire TIMESTAMP WITH TIME ZONE) + RETURNS VOID AS $$ + DECLARE + v_cUpdatedRows INTEGER; + BEGIN + UPDATE TestBoxesInSchedGroups + SET tsExpire = a_tsExpire + WHERE idTestBox = a_idTestBox + AND idSchedGroup = a_idSchedGroup + AND tsExpire = 'infinity'::TIMESTAMP; + GET DIAGNOSTICS v_cUpdatedRows = ROW_COUNT; + IF v_cUpdatedRows <> 1 THEN + IF v_cUpdatedRows = 0 THEN + RAISE EXCEPTION 'TestBox ID % / SchedGroup ID % is no longer a valid combination', a_idTestBox, a_idSchedGroup; + END IF; + RAISE EXCEPTION 'Integrity error in TestBoxesInSchedGroups: % current rows for % / %', + v_cUpdatedRows, a_idTestBox, a_idSchedGroup; + END IF; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Translate string via the string table. +-- +-- @returns NULL if a_sValue is NULL, otherwise a string ID. +-- +CREATE OR REPLACE FUNCTION TestBoxLogic_lookupOrFindString(a_sValue TEXT) + RETURNS INTEGER AS $$ + DECLARE + v_idStr INTEGER; + v_cRows INTEGER; + BEGIN + IF a_sValue IS NULL THEN + RETURN NULL; + END IF; + + SELECT idStr + INTO v_idStr + FROM TestBoxStrTab + WHERE sValue = a_sValue; + GET DIAGNOSTICS v_cRows = ROW_COUNT; + IF v_cRows = 0 THEN + INSERT INTO TestBoxStrTab (sValue) + VALUES (a_sValue) + RETURNING idStr INTO v_idStr; + END IF; + RETURN v_idStr; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Only adds the user settable parts of the row, i.e. not what TestBoxLogic_updateOnSignOn touches. +-- +CREATE OR REPLACE function TestBoxLogic_addEntry(a_uidAuthor INTEGER, + a_ip inet, + a_uuidSystem uuid, + a_sName TEXT, + a_sDescription TEXT, + a_fEnabled BOOLEAN, + a_enmLomKind LomKind_T, + a_ipLom inet, + a_pctScaleTimeout INTEGER, -- Actually smallint, but default typing fun. + a_sComment TEXT, + a_enmPendingCmd TestBoxCmd_T, + OUT r_idTestBox INTEGER, + OUT r_idGenTestBox INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE + ) AS $$ + DECLARE + v_idStrDescription INTEGER; + v_idStrComment INTEGER; + BEGIN + PERFORM TestBoxLogic_checkUniqueName(a_sName, -1); + + SELECT TestBoxLogic_lookupOrFindString(a_sDescription) INTO v_idStrDescription; + SELECT TestBoxLogic_lookupOrFindString(a_sComment) INTO v_idStrComment; + + INSERT INTO TestBoxes ( + tsEffective, -- 1 + uidAuthor, -- 2 + ip, -- 3 + uuidSystem, -- 4 + sName, -- 5 + idStrDescription, -- 6 + fEnabled, -- 7 + enmLomKind, -- 8 + ipLom, -- 9 + pctScaleTimeout, -- 10 + idStrComment, -- 11 + enmPendingCmd ) -- 12 + VALUES (CURRENT_TIMESTAMP, -- 1 + a_uidAuthor, -- 2 + a_ip, -- 3 + a_uuidSystem, -- 4 + a_sName, -- 5 + v_idStrDescription, -- 6 + a_fEnabled, -- 7 + a_enmLomKind, -- 8 + a_ipLom, -- 9 + a_pctScaleTimeout, -- 10 + v_idStrComment, -- 11 + a_enmPendingCmd ) -- 12 + RETURNING idTestBox, idGenTestBox, tsEffective INTO r_idTestBox, r_idGenTestBox, r_tsEffective; + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE function TestBoxLogic_addGroupEntry(a_uidAuthor INTEGER, + a_idTestBox INTEGER, + a_idSchedGroup INTEGER, + a_iSchedPriority INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE + ) AS $$ + BEGIN + PERFORM TestBoxLogic_checkSchedGroupExists(a_idSchedGroup); + PERFORM TestBoxLogic_checkTestBoxNotInSchedGroup(a_idTestBox, a_idSchedGroup); + + INSERT INTO TestBoxesInSchedGroups ( + idTestBox, + idSchedGroup, + tsEffective, + tsExpire, + uidAuthor, + iSchedPriority) + VALUES (a_idTestBox, + a_idSchedGroup, + CURRENT_TIMESTAMP, + 'infinity'::TIMESTAMP, + a_uidAuthor, + a_iSchedPriority) + RETURNING tsEffective INTO r_tsEffective; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Only adds the user settable parts of the row, i.e. not what TestBoxLogic_updateOnSignOn touches. +-- +CREATE OR REPLACE function TestBoxLogic_editEntry(a_uidAuthor INTEGER, + a_idTestBox INTEGER, + a_ip inet, + a_uuidSystem uuid, + a_sName TEXT, + a_sDescription TEXT, + a_fEnabled BOOLEAN, + a_enmLomKind LomKind_T, + a_ipLom inet, + a_pctScaleTimeout INTEGER, -- Actually smallint, but default typing fun. + a_sComment TEXT, + a_enmPendingCmd TestBoxCmd_T, + OUT r_idGenTestBox INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE + ) AS $$ + DECLARE + v_Row TestBoxes%ROWTYPE; + v_idStrDescription INTEGER; + v_idStrComment INTEGER; + BEGIN + PERFORM TestBoxLogic_checkUniqueName(a_sName, a_idTestBox); + + SELECT TestBoxLogic_lookupOrFindString(a_sDescription) INTO v_idStrDescription; + SELECT TestBoxLogic_lookupOrFindString(a_sComment) INTO v_idStrComment; + + -- Fetch and historize the current row - there must be one. + UPDATE TestBoxes + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestBox = a_idTestBox + AND tsExpire = 'infinity'::TIMESTAMP + RETURNING * INTO STRICT v_Row; + + -- Modify the row with the new data. + v_Row.uidAuthor := a_uidAuthor; + v_Row.ip := a_ip; + v_Row.uuidSystem := a_uuidSystem; + v_Row.sName := a_sName; + v_Row.idStrDescription := v_idStrDescription; + v_Row.fEnabled := a_fEnabled; + v_Row.enmLomKind := a_enmLomKind; + v_Row.ipLom := a_ipLom; + v_Row.pctScaleTimeout := a_pctScaleTimeout; + v_Row.idStrComment := v_idStrComment; + v_Row.enmPendingCmd := a_enmPendingCmd; + v_Row.tsEffective := v_Row.tsExpire; + r_tsEffective := v_Row.tsExpire; + v_Row.tsExpire := 'infinity'::TIMESTAMP; + + -- Get a new generation ID. + SELECT NEXTVAL('TestBoxGenIdSeq') INTO v_Row.idGenTestBox; + r_idGenTestBox := v_Row.idGenTestBox; + + -- Insert the modified row. + INSERT INTO TestBoxes VALUES (v_Row.*); + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE function TestBoxLogic_editGroupEntry(a_uidAuthor INTEGER, + a_idTestBox INTEGER, + a_idSchedGroup INTEGER, + a_iSchedPriority INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE + ) AS $$ + DECLARE + v_Row TestBoxesInSchedGroups%ROWTYPE; + v_idStrDescription INTEGER; + v_idStrComment INTEGER; + BEGIN + PERFORM TestBoxLogic_checkSchedGroupExists(a_idSchedGroup); + + -- Fetch and historize the current row - there must be one. + UPDATE TestBoxesInSchedGroups + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestBox = a_idTestBox + AND idSchedGroup = a_idSchedGroup + AND tsExpire = 'infinity'::TIMESTAMP + RETURNING * INTO STRICT v_Row; + + -- Modify the row with the new data. + v_Row.uidAuthor := a_uidAuthor; + v_Row.iSchedPriority := a_iSchedPriority; + v_Row.tsEffective := v_Row.tsExpire; + r_tsEffective := v_Row.tsExpire; + v_Row.tsExpire := 'infinity'::TIMESTAMP; + + -- Insert the modified row. + INSERT INTO TestBoxesInSchedGroups VALUES (v_Row.*); + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION TestBoxLogic_removeEntry(a_uidAuthor INTEGER, a_idTestBox INTEGER, a_fCascade BOOLEAN) + RETURNS VOID AS $$ + DECLARE + v_Row TestBoxes%ROWTYPE; + v_tsEffective TIMESTAMP WITH TIME ZONE; + v_Rec RECORD; + v_sErrors TEXT; + BEGIN + -- + -- Check preconditions. + -- + IF a_fCascade <> TRUE THEN + -- @todo implement checks which throws useful exceptions. + ELSE + RAISE EXCEPTION 'CASCADE test box deletion is not implemented'; + END IF; + + -- + -- Delete all current groups, skipping history since we're also deleting the testbox. + -- + UPDATE TestBoxesInSchedGroups + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestBox = a_idTestBox + AND tsExpire = 'infinity'::TIMESTAMP; + + -- + -- To preserve the information about who deleted the record, we try to + -- add a dummy record which expires immediately. I say try because of + -- the primary key, we must let the new record be valid for 1 us. :-( + -- + SELECT * INTO STRICT v_Row + FROM TestBoxes + WHERE idTestBox = a_idTestBox + AND tsExpire = 'infinity'::TIMESTAMP; + + v_tsEffective := CURRENT_TIMESTAMP - INTERVAL '1 microsecond'; + IF v_Row.tsEffective < v_tsEffective THEN + PERFORM TestBoxLogic_historizeEntry(v_Row.idGenTestBox, v_tsEffective); + + v_Row.tsEffective := v_tsEffective; + v_Row.tsExpire := CURRENT_TIMESTAMP; + v_Row.uidAuthor := a_uidAuthor; + SELECT NEXTVAL('TestBoxGenIdSeq') INTO v_Row.idGenTestBox; + INSERT INTO TestBoxes VALUES (v_Row.*); + ELSE + PERFORM TestBoxLogic_historizeEntry(v_Row.idGenTestBox, CURRENT_TIMESTAMP); + END IF; + + EXCEPTION + WHEN NO_DATA_FOUND THEN + RAISE EXCEPTION 'Test box with ID % does not currently exist', a_idTestBox; + WHEN TOO_MANY_ROWS THEN + RAISE EXCEPTION 'Integrity error in TestBoxes: Too many current rows for %', a_idTestBox; + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION TestBoxLogic_removeGroupEntry(a_uidAuthor INTEGER, a_idTestBox INTEGER, a_idSchedGroup INTEGER) + RETURNS VOID AS $$ + DECLARE + v_Row TestBoxesInSchedGroups%ROWTYPE; + v_tsEffective TIMESTAMP WITH TIME ZONE; + BEGIN + -- + -- To preserve the information about who deleted the record, we try to + -- add a dummy record which expires immediately. I say try because of + -- the primary key, we must let the new record be valid for 1 us. :-( + -- + SELECT * INTO STRICT v_Row + FROM TestBoxesInSchedGroups + WHERE idTestBox = a_idTestBox + AND idSchedGroup = a_idSchedGroup + AND tsExpire = 'infinity'::TIMESTAMP; + + v_tsEffective := CURRENT_TIMESTAMP - INTERVAL '1 microsecond'; + IF v_Row.tsEffective < v_tsEffective THEN + PERFORM TestBoxLogic_historizeGroupEntry(a_idTestBox, a_idSchedGroup, v_tsEffective); + + v_Row.tsEffective := v_tsEffective; + v_Row.tsExpire := CURRENT_TIMESTAMP; + v_Row.uidAuthor := a_uidAuthor; + INSERT INTO TestBoxesInSchedGroups VALUES (v_Row.*); + ELSE + PERFORM TestBoxLogic_historizeGroupEntry(a_idTestBox, a_idSchedGroup, CURRENT_TIMESTAMP); + END IF; + + EXCEPTION + WHEN NO_DATA_FOUND THEN + RAISE EXCEPTION 'TestBox #% does is not currently a member of scheduling group #%', a_idTestBox, a_idSchedGroup; + WHEN TOO_MANY_ROWS THEN + RAISE EXCEPTION 'Integrity error in TestBoxesInSchedGroups: Too many current rows for % / %', + a_idTestBox, a_idSchedGroup; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Sign on update +-- +CREATE OR REPLACE function TestBoxLogic_updateOnSignOn(a_idTestBox INTEGER, + a_ip inet, + a_sOs TEXT, + a_sOsVersion TEXT, + a_sCpuVendor TEXT, + a_sCpuArch TEXT, + a_sCpuName TEXT, + a_lCpuRevision bigint, + a_cCpus INTEGER, -- Actually smallint, but default typing fun. + a_fCpuHwVirt boolean, + a_fCpuNestedPaging boolean, + a_fCpu64BitGuest boolean, + a_fChipsetIoMmu boolean, + a_fRawMode boolean, + a_cMbMemory bigint, + a_cMbScratch bigint, + a_sReport TEXT, + a_iTestBoxScriptRev INTEGER, + a_iPythonHexVersion INTEGER, + OUT r_idGenTestBox INTEGER + ) AS $$ + DECLARE + v_Row TestBoxes%ROWTYPE; + v_idStrOs INTEGER; + v_idStrOsVersion INTEGER; + v_idStrCpuVendor INTEGER; + v_idStrCpuArch INTEGER; + v_idStrCpuName INTEGER; + v_idStrReport INTEGER; + BEGIN + SELECT TestBoxLogic_lookupOrFindString(a_sOs) INTO v_idStrOs; + SELECT TestBoxLogic_lookupOrFindString(a_sOsVersion) INTO v_idStrOsVersion; + SELECT TestBoxLogic_lookupOrFindString(a_sCpuVendor) INTO v_idStrCpuVendor; + SELECT TestBoxLogic_lookupOrFindString(a_sCpuArch) INTO v_idStrCpuArch; + SELECT TestBoxLogic_lookupOrFindString(a_sCpuName) INTO v_idStrCpuName; + SELECT TestBoxLogic_lookupOrFindString(a_sReport) INTO v_idStrReport; + + -- Fetch and historize the current row - there must be one. + UPDATE TestBoxes + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestBox = a_idTestBox + AND tsExpire = 'infinity'::TIMESTAMP + RETURNING * INTO STRICT v_Row; + + -- Modify the row with the new data. + v_Row.uidAuthor := NULL; + v_Row.ip := a_ip; + v_Row.idStrOs := v_idStrOs; + v_Row.idStrOsVersion := v_idStrOsVersion; + v_Row.idStrCpuVendor := v_idStrCpuVendor; + v_Row.idStrCpuArch := v_idStrCpuArch; + v_Row.idStrCpuName := v_idStrCpuName; + v_Row.lCpuRevision := a_lCpuRevision; + v_Row.cCpus := a_cCpus; + v_Row.fCpuHwVirt := a_fCpuHwVirt; + v_Row.fCpuNestedPaging := a_fCpuNestedPaging; + v_Row.fCpu64BitGuest := a_fCpu64BitGuest; + v_Row.fChipsetIoMmu := a_fChipsetIoMmu; + v_Row.fRawMode := a_fRawMode; + v_Row.cMbMemory := a_cMbMemory; + v_Row.cMbScratch := a_cMbScratch; + v_Row.idStrReport := v_idStrReport; + v_Row.iTestBoxScriptRev := a_iTestBoxScriptRev; + v_Row.iPythonHexVersion := a_iPythonHexVersion; + v_Row.tsEffective := v_Row.tsExpire; + v_Row.tsExpire := 'infinity'::TIMESTAMP; + + -- Get a new generation ID. + SELECT NEXTVAL('TestBoxGenIdSeq') INTO v_Row.idGenTestBox; + r_idGenTestBox := v_Row.idGenTestBox; + + -- Insert the modified row. + INSERT INTO TestBoxes VALUES (v_Row.*); + END; +$$ LANGUAGE plpgsql; + + +--- +-- Set new command. +-- +CREATE OR REPLACE function TestBoxLogic_setCommand(a_uidAuthor INTEGER, + a_idTestBox INTEGER, + a_enmOldCmd TestBoxCmd_T, + a_enmNewCmd TestBoxCmd_T, + a_sComment TEXT, + OUT r_idGenTestBox INTEGER, + OUT r_tsEffective TIMESTAMP WITH TIME ZONE + ) AS $$ + DECLARE + v_Row TestBoxes%ROWTYPE; + v_idStrComment INTEGER; + BEGIN + SELECT TestBoxLogic_lookupOrFindString(a_sComment) INTO v_idStrComment; + + -- Fetch and historize the current row - there must be one. + UPDATE TestBoxes + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestBox = a_idTestBox + AND tsExpire = 'infinity'::TIMESTAMP + AND enmPendingCmd = a_enmOldCmd + RETURNING * INTO STRICT v_Row; + + -- Modify the row with the new data. + v_Row.enmPendingCmd := a_enmNewCmd; + IF v_idStrComment IS NOT NULL THEN + v_Row.idStrComment := v_idStrComment; + END IF; + v_Row.tsEffective := v_Row.tsExpire; + r_tsEffective := v_Row.tsExpire; + v_Row.tsExpire := 'infinity'::TIMESTAMP; + + -- Get a new generation ID. + SELECT NEXTVAL('TestBoxGenIdSeq') INTO v_Row.idGenTestBox; + r_idGenTestBox := v_Row.idGenTestBox; + + -- Insert the modified row. + INSERT INTO TestBoxes VALUES (v_Row.*); + END; +$$ LANGUAGE plpgsql; + diff --git a/src/VBox/ValidationKit/testmanager/core/testbox.py b/src/VBox/ValidationKit/testmanager/core/testbox.py new file mode 100755 index 00000000..6686ca3b --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testbox.py @@ -0,0 +1,1286 @@ +# -*- coding: utf-8 -*- +# $Id: testbox.py $ + +""" +Test Manager - TestBox. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import copy; +import sys; +import unittest; + +# Validation Kit imports. +from testmanager.core import db; +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMInFligthCollision, \ + TMInvalidData, TMTooManyRows, TMRowNotFound, \ + ChangeLogEntry, AttributeChangeEntry, AttributeChangeEntryPre; +from testmanager.core.useraccount import UserAccountLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + xrange = range; # pylint: disable=redefined-builtin,invalid-name + + +class TestBoxInSchedGroupData(ModelDataBase): + """ + TestBox in SchedGroup data. + """ + + ksParam_idTestBox = 'TestBoxInSchedGroup_idTestBox'; + ksParam_idSchedGroup = 'TestBoxInSchedGroup_idSchedGroup'; + ksParam_tsEffective = 'TestBoxInSchedGroup_tsEffective'; + ksParam_tsExpire = 'TestBoxInSchedGroup_tsExpire'; + ksParam_uidAuthor = 'TestBoxInSchedGroup_uidAuthor'; + ksParam_iSchedPriority = 'TestBoxInSchedGroup_iSchedPriority'; + + kasAllowNullAttributes = [ 'tsEffective', 'tsExpire', 'uidAuthor', ] + + kiMin_iSchedPriority = 0; + kiMax_iSchedPriority = 32; + + kcDbColumns = 6; + + def __init__(self): + ModelDataBase.__init__(self); + self.idTestBox = None; + self.idSchedGroup = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.iSchedPriority = 16; + + def initFromDbRow(self, aoRow): + """ + Expecting the result from a query like this: + SELECT * FROM TestBoxesInSchedGroups + """ + if aoRow is None: + raise TMRowNotFound('TestBox/SchedGroup not found.'); + + self.idTestBox = aoRow[0]; + self.idSchedGroup = aoRow[1]; + self.tsEffective = aoRow[2]; + self.tsExpire = aoRow[3]; + self.uidAuthor = aoRow[4]; + self.iSchedPriority = aoRow[5]; + + return self; + +class TestBoxInSchedGroupDataEx(TestBoxInSchedGroupData): + """ + Extended version of TestBoxInSchedGroupData that contains the scheduling group. + """ + + def __init__(self): + TestBoxInSchedGroupData.__init__(self); + self.oSchedGroup = None # type: SchedGroupData + + def initFromDbRowEx(self, aoRow, oDb, tsNow = None, sPeriodBack = None): + """ + Extended version of initFromDbRow that fills in the rest from the database. + """ + from testmanager.core.schedgroup import SchedGroupData; + self.initFromDbRow(aoRow); + self.oSchedGroup = SchedGroupData().initFromDbWithId(oDb, self.idSchedGroup, tsNow, sPeriodBack); + return self; + +class TestBoxDataForSchedGroup(TestBoxInSchedGroupData): + """ + Extended version of TestBoxInSchedGroupData that adds the testbox data (if available). + Used by TestBoxLogic.fetchForSchedGroup + """ + + def __init__(self): + TestBoxInSchedGroupData.__init__(self); + self.oTestBox = None # type: TestBoxData + + def initFromDbRow(self, aoRow): + """ + The row is: TestBoxesInSchedGroups.*, TestBoxesWithStrings.* + """ + TestBoxInSchedGroupData.initFromDbRow(self, aoRow); + if aoRow[self.kcDbColumns]: + self.oTestBox = TestBoxData().initFromDbRow(aoRow[self.kcDbColumns:]); + else: + self.oTestBox = None; + return self; + + def getDataAttributes(self): + asAttributes = TestBoxInSchedGroupData.getDataAttributes(self); + asAttributes.remove('oTestBox'); + return asAttributes; + + def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ModelDataBase.ksValidateFor_Other): + dErrors = TestBoxInSchedGroupData._validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor); + if self.ksParam_idTestBox not in dErrors: + self.oTestBox = TestBoxData(); + try: + self.oTestBox.initFromDbWithId(oDb, self.idTestBox); + except Exception as oXcpt: + self.oTestBox = TestBoxData() + dErrors[self.ksParam_idTestBox] = str(oXcpt); + return dErrors; + + +# pylint: disable=invalid-name +class TestBoxData(ModelDataBase): # pylint: disable=too-many-instance-attributes + """ + TestBox Data. + """ + + ## LomKind_T + ksLomKind_None = 'none'; + ksLomKind_ILOM = 'ilom'; + ksLomKind_ELOM = 'elom'; + ksLomKind_AppleXserveLom = 'apple-xserver-lom'; + kasLomKindValues = [ ksLomKind_None, ksLomKind_ILOM, ksLomKind_ELOM, ksLomKind_AppleXserveLom]; + kaoLomKindDescs = \ + [ + ( ksLomKind_None, 'None', ''), + ( ksLomKind_ILOM, 'ILOM', ''), + ( ksLomKind_ELOM, 'ELOM', ''), + ( ksLomKind_AppleXserveLom, 'Apple Xserve LOM', ''), + ]; + + + ## TestBoxCmd_T + ksTestBoxCmd_None = 'none'; + ksTestBoxCmd_Abort = 'abort'; + ksTestBoxCmd_Reboot = 'reboot'; + ksTestBoxCmd_Upgrade = 'upgrade'; + ksTestBoxCmd_UpgradeAndReboot = 'upgrade-and-reboot'; + ksTestBoxCmd_Special = 'special'; + kasTestBoxCmdValues = [ ksTestBoxCmd_None, ksTestBoxCmd_Abort, ksTestBoxCmd_Reboot, ksTestBoxCmd_Upgrade, + ksTestBoxCmd_UpgradeAndReboot, ksTestBoxCmd_Special]; + kaoTestBoxCmdDescs = \ + [ + ( ksTestBoxCmd_None, 'None', ''), + ( ksTestBoxCmd_Abort, 'Abort current test', ''), + ( ksTestBoxCmd_Reboot, 'Reboot TestBox', ''), + ( ksTestBoxCmd_Upgrade, 'Upgrade TestBox Script', ''), + ( ksTestBoxCmd_UpgradeAndReboot, 'Upgrade TestBox Script and reboot', ''), + ( ksTestBoxCmd_Special, 'Special (reserved)', ''), + ]; + + + ksIdAttr = 'idTestBox'; + ksIdGenAttr = 'idGenTestBox'; + + ksParam_idTestBox = 'TestBox_idTestBox'; + ksParam_tsEffective = 'TestBox_tsEffective'; + ksParam_tsExpire = 'TestBox_tsExpire'; + ksParam_uidAuthor = 'TestBox_uidAuthor'; + ksParam_idGenTestBox = 'TestBox_idGenTestBox'; + ksParam_ip = 'TestBox_ip'; + ksParam_uuidSystem = 'TestBox_uuidSystem'; + ksParam_sName = 'TestBox_sName'; + ksParam_sDescription = 'TestBox_sDescription'; + ksParam_fEnabled = 'TestBox_fEnabled'; + ksParam_enmLomKind = 'TestBox_enmLomKind'; + ksParam_ipLom = 'TestBox_ipLom'; + ksParam_pctScaleTimeout = 'TestBox_pctScaleTimeout'; + ksParam_sComment = 'TestBox_sComment'; + ksParam_sOs = 'TestBox_sOs'; + ksParam_sOsVersion = 'TestBox_sOsVersion'; + ksParam_sCpuVendor = 'TestBox_sCpuVendor'; + ksParam_sCpuArch = 'TestBox_sCpuArch'; + ksParam_sCpuName = 'TestBox_sCpuName'; + ksParam_lCpuRevision = 'TestBox_lCpuRevision'; + ksParam_cCpus = 'TestBox_cCpus'; + ksParam_fCpuHwVirt = 'TestBox_fCpuHwVirt'; + ksParam_fCpuNestedPaging = 'TestBox_fCpuNestedPaging'; + ksParam_fCpu64BitGuest = 'TestBox_fCpu64BitGuest'; + ksParam_fChipsetIoMmu = 'TestBox_fChipsetIoMmu'; + ksParam_fRawMode = 'TestBox_fRawMode'; + ksParam_cMbMemory = 'TestBox_cMbMemory'; + ksParam_cMbScratch = 'TestBox_cMbScratch'; + ksParam_sReport = 'TestBox_sReport'; + ksParam_iTestBoxScriptRev = 'TestBox_iTestBoxScriptRev'; + ksParam_iPythonHexVersion = 'TestBox_iPythonHexVersion'; + ksParam_enmPendingCmd = 'TestBox_enmPendingCmd'; + + kasInternalAttributes = [ 'idStrDescription', 'idStrComment', 'idStrOs', 'idStrOsVersion', 'idStrCpuVendor', + 'idStrCpuArch', 'idStrCpuName', 'idStrReport', ]; + kasMachineSettableOnly = [ 'sOs', 'sOsVersion', 'sCpuVendor', 'sCpuArch', 'sCpuName', 'lCpuRevision', 'cCpus', + 'fCpuHwVirt', 'fCpuNestedPaging', 'fCpu64BitGuest', 'fChipsetIoMmu', 'fRawMode', + 'cMbMemory', 'cMbScratch', 'sReport', 'iTestBoxScriptRev', 'iPythonHexVersion', ]; + kasAllowNullAttributes = ['idTestBox', 'tsEffective', 'tsExpire', 'uidAuthor', 'idGenTestBox', 'sDescription', + 'ipLom', 'sComment', ] + kasMachineSettableOnly + kasInternalAttributes; + + kasValidValues_enmLomKind = kasLomKindValues; + kasValidValues_enmPendingCmd = kasTestBoxCmdValues; + kiMin_pctScaleTimeout = 11; + kiMax_pctScaleTimeout = 19999; + kcchMax_sReport = 65535; + + kcDbColumns = 40; # including the 7 string joins columns + + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestBox = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.idGenTestBox = None; + self.ip = None; + self.uuidSystem = None; + self.sName = None; + self.idStrDescription = None; + self.fEnabled = False; + self.enmLomKind = self.ksLomKind_None; + self.ipLom = None; + self.pctScaleTimeout = 100; + self.idStrComment = None; + self.idStrOs = None; + self.idStrOsVersion = None; + self.idStrCpuVendor = None; + self.idStrCpuArch = None; + self.idStrCpuName = None; + self.lCpuRevision = None; + self.cCpus = 1; + self.fCpuHwVirt = False; + self.fCpuNestedPaging = False; + self.fCpu64BitGuest = False; + self.fChipsetIoMmu = False; + self.fRawMode = None; + self.cMbMemory = 1; + self.cMbScratch = 0; + self.idStrReport = None; + self.iTestBoxScriptRev = 0; + self.iPythonHexVersion = 0; + self.enmPendingCmd = self.ksTestBoxCmd_None; + # String table values. + self.sDescription = None; + self.sComment = None; + self.sOs = None; + self.sOsVersion = None; + self.sCpuVendor = None; + self.sCpuArch = None; + self.sCpuName = None; + self.sReport = None; + + def initFromDbRow(self, aoRow): + """ + Internal worker for initFromDbWithId and initFromDbWithGenId as well as + from TestBoxLogic. Expecting the result from a query like this: + SELECT TestBoxesWithStrings.* FROM TestBoxesWithStrings + """ + if aoRow is None: + raise TMRowNotFound('TestBox not found.'); + + self.idTestBox = aoRow[0]; + self.tsEffective = aoRow[1]; + self.tsExpire = aoRow[2]; + self.uidAuthor = aoRow[3]; + self.idGenTestBox = aoRow[4]; + self.ip = aoRow[5]; + self.uuidSystem = aoRow[6]; + self.sName = aoRow[7]; + self.idStrDescription = aoRow[8]; + self.fEnabled = aoRow[9]; + self.enmLomKind = aoRow[10]; + self.ipLom = aoRow[11]; + self.pctScaleTimeout = aoRow[12]; + self.idStrComment = aoRow[13]; + self.idStrOs = aoRow[14]; + self.idStrOsVersion = aoRow[15]; + self.idStrCpuVendor = aoRow[16]; + self.idStrCpuArch = aoRow[17]; + self.idStrCpuName = aoRow[18]; + self.lCpuRevision = aoRow[19]; + self.cCpus = aoRow[20]; + self.fCpuHwVirt = aoRow[21]; + self.fCpuNestedPaging = aoRow[22]; + self.fCpu64BitGuest = aoRow[23]; + self.fChipsetIoMmu = aoRow[24]; + self.fRawMode = aoRow[25]; + self.cMbMemory = aoRow[26]; + self.cMbScratch = aoRow[27]; + self.idStrReport = aoRow[28]; + self.iTestBoxScriptRev = aoRow[29]; + self.iPythonHexVersion = aoRow[30]; + self.enmPendingCmd = aoRow[31]; + + # String table values. + if len(aoRow) > 32: + self.sDescription = aoRow[32]; + self.sComment = aoRow[33]; + self.sOs = aoRow[34]; + self.sOsVersion = aoRow[35]; + self.sCpuVendor = aoRow[36]; + self.sCpuArch = aoRow[37]; + self.sCpuName = aoRow[38]; + self.sReport = aoRow[39]; + + return self; + + def initFromDbWithId(self, oDb, idTestBox, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT TestBoxesWithStrings.*\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE idTestBox = %s\n' + , ( idTestBox, ), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idTestBox=%s not found (tsNow=%s sPeriodBack=%s)' % (idTestBox, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def initFromDbWithGenId(self, oDb, idGenTestBox, tsNow = None): + """ + Initialize the object from the database. + """ + _ = tsNow; # Only useful for extended data classes. + oDb.execute('SELECT TestBoxesWithStrings.*\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE idGenTestBox = %s\n' + , (idGenTestBox, ) ); + return self.initFromDbRow(oDb.fetchOne()); + + def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ModelDataBase.ksValidateFor_Other): + # Override to do extra ipLom checks. + dErrors = ModelDataBase._validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor); + if self.ksParam_ipLom not in dErrors \ + and self.ksParam_enmLomKind not in dErrors \ + and self.enmLomKind != self.ksLomKind_None \ + and self.ipLom is None: + dErrors[self.ksParam_ipLom] = 'Light-out-management IP is mandatory and a LOM is selected.' + return dErrors; + + @staticmethod + def formatPythonVersionEx(iPythonHexVersion): + """ Unbuttons the version number and formats it as a version string. """ + if iPythonHexVersion is None: + return 'N/A'; + return 'v%d.%d.%d.%d' \ + % ( iPythonHexVersion >> 24, + (iPythonHexVersion >> 16) & 0xff, + (iPythonHexVersion >> 8) & 0xff, + iPythonHexVersion & 0xff); + + def formatPythonVersion(self): + """ Unbuttons the version number and formats it as a version string. """ + return self.formatPythonVersionEx(self.iPythonHexVersion); + + + @staticmethod + def getCpuFamilyEx(lCpuRevision): + """ Returns the CPU family for a x86 or amd64 testboxes.""" + if lCpuRevision is None: + return 0; + return (lCpuRevision >> 24 & 0xff); + + def getCpuFamily(self): + """ Returns the CPU family for a x86 or amd64 testboxes.""" + return self.getCpuFamilyEx(self.lCpuRevision); + + @staticmethod + def getCpuModelEx(lCpuRevision): + """ Returns the CPU model for a x86 or amd64 testboxes.""" + if lCpuRevision is None: + return 0; + return (lCpuRevision >> 8 & 0xffff); + + def getCpuModel(self): + """ Returns the CPU model for a x86 or amd64 testboxes.""" + return self.getCpuModelEx(self.lCpuRevision); + + @staticmethod + def getCpuSteppingEx(lCpuRevision): + """ Returns the CPU stepping for a x86 or amd64 testboxes.""" + if lCpuRevision is None: + return 0; + return (lCpuRevision & 0xff); + + def getCpuStepping(self): + """ Returns the CPU stepping for a x86 or amd64 testboxes.""" + return self.getCpuSteppingEx(self.lCpuRevision); + + + # The following is a translation of the g_aenmIntelFamily06 array in CPUMR3CpuId.cpp: + kdIntelFamily06 = { + 0x00: 'P6', + 0x01: 'P6', + 0x03: 'P6_II', + 0x05: 'P6_II', + 0x06: 'P6_II', + 0x07: 'P6_III', + 0x08: 'P6_III', + 0x09: 'P6_M_Banias', + 0x0a: 'P6_III', + 0x0b: 'P6_III', + 0x0d: 'P6_M_Dothan', + 0x0e: 'Core_Yonah', + 0x0f: 'Core2_Merom', + 0x15: 'P6_M_Dothan', + 0x16: 'Core2_Merom', + 0x17: 'Core2_Penryn', + 0x1a: 'Core7_Nehalem', + 0x1c: 'Atom_Bonnell', + 0x1d: 'Core2_Penryn', + 0x1e: 'Core7_Nehalem', + 0x1f: 'Core7_Nehalem', + 0x25: 'Core7_Westmere', + 0x26: 'Atom_Lincroft', + 0x27: 'Atom_Saltwell', + 0x2a: 'Core7_SandyBridge', + 0x2c: 'Core7_Westmere', + 0x2d: 'Core7_SandyBridge', + 0x2e: 'Core7_Nehalem', + 0x2f: 'Core7_Westmere', + 0x35: 'Atom_Saltwell', + 0x36: 'Atom_Saltwell', + 0x37: 'Atom_Silvermont', + 0x3a: 'Core7_IvyBridge', + 0x3c: 'Core7_Haswell', + 0x3d: 'Core7_Broadwell', + 0x3e: 'Core7_IvyBridge', + 0x3f: 'Core7_Haswell', + 0x45: 'Core7_Haswell', + 0x46: 'Core7_Haswell', + 0x47: 'Core7_Broadwell', + 0x4a: 'Atom_Silvermont', + 0x4c: 'Atom_Airmount', + 0x4d: 'Atom_Silvermont', + 0x4e: 'Core7_Skylake', + 0x4f: 'Core7_Broadwell', + 0x55: 'Core7_Skylake', + 0x56: 'Core7_Broadwell', + 0x5a: 'Atom_Silvermont', + 0x5c: 'Atom_Goldmont', + 0x5d: 'Atom_Silvermont', + 0x5e: 'Core7_Skylake', + 0x66: 'Core7_Cannonlake', + }; + # Also from CPUMR3CpuId.cpp, but the switch. + kdIntelFamily15 = { + 0x00: 'NB_Willamette', + 0x01: 'NB_Willamette', + 0x02: 'NB_Northwood', + 0x03: 'NB_Prescott', + 0x04: 'NB_Prescott2M', + 0x05: 'NB_Unknown', + 0x06: 'NB_CedarMill', + 0x07: 'NB_Gallatin', + }; + + @staticmethod + def queryCpuMicroarchEx(lCpuRevision, sCpuVendor): + """ Try guess the microarch name for the cpu. Returns None if we cannot. """ + if lCpuRevision is None or sCpuVendor is None: + return None; + uFam = TestBoxData.getCpuFamilyEx(lCpuRevision); + uMod = TestBoxData.getCpuModelEx(lCpuRevision); + if sCpuVendor == 'GenuineIntel': + if uFam == 6: + return TestBoxData.kdIntelFamily06.get(uMod, None); + if uFam == 15: + return TestBoxData.kdIntelFamily15.get(uMod, None); + elif sCpuVendor == 'AuthenticAMD': + if uFam == 0xf: + if uMod < 0x10: return 'K8_130nm'; + if 0x60 <= uMod < 0x80: return 'K8_65nm'; + if uMod >= 0x40: return 'K8_90nm_AMDV'; + if uMod in [0x21, 0x23, 0x2b, 0x37, 0x3f]: return 'K8_90nm_DualCore'; + return 'AMD_K8_90nm'; + if uFam == 0x10: return 'K10'; + if uFam == 0x11: return 'K10_Lion'; + if uFam == 0x12: return 'K10_Llano'; + if uFam == 0x14: return 'Bobcat'; + if uFam == 0x15: + if uMod <= 0x01: return 'Bulldozer'; + if uMod in [0x02, 0x10, 0x13]: return 'Piledriver'; + return None; + if uFam == 0x16: + return 'Jaguar'; + elif sCpuVendor == 'CentaurHauls': + if uFam == 0x05: + if uMod == 0x01: return 'Centaur_C6'; + if uMod == 0x04: return 'Centaur_C6'; + if uMod == 0x08: return 'Centaur_C2'; + if uMod == 0x09: return 'Centaur_C3'; + if uFam == 0x06: + if uMod == 0x05: return 'VIA_C3_M2'; + if uMod == 0x06: return 'VIA_C3_C5A'; + if uMod == 0x07: return 'VIA_C3_C5B' if TestBoxData.getCpuSteppingEx(lCpuRevision) < 8 else 'VIA_C3_C5C'; + if uMod == 0x08: return 'VIA_C3_C5N'; + if uMod == 0x09: return 'VIA_C3_C5XL' if TestBoxData.getCpuSteppingEx(lCpuRevision) < 8 else 'VIA_C3_C5P'; + if uMod == 0x0a: return 'VIA_C7_C5J'; + if uMod == 0x0f: return 'VIA_Isaiah'; + elif sCpuVendor == ' Shanghai ': + if uFam == 0x07: + if uMod == 0x0b: return 'Shanghai_KX-5000'; + return None; + + def queryCpuMicroarch(self): + """ Try guess the microarch name for the cpu. Returns None if we cannot. """ + return self.queryCpuMicroarchEx(self.lCpuRevision, self.sCpuVendor); + + @staticmethod + def getPrettyCpuVersionEx(lCpuRevision, sCpuVendor): + """ Pretty formatting of the family/model/stepping with microarch optimizations. """ + if lCpuRevision is None or sCpuVendor is None: + return u''; + sMarch = TestBoxData.queryCpuMicroarchEx(lCpuRevision, sCpuVendor); + if sMarch is not None: + return '%s %02x:%x' \ + % (sMarch, TestBoxData.getCpuModelEx(lCpuRevision), TestBoxData.getCpuSteppingEx(lCpuRevision)); + return 'fam%02X m%02X s%02X' \ + % ( TestBoxData.getCpuFamilyEx(lCpuRevision), TestBoxData.getCpuModelEx(lCpuRevision), + TestBoxData.getCpuSteppingEx(lCpuRevision)); + + def getPrettyCpuVersion(self): + """ Pretty formatting of the family/model/stepping with microarch optimizations. """ + return self.getPrettyCpuVersionEx(self.lCpuRevision, self.sCpuVendor); + + def getArchBitString(self): + """ Returns 32-bit, 64-bit, , or sCpuArch. """ + if self.sCpuArch is None: + return ''; + if self.sCpuArch in [ 'x86',]: + return '32-bit'; + if self.sCpuArch in [ 'amd64',]: + return '64-bit'; + return self.sCpuArch; + + def getPrettyCpuVendor(self): + """ Pretty vendor name.""" + if self.sCpuVendor is None: + return ''; + if self.sCpuVendor == 'GenuineIntel': return 'Intel'; + if self.sCpuVendor == 'AuthenticAMD': return 'AMD'; + if self.sCpuVendor == 'CentaurHauls': return 'VIA'; + if self.sCpuVendor == ' Shanghai ': return 'Shanghai'; + return self.sCpuVendor; + + +class TestBoxDataEx(TestBoxData): + """ + TestBox data. + """ + + ksParam_aoInSchedGroups = 'TestBox_aoInSchedGroups'; + + # Use [] instead of None. + kasAltArrayNull = [ 'aoInSchedGroups', ]; + + ## Helper parameter containing the comma separated list with the IDs of + # potential members found in the parameters. + ksParam_aidSchedGroups = 'TestBoxDataEx_aidSchedGroups'; + + def __init__(self): + TestBoxData.__init__(self); + self.aoInSchedGroups = [] # type: list[TestBoxInSchedGroupData] + + def _initExtraMembersFromDb(self, oDb, tsNow = None, sPeriodBack = None): + """ + Worker shared by the initFromDb* methods. + Returns self. Raises exception if no row or database error. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM TestBoxesInSchedGroups\n' + 'WHERE idTestBox = %s\n' + , (self.idTestBox,), tsNow, sPeriodBack) + + 'ORDER BY idSchedGroup\n' ); + self.aoInSchedGroups = []; + for aoRow in oDb.fetchAll(): + self.aoInSchedGroups.append(TestBoxInSchedGroupDataEx().initFromDbRowEx(aoRow, oDb, tsNow, sPeriodBack)); + return self; + + def initFromDbRowEx(self, aoRow, oDb, tsNow = None): + """ + Reinitialize from a SELECT * FROM TestBoxesWithStrings row. Will query the + necessary additional data from oDb using tsNow. + Returns self. Raises exception if no row or database error. + """ + TestBoxData.initFromDbRow(self, aoRow); + return self._initExtraMembersFromDb(oDb, tsNow); + + def initFromDbWithId(self, oDb, idTestBox, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + TestBoxData.initFromDbWithId(self, oDb, idTestBox, tsNow, sPeriodBack); + return self._initExtraMembersFromDb(oDb, tsNow, sPeriodBack); + + def initFromDbWithGenId(self, oDb, idGenTestBox, tsNow = None): + """ + Initialize the object from the database. + """ + TestBoxData.initFromDbWithGenId(self, oDb, idGenTestBox); + if tsNow is None and not oDb.isTsInfinity(self.tsExpire): + tsNow = self.tsEffective; + return self._initExtraMembersFromDb(oDb, tsNow); + + def getAttributeParamNullValues(self, sAttr): # Necessary? + if sAttr in ['aoInSchedGroups', ]: + return [[], '']; + return TestBoxData.getAttributeParamNullValues(self, sAttr); + + def convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict): + """ + For dealing with the in-scheduling-group list. + """ + if sAttr != 'aoInSchedGroups': + return TestBoxData.convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict); + + aoNewValues = []; + aidSelected = oDisp.getListOfIntParams(sParam, iMin = 1, iMax = 0x7ffffffe, aiDefaults = []); + asIds = oDisp.getStringParam(self.ksParam_aidSchedGroups, sDefault = '').split(','); + for idSchedGroup in asIds: + try: idSchedGroup = int(idSchedGroup); + except: pass; + oDispWrapper = self.DispWrapper(oDisp, '%s[%s][%%s]' % (TestBoxDataEx.ksParam_aoInSchedGroups, idSchedGroup,)) + oMember = TestBoxInSchedGroupData().initFromParams(oDispWrapper, fStrict = False); + if idSchedGroup in aidSelected: + aoNewValues.append(oMember); + return aoNewValues; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): # pylint: disable=too-many-locals + """ + Validate special arrays and requirement expressions. + + Some special needs for the in-scheduling-group list. + """ + if sAttr != 'aoInSchedGroups': + return TestBoxData._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + asErrors = []; + aoNewValues = []; + + # Note! We'll be returning an error dictionary instead of an string here. + dErrors = {}; + + # HACK ALERT! idTestBox might not have been validated and converted yet, but we need detect + # adding so we can ignore idTestBox being NIL when validating group memberships. + ## @todo make base.py pass us the ksValidateFor_Xxxx value. + fIsAdding = bool(self.idTestBox in [ None, -1, '-1', 'None', '' ]) + + for iInGrp, oInSchedGroup in enumerate(self.aoInSchedGroups): + oInSchedGroup = copy.copy(oInSchedGroup); + oInSchedGroup.idTestBox = self.idTestBox; + if fIsAdding: + dCurErrors = oInSchedGroup.validateAndConvertEx(['idTestBox',] + oInSchedGroup.kasAllowNullAttributes, + oDb, ModelDataBase.ksValidateFor_Add); + else: + dCurErrors = oInSchedGroup.validateAndConvert(oDb, ModelDataBase.ksValidateFor_Other); + if not dCurErrors: + pass; ## @todo figure out the ID? + else: + asErrors = []; + for sKey in dCurErrors: + asErrors.append('%s: %s' % (sKey[len('TestBoxInSchedGroup_'):], + dCurErrors[sKey] + ('{%s}' % self.idTestBox))) + dErrors[iInGrp] = '
\n'.join(asErrors) + aoNewValues.append(oInSchedGroup); + + for iInGrp, oInSchedGroup in enumerate(self.aoInSchedGroups): + for iInGrp2 in xrange(iInGrp + 1, len(self.aoInSchedGroups)): + if self.aoInSchedGroups[iInGrp2].idSchedGroup == oInSchedGroup.idSchedGroup: + sMsg = 'Duplicate scheduling group #%s".' % (oInSchedGroup.idSchedGroup,); + if iInGrp in dErrors: dErrors[iInGrp] += '
\n' + sMsg; + else: dErrors[iInGrp] = sMsg; + if iInGrp2 in dErrors: dErrors[iInGrp2] += '
\n' + sMsg; + else: dErrors[iInGrp2] = sMsg; + break; + + return (aoNewValues, dErrors if dErrors else None); + + +class TestBoxLogic(ModelLogicBase): + """ + TestBox logic. + """ + + kiSortColumn_sName = 1; + kiSortColumn_sOs = 2; + kiSortColumn_sOsVersion = 3; + kiSortColumn_sCpuVendor = 4; + kiSortColumn_sCpuArch = 5; + kiSortColumn_lCpuRevision = 6; + kiSortColumn_cCpus = 7; + kiSortColumn_cMbMemory = 8; + kiSortColumn_cMbScratch = 9; + kiSortColumn_fCpuNestedPaging = 10; + kiSortColumn_iTestBoxScriptRev = 11; + kiSortColumn_iPythonHexVersion = 12; + kiSortColumn_enmPendingCmd = 13; + kiSortColumn_fEnabled = 14; + kiSortColumn_enmState = 15; + kiSortColumn_tsUpdated = 16; + kcMaxSortColumns = 17; + kdSortColumnMap = { + 0: 'TestBoxesWithStrings.sName', + kiSortColumn_sName: "regexp_replace(TestBoxesWithStrings.sName,'[0-9]*', '', 'g'), " \ + "RIGHT(CONCAT(regexp_replace(TestBoxesWithStrings.sName,'[^0-9]*','', 'g'),'0'),8)::int", + -kiSortColumn_sName: "regexp_replace(TestBoxesWithStrings.sName,'[0-9]*', '', 'g') DESC, " \ + "RIGHT(CONCAT(regexp_replace(TestBoxesWithStrings.sName,'[^0-9]*','', 'g'),'0'),8)::int DESC", + kiSortColumn_sOs: 'TestBoxesWithStrings.sOs', + -kiSortColumn_sOs: 'TestBoxesWithStrings.sOs DESC', + kiSortColumn_sOsVersion: 'TestBoxesWithStrings.sOsVersion', + -kiSortColumn_sOsVersion: 'TestBoxesWithStrings.sOsVersion DESC', + kiSortColumn_sCpuVendor: 'TestBoxesWithStrings.sCpuVendor', + -kiSortColumn_sCpuVendor: 'TestBoxesWithStrings.sCpuVendor DESC', + kiSortColumn_sCpuArch: 'TestBoxesWithStrings.sCpuArch', + -kiSortColumn_sCpuArch: 'TestBoxesWithStrings.sCpuArch DESC', + kiSortColumn_lCpuRevision: 'TestBoxesWithStrings.lCpuRevision', + -kiSortColumn_lCpuRevision: 'TestBoxesWithStrings.lCpuRevision DESC', + kiSortColumn_cCpus: 'TestBoxesWithStrings.cCpus', + -kiSortColumn_cCpus: 'TestBoxesWithStrings.cCpus DESC', + kiSortColumn_cMbMemory: 'TestBoxesWithStrings.cMbMemory', + -kiSortColumn_cMbMemory: 'TestBoxesWithStrings.cMbMemory DESC', + kiSortColumn_cMbScratch: 'TestBoxesWithStrings.cMbScratch', + -kiSortColumn_cMbScratch: 'TestBoxesWithStrings.cMbScratch DESC', + kiSortColumn_fCpuNestedPaging: 'TestBoxesWithStrings.fCpuNestedPaging', + -kiSortColumn_fCpuNestedPaging: 'TestBoxesWithStrings.fCpuNestedPaging DESC', + kiSortColumn_iTestBoxScriptRev: 'TestBoxesWithStrings.iTestBoxScriptRev', + -kiSortColumn_iTestBoxScriptRev: 'TestBoxesWithStrings.iTestBoxScriptRev DESC', + kiSortColumn_iPythonHexVersion: 'TestBoxesWithStrings.iPythonHexVersion', + -kiSortColumn_iPythonHexVersion: 'TestBoxesWithStrings.iPythonHexVersion DESC', + kiSortColumn_enmPendingCmd: 'TestBoxesWithStrings.enmPendingCmd', + -kiSortColumn_enmPendingCmd: 'TestBoxesWithStrings.enmPendingCmd DESC', + kiSortColumn_fEnabled: 'TestBoxesWithStrings.fEnabled', + -kiSortColumn_fEnabled: 'TestBoxesWithStrings.fEnabled DESC', + kiSortColumn_enmState: 'TestBoxStatuses.enmState', + -kiSortColumn_enmState: 'TestBoxStatuses.enmState DESC', + kiSortColumn_tsUpdated: 'TestBoxStatuses.tsUpdated', + -kiSortColumn_tsUpdated: 'TestBoxStatuses.tsUpdated DESC', + }; + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb); + self.dCache = None; + + def tryFetchTestBoxByUuid(self, sTestBoxUuid): + """ + Tries to fetch a testbox by its UUID alone. + """ + self._oDb.execute('SELECT TestBoxesWithStrings.*\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE uuidSystem = %s\n' + ' AND tsExpire = \'infinity\'::timestamp\n' + 'ORDER BY tsEffective DESC\n', + (sTestBoxUuid,)); + if self._oDb.getRowCount() == 0: + return None; + if self._oDb.getRowCount() != 1: + raise TMTooManyRows('Database integrity error: %u hits' % (self._oDb.getRowCount(),)); + oData = TestBoxData(); + oData.initFromDbRow(self._oDb.fetchOne()); + return oData; + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches testboxes for listing. + + Returns an array (list) of TestBoxDataForListing items, empty list if none. + The TestBoxDataForListing instances are just TestBoxData with two extra + members, an extra oStatus member that is either None or a TestBoxStatusData + instance, and a member tsCurrent holding CURRENT_TIMESTAMP. + + Raises exception on error. + """ + class TestBoxDataForListing(TestBoxDataEx): + """ We add two members for the listing. """ + def __init__(self): + TestBoxDataEx.__init__(self); + self.tsCurrent = None; # CURRENT_TIMESTAMP + self.oStatus = None # type: TestBoxStatusData + + from testmanager.core.testboxstatus import TestBoxStatusData; + + if not aiSortColumns: + aiSortColumns = [self.kiSortColumn_sName,]; + + if tsNow is None: + self._oDb.execute('SELECT TestBoxesWithStrings.*,\n' + ' TestBoxStatuses.*\n' + 'FROM TestBoxesWithStrings\n' + ' LEFT OUTER JOIN TestBoxStatuses\n' + ' ON TestBoxStatuses.idTestBox = TestBoxesWithStrings.idTestBox\n' + 'WHERE TestBoxesWithStrings.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY ' + (', '.join([self.kdSortColumnMap[i] for i in aiSortColumns])) + '\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT TestBoxesWithStrings.*,\n' + ' TestBoxStatuses.*\n' + 'FROM TestBoxesWithStrings\n' + ' LEFT OUTER JOIN TestBoxStatuses\n' + ' ON TestBoxStatuses.idTestBox = TestBoxesWithStrings.idTestBox\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY ' + (', '.join([self.kdSortColumnMap[i] for i in aiSortColumns])) + '\n' + 'LIMIT %s OFFSET %s\n' + , ( tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = []; + for aoOne in self._oDb.fetchAll(): + oTestBox = TestBoxDataForListing().initFromDbRowEx(aoOne, self._oDb, tsNow); + oTestBox.tsCurrent = self._oDb.getCurrentTimestamp(); + if aoOne[TestBoxData.kcDbColumns] is not None: + oTestBox.oStatus = TestBoxStatusData().initFromDbRow(aoOne[TestBoxData.kcDbColumns:]); + aoRows.append(oTestBox); + return aoRows; + + def fetchForSchedGroup(self, idSchedGroup, tsNow, aiSortColumns = None): + """ + Fetches testboxes for listing. + + Returns an array (list) of TestBoxDataForSchedGroup items, empty list if none. + + Raises exception on error. + """ + if not aiSortColumns: + aiSortColumns = [self.kiSortColumn_sName,]; + asSortColumns = [self.kdSortColumnMap[i] for i in aiSortColumns]; + asSortColumns.append('TestBoxesInSchedGroups.idTestBox'); + + if tsNow is None: + self._oDb.execute(''' +SELECT TestBoxesInSchedGroups.*, + TestBoxesWithStrings.* +FROM TestBoxesInSchedGroups + LEFT OUTER JOIN TestBoxesWithStrings + ON TestBoxesWithStrings.idTestBox = TestBoxesInSchedGroups.idTestBox + AND TestBoxesWithStrings.tsExpire = 'infinity'::TIMESTAMP +WHERE TestBoxesInSchedGroups.idSchedGroup = %s + AND TestBoxesInSchedGroups.tsExpire = 'infinity'::TIMESTAMP +ORDER BY ''' + ', '.join(asSortColumns), (idSchedGroup, )); + else: + self._oDb.execute(''' +SELECT TestBoxesInSchedGroups.*, + TestBoxesWithStrings.* +FROM TestBoxesInSchedGroups + LEFT OUTER JOIN TestBoxesWithStrings + ON TestBoxesWithStrings.idTestBox = TestBoxesInSchedGroups.idTestBox + AND TestBoxesWithStrings.tsExpire > %s + AND TestBoxesWithStrings.tsEffective <= %s +WHERE TestBoxesInSchedGroups.idSchedGroup = %s + AND TestBoxesInSchedGroups.tsExpire > %s + AND TestBoxesInSchedGroups.tsEffective <= %s +ORDER BY ''' + ', '.join(asSortColumns), (tsNow, tsNow, idSchedGroup, tsNow, tsNow, )); + + aoRows = []; + for aoOne in self._oDb.fetchAll(): + aoRows.append(TestBoxDataForSchedGroup().initFromDbRow(aoOne)); + return aoRows; + + def fetchForChangeLog(self, idTestBox, iStart, cMaxRows, tsNow): # pylint: disable=too-many-locals + """ + Fetches change log entries for a testbox. + + Returns an array of ChangeLogEntry instance and an indicator whether + there are more entries. + Raises exception on error. + """ + + ## @todo calc changes to scheduler group! + + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + + self._oDb.execute('SELECT TestBoxesWithStrings.*\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE TestBoxesWithStrings.tsEffective <= %s\n' + ' AND TestBoxesWithStrings.idTestBox = %s\n' + 'ORDER BY TestBoxesWithStrings.tsExpire DESC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, idTestBox, cMaxRows + 1, iStart,)); + + aoRows = []; + for aoDbRow in self._oDb.fetchAll(): + aoRows.append(TestBoxData().initFromDbRow(aoDbRow)); + + # Calculate the changes. + aoEntries = []; + for i in xrange(0, len(aoRows) - 1): + oNew = aoRows[i]; + oOld = aoRows[i + 1]; + aoChanges = []; + for sAttr in oNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', ]: + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr != oNewAttr: + if sAttr == 'sReport': + aoChanges.append(AttributeChangeEntryPre(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + else: + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + aoEntries.append(ChangeLogEntry(oNew.uidAuthor, None, oNew.tsEffective, oNew.tsExpire, oNew, oOld, aoChanges)); + + # If we're at the end of the log, add the initial entry. + if len(aoRows) <= cMaxRows and aoRows: + oNew = aoRows[-1]; + aoEntries.append(ChangeLogEntry(oNew.uidAuthor, None, oNew.tsEffective, oNew.tsExpire, oNew, None, [])); + + UserAccountLogic(self._oDb).resolveChangeLogAuthors(aoEntries); + return (aoEntries, len(aoRows) > cMaxRows); + + def _validateAndConvertData(self, oData, enmValidateFor): + # type: (TestBoxDataEx, str) -> None + """ + Helper for addEntry and editEntry that validates the scheduling group IDs in + addtion to what's covered by the default validateAndConvert of the data object. + + Raises exception on invalid input. + """ + dDataErrors = oData.validateAndConvert(self._oDb, enmValidateFor); + if dDataErrors: + raise TMInvalidData('TestBoxLogic.addEntry: %s' % (dDataErrors,)); + if isinstance(oData, TestBoxDataEx): + if oData.aoInSchedGroups: + sSchedGrps = ', '.join('(%s)' % oCur.idSchedGroup for oCur in oData.aoInSchedGroups); + self._oDb.execute('SELECT SchedGroupIDs.idSchedGroup\n' + 'FROM (VALUES ' + sSchedGrps + ' ) AS SchedGroupIDs(idSchedGroup)\n' + ' LEFT OUTER JOIN SchedGroups\n' + ' ON SchedGroupIDs.idSchedGroup = SchedGroups.idSchedGroup\n' + ' AND SchedGroups.tsExpire = \'infinity\'::TIMESTAMP\n' + 'WHERE SchedGroups.idSchedGroup IS NULL\n'); + aaoRows = self._oDb.fetchAll(); + if aaoRows: + raise TMInvalidData('TestBoxLogic.addEntry missing scheduling groups: %s' + % (', '.join(str(aoRow[0]) for aoRow in aaoRows),)); + return None; + + def addEntry(self, oData, uidAuthor, fCommit = False): + # type: (TestBoxDataEx, int, bool) -> (int, int, datetime.datetime) + """ + Creates a testbox in the database. + Returns the testbox ID, testbox generation ID and effective timestamp + of the created testbox on success. Throws error on failure. + """ + + # + # Validate. Extra work because of missing foreign key (due to history). + # + self._validateAndConvertData(oData, oData.ksValidateFor_Add); + + # + # Do it. + # + self._oDb.callProc('TestBoxLogic_addEntry' + , ( uidAuthor, + oData.ip, # Should we allow setting the IP? + oData.uuidSystem, + oData.sName, + oData.sDescription, + oData.fEnabled, + oData.enmLomKind, + oData.ipLom, + oData.pctScaleTimeout, + oData.sComment, + oData.enmPendingCmd, ) ); + (idTestBox, idGenTestBox, tsEffective) = self._oDb.fetchOne(); + + for oInSchedGrp in oData.aoInSchedGroups: + self._oDb.callProc('TestBoxLogic_addGroupEntry', + ( uidAuthor, idTestBox, oInSchedGrp.idSchedGroup, oInSchedGrp.iSchedPriority,) ); + + self._oDb.maybeCommit(fCommit); + return (idTestBox, idGenTestBox, tsEffective); + + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Data edit update, web UI is the primary user. + + oData is either TestBoxDataEx or TestBoxData. The latter is for enabling + Returns the new generation ID and effective date. + """ + + # + # Validate. + # + self._validateAndConvertData(oData, oData.ksValidateFor_Edit); + + # + # Get current data. + # + oOldData = TestBoxDataEx().initFromDbWithId(self._oDb, oData.idTestBox); + + # + # Do it. + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', 'aoInSchedGroups', ] + + TestBoxData.kasMachineSettableOnly ): + self._oDb.callProc('TestBoxLogic_editEntry' + , ( uidAuthor, + oData.idTestBox, + oData.ip, # Should we allow setting the IP? + oData.uuidSystem, + oData.sName, + oData.sDescription, + oData.fEnabled, + oData.enmLomKind, + oData.ipLom, + oData.pctScaleTimeout, + oData.sComment, + oData.enmPendingCmd, )); + (idGenTestBox, tsEffective) = self._oDb.fetchOne(); + else: + idGenTestBox = oOldData.idGenTestBox; + tsEffective = oOldData.tsEffective; + + if isinstance(oData, TestBoxDataEx): + # Calc in-group changes. + aoRemoved = list(oOldData.aoInSchedGroups); + aoNew = []; + aoUpdated = []; + for oNewInGroup in oData.aoInSchedGroups: + oOldInGroup = None; + for iCur, oCur in enumerate(aoRemoved): + if oCur.idSchedGroup == oNewInGroup.idSchedGroup: + oOldInGroup = aoRemoved.pop(iCur); + break; + if oOldInGroup is None: + aoNew.append(oNewInGroup); + elif oNewInGroup.iSchedPriority != oOldInGroup.iSchedPriority: + aoUpdated.append(oNewInGroup); + + # Remove in-groups. + for oInGroup in aoRemoved: + self._oDb.callProc('TestBoxLogic_removeGroupEntry', (uidAuthor, oData.idTestBox, oInGroup.idSchedGroup, )); + + # Add new ones. + for oInGroup in aoNew: + self._oDb.callProc('TestBoxLogic_addGroupEntry', + ( uidAuthor, oData.idTestBox, oInGroup.idSchedGroup, oInGroup.iSchedPriority, ) ); + + # Edit existing ones. + for oInGroup in aoUpdated: + self._oDb.callProc('TestBoxLogic_editGroupEntry', + ( uidAuthor, oData.idTestBox, oInGroup.idSchedGroup, oInGroup.iSchedPriority, ) ); + else: + assert isinstance(oData, TestBoxData); + + self._oDb.maybeCommit(fCommit); + return (idGenTestBox, tsEffective); + + + def removeEntry(self, uidAuthor, idTestBox, fCascade = False, fCommit = False): + """ + Delete test box and scheduling group associations. + """ + self._oDb.callProc('TestBoxLogic_removeEntry' + , ( uidAuthor, idTestBox, fCascade,)); + self._oDb.maybeCommit(fCommit); + return True; + + + def updateOnSignOn(self, idTestBox, idGenTestBox, sTestBoxAddr, sOs, sOsVersion, # pylint: disable=too-many-arguments,too-many-locals + sCpuVendor, sCpuArch, sCpuName, lCpuRevision, cCpus, fCpuHwVirt, fCpuNestedPaging, fCpu64BitGuest, + fChipsetIoMmu, fRawMode, cMbMemory, cMbScratch, sReport, iTestBoxScriptRev, iPythonHexVersion): + """ + Update the testbox attributes automatically on behalf of the testbox script. + Returns the new generation id on success, raises an exception on failure. + """ + _ = idGenTestBox; + self._oDb.callProc('TestBoxLogic_updateOnSignOn' + , ( idTestBox, + sTestBoxAddr, + sOs, + sOsVersion, + sCpuVendor, + sCpuArch, + sCpuName, + lCpuRevision, + cCpus, + fCpuHwVirt, + fCpuNestedPaging, + fCpu64BitGuest, + fChipsetIoMmu, + fRawMode, + cMbMemory, + cMbScratch, + sReport, + iTestBoxScriptRev, + iPythonHexVersion,)); + return self._oDb.fetchOne()[0]; + + + def setCommand(self, idTestBox, sOldCommand, sNewCommand, uidAuthor = None, fCommit = False, sComment = None): + """ + Sets or resets the pending command on a testbox. + Returns (idGenTestBox, tsEffective) of the new row. + """ + ## @todo throw TMInFligthCollision again... + self._oDb.callProc('TestBoxLogic_setCommand' + , ( uidAuthor, idTestBox, sOldCommand, sNewCommand, sComment,)); + aoRow = self._oDb.fetchOne(); + self._oDb.maybeCommit(fCommit); + return (aoRow[0], aoRow[1]); + + + def getAll(self): + """ + Retrieve list of all registered Test Box records from DB. + """ + self._oDb.execute('SELECT *\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE tsExpire=\'infinity\'::timestamp\n' + 'ORDER BY sName') + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestBoxData().initFromDbRow(aoRow)) + return aoRet + + + def cachedLookup(self, idTestBox): + # type: (int) -> TestBoxDataEx + """ + Looks up the most recent TestBoxData object for idTestBox via + an object cache. + + Returns a shared TestBoxDataEx object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('TestBoxData'); + oEntry = self.dCache.get(idTestBox, None); + if oEntry is None: + fNeedNow = False; + self._oDb.execute('SELECT TestBoxesWithStrings.*\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE idTestBox = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestBox, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT TestBoxesWithStrings.*\n' + 'FROM TestBoxesWithStrings\n' + 'WHERE idTestBox = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idTestBox, )); + fNeedNow = True; + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idTestBox)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + if not fNeedNow: + oEntry = TestBoxDataEx().initFromDbRowEx(aaoRow, self._oDb); + else: + oEntry = TestBoxDataEx().initFromDbRow(aaoRow); + oEntry.initFromDbRowEx(aaoRow, self._oDb, tsNow = db.dbTimestampMinusOneTick(oEntry.tsExpire)); + self.dCache[idTestBox] = oEntry; + return oEntry; + + + + # + # The virtual test sheriff interface. + # + + def hasTestBoxRecentlyBeenRebooted(self, idTestBox, cHoursBack = 2, tsNow = None): + """ + Checks if the testbox has been rebooted in the specified time period. + + This does not include already pending reboots, though under some + circumstances it may. These being the test box entry being edited for + other reasons. + + Returns True / False. + """ + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + self._oDb.execute('SELECT COUNT(idTestBox)\n' + 'FROM TestBoxes\n' + 'WHERE idTestBox = %s\n' + ' AND tsExpire < %s\n' + ' AND tsExpire >= %s - interval \'%s hours\'\n' + ' AND enmPendingCmd IN (%s, %s)\n' + , ( idTestBox, tsNow, tsNow, cHoursBack, + TestBoxData.ksTestBoxCmd_Reboot, TestBoxData.ksTestBoxCmd_UpgradeAndReboot, )); + return self._oDb.fetchOne()[0] > 0; + + + def rebootTestBox(self, idTestBox, uidAuthor, sComment, sOldCommand = TestBoxData.ksTestBoxCmd_None, fCommit = False): + """ + Issues a reboot command for the given test box. + Return True on succes, False on in-flight collision. + May raise DB exception on other trouble. + """ + try: + self.setCommand(idTestBox, sOldCommand, TestBoxData.ksTestBoxCmd_Reboot, + uidAuthor = uidAuthor, fCommit = fCommit, sComment = sComment); + except TMInFligthCollision: + return False; + return True; + + + def disableTestBox(self, idTestBox, uidAuthor, sComment, fCommit = False): + """ + Disables the given test box. + + Raises exception on trouble, without rollback. + """ + oTestBox = TestBoxData().initFromDbWithId(self._oDb, idTestBox); + if oTestBox.fEnabled: + oTestBox.fEnabled = False; + if sComment is not None: + oTestBox.sComment = sComment; + self.editEntry(oTestBox, uidAuthor = uidAuthor, fCommit = fCommit); + return None; + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestBoxDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestBoxData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testboxcontroller.py b/src/VBox/ValidationKit/testmanager/core/testboxcontroller.py new file mode 100755 index 00000000..b131aa88 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testboxcontroller.py @@ -0,0 +1,954 @@ +# -*- coding: utf-8 -*- +# $Id: testboxcontroller.py $ + +""" +Test Manager Core - Web Server Abstraction Base Class. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import re; +import os; +import string; # pylint: disable=deprecated-module +import sys; +import uuid; + +# Validation Kit imports. +from common import constants; +from testmanager import config; +from testmanager.core import coreconsts; +from testmanager.core.db import TMDatabaseConnection; +from testmanager.core.base import TMExceptionBase; +from testmanager.core.globalresource import GlobalResourceLogic; +from testmanager.core.testboxstatus import TestBoxStatusData, TestBoxStatusLogic; +from testmanager.core.testbox import TestBoxData, TestBoxLogic; +from testmanager.core.testresults import TestResultLogic, TestResultFileData; +from testmanager.core.testset import TestSetData, TestSetLogic; +from testmanager.core.systemlog import SystemLogData, SystemLogLogic; +from testmanager.core.schedulerbase import SchedulerBase; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + long = int; # pylint: disable=redefined-builtin,invalid-name + + +class TestBoxControllerException(TMExceptionBase): + """ + Exception class for TestBoxController. + """ + pass; # pylint: disable=unnecessary-pass + + +class TestBoxController(object): # pylint: disable=too-few-public-methods + """ + TestBox Controller class. + """ + + ## Applicable testbox commands to an idle TestBox. + kasIdleCmds = [TestBoxData.ksTestBoxCmd_Reboot, + TestBoxData.ksTestBoxCmd_Upgrade, + TestBoxData.ksTestBoxCmd_UpgradeAndReboot, + TestBoxData.ksTestBoxCmd_Special]; + ## Applicable testbox commands to a busy TestBox. + kasBusyCmds = [TestBoxData.ksTestBoxCmd_Abort, TestBoxData.ksTestBoxCmd_Reboot]; + ## Commands that can be ACK'ed. + kasAckableCmds = [constants.tbresp.CMD_EXEC, constants.tbresp.CMD_ABORT, constants.tbresp.CMD_REBOOT, + constants.tbresp.CMD_UPGRADE, constants.tbresp.CMD_UPGRADE_AND_REBOOT, constants.tbresp.CMD_SPECIAL]; + ## Commands that can be NACK'ed or NOTSUP'ed. + kasNackableCmds = kasAckableCmds + [kasAckableCmds, constants.tbresp.CMD_IDLE, constants.tbresp.CMD_WAIT]; + + ## Mapping from TestBoxCmd_T to TestBoxState_T + kdCmdToState = \ + { \ + TestBoxData.ksTestBoxCmd_Abort: None, + TestBoxData.ksTestBoxCmd_Reboot: TestBoxStatusData.ksTestBoxState_Rebooting, + TestBoxData.ksTestBoxCmd_Upgrade: TestBoxStatusData.ksTestBoxState_Upgrading, + TestBoxData.ksTestBoxCmd_UpgradeAndReboot: TestBoxStatusData.ksTestBoxState_UpgradingAndRebooting, + TestBoxData.ksTestBoxCmd_Special: TestBoxStatusData.ksTestBoxState_DoingSpecialCmd, + }; + + ## Mapping from TestBoxCmd_T to TestBox responses commands. + kdCmdToTbRespCmd = \ + { + TestBoxData.ksTestBoxCmd_Abort: constants.tbresp.CMD_ABORT, + TestBoxData.ksTestBoxCmd_Reboot: constants.tbresp.CMD_REBOOT, + TestBoxData.ksTestBoxCmd_Upgrade: constants.tbresp.CMD_UPGRADE, + TestBoxData.ksTestBoxCmd_UpgradeAndReboot: constants.tbresp.CMD_UPGRADE_AND_REBOOT, + TestBoxData.ksTestBoxCmd_Special: constants.tbresp.CMD_SPECIAL, + }; + + ## Mapping from TestBox responses to TestBoxCmd_T commands. + kdTbRespCmdToCmd = \ + { + constants.tbresp.CMD_IDLE: None, + constants.tbresp.CMD_WAIT: None, + constants.tbresp.CMD_EXEC: None, + constants.tbresp.CMD_ABORT: TestBoxData.ksTestBoxCmd_Abort, + constants.tbresp.CMD_REBOOT: TestBoxData.ksTestBoxCmd_Reboot, + constants.tbresp.CMD_UPGRADE: TestBoxData.ksTestBoxCmd_Upgrade, + constants.tbresp.CMD_UPGRADE_AND_REBOOT: TestBoxData.ksTestBoxCmd_UpgradeAndReboot, + constants.tbresp.CMD_SPECIAL: TestBoxData.ksTestBoxCmd_Special, + }; + + + ## The path to the upgrade zip, relative WebServerGlueBase.getBaseUrl(). + ksUpgradeZip = 'htdocs/upgrade/VBoxTestBoxScript.zip'; + + ## Valid TestBox result values. + kasValidResults = list(constants.result.g_kasValidResults); + ## Mapping TestBox result values to TestStatus_T values. + kadTbResultToStatus = \ + { + constants.result.PASSED: TestSetData.ksTestStatus_Success, + constants.result.SKIPPED: TestSetData.ksTestStatus_Skipped, + constants.result.ABORTED: TestSetData.ksTestStatus_Aborted, + constants.result.BAD_TESTBOX: TestSetData.ksTestStatus_BadTestBox, + constants.result.FAILED: TestSetData.ksTestStatus_Failure, + constants.result.TIMED_OUT: TestSetData.ksTestStatus_TimedOut, + constants.result.REBOOTED: TestSetData.ksTestStatus_Rebooted, + }; + + + def __init__(self, oSrvGlue): + """ + Won't raise exceptions. + """ + self._oSrvGlue = oSrvGlue; + self._sAction = None; # _getStandardParams / dispatchRequest sets this later on. + self._idTestBox = None; # _getStandardParams / dispatchRequest sets this later on. + self._sTestBoxUuid = None; # _getStandardParams / dispatchRequest sets this later on. + self._sTestBoxAddr = None; # _getStandardParams / dispatchRequest sets this later on. + self._idTestSet = None; # _getStandardParams / dispatchRequest sets this later on. + self._dParams = None; # _getStandardParams / dispatchRequest sets this later on. + self._asCheckedParams = []; + self._dActions = \ + { \ + constants.tbreq.SIGNON : self._actionSignOn, + constants.tbreq.REQUEST_COMMAND_BUSY: self._actionRequestCommandBusy, + constants.tbreq.REQUEST_COMMAND_IDLE: self._actionRequestCommandIdle, + constants.tbreq.COMMAND_ACK : self._actionCommandAck, + constants.tbreq.COMMAND_NACK : self._actionCommandNack, + constants.tbreq.COMMAND_NOTSUP : self._actionCommandNotSup, + constants.tbreq.LOG_MAIN : self._actionLogMain, + constants.tbreq.UPLOAD : self._actionUpload, + constants.tbreq.XML_RESULTS : self._actionXmlResults, + constants.tbreq.EXEC_COMPLETED : self._actionExecCompleted, + }; + + def _getStringParam(self, sName, asValidValues = None, fStrip = False, sDefValue = None): + """ + Gets a string parameter (stripped). + + Raises exception if not found and no default is provided, or if the + value isn't found in asValidValues. + """ + if sName not in self._dParams: + if sDefValue is None: + raise TestBoxControllerException('%s parameter %s is missing' % (self._sAction, sName)); + return sDefValue; + sValue = self._dParams[sName]; + if fStrip: + sValue = sValue.strip(); + + if sName not in self._asCheckedParams: + self._asCheckedParams.append(sName); + + if asValidValues is not None and sValue not in asValidValues: + raise TestBoxControllerException('%s parameter %s value "%s" not in %s ' \ + % (self._sAction, sName, sValue, asValidValues)); + return sValue; + + def _getBoolParam(self, sName, fDefValue = None): + """ + Gets a boolean parameter. + + Raises exception if not found and no default is provided, or if not a + valid boolean. + """ + sValue = self._getStringParam(sName, [ 'True', 'true', '1', 'False', 'false', '0'], sDefValue = str(fDefValue)); + return sValue in ('True', 'true', '1',); + + def _getIntParam(self, sName, iMin = None, iMax = None): + """ + Gets a string parameter. + Raises exception if not found, not a valid integer, or if the value + isn't in the range defined by iMin and iMax. + """ + sValue = self._getStringParam(sName); + try: + iValue = int(sValue, 0); + except: + raise TestBoxControllerException('%s parameter %s value "%s" cannot be convert to an integer' \ + % (self._sAction, sName, sValue)); + + if (iMin is not None and iValue < iMin) \ + or (iMax is not None and iValue > iMax): + raise TestBoxControllerException('%s parameter %s value %d is out of range [%s..%s]' \ + % (self._sAction, sName, iValue, iMin, iMax)); + return iValue; + + def _getLongParam(self, sName, lMin = None, lMax = None, lDefValue = None): + """ + Gets a string parameter. + Raises exception if not found, not a valid long integer, or if the value + isn't in the range defined by lMin and lMax. + """ + sValue = self._getStringParam(sName, sDefValue = (str(lDefValue) if lDefValue is not None else None)); + try: + lValue = long(sValue, 0); + except Exception as oXcpt: + raise TestBoxControllerException('%s parameter %s value "%s" cannot be convert to an integer (%s)' \ + % (self._sAction, sName, sValue, oXcpt)); + + if (lMin is not None and lValue < lMin) \ + or (lMax is not None and lValue > lMax): + raise TestBoxControllerException('%s parameter %s value %d is out of range [%s..%s]' \ + % (self._sAction, sName, lValue, lMin, lMax)); + return lValue; + + def _checkForUnknownParameters(self): + """ + Check if we've handled all parameters, raises exception if anything + unknown was found. + """ + + if len(self._asCheckedParams) != len(self._dParams): + sUnknownParams = ''; + for sKey in self._dParams: + if sKey not in self._asCheckedParams: + sUnknownParams += ' ' + sKey + '=' + self._dParams[sKey]; + raise TestBoxControllerException('Unknown parameters: ' + sUnknownParams); + + return True; + + def _writeResponse(self, dParams): + """ + Makes a reply to the testbox script. + Will raise exception on failure. + """ + self._oSrvGlue.writeParams(dParams); + self._oSrvGlue.flush(); + return True; + + def _resultResponse(self, sResultValue): + """ + Makes a simple reply to the testbox script. + Will raise exception on failure. + """ + return self._writeResponse({constants.tbresp.ALL_PARAM_RESULT: sResultValue}); + + + def _idleResponse(self): + """ + Makes an IDLE reply to the testbox script. + Will raise exception on failure. + """ + return self._writeResponse({ constants.tbresp.ALL_PARAM_RESULT: constants.tbresp.CMD_IDLE }); + + def _cleanupOldTest(self, oDb, oStatusData): + """ + Cleans up any old test set that may be left behind and changes the + state to 'idle'. See scenario #9: + file://../../docs/AutomaticTestingRevamp.html#cleaning-up-abandoned-testcase + + Note. oStatusData.enmState is set to idle, but tsUpdated is not changed. + """ + + # Cleanup any abandoned test. + if oStatusData.idTestSet is not None: + SystemLogLogic(oDb).addEntry(SystemLogData.ksEvent_TestSetAbandoned, + "idTestSet=%u idTestBox=%u enmState=%s %s" + % (oStatusData.idTestSet, oStatusData.idTestBox, + oStatusData.enmState, self._sAction), + fCommit = False); + TestSetLogic(oDb).completeAsAbandoned(oStatusData.idTestSet, fCommit = False); + GlobalResourceLogic(oDb).freeGlobalResourcesByTestBox(self._idTestBox, fCommit = False); + + # Change to idle status + if oStatusData.enmState != TestBoxStatusData.ksTestBoxState_Idle: + TestBoxStatusLogic(oDb).updateState(self._idTestBox, TestBoxStatusData.ksTestBoxState_Idle, fCommit = False); + oStatusData.tsUpdated = oDb.getCurrentTimestamp(); + oStatusData.enmState = TestBoxStatusData.ksTestBoxState_Idle; + + # Commit. + oDb.commit(); + + return True; + + def _connectToDbAndValidateTb(self, asValidStates = None): + """ + Connects to the database and validates the testbox. + + Returns (TMDatabaseConnection, TestBoxStatusData, TestBoxData) on success. + Returns (None, None, None) on failure after sending the box an appropriate response. + May raise exception on DB error. + """ + oDb = TMDatabaseConnection(self._oSrvGlue.dprint); + oLogic = TestBoxStatusLogic(oDb); + (oStatusData, oTestBoxData) = oLogic.tryFetchStatusAndConfig(self._idTestBox, self._sTestBoxUuid, self._sTestBoxAddr); + if oStatusData is None: + self._resultResponse(constants.tbresp.STATUS_DEAD); + elif asValidStates is not None and oStatusData.enmState not in asValidStates: + self._resultResponse(constants.tbresp.STATUS_NACK); + elif self._idTestSet is not None and self._idTestSet != oStatusData.idTestSet: + self._resultResponse(constants.tbresp.STATUS_NACK); + else: + return (oDb, oStatusData, oTestBoxData); + return (None, None, None); + + def writeToMainLog(self, oTestSet, sText, fIgnoreSizeCheck = False): + """ Writes the text to the main log file. """ + + # Calc the file name and open the file. + sFile = os.path.join(config.g_ksFileAreaRootDir, oTestSet.sBaseFilename + '-main.log'); + if not os.path.exists(os.path.dirname(sFile)): + os.makedirs(os.path.dirname(sFile), 0o755); + + with open(sFile, 'ab') as oFile: + # Check the size. + fSizeOk = True; + if not fIgnoreSizeCheck: + oStat = os.fstat(oFile.fileno()); + fSizeOk = oStat.st_size / (1024 * 1024) < config.g_kcMbMaxMainLog; + + # Write the text. + if fSizeOk: + if sys.version_info[0] >= 3: + oFile.write(bytes(sText, 'utf-8')); + else: + oFile.write(sText); + + return fSizeOk; + + def _actionSignOn(self): # pylint: disable=too-many-locals + """ Implement sign-on """ + + # + # Validate parameters (raises exception on failure). + # + sOs = self._getStringParam(constants.tbreq.SIGNON_PARAM_OS, coreconsts.g_kasOses); + sOsVersion = self._getStringParam(constants.tbreq.SIGNON_PARAM_OS_VERSION); + sCpuVendor = self._getStringParam(constants.tbreq.SIGNON_PARAM_CPU_VENDOR); + sCpuArch = self._getStringParam(constants.tbreq.SIGNON_PARAM_CPU_ARCH, coreconsts.g_kasCpuArches); + sCpuName = self._getStringParam(constants.tbreq.SIGNON_PARAM_CPU_NAME, fStrip = True, sDefValue = ''); # new + lCpuRevision = self._getLongParam( constants.tbreq.SIGNON_PARAM_CPU_REVISION, lMin = 0, lDefValue = 0); # new + cCpus = self._getIntParam( constants.tbreq.SIGNON_PARAM_CPU_COUNT, 1, 16384); + fCpuHwVirt = self._getBoolParam( constants.tbreq.SIGNON_PARAM_HAS_HW_VIRT); + fCpuNestedPaging = self._getBoolParam( constants.tbreq.SIGNON_PARAM_HAS_NESTED_PAGING); + fCpu64BitGuest = self._getBoolParam( constants.tbreq.SIGNON_PARAM_HAS_64_BIT_GUEST, fDefValue = True); + fChipsetIoMmu = self._getBoolParam( constants.tbreq.SIGNON_PARAM_HAS_IOMMU); + fRawMode = self._getBoolParam( constants.tbreq.SIGNON_PARAM_WITH_RAW_MODE, fDefValue = None); + cMbMemory = self._getLongParam( constants.tbreq.SIGNON_PARAM_MEM_SIZE, 8, 1073741823); # 8MB..1PB + cMbScratch = self._getLongParam( constants.tbreq.SIGNON_PARAM_SCRATCH_SIZE, 0, 1073741823); # 0..1PB + sReport = self._getStringParam(constants.tbreq.SIGNON_PARAM_REPORT, fStrip = True, sDefValue = ''); # new + iTestBoxScriptRev = self._getIntParam( constants.tbreq.SIGNON_PARAM_SCRIPT_REV, 1, 100000000); + iPythonHexVersion = self._getIntParam( constants.tbreq.SIGNON_PARAM_PYTHON_VERSION, 0x020300f0, 0x030f00f0); + self._checkForUnknownParameters(); + + # Null conversions for new parameters. + if not sReport: + sReport = None; + if not sCpuName: + sCpuName = None; + if lCpuRevision <= 0: + lCpuRevision = None; + + # + # Connect to the database and validate the testbox. + # + oDb = TMDatabaseConnection(self._oSrvGlue.dprint); + oTestBoxLogic = TestBoxLogic(oDb); + oTestBox = oTestBoxLogic.tryFetchTestBoxByUuid(self._sTestBoxUuid); + if oTestBox is None: + oSystemLogLogic = SystemLogLogic(oDb); + oSystemLogLogic.addEntry(SystemLogData.ksEvent_TestBoxUnknown, + 'addr=%s uuid=%s os=%s %d cpus' \ + % (self._sTestBoxAddr, self._sTestBoxUuid, sOs, cCpus), + 24, fCommit = True); + return self._resultResponse(constants.tbresp.STATUS_NACK); + + # + # Update the row in TestBoxes if something changed. + # + if oTestBox.cMbScratch is not None and oTestBox.cMbScratch != 0: + cPctScratchDiff = (cMbScratch - oTestBox.cMbScratch) * 100 / oTestBox.cMbScratch; + else: + cPctScratchDiff = 100; + + # pylint: disable=too-many-boolean-expressions + if self._sTestBoxAddr != oTestBox.ip \ + or sOs != oTestBox.sOs \ + or sOsVersion != oTestBox.sOsVersion \ + or sCpuVendor != oTestBox.sCpuVendor \ + or sCpuArch != oTestBox.sCpuArch \ + or sCpuName != oTestBox.sCpuName \ + or lCpuRevision != oTestBox.lCpuRevision \ + or cCpus != oTestBox.cCpus \ + or fCpuHwVirt != oTestBox.fCpuHwVirt \ + or fCpuNestedPaging != oTestBox.fCpuNestedPaging \ + or fCpu64BitGuest != oTestBox.fCpu64BitGuest \ + or fChipsetIoMmu != oTestBox.fChipsetIoMmu \ + or fRawMode != oTestBox.fRawMode \ + or cMbMemory != oTestBox.cMbMemory \ + or abs(cPctScratchDiff) >= min(4 + cMbScratch / 10240, 12) \ + or sReport != oTestBox.sReport \ + or iTestBoxScriptRev != oTestBox.iTestBoxScriptRev \ + or iPythonHexVersion != oTestBox.iPythonHexVersion: + oTestBoxLogic.updateOnSignOn(oTestBox.idTestBox, + oTestBox.idGenTestBox, + sTestBoxAddr = self._sTestBoxAddr, + sOs = sOs, + sOsVersion = sOsVersion, + sCpuVendor = sCpuVendor, + sCpuArch = sCpuArch, + sCpuName = sCpuName, + lCpuRevision = lCpuRevision, + cCpus = cCpus, + fCpuHwVirt = fCpuHwVirt, + fCpuNestedPaging = fCpuNestedPaging, + fCpu64BitGuest = fCpu64BitGuest, + fChipsetIoMmu = fChipsetIoMmu, + fRawMode = fRawMode, + cMbMemory = cMbMemory, + cMbScratch = cMbScratch, + sReport = sReport, + iTestBoxScriptRev = iTestBoxScriptRev, + iPythonHexVersion = iPythonHexVersion); + + # + # Update the testbox status, making sure there is a status. + # + oStatusLogic = TestBoxStatusLogic(oDb); + oStatusData = oStatusLogic.tryFetchStatus(oTestBox.idTestBox); + if oStatusData is not None: + self._cleanupOldTest(oDb, oStatusData); + else: + oStatusLogic.insertIdleStatus(oTestBox.idTestBox, oTestBox.idGenTestBox, fCommit = True); + + # + # ACK the request. + # + dResponse = \ + { + constants.tbresp.ALL_PARAM_RESULT: constants.tbresp.STATUS_ACK, + constants.tbresp.SIGNON_PARAM_ID: oTestBox.idTestBox, + constants.tbresp.SIGNON_PARAM_NAME: oTestBox.sName, + } + return self._writeResponse(dResponse); + + def _doGangCleanup(self, oDb, oStatusData): + """ + _doRequestCommand worker for handling a box in gang-cleanup. + This will check if all testboxes has completed their run, pretending to + be busy until that happens. Once all are completed, resources will be + freed and the testbox returns to idle state (we update oStatusData). + """ + oStatusLogic = TestBoxStatusLogic(oDb) + oTestSet = TestSetData().initFromDbWithId(oDb, oStatusData.idTestSet); + if oStatusLogic.isWholeGangDoneTesting(oTestSet.idTestSetGangLeader): + oDb.begin(); + + GlobalResourceLogic(oDb).freeGlobalResourcesByTestBox(self._idTestBox, fCommit = False); + TestBoxStatusLogic(oDb).updateState(self._idTestBox, TestBoxStatusData.ksTestBoxState_Idle, fCommit = False); + + oStatusData.tsUpdated = oDb.getCurrentTimestamp(); + oStatusData.enmState = TestBoxStatusData.ksTestBoxState_Idle; + + oDb.commit(); + return None; + + def _doGangGatheringTimedOut(self, oDb, oStatusData): + """ + _doRequestCommand worker for handling a box in gang-gathering-timed-out state. + This will do clean-ups similar to _cleanupOldTest and update the state likewise. + """ + oDb.begin(); + + TestSetLogic(oDb).completeAsGangGatheringTimeout(oStatusData.idTestSet, fCommit = False); + GlobalResourceLogic(oDb).freeGlobalResourcesByTestBox(self._idTestBox, fCommit = False); + TestBoxStatusLogic(oDb).updateState(self._idTestBox, TestBoxStatusData.ksTestBoxState_Idle, fCommit = False); + + oStatusData.tsUpdated = oDb.getCurrentTimestamp(); + oStatusData.enmState = TestBoxStatusData.ksTestBoxState_Idle; + + oDb.commit(); + return None; + + def _doGangGathering(self, oDb, oStatusData): + """ + _doRequestCommand worker for handling a box in gang-gathering state. + This only checks for timeout. It will update the oStatusData if a + timeout is detected, so that the box will be idle upon return. + """ + oStatusLogic = TestBoxStatusLogic(oDb); + if oStatusLogic.timeSinceLastChangeInSecs(oStatusData) > config.g_kcSecGangGathering \ + and SchedulerBase.tryCancelGangGathering(oDb, oStatusData): # <-- Updates oStatusData. + self._doGangGatheringTimedOut(oDb, oStatusData); + return None; + + def _doRequestCommand(self, fIdle): + """ + Common code for handling command request. + """ + + (oDb, oStatusData, oTestBoxData) = self._connectToDbAndValidateTb(); + if oDb is None: + return False; + + # + # Status clean up. + # + # Only when BUSY will the TestBox Script request and execute commands + # concurrently. So, it must be idle when sending REQUEST_COMMAND_IDLE. + # + if fIdle: + if oStatusData.enmState == TestBoxStatusData.ksTestBoxState_GangGathering: + self._doGangGathering(oDb, oStatusData); + elif oStatusData.enmState == TestBoxStatusData.ksTestBoxState_GangGatheringTimedOut: + self._doGangGatheringTimedOut(oDb, oStatusData); + elif oStatusData.enmState == TestBoxStatusData.ksTestBoxState_GangTesting: + dResponse = SchedulerBase.composeExecResponse(oDb, oTestBoxData.idTestBox, self._oSrvGlue.getBaseUrl()); + if dResponse is not None: + return dResponse; + elif oStatusData.enmState == TestBoxStatusData.ksTestBoxState_GangCleanup: + self._doGangCleanup(oDb, oStatusData); + elif oStatusData.enmState != TestBoxStatusData.ksTestBoxState_Idle: # (includes ksTestBoxState_GangGatheringTimedOut) + self._cleanupOldTest(oDb, oStatusData); + + # + # Check for pending command. + # + if oTestBoxData.enmPendingCmd != TestBoxData.ksTestBoxCmd_None: + asValidCmds = TestBoxController.kasIdleCmds if fIdle else TestBoxController.kasBusyCmds; + if oTestBoxData.enmPendingCmd in asValidCmds: + dResponse = { constants.tbresp.ALL_PARAM_RESULT: TestBoxController.kdCmdToTbRespCmd[oTestBoxData.enmPendingCmd] }; + if oTestBoxData.enmPendingCmd in [TestBoxData.ksTestBoxCmd_Upgrade, TestBoxData.ksTestBoxCmd_UpgradeAndReboot]: + dResponse[constants.tbresp.UPGRADE_PARAM_URL] = self._oSrvGlue.getBaseUrl() + TestBoxController.ksUpgradeZip; + return self._writeResponse(dResponse); + + if oTestBoxData.enmPendingCmd == TestBoxData.ksTestBoxCmd_Abort and fIdle: + TestBoxLogic(oDb).setCommand(self._idTestBox, sOldCommand = oTestBoxData.enmPendingCmd, + sNewCommand = TestBoxData.ksTestBoxCmd_None, fCommit = True); + + # + # If doing gang stuff, return 'CMD_WAIT'. + # + ## @todo r=bird: Why is GangTesting included here? Figure out when testing gang testing. + if oStatusData.enmState in [TestBoxStatusData.ksTestBoxState_GangGathering, + TestBoxStatusData.ksTestBoxState_GangTesting, + TestBoxStatusData.ksTestBoxState_GangCleanup]: + return self._resultResponse(constants.tbresp.CMD_WAIT); + + # + # If idling and enabled try schedule a new task. + # + if fIdle \ + and oTestBoxData.fEnabled \ + and not TestSetLogic(oDb).isTestBoxExecutingTooRapidly(oTestBoxData.idTestBox) \ + and oStatusData.enmState == TestBoxStatusData.ksTestBoxState_Idle: # (paranoia) + dResponse = SchedulerBase.scheduleNewTask(oDb, oTestBoxData, oStatusData.iWorkItem, self._oSrvGlue.getBaseUrl()); + if dResponse is not None: + return self._writeResponse(dResponse); + + # + # Touch the status row every couple of mins so we can tell that the box is alive. + # + oStatusLogic = TestBoxStatusLogic(oDb); + if oStatusData.enmState != TestBoxStatusData.ksTestBoxState_GangGathering \ + and oStatusLogic.timeSinceLastChangeInSecs(oStatusData) >= TestBoxStatusLogic.kcSecIdleTouchStatus: + oStatusLogic.touchStatus(oTestBoxData.idTestBox, fCommit = True); + + return self._idleResponse(); + + def _actionRequestCommandBusy(self): + """ Implement request for command. """ + self._checkForUnknownParameters(); + return self._doRequestCommand(False); + + def _actionRequestCommandIdle(self): + """ Implement request for command. """ + self._checkForUnknownParameters(); + return self._doRequestCommand(True); + + def _doCommandAckNck(self, sCmd): + """ Implements ACK, NACK and NACK(ENOTSUP). """ + + (oDb, _, _) = self._connectToDbAndValidateTb(); + if oDb is None: + return False; + + # + # If the command maps to a TestBoxCmd_T value, it means we have to + # check and update TestBoxes. If it's an ACK, the testbox status will + # need updating as well. + # + sPendingCmd = TestBoxController.kdTbRespCmdToCmd[sCmd]; + if sPendingCmd is not None: + oTestBoxLogic = TestBoxLogic(oDb) + oTestBoxLogic.setCommand(self._idTestBox, sOldCommand = sPendingCmd, + sNewCommand = TestBoxData.ksTestBoxCmd_None, fCommit = False); + + if self._sAction == constants.tbreq.COMMAND_ACK \ + and TestBoxController.kdCmdToState[sPendingCmd] is not None: + oStatusLogic = TestBoxStatusLogic(oDb); + oStatusLogic.updateState(self._idTestBox, TestBoxController.kdCmdToState[sPendingCmd], fCommit = False); + + # Commit the two updates. + oDb.commit(); + + # + # Log NACKs. + # + if self._sAction != constants.tbreq.COMMAND_ACK: + oSysLogLogic = SystemLogLogic(oDb); + oSysLogLogic.addEntry(SystemLogData.ksEvent_CmdNacked, + 'idTestBox=%s sCmd=%s' % (self._idTestBox, sPendingCmd), + 24, fCommit = True); + + return self._resultResponse(constants.tbresp.STATUS_ACK); + + def _actionCommandAck(self): + """ Implement command ACK'ing """ + sCmd = self._getStringParam(constants.tbreq.COMMAND_ACK_PARAM_CMD_NAME, TestBoxController.kasAckableCmds); + self._checkForUnknownParameters(); + return self._doCommandAckNck(sCmd); + + def _actionCommandNack(self): + """ Implement command NACK'ing """ + sCmd = self._getStringParam(constants.tbreq.COMMAND_ACK_PARAM_CMD_NAME, TestBoxController.kasNackableCmds); + self._checkForUnknownParameters(); + return self._doCommandAckNck(sCmd); + + def _actionCommandNotSup(self): + """ Implement command NACK(ENOTSUP)'ing """ + sCmd = self._getStringParam(constants.tbreq.COMMAND_ACK_PARAM_CMD_NAME, TestBoxController.kasNackableCmds); + self._checkForUnknownParameters(); + return self._doCommandAckNck(sCmd); + + def _actionLogMain(self): + """ Implement submitting log entries to the main log file. """ + # + # Parameter validation. + # + sBody = self._getStringParam(constants.tbreq.LOG_PARAM_BODY, fStrip = False); + if not sBody: + return self._resultResponse(constants.tbresp.STATUS_NACK); + self._checkForUnknownParameters(); + + (oDb, oStatusData, _) = self._connectToDbAndValidateTb([TestBoxStatusData.ksTestBoxState_Testing, + TestBoxStatusData.ksTestBoxState_GangTesting]); + if oStatusData is None: + return False; + + # + # Write the text to the log file. + # + oTestSet = TestSetData().initFromDbWithId(oDb, oStatusData.idTestSet); + self.writeToMainLog(oTestSet, sBody); + ## @todo Overflow is a hanging offence, need to note it and fail whatever is going on... + + # Done. + return self._resultResponse(constants.tbresp.STATUS_ACK); + + def _actionUpload(self): + """ Implement uploading of files. """ + # + # Parameter validation. + # + sName = self._getStringParam(constants.tbreq.UPLOAD_PARAM_NAME); + sMime = self._getStringParam(constants.tbreq.UPLOAD_PARAM_MIME); + sKind = self._getStringParam(constants.tbreq.UPLOAD_PARAM_KIND); + sDesc = self._getStringParam(constants.tbreq.UPLOAD_PARAM_DESC); + self._checkForUnknownParameters(); + + (oDb, oStatusData, _) = self._connectToDbAndValidateTb([TestBoxStatusData.ksTestBoxState_Testing, + TestBoxStatusData.ksTestBoxState_GangTesting]); + if oStatusData is None: + return False; + + if len(sName) > 128 or len(sName) < 3: + raise TestBoxControllerException('Invalid file name "%s"' % (sName,)); + if re.match(r'^[a-zA-Z0-9_\-(){}#@+,.=]*$', sName) is None: + raise TestBoxControllerException('Invalid file name "%s"' % (sName,)); + + if sMime not in [ 'text/plain', #'text/html', 'text/xml', + 'application/octet-stream', + 'image/png', #'image/gif', 'image/jpeg', + 'video/webm', #'video/mpeg', 'video/mpeg4-generic', + ]: + raise TestBoxControllerException('Invalid MIME type "%s"' % (sMime,)); + + if sKind not in TestResultFileData.kasKinds: + raise TestBoxControllerException('Invalid kind "%s"' % (sKind,)); + + if len(sDesc) > 256: + raise TestBoxControllerException('Invalid description "%s"' % (sDesc,)); + if not set(sDesc).issubset(set(string.printable)): + raise TestBoxControllerException('Invalid description "%s"' % (sDesc,)); + + if ('application/octet-stream', {}) != self._oSrvGlue.getContentType(): + raise TestBoxControllerException('Unexpected content type: %s; %s' % self._oSrvGlue.getContentType()); + + cbFile = self._oSrvGlue.getContentLength(); + if cbFile <= 0: + raise TestBoxControllerException('File "%s" is empty or negative in size (%s)' % (sName, cbFile)); + if (cbFile + 1048575) / 1048576 > config.g_kcMbMaxUploadSingle: + raise TestBoxControllerException('File "%s" is too big %u bytes (max %u MiB)' + % (sName, cbFile, config.g_kcMbMaxUploadSingle,)); + + # + # Write the text to the log file. + # + oTestSet = TestSetData().initFromDbWithId(oDb, oStatusData.idTestSet); + oDstFile = TestSetLogic(oDb).createFile(oTestSet, sName = sName, sMime = sMime, sKind = sKind, sDesc = sDesc, + cbFile = cbFile, fCommit = True); + + offFile = 0; + oSrcFile = self._oSrvGlue.getBodyIoStreamBinary(); + while offFile < cbFile: + cbToRead = cbFile - offFile; + if cbToRead > 256*1024: + cbToRead = 256*1024; + offFile += cbToRead; + + abBuf = oSrcFile.read(cbToRead); + oDstFile.write(abBuf); # pylint: disable=maybe-no-member + del abBuf; + + oDstFile.close(); # pylint: disable=maybe-no-member + + # Done. + return self._resultResponse(constants.tbresp.STATUS_ACK); + + def _actionXmlResults(self): + """ Implement submitting "XML" like test result stream. """ + # + # Parameter validation. + # + sXml = self._getStringParam(constants.tbreq.XML_RESULT_PARAM_BODY, fStrip = False); + self._checkForUnknownParameters(); + if not sXml: # Used for link check by vboxinstaller.py on Windows. + return self._resultResponse(constants.tbresp.STATUS_ACK); + + (oDb, oStatusData, _) = self._connectToDbAndValidateTb([TestBoxStatusData.ksTestBoxState_Testing, + TestBoxStatusData.ksTestBoxState_GangTesting]); + if oStatusData is None: + return False; + + # + # Process the XML. + # + (sError, fUnforgivable) = TestResultLogic(oDb).processXmlStream(sXml, self._idTestSet); + if sError is not None: + oTestSet = TestSetData().initFromDbWithId(oDb, oStatusData.idTestSet); + self.writeToMainLog(oTestSet, '\n!!XML error: %s\n%s\n\n' % (sError, sXml,)); + if fUnforgivable: + return self._resultResponse(constants.tbresp.STATUS_NACK); + return self._resultResponse(constants.tbresp.STATUS_ACK); + + + def _actionExecCompleted(self): + """ + Implement EXEC completion. + + Because the action is request by the worker thread of the testbox + script we cannot pass pending commands back to it like originally + planned. So, we just complete the test set and update the status. + """ + # + # Parameter validation. + # + sStatus = self._getStringParam(constants.tbreq.EXEC_COMPLETED_PARAM_RESULT, TestBoxController.kasValidResults); + self._checkForUnknownParameters(); + + (oDb, oStatusData, _) = self._connectToDbAndValidateTb([TestBoxStatusData.ksTestBoxState_Testing, + TestBoxStatusData.ksTestBoxState_GangTesting]); + if oStatusData is None: + return False; + + # + # Complete the status. + # + oDb.rollback(); + oDb.begin(); + oTestSetLogic = TestSetLogic(oDb); + idTestSetGangLeader = oTestSetLogic.complete(oStatusData.idTestSet, self.kadTbResultToStatus[sStatus], fCommit = False); + + oStatusLogic = TestBoxStatusLogic(oDb); + if oStatusData.enmState == TestBoxStatusData.ksTestBoxState_Testing: + assert idTestSetGangLeader is None; + GlobalResourceLogic(oDb).freeGlobalResourcesByTestBox(self._idTestBox); + oStatusLogic.updateState(self._idTestBox, TestBoxStatusData.ksTestBoxState_Idle, fCommit = False); + else: + assert idTestSetGangLeader is not None; + oStatusLogic.updateState(self._idTestBox, TestBoxStatusData.ksTestBoxState_GangCleanup, oStatusData.idTestSet, + fCommit = False); + if oStatusLogic.isWholeGangDoneTesting(idTestSetGangLeader): + GlobalResourceLogic(oDb).freeGlobalResourcesByTestBox(self._idTestBox); + oStatusLogic.updateState(self._idTestBox, TestBoxStatusData.ksTestBoxState_Idle, fCommit = False); + + oDb.commit(); + return self._resultResponse(constants.tbresp.STATUS_ACK); + + + + def _getStandardParams(self, dParams): + """ + Gets the standard parameters and validates them. + + The parameters are returned as a tuple: sAction, idTestBox, sTestBoxUuid. + Note! the sTextBoxId can be None if it's a SIGNON request. + + Raises TestBoxControllerException on invalid input. + """ + # + # Get the action parameter and validate it. + # + if constants.tbreq.ALL_PARAM_ACTION not in dParams: + raise TestBoxControllerException('No "%s" parameter in request (params: %s)' \ + % (constants.tbreq.ALL_PARAM_ACTION, dParams,)); + sAction = dParams[constants.tbreq.ALL_PARAM_ACTION]; + + if sAction not in self._dActions: + raise TestBoxControllerException('Unknown action "%s" in request (params: %s; action: %s)' \ + % (sAction, dParams, self._dActions)); + + # + # TestBox UUID. + # + if constants.tbreq.ALL_PARAM_TESTBOX_UUID not in dParams: + raise TestBoxControllerException('No "%s" parameter in request (params: %s)' \ + % (constants.tbreq.ALL_PARAM_TESTBOX_UUID, dParams,)); + sTestBoxUuid = dParams[constants.tbreq.ALL_PARAM_TESTBOX_UUID]; + try: + sTestBoxUuid = str(uuid.UUID(sTestBoxUuid)); + except Exception as oXcpt: + raise TestBoxControllerException('Invalid %s parameter value "%s": %s ' \ + % (constants.tbreq.ALL_PARAM_TESTBOX_UUID, sTestBoxUuid, oXcpt)); + if sTestBoxUuid == '00000000-0000-0000-0000-000000000000': + raise TestBoxControllerException('Invalid %s parameter value "%s": NULL UUID not allowed.' \ + % (constants.tbreq.ALL_PARAM_TESTBOX_UUID, sTestBoxUuid)); + + # + # TestBox ID. + # + if constants.tbreq.ALL_PARAM_TESTBOX_ID in dParams: + sTestBoxId = dParams[constants.tbreq.ALL_PARAM_TESTBOX_ID]; + try: + idTestBox = int(sTestBoxId); + if idTestBox <= 0 or idTestBox >= 0x7fffffff: + raise Exception; + except: + raise TestBoxControllerException('Bad value for "%s": "%s"' \ + % (constants.tbreq.ALL_PARAM_TESTBOX_ID, sTestBoxId)); + elif sAction == constants.tbreq.SIGNON: + idTestBox = None; + else: + raise TestBoxControllerException('No "%s" parameter in request (params: %s)' \ + % (constants.tbreq.ALL_PARAM_TESTBOX_ID, dParams,)); + + # + # Test Set ID. + # + if constants.tbreq.RESULT_PARAM_TEST_SET_ID in dParams: + sTestSetId = dParams[constants.tbreq.RESULT_PARAM_TEST_SET_ID]; + try: + idTestSet = int(sTestSetId); + if idTestSet <= 0 or idTestSet >= 0x7fffffff: + raise Exception; + except: + raise TestBoxControllerException('Bad value for "%s": "%s"' \ + % (constants.tbreq.RESULT_PARAM_TEST_SET_ID, sTestSetId)); + elif sAction not in [ constants.tbreq.XML_RESULTS, ]: ## More later. + idTestSet = None; + else: + raise TestBoxControllerException('No "%s" parameter in request (params: %s)' \ + % (constants.tbreq.RESULT_PARAM_TEST_SET_ID, dParams,)); + + # + # The testbox address. + # + sTestBoxAddr = self._oSrvGlue.getClientAddr(); + if sTestBoxAddr is None or sTestBoxAddr.strip() == '': + raise TestBoxControllerException('Invalid client address "%s"' % (sTestBoxAddr,)); + + # + # Update the list of checked parameters. + # + self._asCheckedParams.extend([constants.tbreq.ALL_PARAM_TESTBOX_UUID, constants.tbreq.ALL_PARAM_ACTION]); + if idTestBox is not None: + self._asCheckedParams.append(constants.tbreq.ALL_PARAM_TESTBOX_ID); + if idTestSet is not None: + self._asCheckedParams.append(constants.tbreq.RESULT_PARAM_TEST_SET_ID); + + return (sAction, idTestBox, sTestBoxUuid, sTestBoxAddr, idTestSet); + + def dispatchRequest(self): + """ + Dispatches the incoming request. + + Will raise TestBoxControllerException on failure. + """ + + # + # Must be a POST request. + # + try: + sMethod = self._oSrvGlue.getMethod(); + except Exception as oXcpt: + raise TestBoxControllerException('Error retriving request method: %s' % (oXcpt,)); + if sMethod != 'POST': + raise TestBoxControllerException('Error expected POST request not "%s"' % (sMethod,)); + + # + # Get the parameters and checks for duplicates. + # + try: + dParams = self._oSrvGlue.getParameters(); + except Exception as oXcpt: + raise TestBoxControllerException('Error retriving parameters: %s' % (oXcpt,)); + for sKey in dParams.keys(): + if len(dParams[sKey]) > 1: + raise TestBoxControllerException('Parameter "%s" is given multiple times: %s' % (sKey, dParams[sKey])); + dParams[sKey] = dParams[sKey][0]; + self._dParams = dParams; + + # + # Get+validate the standard action parameters and dispatch the request. + # + (self._sAction, self._idTestBox, self._sTestBoxUuid, self._sTestBoxAddr, self._idTestSet) = \ + self._getStandardParams(dParams); + return self._dActions[self._sAction](); diff --git a/src/VBox/ValidationKit/testmanager/core/testboxstatus.py b/src/VBox/ValidationKit/testmanager/core/testboxstatus.py new file mode 100755 index 00000000..86c6041f --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testboxstatus.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +# $Id: testboxstatus.py $ + +""" +Test Manager - TestBoxStatus. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMTooManyRows, TMRowNotFound; +from testmanager.core.testbox import TestBoxData; + + +class TestBoxStatusData(ModelDataBase): + """ + TestBoxStatus Data. + """ + + ## @name TestBoxState_T + # @{ + ksTestBoxState_Idle = 'idle'; + ksTestBoxState_Testing = 'testing'; + ksTestBoxState_GangGathering = 'gang-gathering'; + ksTestBoxState_GangGatheringTimedOut = 'gang-gathering-timedout'; + ksTestBoxState_GangTesting = 'gang-testing'; + ksTestBoxState_GangCleanup = 'gang-cleanup'; + ksTestBoxState_Rebooting = 'rebooting'; + ksTestBoxState_Upgrading = 'upgrading'; + ksTestBoxState_UpgradingAndRebooting = 'upgrading-and-rebooting'; + ksTestBoxState_DoingSpecialCmd = 'doing-special-cmd'; + ## @} + + ksParam_idTestBox = 'TestBoxStatus_idTestBox'; + ksParam_idGenTestBox = 'TestBoxStatus_idGenTestBox' + ksParam_tsUpdated = 'TestBoxStatus_tsUpdated'; + ksParam_enmState = 'TestBoxStatus_enmState'; + ksParam_idTestSet = 'TestBoxStatus_idTestSet'; + ksParam_iWorkItem = 'TestBoxStatus_iWorkItem'; + + kasAllowNullAttributes = ['idTestSet', ]; + kasValidValues_enmState = \ + [ + ksTestBoxState_Idle, ksTestBoxState_Testing, ksTestBoxState_GangGathering, + ksTestBoxState_GangGatheringTimedOut, ksTestBoxState_GangTesting, ksTestBoxState_GangCleanup, + ksTestBoxState_Rebooting, ksTestBoxState_Upgrading, ksTestBoxState_UpgradingAndRebooting, + ksTestBoxState_DoingSpecialCmd, + ]; + + kcDbColumns = 6; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestBox = None; + self.idGenTestBox = None; + self.tsUpdated = None; + self.enmState = self.ksTestBoxState_Idle; + self.idTestSet = None; + self.iWorkItem = None; + + def initFromDbRow(self, aoRow): + """ + Internal worker for initFromDbWithId and initFromDbWithGenId as well as + TestBoxStatusLogic. + """ + + if aoRow is None: + raise TMRowNotFound('TestBoxStatus not found.'); + + self.idTestBox = aoRow[0]; + self.idGenTestBox = aoRow[1]; + self.tsUpdated = aoRow[2]; + self.enmState = aoRow[3]; + self.idTestSet = aoRow[4]; + self.iWorkItem = aoRow[5]; + return self; + + def initFromDbWithId(self, oDb, idTestBox): + """ + Initialize the object from the database. + """ + oDb.execute('SELECT *\n' + 'FROM TestBoxStatuses\n' + 'WHERE idTestBox = %s\n' + , (idTestBox, ) ); + return self.initFromDbRow(oDb.fetchOne()); + + def initFromDbWithGenId(self, oDb, idGenTestBox): + """ + Initialize the object from the database. + """ + oDb.execute('SELECT *\n' + 'FROM TestBoxStatuses\n' + 'WHERE idGenTestBox = %s\n' + , (idGenTestBox, ) ); + return self.initFromDbRow(oDb.fetchOne()); + + +class TestBoxStatusLogic(ModelLogicBase): + """ + TestBoxStatus logic. + """ + + ## The number of seconds between each time to call touchStatus() when + # returning CMD_IDLE. + kcSecIdleTouchStatus = 120; + + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb); + + + def tryFetchStatus(self, idTestBox): + """ + Attempts to fetch the status of the given testbox. + + Returns a TestBoxStatusData object on success. + Returns None if no status was found. + Raises exception on other errors. + """ + self._oDb.execute('SELECT *\n' + 'FROM TestBoxStatuses\n' + 'WHERE idTestBox = %s\n', + (idTestBox,)); + if self._oDb.getRowCount() == 0: + return None; + oStatus = TestBoxStatusData(); + return oStatus.initFromDbRow(self._oDb.fetchOne()); + + def tryFetchStatusAndConfig(self, idTestBox, sTestBoxUuid, sTestBoxAddr): + """ + Tries to fetch the testbox status and current testbox config. + + Returns (TestBoxStatusData, TestBoxData) on success, (None, None) if + not found. May throw an exception on database error. + """ + self._oDb.execute('SELECT TestBoxStatuses.*,\n' + ' TestBoxesWithStrings.*\n' + 'FROM TestBoxStatuses,\n' + ' TestBoxesWithStrings\n' + 'WHERE TestBoxStatuses.idTestBox = %s\n' + ' AND TestBoxesWithStrings.idTestBox = %s\n' + ' AND TestBoxesWithStrings.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestBoxesWithStrings.uuidSystem = %s\n' + ' AND TestBoxesWithStrings.ip = %s\n' + , ( idTestBox, + idTestBox, + sTestBoxUuid, + sTestBoxAddr,) ); + cRows = self._oDb.getRowCount(); + if cRows != 1: + if cRows != 0: + raise TMTooManyRows('tryFetchStatusForCommandReq got %s rows for idTestBox=%s' % (cRows, idTestBox)); + return (None, None); + aoRow = self._oDb.fetchOne(); + return (TestBoxStatusData().initFromDbRow(aoRow[:TestBoxStatusData.kcDbColumns]), + TestBoxData().initFromDbRow(aoRow[TestBoxStatusData.kcDbColumns:])); + + + def insertIdleStatus(self, idTestBox, idGenTestBox, fCommit = False): + """ + Inserts an idle status for the specified testbox. + """ + self._oDb.execute('INSERT INTO TestBoxStatuses (\n' + ' idTestBox,\n' + ' idGenTestBox,\n' + ' enmState,\n' + ' idTestSet,\n' + ' iWorkItem)\n' + 'VALUES ( %s,\n' + ' %s,\n' + ' \'idle\'::TestBoxState_T,\n' + ' NULL,\n' + ' 0)\n' + , (idTestBox, idGenTestBox) ); + self._oDb.maybeCommit(fCommit); + return True; + + def touchStatus(self, idTestBox, fCommit = False): + """ + Touches the testbox status row, i.e. sets tsUpdated to the current time. + """ + self._oDb.execute('UPDATE TestBoxStatuses\n' + 'SET tsUpdated = CURRENT_TIMESTAMP\n' + 'WHERE idTestBox = %s\n' + , (idTestBox,)); + self._oDb.maybeCommit(fCommit); + return True; + + def updateState(self, idTestBox, sNewState, idTestSet = None, fCommit = False): + """ + Updates the testbox state. + """ + self._oDb.execute('UPDATE TestBoxStatuses\n' + 'SET enmState = %s,\n' + ' idTestSet = %s,\n' + ' tsUpdated = CURRENT_TIMESTAMP\n' + 'WHERE idTestBox = %s\n', + (sNewState, idTestSet, idTestBox)); + self._oDb.maybeCommit(fCommit); + return True; + + def updateGangStatus(self, idTestSetGangLeader, sNewState, fCommit = False): + """ + Update the state of all members of a gang. + """ + self._oDb.execute('UPDATE TestBoxStatuses\n' + 'SET enmState = %s,\n' + ' tsUpdated = CURRENT_TIMESTAMP\n' + 'WHERE idTestBox IN (SELECT idTestBox\n' + ' FROM TestSets\n' + ' WHERE idTestSetGangLeader = %s)\n' + , (sNewState, idTestSetGangLeader,) ); + self._oDb.maybeCommit(fCommit); + return True; + + def updateWorkItem(self, idTestBox, iWorkItem, fCommit = False): + """ + Updates the testbox state. + """ + self._oDb.execute('UPDATE TestBoxStatuses\n' + 'SET iWorkItem = %s\n' + 'WHERE idTestBox = %s\n' + , ( iWorkItem, idTestBox,)); + self._oDb.maybeCommit(fCommit); + return True; + + def isWholeGangDoneTesting(self, idTestSetGangLeader): + """ + Checks if the whole gang is done testing. + """ + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM TestBoxStatuses, TestSets\n' + 'WHERE TestBoxStatuses.idTestSet = TestSets.idTestSet\n' + ' AND TestSets.idTestSetGangLeader = %s\n' + ' AND TestBoxStatuses.enmState IN (%s, %s)\n' + , ( idTestSetGangLeader, + TestBoxStatusData.ksTestBoxState_GangGathering, + TestBoxStatusData.ksTestBoxState_GangTesting)); + return self._oDb.fetchOne()[0] == 0; + + def isTheWholeGangThere(self, idTestSetGangLeader): + """ + Checks if the whole gang is done testing. + """ + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM TestBoxStatuses, TestSets\n' + 'WHERE TestBoxStatuses.idTestSet = TestSets.idTestSet\n' + ' AND TestSets.idTestSetGangLeader = %s\n' + ' AND TestBoxStatuses.enmState IN (%s, %s)\n' + , ( idTestSetGangLeader, + TestBoxStatusData.ksTestBoxState_GangGathering, + TestBoxStatusData.ksTestBoxState_GangTesting)); + return self._oDb.fetchOne()[0] == 0; + + def timeSinceLastChangeInSecs(self, oStatusData): + """ + Figures the time since the last status change. + """ + tsNow = self._oDb.getCurrentTimestamp(); + oDelta = tsNow - oStatusData.tsUpdated; + return oDelta.seconds + oDelta.days * 24 * 3600; + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestBoxStatusDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestBoxStatusData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testcase.pgsql b/src/VBox/ValidationKit/testmanager/core/testcase.pgsql new file mode 100644 index 00000000..8d32d1c9 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testcase.pgsql @@ -0,0 +1,275 @@ +-- $Id: testcase.pgsql $ +--- @file +-- VBox Test Manager Database Stored Procedures - TestCases. +-- + +-- +-- Copyright (C) 2012-2023 Oracle and/or its affiliates. +-- +-- This file is part of VirtualBox base platform packages, as +-- available from https://www.virtualbox.org. +-- +-- This program is free software; you can redistribute it and/or +-- modify it under the terms of the GNU General Public License +-- as published by the Free Software Foundation, in version 3 of the +-- License. +-- +-- 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, see . +-- +-- The contents of this file may alternatively be used under the terms +-- of the Common Development and Distribution License Version 1.0 +-- (CDDL), a copy of it is provided in the "COPYING.CDDL" file included +-- in the VirtualBox distribution, in which case the provisions of the +-- CDDL are applicable instead of those of the GPL. +-- +-- You may elect to license modified versions of this file under the +-- terms and conditions of either the GPL or the CDDL or both. +-- +-- SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +-- + +\set ON_ERROR_STOP 1 +\connect testmanager; + +DROP FUNCTION IF EXISTS add_testcase(INTEGER, TEXT, TEXT, BOOLEAN, INTEGER, TEXT, TEXT); +DROP FUNCTION IF EXISTS edit_testcase(INTEGER, INTEGER, TEXT, TEXT, BOOLEAN, INTEGER, TEXT, TEXT); +DROP FUNCTION IF EXISTS del_testcase(INTEGER); +DROP FUNCTION IF EXISTS TestCaseLogic_delEntry(INTEGER, INTEGER); +DROP FUNCTION IF EXISTS TestCaseLogic_addEntry(a_uidAuthor INTEGER, a_sName TEXT, a_sDescription TEXT, + a_fEnabled BOOL, a_cSecTimeout INTEGER, a_sTestBoxReqExpr TEXT, + a_sBuildReqExpr TEXT, a_sBaseCmd TEXT, a_sTestSuiteZips TEXT); +DROP FUNCTION IF EXISTS TestCaseLogic_editEntry(a_uidAuthor INTEGER, a_idTestCase INTEGER, a_sName TEXT, a_sDescription TEXT, + a_fEnabled BOOL, a_cSecTimeout INTEGER, a_sTestBoxReqExpr TEXT, + a_sBuildReqExpr TEXT, a_sBaseCmd TEXT, a_sTestSuiteZips TEXT); + +--- +-- Checks if the test case name is unique, ignoring a_idTestCaseIgnore. +-- Raises exception if duplicates are found. +-- +-- @internal +-- +CREATE OR REPLACE FUNCTION TestCaseLogic_checkUniqueName(a_sName TEXT, a_idTestCaseIgnore INTEGER) + RETURNS VOID AS $$ + DECLARE + v_cRows INTEGER; + BEGIN + SELECT COUNT(*) INTO v_cRows + FROM TestCases + WHERE sName = a_sName + AND tsExpire = 'infinity'::TIMESTAMP + AND idTestCase <> a_idTestCaseIgnore; + IF v_cRows <> 0 THEN + RAISE EXCEPTION 'Duplicate test case name "%" (% times)', a_sName, v_cRows; + END IF; + END; +$$ LANGUAGE plpgsql; + +--- +-- Check that the test case exists. +-- Raises exception if it doesn't. +-- +-- @internal +-- +CREATE OR REPLACE FUNCTION TestCaseLogic_checkExists(a_idTestCase INTEGER) RETURNS VOID AS $$ + BEGIN + IF NOT EXISTS( SELECT * + FROM TestCases + WHERE idTestCase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP ) THEN + RAISE EXCEPTION 'Test case with ID % does not currently exist', a_idTestCase; + END IF; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Historize a row. +-- @internal +-- +CREATE OR REPLACE FUNCTION TestCaseLogic_historizeEntry(a_idTestCase INTEGER, a_tsExpire TIMESTAMP WITH TIME ZONE) + RETURNS VOID AS $$ + DECLARE + v_cUpdatedRows INTEGER; + BEGIN + UPDATE TestCases + SET tsExpire = a_tsExpire + WHERE idTestcase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP; + GET DIAGNOSTICS v_cUpdatedRows = ROW_COUNT; + IF v_cUpdatedRows <> 1 THEN + IF v_cUpdatedRows = 0 THEN + RAISE EXCEPTION 'Test case ID % does not currently exist', a_idTestCase; + END IF; + RAISE EXCEPTION 'Integrity error in TestCases: % current rows with idTestCase=%d', v_cUpdatedRows, a_idTestCase; + END IF; + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE function TestCaseLogic_addEntry(a_uidAuthor INTEGER, a_sName TEXT, a_sDescription TEXT, + a_fEnabled BOOL, a_cSecTimeout INTEGER, a_sTestBoxReqExpr TEXT, + a_sBuildReqExpr TEXT, a_sBaseCmd TEXT, a_sTestSuiteZips TEXT, + a_sComment TEXT) + RETURNS INTEGER AS $$ + DECLARE + v_idTestCase INTEGER; + BEGIN + PERFORM TestCaseLogic_checkUniqueName(a_sName, -1); + + INSERT INTO TestCases (uidAuthor, sName, sDescription, fEnabled, cSecTimeout, + sTestBoxReqExpr, sBuildReqExpr, sBaseCmd, sTestSuiteZips, sComment) + VALUES (a_uidAuthor, a_sName, a_sDescription, a_fEnabled, a_cSecTimeout, + a_sTestBoxReqExpr, a_sBuildReqExpr, a_sBaseCmd, a_sTestSuiteZips, a_sComment) + RETURNING idTestcase INTO v_idTestCase; + RETURN v_idTestCase; + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE function TestCaseLogic_editEntry(a_uidAuthor INTEGER, a_idTestCase INTEGER, a_sName TEXT, a_sDescription TEXT, + a_fEnabled BOOL, a_cSecTimeout INTEGER, a_sTestBoxReqExpr TEXT, + a_sBuildReqExpr TEXT, a_sBaseCmd TEXT, a_sTestSuiteZips TEXT, + a_sComment TEXT) + RETURNS INTEGER AS $$ + DECLARE + v_idGenTestCase INTEGER; + BEGIN + PERFORM TestCaseLogic_checkExists(a_idTestCase); + PERFORM TestCaseLogic_checkUniqueName(a_sName, a_idTestCase); + + PERFORM TestCaseLogic_historizeEntry(a_idTestCase, CURRENT_TIMESTAMP); + INSERT INTO TestCases (idTestCase, uidAuthor, sName, sDescription, fEnabled, cSecTimeout, + sTestBoxReqExpr, sBuildReqExpr, sBaseCmd, sTestSuiteZips, sComment) + VALUES (a_idTestCase, a_uidAuthor, a_sName, a_sDescription, a_fEnabled, a_cSecTimeout, + a_sTestBoxReqExpr, a_sBuildReqExpr, a_sBaseCmd, a_sTestSuiteZips, a_sComment) + RETURNING idGenTestCase INTO v_idGenTestCase; + RETURN v_idGenTestCase; + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION TestCaseLogic_delEntry(a_uidAuthor INTEGER, a_idTestCase INTEGER, a_fCascade BOOLEAN) + RETURNS VOID AS $$ + DECLARE + v_Row TestCases%ROWTYPE; + v_tsEffective TIMESTAMP WITH TIME ZONE; + v_Rec RECORD; + v_sErrors TEXT; + BEGIN + -- + -- Check preconditions. + -- + IF a_fCascade <> TRUE THEN + IF EXISTS( SELECT * + FROM TestCaseDeps + WHERE idTestCasePreReq = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP ) THEN + v_sErrors := ''; + FOR v_Rec IN + SELECT TestCases.idTestCase AS idTestCase, + TestCases.sName AS sName + FROM TestCaseDeps, TestCases + WHERE TestCaseDeps.idTestCasePreReq = a_idTestCase + AND TestCaseDeps.tsExpire = 'infinity'::TIMESTAMP + AND TestCases.idTestCase = TestCaseDeps.idTestCase + AND TestCases.tsExpire = 'infinity'::TIMESTAMP + LOOP + IF v_sErrors <> '' THEN + v_sErrors := v_sErrors || ', '; + END IF; + v_sErrors := v_sErrors || v_Rec.sName || ' (idTestCase=' || v_Rec.idTestCase || ')'; + END LOOP; + RAISE EXCEPTION 'Other test cases depends on test case with ID %: % ', a_idTestCase, v_sErrors; + END IF; + + IF EXISTS( SELECT * + FROM TestGroupMembers + WHERE idTestCase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP ) THEN + v_sErrors := ''; + FOR v_Rec IN + SELECT TestGroups.idTestGroup AS idTestGroup, + TestGroups.sName AS sName + FROM TestGroupMembers, TestGroups + WHERE TestGroupMembers.idTestCase = a_idTestCase + AND TestGroupMembers.tsExpire = 'infinity'::TIMESTAMP + AND TestGroupMembers.idTestGroup = TestGroups.idTestGroup + AND TestGroups.tsExpire = 'infinity'::TIMESTAMP + LOOP + IF v_sErrors <> '' THEN + v_sErrors := v_sErrors || ', '; + END IF; + v_sErrors := v_sErrors || v_Rec.sName || ' (idTestGroup=' || v_Rec.idTestGroup || ')'; + END LOOP; + RAISE EXCEPTION 'Test case with ID % is member of the following test group(s): % ', a_idTestCase, v_sErrors; + END IF; + END IF; + + -- + -- To preserve the information about who deleted the record, we try to + -- add a dummy record which expires immediately. I say try because of + -- the primary key, we must let the new record be valid for 1 us. :-( + -- + SELECT * INTO STRICT v_Row + FROM TestCases + WHERE idTestCase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP; + + v_tsEffective := CURRENT_TIMESTAMP - INTERVAL '1 microsecond'; + IF v_Row.tsEffective < v_tsEffective THEN + PERFORM TestCaseLogic_historizeEntry(a_idTestCase, v_tsEffective); + v_Row.tsEffective := v_tsEffective; + v_Row.tsExpire := CURRENT_TIMESTAMP; + v_Row.uidAuthor := a_uidAuthor; + SELECT NEXTVAL('TestCaseGenIdSeq') INTO v_Row.idGenTestCase; + INSERT INTO TestCases VALUES (v_Row.*); + ELSE + PERFORM TestCaseLogic_historizeEntry(a_idTestCase, CURRENT_TIMESTAMP); + END IF; + + -- + -- Delete arguments, test case dependencies and resource dependencies. + -- (We don't bother recording who deleted the records here since it's + -- a lot of work and sufficiently covered in the TestCases table.) + -- + UPDATE TestCaseArgs + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestCase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP; + + UPDATE TestCaseDeps + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestCase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP; + + UPDATE TestCaseGlobalRsrcDeps + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestCase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP; + + IF a_fCascade = TRUE THEN + UPDATE TestCaseDeps + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestCasePreReq = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP; + + UPDATE TestGroupMembers + SET tsExpire = CURRENT_TIMESTAMP + WHERE idTestCase = a_idTestCase + AND tsExpire = 'infinity'::TIMESTAMP; + END IF; + + EXCEPTION + WHEN NO_DATA_FOUND THEN + RAISE EXCEPTION 'Test case with ID % does not currently exist', a_idTestCase; + WHEN TOO_MANY_ROWS THEN + RAISE EXCEPTION 'Integrity error in TestCases: Too many current rows for %', a_idTestCase; + END; +$$ LANGUAGE plpgsql; + diff --git a/src/VBox/ValidationKit/testmanager/core/testcase.py b/src/VBox/ValidationKit/testmanager/core/testcase.py new file mode 100755 index 00000000..b2820ff2 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testcase.py @@ -0,0 +1,1467 @@ +# -*- coding: utf-8 -*- +# $Id: testcase.py $ +# pylint: disable=too-many-lines + +""" +Test Manager - Test Case. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import copy; +import sys; +import unittest; + +# Validation Kit imports. +from common import utils; +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMExceptionBase, \ + TMInvalidData, TMRowNotFound, ChangeLogEntry, AttributeChangeEntry; +from testmanager.core.globalresource import GlobalResourceData; +from testmanager.core.useraccount import UserAccountLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + long = int; # pylint: disable=redefined-builtin,invalid-name + + + +class TestCaseGlobalRsrcDepData(ModelDataBase): + """ + Test case dependency on a global resource - data. + """ + + ksParam_idTestCase = 'TestCaseDependency_idTestCase'; + ksParam_idGlobalRsrc = 'TestCaseDependency_idGlobalRsrc'; + ksParam_tsEffective = 'TestCaseDependency_tsEffective'; + ksParam_tsExpire = 'TestCaseDependency_tsExpire'; + ksParam_uidAuthor = 'TestCaseDependency_uidAuthor'; + + kasAllowNullAttributes = ['idTestSet', ]; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestCase = None; + self.idGlobalRsrc = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestCaseDeps row. + """ + if aoRow is None: + raise TMRowNotFound('Test case not found.'); + + self.idTestCase = aoRow[0]; + self.idGlobalRsrc = aoRow[1]; + self.tsEffective = aoRow[2]; + self.tsExpire = aoRow[3]; + self.uidAuthor = aoRow[4]; + return self; + + +class TestCaseGlobalRsrcDepLogic(ModelLogicBase): + """ + Test case dependency on a global resources - logic. + """ + + def getTestCaseDeps(self, idTestCase, tsNow = None): + """ + Returns an array of (TestCaseGlobalRsrcDepData, GlobalResourceData) + with the global resources required by idTestCase. + Returns empty array if none found. Raises exception on database error. + + Note! Maybe a bit overkill... + """ + ## @todo This code isn't entirely kosher... Should use a DataEx with a oGlobalRsrc = GlobalResourceData(). + if tsNow is not None: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseGlobalRsrcDeps, GlobalResources\n' + 'WHERE TestCaseGlobalRsrcDeps.idTestCase = %s\n' + ' AND TestCaseGlobalRsrcDeps.tsExpire > %s\n' + ' AND TestCaseGlobalRsrcDeps.tsEffective <= %s\n' + ' AND GlobalResources.idGlobalRsrc = TestCaseGlobalRsrcDeps.idGlobalRsrc\n' + ' AND GlobalResources.tsExpire > %s\n' + ' AND GlobalResources.tsEffective <= %s\n' + , (idTestCase, tsNow, tsNow, tsNow, tsNow) ); + else: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseGlobalRsrcDeps, GlobalResources\n' + 'WHERE TestCaseGlobalRsrcDeps.idTestCase = %s\n' + ' AND GlobalResources.idGlobalRsrc = TestCaseGlobalRsrcDeps.idGlobalRsrc\n' + ' AND TestCaseGlobalRsrcDeps.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND GlobalResources.tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestCase,)) + aaoRows = self._oDb.fetchAll(); + aoRet = [] + for aoRow in aaoRows: + oItem = [TestCaseDependencyData().initFromDbRow(aoRow), + GlobalResourceData().initFromDbRow(aoRow[5:])]; + aoRet.append(oItem); + + return aoRet + + def getTestCaseDepsIds(self, idTestCase, tsNow = None): + """ + Returns an array of global resources that idTestCase require. + Returns empty array if none found. Raises exception on database error. + """ + if tsNow is not None: + self._oDb.execute('SELECT idGlobalRsrc\n' + 'FROM TestCaseGlobalRsrcDeps\n' + 'WHERE TestCaseGlobalRsrcDeps.idTestCase = %s\n' + ' AND TestCaseGlobalRsrcDeps.tsExpire > %s\n' + ' AND TestCaseGlobalRsrcDeps.tsEffective <= %s\n' + , (idTestCase, tsNow, tsNow, ) ); + else: + self._oDb.execute('SELECT idGlobalRsrc\n' + 'FROM TestCaseGlobalRsrcDeps\n' + 'WHERE TestCaseGlobalRsrcDeps.idTestCase = %s\n' + ' AND TestCaseGlobalRsrcDeps.tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestCase,)) + aidGlobalRsrcs = [] + for aoRow in self._oDb.fetchAll(): + aidGlobalRsrcs.append(aoRow[0]); + return aidGlobalRsrcs; + + + def getDepGlobalResourceData(self, idTestCase, tsNow = None): + """ + Returns an array of objects of type GlobalResourceData on which the + specified test case depends on. + """ + if tsNow is None : + self._oDb.execute('SELECT GlobalResources.*\n' + 'FROM TestCaseGlobalRsrcDeps, GlobalResources\n' + 'WHERE TestCaseGlobalRsrcDeps.idTestCase = %s\n' + ' AND GlobalResources.idGlobalRsrc = TestCaseGlobalRsrcDeps.idGlobalRsrc\n' + ' AND TestCaseGlobalRsrcDeps.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND GlobalResources.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY GlobalResources.idGlobalRsrc\n' + , (idTestCase,)) + else: + self._oDb.execute('SELECT GlobalResources.*\n' + 'FROM TestCaseGlobalRsrcDeps, GlobalResources\n' + 'WHERE TestCaseGlobalRsrcDeps.idTestCase = %s\n' + ' AND GlobalResources.idGlobalRsrc = TestCaseGlobalRsrcDeps.idGlobalRsrc\n' + ' AND TestCaseGlobalRsrcDeps.tsExpire > %s\n' + ' AND TestCaseGlobalRsrcDeps.tsExpire <= %s\n' + ' AND GlobalResources.tsExpire > %s\n' + ' AND GlobalResources.tsEffective <= %s\n' + 'ORDER BY GlobalResources.idGlobalRsrc\n' + , (idTestCase, tsNow, tsNow, tsNow, tsNow)); + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(GlobalResourceData().initFromDbRow(aoRow)); + + return aoRet + + +class TestCaseDependencyData(ModelDataBase): + """ + Test case dependency data + """ + + ksParam_idTestCase = 'TestCaseDependency_idTestCase'; + ksParam_idTestCasePreReq = 'TestCaseDependency_idTestCasePreReq'; + ksParam_tsEffective = 'TestCaseDependency_tsEffective'; + ksParam_tsExpire = 'TestCaseDependency_tsExpire'; + ksParam_uidAuthor = 'TestCaseDependency_uidAuthor'; + + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestCase = None; + self.idTestCasePreReq = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestCaseDeps row. + """ + if aoRow is None: + raise TMRowNotFound('Test case not found.'); + + self.idTestCase = aoRow[0]; + self.idTestCasePreReq = aoRow[1]; + self.tsEffective = aoRow[2]; + self.tsExpire = aoRow[3]; + self.uidAuthor = aoRow[4]; + return self; + + def initFromParams(self, oDisp, fStrict=True): + """ + Initialize the object from parameters. + The input is not validated at all, except that all parameters must be + present when fStrict is True. + Note! Returns parameter NULL values, not database ones. + """ + + self.convertToParamNull(); + fn = oDisp.getStringParam; # Shorter... + + self.idTestCase = fn(self.ksParam_idTestCase, None, None if fStrict else self.idTestCase); + self.idTestCasePreReq = fn(self.ksParam_idTestCasePreReq, None, None if fStrict else self.idTestCasePreReq); + self.tsEffective = fn(self.ksParam_tsEffective, None, None if fStrict else self.tsEffective); + self.tsExpire = fn(self.ksParam_tsExpire, None, None if fStrict else self.tsExpire); + self.uidAuthor = fn(self.ksParam_uidAuthor, None, None if fStrict else self.uidAuthor); + + return True + + def validateAndConvert(self, oDb = None, enmValidateFor = ModelDataBase.ksValidateFor_Other): + """ + Validates the input and converts valid fields to their right type. + Returns a dictionary with per field reports, only invalid fields will + be returned, so an empty dictionary means that the data is valid. + + The dictionary keys are ksParam_*. + """ + dErrors = {} + + self.idTestCase = self._validateInt( dErrors, self.ksParam_idTestCase, self.idTestCase); + self.idTestCasePreReq = self._validateInt( dErrors, self.ksParam_idTestCasePreReq, self.idTestCasePreReq); + self.tsEffective = self._validateTs( dErrors, self.ksParam_tsEffective, self.tsEffective); + self.tsExpire = self._validateTs( dErrors, self.ksParam_tsExpire, self.tsExpire); + self.uidAuthor = self._validateInt( dErrors, self.ksParam_uidAuthor, self.uidAuthor); + + _ = oDb; + _ = enmValidateFor; + return dErrors + + def convertFromParamNull(self): + """ + Converts from parameter NULL values to database NULL values (None). + """ + if self.idTestCase in [-1, '']: self.idTestCase = None; + if self.idTestCasePreReq in [-1, '']: self.idTestCasePreReq = None; + if self.tsEffective == '': self.tsEffective = None; + if self.tsExpire == '': self.tsExpire = None; + if self.uidAuthor in [-1, '']: self.uidAuthor = None; + return True; + + def convertToParamNull(self): + """ + Converts from database NULL values (None) to special values we can + pass thru parameters list. + """ + if self.idTestCase is None: self.idTestCase = -1; + if self.idTestCasePreReq is None: self.idTestCasePreReq = -1; + if self.tsEffective is None: self.tsEffective = ''; + if self.tsExpire is None: self.tsExpire = ''; + if self.uidAuthor is None: self.uidAuthor = -1; + return True; + + def isEqual(self, oOther): + """ Compares two instances. """ + return self.idTestCase == oOther.idTestCase \ + and self.idTestCasePreReq == oOther.idTestCasePreReq \ + and self.tsEffective == oOther.tsEffective \ + and self.tsExpire == oOther.tsExpire \ + and self.uidAuthor == oOther.uidAuthor; + + def getTestCasePreReqIds(self, aTestCaseDependencyData): + """ + Get list of Test Case IDs which current + Test Case depends on + """ + if not aTestCaseDependencyData: + return [] + + aoRet = [] + for oTestCaseDependencyData in aTestCaseDependencyData: + aoRet.append(oTestCaseDependencyData.idTestCasePreReq) + + return aoRet + +class TestCaseDependencyLogic(ModelLogicBase): + """Test case dependency management logic""" + + def getTestCaseDeps(self, idTestCase, tsEffective = None): + """ + Returns an array of TestCaseDependencyData with the prerequisites of + idTestCase. + Returns empty array if none found. Raises exception on database error. + """ + if tsEffective is not None: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseDeps\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire > %s\n' + ' AND tsEffective <= %s\n' + , (idTestCase, tsEffective, tsEffective, ) ); + else: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseDeps\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestCase, ) ); + aaoRows = self._oDb.fetchAll(); + aoRet = []; + for aoRow in aaoRows: + aoRet.append(TestCaseDependencyData().initFromDbRow(aoRow)); + + return aoRet + + def getTestCaseDepsIds(self, idTestCase, tsNow = None): + """ + Returns an array of test case IDs of the prerequisites of idTestCase. + Returns empty array if none found. Raises exception on database error. + """ + if tsNow is not None: + self._oDb.execute('SELECT idTestCase\n' + 'FROM TestCaseDeps\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire > %s\n' + ' AND tsEffective <= %s\n' + , (idTestCase, tsNow, tsNow, ) ); + else: + self._oDb.execute('SELECT idTestCase\n' + 'FROM TestCaseDeps\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestCase, ) ); + aidPreReqs = []; + for aoRow in self._oDb.fetchAll(): + aidPreReqs.append(aoRow[0]); + return aidPreReqs; + + + def getDepTestCaseData(self, idTestCase, tsNow = None): + """ + Returns an array of objects of type TestCaseData2 on which + specified test case depends on + """ + if tsNow is None: + self._oDb.execute('SELECT TestCases.*\n' + 'FROM TestCases, TestCaseDeps\n' + 'WHERE TestCaseDeps.idTestCase = %s\n' + ' AND TestCaseDeps.idTestCasePreReq = TestCases.idTestCase\n' + ' AND TestCaseDeps.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestCases.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY TestCases.idTestCase\n' + , (idTestCase, ) ); + else: + self._oDb.execute('SELECT TestCases.*\n' + 'FROM TestCases, TestCaseDeps\n' + 'WHERE TestCaseDeps.idTestCase = %s\n' + ' AND TestCaseDeps.idTestCasePreReq = TestCases.idTestCase\n' + ' AND TestCaseDeps.tsExpire > %s\n' + ' AND TestCaseDeps.tsEffective <= %s\n' + ' AND TestCases.tsExpire > %s\n' + ' AND TestCases.tsEffective <= %s\n' + 'ORDER BY TestCases.idTestCase\n' + , (idTestCase, tsNow, tsNow, tsNow, tsNow, ) ); + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestCaseData().initFromDbRow(aoRow)); + + return aoRet + + def getApplicableDepTestCaseData(self, idTestCase): + """ + Returns an array of objects of type TestCaseData on which + specified test case might depends on (all test + cases except the specified one and those testcases which are + depend on idTestCase) + """ + self._oDb.execute('SELECT *\n' + 'FROM TestCases\n' + 'WHERE idTestCase <> %s\n' + ' AND idTestCase NOT IN (SELECT idTestCase\n' + ' FROM TestCaseDeps\n' + ' WHERE idTestCasePreReq=%s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP)\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestCase, idTestCase) ) + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestCaseData().initFromDbRow(aoRow)); + + return aoRet + +class TestCaseData(ModelDataBase): + """ + Test case data + """ + + ksIdAttr = 'idTestCase'; + ksIdGenAttr = 'idGenTestCase'; + + ksParam_idTestCase = 'TestCase_idTestCase' + ksParam_tsEffective = 'TestCase_tsEffective' + ksParam_tsExpire = 'TestCase_tsExpire' + ksParam_uidAuthor = 'TestCase_uidAuthor' + ksParam_idGenTestCase = 'TestCase_idGenTestCase' + ksParam_sName = 'TestCase_sName' + ksParam_sDescription = 'TestCase_sDescription' + ksParam_fEnabled = 'TestCase_fEnabled' + ksParam_cSecTimeout = 'TestCase_cSecTimeout' + ksParam_sTestBoxReqExpr = 'TestCase_sTestBoxReqExpr'; + ksParam_sBuildReqExpr = 'TestCase_sBuildReqExpr'; + ksParam_sBaseCmd = 'TestCase_sBaseCmd' + ksParam_sValidationKitZips = 'TestCase_sValidationKitZips' + ksParam_sComment = 'TestCase_sComment' + + kasAllowNullAttributes = [ 'idTestCase', 'tsEffective', 'tsExpire', 'uidAuthor', 'idGenTestCase', 'sDescription', + 'sTestBoxReqExpr', 'sBuildReqExpr', 'sValidationKitZips', 'sComment' ]; + + kcDbColumns = 14; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestCase = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.idGenTestCase = None; + self.sName = None; + self.sDescription = None; + self.fEnabled = False; + self.cSecTimeout = 10; # Init with minimum timeout value + self.sTestBoxReqExpr = None; + self.sBuildReqExpr = None; + self.sBaseCmd = None; + self.sValidationKitZips = None; + self.sComment = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestCases row. + Returns self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test case not found.'); + + self.idTestCase = aoRow[0]; + self.tsEffective = aoRow[1]; + self.tsExpire = aoRow[2]; + self.uidAuthor = aoRow[3]; + self.idGenTestCase = aoRow[4]; + self.sName = aoRow[5]; + self.sDescription = aoRow[6]; + self.fEnabled = aoRow[7]; + self.cSecTimeout = aoRow[8]; + self.sTestBoxReqExpr = aoRow[9]; + self.sBuildReqExpr = aoRow[10]; + self.sBaseCmd = aoRow[11]; + self.sValidationKitZips = aoRow[12]; + self.sComment = aoRow[13]; + return self; + + def initFromDbWithId(self, oDb, idTestCase, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM TestCases\n' + 'WHERE idTestCase = %s\n' + , ( idTestCase,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idTestCase=%s not found (tsNow=%s sPeriodBack=%s)' % (idTestCase, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def initFromDbWithGenId(self, oDb, idGenTestCase, tsNow = None): + """ + Initialize the object from the database. + """ + _ = tsNow; # For relevant for the TestCaseDataEx version only. + oDb.execute('SELECT *\n' + 'FROM TestCases\n' + 'WHERE idGenTestCase = %s\n' + , (idGenTestCase, ) ); + return self.initFromDbRow(oDb.fetchOne()); + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + if sAttr == 'cSecTimeout' and oValue not in aoNilValues: # Allow human readable interval formats. + return utils.parseIntervalSeconds(oValue); + + (oValue, sError) = ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + if sError is None: + if sAttr == 'sTestBoxReqExpr': + sError = TestCaseData.validateTestBoxReqExpr(oValue); + elif sAttr == 'sBuildReqExpr': + sError = TestCaseData.validateBuildReqExpr(oValue); + elif sAttr == 'sBaseCmd': + _, sError = TestCaseData.validateStr(oValue, fAllowUnicodeSymbols=False); + return (oValue, sError); + + + # + # Misc. + # + + def needValidationKitBit(self): + """ + Predicate method for checking whether a validation kit build is required. + """ + return self.sValidationKitZips is None \ + or self.sValidationKitZips.find('@VALIDATIONKIT_ZIP@') >= 0; + + def matchesTestBoxProps(self, oTestBoxData): + """ + Checks if the all of the testbox related test requirements matches the + given testbox. + + Returns True or False according to the expression, None on exception or + non-boolean expression result. + """ + return TestCaseData.matchesTestBoxPropsEx(oTestBoxData, self.sTestBoxReqExpr); + + def matchesBuildProps(self, oBuildDataEx): + """ + Checks if the all of the build related test requirements matches the + given build. + + Returns True or False according to the expression, None on exception or + non-boolean expression result. + """ + return TestCaseData.matchesBuildPropsEx(oBuildDataEx, self.sBuildReqExpr); + + + # + # Expression validation code shared with TestCaseArgsDataEx. + # + @staticmethod + def _safelyEvalExpr(sExpr, dLocals, fMayRaiseXcpt = False): + """ + Safely evaluate requirment expression given a set of locals. + + Returns True or False according to the expression. If the expression + causes an exception to be raised or does not return a boolean result, + None will be returned. + """ + if sExpr is None or sExpr == '': + return True; + + dGlobals = \ + { + '__builtins__': None, + 'long': long, + 'int': int, + 'bool': bool, + 'True': True, + 'False': False, + 'len': len, + 'isinstance': isinstance, + 'type': type, + 'dict': dict, + 'dir': dir, + 'list': list, + 'versionCompare': utils.versionCompare, + }; + + try: + fRc = eval(sExpr, dGlobals, dLocals); + except: + if fMayRaiseXcpt: + raise; + return None; + + if not isinstance(fRc, bool): + if fMayRaiseXcpt: + raise Exception('not a boolean result: "%s" - %s' % (fRc, type(fRc)) ); + return None; + + return fRc; + + @staticmethod + def _safelyValidateReqExpr(sExpr, adLocals): + """ + Validates a requirement expression using the given sets of locals, + returning None on success and an error string on failure. + """ + for dLocals in adLocals: + try: + TestCaseData._safelyEvalExpr(sExpr, dLocals, True); + except Exception as oXcpt: + return str(oXcpt); + return None; + + @staticmethod + def validateTestBoxReqExpr(sExpr): + """ + Validates a testbox expression, returning None on success and an error + string on failure. + """ + adTestBoxes = \ + [ + { + 'sOs': 'win', + 'sOsVersion': '3.1', + 'sCpuVendor': 'VirtualBox', + 'sCpuArch': 'x86', + 'cCpus': 1, + 'fCpuHwVirt': False, + 'fCpuNestedPaging': False, + 'fCpu64BitGuest': False, + 'fChipsetIoMmu': False, + 'fRawMode': False, + 'cMbMemory': 985034, + 'cMbScratch': 1234089, + 'iTestBoxScriptRev': 1, + 'sName': 'emanon', + 'uuidSystem': '8FF81BE5-3901-4AB1-8A65-B48D511C0321', + }, + { + 'sOs': 'linux', + 'sOsVersion': '3.1', + 'sCpuVendor': 'VirtualBox', + 'sCpuArch': 'amd64', + 'cCpus': 8191, + 'fCpuHwVirt': True, + 'fCpuNestedPaging': True, + 'fCpu64BitGuest': True, + 'fChipsetIoMmu': True, + 'fRawMode': True, + 'cMbMemory': 9999999999, + 'cMbScratch': 9999999999999, + 'iTestBoxScriptRev': 9999999, + 'sName': 'emanon', + 'uuidSystem': '00000000-0000-0000-0000-000000000000', + }, + ]; + return TestCaseData._safelyValidateReqExpr(sExpr, adTestBoxes); + + @staticmethod + def matchesTestBoxPropsEx(oTestBoxData, sExpr): + """ Worker for TestCaseData.matchesTestBoxProps and TestCaseArgsDataEx.matchesTestBoxProps. """ + if sExpr is None: + return True; + dLocals = \ + { + 'sOs': oTestBoxData.sOs, + 'sOsVersion': oTestBoxData.sOsVersion, + 'sCpuVendor': oTestBoxData.sCpuVendor, + 'sCpuArch': oTestBoxData.sCpuArch, + 'iCpuFamily': oTestBoxData.getCpuFamily(), + 'iCpuModel': oTestBoxData.getCpuModel(), + 'cCpus': oTestBoxData.cCpus, + 'fCpuHwVirt': oTestBoxData.fCpuHwVirt, + 'fCpuNestedPaging': oTestBoxData.fCpuNestedPaging, + 'fCpu64BitGuest': oTestBoxData.fCpu64BitGuest, + 'fChipsetIoMmu': oTestBoxData.fChipsetIoMmu, + 'fRawMode': oTestBoxData.fRawMode, + 'cMbMemory': oTestBoxData.cMbMemory, + 'cMbScratch': oTestBoxData.cMbScratch, + 'iTestBoxScriptRev': oTestBoxData.iTestBoxScriptRev, + 'iPythonHexVersion': oTestBoxData.iPythonHexVersion, + 'sName': oTestBoxData.sName, + 'uuidSystem': oTestBoxData.uuidSystem, + }; + return TestCaseData._safelyEvalExpr(sExpr, dLocals); + + @staticmethod + def validateBuildReqExpr(sExpr): + """ + Validates a testbox expression, returning None on success and an error + string on failure. + """ + adBuilds = \ + [ + { + 'sProduct': 'VirtualBox', + 'sBranch': 'trunk', + 'sType': 'release', + 'asOsArches': ['win.amd64', 'win.x86'], + 'sVersion': '1.0', + 'iRevision': 1234, + 'uidAuthor': None, + 'idBuild': 953, + }, + { + 'sProduct': 'VirtualBox', + 'sBranch': 'VBox-4.1', + 'sType': 'release', + 'asOsArches': ['linux.x86',], + 'sVersion': '4.2.15', + 'iRevision': 89876, + 'uidAuthor': None, + 'idBuild': 945689, + }, + { + 'sProduct': 'VirtualBox', + 'sBranch': 'VBox-4.1', + 'sType': 'strict', + 'asOsArches': ['solaris.x86', 'solaris.amd64',], + 'sVersion': '4.3.0_RC3', + 'iRevision': 97939, + 'uidAuthor': 33, + 'idBuild': 9456893, + }, + ]; + return TestCaseData._safelyValidateReqExpr(sExpr, adBuilds); + + @staticmethod + def matchesBuildPropsEx(oBuildDataEx, sExpr): + """ + Checks if the all of the build related test requirements matches the + given build. + """ + if sExpr is None: + return True; + dLocals = \ + { + 'sProduct': oBuildDataEx.oCat.sProduct, + 'sBranch': oBuildDataEx.oCat.sBranch, + 'sType': oBuildDataEx.oCat.sType, + 'asOsArches': oBuildDataEx.oCat.asOsArches, + 'sVersion': oBuildDataEx.sVersion, + 'iRevision': oBuildDataEx.iRevision, + 'uidAuthor': oBuildDataEx.uidAuthor, + 'idBuild': oBuildDataEx.idBuild, + }; + return TestCaseData._safelyEvalExpr(sExpr, dLocals); + + + + +class TestCaseDataEx(TestCaseData): + """ + Test case data. + """ + + ksParam_aoTestCaseArgs = 'TestCase_aoTestCaseArgs'; + ksParam_aoDepTestCases = 'TestCase_aoDepTestCases'; + ksParam_aoDepGlobalResources = 'TestCase_aoDepGlobalResources'; + + # Use [] instead of None. + kasAltArrayNull = [ 'aoTestCaseArgs', 'aoDepTestCases', 'aoDepGlobalResources' ]; + + + def __init__(self): + TestCaseData.__init__(self); + + # List of objects of type TestCaseData (or TestCaseDataEx, we don't + # care) on which current Test Case depends. + self.aoDepTestCases = []; + + # List of objects of type GlobalResourceData on which current Test Case depends. + self.aoDepGlobalResources = []; + + # List of objects of type TestCaseArgsData. + self.aoTestCaseArgs = []; + + def _initExtraMembersFromDb(self, oDb, tsNow = None, sPeriodBack = None): + """ + Worker shared by the initFromDb* methods. + Returns self. Raises exception if no row or database error. + """ + _ = sPeriodBack; ## @todo sPeriodBack + from testmanager.core.testcaseargs import TestCaseArgsLogic; + self.aoDepTestCases = TestCaseDependencyLogic(oDb).getDepTestCaseData(self.idTestCase, tsNow); + self.aoDepGlobalResources = TestCaseGlobalRsrcDepLogic(oDb).getDepGlobalResourceData(self.idTestCase, tsNow); + self.aoTestCaseArgs = TestCaseArgsLogic(oDb).getTestCaseArgs(self.idTestCase, tsNow); + # Note! The above arrays are sorted by their relvant IDs for fetchForChangeLog's sake. + return self; + + def initFromDbRowEx(self, aoRow, oDb, tsNow = None): + """ + Reinitialize from a SELECT * FROM TestCases row. Will query the + necessary additional data from oDb using tsNow. + Returns self. Raises exception if no row or database error. + """ + TestCaseData.initFromDbRow(self, aoRow); + return self._initExtraMembersFromDb(oDb, tsNow); + + def initFromDbWithId(self, oDb, idTestCase, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + TestCaseData.initFromDbWithId(self, oDb, idTestCase, tsNow, sPeriodBack); + return self._initExtraMembersFromDb(oDb, tsNow, sPeriodBack); + + def initFromDbWithGenId(self, oDb, idGenTestCase, tsNow = None): + """ + Initialize the object from the database. + """ + TestCaseData.initFromDbWithGenId(self, oDb, idGenTestCase); + if tsNow is None and not oDb.isTsInfinity(self.tsExpire): + tsNow = self.tsEffective; + return self._initExtraMembersFromDb(oDb, tsNow); + + def getAttributeParamNullValues(self, sAttr): + if sAttr in ['aoDepTestCases', 'aoDepGlobalResources', 'aoTestCaseArgs']: + return [[], '']; + return TestCaseData.getAttributeParamNullValues(self, sAttr); + + def convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict): + """For dealing with the arrays.""" + if sAttr not in ['aoDepTestCases', 'aoDepGlobalResources', 'aoTestCaseArgs']: + return TestCaseData.convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict); + + aoNewValues = []; + if sAttr == 'aoDepTestCases': + for idTestCase in oDisp.getListOfIntParams(sParam, 1, 0x7ffffffe, []): + oDep = TestCaseData(); + oDep.idTestCase = str(idTestCase); + aoNewValues.append(oDep); + + elif sAttr == 'aoDepGlobalResources': + for idGlobalRsrc in oDisp.getListOfIntParams(sParam, 1, 0x7ffffffe, []): + oGlobalRsrc = GlobalResourceData(); + oGlobalRsrc.idGlobalRsrc = str(idGlobalRsrc); + aoNewValues.append(oGlobalRsrc); + + elif sAttr == 'aoTestCaseArgs': + from testmanager.core.testcaseargs import TestCaseArgsData; + for sArgKey in oDisp.getStringParam(TestCaseDataEx.ksParam_aoTestCaseArgs, sDefault = '').split(','): + oDispWrapper = self.DispWrapper(oDisp, '%s[%s][%%s]' % (TestCaseDataEx.ksParam_aoTestCaseArgs, sArgKey,)) + aoNewValues.append(TestCaseArgsData().initFromParams(oDispWrapper, fStrict = False)); + return aoNewValues; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): # pylint: disable=too-many-locals + """ + Validate special arrays and requirement expressions. + + For the two dependency arrays we have to supply missing bits by + looking them up in the database. In the argument variation case we + need to validate each item. + """ + if sAttr not in ['aoDepTestCases', 'aoDepGlobalResources', 'aoTestCaseArgs']: + return TestCaseData._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + asErrors = []; + aoNewValues = []; + if sAttr == 'aoDepTestCases': + for oTestCase in self.aoDepTestCases: + if utils.isString(oTestCase.idTestCase): # Stored as string convertParamToAttribute. + oTestCase = copy.copy(oTestCase); + try: + oTestCase.idTestCase = int(oTestCase.idTestCase); + oTestCase.initFromDbWithId(oDb, oTestCase.idTestCase); + except Exception as oXcpt: + asErrors.append('Test case dependency #%s: %s' % (oTestCase.idTestCase, oXcpt)); + aoNewValues.append(oTestCase); + + elif sAttr == 'aoDepGlobalResources': + for oGlobalRsrc in self.aoDepGlobalResources: + if utils.isString(oGlobalRsrc.idGlobalRsrc): # Stored as string convertParamToAttribute. + oGlobalRsrc = copy.copy(oGlobalRsrc); + try: + oGlobalRsrc.idTestCase = int(oGlobalRsrc.idGlobalRsrc); + oGlobalRsrc.initFromDbWithId(oDb, oGlobalRsrc.idGlobalRsrc); + except Exception as oXcpt: + asErrors.append('Resource dependency #%s: %s' % (oGlobalRsrc.idGlobalRsrc, oXcpt)); + aoNewValues.append(oGlobalRsrc); + + else: + assert sAttr == 'aoTestCaseArgs'; + if not self.aoTestCaseArgs: + return (None, 'The testcase requires at least one argument variation to be valid.'); + + # Note! We'll be returning an error dictionary instead of an string here. + dErrors = {}; + + for iVar, oVar in enumerate(self.aoTestCaseArgs): + oVar = copy.copy(oVar); + oVar.idTestCase = self.idTestCase; + dCurErrors = oVar.validateAndConvert(oDb, ModelDataBase.ksValidateFor_Other); + if not dCurErrors: + pass; ## @todo figure out the ID? + else: + asErrors = []; + for sKey in dCurErrors: + asErrors.append('%s: %s' % (sKey[len('TestCaseArgs_'):], dCurErrors[sKey])); + dErrors[iVar] = '
\n'.join(asErrors) + aoNewValues.append(oVar); + + for iVar, oVar in enumerate(self.aoTestCaseArgs): + sArgs = oVar.sArgs; + for iVar2 in range(iVar + 1, len(self.aoTestCaseArgs)): + if self.aoTestCaseArgs[iVar2].sArgs == sArgs: + sMsg = 'Duplicate argument variation "%s".' % (sArgs); + if iVar in dErrors: dErrors[iVar] += '
\n' + sMsg; + else: dErrors[iVar] = sMsg; + if iVar2 in dErrors: dErrors[iVar2] += '
\n' + sMsg; + else: dErrors[iVar2] = sMsg; + break; + + return (aoNewValues, dErrors if dErrors else None); + + return (aoNewValues, None if not asErrors else '
'.join(asErrors)); + + def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ModelDataBase.ksValidateFor_Other): + dErrors = TestCaseData._validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor); + + # Validate dependencies a wee bit for paranoid reasons. The scheduler + # queue generation code does the real validation here! + if not dErrors and self.idTestCase is not None: + for oDep in self.aoDepTestCases: + if oDep.idTestCase == self.idTestCase: + if self.ksParam_aoDepTestCases in dErrors: + dErrors[self.ksParam_aoDepTestCases] += ' Depending on itself!'; + else: + dErrors[self.ksParam_aoDepTestCases] = 'Depending on itself!'; + return dErrors; + + + + + +class TestCaseLogic(ModelLogicBase): + """ + Test case management logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + def getAll(self): + """ + Fetches all test case records from DB (TestCaseData). + """ + self._oDb.execute('SELECT *\n' + 'FROM TestCases\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idTestCase ASC;') + + aaoRows = self._oDb.fetchAll() + aoRet = []; + for aoRow in aaoRows: + aoRet.append(TestCaseData().initFromDbRow(aoRow)) + return aoRet + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches test cases. + + Returns an array (list) of TestCaseDataEx items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM TestCases\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sName ASC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart, )); + else: + self._oDb.execute('SELECT *\n' + 'FROM TestCases\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sName ASC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart, )); + + aoRows = []; + for aoRow in self._oDb.fetchAll(): + aoRows.append(TestCaseDataEx().initFromDbRowEx(aoRow, self._oDb, tsNow)); + return aoRows; + + def fetchForChangeLog(self, idTestCase, iStart, cMaxRows, tsNow): # pylint: disable=too-many-locals + """ + Fetches change log entries for a testbox. + + Returns an array of ChangeLogEntry instance and an indicator whether + there are more entries. + Raises exception on error. + """ + + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + + # 1. Get a list of the relevant change times. + self._oDb.execute('( SELECT tsEffective, uidAuthor FROM TestCases WHERE idTestCase = %s AND tsEffective <= %s )\n' + 'UNION\n' + '( SELECT tsEffective, uidAuthor FROM TestCaseArgs WHERE idTestCase = %s AND tsEffective <= %s )\n' + 'UNION\n' + '( SELECT tsEffective, uidAuthor FROM TestCaseDeps WHERE idTestCase = %s AND tsEffective <= %s )\n' + 'UNION\n' + '( SELECT tsEffective, uidAuthor FROM TestCaseGlobalRsrcDeps \n' \ + ' WHERE idTestCase = %s AND tsEffective <= %s )\n' + 'ORDER BY tsEffective DESC\n' + 'LIMIT %s OFFSET %s\n' + , ( idTestCase, tsNow, + idTestCase, tsNow, + idTestCase, tsNow, + idTestCase, tsNow, + cMaxRows + 1, iStart, )); + aaoChanges = self._oDb.fetchAll(); + + # 2. Collect data sets for each of those points. + # (Doing it the lazy + inefficient way for now.) + aoRows = []; + for aoChange in aaoChanges: + aoRows.append(TestCaseDataEx().initFromDbWithId(self._oDb, idTestCase, aoChange[0])); + + # 3. Calculate the changes. + aoEntries = []; + for i in range(0, len(aoRows) - 1): + oNew = aoRows[i]; + oOld = aoRows[i + 1]; + (tsEffective, uidAuthor) = aaoChanges[i]; + (tsExpire, _) = aaoChanges[i - 1] if i > 0 else (oNew.tsExpire, None) + assert self._oDb.isTsInfinity(tsEffective) != self._oDb.isTsInfinity(tsExpire) or tsEffective < tsExpire, \ + '%s vs %s' % (tsEffective, tsExpire); + + aoChanges = []; + + # The testcase object. + if oNew.tsEffective != oOld.tsEffective: + for sAttr in oNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', \ + 'aoTestCaseArgs', 'aoDepTestCases', 'aoDepGlobalResources']: + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr != oNewAttr: + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + + # The argument variations. + iChildOld = 0; + for oChildNew in oNew.aoTestCaseArgs: + # Locate the old entry, emitting removed markers for old items we have to skip. + while iChildOld < len(oOld.aoTestCaseArgs) \ + and oOld.aoTestCaseArgs[iChildOld].idTestCaseArgs < oChildNew.idTestCaseArgs: + oChildOld = oOld.aoTestCaseArgs[iChildOld]; + aoChanges.append(AttributeChangeEntry('Variation #%s' % (oChildOld.idTestCaseArgs,), + None, oChildOld, 'Removed', str(oChildOld))); + iChildOld += 1; + + if iChildOld < len(oOld.aoTestCaseArgs) \ + and oOld.aoTestCaseArgs[iChildOld].idTestCaseArgs == oChildNew.idTestCaseArgs: + oChildOld = oOld.aoTestCaseArgs[iChildOld]; + if oChildNew.tsEffective != oChildOld.tsEffective: + for sAttr in oChildNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', 'idGenTestCase', ]: + oOldAttr = getattr(oChildOld, sAttr); + oNewAttr = getattr(oChildNew, sAttr); + if oOldAttr != oNewAttr: + aoChanges.append(AttributeChangeEntry('Variation[#%s].%s' + % (oChildOld.idTestCaseArgs, sAttr,), + oNewAttr, oOldAttr, + str(oNewAttr), str(oOldAttr))); + iChildOld += 1; + else: + aoChanges.append(AttributeChangeEntry('Variation #%s' % (oChildNew.idTestCaseArgs,), + oChildNew, None, + str(oChildNew), 'Did not exist')); + + # The testcase dependencies. + iChildOld = 0; + for oChildNew in oNew.aoDepTestCases: + # Locate the old entry, emitting removed markers for old items we have to skip. + while iChildOld < len(oOld.aoDepTestCases) \ + and oOld.aoDepTestCases[iChildOld].idTestCase < oChildNew.idTestCase: + oChildOld = oOld.aoDepTestCases[iChildOld]; + aoChanges.append(AttributeChangeEntry('Dependency #%s' % (oChildOld.idTestCase,), + None, oChildOld, 'Removed', + '%s (#%u)' % (oChildOld.sName, oChildOld.idTestCase,))); + iChildOld += 1; + if iChildOld < len(oOld.aoDepTestCases) \ + and oOld.aoDepTestCases[iChildOld].idTestCase == oChildNew.idTestCase: + iChildOld += 1; + else: + aoChanges.append(AttributeChangeEntry('Dependency #%s' % (oChildNew.idTestCase,), + oChildNew, None, + '%s (#%u)' % (oChildNew.sName, oChildNew.idTestCase,), + 'Did not exist')); + + # The global resource dependencies. + iChildOld = 0; + for oChildNew in oNew.aoDepGlobalResources: + # Locate the old entry, emitting removed markers for old items we have to skip. + while iChildOld < len(oOld.aoDepGlobalResources) \ + and oOld.aoDepGlobalResources[iChildOld].idGlobalRsrc < oChildNew.idGlobalRsrc: + oChildOld = oOld.aoDepGlobalResources[iChildOld]; + aoChanges.append(AttributeChangeEntry('Global Resource #%s' % (oChildOld.idGlobalRsrc,), + None, oChildOld, 'Removed', + '%s (#%u)' % (oChildOld.sName, oChildOld.idGlobalRsrc,))); + iChildOld += 1; + if iChildOld < len(oOld.aoDepGlobalResources) \ + and oOld.aoDepGlobalResources[iChildOld].idGlobalRsrc == oChildNew.idGlobalRsrc: + iChildOld += 1; + else: + aoChanges.append(AttributeChangeEntry('Global Resource #%s' % (oChildNew.idGlobalRsrc,), + oChildNew, None, + '%s (#%u)' % (oChildNew.sName, oChildNew.idGlobalRsrc,), + 'Did not exist')); + + # Done. + aoEntries.append(ChangeLogEntry(uidAuthor, None, tsEffective, tsExpire, oNew, oOld, aoChanges)); + + # If we're at the end of the log, add the initial entry. + if len(aoRows) <= cMaxRows and aoRows: + oNew = aoRows[-1]; + aoEntries.append(ChangeLogEntry(oNew.uidAuthor, None, + aaoChanges[-1][0], aaoChanges[-2][0] if len(aaoChanges) > 1 else oNew.tsExpire, + oNew, None, [])); + + return (UserAccountLogic(self._oDb).resolveChangeLogAuthors(aoEntries), len(aoRows) > cMaxRows); + + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Add a new testcase to the DB. + """ + + # + # Validate the input first. + # + assert isinstance(oData, TestCaseDataEx); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dErrors: + raise TMInvalidData('Invalid input data: %s' % (dErrors,)); + + # + # Add the testcase. + # + self._oDb.callProc('TestCaseLogic_addEntry', + ( uidAuthor, oData.sName, oData.sDescription, oData.fEnabled, oData.cSecTimeout, + oData.sTestBoxReqExpr, oData.sBuildReqExpr, oData.sBaseCmd, oData.sValidationKitZips, + oData.sComment )); + oData.idTestCase = self._oDb.fetchOne()[0]; + + # Add testcase dependencies. + for oDep in oData.aoDepTestCases: + self._oDb.execute('INSERT INTO TestCaseDeps (idTestCase, idTestCasePreReq, uidAuthor) VALUES (%s, %s, %s)' + , (oData.idTestCase, oDep.idTestCase, uidAuthor)) + + # Add global resource dependencies. + for oDep in oData.aoDepGlobalResources: + self._oDb.execute('INSERT INTO TestCaseGlobalRsrcDeps (idTestCase, idGlobalRsrc, uidAuthor) VALUES (%s, %s, %s)' + , (oData.idTestCase, oDep.idGlobalRsrc, uidAuthor)) + + # Set Test Case Arguments variations + for oVar in oData.aoTestCaseArgs: + self._oDb.execute('INSERT INTO TestCaseArgs (\n' + ' idTestCase, uidAuthor, sArgs, cSecTimeout,\n' + ' sTestBoxReqExpr, sBuildReqExpr, cGangMembers, sSubName)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s)' + , ( oData.idTestCase, uidAuthor, oVar.sArgs, oVar.cSecTimeout, + oVar.sTestBoxReqExpr, oVar.sBuildReqExpr, oVar.cGangMembers, oVar.sSubName, )); + + self._oDb.maybeCommit(fCommit); + return True; + + def editEntry(self, oData, uidAuthor, fCommit = False): # pylint: disable=too-many-locals + """ + Edit a testcase entry (extended). + Caller is expected to rollback the database transactions on exception. + """ + + # + # Validate the input. + # + assert isinstance(oData, TestCaseDataEx); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('Invalid input data: %s' % (dErrors,)); + + # + # Did anything change? If not return straight away. + # + oOldDataEx = TestCaseDataEx().initFromDbWithId(self._oDb, oData.idTestCase); + if oOldDataEx.isEqual(oData): + self._oDb.maybeCommit(fCommit); + return True; + + # + # Make the necessary changes. + # + + # The test case itself. + if not TestCaseData().initFromOther(oOldDataEx).isEqual(oData): + self._oDb.callProc('TestCaseLogic_editEntry', ( uidAuthor, oData.idTestCase, oData.sName, oData.sDescription, + oData.fEnabled, oData.cSecTimeout, oData.sTestBoxReqExpr, + oData.sBuildReqExpr, oData.sBaseCmd, oData.sValidationKitZips, + oData.sComment )); + oData.idGenTestCase = self._oDb.fetchOne()[0]; + + # + # Its dependencies on other testcases. + # + aidNewDeps = [oDep.idTestCase for oDep in oData.aoDepTestCases]; + aidOldDeps = [oDep.idTestCase for oDep in oOldDataEx.aoDepTestCases]; + + sQuery = self._oDb.formatBindArgs('UPDATE TestCaseDeps\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::timestamp\n' + , (oData.idTestCase,)); + asKeepers = []; + for idDep in aidOldDeps: + if idDep in aidNewDeps: + asKeepers.append(str(idDep)); + if asKeepers: + sQuery += ' AND idTestCasePreReq NOT IN (' + ', '.join(asKeepers) + ')\n'; + self._oDb.execute(sQuery); + + for idDep in aidNewDeps: + if idDep not in aidOldDeps: + self._oDb.execute('INSERT INTO TestCaseDeps (idTestCase, idTestCasePreReq, uidAuthor)\n' + 'VALUES (%s, %s, %s)\n' + , (oData.idTestCase, idDep, uidAuthor) ); + + # + # Its dependencies on global resources. + # + aidNewDeps = [oDep.idGlobalRsrc for oDep in oData.aoDepGlobalResources]; + aidOldDeps = [oDep.idGlobalRsrc for oDep in oOldDataEx.aoDepGlobalResources]; + + sQuery = self._oDb.formatBindArgs('UPDATE TestCaseGlobalRsrcDeps\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::timestamp\n' + , (oData.idTestCase,)); + asKeepers = []; + for idDep in aidOldDeps: + if idDep in aidNewDeps: + asKeepers.append(str(idDep)); + if asKeepers: + sQuery = ' AND idGlobalRsrc NOT IN (' + ', '.join(asKeepers) + ')\n'; + self._oDb.execute(sQuery); + + for idDep in aidNewDeps: + if idDep not in aidOldDeps: + self._oDb.execute('INSERT INTO TestCaseGlobalRsrcDeps (idTestCase, idGlobalRsrc, uidAuthor)\n' + 'VALUES (%s, %s, %s)\n' + , (oData.idTestCase, idDep, uidAuthor) ); + + # + # Update Test Case Args + # Note! Primary key is idTestCase, tsExpire, sArgs. + # + + # Historize rows that have been removed. + sQuery = self._oDb.formatBindArgs('UPDATE TestCaseArgs\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP' + , (oData.idTestCase, )); + for oNewVar in oData.aoTestCaseArgs: + asKeepers.append(self._oDb.formatBindArgs('%s', (oNewVar.sArgs,))); + if asKeepers: + sQuery += ' AND sArgs NOT IN (' + ', '.join(asKeepers) + ')\n'; + self._oDb.execute(sQuery); + + # Add new TestCaseArgs records if necessary, reusing old IDs when possible. + from testmanager.core.testcaseargs import TestCaseArgsData; + for oNewVar in oData.aoTestCaseArgs: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCase = %s\n' + ' AND sArgs = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (oData.idTestCase, oNewVar.sArgs,)); + aoRow = self._oDb.fetchOne(); + if aoRow is None: + # New + self._oDb.execute('INSERT INTO TestCaseArgs (\n' + ' idTestCase, uidAuthor, sArgs, cSecTimeout,\n' + ' sTestBoxReqExpr, sBuildReqExpr, cGangMembers, sSubName)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s)' + , ( oData.idTestCase, uidAuthor, oNewVar.sArgs, oNewVar.cSecTimeout, + oNewVar.sTestBoxReqExpr, oNewVar.sBuildReqExpr, oNewVar.cGangMembers, oNewVar.sSubName)); + else: + oCurVar = TestCaseArgsData().initFromDbRow(aoRow); + if self._oDb.isTsInfinity(oCurVar.tsExpire): + # Existing current entry, updated if changed. + if oNewVar.cSecTimeout == oCurVar.cSecTimeout \ + and oNewVar.sTestBoxReqExpr == oCurVar.sTestBoxReqExpr \ + and oNewVar.sBuildReqExpr == oCurVar.sBuildReqExpr \ + and oNewVar.cGangMembers == oCurVar.cGangMembers \ + and oNewVar.sSubName == oCurVar.sSubName: + oNewVar.idTestCaseArgs = oCurVar.idTestCaseArgs; + oNewVar.idGenTestCaseArgs = oCurVar.idGenTestCaseArgs; + continue; # Unchanged. + self._oDb.execute('UPDATE TestCaseArgs SET tsExpire = CURRENT_TIMESTAMP WHERE idGenTestCaseArgs = %s\n' + , (oCurVar.idGenTestCaseArgs, )); + else: + # Existing old entry, re-use the ID. + pass; + self._oDb.execute('INSERT INTO TestCaseArgs (\n' + ' idTestCaseArgs, idTestCase, uidAuthor, sArgs, cSecTimeout,\n' + ' sTestBoxReqExpr, sBuildReqExpr, cGangMembers, sSubName)\n' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)\n' + 'RETURNING idGenTestCaseArgs\n' + , ( oCurVar.idTestCaseArgs, oData.idTestCase, uidAuthor, oNewVar.sArgs, oNewVar.cSecTimeout, + oNewVar.sTestBoxReqExpr, oNewVar.sBuildReqExpr, oNewVar.cGangMembers, oNewVar.sSubName)); + oNewVar.idGenTestCaseArgs = self._oDb.fetchOne()[0]; + + self._oDb.maybeCommit(fCommit); + return True; + + def removeEntry(self, uidAuthor, idTestCase, fCascade = False, fCommit = False): + """ Deletes the test case if possible. """ + self._oDb.callProc('TestCaseLogic_delEntry', (uidAuthor, idTestCase, fCascade)); + self._oDb.maybeCommit(fCommit); + return True + + + def getTestCasePreReqIds(self, idTestCase, tsEffective = None, cMax = None): + """ + Returns an array of prerequisite testcases (IDs) for the given testcase. + May raise exception on database error or if the result exceeds cMax. + """ + if tsEffective is None: + self._oDb.execute('SELECT idTestCasePreReq\n' + 'FROM TestCaseDeps\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idTestCasePreReq\n' + , (idTestCase,) ); + else: + self._oDb.execute('SELECT idTestCasePreReq\n' + 'FROM TestCaseDeps\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY idTestCasePreReq\n' + , (idTestCase, tsEffective, tsEffective) ); + + + if cMax is not None and self._oDb.getRowCount() > cMax: + raise TMExceptionBase('Too many prerequisites for testcase %s: %s, max %s' + % (idTestCase, cMax, self._oDb.getRowCount(),)); + + aidPreReqs = []; + for aoRow in self._oDb.fetchAll(): + aidPreReqs.append(aoRow[0]); + return aidPreReqs; + + + def cachedLookup(self, idTestCase): + """ + Looks up the most recent TestCaseDataEx object for idTestCase + via an object cache. + + Returns a shared TestCaseDataEx object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('TestCaseDataEx'); + oEntry = self.dCache.get(idTestCase, None); + if oEntry is None: + fNeedTsNow = False; + self._oDb.execute('SELECT *\n' + 'FROM TestCases\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestCase, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM TestCases\n' + 'WHERE idTestCase = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idTestCase, )); + fNeedTsNow = True; + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idTestCase)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = TestCaseDataEx(); + tsNow = oEntry.initFromDbRow(aaoRow).tsEffective if fNeedTsNow else None; + oEntry.initFromDbRowEx(aaoRow, self._oDb, tsNow); + self.dCache[idTestCase] = oEntry; + return oEntry; + + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestCaseGlobalRsrcDepDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestCaseGlobalRsrcDepData(),]; + +class TestCaseDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestCaseData(),]; + + def testEmptyExpr(self): + self.assertEqual(TestCaseData.validateTestBoxReqExpr(None), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr(''), None); + + def testSimpleExpr(self): + self.assertEqual(TestCaseData.validateTestBoxReqExpr('cMbMemory > 10'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('cMbScratch < 10'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('fChipsetIoMmu'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('fChipsetIoMmu is True'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('fChipsetIoMmu is False'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('fChipsetIoMmu is None'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('isinstance(fChipsetIoMmu, bool)'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('isinstance(iTestBoxScriptRev, int)'), None); + self.assertEqual(TestCaseData.validateTestBoxReqExpr('isinstance(cMbScratch, long)'), None); + + def testBadExpr(self): + self.assertNotEqual(TestCaseData.validateTestBoxReqExpr('this is an bad expression, surely it must be'), None); + self.assertNotEqual(TestCaseData.validateTestBoxReqExpr('x = 1 + 1'), None); + self.assertNotEqual(TestCaseData.validateTestBoxReqExpr('__import__(\'os\').unlink(\'/tmp/no/such/file\')'), None); + self.assertNotEqual(TestCaseData.validateTestBoxReqExpr('print "foobar"'), None); + +class TestCaseDataExTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestCaseDataEx(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testcaseargs.py b/src/VBox/ValidationKit/testmanager/core/testcaseargs.py new file mode 100755 index 00000000..e63df090 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testcaseargs.py @@ -0,0 +1,416 @@ +# -*- coding: utf-8 -*- +# $Id: testcaseargs.py $ + +""" +Test Manager - Test Case Arguments Variations. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; +import sys; + +# Validation Kit imports. +from common import utils; +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMExceptionBase, \ + TMRowNotFound; +from testmanager.core.testcase import TestCaseData, TestCaseDependencyLogic, TestCaseGlobalRsrcDepLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + long = int; # pylint: disable=redefined-builtin,invalid-name + + +class TestCaseArgsData(ModelDataBase): + """ + Test case argument variation. + """ + + ksIdAttr = 'idTestCaseArgs'; + ksIdGenAttr = 'idGenTestCaseArgs'; + + ksParam_idTestCase = 'TestCaseArgs_idTestCase'; + ksParam_idTestCaseArgs = 'TestCaseArgs_idTestCaseArgs'; + ksParam_tsEffective = 'TestCaseArgs_tsEffective'; + ksParam_tsExpire = 'TestCaseArgs_tsExpire'; + ksParam_uidAuthor = 'TestCaseArgs_uidAuthor'; + ksParam_idGenTestCaseArgs = 'TestCaseArgs_idGenTestCaseArgs'; + ksParam_sArgs = 'TestCaseArgs_sArgs'; + ksParam_cSecTimeout = 'TestCaseArgs_cSecTimeout'; + ksParam_sTestBoxReqExpr = 'TestCaseArgs_sTestBoxReqExpr'; + ksParam_sBuildReqExpr = 'TestCaseArgs_sBuildReqExpr'; + ksParam_cGangMembers = 'TestCaseArgs_cGangMembers'; + ksParam_sSubName = 'TestCaseArgs_sSubName'; + + kcDbColumns = 12; + + kasAllowNullAttributes = [ 'idTestCase', 'idTestCaseArgs', 'tsEffective', 'tsExpire', 'uidAuthor', 'idGenTestCaseArgs', + 'cSecTimeout', 'sTestBoxReqExpr', 'sBuildReqExpr', 'sSubName', ]; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestCase = None; + self.idTestCaseArgs = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.idGenTestCaseArgs = None; + self.sArgs = ''; + self.cSecTimeout = None; + self.sTestBoxReqExpr = None; + self.sBuildReqExpr = None; + self.cGangMembers = 1; + self.sSubName = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the object from a SELECT * FROM TestCaseArgs row. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMRowNotFound('TestBoxStatus not found.'); + + self.idTestCase = aoRow[0]; + self.idTestCaseArgs = aoRow[1]; + self.tsEffective = aoRow[2]; + self.tsExpire = aoRow[3]; + self.uidAuthor = aoRow[4]; + self.idGenTestCaseArgs = aoRow[5]; + self.sArgs = aoRow[6]; + self.cSecTimeout = aoRow[7]; + self.sTestBoxReqExpr = aoRow[8]; + self.sBuildReqExpr = aoRow[9]; + self.cGangMembers = aoRow[10]; + self.sSubName = aoRow[11]; + return self; + + def initFromDbWithId(self, oDb, idTestCaseArgs, tsNow = None, sPeriodBack = None): + """ + Initialize from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCaseArgs = %s\n' + , ( idTestCaseArgs,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idTestCaseArgs=%s not found (tsNow=%s sPeriodBack=%s)' + % (idTestCaseArgs, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def initFromDbWithGenId(self, oDb, idGenTestCaseArgs): + """ + Initialize from the database, given the generation ID of a row. + """ + oDb.execute('SELECT * FROM TestCaseArgs WHERE idGenTestCaseArgs = %s', (idGenTestCaseArgs,)); + return self.initFromDbRow(oDb.fetchOne()); + + def initFromValues(self, sArgs, cSecTimeout = None, sTestBoxReqExpr = None, sBuildReqExpr = None, # pylint: disable=too-many-arguments + cGangMembers = 1, idTestCase = None, idTestCaseArgs = None, tsEffective = None, tsExpire = None, + uidAuthor = None, idGenTestCaseArgs = None, sSubName = None): + """ + Reinitialize from values. + Returns self. + """ + self.idTestCase = idTestCase; + self.idTestCaseArgs = idTestCaseArgs; + self.tsEffective = tsEffective; + self.tsExpire = tsExpire; + self.uidAuthor = uidAuthor; + self.idGenTestCaseArgs = idGenTestCaseArgs; + self.sArgs = sArgs; + self.cSecTimeout = utils.parseIntervalSeconds(cSecTimeout); + self.sTestBoxReqExpr = sTestBoxReqExpr; + self.sBuildReqExpr = sBuildReqExpr; + self.cGangMembers = cGangMembers; + self.sSubName = sSubName; + return self; + + def getAttributeParamNullValues(self, sAttr): + aoNilValues = ModelDataBase.getAttributeParamNullValues(self, sAttr); + if sAttr == 'cSecTimeout': + aoNilValues.insert(0, ''); # Prettier NULL value for cSecTimeout. + elif sAttr == 'sArgs': + aoNilValues = []; # No NULL value here, thank you. + return aoNilValues; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + if sAttr == 'cSecTimeout' and oValue not in aoNilValues: # Allow human readable interval formats. + return utils.parseIntervalSeconds(oValue); + + (oValue, sError) = ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + if sError is None: + if sAttr == 'sTestBoxReqExpr': + sError = TestCaseData.validateTestBoxReqExpr(oValue); + elif sAttr == 'sBuildReqExpr': + sError = TestCaseData.validateBuildReqExpr(oValue); + return (oValue, sError); + + + + +class TestCaseArgsDataEx(TestCaseArgsData): + """ + Complete data set. + """ + + def __init__(self): + TestCaseArgsData.__init__(self); + self.oTestCase = None; + self.aoTestCasePreReqs = []; + self.aoGlobalRsrc = []; + + def initFromDbRow(self, aoRow): + raise TMExceptionBase('Do not call me: %s' % (aoRow,)) + + def initFromDbRowEx(self, aoRow, oDb, tsConfigEff = None, tsRsrcEff = None): + """ + Extended version of initFromDbRow that fills in the rest from the database. + """ + TestCaseArgsData.initFromDbRow(self, aoRow); + + if tsConfigEff is None: tsConfigEff = oDb.getCurrentTimestamp(); + if tsRsrcEff is None: tsRsrcEff = oDb.getCurrentTimestamp(); + + self.oTestCase = TestCaseData().initFromDbWithId(oDb, self.idTestCase, tsConfigEff); + self.aoTestCasePreReqs = TestCaseDependencyLogic(oDb).getTestCaseDeps(self.idTestCase, tsConfigEff); + self.aoGlobalRsrc = TestCaseGlobalRsrcDepLogic(oDb).getTestCaseDeps(self.idTestCase, tsRsrcEff); + + return self; + + def initFromDbWithId(self, oDb, idTestCaseArgs, tsNow = None, sPeriodBack = None): + _ = oDb; _ = idTestCaseArgs; _ = tsNow; _ = sPeriodBack; + raise TMExceptionBase('Not supported.'); + + def initFromDbWithGenId(self, oDb, idGenTestCaseArgs): + _ = oDb; _ = idGenTestCaseArgs; + raise TMExceptionBase('Use initFromDbWithGenIdEx...'); + + def initFromDbWithGenIdEx(self, oDb, idGenTestCaseArgs, tsConfigEff = None, tsRsrcEff = None): + """ + Initialize from the database, given the ID of a row. + """ + oDb.execute('SELECT *, CURRENT_TIMESTAMP FROM TestCaseArgs WHERE idGenTestCaseArgs = %s', (idGenTestCaseArgs,)); + aoRow = oDb.fetchOne(); + return self.initFromDbRowEx(aoRow, oDb, tsConfigEff, tsRsrcEff); + + def convertFromParamNull(self): + raise TMExceptionBase('Not implemented'); + + def convertToParamNull(self): + raise TMExceptionBase('Not implemented'); + + def isEqual(self, oOther): + raise TMExceptionBase('Not implemented'); + + def matchesTestBoxProps(self, oTestBoxData): + """ + Checks if the all of the testbox related test requirements matches the + given testbox. + + Returns True or False according to the expression, None on exception or + non-boolean expression result. + """ + return TestCaseData.matchesTestBoxPropsEx(oTestBoxData, self.oTestCase.sTestBoxReqExpr) \ + and TestCaseData.matchesTestBoxPropsEx(oTestBoxData, self.sTestBoxReqExpr); + + def matchesBuildProps(self, oBuildDataEx): + """ + Checks if the all of the build related test requirements matches the + given build. + + Returns True or False according to the expression, None on exception or + non-boolean expression result. + """ + return TestCaseData.matchesBuildPropsEx(oBuildDataEx, self.oTestCase.sBuildReqExpr) \ + and TestCaseData.matchesBuildPropsEx(oBuildDataEx, self.sBuildReqExpr); + + +class TestCaseArgsLogic(ModelLogicBase): + """ + TestCaseArgs database logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb); + self.dCache = None; + + + def areResourcesFree(self, oDataEx): + """ + Checks if all global resources are currently still in existance and free. + Returns True/False. May raise exception on database error. + """ + + # Create a set of global resource IDs. + if not oDataEx.aoGlobalRsrc: + return True; + asIdRsrcs = [str(oDep.idGlobalRsrc) for oDep, _ in oDataEx.aoGlobalRsrc]; + + # A record in the resource status table means it's allocated. + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM GlobalResourceStatuses\n' + 'WHERE GlobalResourceStatuses.idGlobalRsrc IN (' + ', '.join(asIdRsrcs) + ')\n'); + if self._oDb.fetchOne()[0] == 0: + # Check for disabled or deleted resources (we cannot allocate them). + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM GlobalResources\n' + 'WHERE GlobalResources.idGlobalRsrc IN (' + ', '.join(asIdRsrcs) + ')\n' + ' AND GlobalResources.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND GlobalResources.fEnabled = TRUE\n'); + if self._oDb.fetchOne()[0] == len(oDataEx.aoGlobalRsrc): + return True; + return False; + + def getAll(self): + """Get list of objects of type TestCaseArgsData""" + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP') + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestCaseArgsData().initFromDbRow(aoRow)) + + return aoRet + + def getTestCaseArgs(self, idTestCase, tsNow = None, aiWhiteList = None): + """Get list of testcase's arguments variations""" + if aiWhiteList is None: + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY TestCaseArgs.idTestCaseArgs\n' + , (idTestCase,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY TestCaseArgs.idTestCaseArgs\n' + , (idTestCase, tsNow, tsNow)); + else: + sWhiteList = ','.join((str(x) for x in aiWhiteList)); + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND idTestCaseArgs IN (' + sWhiteList + ')\n' + 'ORDER BY TestCaseArgs.idTestCaseArgs\n' + , (idTestCase,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCase = %s\n' + ' AND tsExpire > %s\n' + ' AND tsEffective <= %s\n' + ' AND idTestCaseArgs IN (' + sWhiteList + ')\n' + 'ORDER BY TestCaseArgs.idTestCaseArgs\n' + , (idTestCase, tsNow, tsNow)); + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestCaseArgsData().initFromDbRow(aoRow)) + + return aoRet + + def addTestCaseArgs(self, oTestCaseArgsData): + """Add Test Case Args record into DB""" + pass; # pylint: disable=unnecessary-pass + + def cachedLookup(self, idTestCaseArgs): + """ + Looks up the most recent TestCaseArgsDataEx object for idTestCaseArg + via in an object cache. + + Returns a shared TestCaseArgDataEx object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('TestCaseArgsDataEx'); + oEntry = self.dCache.get(idTestCaseArgs, None); + if oEntry is None: + fNeedTsNow = False; + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCaseArgs = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestCaseArgs, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM TestCaseArgs\n' + 'WHERE idTestCaseArgs = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idTestCaseArgs, )); + fNeedTsNow = True; + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idTestCaseArgs)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = TestCaseArgsDataEx(); + tsNow = TestCaseArgsData().initFromDbRow(aaoRow).tsEffective if fNeedTsNow else None; + oEntry.initFromDbRowEx(aaoRow, self._oDb, tsNow, tsNow); + self.dCache[idTestCaseArgs] = oEntry; + return oEntry; + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestCaseArgsDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestCaseArgsData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testgroup.py b/src/VBox/ValidationKit/testmanager/core/testgroup.py new file mode 100755 index 00000000..9a648b0d --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testgroup.py @@ -0,0 +1,771 @@ +# -*- coding: utf-8 -*- +# $Id: testgroup.py $ + +""" +Test Manager - Test groups management. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMRowInUse, \ + TMTooManyRows, TMInvalidData, TMRowNotFound, TMRowAlreadyExists; +from testmanager.core.testcase import TestCaseData, TestCaseDataEx; + + +class TestGroupMemberData(ModelDataBase): + """Representation of a test group member database row.""" + + ksParam_idTestGroup = 'TestGroupMember_idTestGroup'; + ksParam_idTestCase = 'TestGroupMember_idTestCase'; + ksParam_tsEffective = 'TestGroupMember_tsEffective'; + ksParam_tsExpire = 'TestGroupMember_tsExpire'; + ksParam_uidAuthor = 'TestGroupMember_uidAuthor'; + ksParam_iSchedPriority = 'TestGroupMember_iSchedPriority'; + ksParam_aidTestCaseArgs = 'TestGroupMember_aidTestCaseArgs'; + + kasAllowNullAttributes = ['idTestGroup', 'idTestCase', 'tsEffective', 'tsExpire', 'uidAuthor', 'aidTestCaseArgs' ]; + kiMin_iSchedPriority = 0; + kiMax_iSchedPriority = 31; + + kcDbColumns = 7; + + def __init__(self): + ModelDataBase.__init__(self) + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestGroup = None; + self.idTestCase = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.iSchedPriority = 16; + self.aidTestCaseArgs = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestCaseGroupMembers. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test group member not found.') + + self.idTestGroup = aoRow[0]; + self.idTestCase = aoRow[1]; + self.tsEffective = aoRow[2]; + self.tsExpire = aoRow[3]; + self.uidAuthor = aoRow[4]; + self.iSchedPriority = aoRow[5]; + self.aidTestCaseArgs = aoRow[6]; + return self + + + def getAttributeParamNullValues(self, sAttr): + # Arrays default to [] as NULL currently. That doesn't work for us. + if sAttr == 'aidTestCaseArgs': + aoNilValues = [None, '-1']; + else: + aoNilValues = ModelDataBase.getAttributeParamNullValues(self, sAttr); + return aoNilValues; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + if sAttr != 'aidTestCaseArgs': + return ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + # -1 is a special value, which when present make the whole thing NULL (None). + (aidVariations, sError) = self.validateListOfInts(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull, + iMin = -1, iMax = 0x7ffffffe); + if sError is None: + if aidVariations is None: + pass; + elif -1 in aidVariations: + aidVariations = None; + elif 0 in aidVariations: + sError = 'Invalid test case varation ID #0.'; + else: + aidVariations = sorted(aidVariations); + return (aidVariations, sError); + + + +class TestGroupMemberDataEx(TestGroupMemberData): + """Extended representation of a test group member.""" + + def __init__(self): + """Extend parent class""" + TestGroupMemberData.__init__(self) + self.oTestCase = None; # TestCaseDataEx. + + def initFromDbRowEx(self, aoRow, oDb, tsNow = None): + """ + Reinitialize from a SELECT * FROM TestGroupMembers, TestCases row. + Will query the necessary additional data from oDb using tsNow. + + Returns self. Raises exception if no row or database error. + """ + TestGroupMemberData.initFromDbRow(self, aoRow); + self.oTestCase = TestCaseDataEx(); + self.oTestCase.initFromDbRowEx(aoRow[TestGroupMemberData.kcDbColumns:], oDb, tsNow); + return self; + + def initFromParams(self, oDisp, fStrict = True): + self.oTestCase = None; + return TestGroupMemberData.initFromParams(self, oDisp, fStrict); + + def getDataAttributes(self): + asAttributes = TestGroupMemberData.getDataAttributes(self); + asAttributes.remove('oTestCase'); + return asAttributes; + + def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ModelDataBase.ksValidateFor_Other): + dErrors = TestGroupMemberData._validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor); + if self.ksParam_idTestCase not in dErrors: + self.oTestCase = TestCaseDataEx() + try: + self.oTestCase.initFromDbWithId(oDb, self.idTestCase); + except Exception as oXcpt: + self.oTestCase = TestCaseDataEx() + dErrors[self.ksParam_idTestCase] = str(oXcpt); + return dErrors; + + +class TestGroupMemberData2(TestCaseData): + """Special representation of a Test Group Member item""" + + def __init__(self): + """Extend parent class""" + TestCaseData.__init__(self) + self.idTestGroup = None + self.aidTestCaseArgs = [] + + def initFromDbRowEx(self, aoRow): + """ + Reinitialize from this query: + + SELECT TestCases.*, + TestGroupMembers.idTestGroup, + TestGroupMembers.aidTestCaseArgs + FROM TestCases, TestGroupMembers + WHERE TestCases.idTestCase = TestGroupMembers.idTestCase + + Represents complete test group member (test case) info. + Returns object of type TestGroupMemberData2. Raises exception if no row. + """ + TestCaseData.initFromDbRow(self, aoRow); + self.idTestGroup = aoRow[-2] + self.aidTestCaseArgs = aoRow[-1] + return self; + + +class TestGroupData(ModelDataBase): + """ + Test group data. + """ + + ksIdAttr = 'idTestGroup'; + + ksParam_idTestGroup = 'TestGroup_idTestGroup' + ksParam_tsEffective = 'TestGroup_tsEffective' + ksParam_tsExpire = 'TestGroup_tsExpire' + ksParam_uidAuthor = 'TestGroup_uidAuthor' + ksParam_sName = 'TestGroup_sName' + ksParam_sDescription = 'TestGroup_sDescription' + ksParam_sComment = 'TestGroup_sComment' + + kasAllowNullAttributes = ['idTestGroup', 'tsEffective', 'tsExpire', 'uidAuthor', 'sDescription', 'sComment' ]; + + kcDbColumns = 7; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestGroup = None + self.tsEffective = None + self.tsExpire = None + self.uidAuthor = None + self.sName = None + self.sDescription = None + self.sComment = None + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestGroups row. + Returns object of type TestGroupData. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test group not found.') + + self.idTestGroup = aoRow[0] + self.tsEffective = aoRow[1] + self.tsExpire = aoRow[2] + self.uidAuthor = aoRow[3] + self.sName = aoRow[4] + self.sDescription = aoRow[5] + self.sComment = aoRow[6] + return self + + def initFromDbWithId(self, oDb, idTestGroup, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM TestGroups\n' + 'WHERE idTestGroup = %s\n' + , ( idTestGroup,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idTestGroup=%s not found (tsNow=%s sPeriodBack=%s)' % (idTestGroup, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + +class TestGroupDataEx(TestGroupData): + """ + Extended test group data. + """ + + ksParam_aoMembers = 'TestGroupDataEx_aoMembers'; + kasAltArrayNull = [ 'aoMembers', ]; + + ## Helper parameter containing the comma separated list with the IDs of + # potential members found in the parameters. + ksParam_aidTestCases = 'TestGroupDataEx_aidTestCases'; + + + def __init__(self): + TestGroupData.__init__(self); + self.aoMembers = []; # TestGroupMemberDataEx. + + def _initExtraMembersFromDb(self, oDb, tsNow = None, sPeriodBack = None): + """ + Worker shared by the initFromDb* methods. + Returns self. Raises exception if no row or database error. + """ + self.aoMembers = []; + _ = sPeriodBack; ## @todo sPeriodBack + + if tsNow is None: + oDb.execute('SELECT TestGroupMembers.*, TestCases.*\n' + 'FROM TestGroupMembers\n' + 'LEFT OUTER JOIN TestCases ON (\n' + ' TestGroupMembers.idTestCase = TestCases.idTestCase\n' + ' AND TestCases.tsExpire = \'infinity\'::TIMESTAMP)\n' + 'WHERE TestGroupMembers.idTestGroup = %s\n' + ' AND TestGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY TestCases.sName, TestCases.idTestCase\n' + , (self.idTestGroup,)); + else: + oDb.execute('SELECT TestGroupMembers.*, TestCases.*\n' + 'FROM TestGroupMembers\n' + 'LEFT OUTER JOIN TestCases ON (\n' + ' TestGroupMembers.idTestCase = TestCases.idTestCase\n' + ' AND TestCases.tsExpire > %s\n' + ' AND TestCases.tsEffective <= %s)\n' + 'WHERE TestGroupMembers.idTestGroup = %s\n' + ' AND TestGroupMembers.tsExpire > %s\n' + ' AND TestGroupMembers.tsEffective <= %s\n' + 'ORDER BY TestCases.sName, TestCases.idTestCase\n' + , (tsNow, tsNow, self.idTestGroup, tsNow, tsNow)); + + for aoRow in oDb.fetchAll(): + self.aoMembers.append(TestGroupMemberDataEx().initFromDbRowEx(aoRow, oDb, tsNow)); + return self; + + def initFromDbRowEx(self, aoRow, oDb, tsNow = None, sPeriodBack = None): + """ + Reinitialize from a SELECT * FROM TestGroups row. Will query the + necessary additional data from oDb using tsNow. + Returns self. Raises exception if no row or database error. + """ + TestGroupData.initFromDbRow(self, aoRow); + return self._initExtraMembersFromDb(oDb, tsNow, sPeriodBack); + + def initFromDbWithId(self, oDb, idTestGroup, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + TestGroupData.initFromDbWithId(self, oDb, idTestGroup, tsNow, sPeriodBack); + return self._initExtraMembersFromDb(oDb, tsNow, sPeriodBack); + + + def getAttributeParamNullValues(self, sAttr): + if sAttr != 'aoMembers': + return TestGroupData.getAttributeParamNullValues(self, sAttr); + return ['', [], None]; + + def convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict): + if sAttr != 'aoMembers': + return TestGroupData.convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict); + + aoNewValue = []; + aidSelected = oDisp.getListOfIntParams(sParam, iMin = 1, iMax = 0x7ffffffe, aiDefaults = []) + sIds = oDisp.getStringParam(self.ksParam_aidTestCases, sDefault = ''); + for idTestCase in sIds.split(','): + try: idTestCase = int(idTestCase); + except: pass; + oDispWrapper = self.DispWrapper(oDisp, '%s[%s][%%s]' % (TestGroupDataEx.ksParam_aoMembers, idTestCase,)) + oMember = TestGroupMemberDataEx().initFromParams(oDispWrapper, fStrict = False); + if idTestCase in aidSelected: + aoNewValue.append(oMember); + return aoNewValue; + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + if sAttr != 'aoMembers': + return TestGroupData._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + asErrors = []; + aoNewMembers = []; + for oOldMember in oValue: + oNewMember = TestGroupMemberDataEx().initFromOther(oOldMember); + aoNewMembers.append(oNewMember); + + dErrors = oNewMember.validateAndConvert(oDb, ModelDataBase.ksValidateFor_Other); + if dErrors: + asErrors.append(str(dErrors)); + + if not asErrors: + for i, _ in enumerate(aoNewMembers): + idTestCase = aoNewMembers[i]; + for j in range(i + 1, len(aoNewMembers)): + if aoNewMembers[j].idTestCase == idTestCase: + asErrors.append('Duplicate testcase #%d!' % (idTestCase, )); + break; + + return (aoNewMembers, None if not asErrors else '
\n'.join(asErrors)); + + +class TestGroupLogic(ModelLogicBase): + """ + Test case management logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + # + # Standard methods. + # + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches test groups. + + Returns an array (list) of TestGroupDataEx items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sName ASC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sName ASC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)); + + aoRet = []; + for aoRow in self._oDb.fetchAll(): + aoRet.append(TestGroupDataEx().initFromDbRowEx(aoRow, self._oDb, tsNow)); + return aoRet; + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Adds a testgroup to the database. + """ + + # + # Validate inputs. + # + assert isinstance(oData, TestGroupDataEx); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dErrors: + raise TMInvalidData('addEntry invalid input: %s' % (dErrors,)); + self._assertUniq(oData, None); + + # + # Do the job. + # + self._oDb.execute('INSERT INTO TestGroups (uidAuthor, sName, sDescription, sComment)\n' + 'VALUES (%s, %s, %s, %s)\n' + 'RETURNING idTestGroup\n' + , ( uidAuthor, + oData.sName, + oData.sDescription, + oData.sComment )); + idTestGroup = self._oDb.fetchOne()[0]; + oData.idTestGroup = idTestGroup; + + for oMember in oData.aoMembers: + oMember.idTestGroup = idTestGroup; + self._insertTestGroupMember(uidAuthor, oMember) + + self._oDb.maybeCommit(fCommit); + return True; + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Modifies a test group. + """ + + # + # Validate inputs and read in the old(/current) data. + # + assert isinstance(oData, TestGroupDataEx); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + self._assertUniq(oData, oData.idTestGroup); + + oOldData = TestGroupDataEx().initFromDbWithId(self._oDb, oData.idTestGroup); + + # + # Update the data that needs updating. + # + + if not oData.isEqualEx(oOldData, [ 'aoMembers', 'tsEffective', 'tsExpire', 'uidAuthor', ]): + self._historizeTestGroup(oData.idTestGroup); + self._oDb.execute('INSERT INTO TestGroups\n' + ' (uidAuthor, idTestGroup, sName, sDescription, sComment)\n' + 'VALUES (%s, %s, %s, %s, %s)\n' + , ( uidAuthor, + oData.idTestGroup, + oData.sName, + oData.sDescription, + oData.sComment )); + + # Create a lookup dictionary for old entries. + dOld = {}; + for oOld in oOldData.aoMembers: + dOld[oOld.idTestCase] = oOld; + assert len(dOld) == len(oOldData.aoMembers); + + # Add new members, updated existing ones. + dNew = {}; + for oNewMember in oData.aoMembers: + oNewMember.idTestGroup = oData.idTestGroup; + if oNewMember.idTestCase in dNew: + raise TMRowAlreadyExists('Duplicate test group member: idTestCase=%d (%s / %s)' + % (oNewMember.idTestCase, oNewMember, dNew[oNewMember.idTestCase],)); + dNew[oNewMember.idTestCase] = oNewMember; + + oOldMember = dOld.get(oNewMember.idTestCase, None); + if oOldMember is not None: + if oNewMember.isEqualEx(oOldMember, [ 'uidAuthor', 'tsEffective', 'tsExpire' ]): + continue; # Skip, nothing changed. + self._historizeTestGroupMember(oData.idTestGroup, oNewMember.idTestCase); + self._insertTestGroupMember(uidAuthor, oNewMember); + + # Expire members that have been removed. + sQuery = self._oDb.formatBindArgs('UPDATE TestGroupMembers\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( oData.idTestGroup, )); + if dNew: + sQuery += ' AND idTestCase NOT IN (%s)' % (', '.join([str(iKey) for iKey in dNew]),); + self._oDb.execute(sQuery); + + self._oDb.maybeCommit(fCommit); + return True; + + def removeEntry(self, uidAuthor, idTestGroup, fCascade = False, fCommit = False): + """ + Deletes a test group. + """ + _ = uidAuthor; ## @todo record uidAuthor. + + # + # Cascade. + # + if fCascade is not True: + self._oDb.execute('SELECT SchedGroups.idSchedGroup, SchedGroups.sName\n' + 'FROM SchedGroupMembers, SchedGroups\n' + 'WHERE SchedGroupMembers.idTestGroup = %s\n' + ' AND SchedGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND SchedGroups.idSchedGroup = SchedGroupMembers.idSchedGroup\n' + ' AND SchedGroups.tsExpire = \'infinity\'::TIMESTAMP\n' + , ( idTestGroup, )); + aoGroups = self._oDb.fetchAll(); + if aoGroups: + asGroups = ['%s (#%d)' % (sName, idSchedGroup) for idSchedGroup, sName in aoGroups]; + raise TMRowInUse('Test group #%d is member of one or more scheduling groups: %s' + % (idTestGroup, ', '.join(asGroups),)); + else: + self._oDb.execute('UPDATE SchedGroupMembers\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( idTestGroup, )); + + # + # Remove the group. + # + self._oDb.execute('UPDATE TestGroupMembers\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestGroup,)) + self._oDb.execute('UPDATE TestGroups\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestGroup,)) + + self._oDb.maybeCommit(fCommit) + return True; + + def cachedLookup(self, idTestGroup): + """ + Looks up the most recent TestGroupDataEx object for idTestGroup + via an object cache. + + Returns a shared TestGroupDataEx object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('TestGroupDataEx'); + oEntry = self.dCache.get(idTestGroup, None); + if oEntry is None: + fNeedTsNow = False; + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE idTestGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestGroup, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE idTestGroup = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (idTestGroup, )); + fNeedTsNow = True; + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idTestGroup)); + + if self._oDb.getRowCount() == 1: + aaoRow = self._oDb.fetchOne(); + oEntry = TestGroupDataEx(); + tsNow = oEntry.initFromDbRow(aaoRow).tsEffective if fNeedTsNow else None; + oEntry.initFromDbRowEx(aaoRow, self._oDb, tsNow); + self.dCache[idTestGroup] = oEntry; + return oEntry; + + + # + # Other methods. + # + + def fetchOrderedByName(self, tsNow = None): + """ + Return list of objects of type TestGroupData ordered by name. + May raise exception on database error. + """ + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sName ASC\n'); + else: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sName ASC\n' + , (tsNow, tsNow,)); + aoRet = [] + for _ in range(self._oDb.getRowCount()): + aoRet.append(TestGroupData().initFromDbRow(self._oDb.fetchOne())); + return aoRet; + + def getMembers(self, idTestGroup): + """ + Fetches all test case records from DB which are + belong to current Test Group. + Returns list of objects of type TestGroupMemberData2 (!). + """ + self._oDb.execute('SELECT TestCases.*,\n' + ' TestGroupMembers.idTestGroup,\n' + ' TestGroupMembers.aidTestCaseArgs\n' + 'FROM TestCases, TestGroupMembers\n' + 'WHERE TestCases.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestGroupMembers.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestGroupMembers.idTestCase = TestCases.idTestCase\n' + ' AND TestGroupMembers.idTestGroup = %s\n' + 'ORDER BY TestCases.idTestCase ASC;', + (idTestGroup,)) + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestGroupMemberData2().initFromDbRowEx(aoRow)) + + return aoRet + + def getAll(self, tsNow=None): + """Return list of objects of type TestGroupData""" + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY idTestGroup ASC;') + else: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY idTestGroup ASC;', + (tsNow, tsNow)) + + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestGroupData().initFromDbRow(aoRow)) + + return aoRet + + def getById(self, idTestGroup, tsNow=None): + """Get Test Group data by its ID""" + + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire = \'infinity\'::timestamp\n' + ' AND idTestGroup = %s\n' + 'ORDER BY idTestGroup ASC;' + , (idTestGroup,)) + else: + self._oDb.execute('SELECT *\n' + 'FROM TestGroups\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + ' AND idTestGroup = %s\n' + 'ORDER BY idTestGroup ASC;' + , (tsNow, tsNow, idTestGroup)) + + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise TMTooManyRows('Found more than one test groups with the same credentials. Database structure is corrupted.') + try: + return TestGroupData().initFromDbRow(aRows[0]) + except IndexError: + return None + + # + # Helpers. + # + + def _assertUniq(self, oData, idTestGroupIgnore): + """ Checks that the test group name is unique, raises exception if it isn't. """ + self._oDb.execute('SELECT idTestGroup\n' + 'FROM TestGroups\n' + 'WHERE sName = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + + ('' if idTestGroupIgnore is None else ' AND idTestGroup <> %d\n' % (idTestGroupIgnore,)) + , ( oData.sName, )) + if self._oDb.getRowCount() > 0: + raise TMRowAlreadyExists('A Test group with name "%s" already exist.' % (oData.sName,)); + return True; + + def _historizeTestGroup(self, idTestGroup): + """ Historize Test Group record. """ + self._oDb.execute('UPDATE TestGroups\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestGroup = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( idTestGroup, )); + return True; + + def _historizeTestGroupMember(self, idTestGroup, idTestCase): + """ Historize Test Group Member record. """ + self._oDb.execute('UPDATE TestGroupMembers\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestGroup = %s\n' + ' AND idTestCase = %s\n' + ' AND tsExpire = \'infinity\'::timestamp\n' + , (idTestGroup, idTestCase,)); + return True; + + def _insertTestGroupMember(self, uidAuthor, oMember): + """ Inserts a test group member. """ + self._oDb.execute('INSERT INTO TestGroupMembers\n' + ' (uidAuthor, idTestGroup, idTestCase, iSchedPriority, aidTestCaseArgs)\n' + 'VALUES (%s, %s, %s, %s, %s)\n' + , ( uidAuthor, + oMember.idTestGroup, + oMember.idTestCase, + oMember.iSchedPriority, + oMember.aidTestCaseArgs, )); + return True; + + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestGroupMemberDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestGroupMemberData(),]; + +class TestGroupDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestGroupData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testresultfailures.py b/src/VBox/ValidationKit/testmanager/core/testresultfailures.py new file mode 100755 index 00000000..b94ff7d5 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testresultfailures.py @@ -0,0 +1,529 @@ +# -*- coding: utf-8 -*- +# $Id: testresultfailures.py $ +# pylint: disable=too-many-lines + +## @todo Rename this file to testresult.py! + +""" +Test Manager - Test result failures. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + +# Standard python imports. +import sys; +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelLogicBase, ModelDataBaseTestCase, TMInvalidData, TMRowNotFound, \ + TMRowAlreadyExists, ChangeLogEntry, AttributeChangeEntry; +from testmanager.core.failurereason import FailureReasonData; +from testmanager.core.useraccount import UserAccountLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + xrange = range; # pylint: disable=redefined-builtin,invalid-name + + +class TestResultFailureData(ModelDataBase): + """ + Test result failure reason data. + """ + + ksIdAttr = 'idTestResult'; + kfIdAttrIsForForeign = True; # Modifies the 'add' validation. + + ksParam_idTestResult = 'TestResultFailure_idTestResult'; + ksParam_tsEffective = 'TestResultFailure_tsEffective'; + ksParam_tsExpire = 'TestResultFailure_tsExpire'; + ksParam_uidAuthor = 'TestResultFailure_uidAuthor'; + ksParam_idTestSet = 'TestResultFailure_idTestSet'; + ksParam_idFailureReason = 'TestResultFailure_idFailureReason'; + ksParam_sComment = 'TestResultFailure_sComment'; + + kasAllowNullAttributes = ['tsEffective', 'tsExpire', 'uidAuthor', 'sComment', 'idTestSet' ]; + + kcDbColumns = 7; + + def __init__(self): + ModelDataBase.__init__(self) + self.idTestResult = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.idTestSet = None; + self.idFailureReason = None; + self.sComment = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestResultFailures. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test result file record not found.') + + self.idTestResult = aoRow[0]; + self.tsEffective = aoRow[1]; + self.tsExpire = aoRow[2]; + self.uidAuthor = aoRow[3]; + self.idTestSet = aoRow[4]; + self.idFailureReason = aoRow[5]; + self.sComment = aoRow[6]; + return self; + + def initFromDbWithId(self, oDb, idTestResult, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM TestResultFailures\n' + 'WHERE idTestResult = %s\n' + , ( idTestResult,), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idTestResult=%s not found (tsNow=%s, sPeriodBack=%s)' % (idTestResult, tsNow, sPeriodBack)); + assert len(aoRow) == self.kcDbColumns; + return self.initFromDbRow(aoRow); + + def initFromValues(self, idTestResult, idFailureReason, uidAuthor, + tsExpire = None, tsEffective = None, idTestSet = None, sComment = None): + """ + Initialize from values. + """ + self.idTestResult = idTestResult; + self.tsEffective = tsEffective; + self.tsExpire = tsExpire; + self.uidAuthor = uidAuthor; + self.idTestSet = idTestSet; + self.idFailureReason = idFailureReason; + self.sComment = sComment; + return self; + + + +class TestResultFailureDataEx(TestResultFailureData): + """ + Extends TestResultFailureData by resolving reasons and user. + """ + + def __init__(self): + TestResultFailureData.__init__(self); + self.oFailureReason = None; + self.oAuthor = None; + + def initFromDbRowEx(self, aoRow, oFailureReasonLogic, oUserAccountLogic): + """ + Reinitialize from a SELECT * FROM TestResultFailures. + Return self. Raises exception if no row. + """ + self.initFromDbRow(aoRow); + self.oFailureReason = oFailureReasonLogic.cachedLookup(self.idFailureReason); + self.oAuthor = oUserAccountLogic.cachedLookup(self.uidAuthor); + return self; + + +class TestResultListingData(ModelDataBase): # pylint: disable=too-many-instance-attributes + """ + Test case result data representation for table listing + """ + + def __init__(self): + """Initialize""" + ModelDataBase.__init__(self) + + self.idTestSet = None + + self.idBuildCategory = None; + self.sProduct = None + self.sRepository = None; + self.sBranch = None + self.sType = None + self.idBuild = None; + self.sVersion = None; + self.iRevision = None + + self.sOs = None; + self.sOsVersion = None; + self.sArch = None; + self.sCpuVendor = None; + self.sCpuName = None; + self.cCpus = None; + self.fCpuHwVirt = None; + self.fCpuNestedPaging = None; + self.fCpu64BitGuest = None; + self.idTestBox = None + self.sTestBoxName = None + + self.tsCreated = None + self.tsElapsed = None + self.enmStatus = None + self.cErrors = None; + + self.idTestCase = None + self.sTestCaseName = None + self.sBaseCmd = None + self.sArgs = None + self.sSubName = None; + + self.idBuildTestSuite = None; + self.iRevisionTestSuite = None; + + self.oFailureReason = None; + self.oFailureReasonAssigner = None; + self.tsFailureReasonAssigned = None; + self.sFailureReasonComment = None; + + def initFromDbRowEx(self, aoRow, oFailureReasonLogic, oUserAccountLogic): + """ + Reinitialize from a database query. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test result record not found.') + + self.idTestSet = aoRow[0]; + + self.idBuildCategory = aoRow[1]; + self.sProduct = aoRow[2]; + self.sRepository = aoRow[3]; + self.sBranch = aoRow[4]; + self.sType = aoRow[5]; + self.idBuild = aoRow[6]; + self.sVersion = aoRow[7]; + self.iRevision = aoRow[8]; + + self.sOs = aoRow[9]; + self.sOsVersion = aoRow[10]; + self.sArch = aoRow[11]; + self.sCpuVendor = aoRow[12]; + self.sCpuName = aoRow[13]; + self.cCpus = aoRow[14]; + self.fCpuHwVirt = aoRow[15]; + self.fCpuNestedPaging = aoRow[16]; + self.fCpu64BitGuest = aoRow[17]; + self.idTestBox = aoRow[18]; + self.sTestBoxName = aoRow[19]; + + self.tsCreated = aoRow[20]; + self.tsElapsed = aoRow[21]; + self.enmStatus = aoRow[22]; + self.cErrors = aoRow[23]; + + self.idTestCase = aoRow[24]; + self.sTestCaseName = aoRow[25]; + self.sBaseCmd = aoRow[26]; + self.sArgs = aoRow[27]; + self.sSubName = aoRow[28]; + + self.idBuildTestSuite = aoRow[29]; + self.iRevisionTestSuite = aoRow[30]; + + self.oFailureReason = None; + if aoRow[31] is not None: + self.oFailureReason = oFailureReasonLogic.cachedLookup(aoRow[31]); + self.oFailureReasonAssigner = None; + if aoRow[32] is not None: + self.oFailureReasonAssigner = oUserAccountLogic.cachedLookup(aoRow[32]); + self.tsFailureReasonAssigned = aoRow[33]; + self.sFailureReasonComment = aoRow[34]; + + return self + + + +class TestResultFailureLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Test result failure reason logic. + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + + def fetchForChangeLog(self, idTestResult, iStart, cMaxRows, tsNow): # pylint: disable=too-many-locals + """ + Fetches change log entries for a failure reason. + + Returns an array of ChangeLogEntry instance and an indicator whether + there are more entries. + Raises exception on error. + """ + + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + + # 1. Get a list of the changes from both TestResultFailures and assoicated + # FailureReasons. The latter is useful since the failure reason + # description may evolve along side the invidiual failure analysis. + self._oDb.execute('( SELECT trf.tsEffective AS tsEffectiveChangeLog,\n' + ' trf.uidAuthor AS uidAuthorChangeLog,\n' + ' trf.*,\n' + ' fr.*\n' + ' FROM TestResultFailures trf,\n' + ' FailureReasons fr\n' + ' WHERE trf.idTestResult = %s\n' + ' AND trf.tsEffective <= %s\n' + ' AND trf.idFailureReason = fr.idFailureReason\n' + ' AND fr.tsEffective <= trf.tsEffective\n' + ' AND fr.tsExpire > trf.tsEffective\n' + ')\n' + 'UNION\n' + '( SELECT fr.tsEffective AS tsEffectiveChangeLog,\n' + ' fr.uidAuthor AS uidAuthorChangeLog,\n' + ' trf.*,\n' + ' fr.*\n' + ' FROM TestResultFailures trf,\n' + ' FailureReasons fr\n' + ' WHERE trf.idTestResult = %s\n' + ' AND trf.tsEffective <= %s\n' + ' AND trf.idFailureReason = fr.idFailureReason\n' + ' AND fr.tsEffective > trf.tsEffective\n' + ' AND fr.tsEffective < trf.tsExpire\n' + ')\n' + 'ORDER BY tsEffectiveChangeLog DESC\n' + 'LIMIT %s OFFSET %s\n' + , ( idTestResult, tsNow, idTestResult, tsNow, cMaxRows + 1, iStart, )); + + aaoRows = []; + for aoChange in self._oDb.fetchAll(): + oTrf = TestResultFailureDataEx().initFromDbRow(aoChange[2:]); + oFr = FailureReasonData().initFromDbRow(aoChange[(2+TestResultFailureData.kcDbColumns):]); + oTrf.oFailureReason = oFr; + aaoRows.append([aoChange[0], aoChange[1], oTrf, oFr]); + + # 2. Calculate the changes. + oFailureCategoryLogic = None; + aoEntries = []; + for i in xrange(0, len(aaoRows) - 1): + aoNew = aaoRows[i]; + aoOld = aaoRows[i + 1]; + + aoChanges = []; + oNew = aoNew[2]; + oOld = aoOld[2]; + for sAttr in oNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', 'oFailureReason', 'oAuthor' ]: + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr != oNewAttr: + if sAttr == 'idFailureReason': + oNewAttr = '%s (%s)' % (oNewAttr, oNew.oFailureReason.sShort, ); + oOldAttr = '%s (%s)' % (oOldAttr, oOld.oFailureReason.sShort, ); + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + if oOld.idFailureReason == oNew.idFailureReason: + oNew = aoNew[3]; + oOld = aoOld[3]; + for sAttr in oNew.getDataAttributes(): + if sAttr not in [ 'tsEffective', 'tsExpire', 'uidAuthor', ]: + oOldAttr = getattr(oOld, sAttr); + oNewAttr = getattr(oNew, sAttr); + if oOldAttr != oNewAttr: + if sAttr == 'idFailureCategory': + if oFailureCategoryLogic is None: + from testmanager.core.failurecategory import FailureCategoryLogic; + oFailureCategoryLogic = FailureCategoryLogic(self._oDb); + oCat = oFailureCategoryLogic.cachedLookup(oNewAttr); + if oCat is not None: + oNewAttr = '%s (%s)' % (oNewAttr, oCat.sShort, ); + oCat = oFailureCategoryLogic.cachedLookup(oOldAttr); + if oCat is not None: + oOldAttr = '%s (%s)' % (oOldAttr, oCat.sShort, ); + aoChanges.append(AttributeChangeEntry(sAttr, oNewAttr, oOldAttr, str(oNewAttr), str(oOldAttr))); + + + tsExpire = aaoRows[i - 1][0] if i > 0 else aoNew[2].tsExpire; + aoEntries.append(ChangeLogEntry(aoNew[1], None, aoNew[0], tsExpire, aoNew[2], aoOld[2], aoChanges)); + + # If we're at the end of the log, add the initial entry. + if len(aaoRows) <= cMaxRows and aaoRows: + aoNew = aaoRows[-1]; + tsExpire = aaoRows[-1 - 1][0] if len(aaoRows) > 1 else aoNew[2].tsExpire; + aoEntries.append(ChangeLogEntry(aoNew[1], None, aoNew[0], tsExpire, aoNew[2], None, [])); + + return (UserAccountLogic(self._oDb).resolveChangeLogAuthors(aoEntries), len(aaoRows) > cMaxRows); + + + def getById(self, idTestResult): + """Get Test result failure reason data by idTestResult""" + + self._oDb.execute('SELECT *\n' + 'FROM TestResultFailures\n' + 'WHERE tsExpire = \'infinity\'::timestamp\n' + ' AND idTestResult = %s;', (idTestResult,)) + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise self._oDb.integrityException( + 'Found more than one failure reasons with the same credentials. Database structure is corrupted.') + try: + return TestResultFailureData().initFromDbRow(aRows[0]) + except IndexError: + return None + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Add a test result failure reason record. + """ + + # + # Validate inputs and read in the old(/current) data. + # + assert isinstance(oData, TestResultFailureData); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_AddForeignId); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + + # Check if it exist first (we're adding, not editing, collisions not allowed). + oOldData = self.getById(oData.idTestResult); + if oOldData is not None: + raise TMRowAlreadyExists('TestResult %d already have a failure reason associated with it:' + '%s\n' + 'Perhaps someone else beat you to it? Or did you try resubmit?' + % (oData.idTestResult, oOldData)); + oData = self._resolveSetTestIdIfMissing(oData); + + # + # Add record. + # + self._readdEntry(uidAuthor, oData); + self._oDb.maybeCommit(fCommit); + return True; + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Modifies a test result failure reason. + """ + + # + # Validate inputs and read in the old(/current) data. + # + assert isinstance(oData, TestResultFailureData); + dErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit); + if dErrors: + raise TMInvalidData('editEntry invalid input: %s' % (dErrors,)); + + oOldData = self.getById(oData.idTestResult) + oData.idTestSet = oOldData.idTestSet; + + # + # Update the data that needs updating. + # + if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', ]): + self._historizeEntry(oData.idTestResult); + self._readdEntry(uidAuthor, oData); + self._oDb.maybeCommit(fCommit); + return True; + + + def removeEntry(self, uidAuthor, idTestResult, fCascade = False, fCommit = False): + """ + Deletes a test result failure reason. + """ + _ = fCascade; # Not applicable. + + oData = self.getById(idTestResult) + (tsCur, tsCurMinusOne) = self._oDb.getCurrentTimestamps(); + if oData.tsEffective not in (tsCur, tsCurMinusOne): + self._historizeEntry(idTestResult, tsCurMinusOne); + self._readdEntry(uidAuthor, oData, tsCurMinusOne); + self._historizeEntry(idTestResult); + self._oDb.execute('UPDATE TestResultFailures\n' + 'SET tsExpire = CURRENT_TIMESTAMP\n' + 'WHERE idTestResult = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (idTestResult,)); + self._oDb.maybeCommit(fCommit); + return True; + + # + # Helpers. + # + + def _readdEntry(self, uidAuthor, oData, tsEffective = None): + """ + Re-adds the TestResultFailure entry. Used by addEntry, editEntry and removeEntry. + """ + if tsEffective is None: + tsEffective = self._oDb.getCurrentTimestamp(); + self._oDb.execute('INSERT INTO TestResultFailures (\n' + ' uidAuthor,\n' + ' tsEffective,\n' + ' idTestResult,\n' + ' idTestSet,\n' + ' idFailureReason,\n' + ' sComment)\n' + 'VALUES (%s, %s, %s, %s, %s, %s)\n' + , ( uidAuthor, + tsEffective, + oData.idTestResult, + oData.idTestSet, + oData.idFailureReason, + oData.sComment,) ); + return True; + + + def _historizeEntry(self, idTestResult, tsExpire = None): + """ Historizes the current entry. """ + if tsExpire is None: + tsExpire = self._oDb.getCurrentTimestamp(); + self._oDb.execute('UPDATE TestResultFailures\n' + 'SET tsExpire = %s\n' + 'WHERE idTestResult = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (tsExpire, idTestResult,)); + return True; + + + def _resolveSetTestIdIfMissing(self, oData): + """ Resolve any missing idTestSet reference (it's a duplicate for speed efficiency). """ + if oData.idTestSet is None and oData.idTestResult is not None: + self._oDb.execute('SELECT idTestSet FROM TestResults WHERE idTestResult = %s', (oData.idTestResult,)); + oData.idTestSet = self._oDb.fetchOne()[0]; + return oData; + + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestResultFailureDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestResultFailureData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testresults.py b/src/VBox/ValidationKit/testmanager/core/testresults.py new file mode 100755 index 00000000..58f056d2 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testresults.py @@ -0,0 +1,2926 @@ +# -*- coding: utf-8 -*- +# $Id: testresults.py $ +# pylint: disable=too-many-lines + +## @todo Rename this file to testresult.py! + +""" +Test Manager - Fetch test results. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import sys; +import unittest; + +# Validation Kit imports. +from common import constants; +from testmanager import config; +from testmanager.core.base import ModelDataBase, ModelLogicBase, ModelDataBaseTestCase, ModelFilterBase, \ + FilterCriterion, FilterCriterionValueAndDescription, \ + TMExceptionBase, TMTooManyRows, TMRowNotFound; +from testmanager.core.testgroup import TestGroupData; +from testmanager.core.build import BuildDataEx, BuildCategoryData; +from testmanager.core.failurereason import FailureReasonLogic; +from testmanager.core.testbox import TestBoxData, TestBoxLogic; +from testmanager.core.testcase import TestCaseData; +from testmanager.core.schedgroup import SchedGroupData, SchedGroupLogic; +from testmanager.core.systemlog import SystemLogData, SystemLogLogic; +from testmanager.core.testresultfailures import TestResultFailureDataEx; +from testmanager.core.useraccount import UserAccountLogic; + +# Python 3 hacks: +if sys.version_info[0] >= 3: + long = int; # pylint: disable=redefined-builtin,invalid-name + + +class TestResultData(ModelDataBase): + """ + Test case execution result data + """ + + ## @name TestStatus_T + # @{ + ksTestStatus_Running = 'running'; + ksTestStatus_Success = 'success'; + ksTestStatus_Skipped = 'skipped'; + ksTestStatus_BadTestBox = 'bad-testbox'; + ksTestStatus_Aborted = 'aborted'; + ksTestStatus_Failure = 'failure'; + ksTestStatus_TimedOut = 'timed-out'; + ksTestStatus_Rebooted = 'rebooted'; + ## @} + + ## List of relatively harmless (to testgroup/case) statuses. + kasHarmlessTestStatuses = [ ksTestStatus_Skipped, ksTestStatus_BadTestBox, ksTestStatus_Aborted, ]; + ## List of bad statuses. + kasBadTestStatuses = [ ksTestStatus_Failure, ksTestStatus_TimedOut, ksTestStatus_Rebooted, ]; + + + ksIdAttr = 'idTestResult'; + + ksParam_idTestResult = 'TestResultData_idTestResult'; + ksParam_idTestResultParent = 'TestResultData_idTestResultParent'; + ksParam_idTestSet = 'TestResultData_idTestSet'; + ksParam_tsCreated = 'TestResultData_tsCreated'; + ksParam_tsElapsed = 'TestResultData_tsElapsed'; + ksParam_idStrName = 'TestResultData_idStrName'; + ksParam_cErrors = 'TestResultData_cErrors'; + ksParam_enmStatus = 'TestResultData_enmStatus'; + ksParam_iNestingDepth = 'TestResultData_iNestingDepth'; + kasValidValues_enmStatus = [ + ksTestStatus_Running, + ksTestStatus_Success, + ksTestStatus_Skipped, + ksTestStatus_BadTestBox, + ksTestStatus_Aborted, + ksTestStatus_Failure, + ksTestStatus_TimedOut, + ksTestStatus_Rebooted + ]; + + + def __init__(self): + ModelDataBase.__init__(self) + self.idTestResult = None + self.idTestResultParent = None + self.idTestSet = None + self.tsCreated = None + self.tsElapsed = None + self.idStrName = None + self.cErrors = 0; + self.enmStatus = None + self.iNestingDepth = None + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestResults. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test result record not found.') + + self.idTestResult = aoRow[0] + self.idTestResultParent = aoRow[1] + self.idTestSet = aoRow[2] + self.tsCreated = aoRow[3] + self.tsElapsed = aoRow[4] + self.idStrName = aoRow[5] + self.cErrors = aoRow[6] + self.enmStatus = aoRow[7] + self.iNestingDepth = aoRow[8] + return self; + + def initFromDbWithId(self, oDb, idTestResult, tsNow = None, sPeriodBack = None): + """ + Initialize from the database, given the ID of a row. + """ + _ = tsNow; + _ = sPeriodBack; + oDb.execute('SELECT *\n' + 'FROM TestResults\n' + 'WHERE idTestResult = %s\n' + , ( idTestResult,)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idTestResult=%s not found' % (idTestResult,)); + return self.initFromDbRow(aoRow); + + def isFailure(self): + """ Check if it's a real failure. """ + return self.enmStatus in self.kasBadTestStatuses; + + +class TestResultDataEx(TestResultData): + """ + Extended test result data class. + + This is intended for use as a node in a result tree. This is not intended + for serialization to parameters or vice versa. Use TestResultLogic to + construct the tree. + """ + + def __init__(self): + TestResultData.__init__(self) + self.sName = None; # idStrName resolved. + self.oParent = None; # idTestResultParent within the tree. + + self.aoChildren = []; # TestResultDataEx; + self.aoValues = []; # TestResultValueDataEx; + self.aoMsgs = []; # TestResultMsgDataEx; + self.aoFiles = []; # TestResultFileDataEx; + self.oReason = None; # TestResultReasonDataEx; + + def initFromDbRow(self, aoRow): + """ + Initialize from a query like this: + SELECT TestResults.*, TestResultStrTab.sValue + FROM TestResults, TestResultStrTab + WHERE TestResultStrTab.idStr = TestResults.idStrName + + Note! The caller is expected to fetch children, values, failure + details, and files. + """ + self.sName = None; + self.oParent = None; + self.aoChildren = []; + self.aoValues = []; + self.aoMsgs = []; + self.aoFiles = []; + self.oReason = None; + + TestResultData.initFromDbRow(self, aoRow); + + self.sName = aoRow[9]; + return self; + + def deepCountErrorContributers(self): + """ + Counts how many test result instances actually contributed to cErrors. + """ + + # Check each child (if any). + cChanges = 0; + cChildErrors = 0; + for oChild in self.aoChildren: + if oChild.cErrors > 0: + cChildErrors += oChild.cErrors; + cChanges += oChild.deepCountErrorContributers(); + + # Did we contribute as well? + if self.cErrors > cChildErrors: + cChanges += 1; + return cChanges; + + def getListOfFailures(self): + """ + Get a list of test results instances actually contributing to cErrors. + + Returns a list of TestResultDataEx instances from this tree. (shared!) + """ + # Check each child (if any). + aoRet = []; + cChildErrors = 0; + for oChild in self.aoChildren: + if oChild.cErrors > 0: + cChildErrors += oChild.cErrors; + aoRet.extend(oChild.getListOfFailures()); + + # Did we contribute as well? + if self.cErrors > cChildErrors: + aoRet.append(self); + + return aoRet; + + def getListOfLogFilesByKind(self, asKinds): + """ + Get a list of test results instances actually contributing to cErrors. + + Returns a list of TestResultFileDataEx instances from this tree. (shared!) + """ + aoRet = []; + + # Check the children first. + for oChild in self.aoChildren: + aoRet.extend(oChild.getListOfLogFilesByKind(asKinds)); + + # Check our own files next. + for oFile in self.aoFiles: + if oFile.sKind in asKinds: + aoRet.append(oFile); + + return aoRet; + + def getFullName(self): + """ Constructs the full name of this test result. """ + if self.oParent is None: + return self.sName; + return self.oParent.getFullName() + ' / ' + self.sName; + + + +class TestResultValueData(ModelDataBase): + """ + Test result value data. + """ + + ksIdAttr = 'idTestResultValue'; + + ksParam_idTestResultValue = 'TestResultValue_idTestResultValue'; + ksParam_idTestResult = 'TestResultValue_idTestResult'; + ksParam_idTestSet = 'TestResultValue_idTestSet'; + ksParam_tsCreated = 'TestResultValue_tsCreated'; + ksParam_idStrName = 'TestResultValue_idStrName'; + ksParam_lValue = 'TestResultValue_lValue'; + ksParam_iUnit = 'TestResultValue_iUnit'; + + kasAllowNullAttributes = [ 'idTestSet', ]; + + def __init__(self): + ModelDataBase.__init__(self) + self.idTestResultValue = None; + self.idTestResult = None; + self.idTestSet = None; + self.tsCreated = None; + self.idStrName = None; + self.lValue = None; + self.iUnit = 0; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestResultValues. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test result value record not found.') + + self.idTestResultValue = aoRow[0]; + self.idTestResult = aoRow[1]; + self.idTestSet = aoRow[2]; + self.tsCreated = aoRow[3]; + self.idStrName = aoRow[4]; + self.lValue = aoRow[5]; + self.iUnit = aoRow[6]; + return self; + + +class TestResultValueDataEx(TestResultValueData): + """ + Extends TestResultValue by resolving the value name and unit string. + """ + + def __init__(self): + TestResultValueData.__init__(self) + self.sName = None; + self.sUnit = ''; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a query like this: + SELECT TestResultValues.*, TestResultStrTab.sValue + FROM TestResultValues, TestResultStrTab + WHERE TestResultStrTab.idStr = TestResultValues.idStrName + + Return self. Raises exception if no row. + """ + TestResultValueData.initFromDbRow(self, aoRow); + self.sName = aoRow[7]; + if self.iUnit < len(constants.valueunit.g_asNames): + self.sUnit = constants.valueunit.g_asNames[self.iUnit]; + else: + self.sUnit = '<%d>' % (self.iUnit,); + return self; + +class TestResultMsgData(ModelDataBase): + """ + Test result message data. + """ + + ksIdAttr = 'idTestResultMsg'; + + ksParam_idTestResultMsg = 'TestResultValue_idTestResultMsg'; + ksParam_idTestResult = 'TestResultValue_idTestResult'; + ksParam_idTestSet = 'TestResultValue_idTestSet'; + ksParam_tsCreated = 'TestResultValue_tsCreated'; + ksParam_idStrMsg = 'TestResultValue_idStrMsg'; + ksParam_enmLevel = 'TestResultValue_enmLevel'; + + kasAllowNullAttributes = [ 'idTestSet', ]; + + kcDbColumns = 6 + + def __init__(self): + ModelDataBase.__init__(self) + self.idTestResultMsg = None; + self.idTestResult = None; + self.idTestSet = None; + self.tsCreated = None; + self.idStrMsg = None; + self.enmLevel = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestResultMsgs. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test result value record not found.') + + self.idTestResultMsg = aoRow[0]; + self.idTestResult = aoRow[1]; + self.idTestSet = aoRow[2]; + self.tsCreated = aoRow[3]; + self.idStrMsg = aoRow[4]; + self.enmLevel = aoRow[5]; + return self; + +class TestResultMsgDataEx(TestResultMsgData): + """ + Extends TestResultMsg by resolving the message string. + """ + + def __init__(self): + TestResultMsgData.__init__(self) + self.sMsg = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a query like this: + SELECT TestResultMsg.*, TestResultStrTab.sValue + FROM TestResultMsg, TestResultStrTab + WHERE TestResultStrTab.idStr = TestResultMsgs.idStrName + + Return self. Raises exception if no row. + """ + TestResultMsgData.initFromDbRow(self, aoRow); + self.sMsg = aoRow[self.kcDbColumns]; + return self; + + +class TestResultFileData(ModelDataBase): + """ + Test result message data. + """ + + ksIdAttr = 'idTestResultFile'; + + ksParam_idTestResultFile = 'TestResultFile_idTestResultFile'; + ksParam_idTestResult = 'TestResultFile_idTestResult'; + ksParam_tsCreated = 'TestResultFile_tsCreated'; + ksParam_idStrFile = 'TestResultFile_idStrFile'; + ksParam_idStrDescription = 'TestResultFile_idStrDescription'; + ksParam_idStrKind = 'TestResultFile_idStrKind'; + ksParam_idStrMime = 'TestResultFile_idStrMime'; + + ## @name Kind of files. + ## @{ + ksKind_LogReleaseVm = 'log/release/vm'; + ksKind_LogDebugVm = 'log/debug/vm'; + ksKind_LogReleaseSvc = 'log/release/svc'; + ksKind_LogDebugSvc = 'log/debug/svc'; + ksKind_LogReleaseClient = 'log/release/client'; + ksKind_LogDebugClient = 'log/debug/client'; + ksKind_LogInstaller = 'log/installer'; + ksKind_LogUninstaller = 'log/uninstaller'; + ksKind_LogGuestKernel = 'log/guest/kernel'; + ksKind_ProcessReportVm = 'process/report/vm'; + ksKind_CrashReportVm = 'crash/report/vm'; + ksKind_CrashDumpVm = 'crash/dump/vm'; + ksKind_CrashReportSvc = 'crash/report/svc'; + ksKind_CrashDumpSvc = 'crash/dump/svc'; + ksKind_CrashReportClient = 'crash/report/client'; + ksKind_CrashDumpClient = 'crash/dump/client'; + ksKind_InfoCollection = 'info/collection'; + ksKind_InfoVgaText = 'info/vgatext'; + ksKind_MiscOther = 'misc/other'; + ksKind_ScreenshotFailure = 'screenshot/failure'; + ksKind_ScreenshotSuccesss = 'screenshot/success'; + ksKind_ScreenRecordingFailure = 'screenrecording/failure'; + ksKind_ScreenRecordingSuccess = 'screenrecording/success'; + ## @} + + kasKinds = [ + ksKind_LogReleaseVm, + ksKind_LogDebugVm, + ksKind_LogReleaseSvc, + ksKind_LogDebugSvc, + ksKind_LogReleaseClient, + ksKind_LogDebugClient, + ksKind_LogInstaller, + ksKind_LogUninstaller, + ksKind_LogGuestKernel, + ksKind_ProcessReportVm, + ksKind_CrashReportVm, + ksKind_CrashDumpVm, + ksKind_CrashReportSvc, + ksKind_CrashDumpSvc, + ksKind_CrashReportClient, + ksKind_CrashDumpClient, + ksKind_InfoCollection, + ksKind_InfoVgaText, + ksKind_MiscOther, + ksKind_ScreenshotFailure, + ksKind_ScreenshotSuccesss, + ksKind_ScreenRecordingFailure, + ksKind_ScreenRecordingSuccess, + ]; + + kasAllowNullAttributes = [ 'idTestSet', ]; + + kcDbColumns = 8 + + def __init__(self): + ModelDataBase.__init__(self) + self.idTestResultFile = None; + self.idTestResult = None; + self.idTestSet = None; + self.tsCreated = None; + self.idStrFile = None; + self.idStrDescription = None; + self.idStrKind = None; + self.idStrMime = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a SELECT * FROM TestResultFiles. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test result file record not found.') + + self.idTestResultFile = aoRow[0]; + self.idTestResult = aoRow[1]; + self.idTestSet = aoRow[2]; + self.tsCreated = aoRow[3]; + self.idStrFile = aoRow[4]; + self.idStrDescription = aoRow[5]; + self.idStrKind = aoRow[6]; + self.idStrMime = aoRow[7]; + return self; + +class TestResultFileDataEx(TestResultFileData): + """ + Extends TestResultFile by resolving the strings. + """ + + def __init__(self): + TestResultFileData.__init__(self) + self.sFile = None; + self.sDescription = None; + self.sKind = None; + self.sMime = None; + + def initFromDbRow(self, aoRow): + """ + Reinitialize from a query like this: + SELECT TestResultFiles.*, + StrTabFile.sValue AS sFile, + StrTabDesc.sValue AS sDescription + StrTabKind.sValue AS sKind, + StrTabMime.sValue AS sMime, + FROM ... + + Return self. Raises exception if no row. + """ + TestResultFileData.initFromDbRow(self, aoRow); + self.sFile = aoRow[self.kcDbColumns]; + self.sDescription = aoRow[self.kcDbColumns + 1]; + self.sKind = aoRow[self.kcDbColumns + 2]; + self.sMime = aoRow[self.kcDbColumns + 3]; + return self; + + def initFakeMainLog(self, oTestSet): + """ + Reinitializes to represent the main.log object (not in DB). + + Returns self. + """ + self.idTestResultFile = 0; + self.idTestResult = oTestSet.idTestResult; + self.tsCreated = oTestSet.tsCreated; + self.idStrFile = None; + self.idStrDescription = None; + self.idStrKind = None; + self.idStrMime = None; + + self.sFile = 'main.log'; + self.sDescription = ''; + self.sKind = 'log/main'; + self.sMime = 'text/plain'; + return self; + + def isProbablyUtf8Encoded(self): + """ + Checks if the file is likely to be UTF-8 encoded. + """ + if self.sMime in [ 'text/plain', 'text/html' ]: + return True; + return False; + + def getMimeWithEncoding(self): + """ + Gets the MIME type with encoding if likely to be UTF-8. + """ + if self.isProbablyUtf8Encoded(): + return '%s; charset=utf-8' % (self.sMime,); + return self.sMime; + + + +class TestResultListingData(ModelDataBase): # pylint: disable=too-many-instance-attributes + """ + Test case result data representation for table listing + """ + + class FailureReasonListingData(object): + """ Failure reason listing data """ + def __init__(self): + self.oFailureReason = None; + self.oFailureReasonAssigner = None; + self.tsFailureReasonAssigned = None; + self.sFailureReasonComment = None; + + def __init__(self): + """Initialize""" + ModelDataBase.__init__(self) + + self.idTestSet = None + + self.idBuildCategory = None; + self.sProduct = None + self.sRepository = None; + self.sBranch = None + self.sType = None + self.idBuild = None; + self.sVersion = None; + self.iRevision = None + + self.sOs = None; + self.sOsVersion = None; + self.sArch = None; + self.sCpuVendor = None; + self.sCpuName = None; + self.cCpus = None; + self.fCpuHwVirt = None; + self.fCpuNestedPaging = None; + self.fCpu64BitGuest = None; + self.idTestBox = None + self.sTestBoxName = None + + self.tsCreated = None + self.tsElapsed = None + self.enmStatus = None + self.cErrors = None; + + self.idTestCase = None + self.sTestCaseName = None + self.sBaseCmd = None + self.sArgs = None + self.sSubName = None; + + self.idBuildTestSuite = None; + self.iRevisionTestSuite = None; + + self.aoFailureReasons = []; + + def initFromDbRowEx(self, aoRow, oFailureReasonLogic, oUserAccountLogic): + """ + Reinitialize from a database query. + Return self. Raises exception if no row. + """ + if aoRow is None: + raise TMRowNotFound('Test result record not found.') + + self.idTestSet = aoRow[0]; + + self.idBuildCategory = aoRow[1]; + self.sProduct = aoRow[2]; + self.sRepository = aoRow[3]; + self.sBranch = aoRow[4]; + self.sType = aoRow[5]; + self.idBuild = aoRow[6]; + self.sVersion = aoRow[7]; + self.iRevision = aoRow[8]; + + self.sOs = aoRow[9]; + self.sOsVersion = aoRow[10]; + self.sArch = aoRow[11]; + self.sCpuVendor = aoRow[12]; + self.sCpuName = aoRow[13]; + self.cCpus = aoRow[14]; + self.fCpuHwVirt = aoRow[15]; + self.fCpuNestedPaging = aoRow[16]; + self.fCpu64BitGuest = aoRow[17]; + self.idTestBox = aoRow[18]; + self.sTestBoxName = aoRow[19]; + + self.tsCreated = aoRow[20]; + self.tsElapsed = aoRow[21]; + self.enmStatus = aoRow[22]; + self.cErrors = aoRow[23]; + + self.idTestCase = aoRow[24]; + self.sTestCaseName = aoRow[25]; + self.sBaseCmd = aoRow[26]; + self.sArgs = aoRow[27]; + self.sSubName = aoRow[28]; + + self.idBuildTestSuite = aoRow[29]; + self.iRevisionTestSuite = aoRow[30]; + + self.aoFailureReasons = []; + for i, _ in enumerate(aoRow[31]): + if aoRow[31][i] is not None \ + or aoRow[32][i] is not None \ + or aoRow[33][i] is not None \ + or aoRow[34][i] is not None: + oReason = self.FailureReasonListingData(); + if aoRow[31][i] is not None: + oReason.oFailureReason = oFailureReasonLogic.cachedLookup(aoRow[31][i]); + if aoRow[32][i] is not None: + oReason.oFailureReasonAssigner = oUserAccountLogic.cachedLookup(aoRow[32][i]); + oReason.tsFailureReasonAssigned = aoRow[33][i]; + oReason.sFailureReasonComment = aoRow[34][i]; + self.aoFailureReasons.append(oReason); + + return self + + +class TestResultHangingOffence(TMExceptionBase): + """Hanging offence committed by test case.""" + pass; # pylint: disable=unnecessary-pass + + +class TestResultFilter(ModelFilterBase): + """ + Test result filter. + """ + + kiTestStatus = 0; + kiErrorCounts = 1; + kiBranches = 2; + kiBuildTypes = 3; + kiRevisions = 4; + kiRevisionRange = 5; + kiFailReasons = 6; + kiTestCases = 7; + kiTestCaseMisc = 8; + kiTestBoxes = 9; + kiOses = 10; + kiCpuArches = 11; + kiCpuVendors = 12; + kiCpuCounts = 13; + kiMemory = 14; + kiTestboxMisc = 15; + kiPythonVersions = 16; + kiSchedGroups = 17; + + ## Misc test case / variation name filters. + ## Presented in table order. The first sub element is the presistent ID. + kaTcMisc = ( + ( 1, 'x86', ), + ( 2, 'amd64', ), + ( 3, 'uni', ), + ( 4, 'smp', ), + ( 5, 'raw', ), + ( 6, 'hw', ), + ( 7, 'np', ), + ( 8, 'Install', ), + ( 20, 'UInstall', ), # NB. out of order. + ( 9, 'Benchmark', ), + ( 18, 'smoke', ), # NB. out of order. + ( 19, 'unit', ), # NB. out of order. + ( 10, 'USB', ), + ( 11, 'Debian', ), + ( 12, 'Fedora', ), + ( 13, 'Oracle', ), + ( 14, 'RHEL', ), + ( 15, 'SUSE', ), + ( 16, 'Ubuntu', ), + ( 17, 'Win', ), + ); + + kiTbMisc_NestedPaging = 0; + kiTbMisc_NoNestedPaging = 1; + kiTbMisc_RawMode = 2; + kiTbMisc_NoRawMode = 3; + kiTbMisc_64BitGuest = 4; + kiTbMisc_No64BitGuest = 5; + kiTbMisc_HwVirt = 6; + kiTbMisc_NoHwVirt = 7; + kiTbMisc_IoMmu = 8; + kiTbMisc_NoIoMmu = 9; + + def __init__(self): + ModelFilterBase.__init__(self); + + # Test statuses + oCrit = FilterCriterion('Test statuses', sVarNm = 'ts', sType = FilterCriterion.ksType_String, + sTable = 'TestSets', sColumn = 'enmStatus'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiTestStatus] is oCrit; + + # Error counts + oCrit = FilterCriterion('Error counts', sVarNm = 'ec', sTable = 'TestResults', sColumn = 'cErrors'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiErrorCounts] is oCrit; + + # Branches + oCrit = FilterCriterion('Branches', sVarNm = 'br', sType = FilterCriterion.ksType_String, + sTable = 'BuildCategories', sColumn = 'sBranch'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiBranches] is oCrit; + + # Build types + oCrit = FilterCriterion('Build types', sVarNm = 'bt', sType = FilterCriterion.ksType_String, + sTable = 'BuildCategories', sColumn = 'sType'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiBuildTypes] is oCrit; + + # Revisions + oCrit = FilterCriterion('Revisions', sVarNm = 'rv', sTable = 'Builds', sColumn = 'iRevision'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiRevisions] is oCrit; + + # Revision Range + oCrit = FilterCriterion('Revision Range', sVarNm = 'rr', sType = FilterCriterion.ksType_Ranges, + sKind = FilterCriterion.ksKind_ElementOfOrNot, sTable = 'Builds', sColumn = 'iRevision'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiRevisionRange] is oCrit; + + # Failure reasons + oCrit = FilterCriterion('Failure reasons', sVarNm = 'fr', sType = FilterCriterion.ksType_UIntNil, + sTable = 'TestResultFailures', sColumn = 'idFailureReason'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiFailReasons] is oCrit; + + # Test cases and variations. + oCrit = FilterCriterion('Test case / var', sVarNm = 'tc', sTable = 'TestSets', sColumn = 'idTestCase', + oSub = FilterCriterion('Test variations', sVarNm = 'tv', + sTable = 'TestSets', sColumn = 'idTestCaseArgs')); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiTestCases] is oCrit; + + # Special test case and varation name sub string matching. + oCrit = FilterCriterion('Test case name', sVarNm = 'cm', sKind = FilterCriterion.ksKind_Special, + asTables = ('TestCases', 'TestCaseArgs')); + oCrit.aoPossible = [ + FilterCriterionValueAndDescription(aoCur[0], 'Include %s' % (aoCur[1],)) for aoCur in self.kaTcMisc + ]; + oCrit.aoPossible.extend([ + FilterCriterionValueAndDescription(aoCur[0] + 32, 'Exclude %s' % (aoCur[1],)) for aoCur in self.kaTcMisc + ]); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiTestCaseMisc] is oCrit; + + # Testboxes + oCrit = FilterCriterion('Testboxes', sVarNm = 'tb', sTable = 'TestSets', sColumn = 'idTestBox'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiTestBoxes] is oCrit; + + # Testbox OS and OS version. + oCrit = FilterCriterion('OS / version', sVarNm = 'os', sTable = 'TestBoxesWithStrings', sColumn = 'idStrOs', + oSub = FilterCriterion('OS Versions', sVarNm = 'ov', + sTable = 'TestBoxesWithStrings', sColumn = 'idStrOsVersion')); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiOses] is oCrit; + + # Testbox CPU architectures. + oCrit = FilterCriterion('CPU arches', sVarNm = 'ca', sTable = 'TestBoxesWithStrings', sColumn = 'idStrCpuArch'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiCpuArches] is oCrit; + + # Testbox CPU vendors and revisions. + oCrit = FilterCriterion('CPU vendor / rev', sVarNm = 'cv', sTable = 'TestBoxesWithStrings', sColumn = 'idStrCpuVendor', + oSub = FilterCriterion('CPU revisions', sVarNm = 'cr', + sTable = 'TestBoxesWithStrings', sColumn = 'lCpuRevision')); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiCpuVendors] is oCrit; + + # Testbox CPU (thread) count + oCrit = FilterCriterion('CPU counts', sVarNm = 'cc', sTable = 'TestBoxesWithStrings', sColumn = 'cCpus'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiCpuCounts] is oCrit; + + # Testbox memory sizes. + oCrit = FilterCriterion('Memory', sVarNm = 'mb', sTable = 'TestBoxesWithStrings', sColumn = 'cMbMemory'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiMemory] is oCrit; + + # Testbox features. + oCrit = FilterCriterion('Testbox features', sVarNm = 'tm', sKind = FilterCriterion.ksKind_Special, + sTable = 'TestBoxesWithStrings'); + oCrit.aoPossible = [ + FilterCriterionValueAndDescription(self.kiTbMisc_NestedPaging, "req nested paging"), + FilterCriterionValueAndDescription(self.kiTbMisc_NoNestedPaging, "w/o nested paging"), + #FilterCriterionValueAndDescription(self.kiTbMisc_RawMode, "req raw-mode"), - not implemented yet. + #FilterCriterionValueAndDescription(self.kiTbMisc_NoRawMode, "w/o raw-mode"), - not implemented yet. + FilterCriterionValueAndDescription(self.kiTbMisc_64BitGuest, "req 64-bit guests"), + FilterCriterionValueAndDescription(self.kiTbMisc_No64BitGuest, "w/o 64-bit guests"), + FilterCriterionValueAndDescription(self.kiTbMisc_HwVirt, "req VT-x / AMD-V"), + FilterCriterionValueAndDescription(self.kiTbMisc_NoHwVirt, "w/o VT-x / AMD-V"), + #FilterCriterionValueAndDescription(self.kiTbMisc_IoMmu, "req I/O MMU"), - not implemented yet. + #FilterCriterionValueAndDescription(self.kiTbMisc_NoIoMmu, "w/o I/O MMU"), - not implemented yet. + ]; + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiTestboxMisc] is oCrit; + + # Testbox python versions. + oCrit = FilterCriterion('Python', sVarNm = 'py', sTable = 'TestBoxesWithStrings', sColumn = 'iPythonHexVersion'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiPythonVersions] is oCrit; + + # Scheduling groups. + oCrit = FilterCriterion('Sched groups', sVarNm = 'sg', sTable = 'TestSets', sColumn = 'idSchedGroup'); + self.aCriteria.append(oCrit); + assert self.aCriteria[self.kiSchedGroups] is oCrit; + + + kdTbMiscConditions = { + kiTbMisc_NestedPaging: 'TestBoxesWithStrings.fCpuNestedPaging IS TRUE', + kiTbMisc_NoNestedPaging: 'TestBoxesWithStrings.fCpuNestedPaging IS FALSE', + kiTbMisc_RawMode: 'TestBoxesWithStrings.fRawMode IS TRUE', + kiTbMisc_NoRawMode: 'TestBoxesWithStrings.fRawMode IS NOT TRUE', + kiTbMisc_64BitGuest: 'TestBoxesWithStrings.fCpu64BitGuest IS TRUE', + kiTbMisc_No64BitGuest: 'TestBoxesWithStrings.fCpu64BitGuest IS FALSE', + kiTbMisc_HwVirt: 'TestBoxesWithStrings.fCpuHwVirt IS TRUE', + kiTbMisc_NoHwVirt: 'TestBoxesWithStrings.fCpuHwVirt IS FALSE', + kiTbMisc_IoMmu: 'TestBoxesWithStrings.fChipsetIoMmu IS TRUE', + kiTbMisc_NoIoMmu: 'TestBoxesWithStrings.fChipsetIoMmu IS FALSE', + }; + + def _getWhereWorker(self, iCrit, oCrit, sExtraIndent, iOmit): + """ Formats one - main or sub. """ + sQuery = ''; + if oCrit.sState == FilterCriterion.ksState_Selected and iCrit != iOmit: + if iCrit == self.kiTestCaseMisc: + for iValue, sLike in self.kaTcMisc: + if iValue in oCrit.aoSelected: sNot = ''; + elif iValue + 32 in oCrit.aoSelected: sNot = 'NOT '; + else: continue; + sQuery += '%s AND %s (' % (sExtraIndent, sNot,); + if len(sLike) <= 3: # do word matching for small substrings (hw, np, smp, uni, ++). + sQuery += 'TestCases.sName ~ \'.*\\y%s\\y.*\' ' \ + 'OR COALESCE(TestCaseArgs.sSubName, \'\') ~ \'.*\\y%s\\y.*\')\n' \ + % ( sLike, sLike,); + else: + sQuery += 'TestCases.sName LIKE \'%%%s%%\' ' \ + 'OR COALESCE(TestCaseArgs.sSubName, \'\') LIKE \'%%%s%%\')\n' \ + % ( sLike, sLike,); + elif iCrit == self.kiTestboxMisc: + dConditions = self.kdTbMiscConditions; + for iValue in oCrit.aoSelected: + if iValue in dConditions: + sQuery += '%s AND %s\n' % (sExtraIndent, dConditions[iValue],); + elif oCrit.sType == FilterCriterion.ksType_Ranges: + assert not oCrit.aoPossible; + if oCrit.aoSelected: + asConditions = []; + for tRange in oCrit.aoSelected: + if tRange[0] == tRange[1]: + asConditions.append('%s.%s = %s' % (oCrit.asTables[0], oCrit.sColumn, tRange[0])); + elif tRange[1] is None: # 9999- + asConditions.append('%s.%s >= %s' % (oCrit.asTables[0], oCrit.sColumn, tRange[0])); + elif tRange[0] is None: # -9999 + asConditions.append('%s.%s <= %s' % (oCrit.asTables[0], oCrit.sColumn, tRange[1])); + else: + asConditions.append('%s.%s BETWEEN %s AND %s' % (oCrit.asTables[0], oCrit.sColumn, + tRange[0], tRange[1])); + if not oCrit.fInverted: + sQuery += '%s AND (%s)\n' % (sExtraIndent, ' OR '.join(asConditions)); + else: + sQuery += '%s AND NOT (%s)\n' % (sExtraIndent, ' OR '.join(asConditions)); + else: + assert len(oCrit.asTables) == 1; + sQuery += '%s AND (' % (sExtraIndent,); + + if oCrit.sType != FilterCriterion.ksType_UIntNil or max(oCrit.aoSelected) != -1: + if iCrit == self.kiMemory: + sQuery += '(%s.%s / 1024)' % (oCrit.asTables[0], oCrit.sColumn,); + else: + sQuery += '%s.%s' % (oCrit.asTables[0], oCrit.sColumn,); + if not oCrit.fInverted: + sQuery += ' IN ('; + else: + sQuery += ' NOT IN ('; + if oCrit.sType == FilterCriterion.ksType_String: + sQuery += ', '.join('\'%s\'' % (sValue,) for sValue in oCrit.aoSelected) + ')'; + else: + sQuery += ', '.join(str(iValue) for iValue in oCrit.aoSelected if iValue != -1) + ')'; + + if oCrit.sType == FilterCriterion.ksType_UIntNil \ + and -1 in oCrit.aoSelected: + if sQuery[-1] != '(': sQuery += ' OR '; + sQuery += '%s.%s IS NULL' % (oCrit.asTables[0], oCrit.sColumn,); + + if iCrit == self.kiFailReasons: + if oCrit.fInverted: + sQuery += '%s OR TestResultFailures.idFailureReason IS NULL\n' % (sExtraIndent,); + else: + sQuery += '%s AND TestSets.enmStatus >= \'failure\'::TestStatus_T\n' % (sExtraIndent,); + sQuery += ')\n'; + if oCrit.oSub is not None: + sQuery += self._getWhereWorker(iCrit | (((iCrit >> 8) + 1) << 8), oCrit.oSub, sExtraIndent, iOmit); + return sQuery; + + def getWhereConditions(self, sExtraIndent = '', iOmit = -1): + """ + Construct the WHERE conditions for the filter, optionally omitting one + criterion. + """ + sQuery = ''; + for iCrit, oCrit in enumerate(self.aCriteria): + sQuery += self._getWhereWorker(iCrit, oCrit, sExtraIndent, iOmit); + return sQuery; + + def getTableJoins(self, sExtraIndent = '', iOmit = -1, dOmitTables = None): + """ + Construct the WHERE conditions for the filter, optionally omitting one + criterion. + """ + afDone = { 'TestSets': True, }; + if dOmitTables is not None: + afDone.update(dOmitTables); + + sQuery = ''; + for iCrit, oCrit in enumerate(self.aCriteria): + if oCrit.sState == FilterCriterion.ksState_Selected \ + and iCrit != iOmit: + for sTable in oCrit.asTables: + if sTable not in afDone: + afDone[sTable] = True; + if sTable == 'Builds': + sQuery += '%sINNER JOIN Builds\n' \ + '%s ON Builds.idBuild = TestSets.idBuild\n' \ + '%s AND Builds.tsExpire > TestSets.tsCreated\n' \ + '%s AND Builds.tsEffective <= TestSets.tsCreated\n' \ + % ( sExtraIndent, sExtraIndent, sExtraIndent, sExtraIndent, ); + elif sTable == 'BuildCategories': + sQuery += '%sINNER JOIN BuildCategories\n' \ + '%s ON BuildCategories.idBuildCategory = TestSets.idBuildCategory\n' \ + % ( sExtraIndent, sExtraIndent, ); + elif sTable == 'TestBoxesWithStrings': + sQuery += '%sLEFT OUTER JOIN TestBoxesWithStrings\n' \ + '%s ON TestBoxesWithStrings.idGenTestBox = TestSets.idGenTestBox\n' \ + % ( sExtraIndent, sExtraIndent, ); + elif sTable == 'TestCases': + sQuery += '%sINNER JOIN TestCases\n' \ + '%s ON TestCases.idGenTestCase = TestSets.idGenTestCase\n' \ + % ( sExtraIndent, sExtraIndent, ); + elif sTable == 'TestCaseArgs': + sQuery += '%sINNER JOIN TestCaseArgs\n' \ + '%s ON TestCaseArgs.idGenTestCaseArgs = TestSets.idGenTestCaseArgs\n' \ + % ( sExtraIndent, sExtraIndent, ); + elif sTable == 'TestResults': + sQuery += '%sINNER JOIN TestResults\n' \ + '%s ON TestResults.idTestResult = TestSets.idTestResult\n' \ + % ( sExtraIndent, sExtraIndent, ); + elif sTable == 'TestResultFailures': + sQuery += '%sLEFT OUTER JOIN TestResultFailures\n' \ + '%s ON TestResultFailures.idTestSet = TestSets.idTestSet\n' \ + '%s AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n' \ + % ( sExtraIndent, sExtraIndent, sExtraIndent, ); + else: + assert False, sTable; + return sQuery; + + def isJoiningWithTable(self, sTable): + """ Checks whether getTableJoins already joins with TestResultFailures. """ + for oCrit in self.aCriteria: + if oCrit.sState == FilterCriterion.ksState_Selected and sTable in oCrit.asTables: + return True; + return False + + + +class TestResultLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + Results grouped by scheduling group. + """ + + # + # Result grinding for displaying in the WUI. + # + + ksResultsGroupingTypeNone = 'ResultsGroupingTypeNone'; + ksResultsGroupingTypeTestGroup = 'ResultsGroupingTypeTestGroup'; + ksResultsGroupingTypeBuildCat = 'ResultsGroupingTypeBuildCat'; + ksResultsGroupingTypeBuildRev = 'ResultsGroupingTypeBuildRev'; + ksResultsGroupingTypeTestBox = 'ResultsGroupingTypeTestBox'; + ksResultsGroupingTypeTestCase = 'ResultsGroupingTypeTestCase'; + ksResultsGroupingTypeOS = 'ResultsGroupingTypeOS'; + ksResultsGroupingTypeArch = 'ResultsGroupingTypeArch'; + ksResultsGroupingTypeSchedGroup = 'ResultsGroupingTypeSchedGroup'; + + ## @name Result sorting options. + ## @{ + ksResultsSortByRunningAndStart = 'ResultsSortByRunningAndStart'; ##< Default + ksResultsSortByBuildRevision = 'ResultsSortByBuildRevision'; + ksResultsSortByTestBoxName = 'ResultsSortByTestBoxName'; + ksResultsSortByTestBoxOs = 'ResultsSortByTestBoxOs'; + ksResultsSortByTestBoxOsVersion = 'ResultsSortByTestBoxOsVersion'; + ksResultsSortByTestBoxOsArch = 'ResultsSortByTestBoxOsArch'; + ksResultsSortByTestBoxArch = 'ResultsSortByTestBoxArch'; + ksResultsSortByTestBoxCpuVendor = 'ResultsSortByTestBoxCpuVendor'; + ksResultsSortByTestBoxCpuName = 'ResultsSortByTestBoxCpuName'; + ksResultsSortByTestBoxCpuRev = 'ResultsSortByTestBoxCpuRev'; + ksResultsSortByTestBoxCpuFeatures = 'ResultsSortByTestBoxCpuFeatures'; + ksResultsSortByTestCaseName = 'ResultsSortByTestCaseName'; + ksResultsSortByFailureReason = 'ResultsSortByFailureReason'; + kasResultsSortBy = { + ksResultsSortByRunningAndStart, + ksResultsSortByBuildRevision, + ksResultsSortByTestBoxName, + ksResultsSortByTestBoxOs, + ksResultsSortByTestBoxOsVersion, + ksResultsSortByTestBoxOsArch, + ksResultsSortByTestBoxArch, + ksResultsSortByTestBoxCpuVendor, + ksResultsSortByTestBoxCpuName, + ksResultsSortByTestBoxCpuRev, + ksResultsSortByTestBoxCpuFeatures, + ksResultsSortByTestCaseName, + ksResultsSortByFailureReason, + }; + ## Used by the WUI for generating the drop down. + kaasResultsSortByTitles = ( + ( ksResultsSortByRunningAndStart, 'Running & Start TS' ), + ( ksResultsSortByBuildRevision, 'Build Revision' ), + ( ksResultsSortByTestBoxName, 'TestBox Name' ), + ( ksResultsSortByTestBoxOs, 'O/S' ), + ( ksResultsSortByTestBoxOsVersion, 'O/S Version' ), + ( ksResultsSortByTestBoxOsArch, 'O/S & Architecture' ), + ( ksResultsSortByTestBoxArch, 'Architecture' ), + ( ksResultsSortByTestBoxCpuVendor, 'CPU Vendor' ), + ( ksResultsSortByTestBoxCpuName, 'CPU Vendor & Name' ), + ( ksResultsSortByTestBoxCpuRev, 'CPU Vendor & Revision' ), + ( ksResultsSortByTestBoxCpuFeatures, 'CPU Features' ), + ( ksResultsSortByTestCaseName, 'Test Case Name' ), + ( ksResultsSortByFailureReason, 'Failure Reason' ), + ); + ## @} + + ## Default sort by map. + kdResultSortByMap = { + ksResultsSortByRunningAndStart: ( (), None, None, '', '' ), + ksResultsSortByBuildRevision: ( + # Sorting tables. + ('Builds',), + # Sorting table join(s). + ' AND TestSets.idBuild = Builds.idBuild' + ' AND Builds.tsExpire >= TestSets.tsCreated' + ' AND Builds.tsEffective <= TestSets.tsCreated', + # Start of ORDER BY statement. + ' Builds.iRevision DESC', + # Extra columns to fetch for the above ORDER BY to work in a SELECT DISTINCT statement. + '', + # Columns for the GROUP BY + ''), + ksResultsSortByTestBoxName: ( + ('TestBoxes',), + ' AND TestSets.idGenTestBox = TestBoxes.idGenTestBox', + ' TestBoxes.sName DESC', + '', '' ), + ksResultsSortByTestBoxOsArch: ( + ('TestBoxesWithStrings',), + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox', + ' TestBoxesWithStrings.sOs, TestBoxesWithStrings.sCpuArch', + '', '' ), + ksResultsSortByTestBoxOs: ( + ('TestBoxesWithStrings',), + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox', + ' TestBoxesWithStrings.sOs', + '', '' ), + ksResultsSortByTestBoxOsVersion: ( + ('TestBoxesWithStrings',), + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox', + ' TestBoxesWithStrings.sOs, TestBoxesWithStrings.sOsVersion DESC', + '', '' ), + ksResultsSortByTestBoxArch: ( + ('TestBoxesWithStrings',), + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox', + ' TestBoxesWithStrings.sCpuArch', + '', '' ), + ksResultsSortByTestBoxCpuVendor: ( + ('TestBoxesWithStrings',), + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox', + ' TestBoxesWithStrings.sCpuVendor', + '', '' ), + ksResultsSortByTestBoxCpuName: ( + ('TestBoxesWithStrings',), + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox', + ' TestBoxesWithStrings.sCpuVendor, TestBoxesWithStrings.sCpuName', + '', '' ), + ksResultsSortByTestBoxCpuRev: ( + ('TestBoxesWithStrings',), + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox', + ' TestBoxesWithStrings.sCpuVendor, TestBoxesWithStrings.lCpuRevision DESC', + ', TestBoxesWithStrings.lCpuRevision', + ', TestBoxesWithStrings.lCpuRevision' ), + ksResultsSortByTestBoxCpuFeatures: ( + ('TestBoxes',), + ' AND TestSets.idGenTestBox = TestBoxes.idGenTestBox', + ' TestBoxes.fCpuHwVirt DESC, TestBoxes.fCpuNestedPaging DESC, TestBoxes.fCpu64BitGuest DESC, TestBoxes.cCpus DESC', + '', + '' ), + ksResultsSortByTestCaseName: ( + ('TestCases',), + ' AND TestSets.idGenTestCase = TestCases.idGenTestCase', + ' TestCases.sName', + '', '' ), + ksResultsSortByFailureReason: ( + (), '', + 'asSortByFailureReason ASC', + ', array_agg(FailureReasons.sShort ORDER BY TestResultFailures.idTestResult) AS asSortByFailureReason', + '' ), + }; + + kdResultGroupingMap = { + ksResultsGroupingTypeNone: ( + # Grouping tables; + (), + # Grouping field; + None, + # Grouping where addition. + None, + # Sort by overrides. + {}, + ), + ksResultsGroupingTypeTestGroup: ('', 'TestSets.idTestGroup', None, {},), + ksResultsGroupingTypeTestBox: ('', 'TestSets.idTestBox', None, {},), + ksResultsGroupingTypeTestCase: ('', 'TestSets.idTestCase', None, {},), + ksResultsGroupingTypeOS: ( + ('TestBoxes',), + 'TestBoxes.idStrOs', + ' AND TestBoxes.idGenTestBox = TestSets.idGenTestBox', + {}, + ), + ksResultsGroupingTypeArch: ( + ('TestBoxes',), + 'TestBoxes.idStrCpuArch', + ' AND TestBoxes.idGenTestBox = TestSets.idGenTestBox', + {}, + ), + ksResultsGroupingTypeBuildCat: ('', 'TestSets.idBuildCategory', None, {},), + ksResultsGroupingTypeBuildRev: ( + ('Builds',), + 'Builds.iRevision', + ' AND Builds.idBuild = TestSets.idBuild' + ' AND Builds.tsExpire > TestSets.tsCreated' + ' AND Builds.tsEffective <= TestSets.tsCreated', + { ksResultsSortByBuildRevision: ( (), None, ' Builds.iRevision DESC' ), } + ), + ksResultsGroupingTypeSchedGroup: ( '', 'TestSets.idSchedGroup', None, {},), + }; + + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.oFailureReasonLogic = None; + self.oUserAccountLogic = None; + + def _getTimePeriodQueryPart(self, tsNow, sInterval, sExtraIndent = ''): + """ + Get part of SQL query responsible for SELECT data within + specified period of time. + """ + assert sInterval is not None; # too many rows. + + cMonthsMourningPeriod = 2; # Stop reminding everyone about testboxes after 2 months. (May also speed up the query.) + if tsNow is None: + sRet = '(TestSets.tsDone IS NULL OR TestSets.tsDone >= (CURRENT_TIMESTAMP - \'%s\'::interval))\n' \ + '%s AND TestSets.tsCreated >= (CURRENT_TIMESTAMP - \'%s\'::interval - \'%u months\'::interval)\n' \ + % ( sInterval, + sExtraIndent, sInterval, cMonthsMourningPeriod); + else: + sTsNow = '\'%s\'::TIMESTAMP' % (tsNow,); # It's actually a string already. duh. + sRet = 'TestSets.tsCreated <= %s\n' \ + '%s AND TestSets.tsCreated >= (%s - \'%s\'::interval - \'%u months\'::interval)\n' \ + '%s AND (TestSets.tsDone IS NULL OR TestSets.tsDone >= (%s - \'%s\'::interval))\n' \ + % ( sTsNow, + sExtraIndent, sTsNow, sInterval, cMonthsMourningPeriod, + sExtraIndent, sTsNow, sInterval ); + return sRet + + def fetchResultsForListing(self, iStart, cMaxRows, tsNow, sInterval, oFilter, enmResultSortBy, # pylint: disable=too-many-arguments + enmResultsGroupingType, iResultsGroupingValue, fOnlyFailures, fOnlyNeedingReason): + """ + Fetches TestResults table content. + + If @param enmResultsGroupingType and @param iResultsGroupingValue + are not None, then resulting (returned) list contains only records + that match specified @param enmResultsGroupingType. + + If @param enmResultsGroupingType is None, then + @param iResultsGroupingValue is ignored. + + Returns an array (list) of TestResultData items, empty list if none. + Raises exception on error. + """ + + _ = oFilter; + + # + # Get SQL query parameters + # + if enmResultsGroupingType is None or enmResultsGroupingType not in self.kdResultGroupingMap: + raise TMExceptionBase('Unknown grouping type'); + if enmResultSortBy is None or enmResultSortBy not in self.kasResultsSortBy: + raise TMExceptionBase('Unknown sorting'); + asGroupingTables, sGroupingField, sGroupingCondition, dSortOverrides = self.kdResultGroupingMap[enmResultsGroupingType]; + if enmResultSortBy in dSortOverrides: + asSortTables, sSortWhere, sSortOrderBy, sSortColumns, sSortGroupBy = dSortOverrides[enmResultSortBy]; + else: + asSortTables, sSortWhere, sSortOrderBy, sSortColumns, sSortGroupBy = self.kdResultSortByMap[enmResultSortBy]; + + # + # Construct the query. + # + sQuery = 'SELECT DISTINCT TestSets.idTestSet,\n' \ + ' BuildCategories.idBuildCategory,\n' \ + ' BuildCategories.sProduct,\n' \ + ' BuildCategories.sRepository,\n' \ + ' BuildCategories.sBranch,\n' \ + ' BuildCategories.sType,\n' \ + ' Builds.idBuild,\n' \ + ' Builds.sVersion,\n' \ + ' Builds.iRevision,\n' \ + ' TestBoxesWithStrings.sOs,\n' \ + ' TestBoxesWithStrings.sOsVersion,\n' \ + ' TestBoxesWithStrings.sCpuArch,\n' \ + ' TestBoxesWithStrings.sCpuVendor,\n' \ + ' TestBoxesWithStrings.sCpuName,\n' \ + ' TestBoxesWithStrings.cCpus,\n' \ + ' TestBoxesWithStrings.fCpuHwVirt,\n' \ + ' TestBoxesWithStrings.fCpuNestedPaging,\n' \ + ' TestBoxesWithStrings.fCpu64BitGuest,\n' \ + ' TestBoxesWithStrings.idTestBox,\n' \ + ' TestBoxesWithStrings.sName,\n' \ + ' TestResults.tsCreated,\n' \ + ' COALESCE(TestResults.tsElapsed, CURRENT_TIMESTAMP - TestResults.tsCreated) AS tsElapsedTestResult,\n' \ + ' TestSets.enmStatus,\n' \ + ' TestResults.cErrors,\n' \ + ' TestCases.idTestCase,\n' \ + ' TestCases.sName,\n' \ + ' TestCases.sBaseCmd,\n' \ + ' TestCaseArgs.sArgs,\n' \ + ' TestCaseArgs.sSubName,\n' \ + ' TestSuiteBits.idBuild AS idBuildTestSuite,\n' \ + ' TestSuiteBits.iRevision AS iRevisionTestSuite,\n' \ + ' array_agg(TestResultFailures.idFailureReason ORDER BY TestResultFailures.idTestResult),\n' \ + ' array_agg(TestResultFailures.uidAuthor ORDER BY TestResultFailures.idTestResult),\n' \ + ' array_agg(TestResultFailures.tsEffective ORDER BY TestResultFailures.idTestResult),\n' \ + ' array_agg(TestResultFailures.sComment ORDER BY TestResultFailures.idTestResult),\n' \ + ' (TestSets.tsDone IS NULL) SortRunningFirst' + sSortColumns + '\n' \ + 'FROM ( SELECT TestSets.idTestSet AS idTestSet,\n' \ + ' TestSets.tsDone AS tsDone,\n' \ + ' TestSets.tsCreated AS tsCreated,\n' \ + ' TestSets.enmStatus AS enmStatus,\n' \ + ' TestSets.idBuild AS idBuild,\n' \ + ' TestSets.idBuildTestSuite AS idBuildTestSuite,\n' \ + ' TestSets.idGenTestBox AS idGenTestBox,\n' \ + ' TestSets.idGenTestCase AS idGenTestCase,\n' \ + ' TestSets.idGenTestCaseArgs AS idGenTestCaseArgs\n' \ + ' FROM TestSets\n'; + sQuery += oFilter.getTableJoins(' '); + if fOnlyNeedingReason and not oFilter.isJoiningWithTable('TestResultFailures'): + sQuery += '\n' \ + ' LEFT OUTER JOIN TestResultFailures\n' \ + ' ON TestSets.idTestSet = TestResultFailures.idTestSet\n' \ + ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP'; + for asTables in [asGroupingTables, asSortTables]: + for sTable in asTables: + if not oFilter.isJoiningWithTable(sTable): + sQuery = sQuery[:-1] + ',\n ' + sTable + '\n'; + + sQuery += ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sInterval, ' ') + \ + oFilter.getWhereConditions(' '); + if fOnlyFailures or fOnlyNeedingReason: + sQuery += ' AND TestSets.enmStatus != \'success\'::TestStatus_T\n' \ + ' AND TestSets.enmStatus != \'running\'::TestStatus_T\n'; + if fOnlyNeedingReason: + sQuery += ' AND TestResultFailures.idTestSet IS NULL\n'; + if sGroupingField is not None: + sQuery += ' AND %s = %d\n' % (sGroupingField, iResultsGroupingValue,); + if sGroupingCondition is not None: + sQuery += sGroupingCondition.replace(' AND ', ' AND '); + if sSortWhere is not None: + sQuery += sSortWhere.replace(' AND ', ' AND '); + sQuery += ' ORDER BY '; + if sSortOrderBy is not None and sSortOrderBy.find('FailureReason') < 0: + sQuery += sSortOrderBy + ',\n '; + sQuery += '(TestSets.tsDone IS NULL) DESC, TestSets.idTestSet DESC\n' \ + ' LIMIT %s OFFSET %s\n' % (cMaxRows, iStart,); + + # Note! INNER JOIN TestBoxesWithStrings performs miserable compared to LEFT OUTER JOIN. Doesn't matter for the result + # because TestSets.idGenTestBox is a foreign key and unique in TestBoxes. So, let's do what ever is faster. + sQuery += ' ) AS TestSets\n' \ + ' LEFT OUTER JOIN TestBoxesWithStrings\n' \ + ' ON TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox' \ + ' LEFT OUTER JOIN Builds AS TestSuiteBits\n' \ + ' ON TestSuiteBits.idBuild = TestSets.idBuildTestSuite\n' \ + ' AND TestSuiteBits.tsExpire > TestSets.tsCreated\n' \ + ' AND TestSuiteBits.tsEffective <= TestSets.tsCreated\n' \ + ' LEFT OUTER JOIN TestResultFailures\n' \ + ' ON TestSets.idTestSet = TestResultFailures.idTestSet\n' \ + ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP'; + if sSortOrderBy is not None and sSortOrderBy.find('FailureReason') >= 0: + sQuery += '\n' \ + ' LEFT OUTER JOIN FailureReasons\n' \ + ' ON TestResultFailures.idFailureReason = FailureReasons.idFailureReason\n' \ + ' AND FailureReasons.tsExpire = \'infinity\'::TIMESTAMP'; + sQuery += ',\n' \ + ' BuildCategories,\n' \ + ' Builds,\n' \ + ' TestResults,\n' \ + ' TestCases,\n' \ + ' TestCaseArgs\n'; + sQuery += 'WHERE TestSets.idTestSet = TestResults.idTestSet\n' \ + ' AND TestResults.idTestResultParent is NULL\n' \ + ' AND TestSets.idBuild = Builds.idBuild\n' \ + ' AND Builds.tsExpire > TestSets.tsCreated\n' \ + ' AND Builds.tsEffective <= TestSets.tsCreated\n' \ + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' \ + ' AND TestSets.idGenTestCase = TestCases.idGenTestCase\n' \ + ' AND TestSets.idGenTestCaseArgs = TestCaseArgs.idGenTestCaseArgs\n'; + sQuery += 'GROUP BY TestSets.idTestSet,\n' \ + ' BuildCategories.idBuildCategory,\n' \ + ' BuildCategories.sProduct,\n' \ + ' BuildCategories.sRepository,\n' \ + ' BuildCategories.sBranch,\n' \ + ' BuildCategories.sType,\n' \ + ' Builds.idBuild,\n' \ + ' Builds.sVersion,\n' \ + ' Builds.iRevision,\n' \ + ' TestBoxesWithStrings.sOs,\n' \ + ' TestBoxesWithStrings.sOsVersion,\n' \ + ' TestBoxesWithStrings.sCpuArch,\n' \ + ' TestBoxesWithStrings.sCpuVendor,\n' \ + ' TestBoxesWithStrings.sCpuName,\n' \ + ' TestBoxesWithStrings.cCpus,\n' \ + ' TestBoxesWithStrings.fCpuHwVirt,\n' \ + ' TestBoxesWithStrings.fCpuNestedPaging,\n' \ + ' TestBoxesWithStrings.fCpu64BitGuest,\n' \ + ' TestBoxesWithStrings.idTestBox,\n' \ + ' TestBoxesWithStrings.sName,\n' \ + ' TestResults.tsCreated,\n' \ + ' tsElapsedTestResult,\n' \ + ' TestSets.enmStatus,\n' \ + ' TestResults.cErrors,\n' \ + ' TestCases.idTestCase,\n' \ + ' TestCases.sName,\n' \ + ' TestCases.sBaseCmd,\n' \ + ' TestCaseArgs.sArgs,\n' \ + ' TestCaseArgs.sSubName,\n' \ + ' TestSuiteBits.idBuild,\n' \ + ' TestSuiteBits.iRevision,\n' \ + ' SortRunningFirst' + sSortGroupBy + '\n'; + sQuery += 'ORDER BY '; + if sSortOrderBy is not None: + sQuery += sSortOrderBy.replace('TestBoxes.', 'TestBoxesWithStrings.') + ',\n '; + sQuery += '(TestSets.tsDone IS NULL) DESC, TestSets.idTestSet DESC\n'; + + # + # Execute the query and return the wrapped results. + # + self._oDb.execute(sQuery); + + if self.oFailureReasonLogic is None: + self.oFailureReasonLogic = FailureReasonLogic(self._oDb); + if self.oUserAccountLogic is None: + self.oUserAccountLogic = UserAccountLogic(self._oDb); + + aoRows = []; + for aoRow in self._oDb.fetchAll(): + aoRows.append(TestResultListingData().initFromDbRowEx(aoRow, self.oFailureReasonLogic, self.oUserAccountLogic)); + + return aoRows + + + def fetchTimestampsForLogViewer(self, idTestSet): + """ + Returns an ordered list with all the test result timestamps, both start + and end. + + The log viewer create anchors in the log text so we can jump directly to + the log lines relevant for a test event. + """ + self._oDb.execute('(\n' + 'SELECT tsCreated\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + ') UNION (\n' + 'SELECT tsCreated + tsElapsed\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + ' AND tsElapsed IS NOT NULL\n' + ') UNION (\n' + 'SELECT TestResultFiles.tsCreated\n' + 'FROM TestResultFiles\n' + 'WHERE idTestSet = %s\n' + ') UNION (\n' + 'SELECT tsCreated\n' + 'FROM TestResultValues\n' + 'WHERE idTestSet = %s\n' + ') UNION (\n' + 'SELECT TestResultMsgs.tsCreated\n' + 'FROM TestResultMsgs\n' + 'WHERE idTestSet = %s\n' + ') ORDER by 1' + , ( idTestSet, idTestSet, idTestSet, idTestSet, idTestSet, )); + return [aoRow[0] for aoRow in self._oDb.fetchAll()]; + + + def getEntriesCount(self, tsNow, sInterval, oFilter, enmResultsGroupingType, iResultsGroupingValue, + fOnlyFailures, fOnlyNeedingReason): + """ + Get number of table records. + + If @param enmResultsGroupingType and @param iResultsGroupingValue + are not None, then we count only only those records + that match specified @param enmResultsGroupingType. + + If @param enmResultsGroupingType is None, then + @param iResultsGroupingValue is ignored. + """ + _ = oFilter; + + # + # Get SQL query parameters + # + if enmResultsGroupingType is None: + raise TMExceptionBase('Unknown grouping type') + + if enmResultsGroupingType not in self.kdResultGroupingMap: + raise TMExceptionBase('Unknown grouping type') + asGroupingTables, sGroupingField, sGroupingCondition, _ = self.kdResultGroupingMap[enmResultsGroupingType]; + + # + # Construct the query. + # + sQuery = 'SELECT COUNT(TestSets.idTestSet)\n' \ + 'FROM TestSets\n'; + sQuery += oFilter.getTableJoins(); + if fOnlyNeedingReason and not oFilter.isJoiningWithTable('TestResultFailures'): + sQuery += ' LEFT OUTER JOIN TestResultFailures\n' \ + ' ON TestSets.idTestSet = TestResultFailures.idTestSet\n' \ + ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n'; + for sTable in asGroupingTables: + if not oFilter.isJoiningWithTable(sTable): + sQuery = sQuery[:-1] + ',\n ' + sTable + '\n'; + sQuery += 'WHERE ' + self._getTimePeriodQueryPart(tsNow, sInterval) + \ + oFilter.getWhereConditions(); + if fOnlyFailures or fOnlyNeedingReason: + sQuery += ' AND TestSets.enmStatus != \'success\'::TestStatus_T\n' \ + ' AND TestSets.enmStatus != \'running\'::TestStatus_T\n'; + if fOnlyNeedingReason: + sQuery += ' AND TestResultFailures.idTestSet IS NULL\n'; + if sGroupingField is not None: + sQuery += ' AND %s = %d\n' % (sGroupingField, iResultsGroupingValue,); + if sGroupingCondition is not None: + sQuery += sGroupingCondition.replace(' AND ', ' AND '); + + # + # Execute the query and return the result. + # + self._oDb.execute(sQuery) + return self._oDb.fetchOne()[0] + + def getTestGroups(self, tsNow, sPeriod): + """ + Get list of uniq TestGroupData objects which + found in all test results. + """ + + self._oDb.execute('SELECT DISTINCT TestGroups.*\n' + 'FROM TestGroups, TestSets\n' + 'WHERE TestSets.idTestGroup = TestGroups.idTestGroup\n' + ' AND TestGroups.tsExpire > TestSets.tsCreated\n' + ' AND TestGroups.tsEffective <= TestSets.tsCreated' + ' AND ' + self._getTimePeriodQueryPart(tsNow, sPeriod)) + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(TestGroupData().initFromDbRow(aoRow)) + return aoRet + + def getBuilds(self, tsNow, sPeriod): + """ + Get list of uniq BuildDataEx objects which + found in all test results. + """ + + self._oDb.execute('SELECT DISTINCT Builds.*, BuildCategories.*\n' + 'FROM Builds, BuildCategories, TestSets\n' + 'WHERE TestSets.idBuild = Builds.idBuild\n' + ' AND Builds.idBuildCategory = BuildCategories.idBuildCategory\n' + ' AND Builds.tsExpire > TestSets.tsCreated\n' + ' AND Builds.tsEffective <= TestSets.tsCreated' + ' AND ' + self._getTimePeriodQueryPart(tsNow, sPeriod)) + aaoRows = self._oDb.fetchAll() + aoRet = [] + for aoRow in aaoRows: + aoRet.append(BuildDataEx().initFromDbRow(aoRow)) + return aoRet + + def getTestBoxes(self, tsNow, sPeriod): + """ + Get list of uniq TestBoxData objects which + found in all test results. + """ + # Note! INNER JOIN TestBoxesWithStrings performs miserable compared to LEFT OUTER JOIN. Doesn't matter for the result + # because TestSets.idGenTestBox is a foreign key and unique in TestBoxes. So, let's do what ever is faster. + self._oDb.execute('SELECT TestBoxesWithStrings.*\n' + 'FROM ( SELECT idTestBox AS idTestBox,\n' + ' MAX(idGenTestBox) AS idGenTestBox\n' + ' FROM TestSets\n' + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + ' GROUP BY idTestBox\n' + ' ) AS TestBoxIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxIDs.idGenTestBox\n' + 'ORDER BY TestBoxesWithStrings.sName\n' ); + aoRet = [] + for aoRow in self._oDb.fetchAll(): + aoRet.append(TestBoxData().initFromDbRow(aoRow)); + return aoRet + + def getTestCases(self, tsNow, sPeriod): + """ + Get a list of unique TestCaseData objects which is appears in the test + specified result period. + """ + + # Using LEFT OUTER JOIN instead of INNER JOIN in case it performs better, doesn't matter for the result. + self._oDb.execute('SELECT TestCases.*\n' + 'FROM ( SELECT idTestCase AS idTestCase,\n' + ' MAX(idGenTestCase) AS idGenTestCase\n' + ' FROM TestSets\n' + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + ' GROUP BY idTestCase\n' + ' ) AS TestCasesIDs\n' + ' LEFT OUTER JOIN TestCases ON TestCases.idGenTestCase = TestCasesIDs.idGenTestCase\n' + 'ORDER BY TestCases.sName\n' ); + + aoRet = []; + for aoRow in self._oDb.fetchAll(): + aoRet.append(TestCaseData().initFromDbRow(aoRow)); + return aoRet + + def getOSes(self, tsNow, sPeriod): + """ + Get a list of [idStrOs, sOs] tuples of the OSes that appears in the specified result period. + """ + + # Note! INNER JOIN TestBoxesWithStrings performs miserable compared to LEFT OUTER JOIN. Doesn't matter for the result + # because TestSets.idGenTestBox is a foreign key and unique in TestBoxes. So, let's do what ever is faster. + self._oDb.execute('SELECT DISTINCT TestBoxesWithStrings.idStrOs, TestBoxesWithStrings.sOs\n' + 'FROM ( SELECT idTestBox AS idTestBox,\n' + ' MAX(idGenTestBox) AS idGenTestBox\n' + ' FROM TestSets\n' + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + ' GROUP BY idTestBox\n' + ' ) AS TestBoxIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxIDs.idGenTestBox\n' + 'ORDER BY TestBoxesWithStrings.sOs\n' ); + return self._oDb.fetchAll(); + + def getArchitectures(self, tsNow, sPeriod): + """ + Get a list of [idStrCpuArch, sCpuArch] tuples of the architecutres + that appears in the specified result period. + """ + + # Note! INNER JOIN TestBoxesWithStrings performs miserable compared to LEFT OUTER JOIN. Doesn't matter for the result + # because TestSets.idGenTestBox is a foreign key and unique in TestBoxes. So, let's do what ever is faster. + self._oDb.execute('SELECT DISTINCT TestBoxesWithStrings.idStrCpuArch, TestBoxesWithStrings.sCpuArch\n' + 'FROM ( SELECT idTestBox AS idTestBox,\n' + ' MAX(idGenTestBox) AS idGenTestBox\n' + ' FROM TestSets\n' + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + ' GROUP BY idTestBox\n' + ' ) AS TestBoxIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxIDs.idGenTestBox\n' + 'ORDER BY TestBoxesWithStrings.sCpuArch\n' ); + return self._oDb.fetchAll(); + + def getBuildCategories(self, tsNow, sPeriod): + """ + Get a list of BuildCategoryData that appears in the specified result period. + """ + + self._oDb.execute('SELECT DISTINCT BuildCategories.*\n' + 'FROM ( SELECT DISTINCT idBuildCategory AS idBuildCategory\n' + ' FROM TestSets\n' + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + ' ) AS BuildCategoryIDs\n' + ' LEFT OUTER JOIN BuildCategories\n' + ' ON BuildCategories.idBuildCategory = BuildCategoryIDs.idBuildCategory\n' + 'ORDER BY BuildCategories.sProduct, BuildCategories.sBranch, BuildCategories.sType\n'); + aoRet = []; + for aoRow in self._oDb.fetchAll(): + aoRet.append(BuildCategoryData().initFromDbRow(aoRow)); + return aoRet; + + def getSchedGroups(self, tsNow, sPeriod): + """ + Get list of uniq SchedGroupData objects which + found in all test results. + """ + + self._oDb.execute('SELECT SchedGroups.*\n' + 'FROM ( SELECT idSchedGroup,\n' + ' MAX(TestSets.tsCreated) AS tsNow\n' + ' FROM TestSets\n' + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + ' GROUP BY idSchedGroup\n' + ' ) AS SchedGroupIDs\n' + ' INNER JOIN SchedGroups\n' + ' ON SchedGroups.idSchedGroup = SchedGroupIDs.idSchedGroup\n' + ' AND SchedGroups.tsExpire > SchedGroupIDs.tsNow\n' + ' AND SchedGroups.tsEffective <= SchedGroupIDs.tsNow\n' + 'ORDER BY SchedGroups.sName\n' ); + aoRet = [] + for aoRow in self._oDb.fetchAll(): + aoRet.append(SchedGroupData().initFromDbRow(aoRow)); + return aoRet + + def getById(self, idTestResult): + """ + Get build record by its id + """ + self._oDb.execute('SELECT *\n' + 'FROM TestResults\n' + 'WHERE idTestResult = %s\n', + (idTestResult,)) + + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise TMTooManyRows('Found more than one test result with the same credentials. Database structure is corrupted.') + try: + return TestResultData().initFromDbRow(aRows[0]) + except IndexError: + return None + + def fetchPossibleFilterOptions(self, oFilter, tsNow, sPeriod, oReportModel = None): + """ + Fetches the available filter criteria, given the current filtering. + + Returns oFilter. + """ + assert isinstance(oFilter, TestResultFilter); + + # Hack to avoid lot's of conditionals or duplicate this code. + if oReportModel is None: + class DummyReportModel(object): + """ Dummy """ + def getExtraSubjectTables(self): + """ Dummy """ + return []; + def getExtraSubjectWhereExpr(self): + """ Dummy """ + return ''; + oReportModel = DummyReportModel(); + + def workerDoFetch(oMissingLogicType, sNameAttr = 'sName', fIdIsName = False, idxHover = -1, + idNull = -1, sNullDesc = ''): + """ Does the tedious result fetching and handling of missing bits. """ + dLeft = { oValue: 1 for oValue in oCrit.aoSelected }; + oCrit.aoPossible = []; + for aoRow in self._oDb.fetchAll(): + oCrit.aoPossible.append(FilterCriterionValueAndDescription(aoRow[0] if aoRow[0] is not None else idNull, + aoRow[1] if aoRow[1] is not None else sNullDesc, + aoRow[2], + aoRow[idxHover] if idxHover >= 0 else None)); + if aoRow[0] in dLeft: + del dLeft[aoRow[0]]; + if dLeft: + if fIdIsName: + for idMissing in dLeft: + oCrit.aoPossible.append(FilterCriterionValueAndDescription(idMissing, str(idMissing), + fIrrelevant = True)); + else: + oMissingLogic = oMissingLogicType(self._oDb); + for idMissing in dLeft: + oMissing = oMissingLogic.cachedLookup(idMissing); + if oMissing is not None: + oCrit.aoPossible.append(FilterCriterionValueAndDescription(idMissing, + getattr(oMissing, sNameAttr), + fIrrelevant = True)); + + def workerDoFetchNested(): + """ Does the tedious result fetching and handling of missing bits. """ + oCrit.aoPossible = []; + oCrit.oSub.aoPossible = []; + dLeft = { oValue: 1 for oValue in oCrit.aoSelected }; + dSubLeft = { oValue: 1 for oValue in oCrit.oSub.aoSelected }; + oMain = None; + for aoRow in self._oDb.fetchAll(): + if oMain is None or oMain.oValue != aoRow[0]: + oMain = FilterCriterionValueAndDescription(aoRow[0], aoRow[1], 0); + oCrit.aoPossible.append(oMain); + if aoRow[0] in dLeft: + del dLeft[aoRow[0]]; + oCurSub = FilterCriterionValueAndDescription(aoRow[2], aoRow[3], aoRow[4]); + oCrit.oSub.aoPossible.append(oCurSub); + if aoRow[2] in dSubLeft: + del dSubLeft[aoRow[2]]; + + oMain.aoSubs.append(oCurSub); + oMain.cTimes += aoRow[4]; + + if dLeft: + pass; ## @todo + + # Statuses. + oCrit = oFilter.aCriteria[TestResultFilter.kiTestStatus]; + self._oDb.execute('SELECT TestSets.enmStatus, TestSets.enmStatus, COUNT(TestSets.idTestSet)\n' + 'FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiTestStatus) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + 'WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod) + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiTestStatus) + + oReportModel.getExtraSubjectWhereExpr() + + 'GROUP BY TestSets.enmStatus\n' + 'ORDER BY TestSets.enmStatus\n'); + workerDoFetch(None, fIdIsName = True); + + # Scheduling groups (see getSchedGroups). + oCrit = oFilter.aCriteria[TestResultFilter.kiSchedGroups]; + self._oDb.execute('SELECT SchedGroups.idSchedGroup, SchedGroups.sName, SchedGroupIDs.cTimes\n' + 'FROM ( SELECT TestSets.idSchedGroup,\n' + ' MAX(TestSets.tsCreated) AS tsNow,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiSchedGroups) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiSchedGroups) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idSchedGroup\n' + ' ) AS SchedGroupIDs\n' + ' INNER JOIN SchedGroups\n' + ' ON SchedGroups.idSchedGroup = SchedGroupIDs.idSchedGroup\n' + ' AND SchedGroups.tsExpire > SchedGroupIDs.tsNow\n' + ' AND SchedGroups.tsEffective <= SchedGroupIDs.tsNow\n' + 'ORDER BY SchedGroups.sName\n' ); + workerDoFetch(SchedGroupLogic); + + # Testboxes (see getTestBoxes). + oCrit = oFilter.aCriteria[TestResultFilter.kiTestBoxes]; + self._oDb.execute('SELECT TestBoxesWithStrings.idTestBox,\n' + ' TestBoxesWithStrings.sName,\n' + ' TestBoxIDs.cTimes\n' + 'FROM ( SELECT TestSets.idTestBox AS idTestBox,\n' + ' MAX(TestSets.idGenTestBox) AS idGenTestBox,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiTestBoxes) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiTestBoxes) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idTestBox\n' + ' ) AS TestBoxIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxIDs.idGenTestBox\n' + 'ORDER BY TestBoxesWithStrings.sName\n' ); + workerDoFetch(TestBoxLogic); + + # Testbox OSes and versions. + oCrit = oFilter.aCriteria[TestResultFilter.kiOses]; + self._oDb.execute('SELECT TestBoxesWithStrings.idStrOs,\n' + ' TestBoxesWithStrings.sOs,\n' + ' TestBoxesWithStrings.idStrOsVersion,\n' + ' TestBoxesWithStrings.sOsVersion,\n' + ' SUM(TestBoxGenIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idGenTestBox,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiOses) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiOses) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idGenTestBox\n' + ' ) AS TestBoxGenIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxGenIDs.idGenTestBox\n' + 'GROUP BY TestBoxesWithStrings.idStrOs,\n' + ' TestBoxesWithStrings.sOs,\n' + ' TestBoxesWithStrings.idStrOsVersion,\n' + ' TestBoxesWithStrings.sOsVersion\n' + 'ORDER BY TestBoxesWithStrings.sOs,\n' + ' TestBoxesWithStrings.sOs = \'win\' AND TestBoxesWithStrings.sOsVersion = \'10\' DESC,\n' + ' TestBoxesWithStrings.sOsVersion DESC\n' + ); + workerDoFetchNested(); + + # Testbox CPU(/OS) architectures. + oCrit = oFilter.aCriteria[TestResultFilter.kiCpuArches]; + self._oDb.execute('SELECT TestBoxesWithStrings.idStrCpuArch,\n' + ' TestBoxesWithStrings.sCpuArch,\n' + ' SUM(TestBoxGenIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idGenTestBox,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiCpuArches) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiCpuArches) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idGenTestBox\n' + ' ) AS TestBoxGenIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxGenIDs.idGenTestBox\n' + 'GROUP BY TestBoxesWithStrings.idStrCpuArch, TestBoxesWithStrings.sCpuArch\n' + 'ORDER BY TestBoxesWithStrings.sCpuArch\n' ); + workerDoFetch(None, fIdIsName = True); + + # Testbox CPU revisions. + oCrit = oFilter.aCriteria[TestResultFilter.kiCpuVendors]; + self._oDb.execute('SELECT TestBoxesWithStrings.idStrCpuVendor,\n' + ' TestBoxesWithStrings.sCpuVendor,\n' + ' TestBoxesWithStrings.lCpuRevision,\n' + ' TestBoxesWithStrings.sCpuVendor,\n' + ' SUM(TestBoxGenIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idGenTestBox,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiCpuVendors) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiCpuVendors) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idGenTestBox' + ' ) AS TestBoxGenIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxGenIDs.idGenTestBox\n' + 'GROUP BY TestBoxesWithStrings.idStrCpuVendor,\n' + ' TestBoxesWithStrings.sCpuVendor,\n' + ' TestBoxesWithStrings.lCpuRevision,\n' + ' TestBoxesWithStrings.sCpuVendor\n' + 'ORDER BY TestBoxesWithStrings.sCpuVendor DESC,\n' + ' TestBoxesWithStrings.sCpuVendor = \'GenuineIntel\'\n' + ' AND (TestBoxesWithStrings.lCpuRevision >> 24) = 15,\n' # P4 at the bottom is a start... + ' TestBoxesWithStrings.lCpuRevision DESC\n' + ); + workerDoFetchNested(); + for oCur in oCrit.oSub.aoPossible: + oCur.sDesc = TestBoxData.getPrettyCpuVersionEx(oCur.oValue, oCur.sDesc).replace('_', ' '); + + # Testbox CPU core/thread counts. + oCrit = oFilter.aCriteria[TestResultFilter.kiCpuCounts]; + self._oDb.execute('SELECT TestBoxesWithStrings.cCpus,\n' + ' CAST(TestBoxesWithStrings.cCpus AS TEXT),\n' + ' SUM(TestBoxGenIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idGenTestBox,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiCpuCounts) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiCpuCounts) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idGenTestBox' + ' ) AS TestBoxGenIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxGenIDs.idGenTestBox\n' + 'GROUP BY TestBoxesWithStrings.cCpus\n' + 'ORDER BY TestBoxesWithStrings.cCpus\n' ); + workerDoFetch(None, fIdIsName = True); + + # Testbox memory. + oCrit = oFilter.aCriteria[TestResultFilter.kiMemory]; + self._oDb.execute('SELECT TestBoxesWithStrings.cMbMemory / 1024,\n' + ' NULL,\n' + ' SUM(TestBoxGenIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idGenTestBox,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiMemory) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiMemory) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idGenTestBox' + ' ) AS TestBoxGenIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxGenIDs.idGenTestBox\n' + 'GROUP BY TestBoxesWithStrings.cMbMemory / 1024\n' + 'ORDER BY 1\n' ); + workerDoFetch(None, fIdIsName = True); + for oCur in oCrit.aoPossible: + oCur.sDesc = '%u GB' % (oCur.oValue,); + + # Testbox python versions . + oCrit = oFilter.aCriteria[TestResultFilter.kiPythonVersions]; + self._oDb.execute('SELECT TestBoxesWithStrings.iPythonHexVersion,\n' + ' NULL,\n' + ' SUM(TestBoxGenIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idGenTestBox AS idGenTestBox,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiPythonVersions) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiPythonVersions) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idGenTestBox\n' + ' ) AS TestBoxGenIDs\n' + ' LEFT OUTER JOIN TestBoxesWithStrings\n' + ' ON TestBoxesWithStrings.idGenTestBox = TestBoxGenIDs.idGenTestBox\n' + 'GROUP BY TestBoxesWithStrings.iPythonHexVersion\n' + 'ORDER BY TestBoxesWithStrings.iPythonHexVersion\n' ); + workerDoFetch(None, fIdIsName = True); + for oCur in oCrit.aoPossible: + oCur.sDesc = TestBoxData.formatPythonVersionEx(oCur.oValue); # pylint: disable=redefined-variable-type + + # Testcase with variation. + oCrit = oFilter.aCriteria[TestResultFilter.kiTestCases]; + self._oDb.execute('SELECT TestCaseArgsIDs.idTestCase,\n' + ' TestCases.sName,\n' + ' TestCaseArgsIDs.idTestCaseArgs,\n' + ' CASE WHEN TestCaseArgs.sSubName IS NULL OR TestCaseArgs.sSubName = \'\' THEN\n' + ' CONCAT(\'/ #\', TestCaseArgs.idTestCaseArgs)\n' + ' ELSE\n' + ' TestCaseArgs.sSubName\n' + ' END,' + ' TestCaseArgsIDs.cTimes\n' + 'FROM ( SELECT TestSets.idTestCase AS idTestCase,\n' + ' TestSets.idTestCaseArgs AS idTestCaseArgs,\n' + ' MAX(TestSets.idGenTestCase) AS idGenTestCase,\n' + ' MAX(TestSets.idGenTestCaseArgs) AS idGenTestCaseArgs,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiTestCases) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiTestCases) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idTestCase, TestSets.idTestCaseArgs\n' + ' ) AS TestCaseArgsIDs\n' + ' LEFT OUTER JOIN TestCases ON TestCases.idGenTestCase = TestCaseArgsIDs.idGenTestCase\n' + ' LEFT OUTER JOIN TestCaseArgs\n' + ' ON TestCaseArgs.idGenTestCaseArgs = TestCaseArgsIDs.idGenTestCaseArgs\n' + 'ORDER BY TestCases.sName, 4\n' ); + workerDoFetchNested(); + + # Build revisions. + oCrit = oFilter.aCriteria[TestResultFilter.kiRevisions]; + self._oDb.execute('SELECT Builds.iRevision, CONCAT(\'r\', Builds.iRevision), SUM(BuildIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idBuild AS idBuild,\n' + ' MAX(TestSets.tsCreated) AS tsNow,\n' + ' COUNT(TestSets.idBuild) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiRevisions) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiRevisions) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idBuild\n' + ' ) AS BuildIDs\n' + ' INNER JOIN Builds\n' + ' ON Builds.idBuild = BuildIDs.idBuild\n' + ' AND Builds.tsExpire > BuildIDs.tsNow\n' + ' AND Builds.tsEffective <= BuildIDs.tsNow\n' + 'GROUP BY Builds.iRevision\n' + 'ORDER BY Builds.iRevision DESC\n' ); + workerDoFetch(None, fIdIsName = True); + + # Build branches. + oCrit = oFilter.aCriteria[TestResultFilter.kiBranches]; + self._oDb.execute('SELECT BuildCategories.sBranch, BuildCategories.sBranch, SUM(BuildCategoryIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idBuildCategory,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiBranches) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiBranches) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idBuildCategory\n' + ' ) AS BuildCategoryIDs\n' + ' INNER JOIN BuildCategories\n' + ' ON BuildCategories.idBuildCategory = BuildCategoryIDs.idBuildCategory\n' + 'GROUP BY BuildCategories.sBranch\n' + 'ORDER BY BuildCategories.sBranch DESC\n' ); + workerDoFetch(None, fIdIsName = True); + + # Build types. + oCrit = oFilter.aCriteria[TestResultFilter.kiBuildTypes]; + self._oDb.execute('SELECT BuildCategories.sType, BuildCategories.sType, SUM(BuildCategoryIDs.cTimes)\n' + 'FROM ( SELECT TestSets.idBuildCategory,\n' + ' COUNT(TestSets.idTestSet) AS cTimes\n' + ' FROM TestSets\n' + oFilter.getTableJoins(iOmit = TestResultFilter.kiBuildTypes) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiBuildTypes) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestSets.idBuildCategory\n' + ' ) AS BuildCategoryIDs\n' + ' INNER JOIN BuildCategories\n' + ' ON BuildCategories.idBuildCategory = BuildCategoryIDs.idBuildCategory\n' + 'GROUP BY BuildCategories.sType\n' + 'ORDER BY BuildCategories.sType DESC\n' ); + workerDoFetch(None, fIdIsName = True); + + # Failure reasons. + oCrit = oFilter.aCriteria[TestResultFilter.kiFailReasons]; + self._oDb.execute('SELECT FailureReasons.idFailureReason, FailureReasons.sShort, FailureReasonIDs.cTimes\n' + 'FROM ( SELECT TestResultFailures.idFailureReason,\n' + ' COUNT(TestSets.idTestSet) as cTimes\n' + ' FROM TestSets\n' + ' LEFT OUTER JOIN TestResultFailures\n' + ' ON TestResultFailures.idTestSet = TestSets.idTestSet\n' + ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n' + + oFilter.getTableJoins(iOmit = TestResultFilter.kiFailReasons) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + ' AND TestSets.enmStatus >= \'failure\'::TestStatus_T\n' + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiFailReasons) + + oReportModel.getExtraSubjectWhereExpr() + + ' GROUP BY TestResultFailures.idFailureReason\n' + ' ) AS FailureReasonIDs\n' + ' LEFT OUTER JOIN FailureReasons\n' + ' ON FailureReasons.idFailureReason = FailureReasonIDs.idFailureReason\n' + ' AND FailureReasons.tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY FailureReasons.idFailureReason IS NULL DESC,\n' + ' FailureReasons.sShort\n' ); + workerDoFetch(FailureReasonLogic, 'sShort', sNullDesc = 'Not given'); + + # Error counts. + oCrit = oFilter.aCriteria[TestResultFilter.kiErrorCounts]; + self._oDb.execute('SELECT TestResults.cErrors, CAST(TestResults.cErrors AS TEXT), COUNT(TestResults.idTestResult)\n' + 'FROM ( SELECT TestSets.idTestResult AS idTestResult\n' + ' FROM TestSets\n' + + oFilter.getTableJoins(iOmit = TestResultFilter.kiFailReasons) + + ''.join(' , %s\n' % (sTable,) for sTable in oReportModel.getExtraSubjectTables()) + + ' WHERE ' + self._getTimePeriodQueryPart(tsNow, sPeriod, ' ') + + oFilter.getWhereConditions(iOmit = TestResultFilter.kiFailReasons) + + oReportModel.getExtraSubjectWhereExpr() + + ' ) AS TestSetIDs\n' + ' INNER JOIN TestResults\n' + ' ON TestResults.idTestResult = TestSetIDs.idTestResult\n' + 'GROUP BY TestResults.cErrors\n' + 'ORDER BY TestResults.cErrors\n'); + + workerDoFetch(None, fIdIsName = True); + + return oFilter; + + + # + # Details view and interface. + # + + def fetchResultTree(self, idTestSet, cMaxDepth = None): + """ + Fetches the result tree for the given test set. + + Returns a tree of TestResultDataEx nodes. + Raises exception on invalid input and database issues. + """ + # Depth first, i.e. just like the XML added them. + ## @todo this still isn't performing extremely well, consider optimizations. + sQuery = self._oDb.formatBindArgs( + 'SELECT TestResults.*,\n' + ' TestResultStrTab.sValue,\n' + ' EXISTS ( SELECT idTestResultValue\n' + ' FROM TestResultValues\n' + ' WHERE TestResultValues.idTestResult = TestResults.idTestResult ) AS fHasValues,\n' + ' EXISTS ( SELECT idTestResultMsg\n' + ' FROM TestResultMsgs\n' + ' WHERE TestResultMsgs.idTestResult = TestResults.idTestResult ) AS fHasMsgs,\n' + ' EXISTS ( SELECT idTestResultFile\n' + ' FROM TestResultFiles\n' + ' WHERE TestResultFiles.idTestResult = TestResults.idTestResult ) AS fHasFiles,\n' + ' EXISTS ( SELECT idTestResult\n' + ' FROM TestResultFailures\n' + ' WHERE TestResultFailures.idTestResult = TestResults.idTestResult ) AS fHasReasons\n' + 'FROM TestResults, TestResultStrTab\n' + 'WHERE TestResults.idTestSet = %s\n' + ' AND TestResults.idStrName = TestResultStrTab.idStr\n' + , ( idTestSet, )); + if cMaxDepth is not None: + sQuery += self._oDb.formatBindArgs(' AND TestResults.iNestingDepth <= %s\n', (cMaxDepth,)); + sQuery += 'ORDER BY idTestResult ASC\n' + + self._oDb.execute(sQuery); + cRows = self._oDb.getRowCount(); + if cRows > 65536: + raise TMTooManyRows('Too many rows returned for idTestSet=%d: %d' % (idTestSet, cRows,)); + + aaoRows = self._oDb.fetchAll(); + if not aaoRows: + raise TMRowNotFound('No test results for idTestSet=%d.' % (idTestSet,)); + + # Set up the root node first. + aoRow = aaoRows[0]; + oRoot = TestResultDataEx().initFromDbRow(aoRow); + if oRoot.idTestResultParent is not None: + raise self._oDb.integrityException('The root TestResult (#%s) has a parent (#%s)!' + % (oRoot.idTestResult, oRoot.idTestResultParent)); + self._fetchResultTreeNodeExtras(oRoot, aoRow[-4], aoRow[-3], aoRow[-2], aoRow[-1]); + + # The children (if any). + dLookup = { oRoot.idTestResult: oRoot }; + oParent = oRoot; + for iRow in range(1, len(aaoRows)): + aoRow = aaoRows[iRow]; + oCur = TestResultDataEx().initFromDbRow(aoRow); + self._fetchResultTreeNodeExtras(oCur, aoRow[-4], aoRow[-3], aoRow[-2], aoRow[-1]); + + # Figure out and vet the parent. + if oParent.idTestResult != oCur.idTestResultParent: + oParent = dLookup.get(oCur.idTestResultParent, None); + if oParent is None: + raise self._oDb.integrityException('TestResult #%d is orphaned from its parent #%s.' + % (oCur.idTestResult, oCur.idTestResultParent,)); + if oParent.iNestingDepth + 1 != oCur.iNestingDepth: + raise self._oDb.integrityException('TestResult #%d has incorrect nesting depth (%d instead of %d)' + % (oCur.idTestResult, oCur.iNestingDepth, oParent.iNestingDepth + 1,)); + + # Link it up. + oCur.oParent = oParent; + oParent.aoChildren.append(oCur); + dLookup[oCur.idTestResult] = oCur; + + return (oRoot, dLookup); + + def _fetchResultTreeNodeExtras(self, oCurNode, fHasValues, fHasMsgs, fHasFiles, fHasReasons): + """ + fetchResultTree worker that fetches values, message and files for the + specified node. + """ + assert(oCurNode.aoValues == []); + assert(oCurNode.aoMsgs == []); + assert(oCurNode.aoFiles == []); + assert(oCurNode.oReason is None); + + if fHasValues: + self._oDb.execute('SELECT TestResultValues.*,\n' + ' TestResultStrTab.sValue\n' + 'FROM TestResultValues, TestResultStrTab\n' + 'WHERE TestResultValues.idTestResult = %s\n' + ' AND TestResultValues.idStrName = TestResultStrTab.idStr\n' + 'ORDER BY idTestResultValue ASC\n' + , ( oCurNode.idTestResult, )); + for aoRow in self._oDb.fetchAll(): + oCurNode.aoValues.append(TestResultValueDataEx().initFromDbRow(aoRow)); + + if fHasMsgs: + self._oDb.execute('SELECT TestResultMsgs.*,\n' + ' TestResultStrTab.sValue\n' + 'FROM TestResultMsgs, TestResultStrTab\n' + 'WHERE TestResultMsgs.idTestResult = %s\n' + ' AND TestResultMsgs.idStrMsg = TestResultStrTab.idStr\n' + 'ORDER BY idTestResultMsg ASC\n' + , ( oCurNode.idTestResult, )); + for aoRow in self._oDb.fetchAll(): + oCurNode.aoMsgs.append(TestResultMsgDataEx().initFromDbRow(aoRow)); + + if fHasFiles: + self._oDb.execute('SELECT TestResultFiles.*,\n' + ' StrTabFile.sValue AS sFile,\n' + ' StrTabDesc.sValue AS sDescription,\n' + ' StrTabKind.sValue AS sKind,\n' + ' StrTabMime.sValue AS sMime\n' + 'FROM TestResultFiles,\n' + ' TestResultStrTab AS StrTabFile,\n' + ' TestResultStrTab AS StrTabDesc,\n' + ' TestResultStrTab AS StrTabKind,\n' + ' TestResultStrTab AS StrTabMime\n' + 'WHERE TestResultFiles.idTestResult = %s\n' + ' AND TestResultFiles.idStrFile = StrTabFile.idStr\n' + ' AND TestResultFiles.idStrDescription = StrTabDesc.idStr\n' + ' AND TestResultFiles.idStrKind = StrTabKind.idStr\n' + ' AND TestResultFiles.idStrMime = StrTabMime.idStr\n' + 'ORDER BY idTestResultFile ASC\n' + , ( oCurNode.idTestResult, )); + for aoRow in self._oDb.fetchAll(): + oCurNode.aoFiles.append(TestResultFileDataEx().initFromDbRow(aoRow)); + + if fHasReasons: + if self.oFailureReasonLogic is None: + self.oFailureReasonLogic = FailureReasonLogic(self._oDb); + if self.oUserAccountLogic is None: + self.oUserAccountLogic = UserAccountLogic(self._oDb); + self._oDb.execute('SELECT *\n' + 'FROM TestResultFailures\n' + 'WHERE idTestResult = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , ( oCurNode.idTestResult, )); + if self._oDb.getRowCount() > 0: + oCurNode.oReason = TestResultFailureDataEx().initFromDbRowEx(self._oDb.fetchOne(), self.oFailureReasonLogic, + self.oUserAccountLogic); + + return True; + + + + # + # TestBoxController interface(s). + # + + def _inhumeTestResults(self, aoStack, idTestSet, sError): + """ + The test produces too much output, kill and bury it. + + Note! We leave the test set open, only the test result records are + completed. Thus, _getResultStack will return an empty stack and + cause XML processing to fail immediately, while we can still + record when it actually completed in the test set the normal way. + """ + self._oDb.dprint('** _inhumeTestResults: idTestSet=%d\n%s' % (idTestSet, self._stringifyStack(aoStack),)); + + # + # First add a message. + # + self._newFailureDetails(aoStack[0].idTestResult, idTestSet, sError, None); + + # + # The complete all open test results. + # + for oTestResult in aoStack: + oTestResult.cErrors += 1; + self._completeTestResults(oTestResult, None, TestResultData.ksTestStatus_Failure, oTestResult.cErrors); + + # A bit of paranoia. + self._oDb.execute('UPDATE TestResults\n' + 'SET cErrors = cErrors + 1,\n' + ' enmStatus = \'failure\'::TestStatus_T,\n' + ' tsElapsed = CURRENT_TIMESTAMP - tsCreated\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = \'running\'::TestStatus_T\n' + , ( idTestSet, )); + self._oDb.commit(); + + return None; + + def strTabString(self, sString, fCommit = False): + """ + Gets the string table id for the given string, adding it if new. + + Note! A copy of this code is also in TestSetLogic. + """ + ## @todo move this and make a stored procedure for it. + self._oDb.execute('SELECT idStr\n' + 'FROM TestResultStrTab\n' + 'WHERE sValue = %s' + , (sString,)); + if self._oDb.getRowCount() == 0: + self._oDb.execute('INSERT INTO TestResultStrTab (sValue)\n' + 'VALUES (%s)\n' + 'RETURNING idStr\n' + , (sString,)); + if fCommit: + self._oDb.commit(); + return self._oDb.fetchOne()[0]; + + @staticmethod + def _stringifyStack(aoStack): + """Returns a string rep of the stack.""" + sRet = ''; + for i, _ in enumerate(aoStack): + sRet += 'aoStack[%d]=%s\n' % (i, aoStack[i]); + return sRet; + + def _getResultStack(self, idTestSet): + """ + Gets the current stack of result sets. + """ + self._oDb.execute('SELECT *\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = \'running\'::TestStatus_T\n' + 'ORDER BY idTestResult DESC' + , ( idTestSet, )); + aoStack = []; + for aoRow in self._oDb.fetchAll(): + aoStack.append(TestResultData().initFromDbRow(aoRow)); + + for i, _ in enumerate(aoStack): + assert aoStack[i].iNestingDepth == len(aoStack) - i - 1, self._stringifyStack(aoStack); + + return aoStack; + + def _newTestResult(self, idTestResultParent, idTestSet, iNestingDepth, tsCreated, sName, dCounts, fCommit = False): + """ + Creates a new test result. + Returns the TestResultData object for the new record. + May raise exception on database error. + """ + assert idTestResultParent is not None; + assert idTestResultParent > 1; + + # + # This isn't necessarily very efficient, but it's necessary to prevent + # a wild test or testbox from filling up the database. + # + sCountName = 'cTestResults'; + if sCountName not in dCounts: + self._oDb.execute('SELECT COUNT(idTestResult)\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + , ( idTestSet,)); + dCounts[sCountName] = self._oDb.fetchOne()[0]; + dCounts[sCountName] += 1; + if dCounts[sCountName] > config.g_kcMaxTestResultsPerTS: + raise TestResultHangingOffence('Too many sub-tests in total!'); + + sCountName = 'cTestResultsIn%d' % (idTestResultParent,); + if sCountName not in dCounts: + self._oDb.execute('SELECT COUNT(idTestResult)\n' + 'FROM TestResults\n' + 'WHERE idTestResultParent = %s\n' + , ( idTestResultParent,)); + dCounts[sCountName] = self._oDb.fetchOne()[0]; + dCounts[sCountName] += 1; + if dCounts[sCountName] > config.g_kcMaxTestResultsPerTR: + raise TestResultHangingOffence('Too many immediate sub-tests!'); + + # This is also a hanging offence. + if iNestingDepth > config.g_kcMaxTestResultDepth: + raise TestResultHangingOffence('To deep sub-test nesting!'); + + # Ditto. + if len(sName) > config.g_kcchMaxTestResultName: + raise TestResultHangingOffence('Test name is too long: %d chars - "%s"' % (len(sName), sName)); + + # + # Within bounds, do the job. + # + idStrName = self.strTabString(sName, fCommit); + self._oDb.execute('INSERT INTO TestResults (\n' + ' idTestResultParent,\n' + ' idTestSet,\n' + ' tsCreated,\n' + ' idStrName,\n' + ' iNestingDepth )\n' + 'VALUES (%s, %s, TIMESTAMP WITH TIME ZONE %s, %s, %s)\n' + 'RETURNING *\n' + , ( idTestResultParent, idTestSet, tsCreated, idStrName, iNestingDepth) ) + oData = TestResultData().initFromDbRow(self._oDb.fetchOne()); + + self._oDb.maybeCommit(fCommit); + return oData; + + def _newTestValue(self, idTestResult, idTestSet, sName, lValue, sUnit, dCounts, tsCreated = None, fCommit = False): + """ + Creates a test value. + May raise exception on database error. + """ + + # + # Bounds checking. + # + sCountName = 'cTestValues'; + if sCountName not in dCounts: + self._oDb.execute('SELECT COUNT(idTestResultValue)\n' + 'FROM TestResultValues, TestResults\n' + 'WHERE TestResultValues.idTestResult = TestResults.idTestResult\n' + ' AND TestResults.idTestSet = %s\n' + , ( idTestSet,)); + dCounts[sCountName] = self._oDb.fetchOne()[0]; + dCounts[sCountName] += 1; + if dCounts[sCountName] > config.g_kcMaxTestValuesPerTS: + raise TestResultHangingOffence('Too many values in total!'); + + sCountName = 'cTestValuesIn%d' % (idTestResult,); + if sCountName not in dCounts: + self._oDb.execute('SELECT COUNT(idTestResultValue)\n' + 'FROM TestResultValues\n' + 'WHERE idTestResult = %s\n' + , ( idTestResult,)); + dCounts[sCountName] = self._oDb.fetchOne()[0]; + dCounts[sCountName] += 1; + if dCounts[sCountName] > config.g_kcMaxTestValuesPerTR: + raise TestResultHangingOffence('Too many immediate values for one test result!'); + + if len(sName) > config.g_kcchMaxTestValueName: + raise TestResultHangingOffence('Value name is too long: %d chars - "%s"' % (len(sName), sName)); + + # + # Do the job. + # + iUnit = constants.valueunit.g_kdNameToConst.get(sUnit, constants.valueunit.NONE); + + idStrName = self.strTabString(sName, fCommit); + if tsCreated is None: + self._oDb.execute('INSERT INTO TestResultValues (\n' + ' idTestResult,\n' + ' idTestSet,\n' + ' idStrName,\n' + ' lValue,\n' + ' iUnit)\n' + 'VALUES ( %s, %s, %s, %s, %s )\n' + , ( idTestResult, idTestSet, idStrName, lValue, iUnit,) ); + else: + self._oDb.execute('INSERT INTO TestResultValues (\n' + ' idTestResult,\n' + ' idTestSet,\n' + ' tsCreated,\n' + ' idStrName,\n' + ' lValue,\n' + ' iUnit)\n' + 'VALUES ( %s, %s, TIMESTAMP WITH TIME ZONE %s, %s, %s, %s )\n' + , ( idTestResult, idTestSet, tsCreated, idStrName, lValue, iUnit,) ); + self._oDb.maybeCommit(fCommit); + return True; + + def _newFailureDetails(self, idTestResult, idTestSet, sText, dCounts, tsCreated = None, fCommit = False): + """ + Creates a record detailing cause of failure. + May raise exception on database error. + """ + + # + # Overflow protection. + # + if dCounts is not None: + sCountName = 'cTestMsgsIn%d' % (idTestResult,); + if sCountName not in dCounts: + self._oDb.execute('SELECT COUNT(idTestResultMsg)\n' + 'FROM TestResultMsgs\n' + 'WHERE idTestResult = %s\n' + , ( idTestResult,)); + dCounts[sCountName] = self._oDb.fetchOne()[0]; + dCounts[sCountName] += 1; + if dCounts[sCountName] > config.g_kcMaxTestMsgsPerTR: + raise TestResultHangingOffence('Too many messages under for one test result!'); + + if len(sText) > config.g_kcchMaxTestMsg: + raise TestResultHangingOffence('Failure details message is too long: %d chars - "%s"' % (len(sText), sText)); + + # + # Do the job. + # + idStrMsg = self.strTabString(sText, fCommit); + if tsCreated is None: + self._oDb.execute('INSERT INTO TestResultMsgs (\n' + ' idTestResult,\n' + ' idTestSet,\n' + ' idStrMsg,\n' + ' enmLevel)\n' + 'VALUES ( %s, %s, %s, %s)\n' + , ( idTestResult, idTestSet, idStrMsg, 'failure',) ); + else: + self._oDb.execute('INSERT INTO TestResultMsgs (\n' + ' idTestResult,\n' + ' idTestSet,\n' + ' tsCreated,\n' + ' idStrMsg,\n' + ' enmLevel)\n' + 'VALUES ( %s, %s, TIMESTAMP WITH TIME ZONE %s, %s, %s)\n' + , ( idTestResult, idTestSet, tsCreated, idStrMsg, 'failure',) ); + + self._oDb.maybeCommit(fCommit); + return True; + + + def _completeTestResults(self, oTestResult, tsDone, enmStatus, cErrors = 0, fCommit = False): + """ + Completes a test result. Updates the oTestResult object. + May raise exception on database error. + """ + self._oDb.dprint('** _completeTestResults: cErrors=%s tsDone=%s enmStatus=%s oTestResults=\n%s' + % (cErrors, tsDone, enmStatus, oTestResult,)); + + # + # Sanity check: No open sub tests (aoStack should make sure about this!). + # + self._oDb.execute('SELECT COUNT(idTestResult)\n' + 'FROM TestResults\n' + 'WHERE idTestResultParent = %s\n' + ' AND enmStatus = %s\n' + , ( oTestResult.idTestResult, TestResultData.ksTestStatus_Running,)); + cOpenSubTest = self._oDb.fetchOne()[0]; + assert cOpenSubTest == 0, 'cOpenSubTest=%d - %s' % (cOpenSubTest, oTestResult,); + assert oTestResult.enmStatus == TestResultData.ksTestStatus_Running; + + # + # Make sure the reporter isn't lying about successes or error counts. + # + self._oDb.execute('SELECT COALESCE(SUM(cErrors), 0)\n' + 'FROM TestResults\n' + 'WHERE idTestResultParent = %s\n' + , ( oTestResult.idTestResult, )); + cMinErrors = self._oDb.fetchOne()[0] + oTestResult.cErrors; + cErrors = max(cErrors, cMinErrors); + if cErrors > 0 and enmStatus == TestResultData.ksTestStatus_Success: + enmStatus = TestResultData.ksTestStatus_Failure + + # + # Do the update. + # + if tsDone is None: + self._oDb.execute('UPDATE TestResults\n' + 'SET cErrors = %s,\n' + ' enmStatus = %s,\n' + ' tsElapsed = CURRENT_TIMESTAMP - tsCreated\n' + 'WHERE idTestResult = %s\n' + 'RETURNING tsElapsed' + , ( cErrors, enmStatus, oTestResult.idTestResult,) ); + else: + self._oDb.execute('UPDATE TestResults\n' + 'SET cErrors = %s,\n' + ' enmStatus = %s,\n' + ' tsElapsed = TIMESTAMP WITH TIME ZONE %s - tsCreated\n' + 'WHERE idTestResult = %s\n' + 'RETURNING tsElapsed' + , ( cErrors, enmStatus, tsDone, oTestResult.idTestResult,) ); + + oTestResult.tsElapsed = self._oDb.fetchOne()[0]; + oTestResult.enmStatus = enmStatus; + oTestResult.cErrors = cErrors; + + self._oDb.maybeCommit(fCommit); + return None; + + def _doPopHint(self, aoStack, cStackEntries, dCounts, idTestSet): + """ Executes a PopHint. """ + assert cStackEntries >= 0; + while len(aoStack) > cStackEntries: + if aoStack[0].enmStatus == TestResultData.ksTestStatus_Running: + self._newFailureDetails(aoStack[0].idTestResult, idTestSet, 'XML error: Missing ', dCounts); + self._completeTestResults(aoStack[0], tsDone = None, cErrors = 1, + enmStatus = TestResultData.ksTestStatus_Failure, fCommit = True); + aoStack.pop(0); + return True; + + + @staticmethod + def _validateElement(sName, dAttribs, fClosed): + """ + Validates an element and its attributes. + """ + + # + # Validate attributes by name. + # + + # Validate integer attributes. + for sAttr in [ 'errors', 'testdepth' ]: + if sAttr in dAttribs: + try: + _ = int(dAttribs[sAttr]); + except: + return 'Element %s has an invalid %s attribute value: %s.' % (sName, sAttr, dAttribs[sAttr],); + + # Validate long attributes. + for sAttr in [ 'value', ]: + if sAttr in dAttribs: + try: + _ = long(dAttribs[sAttr]); # pylint: disable=redefined-variable-type + except: + return 'Element %s has an invalid %s attribute value: %s.' % (sName, sAttr, dAttribs[sAttr],); + + # Validate string attributes. + for sAttr in [ 'name', 'text' ]: # 'unit' can be zero length. + if sAttr in dAttribs and not dAttribs[sAttr]: + return 'Element %s has an empty %s attribute value.' % (sName, sAttr,); + + # Validate the timestamp attribute. + if 'timestamp' in dAttribs: + (dAttribs['timestamp'], sError) = ModelDataBase.validateTs(dAttribs['timestamp'], fAllowNull = False); + if sError is not None: + return 'Element %s has an invalid timestamp ("%s"): %s' % (sName, dAttribs['timestamp'], sError,); + + + # + # Check that attributes that are required are present. + # We ignore extra attributes. + # + dElementAttribs = \ + { + 'Test': [ 'timestamp', 'name', ], + 'Value': [ 'timestamp', 'name', 'unit', 'value', ], + 'FailureDetails': [ 'timestamp', 'text', ], + 'Passed': [ 'timestamp', ], + 'Skipped': [ 'timestamp', ], + 'Failed': [ 'timestamp', 'errors', ], + 'TimedOut': [ 'timestamp', 'errors', ], + 'End': [ 'timestamp', ], + 'PushHint': [ 'testdepth', ], + 'PopHint': [ 'testdepth', ], + }; + if sName not in dElementAttribs: + return 'Unknown element "%s".' % (sName,); + for sAttr in dElementAttribs[sName]: + if sAttr not in dAttribs: + return 'Element %s requires attribute "%s".' % (sName, sAttr); + + # + # Only the Test element can (and must) remain open. + # + if sName == 'Test' and fClosed: + return ' is not allowed.'; + if sName != 'Test' and not fClosed: + return 'All elements except must be closed.'; + + return None; + + @staticmethod + def _parseElement(sElement): + """ + Parses an element. + + """ + # + # Element level bits. + # + sName = sElement.split()[0]; + sElement = sElement[len(sName):]; + + fClosed = sElement[-1] == '/'; + if fClosed: + sElement = sElement[:-1]; + + # + # Attributes. + # + sError = None; + dAttribs = {}; + sElement = sElement.strip(); + while sElement: + # Extract attribute name. + off = sElement.find('='); + if off < 0 or not sElement[:off].isalnum(): + sError = 'Attributes shall have alpha numberical names and have values.'; + break; + sAttr = sElement[:off]; + + # Extract attribute value. + if off + 2 >= len(sElement) or sElement[off + 1] != '"': + sError = 'Attribute (%s) value is missing or not in double quotes.' % (sAttr,); + break; + off += 2; + offEndQuote = sElement.find('"', off); + if offEndQuote < 0: + sError = 'Attribute (%s) value is missing end quotation mark.' % (sAttr,); + break; + sValue = sElement[off:offEndQuote]; + + # Check for duplicates. + if sAttr in dAttribs: + sError = 'Attribute "%s" appears more than once.' % (sAttr,); + break; + + # Unescape the value. + sValue = sValue.replace('<', '<'); + sValue = sValue.replace('>', '>'); + sValue = sValue.replace(''', '\''); + sValue = sValue.replace('"', '"'); + sValue = sValue.replace(' ', '\n'); + sValue = sValue.replace(' ', '\r'); + sValue = sValue.replace('&', '&'); # last + + # Done. + dAttribs[sAttr] = sValue; + + # advance + sElement = sElement[offEndQuote + 1:]; + sElement = sElement.lstrip(); + + # + # Validate the element before we return. + # + if sError is None: + sError = TestResultLogic._validateElement(sName, dAttribs, fClosed); + + return (sName, dAttribs, sError) + + def _handleElement(self, sName, dAttribs, idTestSet, aoStack, aaiHints, dCounts): + """ + Worker for processXmlStream that handles one element. + + Returns None on success, error string on bad XML or similar. + Raises exception on hanging offence and on database error. + """ + if sName == 'Test': + iNestingDepth = aoStack[0].iNestingDepth + 1 if aoStack else 0; + aoStack.insert(0, self._newTestResult(idTestResultParent = aoStack[0].idTestResult, idTestSet = idTestSet, + tsCreated = dAttribs['timestamp'], sName = dAttribs['name'], + iNestingDepth = iNestingDepth, dCounts = dCounts, fCommit = True) ); + + elif sName == 'Value': + self._newTestValue(idTestResult = aoStack[0].idTestResult, idTestSet = idTestSet, tsCreated = dAttribs['timestamp'], + sName = dAttribs['name'], sUnit = dAttribs['unit'], lValue = long(dAttribs['value']), + dCounts = dCounts, fCommit = True); + + elif sName == 'FailureDetails': + self._newFailureDetails(idTestResult = aoStack[0].idTestResult, idTestSet = idTestSet, + tsCreated = dAttribs['timestamp'], sText = dAttribs['text'], dCounts = dCounts, + fCommit = True); + + elif sName == 'Passed': + self._completeTestResults(aoStack[0], tsDone = dAttribs['timestamp'], + enmStatus = TestResultData.ksTestStatus_Success, fCommit = True); + + elif sName == 'Skipped': + self._completeTestResults(aoStack[0], tsDone = dAttribs['timestamp'], + enmStatus = TestResultData.ksTestStatus_Skipped, fCommit = True); + + elif sName == 'Failed': + self._completeTestResults(aoStack[0], tsDone = dAttribs['timestamp'], cErrors = int(dAttribs['errors']), + enmStatus = TestResultData.ksTestStatus_Failure, fCommit = True); + + elif sName == 'TimedOut': + self._completeTestResults(aoStack[0], tsDone = dAttribs['timestamp'], cErrors = int(dAttribs['errors']), + enmStatus = TestResultData.ksTestStatus_TimedOut, fCommit = True); + + elif sName == 'End': + self._completeTestResults(aoStack[0], tsDone = dAttribs['timestamp'], + cErrors = int(dAttribs.get('errors', '1')), + enmStatus = TestResultData.ksTestStatus_Success, fCommit = True); + + elif sName == 'PushHint': + if len(aaiHints) > 1: + return 'PushHint cannot be nested.' + + aaiHints.insert(0, [len(aoStack), int(dAttribs['testdepth'])]); + + elif sName == 'PopHint': + if not aaiHints: + return 'No hint to pop.' + + iDesiredTestDepth = int(dAttribs['testdepth']); + cStackEntries, iTestDepth = aaiHints.pop(0); + self._doPopHint(aoStack, cStackEntries, dCounts, idTestSet); # Fake the necessary '' tags. + if iDesiredTestDepth != iTestDepth: + return 'PopHint tag has different testdepth: %d, on stack %d.' % (iDesiredTestDepth, iTestDepth); + else: + return 'Unexpected element "%s".' % (sName,); + return None; + + + def processXmlStream(self, sXml, idTestSet): + """ + Processes the "XML" stream section given in sXml. + + The sXml isn't a complete XML document, even should we save up all sXml + for a given set, they may not form a complete and well formed XML + document since the test may be aborted, abend or simply be buggy. We + therefore do our own parsing and treat the XML tags as commands more + than anything else. + + Returns (sError, fUnforgivable), where sError is None on success. + May raise database exception. + """ + aoStack = self._getResultStack(idTestSet); # [0] == top; [-1] == bottom. + if not aoStack: + return ('No open results', True); + self._oDb.dprint('** processXmlStream len(aoStack)=%s' % (len(aoStack),)); + #self._oDb.dprint('processXmlStream: %s' % (self._stringifyStack(aoStack),)); + #self._oDb.dprint('processXmlStream: sXml=%s' % (sXml,)); + + dCounts = {}; + aaiHints = []; + sError = None; + + fExpectCloseTest = False; + sXml = sXml.strip(); + while sXml: + if sXml.startswith(''): # Only closing tag. + offNext = len(''); + if len(aoStack) <= 1: + sError = 'Trying to close the top test results.' + break; + # ASSUMES that we've just seen an , , , + # or tag earlier in this call! + if aoStack[0].enmStatus == TestResultData.ksTestStatus_Running or not fExpectCloseTest: + sError = 'Missing , , , or tag.'; + break; + aoStack.pop(0); + fExpectCloseTest = False; + + elif fExpectCloseTest: + sError = 'Expected .' + break; + + elif sXml.startswith(''); + if offNext < 0: + sError = 'Unterminated element.'; + break; + offNext += 2; + + elif sXml[0] == '<': + # Parse and check the tag. + if not sXml[1].isalpha(): + sError = 'Malformed element.'; + break; + offNext = sXml.find('>') + if offNext < 0: + sError = 'Unterminated element.'; + break; + (sName, dAttribs, sError) = self._parseElement(sXml[1:offNext]); + offNext += 1; + if sError is not None: + break; + + # Handle it. + try: + sError = self._handleElement(sName, dAttribs, idTestSet, aoStack, aaiHints, dCounts); + except TestResultHangingOffence as oXcpt: + self._inhumeTestResults(aoStack, idTestSet, str(oXcpt)); + return (str(oXcpt), True); + + + fExpectCloseTest = sName in [ 'End', 'Passed', 'Failed', 'TimedOut', 'Skipped', ]; + else: + sError = 'Unexpected content.'; + break; + + # Advance. + sXml = sXml[offNext:]; + sXml = sXml.lstrip(); + + # + # Post processing checks. + # + if sError is None and fExpectCloseTest: + sError = 'Expected before the end of the XML section.' + elif sError is None and aaiHints: + sError = 'Expected before the end of the XML section.' + if aaiHints: + self._doPopHint(aoStack, aaiHints[-1][0], dCounts, idTestSet); + + # + # Log the error. + # + if sError is not None: + SystemLogLogic(self._oDb).addEntry(SystemLogData.ksEvent_XmlResultMalformed, + 'idTestSet=%s idTestResult=%s XML="%s" %s' + % ( idTestSet, + aoStack[0].idTestResult if aoStack else -1, + sXml[:min(len(sXml), 30)], + sError, ), + cHoursRepeat = 6, fCommit = True); + return (sError, False); + + + + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestResultDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestResultData(),]; + +class TestResultValueDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestResultValueData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/testset.py b/src/VBox/ValidationKit/testmanager/core/testset.py new file mode 100755 index 00000000..e9e408fe --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/testset.py @@ -0,0 +1,869 @@ +# -*- coding: utf-8 -*- +# $Id: testset.py $ + +""" +Test Manager - TestSet. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import os; +import zipfile; +import unittest; + +# Validation Kit imports. +from common import utils; +from testmanager import config; +from testmanager.core import db; +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, \ + TMExceptionBase, TMTooManyRows, TMRowNotFound; +from testmanager.core.testbox import TestBoxData; +from testmanager.core.testresults import TestResultFileDataEx; + + +class TestSetData(ModelDataBase): + """ + TestSet Data. + """ + + ## @name TestStatus_T + # @{ + ksTestStatus_Running = 'running'; + ksTestStatus_Success = 'success'; + ksTestStatus_Skipped = 'skipped'; + ksTestStatus_BadTestBox = 'bad-testbox'; + ksTestStatus_Aborted = 'aborted'; + ksTestStatus_Failure = 'failure'; + ksTestStatus_TimedOut = 'timed-out'; + ksTestStatus_Rebooted = 'rebooted'; + ## @} + + ## List of relatively harmless (to testgroup/case) statuses. + kasHarmlessTestStatuses = [ ksTestStatus_Skipped, ksTestStatus_BadTestBox, ksTestStatus_Aborted, ]; + ## List of bad statuses. + kasBadTestStatuses = [ ksTestStatus_Failure, ksTestStatus_TimedOut, ksTestStatus_Rebooted, ]; + + ksIdAttr = 'idTestSet'; + + ksParam_idTestSet = 'TestSet_idTestSet'; + ksParam_tsConfig = 'TestSet_tsConfig'; + ksParam_tsCreated = 'TestSet_tsCreated'; + ksParam_tsDone = 'TestSet_tsDone'; + ksParam_enmStatus = 'TestSet_enmStatus'; + ksParam_idBuild = 'TestSet_idBuild'; + ksParam_idBuildCategory = 'TestSet_idBuildCategory'; + ksParam_idBuildTestSuite = 'TestSet_idBuildTestSuite'; + ksParam_idGenTestBox = 'TestSet_idGenTestBox'; + ksParam_idTestBox = 'TestSet_idTestBox'; + ksParam_idSchedGroup = 'TestSet_idSchedGroup'; + ksParam_idTestGroup = 'TestSet_idTestGroup'; + ksParam_idGenTestCase = 'TestSet_idGenTestCase'; + ksParam_idTestCase = 'TestSet_idTestCase'; + ksParam_idGenTestCaseArgs = 'TestSet_idGenTestCaseArgs'; + ksParam_idTestCaseArgs = 'TestSet_idTestCaseArgs'; + ksParam_idTestResult = 'TestSet_idTestResult'; + ksParam_sBaseFilename = 'TestSet_sBaseFilename'; + ksParam_iGangMemberNo = 'TestSet_iGangMemberNo'; + ksParam_idTestSetGangLeader = 'TestSet_idTestSetGangLeader'; + + kasAllowNullAttributes = [ 'tsDone', 'idBuildTestSuite', 'idTestSetGangLeader' ]; + kasValidValues_enmStatus = [ + ksTestStatus_Running, + ksTestStatus_Success, + ksTestStatus_Skipped, + ksTestStatus_BadTestBox, + ksTestStatus_Aborted, + ksTestStatus_Failure, + ksTestStatus_TimedOut, + ksTestStatus_Rebooted, + ]; + kiMin_iGangMemberNo = 0; + kiMax_iGangMemberNo = 1023; + + + kcDbColumns = 20; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.idTestSet = None; + self.tsConfig = None; + self.tsCreated = None; + self.tsDone = None; + self.enmStatus = 'running'; + self.idBuild = None; + self.idBuildCategory = None; + self.idBuildTestSuite = None; + self.idGenTestBox = None; + self.idTestBox = None; + self.idSchedGroup = None; + self.idTestGroup = None; + self.idGenTestCase = None; + self.idTestCase = None; + self.idGenTestCaseArgs = None; + self.idTestCaseArgs = None; + self.idTestResult = None; + self.sBaseFilename = None; + self.iGangMemberNo = 0; + self.idTestSetGangLeader = None; + + def initFromDbRow(self, aoRow): + """ + Internal worker for initFromDbWithId and initFromDbWithGenId as well as + TestBoxSetLogic. + """ + + if aoRow is None: + raise TMRowNotFound('TestSet not found.'); + + self.idTestSet = aoRow[0]; + self.tsConfig = aoRow[1]; + self.tsCreated = aoRow[2]; + self.tsDone = aoRow[3]; + self.enmStatus = aoRow[4]; + self.idBuild = aoRow[5]; + self.idBuildCategory = aoRow[6]; + self.idBuildTestSuite = aoRow[7]; + self.idGenTestBox = aoRow[8]; + self.idTestBox = aoRow[9]; + self.idSchedGroup = aoRow[10]; + self.idTestGroup = aoRow[11]; + self.idGenTestCase = aoRow[12]; + self.idTestCase = aoRow[13]; + self.idGenTestCaseArgs = aoRow[14]; + self.idTestCaseArgs = aoRow[15]; + self.idTestResult = aoRow[16]; + self.sBaseFilename = aoRow[17]; + self.iGangMemberNo = aoRow[18]; + self.idTestSetGangLeader = aoRow[19]; + return self; + + + def initFromDbWithId(self, oDb, idTestSet): + """ + Initialize the object from the database. + """ + oDb.execute('SELECT *\n' + 'FROM TestSets\n' + 'WHERE idTestSet = %s\n' + , (idTestSet, ) ); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('idTestSet=%s not found' % (idTestSet,)); + return self.initFromDbRow(aoRow); + + + def openFile(self, sFilename, sMode = 'rb'): + """ + Opens a file. + + Returns (oFile, cbFile, fIsStream) on success. + Returns (None, sErrorMsg, None) on failure. + Will not raise exceptions, unless the class instance is invalid. + """ + assert sMode in [ 'rb', 'r', 'rU' ]; + + # Try raw file first. + sFile1 = os.path.join(config.g_ksFileAreaRootDir, self.sBaseFilename + '-' + sFilename); + try: + oFile = open(sFile1, sMode); # pylint: disable=consider-using-with,unspecified-encoding + return (oFile, os.fstat(oFile.fileno()).st_size, False); + except Exception as oXcpt1: + # Try the zip archive next. + sFile2 = os.path.join(config.g_ksZipFileAreaRootDir, self.sBaseFilename + '.zip'); + try: + oZipFile = zipfile.ZipFile(sFile2, 'r'); # pylint: disable=consider-using-with + oFile = oZipFile.open(sFilename, sMode if sMode != 'rb' else 'r'); # pylint: disable=consider-using-with + cbFile = oZipFile.getinfo(sFilename).file_size; + return (oFile, cbFile, True); + except Exception as oXcpt2: + # Construct a meaningful error message. + try: + if os.path.exists(sFile1): + return (None, 'Error opening "%s": %s' % (sFile1, oXcpt1), None); + if not os.path.exists(sFile2): + return (None, 'File "%s" not found. [%s, %s]' % (sFilename, sFile1, sFile2,), None); + return (None, 'Error opening "%s" inside "%s": %s' % (sFilename, sFile2, oXcpt2), None); + except Exception as oXcpt3: + return (None, 'OMG! %s; %s; %s' % (oXcpt1, oXcpt2, oXcpt3,), None); + return (None, 'Code not reachable!', None); + + def createFile(self, sFilename, sMode = 'wb'): + """ + Creates a new file. + + Returns oFile on success. + Returns sErrorMsg on failure. + """ + assert sMode in [ 'wb', 'w', 'wU' ]; + + # Try raw file first. + sFile1 = os.path.join(config.g_ksFileAreaRootDir, self.sBaseFilename + '-' + sFilename); + try: + if not os.path.exists(os.path.dirname(sFile1)): + os.makedirs(os.path.dirname(sFile1), 0o755); + oFile = open(sFile1, sMode); # pylint: disable=consider-using-with,unspecified-encoding + except Exception as oXcpt1: + return str(oXcpt1); + return oFile; + + @staticmethod + def findLogOffsetForTimestamp(sLogContent, tsTimestamp, offStart = 0, fAfter = False): + """ + Log parsing utility function for finding the offset for the given timestamp. + + We ASSUME the log lines are prefixed with UTC timestamps on the format + '09:43:55.789353'. + + Return index into the sLogContent string, 0 if not found. + """ + # Turn tsTimestamp into a string compatible with what we expect to find in the log. + oTsZulu = db.dbTimestampToZuluDatetime(tsTimestamp); + sWantedTs = oTsZulu.strftime('%H:%M:%S.%f'); + assert len(sWantedTs) == 15; + + # Now loop thru the string, line by line. + offRet = offStart; + off = offStart; + while True: + sThisTs = sLogContent[off : off + 15]; + if len(sThisTs) >= 15 \ + and sThisTs[2] == ':' \ + and sThisTs[5] == ':' \ + and sThisTs[8] == '.' \ + and sThisTs[14] in '0123456789': + if sThisTs < sWantedTs: + offRet = off; + elif sThisTs == sWantedTs: + if not fAfter: + return off; + offRet = off; + else: + if fAfter: + offRet = off; + break; + + # next line. + off = sLogContent.find('\n', off); + if off < 0: + if fAfter: + offRet = len(sLogContent); + break; + off += 1; + + return offRet; + + @staticmethod + def extractLogSection(sLogContent, tsStart, tsLast): + """ + Returns log section from tsStart to tsLast (or all if we cannot make sense of it). + """ + offStart = TestSetData.findLogOffsetForTimestamp(sLogContent, tsStart); + offEnd = TestSetData.findLogOffsetForTimestamp(sLogContent, tsLast, offStart, fAfter = True); + return sLogContent[offStart : offEnd]; + + @staticmethod + def extractLogSectionElapsed(sLogContent, tsStart, tsElapsed): + """ + Returns log section from tsStart and tsElapsed forward (or all if we cannot make sense of it). + """ + tsStart = db.dbTimestampToZuluDatetime(tsStart); + tsLast = tsStart + tsElapsed; + return TestSetData.extractLogSection(sLogContent, tsStart, tsLast); + + + +class TestSetLogic(ModelLogicBase): + """ + TestSet logic. + """ + + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb); + + + def tryFetch(self, idTestSet): + """ + Attempts to fetch a test set. + + Returns a TestSetData object on success. + Returns None if no status was found. + Raises exception on other errors. + """ + self._oDb.execute('SELECT *\n' + 'FROM TestSets\n' + 'WHERE idTestSet = %s\n', + (idTestSet,)); + if self._oDb.getRowCount() == 0: + return None; + oData = TestSetData(); + return oData.initFromDbRow(self._oDb.fetchOne()); + + def strTabString(self, sString, fCommit = False): + """ + Gets the string table id for the given string, adding it if new. + """ + ## @todo move this and make a stored procedure for it. + self._oDb.execute('SELECT idStr\n' + 'FROM TestResultStrTab\n' + 'WHERE sValue = %s' + , (sString,)); + if self._oDb.getRowCount() == 0: + self._oDb.execute('INSERT INTO TestResultStrTab (sValue)\n' + 'VALUES (%s)\n' + 'RETURNING idStr\n' + , (sString,)); + if fCommit: + self._oDb.commit(); + return self._oDb.fetchOne()[0]; + + def complete(self, idTestSet, sStatus, fCommit = False): + """ + Completes the testset. + Returns the test set ID of the gang leader, None if no gang involvement. + Raises exceptions on database errors and invalid input. + """ + + assert sStatus != TestSetData.ksTestStatus_Running; + + # + # Get the basic test set data and check if there is anything to do here. + # + oData = TestSetData().initFromDbWithId(self._oDb, idTestSet); + if oData.enmStatus != TestSetData.ksTestStatus_Running: + raise TMExceptionBase('TestSet %s is already completed as %s.' % (idTestSet, oData.enmStatus)); + if oData.idTestResult is None: + raise self._oDb.integrityException('idTestResult is NULL for TestSet %u' % (idTestSet,)); + + # + # Close open sub test results, count these as errors. + # Note! No need to propagate error counts here. Only one tree line will + # have open sets, and it will go all the way to the root. + # + self._oDb.execute('SELECT idTestResult\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = %s\n' + ' AND idTestResult <> %s\n' + 'ORDER BY idTestResult DESC\n' + , (idTestSet, TestSetData.ksTestStatus_Running, oData.idTestResult)); + aaoRows = self._oDb.fetchAll(); + if aaoRows: + idStr = self.strTabString('Unclosed test result', fCommit = fCommit); + for aoRow in aaoRows: + self._oDb.execute('UPDATE TestResults\n' + 'SET enmStatus = \'failure\',\n' + ' tsElapsed = CURRENT_TIMESTAMP - tsCreated,\n' + ' cErrors = cErrors + 1\n' + 'WHERE idTestResult = %s\n' + , (aoRow[0],)); + self._oDb.execute('INSERT INTO TestResultMsgs (idTestResult, idTestSet, idStrMsg, enmLevel)\n' + 'VALUES ( %s, %s, %s, \'failure\'::TestResultMsgLevel_T)\n' + , (aoRow[0], idTestSet, idStr,)); + + # + # If it's a success result, check it against error counters. + # + if sStatus not in TestSetData.kasBadTestStatuses: + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + ' AND cErrors > 0\n' + , (idTestSet,)); + cErrors = self._oDb.fetchOne()[0]; + if cErrors > 0: + sStatus = TestSetData.ksTestStatus_Failure; + + # + # If it's an pure 'failure', check for timeouts and propagate it. + # + if sStatus == TestSetData.ksTestStatus_Failure: + self._oDb.execute('SELECT COUNT(*)\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = %s\n' + , ( idTestSet, TestSetData.ksTestStatus_TimedOut, )); + if self._oDb.fetchOne()[0] > 0: + sStatus = TestSetData.ksTestStatus_TimedOut; + + # + # Complete the top level test result and then the test set. + # + self._oDb.execute('UPDATE TestResults\n' + 'SET cErrors = (SELECT COALESCE(SUM(cErrors), 0)\n' + ' FROM TestResults\n' + ' WHERE idTestResultParent = %s)\n' + 'WHERE idTestResult = %s\n' + 'RETURNING cErrors\n' + , (oData.idTestResult, oData.idTestResult)); + cErrors = self._oDb.fetchOne()[0]; + if cErrors == 0 and sStatus in TestSetData.kasBadTestStatuses: + self._oDb.execute('UPDATE TestResults\n' + 'SET cErrors = 1\n' + 'WHERE idTestResult = %s\n' + , (oData.idTestResult,)); + elif cErrors > 0 and sStatus not in TestSetData.kasBadTestStatuses: + sStatus = TestSetData.ksTestStatus_Failure; # Impossible. + self._oDb.execute('UPDATE TestResults\n' + 'SET enmStatus = %s,\n' + ' tsElapsed = CURRENT_TIMESTAMP - tsCreated\n' + 'WHERE idTestResult = %s\n' + , (sStatus, oData.idTestResult,)); + + self._oDb.execute('UPDATE TestSets\n' + 'SET enmStatus = %s,\n' + ' tsDone = CURRENT_TIMESTAMP\n' + 'WHERE idTestSet = %s\n' + , (sStatus, idTestSet,)); + + self._oDb.maybeCommit(fCommit); + return oData.idTestSetGangLeader; + + def completeAsAbandoned(self, idTestSet, fCommit = False): + """ + Completes the testset as abandoned if necessary. + + See scenario #9: + file://../../docs/AutomaticTestingRevamp.html#cleaning-up-abandond-testcase + + Returns True if successfully completed as abandond, False if it's already + completed, and raises exceptions under exceptional circumstances. + """ + + # + # Get the basic test set data and check if there is anything to do here. + # + oData = self.tryFetch(idTestSet); + if oData is None: + return False; + if oData.enmStatus != TestSetData.ksTestStatus_Running: + return False; + + if oData.idTestResult is not None: + # + # Clean up test results, adding a message why they failed. + # + self._oDb.execute('UPDATE TestResults\n' + 'SET enmStatus = \'failure\',\n' + ' tsElapsed = CURRENT_TIMESTAMP - tsCreated,\n' + ' cErrors = cErrors + 1\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = \'running\'::TestStatus_T\n' + , (idTestSet,)); + + idStr = self.strTabString('The test was abandond by the testbox', fCommit = fCommit); + self._oDb.execute('INSERT INTO TestResultMsgs (idTestResult, idTestSet, idStrMsg, enmLevel)\n' + 'VALUES ( %s, %s, %s, \'failure\'::TestResultMsgLevel_T)\n' + , (oData.idTestResult, idTestSet, idStr,)); + + # + # Complete the testset. + # + self._oDb.execute('UPDATE TestSets\n' + 'SET enmStatus = \'failure\',\n' + ' tsDone = CURRENT_TIMESTAMP\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = \'running\'::TestStatus_T\n' + , (idTestSet,)); + + self._oDb.maybeCommit(fCommit); + return True; + + def completeAsGangGatheringTimeout(self, idTestSet, fCommit = False): + """ + Completes the testset with a gang-gathering timeout. + Raises exceptions on database errors and invalid input. + """ + # + # Get the basic test set data and check if there is anything to do here. + # + oData = TestSetData().initFromDbWithId(self._oDb, idTestSet); + if oData.enmStatus != TestSetData.ksTestStatus_Running: + raise TMExceptionBase('TestSet %s is already completed as %s.' % (idTestSet, oData.enmStatus)); + if oData.idTestResult is None: + raise self._oDb.integrityException('idTestResult is NULL for TestSet %u' % (idTestSet,)); + + # + # Complete the top level test result and then the test set. + # + self._oDb.execute('UPDATE TestResults\n' + 'SET enmStatus = \'failure\',\n' + ' tsElapsed = CURRENT_TIMESTAMP - tsCreated,\n' + ' cErrors = cErrors + 1\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = \'running\'::TestStatus_T\n' + , (idTestSet,)); + + idStr = self.strTabString('Gang gathering timed out', fCommit = fCommit); + self._oDb.execute('INSERT INTO TestResultMsgs (idTestResult, idTestSet, idStrMsg, enmLevel)\n' + 'VALUES ( %s, %s, %s, \'failure\'::TestResultMsgLevel_T)\n' + , (oData.idTestResult, idTestSet, idStr,)); + + self._oDb.execute('UPDATE TestSets\n' + 'SET enmStatus = \'failure\',\n' + ' tsDone = CURRENT_TIMESTAMP\n' + 'WHERE idTestSet = %s\n' + , (idTestSet,)); + + self._oDb.maybeCommit(fCommit); + return True; + + def createFile(self, oTestSet, sName, sMime, sKind, sDesc, cbFile, fCommit = False): # pylint: disable=too-many-locals + """ + Creates a file and associating with the current test result record in + the test set. + + Returns file object that the file content can be written to. + Raises exception on database error, I/O errors, if there are too many + files in the test set or if they take up too much disk space. + + The caller (testboxdisp.py) is expected to do basic input validation, + so we skip that and get on with the bits only we can do. + """ + + # + # Furhter input and limit checks. + # + if oTestSet.enmStatus != TestSetData.ksTestStatus_Running: + raise TMExceptionBase('Cannot create files on a test set with status "%s".' % (oTestSet.enmStatus,)); + + self._oDb.execute('SELECT TestResultStrTab.sValue\n' + 'FROM TestResultFiles,\n' + ' TestResults,\n' + ' TestResultStrTab\n' + 'WHERE TestResults.idTestSet = %s\n' + ' AND TestResultFiles.idTestResult = TestResults.idTestResult\n' + ' AND TestResultStrTab.idStr = TestResultFiles.idStrFile\n' + , ( oTestSet.idTestSet,)); + if self._oDb.getRowCount() + 1 > config.g_kcMaxUploads: + raise TMExceptionBase('Uploaded too many files already (%d).' % (self._oDb.getRowCount(),)); + + dFiles = {} + cbTotalFiles = 0; + for aoRow in self._oDb.fetchAll(): + dFiles[aoRow[0].lower()] = 1; # For determining a unique filename further down. + sFile = os.path.join(config.g_ksFileAreaRootDir, oTestSet.sBaseFilename + '-' + aoRow[0]); + try: + cbTotalFiles += os.path.getsize(sFile); + except: + cbTotalFiles += config.g_kcMbMaxUploadSingle * 1048576; + if (cbTotalFiles + cbFile + 1048575) / 1048576 > config.g_kcMbMaxUploadTotal: + raise TMExceptionBase('Will exceed total upload limit: %u bytes + %u bytes > %s MiB.' \ + % (cbTotalFiles, cbFile, config.g_kcMbMaxUploadTotal)); + + # + # Create a new file. + # + self._oDb.execute('SELECT idTestResult\n' + 'FROM TestResults\n' + 'WHERE idTestSet = %s\n' + ' AND enmStatus = \'running\'::TestStatus_T\n' + 'ORDER BY idTestResult DESC\n' + 'LIMIT 1\n' + % ( oTestSet.idTestSet, )); + if self._oDb.getRowCount() < 1: + raise TMExceptionBase('No open test results - someone committed a capital offence or we ran into a race.'); + idTestResult = self._oDb.fetchOne()[0]; + + if sName.lower() in dFiles: + # Note! There is in theory a race here, but that's something the + # test driver doing parallel upload with non-unique names + # should worry about. The TD should always avoid this path. + sOrgName = sName; + for i in range(2, config.g_kcMaxUploads + 6): + sName = '%s-%s' % (i, sName,); + if sName not in dFiles: + break; + sName = None; + if sName is None: + raise TMExceptionBase('Failed to find unique name for %s.' % (sOrgName,)); + + self._oDb.execute('INSERT INTO TestResultFiles(idTestResult, idTestSet, idStrFile, idStrDescription,\n' + ' idStrKind, idStrMime)\n' + 'VALUES (%s, %s, %s, %s, %s, %s)\n' + , ( idTestResult, + oTestSet.idTestSet, + self.strTabString(sName), + self.strTabString(sDesc), + self.strTabString(sKind), + self.strTabString(sMime), + )); + + oFile = oTestSet.createFile(sName, 'wb'); + if utils.isString(oFile): + raise TMExceptionBase('Error creating "%s": %s' % (sName, oFile)); + self._oDb.maybeCommit(fCommit); + return oFile; + + def getGang(self, idTestSetGangLeader): + """ + Returns an array of TestBoxData object representing the gang for the given testset. + """ + self._oDb.execute('SELECT TestBoxesWithStrings.*\n' + 'FROM TestBoxesWithStrings,\n' + ' TestSets' + 'WHERE TestSets.idTestSetGangLeader = %s\n' + ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox\n' + 'ORDER BY iGangMemberNo ASC\n' + , ( idTestSetGangLeader,)); + aaoRows = self._oDb.fetchAll(); + aoTestBoxes = []; + for aoRow in aaoRows: + aoTestBoxes.append(TestBoxData().initFromDbRow(aoRow)); + return aoTestBoxes; + + def getFile(self, idTestSet, idTestResultFile): + """ + Gets the TestResultFileEx corresponding to idTestResultFile. + + Raises an exception if the file wasn't found, doesn't belong to + idTestSet, and on DB error. + """ + self._oDb.execute('SELECT TestResultFiles.*,\n' + ' StrTabFile.sValue AS sFile,\n' + ' StrTabDesc.sValue AS sDescription,\n' + ' StrTabKind.sValue AS sKind,\n' + ' StrTabMime.sValue AS sMime\n' + 'FROM TestResultFiles,\n' + ' TestResultStrTab AS StrTabFile,\n' + ' TestResultStrTab AS StrTabDesc,\n' + ' TestResultStrTab AS StrTabKind,\n' + ' TestResultStrTab AS StrTabMime,\n' + ' TestResults\n' + 'WHERE TestResultFiles.idTestResultFile = %s\n' + ' AND TestResultFiles.idStrFile = StrTabFile.idStr\n' + ' AND TestResultFiles.idStrDescription = StrTabDesc.idStr\n' + ' AND TestResultFiles.idStrKind = StrTabKind.idStr\n' + ' AND TestResultFiles.idStrMime = StrTabMime.idStr\n' + ' AND TestResults.idTestResult = TestResultFiles.idTestResult\n' + ' AND TestResults.idTestSet = %s\n' + , ( idTestResultFile, idTestSet, )); + return TestResultFileDataEx().initFromDbRow(self._oDb.fetchOne()); + + + def getById(self, idTestSet): + """ + Get TestSet table record by its id + """ + self._oDb.execute('SELECT *\n' + 'FROM TestSets\n' + 'WHERE idTestSet=%s\n', + (idTestSet,)) + + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise TMTooManyRows('Found more than one test sets with the same credentials. Database structure is corrupted.') + try: + return TestSetData().initFromDbRow(aRows[0]) + except IndexError: + return None + + + def fetchOrphaned(self): + """ + Returns a list of TestSetData objects of orphaned test sets. + + A test set is orphaned if tsDone is NULL and the testbox has created + one or more newer testsets. + """ + + self._oDb.execute('SELECT TestSets.*\n' + 'FROM TestSets,\n' + ' (SELECT idTestSet, idTestBox FROM TestSets WHERE tsDone is NULL) AS t\n' + 'WHERE TestSets.idTestSet = t.idTestSet\n' + ' AND EXISTS(SELECT 1 FROM TestSets st\n' + ' WHERE st.idTestBox = t.idTestBox AND st.idTestSet > t.idTestSet)\n' + ' AND NOT EXISTS(SELECT 1 FROM TestBoxStatuses tbs\n' + ' WHERE tbs.idTestBox = t.idTestBox AND tbs.idTestSet = t.idTestSet)\n' + 'ORDER by TestSets.idTestBox, TestSets.idTestSet' + ); + aoRet = []; + for aoRow in self._oDb.fetchAll(): + aoRet.append(TestSetData().initFromDbRow(aoRow)); + return aoRet; + + def fetchByAge(self, tsNow = None, cHoursBack = 24): + """ + Returns a list of TestSetData objects of a given time period (default is 24 hours). + + Returns None if no testsets stored, + Returns an empty list if no testsets found with given criteria. + """ + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + + if self._oDb.getRowCount() == 0: + return None; + + self._oDb.execute('(SELECT *\n' + ' FROM TestSets\n' + ' WHERE tsDone <= %s\n' + ' AND tsDone > (%s - interval \'%s hours\')\n' + ')\n' + , ( tsNow, tsNow, cHoursBack, )); + + aoRet = []; + for aoRow in self._oDb.fetchAll(): + aoRet.append(TestSetData().initFromDbRow(aoRow)); + return aoRet; + + def isTestBoxExecutingTooRapidly(self, idTestBox): ## s/To/Too/ + """ + Checks whether the specified test box is executing tests too rapidly. + + The parameters defining too rapid execution are defined in config.py. + + Returns True if it does, False if it doesn't. + May raise database problems. + """ + + self._oDb.execute('(\n' + 'SELECT tsCreated\n' + 'FROM TestSets\n' + 'WHERE idTestBox = %s\n' + ' AND tsCreated >= (CURRENT_TIMESTAMP - interval \'%s seconds\')\n' + ') UNION (\n' + 'SELECT tsCreated\n' + 'FROM TestSets\n' + 'WHERE idTestBox = %s\n' + ' AND tsCreated >= (CURRENT_TIMESTAMP - interval \'%s seconds\')\n' + ' AND enmStatus >= \'failure\'\n' + ')' + , ( idTestBox, config.g_kcSecMinSinceLastTask, + idTestBox, config.g_kcSecMinSinceLastFailedTask, )); + return self._oDb.getRowCount() > 0; + + + # + # The virtual test sheriff interface. + # + + def fetchBadTestBoxIds(self, cHoursBack = 2, tsNow = None, aidFailureReasons = None): + """ + Fetches a list of test box IDs which returned bad-testbox statuses in the + given period (tsDone). + """ + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + if aidFailureReasons is None: + aidFailureReasons = [ -1, ]; + self._oDb.execute('(SELECT idTestBox\n' + ' FROM TestSets\n' + ' WHERE TestSets.enmStatus = \'bad-testbox\'\n' + ' AND tsDone <= %s\n' + ' AND tsDone > (%s - interval \'%s hours\')\n' + ') UNION (\n' + ' SELECT TestSets.idTestBox\n' + ' FROM TestSets,\n' + ' TestResultFailures\n' + ' WHERE TestSets.tsDone <= %s\n' + ' AND TestSets.tsDone > (%s - interval \'%s hours\')\n' + ' AND TestSets.enmStatus >= \'failure\'::TestStatus_T\n' + ' AND TestSets.idTestSet = TestResultFailures.idTestSet\n' + ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND TestResultFailures.idFailureReason IN (' + + ', '.join([str(i) for i in aidFailureReasons]) + ')\n' + ')\n' + , ( tsNow, tsNow, cHoursBack, + tsNow, tsNow, cHoursBack, )); + return [aoRow[0] for aoRow in self._oDb.fetchAll()]; + + def fetchSetsForTestBox(self, idTestBox, cHoursBack = 2, tsNow = None): + """ + Fetches the TestSet rows for idTestBox for the given period (tsDone), w/o running ones. + + Returns list of TestSetData sorted by tsDone in descending order. + """ + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + self._oDb.execute('SELECT *\n' + 'FROM TestSets\n' + 'WHERE TestSets.idTestBox = %s\n' + ' AND tsDone IS NOT NULL\n' + ' AND tsDone <= %s\n' + ' AND tsDone > (%s - interval \'%s hours\')\n' + 'ORDER by tsDone DESC\n' + , ( idTestBox, tsNow, tsNow, cHoursBack,)); + return self._dbRowsToModelDataList(TestSetData); + + def fetchFailedSetsWithoutReason(self, cHoursBack = 2, tsNow = None): + """ + Fetches the TestSet failure rows without any currently (CURRENT_TIMESTAMP + not tsNow) assigned failure reason. + + Returns list of TestSetData sorted by tsDone in descending order. + + Note! Includes bad-testbox sets too as it can be useful to analyze these + too even if we normally count them in the 'skipped' category. + """ + if tsNow is None: + tsNow = self._oDb.getCurrentTimestamp(); + self._oDb.execute('SELECT TestSets.*\n' + 'FROM TestSets\n' + ' LEFT OUTER JOIN TestResultFailures\n' + ' ON TestResultFailures.idTestSet = TestSets.idTestSet\n' + ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n' + 'WHERE TestSets.tsDone IS NOT NULL\n' + ' AND TestSets.enmStatus IN ( %s, %s, %s, %s )\n' + ' AND TestSets.tsDone <= %s\n' + ' AND TestSets.tsDone > (%s - interval \'%s hours\')\n' + ' AND TestResultFailures.idTestSet IS NULL\n' + 'ORDER by tsDone DESC\n' + , ( TestSetData.ksTestStatus_Failure, TestSetData.ksTestStatus_TimedOut, + TestSetData.ksTestStatus_Rebooted, TestSetData.ksTestStatus_BadTestBox, + tsNow, + tsNow, cHoursBack,)); + return self._dbRowsToModelDataList(TestSetData); + + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class TestSetDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [TestSetData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. diff --git a/src/VBox/ValidationKit/testmanager/core/useraccount.pgsql b/src/VBox/ValidationKit/testmanager/core/useraccount.pgsql new file mode 100644 index 00000000..1abd8b71 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/useraccount.pgsql @@ -0,0 +1,178 @@ +-- $Id: useraccount.pgsql $ +--- @file +-- VBox Test Manager Database Stored Procedures - UserAccounts. +-- + +-- +-- Copyright (C) 2012-2023 Oracle and/or its affiliates. +-- +-- This file is part of VirtualBox base platform packages, as +-- available from https://www.virtualbox.org. +-- +-- This program is free software; you can redistribute it and/or +-- modify it under the terms of the GNU General Public License +-- as published by the Free Software Foundation, in version 3 of the +-- License. +-- +-- 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, see . +-- +-- The contents of this file may alternatively be used under the terms +-- of the Common Development and Distribution License Version 1.0 +-- (CDDL), a copy of it is provided in the "COPYING.CDDL" file included +-- in the VirtualBox distribution, in which case the provisions of the +-- CDDL are applicable instead of those of the GPL. +-- +-- You may elect to license modified versions of this file under the +-- terms and conditions of either the GPL or the CDDL or both. +-- +-- SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +-- + +\set ON_ERROR_STOP 1 +\connect testmanager; + +--- +-- Checks if the user name and login name are unique, ignoring a_uidIgnore. +-- Raises exception if duplicates are found. +-- +-- @internal +-- +CREATE OR REPLACE FUNCTION UserAccountLogic_checkUniqueUser(a_sUsername TEXT, a_sLoginName TEXT, a_uidIgnore INTEGER) + RETURNS VOID AS $$ + DECLARE + v_cRows INTEGER; + BEGIN + -- sUserName + SELECT COUNT(*) INTO v_cRows + FROM Users + WHERE sUsername = a_sUsername + AND tsExpire = 'infinity'::TIMESTAMP + AND uid <> a_uidIgnore; + IF v_cRows <> 0 THEN + RAISE EXCEPTION 'Duplicate user name "%" (% times)', a_sUsername, v_cRows; + END IF; + + -- sLoginName + SELECT COUNT(*) INTO v_cRows + FROM Users + WHERE sLoginName = a_sLoginName + AND tsExpire = 'infinity'::TIMESTAMP + AND uid <> a_uidIgnore; + IF v_cRows <> 0 THEN + RAISE EXCEPTION 'Duplicate login name "%" (% times)', a_sUsername, v_cRows; + END IF; + END; +$$ LANGUAGE plpgsql; + +--- +-- Check that the user account exists. +-- Raises exception if it doesn't. +-- +-- @internal +-- +CREATE OR REPLACE FUNCTION UserAccountLogic_checkExists(a_uid INTEGER) RETURNS VOID AS $$ + DECLARE + v_cUpdatedRows INTEGER; + BEGIN + IF NOT EXISTS( SELECT * + FROM Users + WHERE uid = a_uid + AND tsExpire = 'infinity'::TIMESTAMP ) THEN + RAISE EXCEPTION 'User with ID % does not currently exist', a_uid; + END IF; + END; +$$ LANGUAGE plpgsql; + +--- +-- Historize a row. +-- @internal +-- +CREATE OR REPLACE FUNCTION UserAccountLogic_historizeEntry(a_uid INTEGER, a_tsExpire TIMESTAMP WITH TIME ZONE) + RETURNS VOID AS $$ + DECLARE + v_cUpdatedRows INTEGER; + BEGIN + UPDATE Users + SET tsExpire = a_tsExpire + WHERE uid = a_uid + AND tsExpire = 'infinity'::TIMESTAMP; + GET DIAGNOSTICS v_cUpdatedRows = ROW_COUNT; + IF v_cUpdatedRows <> 1 THEN + IF v_cUpdatedRows = 0 THEN + RAISE EXCEPTION 'User with ID % does not currently exist', a_uid; + END IF; + RAISE EXCEPTION 'Integrity error in UserAccounts: % current rows with uid=%d', v_cUpdatedRows, a_uid; + END IF; + END; +$$ LANGUAGE plpgsql; + + +--- +-- Adds a new user. +-- +CREATE OR REPLACE FUNCTION UserAccountLogic_addEntry(a_uidAuthor INTEGER, a_sUsername TEXT, a_sEmail TEXT, a_sFullName TEXT, + a_sLoginName TEXT, a_fReadOnly BOOLEAN) + RETURNS VOID AS $$ + DECLARE + v_cRows INTEGER; + BEGIN + PERFORM UserAccountLogic_checkUniqueUser(a_sUsername, a_sLoginName, -1); + INSERT INTO Users(uidAuthor, sUsername, sEmail, sFullName, sLoginName) + VALUES (a_uidAuthor, a_sUsername, a_sEmail, a_sFullName, a_sLoginName); + END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION UserAccountLogic_editEntry(a_uidAuthor INTEGER, a_uid INTEGER, a_sUsername TEXT, a_sEmail TEXT, + a_sFullName TEXT, a_sLoginName TEXT, a_fReadOnly BOOLEAN) + RETURNS VOID AS $$ + BEGIN + PERFORM UserAccountLogic_checkExists(a_uid); + PERFORM UserAccountLogic_checkUniqueUser(a_sUsername, a_sLoginName, a_uid); + + PERFORM UserAccountLogic_historizeEntry(a_uid, CURRENT_TIMESTAMP); + INSERT INTO Users (uid, uidAuthor, sUsername, sEmail, sFullName, sLoginName, fReadOnly) + VALUES (a_uid, a_uidAuthor, a_sUsername, a_sEmail, a_sFullName, a_sLoginName, a_fReadOnly); + END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION UserAccountLogic_delEntry(a_uidAuthor INTEGER, a_uid INTEGER) RETURNS VOID AS $$ + DECLARE + v_Row Users%ROWTYPE; + v_tsEffective TIMESTAMP WITH TIME ZONE; + BEGIN + -- + -- To preserve the information about who deleted the record, we try to + -- add a dummy record which expires immediately. I say try because of + -- the primary key, we must let the new record be valid for 1 us. :-( + -- + + SELECT * INTO STRICT v_Row + FROM Users + WHERE uid = a_uid + AND tsExpire = 'infinity'::TIMESTAMP; + + v_tsEffective := CURRENT_TIMESTAMP - INTERVAL '1 microsecond'; + IF v_Row.tsEffective < v_tsEffective THEN + PERFORM UserAccountLogic_historizeEntry(a_uid, v_tsEffective); + v_Row.tsEffective = v_tsEffective; + v_Row.tsExpire = CURRENT_TIMESTAMP; + INSERT INTO Users VALUES (v_Row.*); + ELSE + PERFORM UserAccountLogic_historizeEntry(a_uid, CURRENT_TIMESTAMP); + END IF; + + EXCEPTION + WHEN NO_DATA_FOUND THEN + RAISE EXCEPTION 'User with ID % does not currently exist', a_uid; + WHEN TOO_MANY_ROWS THEN + RAISE EXCEPTION 'Integrity error in UserAccounts: Too many current rows for %', a_uid; + END; +$$ LANGUAGE plpgsql; + diff --git a/src/VBox/ValidationKit/testmanager/core/useraccount.py b/src/VBox/ValidationKit/testmanager/core/useraccount.py new file mode 100755 index 00000000..6c3f3f51 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/useraccount.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +# $Id: useraccount.py $ + +""" +Test Manager - User DB records management. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager import config; +from testmanager.core.base import ModelDataBase, ModelLogicBase, ModelDataBaseTestCase, TMTooManyRows, TMRowNotFound; + + +class UserAccountData(ModelDataBase): + """ + User account data + """ + + ksIdAttr = 'uid'; + + ksParam_uid = 'UserAccount_uid' + ksParam_tsExpire = 'UserAccount_tsExpire' + ksParam_tsEffective = 'UserAccount_tsEffective' + ksParam_uidAuthor = 'UserAccount_uidAuthor' + ksParam_sLoginName = 'UserAccount_sLoginName' + ksParam_sUsername = 'UserAccount_sUsername' + ksParam_sEmail = 'UserAccount_sEmail' + ksParam_sFullName = 'UserAccount_sFullName' + ksParam_fReadOnly = 'UserAccount_fReadOnly' + + kasAllowNullAttributes = ['uid', 'tsEffective', 'tsExpire', 'uidAuthor']; + + + def __init__(self): + """Init parameters""" + ModelDataBase.__init__(self); + self.uid = None; + self.tsEffective = None; + self.tsExpire = None; + self.uidAuthor = None; + self.sUsername = None; + self.sEmail = None; + self.sFullName = None; + self.sLoginName = None; + self.fReadOnly = None; + + def initFromDbRow(self, aoRow): + """ + Init from database table row + Returns self. Raises exception of the row is None. + """ + if aoRow is None: + raise TMRowNotFound('User not found.'); + + self.uid = aoRow[0]; + self.tsEffective = aoRow[1]; + self.tsExpire = aoRow[2]; + self.uidAuthor = aoRow[3]; + self.sUsername = aoRow[4]; + self.sEmail = aoRow[5]; + self.sFullName = aoRow[6]; + self.sLoginName = aoRow[7]; + self.fReadOnly = aoRow[8]; + return self; + + def initFromDbWithId(self, oDb, uid, tsNow = None, sPeriodBack = None): + """ + Initialize the object from the database. + """ + oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb, + 'SELECT *\n' + 'FROM Users\n' + 'WHERE uid = %s\n' + , ( uid, ), tsNow, sPeriodBack)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMRowNotFound('uid=%s not found (tsNow=%s sPeriodBack=%s)' % (uid, tsNow, sPeriodBack,)); + return self.initFromDbRow(aoRow); + + def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): + # Custom handling of the email field. + if sAttr == 'sEmail': + return ModelDataBase.validateEmail(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull); + + # Automatically lowercase the login name if we're supposed to do case + # insensitive matching. (The feature assumes lower case in DB.) + if sAttr == 'sLoginName' and oValue is not None and config.g_kfLoginNameCaseInsensitive: + oValue = oValue.lower(); + + return ModelDataBase._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb); + + +class UserAccountLogic(ModelLogicBase): + """ + User account logic (for the Users table). + """ + + def __init__(self, oDb): + ModelLogicBase.__init__(self, oDb) + self.dCache = None; + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches user accounts. + + Returns an array (list) of UserAccountData items, empty list if none. + Raises exception on error. + """ + _ = aiSortColumns; + if tsNow is None: + self._oDb.execute('SELECT *\n' + 'FROM Users\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + 'ORDER BY sUsername DESC\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + else: + self._oDb.execute('SELECT *\n' + 'FROM Users\n' + 'WHERE tsExpire > %s\n' + ' AND tsEffective <= %s\n' + 'ORDER BY sUsername DESC\n' + 'LIMIT %s OFFSET %s\n' + , (tsNow, tsNow, cMaxRows, iStart,)); + + aoRows = []; + for _ in range(self._oDb.getRowCount()): + aoRows.append(UserAccountData().initFromDbRow(self._oDb.fetchOne())); + return aoRows; + + def addEntry(self, oData, uidAuthor, fCommit = False): + """ + Add user account entry to the DB. + """ + self._oDb.callProc('UserAccountLogic_addEntry', + (uidAuthor, oData.sUsername, oData.sEmail, oData.sFullName, oData.sLoginName, oData.fReadOnly)); + self._oDb.maybeCommit(fCommit); + return True; + + def editEntry(self, oData, uidAuthor, fCommit = False): + """ + Modify user account. + """ + self._oDb.callProc('UserAccountLogic_editEntry', + ( uidAuthor, oData.uid, oData.sUsername, oData.sEmail, + oData.sFullName, oData.sLoginName, oData.fReadOnly)); + self._oDb.maybeCommit(fCommit); + return True; + + def removeEntry(self, uidAuthor, uid, fCascade = False, fCommit = False): + """ + Delete user account + """ + self._oDb.callProc('UserAccountLogic_delEntry', (uidAuthor, uid)); + self._oDb.maybeCommit(fCommit); + _ = fCascade; + return True; + + def _getByField(self, sField, sValue): + """ + Get user account record by its field value + """ + self._oDb.execute('SELECT *\n' + 'FROM Users\n' + 'WHERE tsExpire = \'infinity\'::TIMESTAMP\n' + ' AND ' + sField + ' = %s' + , (sValue,)) + + aRows = self._oDb.fetchAll() + if len(aRows) not in (0, 1): + raise TMTooManyRows('Found more than one user account with the same credentials. Database structure is corrupted.') + + try: + return aRows[0] + except IndexError: + return [] + + def getById(self, idUserId): + """ + Get user account information by ID. + """ + return self._getByField('uid', idUserId) + + def tryFetchAccountByLoginName(self, sLoginName): + """ + Try get user account information by login name. + + Returns UserAccountData if found, None if not. + Raises exception on DB error. + """ + if config.g_kfLoginNameCaseInsensitive: + sLoginName = sLoginName.lower(); + + self._oDb.execute('SELECT *\n' + 'FROM Users\n' + 'WHERE sLoginName = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (sLoginName, )); + if self._oDb.getRowCount() != 1: + if self._oDb.getRowCount() != 0: + raise self._oDb.integrityException('%u rows in Users with sLoginName="%s"' + % (self._oDb.getRowCount(), sLoginName)); + return None; + return UserAccountData().initFromDbRow(self._oDb.fetchOne()); + + def cachedLookup(self, uid): + """ + Looks up the current UserAccountData object for uid via an object cache. + + Returns a shared UserAccountData object. None if not found. + Raises exception on DB error. + """ + if self.dCache is None: + self.dCache = self._oDb.getCache('UserAccount'); + + oUser = self.dCache.get(uid, None); + if oUser is None: + self._oDb.execute('SELECT *\n' + 'FROM Users\n' + 'WHERE uid = %s\n' + ' AND tsExpire = \'infinity\'::TIMESTAMP\n' + , (uid, )); + if self._oDb.getRowCount() == 0: + # Maybe it was deleted, try get the last entry. + self._oDb.execute('SELECT *\n' + 'FROM Users\n' + 'WHERE uid = %s\n' + 'ORDER BY tsExpire DESC\n' + 'LIMIT 1\n' + , (uid, )); + elif self._oDb.getRowCount() > 1: + raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), uid)); + + if self._oDb.getRowCount() == 1: + oUser = UserAccountData().initFromDbRow(self._oDb.fetchOne()); + self.dCache[uid] = oUser; + return oUser; + + def resolveChangeLogAuthors(self, aoEntries): + """ + Given an array of ChangeLogEntry instances, set sAuthor to whatever + uidAuthor resolves to. + + Returns aoEntries. + Raises exception on DB error. + """ + for oEntry in aoEntries: + oUser = self.cachedLookup(oEntry.uidAuthor) + if oUser is not None: + oEntry.sAuthor = oUser.sUsername; + return aoEntries; + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class UserAccountDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [UserAccountData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/vcsbugreference.py b/src/VBox/ValidationKit/testmanager/core/vcsbugreference.py new file mode 100755 index 00000000..9ae9c941 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/vcsbugreference.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +# $Id: vcsbugreference.py $ + +""" +Test Manager - VcsBugReferences +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMExceptionBase; + + +class VcsBugReferenceData(ModelDataBase): + """ + A version control system (VCS) bug tracker reference (commit message tag). + """ + + #kasIdAttr = ['sRepository','iRevision', 'sBugTracker', 'iBugNo']; + + ksParam_sRepository = 'VcsBugReference_sRepository'; + ksParam_iRevision = 'VcsBugReference_iRevision'; + ksParam_sBugTracker = 'VcsBugReference_sBugTracker'; + ksParam_lBugNo = 'VcsBugReference_lBugNo'; + + kasAllowNullAttributes = [ ]; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.sRepository = None; + self.iRevision = None; + self.sBugTracker = None; + self.lBugNo = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the object from a SELECT * FROM VcsBugReferences row. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMExceptionBase('VcsBugReference not found.'); + + self.sRepository = aoRow[0]; + self.iRevision = aoRow[1]; + self.sBugTracker = aoRow[2]; + self.lBugNo = aoRow[3]; + return self; + + def initFromValues(self, sRepository, iRevision, sBugTracker, lBugNo): + """ + Reinitializes form a set of values. + return self. + """ + self.sRepository = sRepository; + self.iRevision = iRevision; + self.sBugTracker = sBugTracker; + self.lBugNo = lBugNo; + return self; + + +class VcsBugReferenceDataEx(VcsBugReferenceData): + """ + Extended version of VcsBugReferenceData that includes the commit details. + """ + def __init__(self): + VcsBugReferenceData.__init__(self); + self.tsCreated = None; + self.sAuthor = None; + self.sMessage = None; + + def initFromDbRow(self, aoRow): + VcsBugReferenceData.initFromDbRow(self, aoRow); + self.tsCreated = aoRow[4]; + self.sAuthor = aoRow[5]; + self.sMessage = aoRow[6]; + return self; + + +class VcsBugReferenceLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + VCS revision <-> bug tracker references database logic. + """ + + # + # Standard methods. + # + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches VCS revisions for listing. + + Returns an array (list) of VcsBugReferenceData items, empty list if none. + Raises exception on error. + """ + _ = tsNow; _ = aiSortColumns; + self._oDb.execute(''' +SELECT * +FROM VcsBugReferences +ORDER BY sRepository, iRevision, sBugTracker, lBugNo +LIMIT %s OFFSET %s +''', (cMaxRows, iStart,)); + + aoRows = []; + for _ in range(self._oDb.getRowCount()): + aoRows.append(VcsBugReferenceData().initFromDbRow(self._oDb.fetchOne())); + return aoRows; + + def exists(self, oData): + """ + Checks if the data is already present in the DB. + Returns True / False. + Raises exception on input and database errors. + """ + self._oDb.execute(''' +SELECT COUNT(*) +FROM VcsBugReferences +WHERE sRepository = %s + AND iRevision = %s + AND sBugTracker = %s + AND lBugNo = %s +''', ( oData.sRepository, oData.iRevision, oData.sBugTracker, oData.lBugNo)); + cRows = self._oDb.fetchOne()[0]; + if cRows < 0 or cRows > 1: + raise TMExceptionBase('VcsBugReferences has a primary key problem: %u duplicates' % (cRows,)); + return cRows != 0; + + + # + # Other methods. + # + + def addVcsBugReference(self, oData, fCommit = False): + """ + Adds (or updates) a tree revision record. + Raises exception on input and database errors. + """ + + # Check VcsBugReferenceData before do anything + dDataErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dDataErrors: + raise TMExceptionBase('Invalid data passed to addVcsBugReference(): %s' % (dDataErrors,)); + + # Does it already exist? + if not self.exists(oData): + # New row. + self._oDb.execute('INSERT INTO VcsBugReferences (sRepository, iRevision, sBugTracker, lBugNo)\n' + 'VALUES (%s, %s, %s, %s)\n' + , ( oData.sRepository, + oData.iRevision, + oData.sBugTracker, + oData.lBugNo, + )); + + self._oDb.maybeCommit(fCommit); + return oData; + + def getLastRevision(self, sRepository): + """ + Get the last known revision number for the given repository, returns 0 + if the repository is not known to us: + """ + self._oDb.execute(''' +SELECT iRevision +FROM VcsBugReferences +WHERE sRepository = %s +ORDER BY iRevision DESC +LIMIT 1 +''', ( sRepository, )); + if self._oDb.getRowCount() == 0: + return 0; + return self._oDb.fetchOne()[0]; + + def fetchForBug(self, sBugTracker, lBugNo): + """ + Fetches VCS revisions for a bug. + + Returns an array (list) of VcsBugReferenceDataEx items, empty list if none. + Raises exception on error. + """ + self._oDb.execute(''' +SELECT VcsBugReferences.*, + VcsRevisions.tsCreated, + VcsRevisions.sAuthor, + VcsRevisions.sMessage +FROM VcsBugReferences +LEFT OUTER JOIN VcsRevisions ON ( VcsRevisions.sRepository = VcsBugReferences.sRepository + AND VcsRevisions.iRevision = VcsBugReferences.iRevision ) +WHERE sBugTracker = %s + AND lBugNo = %s +ORDER BY VcsRevisions.tsCreated, VcsBugReferences.sRepository, VcsBugReferences.iRevision +''', (sBugTracker, lBugNo,)); + + aoRows = []; + for _ in range(self._oDb.getRowCount()): + aoRows.append(VcsBugReferenceDataEx().initFromDbRow(self._oDb.fetchOne())); + return aoRows; + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class VcsBugReferenceDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [VcsBugReferenceData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/vcsrevisions.py b/src/VBox/ValidationKit/testmanager/core/vcsrevisions.py new file mode 100755 index 00000000..ea841d11 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/vcsrevisions.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +# $Id: vcsrevisions.py $ + +""" +Test Manager - VcsRevisions +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import unittest; + +# Validation Kit imports. +from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMExceptionBase; + + +class VcsRevisionData(ModelDataBase): + """ + A version control system (VCS) revision. + """ + + #kasIdAttr = ['sRepository',iRevision]; + + ksParam_sRepository = 'VcsRevision_sRepository'; + ksParam_iRevision = 'VcsRevision_iRevision'; + ksParam_tsCreated = 'VcsRevision_tsCreated'; + ksParam_sAuthor = 'VcsRevision_sAuthor'; + ksParam_sMessage = 'VcsRevision_sMessage'; + + kasAllowNullAttributes = [ ]; + kfAllowUnicode_sMessage = True; + kcchMax_sMessage = 8192; + + def __init__(self): + ModelDataBase.__init__(self); + + # + # Initialize with defaults. + # See the database for explanations of each of these fields. + # + self.sRepository = None; + self.iRevision = None; + self.tsCreated = None; + self.sAuthor = None; + self.sMessage = None; + + def initFromDbRow(self, aoRow): + """ + Re-initializes the object from a SELECT * FROM VcsRevisions row. + Returns self. Raises exception if aoRow is None. + """ + if aoRow is None: + raise TMExceptionBase('VcsRevision not found.'); + + self.sRepository = aoRow[0]; + self.iRevision = aoRow[1]; + self.tsCreated = aoRow[2]; + self.sAuthor = aoRow[3]; + self.sMessage = aoRow[4]; + return self; + + def initFromDbWithRepoAndRev(self, oDb, sRepository, iRevision): + """ + Initialize from the database, given the tree and revision of a row. + """ + oDb.execute('SELECT * FROM VcsRevisions WHERE sRepository = %s AND iRevision = %u', (sRepository, iRevision,)); + aoRow = oDb.fetchOne() + if aoRow is None: + raise TMExceptionBase('sRepository = %s iRevision = %u not found' % (sRepository, iRevision, )); + return self.initFromDbRow(aoRow); + + def initFromValues(self, sRepository, iRevision, tsCreated, sAuthor, sMessage): + """ + Reinitializes form a set of values. + return self. + """ + self.sRepository = sRepository; + self.iRevision = iRevision; + self.tsCreated = tsCreated; + self.sAuthor = sAuthor; + self.sMessage = sMessage; + return self; + + +class VcsRevisionLogic(ModelLogicBase): # pylint: disable=too-few-public-methods + """ + VCS revisions database logic. + """ + + # + # Standard methods. + # + + def fetchForListing(self, iStart, cMaxRows, tsNow, aiSortColumns = None): + """ + Fetches VCS revisions for listing. + + Returns an array (list) of VcsRevisionData items, empty list if none. + Raises exception on error. + """ + _ = tsNow; _ = aiSortColumns; + self._oDb.execute('SELECT *\n' + 'FROM VcsRevisions\n' + 'ORDER BY tsCreated, sRepository, iRevision\n' + 'LIMIT %s OFFSET %s\n' + , (cMaxRows, iStart,)); + + aoRows = []; + for _ in range(self._oDb.getRowCount()): + aoRows.append(VcsRevisionData().initFromDbRow(self._oDb.fetchOne())); + return aoRows; + + def tryFetch(self, sRepository, iRevision): + """ + Tries to fetch the specified tree revision record. + Returns VcsRevisionData instance if found, None if not found. + Raises exception on input and database errors. + """ + self._oDb.execute('SELECT * FROM VcsRevisions WHERE sRepository = %s AND iRevision = %s', + ( sRepository, iRevision, )); + aaoRows = self._oDb.fetchAll(); + if len(aaoRows) == 1: + return VcsRevisionData().initFromDbRow(aaoRows[0]); + if aaoRows: + raise TMExceptionBase('VcsRevisions has a primary key problem: %u duplicates' % (len(aaoRows),)); + return None + + + # + # Other methods. + # + + def addVcsRevision(self, oData, fCommit = False): + """ + Adds (or updates) a tree revision record. + Raises exception on input and database errors. + """ + + # Check VcsRevisionData before do anything + dDataErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add); + if dDataErrors: + raise TMExceptionBase('Invalid data passed to addVcsRevision(): %s' % (dDataErrors,)); + + # Does it already exist? + oOldData = self.tryFetch(oData.sRepository, oData.iRevision); + if oOldData is None: + # New row. + self._oDb.execute('INSERT INTO VcsRevisions (sRepository, iRevision, tsCreated, sAuthor, sMessage)\n' + 'VALUES (%s, %s, %s, %s, %s)\n' + , ( oData.sRepository, + oData.iRevision, + oData.tsCreated, + oData.sAuthor, + oData.sMessage, + )); + elif not oOldData.isEqual(oData): + # Update old row. + self._oDb.execute('UPDATE VcsRevisions\n' + ' SET tsCreated = %s,\n' + ' sAuthor = %s,\n' + ' sMessage = %s\n' + 'WHERE sRepository = %s\n' + ' AND iRevision = %s' + , ( oData.tsCreated, + oData.sAuthor, + oData.sMessage, + oData.sRepository, + oData.iRevision, + )); + + self._oDb.maybeCommit(fCommit); + return oData; + + def getLastRevision(self, sRepository): + """ + Get the last known revision number for the given repository, returns 0 + if the repository is not known to us: + """ + self._oDb.execute('SELECT iRevision\n' + 'FROM VcsRevisions\n' + 'WHERE sRepository = %s\n' + 'ORDER BY iRevision DESC\n' + 'LIMIT 1\n' + , ( sRepository, )); + if self._oDb.getRowCount() == 0: + return 0; + return self._oDb.fetchOne()[0]; + + def fetchTimeline(self, sRepository, iRevision, cEntriesBack): + """ + Fetches a VCS timeline portion for a repository. + + Returns an array (list) of VcsRevisionData items, empty list if none. + Raises exception on error. + """ + self._oDb.execute('SELECT *\n' + 'FROM VcsRevisions\n' + 'WHERE sRepository = %s\n' + ' AND iRevision > %s\n' + ' AND iRevision <= %s\n' + 'ORDER BY iRevision DESC\n' + 'LIMIT %s\n' + , ( sRepository, iRevision - cEntriesBack*2 + 1, iRevision, cEntriesBack)); + aoRows = []; + for _ in range(self._oDb.getRowCount()): + aoRows.append(VcsRevisionData().initFromDbRow(self._oDb.fetchOne())); + return aoRows; + + +# +# Unit testing. +# + +# pylint: disable=missing-docstring +class VcsRevisionDataTestCase(ModelDataBaseTestCase): + def setUp(self): + self.aoSamples = [VcsRevisionData(),]; + +if __name__ == '__main__': + unittest.main(); + # not reached. + diff --git a/src/VBox/ValidationKit/testmanager/core/webservergluebase.py b/src/VBox/ValidationKit/testmanager/core/webservergluebase.py new file mode 100755 index 00000000..a36e1af7 --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/webservergluebase.py @@ -0,0 +1,717 @@ +# -*- coding: utf-8 -*- +# $Id: webservergluebase.py $ + +""" +Test Manager Core - Web Server Abstraction Base Class. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import cgitb +import codecs; +import os +import sys + +# Validation Kit imports. +from common import webutils, utils; +from testmanager import config; + + +class WebServerGlueException(Exception): + """ + For exceptions raised by glue code. + """ + pass; # pylint: disable=unnecessary-pass + + +class WebServerGlueBase(object): + """ + Web server interface abstraction and some HTML utils. + """ + + ## Enables more debug output. + kfDebugInfoEnabled = True; + + ## The maximum number of characters to cache. + kcchMaxCached = 65536; + + ## Special getUserName return value. + ksUnknownUser = 'Unknown User'; + + ## HTTP status codes and their messages. + kdStatusMsgs = { + 100: 'Continue', + 101: 'Switching Protocols', + 102: 'Processing', + 103: 'Early Hints', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status', + 208: 'Already Reported', + 226: 'IM Used', + 300: 'Multiple Choices', + 301: 'Moved Permantently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 306: 'Switch Proxy', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Payload Too Large', + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', + 417: 'Expectation Failed', + 418: 'I\'m a teapot', + 421: 'Misdirection Request', + 422: 'Unprocessable Entity', + 423: 'Locked', + 424: 'Failed Dependency', + 425: 'Too Early', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 451: 'Unavailable For Legal Reasons', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', + 507: 'Insufficient Storage', + 508: 'Loop Detected', + 510: 'Not Extended', + 511: 'Network Authentication Required', + }; + + + def __init__(self, sValidationKitDir, fHtmlDebugOutput = True): + self._sValidationKitDir = sValidationKitDir; + + # Debug + self.tsStart = utils.timestampNano(); + self._fHtmlDebugOutput = fHtmlDebugOutput; # For trace + self._oDbgFile = sys.stderr; + if config.g_ksSrvGlueDebugLogDst is not None and config.g_kfSrvGlueDebug is True: + self._oDbgFile = open(config.g_ksSrvGlueDebugLogDst, 'a'); # pylint: disable=consider-using-with,unspecified-encoding + if config.g_kfSrvGlueCgiDumpArgs: + self._oDbgFile.write('Arguments: %s\nEnvironment:\n' % (sys.argv,)); + if config.g_kfSrvGlueCgiDumpEnv: + for sVar in sorted(os.environ): + self._oDbgFile.write(' %s=\'%s\' \\\n' % (sVar, os.environ[sVar],)); + + self._afnDebugInfo = []; + + # HTTP header. + self._fHeaderWrittenOut = False; + self._dHeaderFields = \ + { \ + 'Content-Type': 'text/html; charset=utf-8', + }; + + # Body. + self._sBodyType = None; + self._dParams = {}; + self._sHtmlBody = ''; + self._cchCached = 0; + self._cchBodyWrittenOut = 0; + + # Output. + if sys.version_info[0] >= 3: + self.oOutputRaw = sys.stdout.detach(); # pylint: disable=no-member + sys.stdout = None; # Prevents flush_std_files() from complaining on stderr during sys.exit(). + else: + self.oOutputRaw = sys.stdout; + self.oOutputText = codecs.getwriter('utf-8')(self.oOutputRaw); + + + # + # Get stuff. + # + + def getParameters(self): + """ + Returns a dictionary with the query parameters. + + The parameter name is the key, the values are given as lists. If a + parameter is given more than once, the value is appended to the + existing dictionary entry. + """ + return {}; + + def getClientAddr(self): + """ + Returns the client address, as a string. + """ + raise WebServerGlueException('getClientAddr is not implemented'); + + def getMethod(self): + """ + Gets the HTTP request method. + """ + return 'POST'; + + def getLoginName(self): + """ + Gets login name provided by Apache. + Returns kUnknownUser if not logged on. + """ + return WebServerGlueBase.ksUnknownUser; + + def getUrlScheme(self): + """ + Gets scheme name (aka. access protocol) from request URL, i.e. 'http' or 'https'. + See also urlparse.scheme. + """ + return 'http'; + + def getUrlNetLoc(self): + """ + Gets the network location (server host name / ip) from the request URL. + See also urlparse.netloc. + """ + raise WebServerGlueException('getUrlNetLoc is not implemented'); + + def getUrlPath(self): + """ + Gets the hirarchical path (relative to server) from the request URL. + See also urlparse.path. + Note! This includes the leading slash. + """ + raise WebServerGlueException('getUrlPath is not implemented'); + + def getUrlBasePath(self): + """ + Gets the hirarchical base path (relative to server) from the request URL. + Note! This includes both a leading an trailing slash. + """ + sPath = self.getUrlPath(); # virtual method # pylint: disable=assignment-from-no-return + iLastSlash = sPath.rfind('/'); + if iLastSlash >= 0: + sPath = sPath[:iLastSlash]; + sPath = sPath.rstrip('/'); + return sPath + '/'; + + def getUrl(self): + """ + Gets the URL being accessed, sans parameters. + For instance this will return, "http://localhost/testmanager/admin.cgi" + when "http://localhost/testmanager/admin.cgi?blah=blah" is being access. + """ + return '%s://%s%s' % (self.getUrlScheme(), self.getUrlNetLoc(), self.getUrlPath()); + + def getBaseUrl(self): + """ + Gets the base URL (with trailing slash). + For instance this will return, "http://localhost/testmanager/" when + "http://localhost/testmanager/admin.cgi?blah=blah" is being access. + """ + return '%s://%s%s' % (self.getUrlScheme(), self.getUrlNetLoc(), self.getUrlBasePath()); + + def getUserAgent(self): + """ + Gets the User-Agent field of the HTTP header, returning empty string + if not present. + """ + return ''; + + def getContentType(self): + """ + Gets the Content-Type field of the HTTP header, parsed into a type + string and a dictionary. + """ + return ('text/html', {}); + + def getContentLength(self): + """ + Gets the content length. + Returns int. + """ + return 0; + + def getBodyIoStream(self): + """ + Returns file object for reading the HTML body. + """ + raise WebServerGlueException('getUrlPath is not implemented'); + + def getBodyIoStreamBinary(self): + """ + Returns file object for reading the binary HTML body. + """ + raise WebServerGlueException('getBodyIoStreamBinary is not implemented'); + + # + # Output stuff. + # + + def _writeHeader(self, sHeaderLine): + """ + Worker function which child classes can override. + """ + sys.stderr.write('_writeHeader: cch=%s "%s..."\n' % (len(sHeaderLine), sHeaderLine[0:10],)) + self.oOutputText.write(sHeaderLine); + return True; + + def flushHeader(self): + """ + Flushes the HTTP header. + """ + if self._fHeaderWrittenOut is False: + for sKey, sValue in self._dHeaderFields.items(): + self._writeHeader('%s: %s\n' % (sKey, sValue,)); + self._fHeaderWrittenOut = True; + self._writeHeader('\n'); # End of header indicator. + return None; + + def setHeaderField(self, sField, sValue): + """ + Sets a header field. + """ + assert self._fHeaderWrittenOut is False; + self._dHeaderFields[sField] = sValue; + return True; + + def setRedirect(self, sLocation, iCode = 302): + """ + Sets up redirection of the page. + Raises an exception if called too late. + """ + if self._fHeaderWrittenOut is True: + raise WebServerGlueException('setRedirect called after the header was written'); + if iCode != 302: + raise WebServerGlueException('Redirection code %d is not supported' % (iCode,)); + + self.setHeaderField('Location', sLocation); + self.setHeaderField('Status', '302 Found'); + return True; + + def setStatus(self, iStatus, sMsg = None): + """ Sets the status code. """ + if not sMsg: + sMsg = self.kdStatusMsgs[iStatus]; + return self.setHeaderField('Status', '%u %s' % (iStatus, sMsg)); + + def setContentType(self, sType): + """ Sets the content type header field. """ + return self.setHeaderField('Content-Type', sType); + + def _writeWorker(self, sChunkOfHtml): + """ + Worker function which child classes can override. + """ + sys.stderr.write('_writeWorker: cch=%s "%s..."\n' % (len(sChunkOfHtml), sChunkOfHtml[0:10],)) + self.oOutputText.write(sChunkOfHtml); + return True; + + def write(self, sChunkOfHtml): + """ + Writes chunk of HTML, making sure the HTTP header is flushed first. + """ + if self._sBodyType is None: + self._sBodyType = 'html'; + elif self._sBodyType != 'html': + raise WebServerGlueException('Cannot use writeParameter when body type is "%s"' % (self._sBodyType, )); + + self._sHtmlBody += sChunkOfHtml; + self._cchCached += len(sChunkOfHtml); + + if self._cchCached > self.kcchMaxCached: + self.flush(); + return True; + + def writeRaw(self, abChunk): + """ + Writes a raw chunk the document. Can be binary or any encoding. + No caching. + """ + if self._sBodyType is None: + self._sBodyType = 'raw'; + elif self._sBodyType != 'raw': + raise WebServerGlueException('Cannot use writeRaw when body type is "%s"' % (self._sBodyType, )); + + self.flushHeader(); + if self._cchCached > 0: + self.flush(); + + sys.stderr.write('writeRaw: cb=%s\n' % (len(abChunk),)) + self.oOutputRaw.write(abChunk); + return True; + + def writeParams(self, dParams): + """ + Writes one or more reply parameters in a form style response. The names + and values in dParams are unencoded, this method takes care of that. + + Note! This automatically changes the content type to + 'application/x-www-form-urlencoded', if the header hasn't been flushed + already. + """ + if self._sBodyType is None: + if not self._fHeaderWrittenOut: + self.setHeaderField('Content-Type', 'application/x-www-form-urlencoded; charset=utf-8'); + elif self._dHeaderFields['Content-Type'] != 'application/x-www-form-urlencoded; charset=utf-8': + raise WebServerGlueException('Cannot use writeParams when content-type is "%s"' % \ + (self._dHeaderFields['Content-Type'],)); + self._sBodyType = 'form'; + + elif self._sBodyType != 'form': + raise WebServerGlueException('Cannot use writeParams when body type is "%s"' % (self._sBodyType, )); + + for sKey in dParams: + sValue = str(dParams[sKey]); + self._dParams[sKey] = sValue; + self._cchCached += len(sKey) + len(sValue); + + if self._cchCached > self.kcchMaxCached: + self.flush(); + + return True; + + def flush(self): + """ + Flush the output. + """ + self.flushHeader(); + + if self._sBodyType == 'form': + sBody = webutils.encodeUrlParams(self._dParams); + self._writeWorker(sBody); + + self._dParams = {}; + self._cchBodyWrittenOut += self._cchCached; + + elif self._sBodyType == 'html': + self._writeWorker(self._sHtmlBody); + + self._sHtmlBody = ''; + self._cchBodyWrittenOut += self._cchCached; + + self._cchCached = 0; + return None; + + # + # Paths. + # + + def pathTmWebUI(self): + """ + Gets the path to the TM 'webui' directory. + """ + return os.path.join(self._sValidationKitDir, 'testmanager', 'webui'); + + # + # Error stuff & Debugging. + # + + def errorLog(self, sError, aXcptInfo, sLogFile): + """ + Writes the error to a log file. + """ + # Easy solution for log file size: Only one report. + try: os.unlink(sLogFile); + except: pass; + + # Try write the log file. + fRc = True; + fSaved = self._fHtmlDebugOutput; + + try: + with open(sLogFile, 'w') as oFile: # pylint: disable=unspecified-encoding + oFile.write(sError + '\n\n'); + if aXcptInfo[0] is not None: + oFile.write(' B a c k t r a c e\n'); + oFile.write('===================\n'); + oFile.write(cgitb.text(aXcptInfo, 5)); + oFile.write('\n\n'); + + oFile.write(' D e b u g I n f o\n'); + oFile.write('=====================\n\n'); + self._fHtmlDebugOutput = False; + self.debugDumpStuff(oFile.write); + except: + fRc = False; + + self._fHtmlDebugOutput = fSaved; + return fRc; + + def errorPage(self, sError, aXcptInfo, sLogFile = None): + """ + Displays a page with an error message. + """ + if sLogFile is not None: + self.errorLog(sError, aXcptInfo, sLogFile); + + # Reset buffering, hoping that nothing was flushed yet. + self._sBodyType = None; + self._sHtmlBody = ''; + self._cchCached = 0; + if not self._fHeaderWrittenOut: + if self._fHtmlDebugOutput: + self.setHeaderField('Content-Type', 'text/html; charset=utf-8'); + else: + self.setHeaderField('Content-Type', 'text/plain; charset=utf-8'); + + # Write the error page. + if self._fHtmlDebugOutput: + self.write('Test Manage Error\n' + + '

Test Manager Error:

\n' + + '

' + sError + '

\n'); + else: + self.write(' Test Manage Error\n' + '===================\n' + '\n' + '' + sError + '\n\n'); + + if aXcptInfo[0] is not None: + if self._fHtmlDebugOutput: + self.write('

Backtrace:

\n'); + self.write(cgitb.html(aXcptInfo, 5)); + else: + self.write('Backtrace\n' + '---------\n' + '\n'); + self.write(cgitb.text(aXcptInfo, 5)); + self.write('\n\n'); + + if self.kfDebugInfoEnabled: + if self._fHtmlDebugOutput: + self.write('

Debug Info:

\n'); + else: + self.write('Debug Info\n' + '----------\n' + '\n'); + self.debugDumpStuff(); + + for fn in self._afnDebugInfo: + try: + fn(self, self._fHtmlDebugOutput); + except Exception as oXcpt: + self.write('\nDebug info callback %s raised exception: %s\n' % (fn, oXcpt)); + + if self._fHtmlDebugOutput: + self.write(''); + + self.flush(); + + def debugInfoPage(self, fnWrite = None): + """ + Dumps useful debug info. + """ + if fnWrite is None: + fnWrite = self.write; + + fnWrite('Test Manage Debug Info\n\n'); + self.debugDumpStuff(fnWrite = fnWrite); + fnWrite(''); + self.flush(); + + def debugDumpDict(self, sName, dDict, fSorted = True, fnWrite = None): + """ + Dumps dictionary. + """ + if fnWrite is None: + fnWrite = self.write; + + asKeys = list(dDict.keys()); + if fSorted: + asKeys.sort(); + + if self._fHtmlDebugOutput: + fnWrite('

%s

\n' + '\n' % (sName,)); + for sKey in asKeys: + fnWrite(' \n'); + fnWrite('
namevalue
' + webutils.escapeElem(sKey) + '' \ + + webutils.escapeElem(str(dDict.get(sKey))) \ + + '
\n'); + else: + for i in range(len(sName) - 1): + fnWrite('%s ' % (sName[i],)); + fnWrite('%s\n\n' % (sName[-1],)); + + fnWrite('%28s Value\n' % ('Name',)); + fnWrite('------------------------------------------------------------------------\n'); + for sKey in asKeys: + fnWrite('%28s: %s\n' % (sKey, dDict.get(sKey),)); + fnWrite('\n'); + + return True; + + def debugDumpList(self, sName, aoStuff, fnWrite = None): + """ + Dumps array. + """ + if fnWrite is None: + fnWrite = self.write; + + if self._fHtmlDebugOutput: + fnWrite('

%s

\n' + '\n' % (sName,)); + for i, _ in enumerate(aoStuff): + fnWrite(' \n'); + fnWrite('
indexvalue
' + str(i) + '' + webutils.escapeElem(str(aoStuff[i])) + '
\n'); + else: + for ch in sName[:-1]: + fnWrite('%s ' % (ch,)); + fnWrite('%s\n\n' % (sName[-1],)); + + fnWrite('Index Value\n'); + fnWrite('------------------------------------------------------------------------\n'); + for i, oStuff in enumerate(aoStuff): + fnWrite('%5u %s\n' % (i, str(oStuff))); + fnWrite('\n'); + + return True; + + def debugDumpParameters(self, fnWrite): + """ Dumps request parameters. """ + if fnWrite is None: + fnWrite = self.write; + + try: + dParams = self.getParameters(); + return self.debugDumpDict('Parameters', dParams); + except Exception as oXcpt: + if self._fHtmlDebugOutput: + fnWrite('

Exception %s while retriving parameters.

\n' % (oXcpt,)) + else: + fnWrite('Exception %s while retriving parameters.\n' % (oXcpt,)) + return False; + + def debugDumpEnv(self, fnWrite = None): + """ Dumps os.environ. """ + return self.debugDumpDict('Environment (os.environ)', os.environ, fnWrite = fnWrite); + + def debugDumpArgv(self, fnWrite = None): + """ Dumps sys.argv. """ + return self.debugDumpList('Arguments (sys.argv)', sys.argv, fnWrite = fnWrite); + + def debugDumpPython(self, fnWrite = None): + """ + Dump python info. + """ + dInfo = {}; + dInfo['sys.version'] = sys.version; + dInfo['sys.hexversion'] = sys.hexversion; + dInfo['sys.api_version'] = sys.api_version; + if hasattr(sys, 'subversion'): + dInfo['sys.subversion'] = sys.subversion; # pylint: disable=no-member + dInfo['sys.platform'] = sys.platform; + dInfo['sys.executable'] = sys.executable; + dInfo['sys.copyright'] = sys.copyright; + dInfo['sys.byteorder'] = sys.byteorder; + dInfo['sys.exec_prefix'] = sys.exec_prefix; + dInfo['sys.prefix'] = sys.prefix; + dInfo['sys.path'] = sys.path; + dInfo['sys.builtin_module_names'] = sys.builtin_module_names; + dInfo['sys.flags'] = sys.flags; + + return self.debugDumpDict('Python Info', dInfo, fnWrite = fnWrite); + + + def debugDumpStuff(self, fnWrite = None): + """ + Dumps stuff to the error page and debug info page. + Should be extended by child classes when possible. + """ + self.debugDumpParameters(fnWrite); + self.debugDumpEnv(fnWrite); + self.debugDumpArgv(fnWrite); + self.debugDumpPython(fnWrite); + return True; + + def dprint(self, sMessage): + """ + Prints to debug log (usually apache error log). + """ + if config.g_kfSrvGlueDebug is True: + if config.g_kfSrvGlueDebugTS is False: + self._oDbgFile.write(sMessage); + if not sMessage.endswith('\n'): + self._oDbgFile.write('\n'); + else: + tsNow = utils.timestampMilli(); + tsReq = tsNow - (self.tsStart / 1000000); + iPid = os.getpid(); + for sLine in sMessage.split('\n'): + self._oDbgFile.write('%s/%03u,pid=%04x: %s\n' % (tsNow, tsReq, iPid, sLine,)); + + return True; + + def registerDebugInfoCallback(self, fnDebugInfo): + """ + Registers a debug info method for calling when the error page is shown. + + The fnDebugInfo function takes two parameters. The first is this + object, the second is a boolean indicating html (True) or text (False) + output. The return value is ignored. + """ + if self.kfDebugInfoEnabled: + self._afnDebugInfo.append(fnDebugInfo); + return True; + + def unregisterDebugInfoCallback(self, fnDebugInfo): + """ + Unregisters a debug info method previously registered by + registerDebugInfoCallback. + """ + if self.kfDebugInfoEnabled: + try: self._afnDebugInfo.remove(fnDebugInfo); + except: pass; + return True; + diff --git a/src/VBox/ValidationKit/testmanager/core/webservergluecgi.py b/src/VBox/ValidationKit/testmanager/core/webservergluecgi.py new file mode 100755 index 00000000..e530c7ec --- /dev/null +++ b/src/VBox/ValidationKit/testmanager/core/webservergluecgi.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# $Id: webservergluecgi.py $ + +""" +Test Manager Core - Web Server Abstraction Base Class. +""" + +__copyright__ = \ +""" +Copyright (C) 2012-2023 Oracle and/or its affiliates. + +This file is part of VirtualBox base platform packages, as +available from https://www.virtualbox.org. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, in version 3 of the +License. + +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, see . + +The contents of this file may alternatively be used under the terms +of the Common Development and Distribution License Version 1.0 +(CDDL), a copy of it is provided in the "COPYING.CDDL" file included +in the VirtualBox distribution, in which case the provisions of the +CDDL are applicable instead of those of the GPL. + +You may elect to license modified versions of this file under the +terms and conditions of either the GPL or the CDDL or both. + +SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +""" +__version__ = "$Revision: 155244 $" + + +# Standard python imports. +import cgitb; +import os; +import sys; +import cgi; + +# Validation Kit imports. +from testmanager.core.webservergluebase import WebServerGlueBase; +from testmanager import config; + + +class WebServerGlueCgi(WebServerGlueBase): + """ + CGI glue. + """ + def __init__(self, sValidationKitDir, fHtmlOutput=True): + WebServerGlueBase.__init__(self, sValidationKitDir, fHtmlOutput); + + if config.g_kfSrvGlueCgiTb is True: + cgitb.enable(format=('html' if fHtmlOutput else 'text')); + + def getParameters(self): + return cgi.parse(keep_blank_values=True); + + def getClientAddr(self): + return os.environ.get('REMOTE_ADDR'); + + def getMethod(self): + return os.environ.get('REQUEST_METHOD', 'POST'); + + def getLoginName(self): + return os.environ.get('REMOTE_USER', WebServerGlueBase.ksUnknownUser); + + def getUrlScheme(self): + return 'https' if 'HTTPS' in os.environ else 'http'; + + def getUrlNetLoc(self): + return os.environ['HTTP_HOST']; + + def getUrlPath(self): + return os.environ['REQUEST_URI']; + + def getUserAgent(self): + return os.getenv('HTTP_USER_AGENT', ''); + + def getContentType(self): + return cgi.parse_header(os.environ.get('CONTENT_TYPE', 'text/html')); + + def getContentLength(self): + return int(os.environ.get('CONTENT_LENGTH', 0)); + + def getBodyIoStream(self): + return sys.stdin; + + def getBodyIoStreamBinary(self): + # Python 3: sys.stdin.read() returns a string. To get untranslated + # binary data we use the sys.stdin.buffer object instead. + return getattr(sys.stdin, 'buffer', sys.stdin); + -- cgit v1.2.3