diff --git a/pom.xml b/pom.xml index c24b10c5..0e65494c 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 com.sonyericsson.jenkins.plugins.bfa build-failure-analyzer - 1.19.3-SNAPSHOT + 1.20.0 hpi Build Failure Analyzer Jenkins Build Failure Analyzer Plugin @@ -245,6 +245,11 @@ 1.35 test + + com.amazonaws + aws-java-sdk + 1.11.106 + diff --git a/src/main/java/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBase.java b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBase.java new file mode 100644 index 00000000..fd7f6507 --- /dev/null +++ b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBase.java @@ -0,0 +1,534 @@ +package com.sonyericsson.jenkins.plugins.bfa.db; + +import com.amazonaws.regions.Region; +import com.amazonaws.regions.RegionUtils; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.model.Condition; +import com.amazonaws.services.dynamodbv2.model.CreateTableRequest; +import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput; +import com.amazonaws.services.dynamodbv2.util.TableUtils; +import com.sonyericsson.jenkins.plugins.bfa.model.FailureCause; +import com.sonyericsson.jenkins.plugins.bfa.Messages; +import com.sonyericsson.jenkins.plugins.bfa.statistics.Statistics; +import hudson.Extension; +import hudson.model.Descriptor; +import com.amazonaws.AmazonClientException; +import com.amazonaws.auth.profile.ProfileCredentialsProvider; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Handling of the Amazon Web Services DynamoDB way of saving the knowledge base. + * + * @author Ken Petti <kpetti@constantcontact.com> + */ +public class DynamoDBKnowledgeBase extends KnowledgeBase { + + private static final long serialVersionUID = 1; + private static final String DYNAMODB_DEFAULT_REGION = Regions.DEFAULT_REGION.getName(); + private static final String DYNAMODB_DEFAULT_CREDENTIALS_PATH = + System.getProperty("user.home") + "/.aws/credentials"; + private static final String DYNAMODB_DEFAULT_CREDENTIAL_PROFILE = "default"; + static final Map NOT_REMOVED_FILTER_EXPRESSION = new HashMap(){{ + put("_removed", new Condition().withComparisonOperator("NULL")); + }}; + + private static AmazonDynamoDB dynamoDB; + private transient DynamoDBMapper dbMapper; + + private String region; + private String credentialsPath; + private String credentialsProfile; + + /** + * Getter for the DynamoDB region. + * @return the region. + */ + public String getRegion() { + return region; + } + + /** + * Getter for the AWS credentials path. + * @return the credentialsPath. + */ + public String getCredentialsPath() { + return credentialsPath; + } + + /** + * Getter for the AWS credentials profile. + * @return the credentialsProfile string. + */ + public String getCredentialsProfile() { + return credentialsProfile; + } + + /** + * Standard constructor. + * @param region the AWS region to connect to DynamoDB with. + * @param credentialsPath the path to a local file containing AWS credentials. + * @param credentialsProfile the AWS credential profile to use for connecting to DynamoDB. + */ + @DataBoundConstructor + public DynamoDBKnowledgeBase(String region, String credentialsPath, String credentialsProfile) { + if (region == null || region.isEmpty()) { + region = DYNAMODB_DEFAULT_REGION; + } + if (credentialsPath == null || credentialsPath.isEmpty()) { + credentialsPath = DYNAMODB_DEFAULT_CREDENTIALS_PATH; + } + if (credentialsProfile == null || credentialsProfile.isEmpty()) { + credentialsProfile = DYNAMODB_DEFAULT_CREDENTIAL_PROFILE; + } + + this.region = region; + this.credentialsPath = credentialsPath; + this.credentialsProfile = credentialsProfile; + } + + /** + * Get the list of {@link FailureCause}s except those marked as removed. It is intended to be used in the scanning + * phase hence it should be returned as quickly as possible, so the list could be cached. + * + * @return the full list of causes. + */ + // TODO: 4/19/18 Add caching here + @Override + public Collection getCauses() { + DynamoDBScanExpression scan = new DynamoDBScanExpression(); + scan.setScanFilter(NOT_REMOVED_FILTER_EXPRESSION); + return getDbMapper().scan(FailureCause.class, scan); + } + + /** + * Get the list of the {@link FailureCause}'s names and ids. The list should be the latest possible from the DB as + * they will be used for editing. The objects returned should contain at least the id and the name of the cause. + * + * @return the full list of the names and ids of the causes. + */ + @Override + public Collection getCauseNames() { + DynamoDBScanExpression scan = new DynamoDBScanExpression(); + scan.addExpressionAttributeNamesEntry("#n", "name"); + scan.setProjectionExpression("id,#n"); + scan.setScanFilter(NOT_REMOVED_FILTER_EXPRESSION); + return getDbMapper().scan(FailureCause.class, scan); + } + + /** + * Get a shallow list of the {@link FailureCause}s. The list should be the latest possible from the DB as + * they will be used in the list of causes to edit. + * shallow meaning no indications but information enough to show a nice list; at least id and name but description, + * comment, lastOccurred and categories are preferred as well. + * + * @return a shallow list of all causes. + * @see #getCauseNames() + */ + @Override + public Collection getShallowCauses() { + DynamoDBScanExpression scan = new DynamoDBScanExpression(); + // The following attributes are reserved words in Dynamo, so we need to substitute the actual name for + // something safe + scan.addExpressionAttributeNamesEntry("#n", "name"); + scan.addExpressionAttributeNamesEntry("#c", "comment"); + scan.addExpressionAttributeNamesEntry("#r", "_removed"); + scan.setProjectionExpression("id,#n,description,categories,#c,modifications,lastOccurred"); + scan.setFilterExpression(" attribute_not_exists(#r) "); + return getDbMapper().scan(FailureCause.class, scan); + } + + /** + * Get the cause with the given id. The cause returned is intended to be edited right away, so it should be as fresh + * from the db as possible. + * + * @param id the id of the cause. + * @return the cause or null if a cause with that id could not be found. + */ + @Override + public FailureCause getCause(String id) { + return getDbMapper().load(FailureCause.class, id); + } + + /** + * Saves a new cause to the db, which generates a new id for the cause. + * + * @param cause the cause to add. + * @return the same cause but with a new id. + */ + @Override + public FailureCause addCause(FailureCause cause) { + return saveCause(cause); + } + + /** + * Marks the cause as removed in the knowledge base. + * + * @param id the id of the cause to remove. + * @return the removed FailureCause. + */ + @Override + public FailureCause removeCause(String id) { + FailureCause cause = getDbMapper().load(FailureCause.class, id); + cause.setRemoved(); + getDbMapper().save(cause); + return cause; + } + + /** + * Saves a cause to the db. Assumes that the id is kept from when it was fetched. Can also be an existing cause in + * another {@link KnowledgeBase} implementation with a preexisting id that is being converted via {@link + * #convertFrom(KnowledgeBase)}. + * + * @param cause the cause to add. + * @return the same cause but with a new id. + */ + @Override + public FailureCause saveCause(FailureCause cause) { + getDbMapper().save(cause); + return cause; + } + + /** + * Converts the existing old knowledge base into this one. Will be called after the creation of a new object when + * then Jenkins config is saved, So it could just be that the old one is exactly the same as this one. + * + * @param oldKnowledgeBase the old one. + * @throws Exception if converting DB fails or something in the KnowledgeBase handling goes wrong. + */ + @Override + public void convertFrom(KnowledgeBase oldKnowledgeBase) throws Exception { + if (oldKnowledgeBase instanceof DynamoDBKnowledgeBase) { + convertFromAbstract(oldKnowledgeBase); + convertRemoved((DynamoDBKnowledgeBase)oldKnowledgeBase); + } else { + for (FailureCause cause : oldKnowledgeBase.getCauseNames()) { + saveCause(cause); + } + } + } + + /** + * Copies all causes flagged as removed from the old database to this one. + * + * @param oldKnowledgeBase the old database. + */ + private void convertRemoved(DynamoDBKnowledgeBase oldKnowledgeBase) { + Collection removed = oldKnowledgeBase.getRemovedCauses(); + for (FailureCause obj : removed) { + saveCause(obj); + } + } + + /** + * Gets all causes flagged as removed in a "raw" JSON format. + * + * @return the list of removed causes. + */ + private Collection getRemovedCauses() { + DynamoDBScanExpression scan = new DynamoDBScanExpression(); + scan.setFilterExpression(" attribute_exists(#r) "); + return getDbMapper().scan(FailureCause.class, scan); + } + + /** + * Gets the unique categories of all FailureCauses. + * + * @return the list of categories. + */ + @Override + public List getCategories() { + DynamoDBScanExpression scan = new DynamoDBScanExpression(); + scan.setProjectionExpression("categories"); + scan.setFilterExpression(" attribute_exists(categories) "); + List causes = getDbMapper().scan(FailureCause.class, scan); + Set categories = new HashSet<>(); + for (FailureCause c:causes) { + categories.addAll(c.getCategories()); + } + return new ArrayList<>(categories); + } + + /** + * Called to see if the configuration has changed. + * + * @param oldKnowledgeBase the previous config. + * @return true if it is the same. + */ + @Override + public boolean equals(KnowledgeBase oldKnowledgeBase) { + if (getClass().isInstance(oldKnowledgeBase)) { + DynamoDBKnowledgeBase oldDynamoDBKnowledgeBase = (DynamoDBKnowledgeBase)oldKnowledgeBase; + return oldDynamoDBKnowledgeBase.getRegion().equals(region) + && oldDynamoDBKnowledgeBase.getCredentialsPath().equals(credentialsPath) + && oldDynamoDBKnowledgeBase.getCredentialsProfile().equals(credentialsProfile); + } else { + return false; + } + } + + /** + * Overrides base Object equals. + * @param other object to check + * @return boolean if values are equal + */ + @Override + public boolean equals(Object other) { + if (other instanceof KnowledgeBase) { + return this.equals((KnowledgeBase)other); + } else { + return false; + } + } + + /** + * Makes checkstyle happy. + * @return hashcode of class + */ + @Override + public int hashCode() { + //Making checkstyle happy. + return getClass().getName().hashCode(); + } + + /** + * Called when the KnowledgeBase should be up and running. + */ + @Override + public void start() { + getDynamoDb(); + } + + /** + * Called when it is time to clean up after the KnowledgeBase. + */ + // TODO: 4/19/18 Implement this + @Override + public void stop() { + + } + + /** + * If Statistics logging is enabled on this knowledge base or not. + * + * @return true if so. False if not or not implemented. + */ + // TODO: 4/19/18 Implement this + @Override + public boolean isStatisticsEnabled() { + return false; + } + + /** + * If all builds should be added to statistics logging, not just unsuccessful builds. + * Only relevant if {@link #isStatisticsEnabled()} is true. + * + * @return true if set, false otherwise or if not implemented + */ + // TODO: 4/19/18 Implement this + @Override + public boolean isSuccessfulLoggingEnabled() { + return false; + } + + /** + * Saves the Statistics. + * + * @param stat the Statistics. + * @throws Exception if something in the KnowledgeBase handling goes wrong. + */ + // TODO: 4/19/18 Implement this + @Override + public void saveStatistics(Statistics stat) throws Exception { + + } + + /** + * Get an instance of {@link AmazonDynamoDB}. Connects to the defined region with the defined AWS + * credentials file/profile. If this has been called before, it will return the cached version. + * @return instance of AmazonDynamoDB + */ + private AmazonDynamoDB getDynamoDb() { + if (dynamoDB != null) { + return dynamoDB; + } + + ProfileCredentialsProvider credentialsProvider = + new ProfileCredentialsProvider(credentialsPath, credentialsProfile); + try { + credentialsProvider.getCredentials(); + } catch (Exception e) { + throw new AmazonClientException( + "Cannot load the credentials from the credential profiles file. " + + "Please make sure that your credentials file is at the correct " + + "location (~/.aws/credentials), and is in valid format.", + e); + } + + dynamoDB = AmazonDynamoDBClientBuilder.standard() + .withCredentials(credentialsProvider) + .withRegion(region) + .build(); + + return dynamoDB; + } + + /** + * Get a cached or new instance of {@link DynamoDBMapper}. + * @return dbMapper + */ + private DynamoDBMapper getDbMapper() { + if (dbMapper != null) { + return dbMapper; + } + dbMapper = new DynamoDBMapper(getDynamoDb()); + createTable(dbMapper.generateCreateTableRequest(FailureCause.class)); + + return dbMapper; + } + + /** + * Creates a DynamoDB table. + * @param request {@link CreateTableRequest} + */ + private void createTable(CreateTableRequest request) { + try { + String tableName = request.getTableName(); + AmazonDynamoDB db = getDynamoDb(); + request.setProvisionedThroughput(new ProvisionedThroughput() + .withReadCapacityUnits(1L).withWriteCapacityUnits(1L)); + TableUtils.createTableIfNotExists(db, request); + TableUtils.waitUntilActive(db, tableName); + + } catch (Exception e) { + throw new AmazonClientException(e); + } + } + + /** + * Use Jenkins to get and instance of {@link DynamoDBKnowledgeBaseDescriptor}. + * @return Descriptor + */ + @Override + public Descriptor getDescriptor() { + return Jenkins.getInstance().getDescriptorByType(DynamoDBKnowledgeBaseDescriptor.class); + } + + /** + * Descriptor for {@link DynamoDBKnowledgeBase}. + */ + @Extension + public static class DynamoDBKnowledgeBaseDescriptor extends KnowledgeBaseDescriptor { + + @Override + public String getDisplayName() { + return Messages.DynamoDBKnowledgeBase_DisplayName(); + } + + /** + * Convenience method for jelly. + * @return the default region. + */ + public String getDefaultRegion() { + return DYNAMODB_DEFAULT_REGION; + } + + /** + * Convenience method for jelly. + * @return the default region. + */ + public String getDefaultCredentialsPath() { + return DYNAMODB_DEFAULT_CREDENTIALS_PATH; + } + + /** + * Convenience method for jelly. + * @return the default region. + */ + public String getDefaultCredentialProfile() { + return DYNAMODB_DEFAULT_CREDENTIAL_PROFILE; + } + + /** + * Get a list of valid AWS regions for Jelly. + * @return ListBoxModel containing AWS regions + */ + public ListBoxModel doFillRegionItems() { + ListBoxModel items = new ListBoxModel(); + for (Region r:RegionUtils.getRegions()) { + String regionName = r.getName(); + items.add(regionName, regionName); + } + return items; + } + + /** + * Checks that the credential file exists. + * + * @param value the pattern to check. + * @return {@link hudson.util.FormValidation#ok()} if everything is well. + */ + public FormValidation doCheckCredentialsPath(@QueryParameter("value") final String value) { + File f = new File(value); + if(!f.exists()) { + return FormValidation.error("Credential file does not exist!"); + } + + if (f.isDirectory()) { + return FormValidation.error("Credential file can not be a directory!"); + } + return FormValidation.ok(); + } + + /** + * Checks that the credential profile is set. + * + * @param value the pattern to check. + * @return {@link hudson.util.FormValidation#ok()} if everything is well. + */ + public FormValidation doCheckCredentialsProfile(@QueryParameter("value") final String value) { + if (value == null || value.isEmpty()) { + return FormValidation.warning("No credential profile entered, using \"default\" profile"); + } + + return FormValidation.ok(); + } + + /** + * Tests if the provided parameters can connect to the DynamoDB service. + * @param region the region name. + * @param credentialsPath the filepath to credentials. + * @param credentialProfile the credential profile to use. + * @return {@link FormValidation#ok() } if can be done, + * {@link FormValidation#error(java.lang.String) } otherwise. + */ + public FormValidation doTestConnection( + @QueryParameter("region") final String region, + @QueryParameter("credentialsPath") final String credentialsPath, + @QueryParameter("credentialProfile") final String credentialProfile + ) { + DynamoDBKnowledgeBase base = new DynamoDBKnowledgeBase(region, credentialsPath, credentialProfile); + try { + base.getDynamoDb(); + } catch (Exception e) { + return FormValidation.error(e, Messages.DynamoDBKnowledgeBase_ConnectionError()); + } + return FormValidation.ok(Messages.DynamoDBKnowledgeBase_ConnectionOK()); + } + } +} diff --git a/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCause.java b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCause.java index a9b36edc..8585c4f0 100644 --- a/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCause.java +++ b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCause.java @@ -23,6 +23,15 @@ */ package com.sonyericsson.jenkins.plugins.bfa.model; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverted; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBIgnore; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; +import org.apache.commons.lang.builder.EqualsBuilder; +import org.codehaus.jackson.type.TypeReference; import com.sonyericsson.jenkins.plugins.bfa.CauseManagement; import com.sonyericsson.jenkins.plugins.bfa.PluginImpl; import com.sonyericsson.jenkins.plugins.bfa.db.KnowledgeBase; @@ -47,6 +56,7 @@ import org.codehaus.jackson.annotate.JsonIgnoreProperties; import org.codehaus.jackson.annotate.JsonIgnoreType; import org.codehaus.jackson.annotate.JsonProperty; +import org.codehaus.jackson.map.ObjectMapper; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.Stapler; @@ -54,10 +64,17 @@ import org.kohsuke.stapler.StaplerResponse; import java.io.Serializable; -import java.util.Arrays; +import java.lang.reflect.Field; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.List; import java.util.Date; import java.util.LinkedList; -import java.util.List; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; +import java.util.ArrayList; +import java.util.Arrays; import java.util.logging.Level; import java.util.logging.Logger; @@ -67,17 +84,30 @@ * * @author Tomas Westling <thomas.westling@sonyericsson.com> */ +@DynamoDBTable(tableName = "failureCauses") @JsonIgnoreProperties(ignoreUnknown = true) public class FailureCause implements Serializable, Action, Describable { private static final Logger logger = Logger.getLogger(FailureCause.class.getName()); + @DynamoDBHashKey(attributeName = "id") + @DynamoDBAutoGeneratedKey private String id; + @DynamoDBAttribute(attributeName = "name") private String name; + @DynamoDBAttribute(attributeName = "description") private String description; + @DynamoDBAttribute(attributeName = "comment") private String comment; + @DynamoDBAttribute(attributeName = "lastOccurred") private Date lastOccurred; + @DynamoDBAttribute(attributeName = "categories") private List categories; + @DynamoDBTypeConverted(converter = IndicationsTypeConverter.class) + @DynamoDBAttribute(attributeName = "indications") private List indications; + @DynamoDBAttribute(attributeName = "modifications") private List modifications; + @DynamoDBAttribute(attributeName = "_removed") + private Map removed; /** * Standard data bound constructor. @@ -338,8 +368,7 @@ public String getId() { /** * The id. - * - * @param id the id. + * @param id String */ @Id @ObjectId @@ -356,6 +385,14 @@ public String getName() { return name; } + /** + * Setter for the name. + * @param name String + */ + public void setName(String name) { + this.name = name; + } + /** * Getter for the description. * @@ -365,6 +402,12 @@ public String getDescription() { return description; } + /** + * Setter for the description. + * @param description String + */ + public void setDescription(String description) { this.description = description; } + /** * Getter for the comment. * @@ -374,6 +417,45 @@ public String getComment() { return comment; } + /** + * Setter for the comment. + * @param comment String + */ + public void setComment(String comment) { this.comment = comment; } + + /** + * Getter for removed info. + * + * @return the removed info. + */ + public Map getRemoved() { + return removed; + } + + /** + * Setter for removed. This creates the removed info before setting it + */ + public void setRemoved() { + TimeZone tz = TimeZone.getTimeZone("UTC"); + // Quoted "Z" to indicate UTC, no timezone offset + DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'"); + df.setTimeZone(tz); + String nowAsISO = df.format(new Date()); + + Map removedInfo = new HashMap<>(); + removedInfo.put("by", Jenkins.getAuthentication().getName()); + removedInfo.put("timestamp", nowAsISO); + setRemoved(removedInfo); + } + + /** + * Setter for removed, when removed is defined elsewhere. + * @param removedInfo map containing "by" and "timestamp" keys + */ + public void setRemoved(Map removedInfo) { + this.removed = removedInfo; + } + /** * Getter for the last occurrence. * @@ -393,6 +475,7 @@ public Date getLastOccurred() { * @return the last occurrence. */ @JsonIgnore + @DynamoDBIgnore public Date getAndInitiateLastOccurred() { if (lastOccurred == null && id != null) { loadLastOccurred(); @@ -427,12 +510,21 @@ public List getModifications() { return modifications; } + /** + * Setter for the list of modifications. + * @param modifications List of {@link FailureCauseModification} + */ + public void setModifications(List modifications) { + this.modifications = modifications; + } + /** * Initiates the list of modifications if it's not already initiated * and then returns the list. * @return list of modifications */ @JsonIgnore + @DynamoDBIgnore public List getAndInitiateModifications() { if ((modifications == null || modifications.isEmpty()) && id != null) { @@ -456,6 +548,7 @@ public List getCategories() { * @return the categories as a String. */ @JsonIgnore + @DynamoDBIgnore public String getCategoriesAsString() { if (categories == null || categories.isEmpty()) { return null; @@ -515,6 +608,7 @@ private void initModifications() { * @return the latest modification */ @JsonIgnore + @DynamoDBIgnore public FailureCauseModification getLatestModification() { List mods = getAndInitiateModifications(); if (mods != null && !mods.isEmpty()) { @@ -566,6 +660,12 @@ public List getIndications() { return indications; } + /** + * Setter for the list of indications. + * @param indications List of {@link Indication} + */ + public void setIndications(List indications) { this.indications = indications; } + //CS IGNORE JavadocMethod FOR NEXT 8 LINES. REASON: The exception can be thrown. /** @@ -575,6 +675,7 @@ public List getIndications() { * @throws IllegalStateException if no ancestor is found. */ @JsonIgnore + @DynamoDBIgnore public CauseManagement getAncestorCauseManagement() { StaplerRequest currentRequest = Stapler.getCurrentRequest(); if (currentRequest == null) { @@ -589,28 +690,62 @@ public CauseManagement getAncestorCauseManagement() { @Override @JsonIgnore + @DynamoDBIgnore public String getIconFileName() { return PluginImpl.getDefaultIcon(); } @Override @JsonIgnore + @DynamoDBIgnore public String getDisplayName() { return name; } @Override @JsonIgnore + @DynamoDBIgnore public String getUrlName() { return id; } @Override + @DynamoDBIgnore public FailureCauseDescriptor getDescriptor() { return Jenkins.getInstance().getDescriptorByType(FailureCauseDescriptor.class); } + @Override + public boolean equals(Object o) { + if (o == this) { return true; } + if (!(o instanceof FailureCause)) { + return false; + } + + FailureCause fc = (FailureCause)o; + Field[] fields = this.getClass().getDeclaredFields(); + EqualsBuilder eb = new EqualsBuilder(); + for (Field f:fields) { + try { + eb.append(f.get(this), f.get(fc)); + } catch (IllegalArgumentException | IllegalAccessException e) { + return false; + } + } + return eb.isEquals(); + } + + /** + * Makes checkstyle happy. + * @return hashcode of class + */ + @Override + public int hashCode() { + //Making checkstyle happy. + return getClass().getName().hashCode(); + } + /** * Descriptor is only used for auto completion of categories. */ @@ -688,4 +823,59 @@ public AutoCompletionCandidates doAutoCompleteCategories(@QueryParameter String return candidates; } } + + /** + * Defines methods to convert {@link Indication}s to/from DynamoDB objects. + */ + public static class IndicationsTypeConverter implements + DynamoDBTypeConverter>, List> { + private ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Transform a list of {@link Indication} objects into a list of maps, + * for use as a {@link FailureCause} property. + * @param indications list of {@link Indication}s + * @return the converted list of {@link Indication}s + */ + @Override + public List> convert(List indications) { + List> stringedIndications = new ArrayList<>(); + for (Indication i:indications) { + try { + // Have to convert to the indication to a string to capture the indication type + String stringed = objectMapper.writeValueAsString(i); + TypeReference> typeRef + = new TypeReference>() {}; + // Then convert the stringed to a map, so the whole list of indication can be written as a string + Map mapped = objectMapper.readValue(stringed, typeRef); + stringedIndications.add(mapped); + } catch (Exception e) { + e.printStackTrace(); + } + } + return stringedIndications; + } + + /** + * Transform a list of Indication maps into a list of {@link Indication}s. + * @param toUnConvert list of converted Indications + * @return list of {@link Indication}s + */ + @Override + public List unconvert(List> toUnConvert) { + List indications = new ArrayList<>(); + for (Map i:toUnConvert) { + String clazz = i.get("@class"); + try { + // Create a new Indication instance based on the class that was stored when converting + Object obj = Class.forName(clazz).getConstructor(String.class).newInstance(i.get("pattern")); + Indication indication = Indication.class.cast(obj); + indications.add(indication); + } catch (Exception e) { + e.printStackTrace(); + } + } + return indications; + } + } } diff --git a/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseModification.java b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseModification.java index 34d9843f..603d8e68 100644 --- a/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseModification.java +++ b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseModification.java @@ -23,8 +23,10 @@ */ package com.sonyericsson.jenkins.plugins.bfa.model; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBDocument; import org.codehaus.jackson.annotate.JsonCreator; import org.codehaus.jackson.annotate.JsonProperty; +import org.kohsuke.stapler.DataBoundConstructor; import java.io.Serializable; import java.util.Date; @@ -34,6 +36,7 @@ * * @author Felix Hall <felix.hall@sonymobile.com> */ +@DynamoDBDocument public class FailureCauseModification implements Serializable { private String user; private Date time; @@ -54,6 +57,12 @@ public FailureCauseModification(@JsonProperty("user") String user, @JsonProperty } } + /** + * Empty constructor used for DynamoDB converting. + */ + @DataBoundConstructor + public FailureCauseModification() {}; + /** * Getter for the time. * @@ -67,6 +76,12 @@ public Date getTime() { } } + /** + * Setter for the time. + * @param time {@link Date} + */ + public void setTime(Date time) { this.time = new Date(time.getTime()); } + /** * Getter for the user. * @@ -76,4 +91,10 @@ public String getUser() { return user; } + /** + * Setter for the user. + * @param user String of user name + */ + public void setUser(String user) { this.user = user; } + } diff --git a/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/indication/Indication.java b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/indication/Indication.java index 6cd6e0ef..c8832171 100644 --- a/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/indication/Indication.java +++ b/src/main/java/com/sonyericsson/jenkins/plugins/bfa/model/indication/Indication.java @@ -106,6 +106,22 @@ public String toString() { return getUserProvidedExpression(); } + @Override + public boolean equals(Object o) { + return o instanceof Indication && this.getUserProvidedExpression().equals( + ((Indication)o).getUserProvidedExpression()); + } + + /** + * Makes checkstyle happy. + * @return hashcode of class + */ + @Override + public int hashCode() { + //Making checkstyle happy. + return getClass().getName().hashCode(); + } + /** * The descriptor for this indicator. */ diff --git a/src/main/resources/com/sonyericsson/jenkins/plugins/bfa/Messages.properties b/src/main/resources/com/sonyericsson/jenkins/plugins/bfa/Messages.properties index 7162e396..6b39d9e2 100644 --- a/src/main/resources/com/sonyericsson/jenkins/plugins/bfa/Messages.properties +++ b/src/main/resources/com/sonyericsson/jenkins/plugins/bfa/Messages.properties @@ -12,6 +12,9 @@ LocalFileKnowledgeBase_DisplayName=Jenkins Local MongoDBKnowledgeBase_DisplayName=Mongo DB MongoDBKnowledgeBase_ConnectionError=Could not connect MongoDBKnowledgeBase_ConnectionOK=Connection OK! +DynamoDBKnowledgeBase_DisplayName=DynamoDB +DynamoDBKnowledgeBase_ConnectionError=Could not connect +DynamoDBKnowledgeBase_ConnectionOK=Connection OK! StringMatchesPattern=String matches pattern StringDoesNotMatchPattern=String does not match pattern InvalidPattern_Error=Invalid pattern diff --git a/src/main/resources/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBase/config.jelly b/src/main/resources/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBase/config.jelly new file mode 100644 index 00000000..73829ef3 --- /dev/null +++ b/src/main/resources/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBase/config.jelly @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/src/test/java/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBaseTest.java b/src/test/java/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBaseTest.java new file mode 100644 index 00000000..5714bdbb --- /dev/null +++ b/src/test/java/com/sonyericsson/jenkins/plugins/bfa/db/DynamoDBKnowledgeBaseTest.java @@ -0,0 +1,328 @@ +/* + * The MIT License + * + * Copyright 2012 Sony Mobile Communications Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.sonyericsson.jenkins.plugins.bfa.db; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperTableModel; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.model.AttributeValue; +import com.amazonaws.services.dynamodbv2.model.ScanResult; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; +import com.sonyericsson.jenkins.plugins.bfa.model.FailureCause; +import com.sonyericsson.jenkins.plugins.bfa.model.indication.BuildLogIndication; +import com.sonyericsson.jenkins.plugins.bfa.model.indication.Indication; +import com.sonyericsson.jenkins.plugins.bfa.statistics.Statistics; +import jenkins.model.Jenkins; +import org.acegisecurity.Authentication; +import org.apache.commons.collections.CollectionUtils; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.powermock.core.classloader.annotations.PowerMockIgnore; + + +import java.util.LinkedList; +import java.util.Map; +import java.util.ArrayList; +import java.util.List; +import java.util.Collection; +import java.util.Date; + + +import static junit.framework.Assert.assertTrue; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertSame; +import static junit.framework.Assert.assertNotSame; +import static junit.framework.Assert.assertNotNull; +import static org.powermock.api.mockito.PowerMockito.spy; +import static org.powermock.api.mockito.PowerMockito.mock; +import static org.powermock.api.mockito.PowerMockito.whenNew; +import static org.powermock.api.mockito.PowerMockito.doReturn; +import static org.powermock.api.mockito.PowerMockito.doNothing; + + + +/** + * Tests for the DynamoDBKnowledgeBase. + * + * @author Tomas Westling <tomas.westling@sonyericsson.com> + */ +@RunWith(PowerMockRunner.class) +@PowerMockIgnore({"javax.*"}) +@PrepareForTest({DynamoDBKnowledgeBase.class, Jenkins.class, Jenkins.DescriptorImpl.class}) +public class DynamoDBKnowledgeBaseTest { + + @Mock + private Jenkins jenkins; + @Mock + private DynamoDBMapper dbMapper; + @Mock + private AmazonDynamoDB db; + + private DynamoDBKnowledgeBase kb; + private List indications; + private Indication indication; + private Statistics mockedStatistics; + private String mockJenkinsUserName = "Jtester"; + private static final int MAX_ITERATIONS = 3; + + /** + * Common stuff to set up for the tests. + * @throws Exception if so. + */ + @Before + public void setUp() throws Exception{ + // Mock interactions with Jenkins + Authentication mockAuth = mock(Authentication.class); + PowerMockito.mockStatic(Jenkins.class); + PowerMockito.when(Jenkins.getInstance()).thenReturn(jenkins); + PowerMockito.when(Jenkins.getAuthentication()).thenReturn(mockAuth); + PowerMockito.when(mockAuth.getName()).thenReturn(mockJenkinsUserName); + + // Mock DynamoDB and DBMapper + kb = PowerMockito.spy(new DynamoDBKnowledgeBase("", "", "default")); + db = mock(AmazonDynamoDB.class); + dbMapper = spy(new DynamoDBMapper(db)); + PowerMockito.doReturn(dbMapper).when(kb, "getDbMapper"); + + indications = new LinkedList(); + indication = new BuildLogIndication("something"); + indications.add(indication); + } + + /** + * Helper to create a FailureCause during testing. + * @param id string id FailureCause to return + * @return FailureCause + * @throws Exception if so. + */ + public FailureCause createFailureCause(String id) throws Exception{ + return new FailureCause(id, "myFailureCause", "description", "comment", new Date(), + "category", indications, null); + } + + /** + * Sets up a mock scan result, which DynamoDB uses when creating a paginated list of results. + * @param causes collection of FailureCauses + * @throws Exception if so. + */ + public void mockScanResult(Collection causes) throws Exception { + DynamoDBMapperTableModel fcModel = dbMapper.getTableModel(FailureCause.class); + Collection> convertedFcs = new ArrayList<>(); + for (FailureCause fc:causes) { + Map convertedFc = fcModel.convert(fc); + convertedFcs.add(convertedFc); + } + + ScanResult mockedScanResult = spy(new ScanResult()).withItems(convertedFcs); + doReturn(mockedScanResult).when(db, "scan", Matchers.any()); + } + + /** + * Tests finding one cause by its id. + * + * @throws Exception if so. + */ + @Test + public void testFindOneCause() throws Exception { + String id = "2ce2ae7b-7f66-4a8c-984a-802a43d3a9a4"; + FailureCause mockedCause = createFailureCause(id); + doReturn(mockedCause).when(dbMapper).load(FailureCause.class, id); + FailureCause fetchedCause = kb.getCause(id); + assertNotNull("The fetched cause should not be null", fetchedCause); + assertSame(mockedCause, fetchedCause); + } + + /** + * Tests finding all causes. + * + * @throws Exception if so. + */ + @Test + public void testGetCauseNames() throws Exception { + Collection expectedCauses = new ArrayList<>(); + for (int i = 0; i < MAX_ITERATIONS; i++) { + Integer id = i; + FailureCause cause = new FailureCause(id.toString(), + "myFailureCause" + id.toString(), "description", "comment", new Date(), + "category", indications, null); + expectedCauses.add(cause); + } + + mockScanResult(expectedCauses); + DynamoDBScanExpression scanExpression = PowerMockito.spy(new DynamoDBScanExpression()); + whenNew(DynamoDBScanExpression.class).withAnyArguments().thenReturn(scanExpression); + + Collection fetchedCauses = kb.getCauseNames(); + + // Ensure the scan filtering is actually called + Mockito.verify(scanExpression).addExpressionAttributeNamesEntry("#n", "name"); + Mockito.verify(scanExpression).setProjectionExpression("id,#n"); + Mockito.verify(scanExpression).setScanFilter(DynamoDBKnowledgeBase.NOT_REMOVED_FILTER_EXPRESSION); + + assertNotNull("The fetched cause should not be null", fetchedCauses); + // Convert fetchedCauses to list, because PaginatedList does not allow iterators + List actualCauses = new ArrayList<>(fetchedCauses); + assertTrue(expectedCauses.equals(actualCauses)); + } + + /** + * Tests adding one cause. + * + * @throws Exception if so. + */ + @Test + public void testAddCause() throws Exception { + // This is not a very effective test, since addCause is just a passthrough to saveCause + FailureCause noIdCause = createFailureCause(null); + FailureCause idCause = createFailureCause("foo"); + doReturn(idCause).when(kb).saveCause(noIdCause); + FailureCause addedCause = kb.addCause(noIdCause); + assertNotNull(addedCause); + assertNotSame(noIdCause, addedCause); + } + + /** + * Tests saving one cause. + * + * @throws Exception if so. + */ + @Test + public void testSaveCause() throws Exception { + FailureCause cause = createFailureCause("foo"); + doNothing().when(dbMapper).save(cause); + FailureCause savedCause = kb.saveCause(cause); + assertNotNull(savedCause); + assertSame(cause, savedCause); + } + + /** + * Tests getting a the shallow form of causes. + * @throws Exception if so + */ + @Test + public void testGetShallowCauses() throws Exception { + Collection expectedCauses = new ArrayList<>(); + for (int i = 0; i < MAX_ITERATIONS; i++) { + Integer id = i; + FailureCause cause = createFailureCause(id.toString()); + expectedCauses.add(cause); + } + + mockScanResult(expectedCauses); + DynamoDBScanExpression scanExpression = PowerMockito.spy(new DynamoDBScanExpression()); + whenNew(DynamoDBScanExpression.class).withAnyArguments().thenReturn(scanExpression); + + Collection fetchedCauses = kb.getShallowCauses(); + Mockito.verify(scanExpression).addExpressionAttributeNamesEntry("#n", "name"); + Mockito.verify(scanExpression).addExpressionAttributeNamesEntry("#c", "comment"); + Mockito.verify(scanExpression).addExpressionAttributeNamesEntry("#r", "_removed"); + Mockito.verify(scanExpression) + .setProjectionExpression("id,#n,description,categories,#c,modifications,lastOccurred"); + Mockito.verify(scanExpression).setFilterExpression(" attribute_not_exists(#r) "); + + assertNotNull("The fetched cause should not be null", fetchedCauses); + // Convert fetchedCauses to list, because PaginatedList does not allow iterators + List actualCauses = new ArrayList<>(fetchedCauses); + assertEquals(expectedCauses, actualCauses); + } + + /** + * Test that the cause gets updated with removed info. + * @throws Exception if so. + */ + @Test + public void testRemoveCause() throws Exception { + String id = "test"; + FailureCause cause = createFailureCause(id); + + doReturn(cause).when(dbMapper).load(FailureCause.class, id); + doNothing().when(dbMapper).save(cause); + FailureCause actualCause = kb.removeCause(id); + assertNotNull(actualCause); + assertSame(cause, actualCause); + assertNotNull(actualCause.getRemoved()); + assertEquals(mockJenkinsUserName, actualCause.getRemoved().get("by")); + } + + /** + * Tests getting {@link FailureCause} categories. + * @throws Exception if so + */ + @Test + public void testGetCategories() throws Exception { + List expectedCategories = new ArrayList<>(); + expectedCategories.add("category0"); + expectedCategories.add("category1"); + expectedCategories.add("category2"); + + Collection causes = new ArrayList<>(); + for (int i = 0; i < MAX_ITERATIONS; i++) { + Integer id = i; + FailureCause cause = new FailureCause(id.toString(), + "myFailureCause" + id.toString(), "description", "comment", new Date(), + "category" + id.toString(), indications, null); + causes.add(cause); + } + + mockScanResult(causes); + DynamoDBScanExpression scanExpression = PowerMockito.spy(new DynamoDBScanExpression()); + whenNew(DynamoDBScanExpression.class).withAnyArguments().thenReturn(scanExpression); + + List actualCategories = kb.getCategories(); + Mockito.verify(scanExpression).setProjectionExpression("categories"); + Mockito.verify(scanExpression).setFilterExpression(" attribute_exists(categories) "); + assertNotNull(actualCategories); + + // Values should be the same, but might be in different order + assertTrue(CollectionUtils.isEqualCollection(expectedCategories, actualCategories)); + } + + /** + * Tests converting {@link FailureCause} from a different {@link KnowledgeBase} type. + * @throws Exception if so + */ + @Test + public void testConvertFrom() throws Exception { + LocalFileKnowledgeBase localKb = spy(new LocalFileKnowledgeBase()); + Collection causes = new ArrayList<>(); + for (int i = 0; i < MAX_ITERATIONS; i++) { + Integer id = i; + FailureCause cause = createFailureCause(id.toString()); + causes.add(cause); + } + + doReturn(causes).when(localKb).getCauseNames(); + doReturn(createFailureCause("foo")).when(kb).saveCause(Matchers.any(FailureCause.class)); + kb.convertFrom(localKb); + Mockito.verify(kb, Mockito.times(MAX_ITERATIONS)).saveCause(Matchers.any(FailureCause.class)); + } +} diff --git a/src/test/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseTest.java b/src/test/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseTest.java index eeafb5bb..9a8c5cf6 100644 --- a/src/test/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseTest.java +++ b/src/test/java/com/sonyericsson/jenkins/plugins/bfa/model/FailureCauseTest.java @@ -52,8 +52,9 @@ import java.util.List; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertSame; import static org.junit.matchers.JUnitMatchers.hasItems; import static org.mockito.Matchers.any; import static org.mockito.Matchers.same; @@ -461,6 +462,42 @@ public void testDoConfigSubmitNewCloneAttempt() throws Exception { cause.doConfigSubmit(request, response); } + /** + * Helper to return instance of {@link FailureCause}. + * @param name name of the cause + * @param date instance of {@link Date} + * @param i list of {@link Indication} + * @return {@link FailureCause} + */ + public FailureCause getCauseForEquality(String name, Date date, List i) { + return new FailureCause(name, "myFailureCause", "description", "comment", date, + "category", i, null); + } + + /** + * Test two {@link FailureCause} instances are equal. + */ + @Test + public void testEquals() { + Date d = new Date(); + Indication i = new BuildLogIndication("something"); + FailureCause cause1 = getCauseForEquality("foo", d, Collections.singletonList(i)); + FailureCause cause2 = getCauseForEquality("foo", d, Collections.singletonList(i)); + assertEquals(cause1, cause2); + } + + /** + * Test two {@link FailureCause} instances are not equal. + */ + @Test + public void testNotEquals(){ + Date d = new Date(); + Indication i = new BuildLogIndication("something"); + FailureCause cause1 = getCauseForEquality("foo1", d, Collections.singletonList(i)); + FailureCause cause2 = getCauseForEquality("foo2", d, Collections.singletonList(i)); + assertNotEquals(cause1, cause2); + } + /** * Mocks a request to contain the form data for a {@code FailureCause} with the provided content. * diff --git a/src/test/java/com/sonyericsson/jenkins/plugins/bfa/model/indication/IndicationTest.java b/src/test/java/com/sonyericsson/jenkins/plugins/bfa/model/indication/IndicationTest.java new file mode 100644 index 00000000..4fe45921 --- /dev/null +++ b/src/test/java/com/sonyericsson/jenkins/plugins/bfa/model/indication/IndicationTest.java @@ -0,0 +1,34 @@ +package com.sonyericsson.jenkins.plugins.bfa.model.indication; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +/** + * Tests for {@link Indication}. + * + * @author Ken Petti <kpetti@constantcontact.com> + */ +public class IndicationTest { + + /** + * Test two {@link Indication} instances are equal. + */ + @Test + public void testEquals() { + BuildLogIndication i1 = new BuildLogIndication("regex"); + BuildLogIndication i2 = new BuildLogIndication("regex"); + assertEquals(i1, i2); + } + + /** + * Test two {@link Indication} instances are not equal. + */ + @Test + public void testNotEquals() { + BuildLogIndication i1 = new BuildLogIndication("foo"); + BuildLogIndication i2 = new BuildLogIndication("bar"); + assertNotEquals(i1, i2); + } +}