| // Copyright (C) 2012 The Android Open Source Project | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| package com.google.gerrit.server.rules.prolog; | |
| import static com.google.common.base.Preconditions.checkState; | |
| import static com.google.gerrit.server.project.ProjectCache.illegalState; | |
| import com.google.common.annotations.VisibleForTesting; | |
| import com.google.common.base.CharMatcher; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.entities.Account; | |
| import com.google.gerrit.entities.Change; | |
| import com.google.gerrit.entities.LabelType; | |
| import com.google.gerrit.entities.SubmitRecord; | |
| import com.google.gerrit.entities.SubmitTypeRecord; | |
| import com.google.gerrit.exceptions.StorageException; | |
| import com.google.gerrit.extensions.client.SubmitType; | |
| import com.google.gerrit.server.account.AccountCache; | |
| import com.google.gerrit.server.account.Accounts; | |
| import com.google.gerrit.server.account.Emails; | |
| import com.google.gerrit.server.project.NoSuchProjectException; | |
| import com.google.gerrit.server.project.ProjectCache; | |
| import com.google.gerrit.server.project.ProjectState; | |
| import com.google.gerrit.server.project.RuleEvalException; | |
| import com.google.gerrit.server.query.change.ChangeData; | |
| import com.google.inject.assistedinject.Assisted; | |
| import com.google.inject.assistedinject.AssistedInject; | |
| import com.googlecode.prolog_cafe.exceptions.CompileException; | |
| import com.googlecode.prolog_cafe.exceptions.ReductionLimitException; | |
| import com.googlecode.prolog_cafe.lang.IntegerTerm; | |
| import com.googlecode.prolog_cafe.lang.ListTerm; | |
| import com.googlecode.prolog_cafe.lang.Prolog; | |
| import com.googlecode.prolog_cafe.lang.PrologMachineCopy; | |
| import com.googlecode.prolog_cafe.lang.StructureTerm; | |
| import com.googlecode.prolog_cafe.lang.SymbolTerm; | |
| import com.googlecode.prolog_cafe.lang.Term; | |
| import com.googlecode.prolog_cafe.lang.VariableTerm; | |
| import java.io.StringReader; | |
| import java.util.ArrayList; | |
| import java.util.Collections; | |
| import java.util.List; | |
| import java.util.Locale; | |
| /** | |
| * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters | |
| * the results through rules found in the parent projects, all the way up to All-Projects. | |
| */ | |
| public class PrologRuleEvaluator { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| private static final String DEFAULT_MSG = "Error evaluating project rules, check server log"; | |
| /** | |
| * List of characters to allow in the label name, when an invalid name is used. Dash is allowed as | |
| * it can't be the first character: we use a prefix. | |
| */ | |
| private static final CharMatcher VALID_LABEL_MATCHER = | |
| CharMatcher.is('-') | |
| .or(CharMatcher.inRange('a', 'z')) | |
| .or(CharMatcher.inRange('A', 'Z')) | |
| .or(CharMatcher.inRange('0', '9')); | |
| public interface Factory { | |
| /** Returns a new {@link PrologRuleEvaluator} with the specified options */ | |
| PrologRuleEvaluator create(ChangeData cd, PrologOptions options); | |
| } | |
| /** | |
| * Exception thrown when the label term of a submit record unexpectedly didn't contain a user | |
| * term. | |
| */ | |
| private static class UserTermExpected extends Exception { | |
| private static final long serialVersionUID = 1L; | |
| UserTermExpected(SubmitRecord.Label label) { | |
| super(String.format("A label with the status %s must contain a user.", label.toString())); | |
| } | |
| } | |
| private final AccountCache accountCache; | |
| private final Accounts accounts; | |
| private final Emails emails; | |
| private final RulesCache rulesCache; | |
| private final PrologEnvironment.Factory envFactory; | |
| private final ChangeData cd; | |
| private final ProjectState projectState; | |
| private final PrologOptions opts; | |
| private Term submitRule; | |
| @SuppressWarnings("UnusedMethod") | |
| @AssistedInject | |
| private PrologRuleEvaluator( | |
| AccountCache accountCache, | |
| Accounts accounts, | |
| Emails emails, | |
| RulesCache rulesCache, | |
| PrologEnvironment.Factory envFactory, | |
| ProjectCache projectCache, | |
| @Assisted ChangeData cd, | |
| @Assisted PrologOptions options) { | |
| this.accountCache = accountCache; | |
| this.accounts = accounts; | |
| this.emails = emails; | |
| this.rulesCache = rulesCache; | |
| this.envFactory = envFactory; | |
| this.cd = cd; | |
| this.opts = options; | |
| this.projectState = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project())); | |
| } | |
| private static Term toListTerm(List<Term> terms) { | |
| Term list = Prolog.Nil; | |
| for (int i = terms.size() - 1; i >= 0; i--) { | |
| list = new ListTerm(terms.get(i), list); | |
| } | |
| return list; | |
| } | |
| private static boolean isUser(Term who) { | |
| return who instanceof StructureTerm | |
| && who.arity() == 1 | |
| && who.name().equals("user") | |
| && who.arg(0) instanceof IntegerTerm; | |
| } | |
| private Term getSubmitRule() { | |
| return submitRule; | |
| } | |
| /** | |
| * Evaluate the submit rules. | |
| * | |
| * @return {@link SubmitRecord} returned from the evaluated rules. Can include errors. | |
| */ | |
| public SubmitRecord evaluate() { | |
| Change change; | |
| try { | |
| change = cd.change(); | |
| if (change == null) { | |
| throw new StorageException("No change found"); | |
| } | |
| if (projectState == null) { | |
| throw new NoSuchProjectException(cd.project()); | |
| } | |
| } catch (StorageException | NoSuchProjectException e) { | |
| return ruleError("Error looking up change " + cd.getId(), e); | |
| } | |
| logger.atFine().log("input approvals: %s", cd.approvals()); | |
| List<Term> results; | |
| try { | |
| results = | |
| evaluateImpl( | |
| "locate_submit_rule", "can_submit", "locate_submit_filter", "filter_submit_results"); | |
| } catch (RuleEvalException e) { | |
| return ruleError(e.getMessage(), e); | |
| } | |
| if (results.isEmpty()) { | |
| // This should never occur. A well written submit rule will always produce | |
| // at least one result informing the caller of the labels that are | |
| // required for this change to be submittable. Each label will indicate | |
| // whether or not that is actually possible given the permissions. | |
| return ruleError( | |
| String.format( | |
| "Submit rule '%s' for change %s of %s has no solution.", | |
| getSubmitRuleName(), cd.getId(), projectState.getName())); | |
| } | |
| SubmitRecord submitRecord = resultsToSubmitRecord(getSubmitRule(), results); | |
| logger.atFine().log("submit record: %s", submitRecord); | |
| return submitRecord; | |
| } | |
| private String getSubmitRuleName() { | |
| return submitRule == null ? "<unknown>" : submitRule.name(); | |
| } | |
| /** | |
| * Convert the results from Prolog Cafe's format to Gerrit's common format. | |
| * | |
| * <p>can_submit/1 terminates when an ok(P) record is found. Therefore walk the results backwards, | |
| * using only that ok(P) record if it exists. This skips partial results that occur early in the | |
| * output. Later after the loop the out collection is reversed to restore it to the original | |
| * ordering. | |
| */ | |
| public SubmitRecord resultsToSubmitRecord(Term submitRule, List<Term> results) { | |
| checkState(!results.isEmpty(), "the list of Prolog terms must not be empty"); | |
| SubmitRecord resultSubmitRecord = new SubmitRecord(); | |
| resultSubmitRecord.labels = new ArrayList<>(); | |
| for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) { | |
| Term submitRecord = results.get(resultIdx); | |
| if (!(submitRecord instanceof StructureTerm) || 1 != submitRecord.arity()) { | |
| return invalidResult(submitRule, submitRecord); | |
| } | |
| if (!"ok".equals(submitRecord.name()) && !"not_ready".equals(submitRecord.name())) { | |
| return invalidResult(submitRule, submitRecord); | |
| } | |
| // This transformation is required to adapt Prolog's behavior to the way Gerrit handles | |
| // SubmitRecords, as defined in the SubmitRecord#allRecordsOK method. | |
| // When several rules are defined in Prolog, they are all matched to a SubmitRecord. We want | |
| // the change to be submittable when at least one result is OK. | |
| if ("ok".equals(submitRecord.name())) { | |
| resultSubmitRecord.status = SubmitRecord.Status.OK; | |
| } else if ("not_ready".equals(submitRecord.name()) && resultSubmitRecord.status == null) { | |
| resultSubmitRecord.status = SubmitRecord.Status.NOT_READY; | |
| } | |
| // Unpack the one argument. This should also be a structure with one | |
| // argument per label that needs to be reported on to the caller. | |
| // | |
| submitRecord = submitRecord.arg(0); | |
| if (!(submitRecord instanceof StructureTerm)) { | |
| return invalidResult(submitRule, submitRecord); | |
| } | |
| for (Term state : ((StructureTerm) submitRecord).args()) { | |
| if (!(state instanceof StructureTerm) | |
| || 2 != state.arity() | |
| || !"label".equals(state.name())) { | |
| return invalidResult(submitRule, submitRecord); | |
| } | |
| SubmitRecord.Label lbl = new SubmitRecord.Label(); | |
| resultSubmitRecord.labels.add(lbl); | |
| lbl.label = checkLabelName(state.arg(0).name()); | |
| Term status = state.arg(1); | |
| try { | |
| if ("ok".equals(status.name())) { | |
| lbl.status = SubmitRecord.Label.Status.OK; | |
| appliedBy(lbl, status); | |
| } else if ("reject".equals(status.name())) { | |
| lbl.status = SubmitRecord.Label.Status.REJECT; | |
| appliedBy(lbl, status); | |
| } else if ("need".equals(status.name())) { | |
| lbl.status = SubmitRecord.Label.Status.NEED; | |
| } else if ("may".equals(status.name())) { | |
| lbl.status = SubmitRecord.Label.Status.MAY; | |
| } else if ("impossible".equals(status.name())) { | |
| lbl.status = SubmitRecord.Label.Status.IMPOSSIBLE; | |
| } else { | |
| return invalidResult(submitRule, submitRecord); | |
| } | |
| } catch (UserTermExpected e) { | |
| return invalidResult(submitRule, submitRecord, e.getMessage()); | |
| } | |
| } | |
| if (resultSubmitRecord.status == SubmitRecord.Status.OK) { | |
| break; | |
| } | |
| } | |
| Collections.reverse(resultSubmitRecord.labels); | |
| return resultSubmitRecord; | |
| } | |
| @VisibleForTesting | |
| static String checkLabelName(String name) { | |
| try { | |
| return LabelType.checkName(name); | |
| } catch (IllegalArgumentException e) { | |
| String newName = "Invalid-Prolog-Rules-Label-Name-" + sanitizeLabelName(name); | |
| return LabelType.checkName(newName.replace("--", "-")); | |
| } | |
| } | |
| private static String sanitizeLabelName(String name) { | |
| return VALID_LABEL_MATCHER.retainFrom(name); | |
| } | |
| private static SubmitRecord createRuleError(String err) { | |
| SubmitRecord rec = new SubmitRecord(); | |
| rec.status = SubmitRecord.Status.RULE_ERROR; | |
| rec.errorMessage = err; | |
| return rec; | |
| } | |
| private SubmitRecord invalidResult(Term rule, Term record, String reason) { | |
| return ruleError( | |
| String.format( | |
| "Submit rule %s for change %s of %s output invalid result: %s%s", | |
| rule, | |
| cd.getId(), | |
| cd.project().get(), | |
| record, | |
| (reason == null ? "" : ". Reason: " + reason))); | |
| } | |
| private SubmitRecord invalidResult(Term rule, Term record) { | |
| return invalidResult(rule, record, null); | |
| } | |
| private SubmitRecord ruleError(String err) { | |
| return ruleError(err, null); | |
| } | |
| private SubmitRecord ruleError(String err, Exception e) { | |
| if (opts.logErrors()) { | |
| logger.atSevere().withCause(e).log("%s", err); | |
| return createRuleError(DEFAULT_MSG); | |
| } | |
| logger.atFine().log("rule error: %s", err); | |
| return createRuleError(err); | |
| } | |
| /** | |
| * Evaluate the submit type rules to get the submit type. | |
| * | |
| * @return record from the evaluated rules. | |
| */ | |
| public SubmitTypeRecord getSubmitType() { | |
| try { | |
| if (projectState == null) { | |
| throw new NoSuchProjectException(cd.project()); | |
| } | |
| } catch (NoSuchProjectException e) { | |
| return typeError("Error looking up change " + cd.getId(), e); | |
| } | |
| List<Term> results; | |
| try { | |
| results = | |
| evaluateImpl( | |
| "locate_submit_type", | |
| "get_submit_type", | |
| "locate_submit_type_filter", | |
| "filter_submit_type_results"); | |
| } catch (RuleEvalException e) { | |
| return typeError(e.getMessage(), e); | |
| } | |
| if (results.isEmpty()) { | |
| // Should never occur for a well written rule | |
| return typeError( | |
| "Submit rule '" | |
| + getSubmitRuleName() | |
| + "' for change " | |
| + cd.getId() | |
| + " of " | |
| + projectState.getName() | |
| + " has no solution."); | |
| } | |
| Term typeTerm = results.get(0); | |
| if (!(typeTerm instanceof SymbolTerm)) { | |
| return typeError( | |
| "Submit rule '" | |
| + getSubmitRuleName() | |
| + "' for change " | |
| + cd.getId() | |
| + " of " | |
| + projectState.getName() | |
| + " did not return a symbol."); | |
| } | |
| String typeName = typeTerm.name(); | |
| try { | |
| return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase(Locale.US))); | |
| } catch (IllegalArgumentException e) { | |
| return typeError( | |
| "Submit type rule " | |
| + getSubmitRule() | |
| + " for change " | |
| + cd.getId() | |
| + " of " | |
| + projectState.getName() | |
| + " output invalid result: " | |
| + typeName); | |
| } | |
| } | |
| private SubmitTypeRecord typeError(String err) { | |
| return typeError(err, null); | |
| } | |
| private SubmitTypeRecord typeError(String err, Exception e) { | |
| if (opts.logErrors()) { | |
| logger.atSevere().withCause(e).log("%s", err); | |
| } | |
| return SubmitTypeRecord.error(err); | |
| } | |
| private List<Term> evaluateImpl( | |
| String userRuleLocatorName, | |
| String userRuleWrapperName, | |
| String filterRuleLocatorName, | |
| String filterRuleWrapperName) | |
| throws RuleEvalException { | |
| PrologEnvironment env = getPrologEnvironment(); | |
| try { | |
| Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm()); | |
| List<Term> results = new ArrayList<>(); | |
| try { | |
| for (Term[] template : env.all("gerrit", userRuleWrapperName, sr, new VariableTerm())) { | |
| results.add(template[1]); | |
| } | |
| } catch (ReductionLimitException err) { | |
| throw new RuleEvalException( | |
| String.format( | |
| "%s on change %d of %s", | |
| err.getMessage(), cd.getId().get(), projectState.getName())); | |
| } catch (RuntimeException err) { | |
| throw new RuleEvalException( | |
| String.format( | |
| "Exception calling %s on change %d of %s", | |
| sr, cd.getId().get(), projectState.getName()), | |
| err); | |
| } | |
| Term resultsTerm = toListTerm(results); | |
| if (!opts.skipFilters()) { | |
| resultsTerm = | |
| runSubmitFilters(resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName); | |
| } | |
| List<Term> r; | |
| if (resultsTerm instanceof ListTerm) { | |
| r = new ArrayList<>(); | |
| for (Term t = resultsTerm; t instanceof ListTerm; ) { | |
| ListTerm l = (ListTerm) t; | |
| r.add(l.car().dereference()); | |
| t = l.cdr().dereference(); | |
| } | |
| } else { | |
| r = Collections.emptyList(); | |
| } | |
| submitRule = sr; | |
| return r; | |
| } finally { | |
| env.close(); | |
| } | |
| } | |
| private PrologEnvironment getPrologEnvironment() throws RuleEvalException { | |
| PrologEnvironment env; | |
| try { | |
| PrologMachineCopy pmc; | |
| if (opts.rule().isPresent()) { | |
| pmc = rulesCache.loadMachine("stdin", new StringReader(opts.rule().get())); | |
| } else { | |
| pmc = | |
| rulesCache.loadMachine( | |
| projectState.getNameKey(), projectState.getConfig().getRulesId().orElse(null)); | |
| } | |
| env = envFactory.create(pmc); | |
| } catch (CompileException err) { | |
| String msg; | |
| if (opts.rule().isPresent()) { | |
| msg = err.getMessage(); | |
| } else { | |
| msg = | |
| String.format( | |
| "Cannot load rules.pl for %s: %s", projectState.getName(), err.getMessage()); | |
| } | |
| throw new RuleEvalException(msg, err); | |
| } | |
| env.set(StoredValues.ACCOUNTS, accounts); | |
| env.set(StoredValues.ACCOUNT_CACHE, accountCache); | |
| env.set(StoredValues.EMAILS, emails); | |
| env.set(StoredValues.CHANGE_DATA, cd); | |
| env.set(StoredValues.PROJECT_STATE, projectState); | |
| return env; | |
| } | |
| private Term runSubmitFilters( | |
| Term results, | |
| PrologEnvironment env, | |
| String filterRuleLocatorName, | |
| String filterRuleWrapperName) | |
| throws RuleEvalException { | |
| PrologEnvironment childEnv = env; | |
| ChangeData cd = env.get(StoredValues.CHANGE_DATA); | |
| ProjectState projectState = env.get(StoredValues.PROJECT_STATE); | |
| for (ProjectState parentState : projectState.parents()) { | |
| PrologEnvironment parentEnv; | |
| try { | |
| parentEnv = | |
| envFactory.create( | |
| rulesCache.loadMachine( | |
| parentState.getNameKey(), parentState.getConfig().getRulesId().orElse(null))); | |
| } catch (CompileException err) { | |
| throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err); | |
| } | |
| parentEnv.copyStoredValues(childEnv); | |
| Term filterRule = parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm()); | |
| try { | |
| Term[] template = | |
| parentEnv.once( | |
| "gerrit", filterRuleWrapperName, filterRule, results, new VariableTerm()); | |
| results = template[2]; | |
| } catch (ReductionLimitException err) { | |
| throw new RuleEvalException( | |
| String.format( | |
| "%s on change %d of %s", | |
| err.getMessage(), cd.getId().get(), parentState.getName())); | |
| } catch (RuntimeException err) { | |
| throw new RuleEvalException( | |
| String.format( | |
| "Exception calling %s on change %d of %s", | |
| filterRule, cd.getId().get(), parentState.getName()), | |
| err); | |
| } | |
| childEnv = parentEnv; | |
| } | |
| return results; | |
| } | |
| private void appliedBy(SubmitRecord.Label label, Term status) throws UserTermExpected { | |
| if (status instanceof StructureTerm && status.arity() == 1) { | |
| Term who = status.arg(0); | |
| if (isUser(who)) { | |
| label.appliedBy = Account.id(((IntegerTerm) who.arg(0)).intValue()); | |
| } else { | |
| throw new UserTermExpected(label); | |
| } | |
| } | |
| } | |
| } |