/* * Copyright (c) 2015 EMC Corporation * All Rights Reserved */ package com.emc.storageos.systemservices.impl.licensing; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.math.BigInteger; import java.net.URI; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.emc.storageos.model.vpool.ManagedResourcesCapacity; import com.emc.storageos.model.vpool.ManagedResourcesCapacity.ManagedResourceCapacity; import com.emc.storageos.svcs.errorhandling.resources.APIException; import com.emc.storageos.svcs.errorhandling.resources.InternalServerErrorException; import org.joda.time.DateTime; import org.joda.time.Days; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import com.emc.cams.elm.ELMFeatureDetail; import com.emc.cams.elm.ELMLicenseProps; import com.emc.cams.elm.ELMLicenseSource; import com.emc.cams.elm.exception.ELMLicenseException; import com.emc.storageos.coordinator.client.service.CoordinatorClient.LicenseType; import com.emc.storageos.coordinator.client.service.LicenseInfo; import com.emc.storageos.coordinator.client.model.Constants; import com.emc.storageos.coordinator.common.Service; import com.emc.storageos.systemservices.exceptions.CoordinatorClientException; import com.emc.storageos.systemservices.exceptions.LocalRepositoryException; import com.emc.storageos.systemservices.exceptions.SysClientException; import com.emc.storageos.systemservices.impl.client.SysClientFactory; import com.emc.storageos.systemservices.impl.client.SysClientFactory.SysClient; import com.emc.storageos.systemservices.impl.eventhandler.connectemc.SendEventScheduler; import com.emc.storageos.systemservices.impl.upgrade.CoordinatorClientExt; import com.emc.vipr.model.sys.licensing.License; import com.emc.vipr.model.sys.licensing.LicenseFeature; import static com.emc.storageos.coordinator.client.service.LicenseInfo.*; public class LicenseManagerImpl implements LicenseManager { private CoordinatorClientExt _coordinator; private SendEventScheduler _sendEventScheduler; private static final Logger _log = LoggerFactory.getLogger(LicenseManagerImpl.class); public static final int waitClusterStableInterval = 5000; public static final int waitRetryConvertLicneseInterval = 5000; public static final String LICENSETYPE_DELIMITER = ":"; // used to ensure thread-safety of parsing license final Lock parseLicenseLock = new ReentrantLock(); public final static int waitAcquireParseLicenseLock = 60; // 60 seconds /** * Configure the license information in properties file, disk and coordinator. * * @param license * @throws LocalRepositoryException * @throws CoordinatorClientException */ public void addLicense(License license) throws LocalRepositoryException, CoordinatorClientException, ELMLicenseException { if (getTargetInfoLock()) { try { // Step 1: Add the license test to disk in the .license file // Step 2: parse the .license file on disk in root directory using the ELMS API. This is required by the // ELMS API. License fullLicense = buildLicenseObjectFromText(license.getLicenseText()); if (fullLicense != null) { boolean isTrial = false; for (LicenseFeature feature : fullLicense.getLicenseFeatures()) { if (feature.getModelId().startsWith(LicenseConstants.VIPR_CONTROLLER) && feature.isTrialLicense()) { isTrial = true; } } if (!isTrial) { // Do not support the licenses of pre-yoda releases unless it is trial license. for (LicenseFeature licenseFeature : fullLicense.getLicenseFeatures()) { if (licenseFeature.getModelId().contains(LicenseFeature.OLD_LICENSE_SUBMODEL)) { _log.info("The license file contains a feature which is not supported any more. The license was not added to the system."); throw APIException.badRequests .licenseIsNotValid( "The license file contains a feature which is not supported any more. The license was not added to the system."); } } } } // Step 3: Add license features to coordinator service. updateCoordinatorWithLicenseFeatures(fullLicense, true); // Step 4: Add the raw license file to coordinator to keep a copy of the actual license file. updateCoordinatorWithLicenseText(license); // Step 5: Force the events to run _sendEventScheduler.run(); } finally { releaseTargetVersionLock(); } } else { _log.warn("Cannot acquire lock for adding license"); throw APIException.serviceUnavailable.postLicenseBusy(); } } /** * Check if it is a one-node deployment required by trial package in vipr 1.1 * * @return true if it is a 1+0 vipr deployment */ public boolean isTrialPackage() { // check if it is 1+0 deployment of controller return (_coordinator.getNodeCount() == 1 && Constants.CONTROL_NODE_SYSSVC_ID_PATTERN.matcher( _coordinator.getMySvcId()).matches()); } /** * Check if the license is a trial license. In vipr 1.1, only ViPR_Controller license can be trial license. * * @param license license object * @return true if the license object is a trial license * @throws ELMLicenseException */ public boolean isTrialLicense(License license) throws ELMLicenseException { // parse the license text License fullLicense = buildLicenseObjectFromText(license.getLicenseText()); if (fullLicense != null) { for (LicenseFeature feature : fullLicense.getLicenseFeatures()) { if (feature.getModelId().startsWith(LicenseConstants.VIPR_CONTROLLER) && feature.isTrialLicense()) { return true; } } } return false; } /** * Returns a full license object complete with features. * * @return */ public License getLicense() throws Exception { LicenseInfoListExt licenseInfoList = getLicenseInfoListFromCoordinator(); License license = new License(); if (licenseInfoList != null) { // create a license object from the above license features. createLicenseObject(licenseInfoList.getLicenseList(), license); } // get the raw license text from coordinator. LicenseTextInfo licenseTextInfo = getLicenseTextFromCoordinator(); // if text is found, add the raw license text to the License object. // if text is not found, put message in license text stating that the product // is not licensed. if (licenseTextInfo != null) { license.setLicenseText(licenseTextInfo.getLicenseText()); } else { license.setLicenseText("The product is not licensed"); } return license; } /** * Build a valid license object from the license string. If there is any * error while reading the license file, a license object will be created * with a licensed value of false. * * @return license object if successful, otherwise null */ protected License buildLicenseObjectFromText(String licenseText) throws ELMLicenseException { boolean bGetLock = false; try { bGetLock = parseLicenseLock.tryLock(waitAcquireParseLicenseLock, TimeUnit.SECONDS); } catch (Exception e) { _log.warn("Exception when adding license, msg: {}", e.getMessage()); throw APIException.internalServerErrors.processLicenseError("failed getting lock to validate and parse license, error:" + e.getMessage()); } if (bGetLock) { try { License license = new License(); // Add the license test to disk in the .license file in root. This is required in order // to parse the license using the ELMS API. addLicenseToDiskLicenseFile(licenseText); ELMLicenseProps licProps = new ELMLicenseProps(); licProps.setLicPath(LicenseConstants.LICENSE_FILE_PATH); ELMFeatureDetail[] featureDetails = null; ELMLicenseSource licSource = new ELMLicenseSource(licProps); featureDetails = licSource.getFeatureDetailList(); LicenseFeature licenseFeature = null; for (ELMFeatureDetail featureDetail : featureDetails) { // create a license feature object. licenseFeature = new LicenseFeature(); if (!featureDetail.getFeatureName().equals(LicenseConstants.VIPR_CONTROLLER)) { throw APIException.badRequests.licenseIsNotValid( String.format("The license file contains a not supported feature: %s.", featureDetail.getFeatureName()) + "Non controller license is no longer supported."); } if (featureDetail.getDaysUntilExp() > 0) { licenseFeature.setLicensed(true); licenseFeature.setVersion(featureDetail.getVersion()); licenseFeature.setIssuer(featureDetail.getIssuer()); licenseFeature.setNotice(featureDetail.getNotice()); licenseFeature.setDateExpires(convertCalendarToString(featureDetail.getExpDate())); licenseFeature.setExpired(isExpired(licenseFeature.getDateExpires())); licenseFeature.setDateIssued(convertCalendarToString(featureDetail.getIssuedDate())); String subModelId = LicenseFeature.OLD_LICENSE_SUBMODEL; Properties p = featureDetail.getVendorString(";"); if (p.size() > 0) { for (Enumeration e = p.propertyNames(); e.hasMoreElements();) { String str = (String) e.nextElement(); if (str.equals(LicenseConstants.LICENSE_TYPE_PROPERTYNAME)) { subModelId = p.getProperty(str); _log.info("Get a license increment with type: {}", subModelId); break; } } } licenseFeature.setModelId(featureDetail.getFeatureName() + LicenseFeature.MODELID_DELIMETER+ subModelId); setVendorStringFields(featureDetail, licenseFeature, p); } else { _log.info("The license file contains a feature which is in an expired state. The license was not added to the system."); throw APIException.badRequests .licenseIsNotValid( "The license file contains a feature which is in an expired state. The license was not added to the system."); } license.addLicenseFeature(licenseFeature); } // delete /tmp/.license if it exists deleteCurrentLicenseFileOnDisk(); _log.debug("Finished parsing of license"); return license; } finally { parseLicenseLock.unlock(); } } else { _log.warn("Cannot acquire lock. Another thread is holding the lock validating and parsing license"); throw APIException.serviceUnavailable.postLicenseBusy(); } } /** * Convert Calendar to MM/dd/yyyy format. * * @param calendar * @return */ private static String convertCalendarToString(Calendar calendar) { if (calendar != null) { SimpleDateFormat sdf = new SimpleDateFormat(LicenseConstants.MM_DD_YYYY_FORMAT); return sdf.format(calendar.getTime()); } else { return null; } } /** * Verify if product is licensed for the specified feature. * * @return boolean */ public boolean isProductLicensed(LicenseType licenseType) { return _coordinator.isProductLicensed(licenseType); } /** * Get all license info (features) from coordinator. * * @return LicenseInfoListExt which represent a list of license features * @throws Exception */ public LicenseInfoListExt getLicenseInfoListFromCoordinator() { try { return _coordinator.getTargetInfo(LicenseInfoListExt.class); } catch (Exception e) { throw APIException.internalServerErrors.getObjectFromError("license info list", "coordinator", e); } } /** * Get license info for a specific license type from coordinator. * * @return license info for the specified license type * @throws Exception */ public LicenseInfoExt getLicenseInfoFromCoordinator(LicenseType licenseType) { try { LicenseInfoListExt licInfoList = _coordinator.getTargetInfo(LicenseInfoListExt.class); if (licInfoList != null) { for (LicenseInfoExt licenseInfo : licInfoList.getLicenseList()) { if (licenseInfo.getLicenseType() == licenseType) { return licenseInfo; } } } return null; } catch (Exception e) { throw APIException.internalServerErrors.getObjectFromError(licenseType + " license info", "coordinator", e); } } /** * Get raw license text in LicenseTextInfo from coordinator. * * @return * @throws Exception */ public LicenseTextInfo getLicenseTextFromCoordinator() throws Exception { return _coordinator.getTargetInfo(LicenseTextInfo.class); } /** * Write the contents of the license file to /tmp/.license file. This is required * by the ELMS API for parsing the license. * * @param licenseText */ private void addLicenseToDiskLicenseFile(String licenseText) { // check if file exists, if so, delete it first and create a new one. deleteCurrentLicenseFileOnDisk(); // create a new file to hold the license text. // licenseFile.createNewFile(); writeFile(LicenseConstants.LICENSE_FILE_PATH, licenseText); } /** * Write license file text to disk. * * @param file licenseFile, String licenseText */ private void writeFile(String file, String licenseText) { BufferedWriter writer = null; File licenseFile = new File(file); try { writer = new BufferedWriter(new FileWriter(licenseFile.getAbsoluteFile())); writer.write(licenseText); writer.close(); } catch (IOException e) { _log.error("IO Exception while writing to .license file: {}", e); APIException.internalServerErrors.ioWriteError("/tmp/.license"); } finally { try { writer.close(); } catch (Exception e) { // do nothing. } } } /** * Deletes current version of license file in /tmp/.license * * @return File */ private void deleteCurrentLicenseFileOnDisk() { File licenseFile = new File(LicenseConstants.LICENSE_FILE_PATH); if (licenseFile.exists()) { licenseFile.delete(); } } /** * Build the coordinator service version of the license features from the * License object. * * @param license license to update * @param checkClusterUpgradable check if cluster is upgradable * @throws CoordinatorClientException */ private void updateCoordinatorWithLicenseFeatures(License license, boolean checkClusterUpgradable) throws CoordinatorClientException { LicenseInfoListExt licenseList = null; List<LicenseInfoExt> licenseInfoList = new ArrayList<LicenseInfoExt>(); for (LicenseFeature licenseFeature : license.getLicenseFeatures()) { LicenseType licenseType; if (licenseFeature.getModelId().startsWith(LicenseConstants.VIPR_CONTROLLER)) { licenseType = LicenseType.CONTROLLER; } else { throw APIException.internalServerErrors.licenseInfoNotFoundForType( "invalid license model id" + licenseFeature.getModelId()); } LicenseInfoExt licenseInfo = new LicenseInfoExt(); licenseInfo.setLicenseType(licenseType); licenseInfo.setExpirationDate(licenseFeature.getDateExpires()); licenseInfo.setStorageCapacity(licenseFeature.getStorageCapacity()); licenseInfo.setProductId(licenseFeature.getProductId()); licenseInfo.setModelId(licenseFeature.getModelId()); licenseInfo.setIssuedDate(licenseFeature.getDateIssued()); licenseInfo.setLicenseTypeIndicator(licenseFeature.getLicenseIdIndicator()); licenseInfo.setVersion(licenseFeature.getVersion()); licenseInfo.setNotice(licenseFeature.getNotice()); if (licenseFeature.isTrialLicense()) { licenseInfo.setTrialLicense(true); } licenseInfoList.add(licenseInfo); } if (!licenseInfoList.isEmpty()) { licenseList = new LicenseInfoListExt(licenseInfoList); _coordinator.setTargetInfo(licenseList, checkClusterUpgradable); } } /** * Update Coordinator Service with the customers actual raw license file text. * * @param license * @throws CoordinatorClientException */ public void updateCoordinatorWithLicenseText(License license) throws CoordinatorClientException { LicenseTextInfo licenseTextInfo = new LicenseTextInfo(); licenseTextInfo.setLicenseText(license.getLicenseText()); _coordinator.setTargetInfo(licenseTextInfo); } /** * Update Coordinator Service with license information. * * @throws CoordinatorClientException */ public void updateCoordinatorWithLicenseInfo(LicenseInfoExt licenseInfo) throws CoordinatorClientException { LicenseInfoListExt licenseList = getLicenseInfoListFromCoordinator(); if (licenseList != null) { licenseList.updateLicense(licenseInfo); _coordinator.setTargetInfo(licenseList, TARGET_PROPERTY_ID, LicenseInfo.LICENSE_INFO_TARGET_PROPERTY); } } /** * Verify if the license has expired. * * @param licenseInfo * @return */ public boolean isLicenseExpired(LicenseInfoExt licenseInfo) { LicenseFeature licenseFeature = createLicenseFeatureFromLicenseInfoExt(licenseInfo); if (licenseFeature != null) { return licenseFeature.isExpired(); } return false; } /** * Verify if storage capacity currently used has exceeded the licensed capacity from the license file. * * @param licenseInfo * @return true if capacity is exceeded */ public boolean isCapacityExceeded(LicenseInfoExt licenseInfo) { double currentCapacityUsed = 0; double licenseCapacity = Double.parseDouble(licenseInfo.getStorageCapacity()); try { // if (licenseInfo.getModelId().equalsIgnoreCase(LicenseConstants.getModelId(LicenseType.CONTROLLER))) if (licenseInfo.getLicenseType().equals(LicenseType.CONTROLLER)) { // Get capacity from apisvc currentCapacityUsed = getTotalControllerCapacity(); } else { return false; } Object[] args = new Object[] { licenseInfo.getModelId(), currentCapacityUsed, licenseCapacity }; _log.info("Capacity currently used by {}: {}, licensed capacity: {}", args); return currentCapacityUsed > licenseCapacity; } catch (Exception e) { _log.warn("Internal server error occurred while getting capacity: {}", e); } return false; } /** * Update License object with data license information from coordinator service. * * @param licenseInfoExts licenseInfoExts, License license * @return */ private void createLicenseObject(List<LicenseInfoExt> licenseInfoExts, License license) { if (licenseInfoExts.isEmpty()) { return; } for (LicenseInfoExt licenseExt : licenseInfoExts) { LicenseFeature licenseFeature = createLicenseFeatureFromLicenseInfoExt(licenseExt); license.addLicenseFeature(licenseFeature); } } /** * Create a LicenseFeature object from a LicenseInfoExt from coordinator service. * * @return LicenseFeature */ private LicenseFeature createLicenseFeatureFromLicenseInfoExt(LicenseInfoExt licenseInfo) { if (licenseInfo == null) { return null; } LicenseFeature licenseFeature = new LicenseFeature(); licenseFeature.setDateExpires(licenseInfo.getExpirationDate()); licenseFeature.setExpired(isExpired(licenseFeature.getDateExpires())); licenseFeature.setStorageCapacity(licenseInfo.getStorageCapacity()); licenseFeature.setProductId(licenseInfo.getProductId()); licenseFeature.setSerial(licenseInfo.getProductId()); licenseFeature.setModelId(licenseInfo.getModelId()); licenseFeature.setDateIssued(licenseInfo.getIssuedDate()); licenseFeature.setLicenseIdIndicator(licenseInfo.getLicenseTypeIndicator()); licenseFeature.setVersion(licenseInfo.getVersion()); licenseFeature.setNotice(licenseInfo.getNotice()); licenseFeature.setTrialLicense(licenseInfo.isTrialLicense()); licenseFeature.setLicensed(true); return licenseFeature; } /** * Parse the license text manually to get the model id's and appropriate vendor string. * This must be done manually because retrieving this via the ELMS API does not work * * @param licenseText * @return Map */ private Map<String, String> parseLicenseVendorStrings(String licenseText) { Pattern pattern = Pattern.compile(LicenseConstants.LICENSE_PATTERN, Pattern.DOTALL); Map<String, String> featureMap = new HashMap<String, String>(); // get a String array of the license features. String[] licenseFeatures = licenseText.split(LicenseConstants.LICENSE_FEATRES_DELIM); // iterate through the license features, building the vendor string map. for (String licenseFeature : licenseFeatures) { if (licenseFeature == null || licenseFeature.isEmpty()) { continue; } Matcher matcher = pattern.matcher(licenseFeature.trim()); if (matcher.find()) { featureMap.put(matcher.group(1), matcher.group(2)); } } return featureMap; } /** * Set the license feature with the appropriate data from model number's * vendor string. */ private void setVendorStringFields(ELMFeatureDetail featureDetail, LicenseFeature licenseFeature, Properties vendorProps) { if (vendorProps.getProperty(LicenseConstants.STORAGE_CAPACITY) != null) { licenseFeature.setStorageCapacity(computeLicensedAmount(vendorProps)); } // If SWID is returned in the vendor string, we will use that as the Serial number, // otherwise, use SN. Set the LicenseIndicator so that the SYR knows if we're using a SWID or LAC. String swidValue = vendorProps.getProperty(LicenseConstants.SWID_VALUE); if (swidValue == null || swidValue.startsWith("ERR")) { licenseFeature.setSerial(featureDetail.getSN()); licenseFeature.setProductId(featureDetail.getSN()); licenseFeature.setLicenseIdIndicator(LicenseConstants.LAC); } else { licenseFeature.setSerial(swidValue); licenseFeature.setProductId(swidValue); licenseFeature.setLicenseIdIndicator(LicenseConstants.SWID); } // if it is a trial license String trialLicenseStr = vendorProps.getProperty(LicenseConstants.TRIAL_LICENSE_NAME); if (trialLicenseStr != null) { for (String value : LicenseConstants.TRIAL_LICENSE_VALUE) { if (trialLicenseStr.equals(value)) { licenseFeature.setTrialLicense(true); _log.info("License {} is trial license", featureDetail.getFeatureName()); break; } } } } /** * Splits the vendor string from license into individual entries in a map. */ private Map<String, String> splitVendorString(String vendorString) { Map<String, String> vendorMap = new HashMap<String, String>(); String[] vendorStringArray = vendorString.split(";"); for (int x = 0; x < vendorStringArray.length; x++) { String[] vendorEntry = vendorStringArray[x].split("="); vendorMap.put(vendorEntry[0], vendorEntry[1]); } return vendorMap; } /** * Gets capacity from controller. * List of returned resources include volume, file and free storage pool capacities. */ public ManagedResourcesCapacity getControllerCapacity() throws InternalServerErrorException { _log.info("Getting controller capacity"); List<Service> services = _coordinator.locateAllServices( LicenseConstants.API_SVC_LOOKUP_KEY, LicenseConstants.SERVICE_LOOKUP_VERSION, null, null); for (Service service : services) { try { // service could be null, if so get next service. if (service != null) { return getClient(service).get(SysClientFactory._URI_PROVISIONING_MANAGED_CAPACITY, ManagedResourcesCapacity.class, null); } } catch (SysClientException exception) { _log.error("LicenseManager::getCapacity for Controller. Cannot connect to host: {}" , service.getEndpoint().toString()); } } // if capacity cannot be retrieved _log.error("Controller capacity could not be retrieved"); throw APIException.internalServerErrors.getObjectError("controller capacity", null); } /** * Gets controller total managed capacity. * sum of volume managed + file managed + free managed storage pool * This is mainly used to check capacity against licensed capacity amount. */ private double getTotalControllerCapacity() throws InternalServerErrorException { _log.info("Getting controller total capacity"); ManagedResourcesCapacity resourceCapacities = getControllerCapacity(); double total = 0; for (ManagedResourceCapacity cap : resourceCapacities.getResourceCapacityList()) { _log.debug("{} capacity is {}", cap.getType(), cap.getResourceCapacity()); total += cap.getResourceCapacity(); } _log.info("Controller total capacity is {}", total); return total; } /** * Get a instance of the SysClient for the base url. * * @return */ private SysClient getClient(Service service) { URI hostUri = null; hostUri = service.getEndpoint(); String baseNodeURL = String.format(LicenseConstants.BASE_URL_FORMAT, hostUri.getHost(), hostUri.getPort()); _log.info("Calling URI: " + baseNodeURL); return SysClientFactory.getSysClient(URI.create(baseNodeURL)); } /** * Compute the licensed amount by computing storage capacity times the * storage capacity unit. * * @param vendorProps * @return */ private String computeLicensedAmount(Properties vendorProps) { String units = vendorProps.getProperty(LicenseConstants.STORAGE_CAPACITY_UNITS); if (units == null) { return null; } BigInteger computedLicensedCapacity = null; // Currently, only TERABYTE is supported in the license. if (units.equalsIgnoreCase(LicenseConstants.TERABYTE)) { BigInteger licensedCapacity = new BigInteger(vendorProps.getProperty(LicenseConstants.STORAGE_CAPACITY)); computedLicensedCapacity = licensedCapacity.multiply(LicenseConstants.TB_VALUE); } // If for some reason we cannot compute the capacity for the capacity values in the license // (CAPACITY * CAPACITY_UNIT), return 0. if (computedLicensedCapacity != null) { return computedLicensedCapacity.toString(); } else { return "0"; } } protected static boolean isExpired(String expiryDateString) { // if date expires is empty, check to see if the product is licensed. If // it is licensed and the _dateExpires is false, the license has expired. // This is because the ELM software does not set the date expires when // it notices a license is invalid. This license must have expired // sometime between server restarts. The product is technically // licenses..but in an expired state. if (expiryDateString == null) { return false; } if (!expiryDateString.equalsIgnoreCase(PERMANENT_LICENSE)) { SimpleDateFormat sdf = new SimpleDateFormat(EXPIRE_DATE_FORMAT); Date expireDate = null; try { expireDate = sdf.parse(expiryDateString); Date today = Calendar.getInstance().getTime(); int days = Days.daysBetween(new DateTime(expireDate), new DateTime(today)).getDays(); if (days > 0) { return true; } } catch (ParseException e) { _log.error("Parse Exception in License::isExpired() : " + e.getMessage()); } } return false; } /** * Get a target info lock from coordinator. * * @return */ public boolean getTargetInfoLock() { return _coordinator.getTargetInfoLock(); } /** * calls coordinator client to release a target version lock. */ public void releaseTargetVersionLock() { _coordinator.releaseTargetVersionLock(); } /** * Set the CoordinatorClientExt * * @param coordinatorClientExt */ public void setCoordinatorClientExt(CoordinatorClientExt coordinatorClientExt) { _coordinator = coordinatorClientExt; } /** * Set the events scheduler. * * @param scheduler */ @Autowired public void setSendEventScheduler(SendEventScheduler scheduler) { _sendEventScheduler = scheduler; } }