Line data Source code
1 : // Copyright (C) 2012 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server.rules;
16 :
17 : import static com.google.common.base.Preconditions.checkState;
18 : import static com.google.gerrit.server.project.ProjectCache.illegalState;
19 :
20 : import com.google.common.annotations.VisibleForTesting;
21 : import com.google.common.base.CharMatcher;
22 : import com.google.common.flogger.FluentLogger;
23 : import com.google.gerrit.entities.Account;
24 : import com.google.gerrit.entities.Change;
25 : import com.google.gerrit.entities.LabelType;
26 : import com.google.gerrit.entities.SubmitRecord;
27 : import com.google.gerrit.entities.SubmitTypeRecord;
28 : import com.google.gerrit.exceptions.StorageException;
29 : import com.google.gerrit.extensions.client.SubmitType;
30 : import com.google.gerrit.server.account.AccountCache;
31 : import com.google.gerrit.server.account.Accounts;
32 : import com.google.gerrit.server.account.Emails;
33 : import com.google.gerrit.server.project.NoSuchProjectException;
34 : import com.google.gerrit.server.project.ProjectCache;
35 : import com.google.gerrit.server.project.ProjectState;
36 : import com.google.gerrit.server.project.RuleEvalException;
37 : import com.google.gerrit.server.query.change.ChangeData;
38 : import com.google.inject.assistedinject.Assisted;
39 : import com.google.inject.assistedinject.AssistedInject;
40 : import com.googlecode.prolog_cafe.exceptions.CompileException;
41 : import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
42 : import com.googlecode.prolog_cafe.lang.IntegerTerm;
43 : import com.googlecode.prolog_cafe.lang.ListTerm;
44 : import com.googlecode.prolog_cafe.lang.Prolog;
45 : import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
46 : import com.googlecode.prolog_cafe.lang.StructureTerm;
47 : import com.googlecode.prolog_cafe.lang.SymbolTerm;
48 : import com.googlecode.prolog_cafe.lang.Term;
49 : import com.googlecode.prolog_cafe.lang.VariableTerm;
50 : import java.io.StringReader;
51 : import java.util.ArrayList;
52 : import java.util.Collections;
53 : import java.util.List;
54 :
55 : /**
56 : * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
57 : * the results through rules found in the parent projects, all the way up to All-Projects.
58 : */
59 : public class PrologRuleEvaluator {
60 104 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
61 :
62 : private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
63 :
64 : /**
65 : * List of characters to allow in the label name, when an invalid name is used. Dash is allowed as
66 : * it can't be the first character: we use a prefix.
67 : */
68 104 : private static final CharMatcher VALID_LABEL_MATCHER =
69 104 : CharMatcher.is('-')
70 104 : .or(CharMatcher.inRange('a', 'z'))
71 104 : .or(CharMatcher.inRange('A', 'Z'))
72 104 : .or(CharMatcher.inRange('0', '9'));
73 :
74 : public interface Factory {
75 : /** Returns a new {@link PrologRuleEvaluator} with the specified options */
76 : PrologRuleEvaluator create(ChangeData cd, PrologOptions options);
77 : }
78 :
79 : /**
80 : * Exception thrown when the label term of a submit record unexpectedly didn't contain a user
81 : * term.
82 : */
83 : private static class UserTermExpected extends Exception {
84 : private static final long serialVersionUID = 1L;
85 :
86 : UserTermExpected(SubmitRecord.Label label) {
87 0 : super(String.format("A label with the status %s must contain a user.", label.toString()));
88 0 : }
89 : }
90 :
91 : private final AccountCache accountCache;
92 : private final Accounts accounts;
93 : private final Emails emails;
94 : private final RulesCache rulesCache;
95 : private final PrologEnvironment.Factory envFactory;
96 : private final ChangeData cd;
97 : private final ProjectState projectState;
98 : private final PrologOptions opts;
99 : private Term submitRule;
100 :
101 : @SuppressWarnings("UnusedMethod")
102 : @AssistedInject
103 : private PrologRuleEvaluator(
104 : AccountCache accountCache,
105 : Accounts accounts,
106 : Emails emails,
107 : RulesCache rulesCache,
108 : PrologEnvironment.Factory envFactory,
109 : ProjectCache projectCache,
110 : @Assisted ChangeData cd,
111 103 : @Assisted PrologOptions options) {
112 103 : this.accountCache = accountCache;
113 103 : this.accounts = accounts;
114 103 : this.emails = emails;
115 103 : this.rulesCache = rulesCache;
116 103 : this.envFactory = envFactory;
117 103 : this.cd = cd;
118 103 : this.opts = options;
119 :
120 103 : this.projectState = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
121 103 : }
122 :
123 : private static Term toListTerm(List<Term> terms) {
124 103 : Term list = Prolog.Nil;
125 103 : for (int i = terms.size() - 1; i >= 0; i--) {
126 103 : list = new ListTerm(terms.get(i), list);
127 : }
128 103 : return list;
129 : }
130 :
131 : private static boolean isUser(Term who) {
132 5 : return who instanceof StructureTerm
133 5 : && who.arity() == 1
134 5 : && who.name().equals("user")
135 5 : && who.arg(0) instanceof IntegerTerm;
136 : }
137 :
138 : private Term getSubmitRule() {
139 5 : return submitRule;
140 : }
141 :
142 : /**
143 : * Evaluate the submit rules.
144 : *
145 : * @return {@link SubmitRecord} returned from the evaluated rules. Can include errors.
146 : */
147 : public SubmitRecord evaluate() {
148 : Change change;
149 : try {
150 5 : change = cd.change();
151 5 : if (change == null) {
152 0 : throw new StorageException("No change found");
153 : }
154 :
155 5 : if (projectState == null) {
156 0 : throw new NoSuchProjectException(cd.project());
157 : }
158 0 : } catch (StorageException | NoSuchProjectException e) {
159 0 : return ruleError("Error looking up change " + cd.getId(), e);
160 5 : }
161 :
162 5 : logger.atFine().log("input approvals: %s", cd.approvals());
163 :
164 : List<Term> results;
165 : try {
166 5 : results =
167 5 : evaluateImpl(
168 : "locate_submit_rule", "can_submit", "locate_submit_filter", "filter_submit_results");
169 2 : } catch (RuleEvalException e) {
170 2 : return ruleError(e.getMessage(), e);
171 5 : }
172 :
173 5 : if (results.isEmpty()) {
174 : // This should never occur. A well written submit rule will always produce
175 : // at least one result informing the caller of the labels that are
176 : // required for this change to be submittable. Each label will indicate
177 : // whether or not that is actually possible given the permissions.
178 1 : return ruleError(
179 1 : String.format(
180 : "Submit rule '%s' for change %s of %s has no solution.",
181 1 : getSubmitRuleName(), cd.getId(), projectState.getName()));
182 : }
183 :
184 5 : SubmitRecord submitRecord = resultsToSubmitRecord(getSubmitRule(), results);
185 5 : logger.atFine().log("submit record: %s", submitRecord);
186 5 : return submitRecord;
187 : }
188 :
189 : private String getSubmitRuleName() {
190 1 : return submitRule == null ? "<unknown>" : submitRule.name();
191 : }
192 :
193 : /**
194 : * Convert the results from Prolog Cafe's format to Gerrit's common format.
195 : *
196 : * <p>can_submit/1 terminates when an ok(P) record is found. Therefore walk the results backwards,
197 : * using only that ok(P) record if it exists. This skips partial results that occur early in the
198 : * output. Later after the loop the out collection is reversed to restore it to the original
199 : * ordering.
200 : */
201 : public SubmitRecord resultsToSubmitRecord(Term submitRule, List<Term> results) {
202 5 : checkState(!results.isEmpty(), "the list of Prolog terms must not be empty");
203 :
204 5 : SubmitRecord resultSubmitRecord = new SubmitRecord();
205 5 : resultSubmitRecord.labels = new ArrayList<>();
206 5 : for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) {
207 5 : Term submitRecord = results.get(resultIdx);
208 :
209 5 : if (!(submitRecord instanceof StructureTerm) || 1 != submitRecord.arity()) {
210 0 : return invalidResult(submitRule, submitRecord);
211 : }
212 :
213 5 : if (!"ok".equals(submitRecord.name()) && !"not_ready".equals(submitRecord.name())) {
214 0 : return invalidResult(submitRule, submitRecord);
215 : }
216 :
217 : // This transformation is required to adapt Prolog's behavior to the way Gerrit handles
218 : // SubmitRecords, as defined in the SubmitRecord#allRecordsOK method.
219 : // When several rules are defined in Prolog, they are all matched to a SubmitRecord. We want
220 : // the change to be submittable when at least one result is OK.
221 5 : if ("ok".equals(submitRecord.name())) {
222 5 : resultSubmitRecord.status = SubmitRecord.Status.OK;
223 5 : } else if ("not_ready".equals(submitRecord.name()) && resultSubmitRecord.status == null) {
224 5 : resultSubmitRecord.status = SubmitRecord.Status.NOT_READY;
225 : }
226 :
227 : // Unpack the one argument. This should also be a structure with one
228 : // argument per label that needs to be reported on to the caller.
229 : //
230 5 : submitRecord = submitRecord.arg(0);
231 :
232 5 : if (!(submitRecord instanceof StructureTerm)) {
233 0 : return invalidResult(submitRule, submitRecord);
234 : }
235 :
236 5 : for (Term state : ((StructureTerm) submitRecord).args()) {
237 5 : if (!(state instanceof StructureTerm)
238 5 : || 2 != state.arity()
239 5 : || !"label".equals(state.name())) {
240 0 : return invalidResult(submitRule, submitRecord);
241 : }
242 :
243 5 : SubmitRecord.Label lbl = new SubmitRecord.Label();
244 5 : resultSubmitRecord.labels.add(lbl);
245 :
246 5 : lbl.label = checkLabelName(state.arg(0).name());
247 5 : Term status = state.arg(1);
248 :
249 : try {
250 5 : if ("ok".equals(status.name())) {
251 5 : lbl.status = SubmitRecord.Label.Status.OK;
252 5 : appliedBy(lbl, status);
253 :
254 5 : } else if ("reject".equals(status.name())) {
255 1 : lbl.status = SubmitRecord.Label.Status.REJECT;
256 1 : appliedBy(lbl, status);
257 :
258 5 : } else if ("need".equals(status.name())) {
259 4 : lbl.status = SubmitRecord.Label.Status.NEED;
260 :
261 1 : } else if ("may".equals(status.name())) {
262 1 : lbl.status = SubmitRecord.Label.Status.MAY;
263 :
264 0 : } else if ("impossible".equals(status.name())) {
265 0 : lbl.status = SubmitRecord.Label.Status.IMPOSSIBLE;
266 :
267 : } else {
268 0 : return invalidResult(submitRule, submitRecord);
269 : }
270 0 : } catch (UserTermExpected e) {
271 0 : return invalidResult(submitRule, submitRecord, e.getMessage());
272 5 : }
273 : }
274 :
275 5 : if (resultSubmitRecord.status == SubmitRecord.Status.OK) {
276 5 : break;
277 : }
278 : }
279 5 : Collections.reverse(resultSubmitRecord.labels);
280 5 : return resultSubmitRecord;
281 : }
282 :
283 : @VisibleForTesting
284 : static String checkLabelName(String name) {
285 : try {
286 6 : return LabelType.checkName(name);
287 1 : } catch (IllegalArgumentException e) {
288 1 : String newName = "Invalid-Prolog-Rules-Label-Name-" + sanitizeLabelName(name);
289 1 : return LabelType.checkName(newName.replace("--", "-"));
290 : }
291 : }
292 :
293 : private static String sanitizeLabelName(String name) {
294 1 : return VALID_LABEL_MATCHER.retainFrom(name);
295 : }
296 :
297 : private static SubmitRecord createRuleError(String err) {
298 2 : SubmitRecord rec = new SubmitRecord();
299 2 : rec.status = SubmitRecord.Status.RULE_ERROR;
300 2 : rec.errorMessage = err;
301 2 : return rec;
302 : }
303 :
304 : private SubmitRecord invalidResult(Term rule, Term record, String reason) {
305 0 : return ruleError(
306 0 : String.format(
307 : "Submit rule %s for change %s of %s output invalid result: %s%s",
308 : rule,
309 0 : cd.getId(),
310 0 : cd.project().get(),
311 : record,
312 0 : (reason == null ? "" : ". Reason: " + reason)));
313 : }
314 :
315 : private SubmitRecord invalidResult(Term rule, Term record) {
316 0 : return invalidResult(rule, record, null);
317 : }
318 :
319 : private SubmitRecord ruleError(String err) {
320 1 : return ruleError(err, null);
321 : }
322 :
323 : private SubmitRecord ruleError(String err, Exception e) {
324 2 : if (opts.logErrors()) {
325 1 : logger.atSevere().withCause(e).log("%s", err);
326 1 : return createRuleError(DEFAULT_MSG);
327 : }
328 1 : logger.atFine().log("rule error: %s", err);
329 1 : return createRuleError(err);
330 : }
331 :
332 : /**
333 : * Evaluate the submit type rules to get the submit type.
334 : *
335 : * @return record from the evaluated rules.
336 : */
337 : public SubmitTypeRecord getSubmitType() {
338 : try {
339 103 : if (projectState == null) {
340 0 : throw new NoSuchProjectException(cd.project());
341 : }
342 0 : } catch (NoSuchProjectException e) {
343 0 : return typeError("Error looking up change " + cd.getId(), e);
344 103 : }
345 :
346 : List<Term> results;
347 : try {
348 103 : results =
349 103 : evaluateImpl(
350 : "locate_submit_type",
351 : "get_submit_type",
352 : "locate_submit_type_filter",
353 : "filter_submit_type_results");
354 1 : } catch (RuleEvalException e) {
355 1 : return typeError(e.getMessage(), e);
356 103 : }
357 :
358 103 : if (results.isEmpty()) {
359 : // Should never occur for a well written rule
360 0 : return typeError(
361 : "Submit rule '"
362 0 : + getSubmitRuleName()
363 : + "' for change "
364 0 : + cd.getId()
365 : + " of "
366 0 : + projectState.getName()
367 : + " has no solution.");
368 : }
369 :
370 103 : Term typeTerm = results.get(0);
371 103 : if (!(typeTerm instanceof SymbolTerm)) {
372 0 : return typeError(
373 : "Submit rule '"
374 0 : + getSubmitRuleName()
375 : + "' for change "
376 0 : + cd.getId()
377 : + " of "
378 0 : + projectState.getName()
379 : + " did not return a symbol.");
380 : }
381 :
382 103 : String typeName = typeTerm.name();
383 : try {
384 103 : return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
385 0 : } catch (IllegalArgumentException e) {
386 0 : return typeError(
387 : "Submit type rule "
388 0 : + getSubmitRule()
389 : + " for change "
390 0 : + cd.getId()
391 : + " of "
392 0 : + projectState.getName()
393 : + " output invalid result: "
394 : + typeName);
395 : }
396 : }
397 :
398 : private SubmitTypeRecord typeError(String err) {
399 0 : return typeError(err, null);
400 : }
401 :
402 : private SubmitTypeRecord typeError(String err, Exception e) {
403 1 : if (opts.logErrors()) {
404 1 : logger.atSevere().withCause(e).log("%s", err);
405 : }
406 1 : return SubmitTypeRecord.error(err);
407 : }
408 :
409 : private List<Term> evaluateImpl(
410 : String userRuleLocatorName,
411 : String userRuleWrapperName,
412 : String filterRuleLocatorName,
413 : String filterRuleWrapperName)
414 : throws RuleEvalException {
415 103 : PrologEnvironment env = getPrologEnvironment();
416 : try {
417 103 : Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
418 103 : List<Term> results = new ArrayList<>();
419 : try {
420 103 : for (Term[] template : env.all("gerrit", userRuleWrapperName, sr, new VariableTerm())) {
421 103 : results.add(template[1]);
422 103 : }
423 0 : } catch (ReductionLimitException err) {
424 0 : throw new RuleEvalException(
425 0 : String.format(
426 : "%s on change %d of %s",
427 0 : err.getMessage(), cd.getId().get(), projectState.getName()));
428 0 : } catch (RuntimeException err) {
429 0 : throw new RuleEvalException(
430 0 : String.format(
431 : "Exception calling %s on change %d of %s",
432 0 : sr, cd.getId().get(), projectState.getName()),
433 : err);
434 103 : }
435 :
436 103 : Term resultsTerm = toListTerm(results);
437 103 : if (!opts.skipFilters()) {
438 103 : resultsTerm =
439 103 : runSubmitFilters(resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
440 : }
441 : List<Term> r;
442 103 : if (resultsTerm instanceof ListTerm) {
443 103 : r = new ArrayList<>();
444 103 : for (Term t = resultsTerm; t instanceof ListTerm; ) {
445 103 : ListTerm l = (ListTerm) t;
446 103 : r.add(l.car().dereference());
447 103 : t = l.cdr().dereference();
448 103 : }
449 : } else {
450 1 : r = Collections.emptyList();
451 : }
452 103 : submitRule = sr;
453 103 : return r;
454 : } finally {
455 103 : env.close();
456 : }
457 : }
458 :
459 : private PrologEnvironment getPrologEnvironment() throws RuleEvalException {
460 : PrologEnvironment env;
461 : try {
462 : PrologMachineCopy pmc;
463 103 : if (opts.rule().isPresent()) {
464 1 : pmc = rulesCache.loadMachine("stdin", new StringReader(opts.rule().get()));
465 : } else {
466 103 : pmc =
467 103 : rulesCache.loadMachine(
468 103 : projectState.getNameKey(), projectState.getConfig().getRulesId().orElse(null));
469 : }
470 103 : env = envFactory.create(pmc);
471 2 : } catch (CompileException err) {
472 : String msg;
473 2 : if (opts.rule().isPresent()) {
474 1 : msg = err.getMessage();
475 : } else {
476 1 : msg =
477 1 : String.format(
478 1 : "Cannot load rules.pl for %s: %s", projectState.getName(), err.getMessage());
479 : }
480 2 : throw new RuleEvalException(msg, err);
481 103 : }
482 103 : env.set(StoredValues.ACCOUNTS, accounts);
483 103 : env.set(StoredValues.ACCOUNT_CACHE, accountCache);
484 103 : env.set(StoredValues.EMAILS, emails);
485 103 : env.set(StoredValues.CHANGE_DATA, cd);
486 103 : env.set(StoredValues.PROJECT_STATE, projectState);
487 103 : return env;
488 : }
489 :
490 : private Term runSubmitFilters(
491 : Term results,
492 : PrologEnvironment env,
493 : String filterRuleLocatorName,
494 : String filterRuleWrapperName)
495 : throws RuleEvalException {
496 103 : PrologEnvironment childEnv = env;
497 103 : ChangeData cd = env.get(StoredValues.CHANGE_DATA);
498 103 : ProjectState projectState = env.get(StoredValues.PROJECT_STATE);
499 103 : for (ProjectState parentState : projectState.parents()) {
500 : PrologEnvironment parentEnv;
501 : try {
502 102 : parentEnv =
503 102 : envFactory.create(
504 102 : rulesCache.loadMachine(
505 102 : parentState.getNameKey(), parentState.getConfig().getRulesId().orElse(null)));
506 0 : } catch (CompileException err) {
507 0 : throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
508 102 : }
509 :
510 102 : parentEnv.copyStoredValues(childEnv);
511 102 : Term filterRule = parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
512 : try {
513 102 : Term[] template =
514 102 : parentEnv.once(
515 : "gerrit", filterRuleWrapperName, filterRule, results, new VariableTerm());
516 102 : results = template[2];
517 0 : } catch (ReductionLimitException err) {
518 0 : throw new RuleEvalException(
519 0 : String.format(
520 : "%s on change %d of %s",
521 0 : err.getMessage(), cd.getId().get(), parentState.getName()));
522 0 : } catch (RuntimeException err) {
523 0 : throw new RuleEvalException(
524 0 : String.format(
525 : "Exception calling %s on change %d of %s",
526 0 : filterRule, cd.getId().get(), parentState.getName()),
527 : err);
528 102 : }
529 102 : childEnv = parentEnv;
530 102 : }
531 103 : return results;
532 : }
533 :
534 : private void appliedBy(SubmitRecord.Label label, Term status) throws UserTermExpected {
535 5 : if (status instanceof StructureTerm && status.arity() == 1) {
536 5 : Term who = status.arg(0);
537 5 : if (isUser(who)) {
538 5 : label.appliedBy = Account.id(((IntegerTerm) who.arg(0)).intValue());
539 : } else {
540 0 : throw new UserTermExpected(label);
541 : }
542 : }
543 5 : }
544 : }
|