package com.indeed.proctor.webapp.controllers;
import com.fasterxml.jackson.core.JsonGenerationException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.indeed.proctor.common.EnvironmentVersion;
import com.indeed.proctor.common.IncompatibleTestMatrixException;
import com.indeed.proctor.common.ProctorLoadResult;
import com.indeed.proctor.common.ProctorPromoter;
import com.indeed.proctor.common.ProctorSpecification;
import com.indeed.proctor.common.ProctorUtils;
import com.indeed.proctor.common.TestSpecification;
import com.indeed.proctor.common.model.Allocation;
import com.indeed.proctor.common.model.ConsumableTestDefinition;
import com.indeed.proctor.common.model.Payload;
import com.indeed.proctor.common.model.Range;
import com.indeed.proctor.common.model.TestBucket;
import com.indeed.proctor.common.model.TestDefinition;
import com.indeed.proctor.common.model.TestMatrixArtifact;
import com.indeed.proctor.common.model.TestMatrixDefinition;
import com.indeed.proctor.common.model.TestMatrixVersion;
import com.indeed.proctor.common.model.TestType;
import com.indeed.proctor.store.ProctorStore;
import com.indeed.proctor.store.Revision;
import com.indeed.proctor.store.StoreException;
import com.indeed.proctor.webapp.ProctorSpecificationSource;
import com.indeed.proctor.webapp.controllers.BackgroundJob.ResultUrl;
import com.indeed.proctor.webapp.db.Environment;
import com.indeed.proctor.webapp.extensions.DefinitionChangeLog;
import com.indeed.proctor.webapp.extensions.PostDefinitionCreateChange;
import com.indeed.proctor.webapp.extensions.PostDefinitionDeleteChange;
import com.indeed.proctor.webapp.extensions.PostDefinitionEditChange;
import com.indeed.proctor.webapp.extensions.PostDefinitionPromoteChange;
import com.indeed.proctor.webapp.extensions.PreDefinitionCreateChange;
import com.indeed.proctor.webapp.extensions.PreDefinitionDeleteChange;
import com.indeed.proctor.webapp.extensions.PreDefinitionEditChange;
import com.indeed.proctor.webapp.extensions.PreDefinitionPromoteChange;
import com.indeed.proctor.webapp.extensions.RevisionCommitCommentFormatter;
import com.indeed.proctor.webapp.model.AppVersion;
import com.indeed.proctor.webapp.model.ProctorClientApplication;
import com.indeed.proctor.webapp.model.RevisionDefinition;
import com.indeed.proctor.webapp.model.SessionViewModel;
import com.indeed.proctor.webapp.model.WebappConfiguration;
import com.indeed.proctor.webapp.tags.TestDefinitionFunctions;
import com.indeed.proctor.webapp.tags.UtilityFunctions;
import com.indeed.proctor.webapp.util.threads.LogOnUncaughtExceptionHandler;
import com.indeed.proctor.webapp.views.JsonView;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.RedirectView;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.StringWriter;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author parker
*/
@Controller
@RequestMapping({"/definition", "/proctor/definition"})
public class ProctorTestDefinitionController extends AbstractController {
private static final Logger LOGGER = Logger.getLogger(ProctorTestDefinitionController.class);
private static final Pattern ALPHA_NUMERIC_JAVA_IDENTIFIER_PATTERN = Pattern.compile("^[a-z_][a-z0-9_]*$", Pattern.CASE_INSENSITIVE);
private static final Pattern VALID_TEST_NAME_PATTERN = ALPHA_NUMERIC_JAVA_IDENTIFIER_PATTERN;
private static final Pattern VALID_BUCKET_NAME_PATTERN = ALPHA_NUMERIC_JAVA_IDENTIFIER_PATTERN;
private final ProctorPromoter promoter;
private final ProctorSpecificationSource specificationSource;
private final int verificationTimeout;
private final ExecutorService verifierExecutor;
private final BackgroundJobManager jobManager;
private final BackgroundJobFactory jobFactory;
/*
TODO: preDefinitionChanges and postDefinitionChanges should be included in the autowird constructor.
Four constructors would need to be made, which leads to type erasure problems.
*/
@Autowired(required=false)
private List<PreDefinitionEditChange> preDefinitionEditChanges = Collections.emptyList();
@Autowired(required=false)
private List<PostDefinitionEditChange> postDefinitionEditChanges = Collections.emptyList();
@Autowired(required=false)
private List<PreDefinitionCreateChange> preDefinitionCreateChanges = Collections.emptyList();
@Autowired(required=false)
private List<PostDefinitionCreateChange> postDefinitionCreateChanges = Collections.emptyList();
@Autowired(required=false)
private List<PreDefinitionDeleteChange> preDefinitionDeleteChanges = Collections.emptyList();
@Autowired(required=false)
private List<PostDefinitionDeleteChange> postDefinitionDeleteChanges = Collections.emptyList();
@Autowired(required=false)
private List<PreDefinitionPromoteChange> preDefinitionPromoteChanges = Collections.emptyList();
@Autowired(required=false)
private List<PostDefinitionPromoteChange> postDefinitionPromoteChanges = Collections.emptyList();
@Autowired(required=false)
private RevisionCommitCommentFormatter revisionCommitCommentFormatter;
private static enum Views {
DETAILS("definition/details"),
EDIT("definition/edit"),
CREATE("definition/edit");
private final String name;
private Views(final String name) {
this.name = name;
}
public String getName() {
return name;
}
}
@Autowired
public ProctorTestDefinitionController(final WebappConfiguration configuration,
@Qualifier("trunk") final ProctorStore trunkStore,
@Qualifier("qa") final ProctorStore qaStore,
@Qualifier("production") final ProctorStore productionStore,
final ProctorPromoter promoter,
final ProctorSpecificationSource specificationSource,
final BackgroundJobManager jobManager,
final BackgroundJobFactory jobFactory) {
super(configuration, trunkStore, qaStore, productionStore);
this.promoter = promoter;
this.jobManager = jobManager;
this.jobFactory = jobFactory;
this.verificationTimeout = configuration.getVerifyHttpTimeout();
this.specificationSource = specificationSource;
Preconditions.checkArgument(verificationTimeout > 0, "verificationTimeout > 0");
final ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat("proctor-verifiers-Thread-%d")
.setUncaughtExceptionHandler(new LogOnUncaughtExceptionHandler())
.build();
this.verifierExecutor = Executors.newFixedThreadPool(configuration.getVerifyExecutorThreads(), threadFactory);
}
@RequestMapping(value = "/create", method = RequestMethod.GET)
public String create(
final Model model
) {
final TestDefinition definition = new TestDefinition(
"" /* version */,
null /* rule */,
TestType.USER /* testType */,
"" /* salt */,
Collections.<TestBucket>emptyList(),
Lists.<Allocation>newArrayList(
new Allocation(null, Collections.<Range>emptyList())
),
Collections.<String, Object>emptyMap(),
Collections.<String, Object>emptyMap(),
"" /* description */
);
final List<RevisionDefinition> history = Collections.emptyList();
final EnvironmentVersion version = null;
return doView(Environment.WORKING, Views.CREATE, "", definition, history, version, model);
}
@RequestMapping(value = "/{testName}", method = RequestMethod.GET)
public String show(
HttpServletResponse response,
@PathVariable final String testName,
@RequestParam(required = false) final String branch,
@RequestParam(required = false, defaultValue = "", value = "r") final String revision,
@RequestParam(required = false, value = "alloc_hist") final String loadAllocHistParam,
@CookieValue(value="loadAllocationHistory", defaultValue = "") String loadAllocHistCookie,
final Model model
) throws StoreException {
final Environment theEnvironment = determineEnvironmentFromParameter(branch);
final ProctorStore store = determineStoreFromEnvironment(theEnvironment);
// Git performance suffers when there are many concurrent operations
// Only request full test history for one test at a time
final EnvironmentVersion version;
final List<RevisionDefinition> history;
final TestDefinition definition;
synchronized (this) {
version = promoter.getEnvironmentVersion(testName);
if (revision.length() > 0) {
definition = getTestDefinition(store, testName, revision);
} else {
definition = getTestDefinition(store, testName);
}
if (definition == null) {
LOGGER.info("Unknown test definition : " + testName + " revision " + revision);
// unknown testdefinition
return "404";
}
final boolean loadAllocHistory = shouldLoadAllocationHistory(loadAllocHistParam, loadAllocHistCookie, response);
history = makeRevisionDefinitionList(store, testName, version.getRevision(theEnvironment), loadAllocHistory);
}
return doView(theEnvironment, Views.DETAILS, testName, definition, history, version, model);
}
private List<RevisionDefinition> makeRevisionDefinitionList(final ProctorStore store,
final String testName,
final String startRevision,
final boolean useDefinitions) {
final List<Revision> history = getTestHistory(store, testName, startRevision);
final List<RevisionDefinition> revisionDefinitions = new ArrayList<RevisionDefinition>();
if(useDefinitions) {
for (Revision revision : history) {
final String revisionName = revision.getRevision();
final TestDefinition definition = getTestDefinition(store, testName, revisionName);
revisionDefinitions.add(new RevisionDefinition(revision, definition));
}
}
else {
for (Revision revision : history) {
revisionDefinitions.add(new RevisionDefinition(revision, null));
}
}
return revisionDefinitions;
}
private boolean shouldLoadAllocationHistory(String loadAllocHistParam, String loadAllocHistCookie, HttpServletResponse response) {
if (loadAllocHistParam != null) {
if (loadAllocHistParam.equals("true") || loadAllocHistParam.equals("1")) {
final Cookie lahCookie = new Cookie("loadAllocationHistory", "true");
final int thirtyMinutes = 60 * 30;
lahCookie.setMaxAge(thirtyMinutes);
lahCookie.setPath("/");
response.addCookie(lahCookie);
return true;
} else {
final Cookie deletionCookie = new Cookie("loadAllocationHistory", "");
deletionCookie.setMaxAge(0);
deletionCookie.setPath("/");
response.addCookie(deletionCookie);
return false;
}
} else if (loadAllocHistCookie.equals("true") || loadAllocHistCookie.equals("false")) {
return Boolean.parseBoolean(loadAllocHistCookie);
} else {
return false;
}
}
@RequestMapping(value = "/{testName}/edit", method = RequestMethod.GET)
public String doEditGet(
@PathVariable String testName,
final Model model
) throws StoreException {
final Environment theEnvironment = Environment.WORKING; // only allow editing of TRUNK!
final ProctorStore store = determineStoreFromEnvironment(theEnvironment);
final EnvironmentVersion version = promoter.getEnvironmentVersion(testName);
final TestDefinition definition = getTestDefinition(store, testName);
if (definition == null) {
LOGGER.info("Unknown test definition : " + testName);
// unknown testdefinition
return "404";
}
return doView(theEnvironment, Views.EDIT, testName, definition, Collections.<RevisionDefinition>emptyList(), version, model);
}
@RequestMapping(value = "/{testName}/delete", method = RequestMethod.POST)
public View doDeletePost(
@PathVariable final String testName,
@RequestParam(required = false) String src,
@RequestParam(required = false) final String srcRevision,
@RequestParam(required = false) final String username,
@RequestParam(required = false) final String password,
@RequestParam(required = false, defaultValue = "") final String comment,
final HttpServletRequest request,
final Model model
) {
final Environment theEnvironment = determineEnvironmentFromParameter(src);
Map<String, String[]> requestParameterMap = new HashMap<String, String[]>();
requestParameterMap.putAll(request.getParameterMap());
final String nonEmptyComment = formatDefaultDeleteComment(testName, comment);
final BackgroundJob<Boolean> job = createDeleteBackgroundJob(testName,
theEnvironment,
srcRevision,
username,
password,
nonEmptyComment,
requestParameterMap);
jobManager.submit(job);
if (isAJAXRequest(request)) {
final JsonResponse<Map> response = new JsonResponse<Map>(BackgroundJobRpcController.buildJobJson(job), true, job.getTitle());
return new JsonView(response);
} else {
// redirect to a status page for the job id
return new RedirectView("/proctor/rpc/jobs/list?id=" + job.getId());
}
}
private BackgroundJob<Boolean> createDeleteBackgroundJob(
final String testName,
final Environment source,
final String srcRevision,
final String username,
final String password,
final String comment,
final Map<String, String[]> requestParameterMap
) {
LOGGER.info(String.format("Deleting test %s branch: %s user: %s ", testName, source, username));
return jobFactory.createBackgroundJob(
String.format("(%s) deleting %s branch: %s ", username, testName, source),
BackgroundJob.JobType.TEST_DELETION,
new BackgroundJobFactory.Executor<Boolean>() {
@Override
public Boolean execute(final BackgroundJob job) {
final ProctorStore store = determineStoreFromEnvironment(source);
final TestDefinition definition = getTestDefinition(store, testName);
if (definition == null) {
job.log("Unknown test definition : " + testName);
return false;
}
try {
validateUsernamePassword(username, password);
final Revision prevVersion;
job.log("(scm) getting history for '" + testName + "'");
final List<Revision> history = getTestHistory(store, testName, 1);
if (history.size() > 0) {
prevVersion = history.get(0);
if (!prevVersion.getRevision().equals(srcRevision)) {
throw new IllegalArgumentException("Test has been updated since " + srcRevision + " currently at " + prevVersion.getRevision());
}
} else {
throw new IllegalArgumentException("Could not get any history for " + testName);
}
final String fullComment = formatFullComment(comment, requestParameterMap);
if (source.equals(Environment.WORKING) || source.equals(Environment.QA)) {
final CheckMatrixResult checkMatrixResultInQa = checkMatrix(Environment.QA, testName, null);
if (!checkMatrixResultInQa.isValid) {
throw new IllegalArgumentException("There are still clients in QA using " + testName + " " + checkMatrixResultInQa.getErrors().get(0));
}
final CheckMatrixResult checkMatrixResultInProd = checkMatrix(Environment.PRODUCTION, testName, null);
if (!checkMatrixResultInProd.isValid) {
throw new IllegalArgumentException("There are still clients in prod using " + testName + " " + checkMatrixResultInProd.getErrors().get(0));
}
} else {
final CheckMatrixResult checkMatrixResult = checkMatrix(source, testName, null);
if (!checkMatrixResult.isValid()) {
throw new IllegalArgumentException("There are still clients in prod using " + testName + " " + checkMatrixResult.getErrors().get(0));
}
}
//PreDefinitionDeleteChanges
job.log("Executing pre delete extension tasks.");
for (final PreDefinitionDeleteChange preDefinitionDeleteChange: preDefinitionDeleteChanges) {
final DefinitionChangeLog definitionChangeLog = preDefinitionDeleteChange.preDelete(definition, requestParameterMap);
logDefinitionChangeLog(definitionChangeLog, preDefinitionDeleteChange.getClass().getSimpleName(), job);
}
job.log("(svn) delete " + testName);
store.deleteTestDefinition(username, password, srcRevision, testName, definition, fullComment);
boolean testExistsInOtherEnvironments = false;
for (final Environment otherEnvironment : Environment.values()) {
if (otherEnvironment != source) {
final ProctorStore otherStore = determineStoreFromEnvironment(otherEnvironment);
final TestDefinition otherDefinition = getTestDefinition(otherStore, testName);
if (otherDefinition != null) {
testExistsInOtherEnvironments = true;
job.addUrl("/proctor/definition/" + UtilityFunctions.urlEncode(testName) + "?branch=" + otherEnvironment.getName(), "view " + testName + " on " + otherEnvironment.getName());
}
}
}
if (!testExistsInOtherEnvironments) {
job.setEndMessage("This test no longer exists in any environment.");
}
//PostDefinitionDeleteChanges
job.log("Executing post delete extension tasks.");
for (final PostDefinitionDeleteChange postDefinitionDeleteChange: postDefinitionDeleteChanges) {
final DefinitionChangeLog definitionChangeLog = postDefinitionDeleteChange.postDelete(requestParameterMap);
logDefinitionChangeLog(definitionChangeLog, postDefinitionDeleteChange.getClass().getSimpleName(), job);
}
} catch (StoreException.TestUpdateException exp) {
job.logFailedJob(exp);
LOGGER.error("Deletion Failed: " + job.getTitle(), exp);
} catch (IllegalArgumentException exp) {
job.logFailedJob(exp);
LOGGER.info("Deletion Failed: " + job.getTitle(), exp);
} catch (Exception e) {
job.logFailedJob(e);
LOGGER.error("Deletion Failed: " + job.getTitle(), e);
}
return null;
}
}
);
}
@RequestMapping(value = "/{testName}/promote", method = RequestMethod.POST)
public View doPromotePost(
@PathVariable final String testName,
@RequestParam(required = false) final String username,
@RequestParam(required = false) final String password,
@RequestParam(required = false) final String src,
@RequestParam(required = false) final String srcRevision,
@RequestParam(required = false) final String dest,
@RequestParam(required = false) final String destRevision,
final HttpServletRequest request,
final Model model
) {
final Environment source = determineEnvironmentFromParameter(src);
final Environment destination = determineEnvironmentFromParameter(dest);
final Map<String, String[]> requestParameterMap = new HashMap<String, String[]>();
requestParameterMap.putAll(request.getParameterMap());
final BackgroundJob job = doPromoteInternal(testName, username, password, source, srcRevision, destination, destRevision, requestParameterMap);
jobManager.submit(job);
if (isAJAXRequest(request)) {
final JsonResponse<Map> response = new JsonResponse<Map>(BackgroundJobRpcController.buildJobJson(job), true, job.getTitle());
return new JsonView(response);
} else {
return new RedirectView("/proctor/definition/" + UtilityFunctions.urlEncode(testName) + "?branch=" + destination.getName());
}
}
private BackgroundJob doPromoteInternal(final String testName,
final String username,
final String password,
final Environment source,
final String srcRevision,
final Environment destination,
final String destRevision,
final Map<String, String[]> requestParameterMap
) {
return jobFactory.createBackgroundJob(
String.format("(%s) promoting %s %s %1.7s to %s", username, testName, source, srcRevision, destination),
BackgroundJob.JobType.TEST_PROMOTION,
new BackgroundJobFactory.Executor() {
@Override
public Object execute(final BackgroundJob job) {
/*
Valid permutations:
TRUNK -> QA
TRUNK -> PRODUCTION
QA -> PRODUCTION
*/
try {
doJobIndependentPromoteInternal(testName, username, password, source, srcRevision, destination, destRevision, requestParameterMap, job, false);
} catch (ProctorPromoter.TestPromotionException exp) {
job.logFailedJob(exp);
LOGGER.error("Promotion Failed: " + job.getTitle(), exp);
} catch (StoreException.TestUpdateException exp) {
job.logFailedJob(exp);
LOGGER.error("Promotion Failed: " + job.getTitle(), exp);
} catch (IllegalArgumentException exp) {
job.logFailedJob(exp);
LOGGER.info("Promotion Failed: " + job.getTitle(), exp);
} catch (Exception exp) {
job.logFailedJob(exp);
LOGGER.error("Promotion Failed: " + job.getTitle(), exp);
}
return null;
}
}
);
}
private boolean doJobIndependentPromoteInternal(final String testName,
final String username,
final String password,
final Environment source,
final String srcRevision,
final Environment destination,
final String destRevision,
final Map<String, String[]> requestParameterMap,
final BackgroundJob job,
final boolean isAutopromote) throws Exception {
final Map<String, String> metadata = Collections.emptyMap();
validateUsernamePassword(username, password);
// TODO (parker) 9/5/12 - Verify that promoting to the destination branch won't cause issues
final TestDefinition testDefintion = getTestDefinition(source, testName, srcRevision);
// if(d == null) {
// return "could not find " + testName + " on " + source + " with revision " + srcRevision;
// }
final CheckMatrixResult result = checkMatrix(destination, testName, testDefintion);
if (!result.isValid()) {
throw new IllegalArgumentException(String.format("Test Promotion not compatible, errors: %s", Joiner.on("\n").join(result.getErrors())));
} else {
final Map<Environment, PromoteAction> actions = PROMOTE_ACTIONS.get(source);
if (actions == null || !actions.containsKey(destination)) {
throw new IllegalArgumentException("Invalid combination of source and destination: source=" + source + " dest=" + destination);
}
final PromoteAction action = actions.get(destination);
//PreDefinitionPromoteChanges
job.log("Executing pre promote extension tasks.");
for (final PreDefinitionPromoteChange preDefinitionPromoteChange: preDefinitionPromoteChanges) {
final DefinitionChangeLog definitionChangeLog = preDefinitionPromoteChange.prePromote(testDefintion, requestParameterMap, source, destination, isAutopromote);
logDefinitionChangeLog(definitionChangeLog, preDefinitionPromoteChange.getClass().getSimpleName(), job);
}
//Promote Change
final boolean success = action.promoteTest(job, testName, srcRevision, destRevision, username, password, metadata);
//PostDefinitionPromoteChanges
job.log("Executing post promote extension tasks.");
for (final PostDefinitionPromoteChange postDefinitionPromoteChange: postDefinitionPromoteChanges) {
final DefinitionChangeLog definitionChangeLog = postDefinitionPromoteChange.postPromote(requestParameterMap, source, destination, isAutopromote);
logDefinitionChangeLog(definitionChangeLog, postDefinitionPromoteChange.getClass().getSimpleName(), job);
}
job.log(String.format("Promoted %s from %s (%1.7s) to %s (%1.7s)", testName, source.getName(), srcRevision, destination.getName(), destRevision));
job.addUrl("/proctor/definition/" + UtilityFunctions.urlEncode(testName) + "?branch=" + destination.getName(), "view " + testName + " on " + destination.getName());
return success;
}
}
private void logDefinitionChangeLog(DefinitionChangeLog definitionChangeLog, String changeName, BackgroundJob backgroundJob) {
if (definitionChangeLog != null) {
final List<ResultUrl> urls = definitionChangeLog.getUrls();
if (urls != null) {
for (final ResultUrl url : urls) {
backgroundJob.addUrl(url);
}
}
final List<String> changeLog = definitionChangeLog.getLog();
if (changeLog != null) {
for (final String logMessage : changeLog) {
backgroundJob.log(logMessage);
}
}
if (definitionChangeLog.isErrorsFound()) {
throw new IllegalArgumentException(changeName + " failed with the following errors: " + definitionChangeLog.getErrors());
}
}
}
private static interface PromoteAction {
Environment getSource();
Environment getDestination();
boolean promoteTest(BackgroundJob job,
final String testName,
final String srcRevision,
final String destRevision,
final String username,
final String password,
final Map<String, String> metadata) throws IllegalArgumentException, ProctorPromoter.TestPromotionException, StoreException.TestUpdateException;
}
private abstract class PromoteActionBase implements PromoteAction {
final Environment src;
final Environment destination;
protected PromoteActionBase(final Environment src,
final Environment destination) {
this.destination = destination;
this.src = src;
}
@Override
public boolean promoteTest(final BackgroundJob job,
final String testName,
final String srcRevision,
final String destRevision,
final String username,
final String password,
final Map<String, String> metadata) throws IllegalArgumentException, ProctorPromoter.TestPromotionException, StoreException.TestUpdateException, StoreException.TestUpdateException {
try {
doPromotion(job, testName, srcRevision, destRevision, username, password, metadata);
return true;
} catch (Exception t) {
Throwables.propagateIfInstanceOf(t, ProctorPromoter.TestPromotionException.class);
Throwables.propagateIfInstanceOf(t, StoreException.TestUpdateException.class);
throw Throwables.propagate(t);
}
}
@Override
public final Environment getSource() {
return src;
}
@Override
public final Environment getDestination() {
return destination;
}
abstract void doPromotion(BackgroundJob job, String testName, String srcRevision, String destRevision,
String username, String password, Map<String, String> metadata)
throws ProctorPromoter.TestPromotionException, StoreException;
}
private final PromoteAction TRUNK_TO_QA = new PromoteActionBase(Environment.WORKING,
Environment.QA) {
@Override
void doPromotion(final BackgroundJob job,
final String testName,
final String srcRevision,
final String destRevision,
final String username,
final String password,
final Map<String, String> metadata)
throws ProctorPromoter.TestPromotionException, StoreException {
job.log(String.format("(scm) promote %s %1.7s (trunk to qa)", testName, srcRevision));
promoter.promoteTrunkToQa(testName, srcRevision, destRevision, username, password, metadata);
}
};
private final PromoteAction TRUNK_TO_PRODUCTION = new PromoteActionBase(Environment.WORKING,
Environment.PRODUCTION) {
@Override
void doPromotion(final BackgroundJob job,
final String testName,
final String srcRevision,
final String destRevision,
final String username,
final String password,
final Map<String, String> metadata)
throws ProctorPromoter.TestPromotionException, StoreException {
job.log(String.format("(scm) promote %s %1.7s (trunk to production)", testName, srcRevision));
promoter.promoteTrunkToProduction(testName, srcRevision, destRevision, username, password, metadata);
}
};
private final PromoteAction QA_TO_PRODUCTION = new PromoteActionBase(Environment.QA,
Environment.PRODUCTION) {
@Override
void doPromotion(final BackgroundJob job,
final String testName,
final String srcRevision,
final String destRevision,
final String username,
final String password,
final Map<String, String> metadata) throws ProctorPromoter.TestPromotionException, StoreException {
job.log(String.format("(scm) promote %s %1.7s (qa to production)", testName, srcRevision));
promoter.promoteQaToProduction(testName, srcRevision, destRevision, username, password, metadata);
}
};
private final Map<Environment, Map<Environment, PromoteAction>> PROMOTE_ACTIONS = ImmutableMap.<Environment, Map<Environment, PromoteAction>>builder()
.put(Environment.WORKING, ImmutableMap.of(Environment.QA, TRUNK_TO_QA, Environment.PRODUCTION, TRUNK_TO_PRODUCTION))
.put(Environment.QA, ImmutableMap.of(Environment.PRODUCTION, QA_TO_PRODUCTION)).build();
@RequestMapping(value = "/{testName}/edit", method = RequestMethod.POST)
public View doEditPost(
@PathVariable final String testName,
@RequestParam(required = false) final String username,
@RequestParam(required = false) final String password,
@RequestParam(required = false, defaultValue = "false") final boolean isCreate,
@RequestParam(required = false, defaultValue = "") final String comment,
@RequestParam(required = false) final String testDefinition, // testDefinition is JSON representation of test-definition
@RequestParam(required = false, defaultValue = "") final String previousRevision,
@RequestParam(required = false, defaultValue = "false") final boolean isAutopromote,
final HttpServletRequest request,
final Model model) {
//TODO: Remove all internal params and just pass request.getParameterMap() to doEditPost() as map of fields and values
Map<String, String[]> requestParameterMap = new HashMap<String, String[]>();
requestParameterMap.putAll(request.getParameterMap());
final String nonEmptyComment;
if (isCreate) {
nonEmptyComment = formatDefaultCreateComment(testName, comment);
} else {
nonEmptyComment = formatDefaultUpdateComment(testName, comment);
}
final BackgroundJob job = doEditPost(testName,
username,
password,
isCreate,
nonEmptyComment,
testDefinition,
previousRevision,
isAutopromote,
requestParameterMap);
jobManager.submit(job);
if (isAJAXRequest(request)) {
final JsonResponse<Map> response = new JsonResponse<Map>(BackgroundJobRpcController.buildJobJson(job), true, job.getTitle());
return new JsonView(response);
} else {
// redirect to a status page for the job id
return new RedirectView("/proctor/rpc/jobs/list?id=" + job.getId());
}
}
private BackgroundJob<Boolean> doEditPost(
final String testName,
final String username,
final String password,
final boolean isCreate,
final String comment,
final String testDefinitionJson,
final String previousRevision,
final boolean isAutopromote,
final Map<String, String[]> requestParameterMap) {
return jobFactory.createBackgroundJob(
String.format("(%s) %s %s", username, (isCreate ? "Creating" : "Editing"), testName),
isCreate ? BackgroundJob.JobType.TEST_CREATION : BackgroundJob.JobType.TEST_EDIT,
new BackgroundJobFactory.Executor<Boolean>() {
@Override
public Boolean execute(final BackgroundJob job) {
final Environment theEnvironment = Environment.WORKING; // only allow editing of TRUNK!
final ProctorStore store = determineStoreFromEnvironment(theEnvironment);
final EnvironmentVersion environmentVersion = promoter.getEnvironmentVersion(testName);
final String qaRevision = environmentVersion == null ? EnvironmentVersion.UNKNOWN_REVISION : environmentVersion.getQaRevision();
final String prodRevision = environmentVersion == null ? EnvironmentVersion.UNKNOWN_REVISION : environmentVersion.getProductionRevision();
try {
if (CharMatcher.WHITESPACE.matchesAllOf(Strings.nullToEmpty(testDefinitionJson))) {
throw new IllegalArgumentException("No new test definition given");
}
validateUsernamePassword(username, password);
validateComment(comment);
final Revision prevVersion;
if (previousRevision.length() > 0) {
job.log("(scm) getting history for '" + testName + "'");
final List<Revision> history = getTestHistory(store, testName, 1);
if (history.size() > 0) {
prevVersion = history.get(0);
if (!prevVersion.getRevision().equals(previousRevision)) {
throw new IllegalArgumentException("Test has been updated since " + previousRevision + " currently at " + prevVersion.getRevision());
}
} else {
prevVersion = null;
}
} else {
// Create flow
prevVersion = null;
// check that the test name is valid
if (!isValidTestName(testName)) {
throw new IllegalArgumentException("Test Name must be alpha-numeric underscore and not start with a number, found: '" + testName + "'");
}
}
final TestDefinition testDefinitionToUpdate;
job.log("Parsing test definition json");
testDefinitionToUpdate = TestDefinitionFunctions.parseTestDefinition(testDefinitionJson);
// TODO: make these parameters
final boolean skipVerification = true;
// TODO: make these parameters
final boolean allowInstanceFailure = true;
final ProctorStore trunkStore = determineStoreFromEnvironment(Environment.WORKING);
job.log("(scm) loading existing test definition for '" + testName + "'");
// Getting the TestDefinition via currentTestMatrix instead of trunkStore.getTestDefinition because the test
final TestDefinition existingTestDefinition = trunkStore.getCurrentTestMatrix().getTestMatrixDefinition().getTests().get(testName);
if (previousRevision.length() <= 0 && existingTestDefinition != null) {
throw new IllegalArgumentException("Current tests exists with name : '" + testName + "'");
}
if (testDefinitionToUpdate.getTestType() == null && existingTestDefinition != null) {
testDefinitionToUpdate.setTestType(existingTestDefinition.getTestType());
}
if (isCreate) {
testDefinitionToUpdate.setVersion("-1");
} else if (existingTestDefinition != null) {
testDefinitionToUpdate.setVersion(existingTestDefinition.getVersion());
}
job.log("verifying test definition and buckets");
validateBasicInformation(testDefinitionToUpdate, job);
final ConsumableTestDefinition consumableTestDefinition = ProctorUtils.convertToConsumableTestDefinition(testDefinitionToUpdate);
ProctorUtils.verifyInternallyConsistentDefinition(testName, "edit", consumableTestDefinition);
//PreDefinitionEdit
if (isCreate) {
job.log("Executing pre create extension tasks.");
for (final PreDefinitionCreateChange preDefinitionCreateChange : preDefinitionCreateChanges) {
final DefinitionChangeLog definitionChangeLog = preDefinitionCreateChange.preCreate(testDefinitionToUpdate, requestParameterMap);
logDefinitionChangeLog(definitionChangeLog, preDefinitionCreateChange.getClass().getSimpleName(), job);
}
} else {
job.log("Executing pre edit extension tasks.");
for (final PreDefinitionEditChange preDefinitionEditChange : preDefinitionEditChanges) {
final DefinitionChangeLog definitionChangeLog = preDefinitionEditChange.preEdit(existingTestDefinition, testDefinitionToUpdate, requestParameterMap);
logDefinitionChangeLog(definitionChangeLog, preDefinitionEditChange.getClass().getSimpleName(), job);
}
}
final String fullComment = formatFullComment(comment, requestParameterMap);
//Change definition
final Map<String, String> metadata = Collections.emptyMap();
if (existingTestDefinition == null) {
job.log("(scm) adding test definition");
trunkStore.addTestDefinition(username, password, testName, testDefinitionToUpdate, metadata, fullComment);
promoter.refreshWorkingVersion(testName);
} else {
job.log("(scm) updating test definition");
trunkStore.updateTestDefinition(username, password, previousRevision, testName, testDefinitionToUpdate, metadata, fullComment);
promoter.refreshWorkingVersion(testName);
}
//PostDefinitionEdit
if (isCreate) {
job.log("Executing post create extension tasks.");
for (final PostDefinitionCreateChange postDefinitionCreateChange : postDefinitionCreateChanges) {
final DefinitionChangeLog definitionChangeLog = postDefinitionCreateChange.postCreate(testDefinitionToUpdate, requestParameterMap);
logDefinitionChangeLog(definitionChangeLog, postDefinitionCreateChange.getClass().getSimpleName(), job);
}
} else {
job.log("Executing post edit extension tasks.");
for (final PostDefinitionEditChange postDefinitionEditChange : postDefinitionEditChanges) {
final DefinitionChangeLog definitionChangeLog = postDefinitionEditChange.postEdit(existingTestDefinition, testDefinitionToUpdate, requestParameterMap);
logDefinitionChangeLog(definitionChangeLog, postDefinitionEditChange.getClass().getSimpleName(), job);
}
}
//Autopromote if necessary
if (isAutopromote
&& existingTestDefinition != null
&& isAllocationOnlyChange(existingTestDefinition, testDefinitionToUpdate)) {
final boolean isQaPromoted;
job.log("allocation only change, checking against other branches for auto-promote capability for test " + testName + "\nat QA revision " + qaRevision + " and PRODUCTION revision " + prodRevision);
final boolean isQaPromotable = qaRevision != EnvironmentVersion.UNKNOWN_REVISION
&& isAllocationOnlyChange(getTestDefinition(Environment.QA, testName, qaRevision), testDefinitionToUpdate);
if (isQaPromotable) {
job.log("auto-promoting changes to QA");
isQaPromoted = doJobIndependentPromoteInternal(testName, username, password, Environment.WORKING, trunkStore.getLatestVersion(), Environment.QA, qaRevision, requestParameterMap, job, true);
} else {
isQaPromoted = false;
job.log("previous revision changes prevented auto-promote to QA");
}
if (isQaPromotable && isQaPromoted
&& prodRevision != EnvironmentVersion.UNKNOWN_REVISION
&& isAllocationOnlyChange(getTestDefinition(Environment.PRODUCTION, testName, prodRevision), testDefinitionToUpdate)) {
job.log("auto-promoting changes to PRODUCTION");
doJobIndependentPromoteInternal(testName, username, password, Environment.WORKING, trunkStore.getLatestVersion(), Environment.PRODUCTION, prodRevision, requestParameterMap, job, true);
} else {
job.log("previous revision changes prevented auto-promote to PRODUCTION");
}
}
job.log("COMPLETE");
job.addUrl("/proctor/definition/" + UtilityFunctions.urlEncode(testName) + "?branch=" + theEnvironment.getName(), "View Result");
return true;
} catch (final StoreException.TestUpdateException exp) {
job.logFailedJob(exp);
LOGGER.error("Edit Failed: " + job.getTitle(), exp);
} catch (IncompatibleTestMatrixException exp) {
job.logFailedJob(exp);
LOGGER.info("Edit Failed: " + job.getTitle(), exp);
} catch (IllegalArgumentException exp) {
job.logFailedJob(exp);
LOGGER.info("Edit Failed: " + job.getTitle(), exp);
} catch (Exception exp) {
job.logFailedJob(exp);
LOGGER.error("Edit Failed: " + job.getTitle(), exp);
}
return false;
}
}
);
}
public static boolean isValidTestName(String testName) {
final Matcher m = VALID_TEST_NAME_PATTERN.matcher(testName);
return m.matches();
}
public static boolean isValidBucketName(String bucketName) {
final Matcher m = VALID_BUCKET_NAME_PATTERN.matcher(bucketName);
return m.matches();
}
public static boolean isAllocationOnlyChange(final TestDefinition existingTestDefinition, final TestDefinition testDefinitionToUpdate) {
final List<Allocation> existingAllocations = existingTestDefinition.getAllocations();
final List<Allocation> allocationsToUpdate = testDefinitionToUpdate.getAllocations();
final boolean nullRule = existingTestDefinition.getRule() == null;
if (nullRule && testDefinitionToUpdate.getRule() != null) {
return false;
} else if (!nullRule && !existingTestDefinition.getRule().equals(testDefinitionToUpdate.getRule())) {
return false;
}
if (!existingTestDefinition.getConstants().equals(testDefinitionToUpdate.getConstants())
|| !existingTestDefinition.getSpecialConstants().equals(testDefinitionToUpdate.getSpecialConstants())
|| !existingTestDefinition.getTestType().equals(testDefinitionToUpdate.getTestType())
|| !existingTestDefinition.getSalt().equals(testDefinitionToUpdate.getSalt())
|| !existingTestDefinition.getBuckets().equals(testDefinitionToUpdate.getBuckets())
|| existingAllocations.size()!=allocationsToUpdate.size())
return false;
/*
* TestBucket .equals() override only checks name equality
* loop below compares each attribute of a TestBucket
*/
for (int i = 0; i<existingTestDefinition.getBuckets().size(); i++) {
final TestBucket bucketOne = existingTestDefinition.getBuckets().get(i);
final TestBucket bucketTwo = testDefinitionToUpdate.getBuckets().get(i);
if (bucketOne == null) {
if (bucketTwo != null) {
return false;
}
} else if (bucketTwo == null) {
return false;
} else {
if (bucketOne.getValue() != bucketTwo.getValue()) {
return false;
}
final Payload payloadOne = bucketOne.getPayload();
final Payload payloadTwo = bucketTwo.getPayload();
if (payloadOne == null) {
if (payloadTwo != null) {
return false;
}
} else if (!payloadOne.equals(payloadTwo)) {
return false;
}
if (bucketOne.getDescription() == null) {
if (bucketTwo.getDescription() != null) {
return false;
}
} else if (!bucketOne.getDescription().equals(bucketTwo.getDescription())) {
return false;
}
}
}
/*
* Comparing everything in an allocation except the lengths
*/
for (int i = 0; i<existingAllocations.size(); i++) {
final List<Range> existingAllocationRanges = existingAllocations.get(i).getRanges();
final List<Range> allocationToUpdateRanges = allocationsToUpdate.get(i).getRanges();
if (existingAllocations.get(i).getRule() == null && allocationsToUpdate.get(i).getRule() != null)
return false;
else if (existingAllocations.get(i).getRule() != null && !existingAllocations.get(i).getRule().equals(allocationsToUpdate.get(i).getRule()))
return false;
Map<Integer, Double> existingAllocRangeMap = generateAllocationRangeMap(existingAllocationRanges);
Map<Integer, Double> allocToUpdateRangeMap = generateAllocationRangeMap(allocationToUpdateRanges);
if (!existingAllocRangeMap.keySet().equals(allocToUpdateRangeMap.keySet())) {
//An allocation was removed or added, do not autopromote
return false;
} else {
for (Map.Entry<Integer, Double> entry : existingAllocRangeMap.entrySet()) {
final int bucketVal = entry.getKey();
final double existingLength = entry.getValue();
final double allocToUpdateLength = allocToUpdateRangeMap.get(bucketVal);
if (existingLength == 0 && allocToUpdateLength != 0) {
return false;
}
}
}
}
return true;
}
private static Map<Integer, Double> generateAllocationRangeMap(List<Range> ranges) {
Map<Integer, Double> bucketToTotalAllocationMap = new HashMap<Integer, Double>();
for (int aIndex = 0; aIndex < ranges.size(); aIndex++) {
final int bucketVal = ranges.get(aIndex).getBucketValue();
double sum = 0;
if (bucketToTotalAllocationMap.containsKey(bucketVal)) {
sum+=bucketToTotalAllocationMap.get(bucketVal);
}
sum+=ranges.get(aIndex).getLength();
bucketToTotalAllocationMap.put(bucketVal, sum);
}
return bucketToTotalAllocationMap;
}
private String formatDefaultDeleteComment(final String testName, final String comment) {
if (Strings.isNullOrEmpty(comment)) {
return String.format("Deleting A/B test %s", testName);
}
return comment;
}
private String formatDefaultUpdateComment(final String testName, final String comment) {
if (Strings.isNullOrEmpty(comment)) {
return String.format("Updating A/B test %s", testName);
}
return comment;
}
private String formatDefaultCreateComment(final String testName, final String comment) {
if (Strings.isNullOrEmpty(comment)) {
return String.format("Creating A/B test %s", testName);
}
return comment;
}
private String formatFullComment(final String comment, final Map<String,String[]> requestParameterMap) {
if (revisionCommitCommentFormatter != null) {
return revisionCommitCommentFormatter.formatComment(comment, requestParameterMap);
}
else return comment.trim();
}
@RequestMapping(value = "/{testName}/verify", method = RequestMethod.GET)
@ResponseBody
public String doVerifyGet
(
@PathVariable String testName,
@RequestParam(required = false) String src,
@RequestParam(required = false) String srcRevision,
@RequestParam(required = false) String dest,
final HttpServletRequest request,
final Model model
) {
final Environment srcBranch = determineEnvironmentFromParameter(src);
final Environment destBranch = determineEnvironmentFromParameter(dest);
if (srcBranch == destBranch) {
return "source == destination";
}
final TestDefinition d = getTestDefinition(srcBranch, testName, srcRevision);
if (d == null) {
return "could not find " + testName + " on " + srcBranch + " with revision " + srcRevision;
}
final CheckMatrixResult result = checkMatrix(destBranch, testName, d);
if (result.isValid()) {
return "check success";
} else {
return "failed: " + Joiner.on("\n").join(result.getErrors());
}
}
@RequestMapping(value= "/{testName}/specification")
public View doSpecificationGet(
@PathVariable String testName,
@RequestParam(required = false) final String branch
) {
final Environment theEnvironment = determineEnvironmentFromParameter(branch);
final ProctorStore store = determineStoreFromEnvironment(theEnvironment);
final TestDefinition definition = getTestDefinition(store, testName);
if (definition == null) {
LOGGER.info("Unknown test definition : " + testName);
// unknown testdefinition
throw new NullPointerException("Unknown test definition");
}
JsonView view;
try {
final TestSpecification specification = ProctorUtils.generateSpecification(definition);
view = new JsonView(specification);
} catch (IllegalArgumentException e) {
LOGGER.error("Could not generate Test Specification", e);
view = new JsonView(new JsonResponse(e.getMessage(), false, "Could not generate Test Specification"));
}
return view;
}
private CheckMatrixResult checkMatrix(final Environment checkAgainst,
final String testName,
final TestDefinition potential) {
final TestMatrixVersion tmv = new TestMatrixVersion();
tmv.setAuthor("author");
tmv.setVersion("");
tmv.setDescription("fake matrix for validation of " + testName);
tmv.setPublished(new Date());
final TestMatrixDefinition tmd = new TestMatrixDefinition();
// The potential test definition will be null for test deletions
if (potential != null) {
tmd.setTests(ImmutableMap.<String, TestDefinition>of(testName, potential));
}
tmv.setTestMatrixDefinition(tmd);
final TestMatrixArtifact artifact = ProctorUtils.convertToConsumableArtifact(tmv);
// Verify
final Map<AppVersion, Future<ProctorLoadResult>> futures = Maps.newLinkedHashMap();
final Map<AppVersion, ProctorSpecification> toVerify = specificationSource.loadAllSuccessfulSpecifications(checkAgainst);
for (Map.Entry<AppVersion, ProctorSpecification> entry : toVerify.entrySet()) {
final AppVersion appVersion = entry.getKey();
final ProctorSpecification specification = entry.getValue();
futures.put(appVersion, verifierExecutor.submit(new Callable<ProctorLoadResult>() {
@Override
public ProctorLoadResult call() throws Exception {
LOGGER.info("Verifying artifact against : cached " + appVersion);
return verify(specification, artifact, testName, appVersion.toString());
}
}));
}
final ImmutableList.Builder<String> errorsBuilder = ImmutableList.builder();
while (!futures.isEmpty()) {
try {
Thread.sleep(10);
} catch (final InterruptedException e) {
LOGGER.error("Oh heavens", e);
}
for (final Iterator<Map.Entry<AppVersion, Future<ProctorLoadResult>>> iterator = futures.entrySet().iterator(); iterator.hasNext(); ) {
final Map.Entry<AppVersion, Future<ProctorLoadResult>> entry = iterator.next();
final AppVersion version = entry.getKey();
final Future<ProctorLoadResult> future = entry.getValue();
if (future.isDone()) {
iterator.remove();
try {
final ProctorLoadResult proctorLoadResult = future.get();
if (proctorLoadResult.hasInvalidTests()) {
errorsBuilder.add(getErrorMessage(version, proctorLoadResult));
}
} catch (final InterruptedException e) {
errorsBuilder.add(version.toString() + " failed. " + e.getMessage());
LOGGER.error("Interrupted getting " + version, e);
} catch (final ExecutionException e) {
final Throwable cause = e.getCause();
errorsBuilder.add(version.toString() + " failed. " + cause.getMessage());
LOGGER.error("Unable to verify " + version, cause);
}
}
}
}
final ImmutableList<String> errors = errorsBuilder.build();
final boolean greatSuccess = errors.isEmpty();
return new CheckMatrixResult(greatSuccess, errors);
}
private static String getErrorMessage(final AppVersion appVersion, final ProctorLoadResult proctorLoadResult) {
final Map<String, String> testsWithErrors = proctorLoadResult.getTestErrorMap();
final Set<String> missingTests = proctorLoadResult.getMissingTests();
// We expect at most one test to have a problem because we limited the verification to a single test
if (testsWithErrors.size() > 0) {
return testsWithErrors.values().iterator().next();
} else if (missingTests.size() > 0) {
return String.format("%s requires test '%s'", appVersion, missingTests.iterator().next());
} else {
return "";
}
}
private ProctorLoadResult verify(final ProctorSpecification spec,
final TestMatrixArtifact testMatrix,
final String testName,
final String matrixSource) {
final Map<String, TestSpecification> requiredTests;
if (spec.getTests().containsKey(testName)) {
requiredTests = ImmutableMap.of(testName, spec.getTests().get(testName));
} else {
requiredTests = Collections.emptyMap();
}
return ProctorUtils.verify(testMatrix, matrixSource, requiredTests);
}
private static void validateUsernamePassword(String username, String password) throws IllegalArgumentException {
if (CharMatcher.WHITESPACE.matchesAllOf(Strings.nullToEmpty(username)) || CharMatcher.WHITESPACE.matchesAllOf(Strings.nullToEmpty(password))) {
throw new IllegalArgumentException("No username or password provided");
}
}
private void validateComment(String comment) throws IllegalArgumentException {
if (CharMatcher.WHITESPACE.matchesAllOf(Strings.nullToEmpty(comment))) {
throw new IllegalArgumentException("Comment is required.");
}
}
private void validateBasicInformation(final TestDefinition definition,
final BackgroundJob backgroundJob) throws IllegalArgumentException {
if (CharMatcher.WHITESPACE.matchesAllOf(Strings.nullToEmpty(definition.getDescription()))) {
throw new IllegalArgumentException("Description is required.");
}
if (CharMatcher.WHITESPACE.matchesAllOf(Strings.nullToEmpty(definition.getSalt()))) {
throw new IllegalArgumentException("Salt is required.");
}
if (definition.getTestType() == null) {
throw new IllegalArgumentException("TestType is required.");
}
if (definition.getBuckets().isEmpty()) {
throw new IllegalArgumentException("Buckets cannot be empty.");
}
if (definition.getAllocations().isEmpty()) {
throw new IllegalArgumentException("Allocations cannot be empty.");
}
validateAllocationsAndBuckets(definition, backgroundJob);
}
private void validateAllocationsAndBuckets(final TestDefinition definition, final BackgroundJob backgroundJob) throws IllegalArgumentException {
final Allocation allocation = definition.getAllocations().get(0);
final List<Range> ranges = allocation.getRanges();
final TestType testType = definition.getTestType();
final int controlBucketValue = 0;
final double DELTA = 1E-6;
final Map<Integer, Double> totalTestAllocationMap = new HashMap<Integer, Double>();
for (Range range : ranges) {
final int bucketValue = range.getBucketValue();
double bucketAllocation = range.getLength();
if (totalTestAllocationMap.containsKey(bucketValue)) {
bucketAllocation += totalTestAllocationMap.get(bucketValue);
}
totalTestAllocationMap.put(bucketValue, bucketAllocation);
}
final boolean hasControlBucket = totalTestAllocationMap.containsKey(controlBucketValue);
/* The number of buckets with allocation greater than zero */
int numActiveBuckets = 0;
for (Integer bucketValue : totalTestAllocationMap.keySet()) {
final double totalBucketAllocation = totalTestAllocationMap.get(bucketValue);
if(totalBucketAllocation > 0) {
numActiveBuckets++;
}
}
/* if there are 2 buckets with positive allocations, test and control buckets
should be the same size
*/
if(numActiveBuckets > 1 && hasControlBucket) {
final double totalControlBucketAllocation = totalTestAllocationMap.get(controlBucketValue);
for (Integer bucketValue : totalTestAllocationMap.keySet()) {
final double totalBucketAllocation = totalTestAllocationMap.get(bucketValue);
if (totalBucketAllocation > 0) {
numActiveBuckets++;
}
final double difference = totalBucketAllocation - totalControlBucketAllocation;
if (bucketValue > 0 && totalBucketAllocation > 0 && Math.abs(difference) >= DELTA) {
backgroundJob.log("WARNING: Positive bucket total allocation size not same as control bucket total allocation size. \nBucket #" + bucketValue + "=" + totalBucketAllocation + ", Zero Bucket=" + totalControlBucketAllocation);
}
}
}
/* If there are 2 buckets with positive allocations, one should be control */
if (numActiveBuckets > 1 && !hasControlBucket) {
backgroundJob.log("WARNING: You should have a zero bucket (control).");
}
for (TestBucket bucket : definition.getBuckets()) {
if (testType == TestType.PAGE && bucket.getValue() < 0) {
throw new IllegalArgumentException("PAGE tests cannot contain negative buckets.");
}
}
for (TestBucket bucket : definition.getBuckets()) {
final String name = bucket.getName();
if (!isValidBucketName(name)) {
throw new IllegalArgumentException("Bucket name must be alpha-numeric underscore and not start with a number, found: '" + name + "'");
}
}
}
private String doView(final Environment b,
final Views view,
final String testName,
// TODO (parker) 7/27/12 - add Revisioned (that has Revision + testName)
final TestDefinition definition,
final List<RevisionDefinition> history,
final EnvironmentVersion version,
Model model) {
model.addAttribute("testName", testName);
model.addAttribute("testDefinition", definition);
model.addAttribute("isCreate", view == Views.CREATE);
model.addAttribute("branch", b);
model.addAttribute("version", version);
final Map<String, Object> specialConstants;
if (definition.getSpecialConstants() != null) {
specialConstants = definition.getSpecialConstants();
} else {
specialConstants = Collections.<String, Object>emptyMap();
}
model.addAttribute("specialConstants", specialConstants);
model.addAttribute("session",
SessionViewModel.builder()
.setUseCompiledCSS(getConfiguration().isUseCompiledCSS())
.setUseCompiledJavaScript(getConfiguration().isUseCompiledJavaScript())
// todo get the appropriate js compile / non-compile url
.build());
boolean emptyClients = true;
for (final Environment environment : Environment.values()) {
emptyClients &= specificationSource.loadAllSpecifications(environment).keySet().isEmpty();
}
model.addAttribute("emptyClients", emptyClients);
final Set<AppVersion> devApplications = specificationSource.activeClients(Environment.WORKING, testName);
model.addAttribute("devApplications", devApplications);
final Set<AppVersion> qaApplications = specificationSource.activeClients(Environment.QA, testName);
model.addAttribute("qaApplications", qaApplications);
final Set<AppVersion> productionApplications = specificationSource.activeClients(Environment.PRODUCTION, testName);
model.addAttribute("productionApplications", productionApplications);
try {
// convert to artifact?
final StringWriter sw = new StringWriter();
ProctorUtils.serializeTestDefinition(sw, definition);
model.addAttribute("testDefinitionJson", sw.toString());
} catch (JsonGenerationException e) {
LOGGER.error("Could not generate JSON", e);
} catch (JsonMappingException e) {
LOGGER.error("Could not generate JSON", e);
} catch (IOException e) {
LOGGER.error("Could not generate JSON", e);
}
try {
final StringWriter swSpecification = new StringWriter();
ProctorUtils.serializeTestSpecification(swSpecification, ProctorUtils.generateSpecification(definition));
model.addAttribute("testSpecificationJson", swSpecification.toString());
} catch (IllegalArgumentException e) {
LOGGER.error("Could not generate Test Specification", e);
} catch (JsonGenerationException e) {
LOGGER.error("Could not generate JSON", e);
} catch (JsonMappingException e) {
LOGGER.error("Could not generate JSON", e);
} catch (IOException e) {
LOGGER.error("Could not generate JSON", e);
}
model.addAttribute("testDefinitionHistory", history);
final Revision testDefinitionVersion = version == null ? EnvironmentVersion.FULL_UNKNOWN_REVISION : version.getFullRevision(b);
model.addAttribute("testDefinitionVersion", testDefinitionVersion);
// TODO (parker) 8/9/12 - Add common model for TestTypes and other Drop Downs
model.addAttribute("testTypes", Arrays.asList(TestType.values()));
return view.getName();
}
/**
* This needs to be moved to a separate checker class implementing some interface
*/
private URL getSpecificationUrl(final ProctorClientApplication client) {
final String urlStr = client.getBaseApplicationUrl() + "/private/proctor/specification";
try {
return new URL(urlStr);
} catch (final MalformedURLException e) {
throw new RuntimeException("Somehow created a malformed URL: " + urlStr, e);
}
}
// @Nullable
private static TestDefinition getTestDefinition(final ProctorStore store, final String testName) {
try {
return store.getCurrentTestDefinition(testName);
} catch (StoreException e) {
LOGGER.info("Failed to get current test definition for: " + testName, e);
return null;
}
}
// @Nullable
private static TestDefinition getTestDefinition(final ProctorStore store, final String testName, final String revision) {
try {
if ("-1".equals(revision)){
LOGGER.info("Ignore revision id -1");
return null;
}
return store.getTestDefinition(testName, revision);
} catch (StoreException e) {
LOGGER.info("Failed to get current test definition for: " + testName, e);
return null;
}
}
private TestDefinition getTestDefinition(final Environment environment, final String testName, final String revision) {
final ProctorStore store = determineStoreFromEnvironment(environment);
final EnvironmentVersion version = promoter.getEnvironmentVersion(testName);
final String environmentVersion = version.getRevision(environment);
if (!"-1".equals(environmentVersion) && revision.equals(environmentVersion)) {
// if revision is environment latest version, fetching current environment version is more cache-friendly
return getTestDefinition(store, testName);
} else {
return getTestDefinition(store, testName, revision);
}
}
private static List<Revision> getTestHistory(final ProctorStore store, final String testName, final int limit) {
return getTestHistory(store, testName, null, limit);
}
private static List<Revision> getTestHistory(final ProctorStore store, final String testName, final String startRevision) {
return getTestHistory(store, testName, startRevision, Integer.MAX_VALUE);
}
// @Nonnull
private static List<Revision> getTestHistory(final ProctorStore store,
final String testName,
final String startRevision,
final int limit) {
try {
final List<Revision> history;
if (startRevision == null) {
history = store.getHistory(testName, 0, limit);
} else {
history = store.getHistory(testName, startRevision, 0, limit);
}
if (history.size() == 0) {
LOGGER.info("No version history for [" + testName + "]");
}
return history;
} catch (StoreException e) {
LOGGER.info("Failed to get current test history for: " + testName, e);
return null;
}
}
private static class CheckMatrixResult {
final boolean isValid;
final List<String> errors;
private CheckMatrixResult(boolean valid, List<String> errors) {
isValid = valid;
this.errors = errors;
}
public boolean isValid() {
return isValid;
}
public List<String> getErrors() {
return errors;
}
}
}