| // Copyright (C) 2014 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.change; |
| |
| import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER; |
| import static com.google.gerrit.server.ChangeUtil.TO_PS_ID; |
| |
| import com.google.auto.value.AutoValue; |
| import com.google.common.base.Function; |
| import com.google.common.base.Predicate; |
| import com.google.common.collect.Collections2; |
| import com.google.common.collect.FluentIterable; |
| import com.google.common.collect.Iterables; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Multimap; |
| import com.google.common.collect.MultimapBuilder; |
| import com.google.gerrit.common.FooterConstants; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.common.TimeUtil; |
| import com.google.gerrit.extensions.api.changes.FixInput; |
| import com.google.gerrit.extensions.common.ProblemInfo; |
| import com.google.gerrit.extensions.common.ProblemInfo.Status; |
| import com.google.gerrit.extensions.restapi.RestApiException; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.PatchSet; |
| import com.google.gerrit.reviewdb.client.Project; |
| import com.google.gerrit.reviewdb.client.RevId; |
| import com.google.gerrit.reviewdb.server.ReviewDb; |
| import com.google.gerrit.server.ChangeUtil; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.git.BatchUpdate; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.git.UpdateException; |
| import com.google.gerrit.server.git.validators.CommitValidators; |
| import com.google.gerrit.server.patch.PatchSetInfoFactory; |
| import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException; |
| import com.google.gerrit.server.project.NoSuchProjectException; |
| import com.google.gerrit.server.project.ProjectControl; |
| import com.google.gerrit.server.project.RefControl; |
| import com.google.gerrit.server.query.change.ChangeData; |
| import com.google.gwtorm.server.AtomicUpdate; |
| import com.google.gwtorm.server.OrmException; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| |
| import org.eclipse.jgit.errors.IncorrectObjectTypeException; |
| import org.eclipse.jgit.errors.MissingObjectException; |
| import org.eclipse.jgit.errors.RepositoryNotFoundException; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.ObjectInserter; |
| import org.eclipse.jgit.lib.PersonIdent; |
| import org.eclipse.jgit.lib.Ref; |
| import org.eclipse.jgit.lib.RefUpdate; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| |
| /** |
| * Checks changes for various kinds of inconsistency and corruption. |
| * <p> |
| * A single instance may be reused for checking multiple changes, but not |
| * concurrently. |
| */ |
| public class ConsistencyChecker { |
| private static final Logger log = |
| LoggerFactory.getLogger(ConsistencyChecker.class); |
| |
| @AutoValue |
| public abstract static class Result { |
| private static Result create(Change.Id id, List<ProblemInfo> problems) { |
| return new AutoValue_ConsistencyChecker_Result(id, null, problems); |
| } |
| |
| private static Result create(Change c, List<ProblemInfo> problems) { |
| return new AutoValue_ConsistencyChecker_Result(c.getId(), c, problems); |
| } |
| |
| public abstract Change.Id id(); |
| |
| @Nullable |
| public abstract Change change(); |
| |
| public abstract List<ProblemInfo> problems(); |
| } |
| |
| private final Provider<ReviewDb> db; |
| private final GitRepositoryManager repoManager; |
| private final Provider<CurrentUser> user; |
| private final Provider<PersonIdent> serverIdent; |
| private final ProjectControl.GenericFactory projectControlFactory; |
| private final PatchSetInfoFactory patchSetInfoFactory; |
| private final PatchSetInserter.Factory patchSetInserterFactory; |
| private final BatchUpdate.Factory updateFactory; |
| |
| private FixInput fix; |
| private Change change; |
| private Repository repo; |
| private RevWalk rw; |
| |
| private RevCommit tip; |
| private Multimap<ObjectId, PatchSet> patchSetsBySha; |
| private PatchSet currPs; |
| private RevCommit currPsCommit; |
| |
| private List<ProblemInfo> problems; |
| |
| @Inject |
| ConsistencyChecker(Provider<ReviewDb> db, |
| GitRepositoryManager repoManager, |
| Provider<CurrentUser> user, |
| @GerritPersonIdent Provider<PersonIdent> serverIdent, |
| ProjectControl.GenericFactory projectControlFactory, |
| PatchSetInfoFactory patchSetInfoFactory, |
| PatchSetInserter.Factory patchSetInserterFactory, |
| BatchUpdate.Factory updateFactory) { |
| this.db = db; |
| this.repoManager = repoManager; |
| this.user = user; |
| this.serverIdent = serverIdent; |
| this.projectControlFactory = projectControlFactory; |
| this.patchSetInfoFactory = patchSetInfoFactory; |
| this.patchSetInserterFactory = patchSetInserterFactory; |
| this.updateFactory = updateFactory; |
| reset(); |
| } |
| |
| private void reset() { |
| change = null; |
| repo = null; |
| rw = null; |
| problems = new ArrayList<>(); |
| } |
| |
| public Result check(ChangeData cd) { |
| return check(cd, null); |
| } |
| |
| public Result check(ChangeData cd, @Nullable FixInput f) { |
| reset(); |
| try { |
| return check(cd.change(), f); |
| } catch (OrmException e) { |
| error("Error looking up change", e); |
| return Result.create(cd.getId(), problems); |
| } |
| } |
| |
| public Result check(Change c) { |
| return check(c, null); |
| } |
| |
| public Result check(Change c, @Nullable FixInput f) { |
| reset(); |
| fix = f; |
| change = c; |
| try { |
| checkImpl(); |
| return Result.create(c, problems); |
| } finally { |
| if (rw != null) { |
| rw.close(); |
| } |
| if (repo != null) { |
| repo.close(); |
| } |
| } |
| } |
| |
| private void checkImpl() { |
| checkOwner(); |
| checkCurrentPatchSetEntity(); |
| |
| // All checks that require the repo. |
| if (!openRepo()) { |
| return; |
| } |
| if (!checkPatchSets()) { |
| return; |
| } |
| checkMerged(); |
| } |
| |
| private void checkOwner() { |
| try { |
| if (db.get().accounts().get(change.getOwner()) == null) { |
| problem("Missing change owner: " + change.getOwner()); |
| } |
| } catch (OrmException e) { |
| error("Failed to look up owner", e); |
| } |
| } |
| |
| private void checkCurrentPatchSetEntity() { |
| try { |
| PatchSet.Id psId = change.currentPatchSetId(); |
| currPs = db.get().patchSets().get(psId); |
| if (currPs == null) { |
| problem(String.format("Current patch set %d not found", psId.get())); |
| } |
| } catch (OrmException e) { |
| error("Failed to look up current patch set", e); |
| } |
| } |
| |
| private boolean openRepo() { |
| Project.NameKey project = change.getDest().getParentKey(); |
| try { |
| repo = repoManager.openRepository(project); |
| rw = new RevWalk(repo); |
| return true; |
| } catch (RepositoryNotFoundException e) { |
| return error("Destination repository not found: " + project, e); |
| } catch (IOException e) { |
| return error("Failed to open repository: " + project, e); |
| } |
| } |
| |
| private boolean checkPatchSets() { |
| List<PatchSet> all; |
| try { |
| all = Lists.newArrayList(db.get().patchSets().byChange(change.getId())); |
| } catch (OrmException e) { |
| return error("Failed to look up patch sets", e); |
| } |
| // Iterate in descending order so deletePatchSet can assume the latest patch |
| // set exists. |
| Collections.sort(all, PS_ID_ORDER.reverse()); |
| patchSetsBySha = MultimapBuilder.hashKeys(all.size()) |
| .treeSetValues(PS_ID_ORDER) |
| .build(); |
| |
| Map<String, Ref> refs; |
| try { |
| refs = repo.getRefDatabase().exactRef( |
| Lists.transform(all, new Function<PatchSet, String>() { |
| @Override |
| public String apply(PatchSet ps) { |
| return ps.getId().toRefName(); |
| } |
| }).toArray(new String[all.size()])); |
| } catch (IOException e) { |
| error("error reading refs", e); |
| refs = Collections.emptyMap(); |
| } |
| |
| for (PatchSet ps : all) { |
| // Check revision format. |
| int psNum = ps.getId().get(); |
| String refName = ps.getId().toRefName(); |
| ObjectId objId = |
| parseObjectId(ps.getRevision().get(), "patch set " + psNum); |
| if (objId == null) { |
| continue; |
| } |
| patchSetsBySha.put(objId, ps); |
| |
| // Check ref existence. |
| ProblemInfo refProblem = null; |
| Ref ref = refs.get(refName); |
| if (ref == null) { |
| refProblem = problem("Ref missing: " + refName); |
| } else if (!objId.equals(ref.getObjectId())) { |
| String actual = ref.getObjectId() != null |
| ? ref.getObjectId().name() |
| : "null"; |
| refProblem = problem(String.format( |
| "Expected %s to point to %s, found %s", |
| ref.getName(), objId.name(), actual)); |
| } |
| |
| // Check object existence. |
| RevCommit psCommit = parseCommit( |
| objId, String.format("patch set %d", psNum)); |
| if (psCommit == null) { |
| if (fix != null && fix.deletePatchSetIfCommitMissing) { |
| deletePatchSet(lastProblem(), ps.getId()); |
| } |
| continue; |
| } else if (refProblem != null && fix != null) { |
| fixPatchSetRef(refProblem, ps); |
| } |
| if (ps.getId().equals(change.currentPatchSetId())) { |
| currPsCommit = psCommit; |
| } |
| } |
| |
| // Check for duplicates. |
| for (Map.Entry<ObjectId, Collection<PatchSet>> e |
| : patchSetsBySha.asMap().entrySet()) { |
| if (e.getValue().size() > 1) { |
| problem(String.format("Multiple patch sets pointing to %s: %s", |
| e.getKey().name(), |
| Collections2.transform(e.getValue(), TO_PS_ID))); |
| } |
| } |
| |
| return currPs != null && currPsCommit != null; |
| } |
| |
| private void checkMerged() { |
| String refName = change.getDest().get(); |
| Ref dest; |
| try { |
| dest = repo.getRefDatabase().exactRef(refName); |
| } catch (IOException e) { |
| problem("Failed to look up destination ref: " + refName); |
| return; |
| } |
| if (dest == null) { |
| problem("Destination ref not found (may be new branch): " + refName); |
| return; |
| } |
| tip = parseCommit(dest.getObjectId(), |
| "destination ref " + refName); |
| if (tip == null) { |
| return; |
| } |
| |
| if (fix != null && fix.expectMergedAs != null) { |
| checkExpectMergedAs(); |
| } else { |
| boolean merged; |
| try { |
| merged = rw.isMergedInto(currPsCommit, tip); |
| } catch (IOException e) { |
| problem("Error checking whether patch set " + currPs.getId().get() |
| + " is merged"); |
| return; |
| } |
| checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged); |
| } |
| } |
| |
| private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, |
| boolean merged) { |
| String refName = change.getDest().get(); |
| if (merged && change.getStatus() != Change.Status.MERGED) { |
| ProblemInfo p = problem(String.format( |
| "Patch set %d (%s) is merged into destination ref %s (%s), but change" |
| + " status is %s", psId.get(), commit.name(), |
| refName, tip.name(), change.getStatus())); |
| if (fix != null) { |
| fixMerged(p); |
| } |
| } else if (!merged && change.getStatus() == Change.Status.MERGED) { |
| problem(String.format("Patch set %d (%s) is not merged into" |
| + " destination ref %s (%s), but change status is %s", |
| currPs.getId().get(), commit.name(), refName, tip.name(), |
| change.getStatus())); |
| } |
| } |
| |
| private void checkExpectMergedAs() { |
| ObjectId objId = |
| parseObjectId(fix.expectMergedAs, "expected merged commit"); |
| RevCommit commit = parseCommit(objId, "expected merged commit"); |
| if (commit == null) { |
| return; |
| } |
| if (Objects.equals(commit, currPsCommit)) { |
| // Caller gave us latest patch set SHA-1; verified in checkPatchSets. |
| return; |
| } |
| |
| try { |
| if (!rw.isMergedInto(commit, tip)) { |
| problem(String.format("Expected merged commit %s is not merged into" |
| + " destination ref %s (%s)", |
| commit.name(), change.getDest().get(), tip.name())); |
| return; |
| } |
| |
| RevId revId = new RevId(commit.name()); |
| List<PatchSet> patchSets = FluentIterable |
| .from(db.get().patchSets().byRevision(revId)) |
| .filter(new Predicate<PatchSet>() { |
| @Override |
| public boolean apply(PatchSet ps) { |
| try { |
| Change c = db.get().changes().get(ps.getId().getParentKey()); |
| return c != null && c.getDest().equals(change.getDest()); |
| } catch (OrmException e) { |
| warn(e); |
| return true; // Should cause an error below, that's good. |
| } |
| } |
| }).toSortedList(ChangeUtil.PS_ID_ORDER); |
| switch (patchSets.size()) { |
| case 0: |
| // No patch set for this commit; insert one. |
| rw.parseBody(commit); |
| String changeId = Iterables.getFirst( |
| commit.getFooterLines(FooterConstants.CHANGE_ID), null); |
| // Missing Change-Id footer is ok, but mismatched is not. |
| if (changeId != null && !changeId.equals(change.getKey().get())) { |
| problem(String.format("Expected merged commit %s has Change-Id: %s," |
| + " but expected %s", |
| commit.name(), changeId, change.getKey().get())); |
| return; |
| } |
| PatchSet.Id psId = insertPatchSet(commit); |
| if (psId != null) { |
| checkMergedBitMatchesStatus(psId, commit, true); |
| } |
| break; |
| |
| case 1: |
| // Existing patch set of this commit; check that it is the current |
| // patch set. |
| // TODO(dborowitz): This could be fixed if it's an older patch set of |
| // the current change. |
| PatchSet.Id id = patchSets.get(0).getId(); |
| if (!id.equals(change.currentPatchSetId())) { |
| problem(String.format("Expected merged commit %s corresponds to" |
| + " patch set %s, which is not the current patch set %s", |
| commit.name(), id, change.currentPatchSetId())); |
| } |
| break; |
| |
| default: |
| problem(String.format( |
| "Multiple patch sets for expected merged commit %s: %s", |
| commit.name(), patchSets)); |
| break; |
| } |
| } catch (OrmException | IOException e) { |
| error("Error looking up expected merged commit " + fix.expectMergedAs, |
| e); |
| } |
| } |
| |
| private PatchSet.Id insertPatchSet(RevCommit commit) { |
| ProblemInfo p = |
| problem("No patch set found for merged commit " + commit.name()); |
| if (!user.get().isIdentifiedUser()) { |
| p.status = Status.FIX_FAILED; |
| p.outcome = |
| "Must be called by an identified user to insert new patch set"; |
| return null; |
| } |
| |
| try { |
| RefControl ctl = projectControlFactory |
| .controlFor(change.getProject(), user.get()) |
| .controlForRef(change.getDest()); |
| PatchSet.Id psId = |
| ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId()); |
| PatchSetInserter inserter = |
| patchSetInserterFactory.create(ctl, psId, commit); |
| try (BatchUpdate bu = updateFactory.create( |
| db.get(), change.getProject(), ctl.getUser(), TimeUtil.nowTs()); |
| ObjectInserter oi = repo.newObjectInserter()) { |
| bu.setRepository(repo, rw, oi); |
| bu.addOp(change.getId(), inserter |
| .setValidatePolicy(CommitValidators.Policy.NONE) |
| .setRunHooks(false) |
| .setSendMail(false) |
| .setAllowClosed(true) |
| .setUploader(user.get().getAccountId()) |
| .setMessage( |
| "Patch set for merged commit inserted by consistency checker")); |
| bu.execute(); |
| } |
| change = inserter.getChange(); |
| p.status = Status.FIXED; |
| p.outcome = "Inserted as patch set " + psId.get(); |
| return psId; |
| } catch (IOException | NoSuchProjectException | UpdateException |
| | RestApiException e) { |
| warn(e); |
| p.status = Status.FIX_FAILED; |
| p.outcome = "Error inserting new patch set"; |
| return null; |
| } |
| } |
| |
| private void fixMerged(ProblemInfo p) { |
| try { |
| change = db.get().changes().atomicUpdate(change.getId(), |
| new AtomicUpdate<Change>() { |
| @Override |
| public Change update(Change c) { |
| c.setStatus(Change.Status.MERGED); |
| return c; |
| } |
| }); |
| p.status = Status.FIXED; |
| p.outcome = "Marked change as merged"; |
| } catch (OrmException e) { |
| log.warn("Error marking " + change.getId() + "as merged", e); |
| p.status = Status.FIX_FAILED; |
| p.outcome = "Error updating status to merged"; |
| } |
| } |
| |
| private void fixPatchSetRef(ProblemInfo p, PatchSet ps) { |
| try { |
| RefUpdate ru = repo.updateRef(ps.getId().toRefName()); |
| ru.setForceUpdate(true); |
| ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get())); |
| ru.setRefLogIdent(newRefLogIdent()); |
| ru.setRefLogMessage("Repair patch set ref", true); |
| RefUpdate.Result result = ru.update(); |
| switch (result) { |
| case NEW: |
| case FORCED: |
| case FAST_FORWARD: |
| case NO_CHANGE: |
| p.status = Status.FIXED; |
| p.outcome = "Repaired patch set ref"; |
| return; |
| default: |
| p.status = Status.FIX_FAILED; |
| p.outcome = "Failed to update patch set ref: " + result; |
| return; |
| } |
| } catch (IOException e) { |
| String msg = "Error fixing patch set ref"; |
| log.warn(msg + ' ' + ps.getId().toRefName(), e); |
| p.status = Status.FIX_FAILED; |
| p.outcome = msg; |
| } |
| } |
| |
| private void deletePatchSet(ProblemInfo p, PatchSet.Id psId) { |
| ReviewDb db = this.db.get(); |
| Change.Id cid = psId.getParentKey(); |
| try { |
| db.changes().beginTransaction(cid); |
| try { |
| Change c = db.changes().get(cid); |
| if (c == null) { |
| throw new OrmException("Change missing: " + cid); |
| } |
| |
| if (psId.equals(c.currentPatchSetId())) { |
| List<PatchSet> all = Lists.newArrayList(db.patchSets().byChange(cid)); |
| if (all.size() == 1 && all.get(0).getId().equals(psId)) { |
| p.status = Status.FIX_FAILED; |
| p.outcome = "Cannot delete patch set; no patch sets would remain"; |
| return; |
| } |
| // If there were multiple missing patch sets, assumes deletePatchSet |
| // has been called in decreasing order, so the max remaining PatchSet |
| // is the effective current patch set. |
| Collections.sort(all, PS_ID_ORDER.reverse()); |
| PatchSet.Id latest = null; |
| for (PatchSet ps : all) { |
| latest = ps.getId(); |
| if (!ps.getId().equals(psId)) { |
| break; |
| } |
| } |
| c.setCurrentPatchSet(patchSetInfoFactory.get(db, latest)); |
| db.changes().update(Collections.singleton(c)); |
| } |
| |
| // Delete dangling primary key references. Don't delete ChangeMessages, |
| // which don't use patch sets as a primary key, and may provide useful |
| // historical information. |
| db.accountPatchReviews().delete( |
| db.accountPatchReviews().byPatchSet(psId)); |
| db.patchSetApprovals().delete( |
| db.patchSetApprovals().byPatchSet(psId)); |
| db.patchComments().delete( |
| db.patchComments().byPatchSet(psId)); |
| db.patchSets().deleteKeys(Collections.singleton(psId)); |
| db.commit(); |
| |
| p.status = Status.FIXED; |
| p.outcome = "Deleted patch set"; |
| } finally { |
| db.rollback(); |
| } |
| } catch (PatchSetInfoNotAvailableException | OrmException e) { |
| String msg = "Error deleting patch set"; |
| log.warn(msg + ' ' + psId, e); |
| p.status = Status.FIX_FAILED; |
| p.outcome = msg; |
| } |
| } |
| |
| private PersonIdent newRefLogIdent() { |
| CurrentUser u = user.get(); |
| if (u.isIdentifiedUser()) { |
| return u.asIdentifiedUser().newRefLogIdent(); |
| } else { |
| return serverIdent.get(); |
| } |
| } |
| |
| private ObjectId parseObjectId(String objIdStr, String desc) { |
| try { |
| return ObjectId.fromString(objIdStr); |
| } catch (IllegalArgumentException e) { |
| problem(String.format("Invalid revision on %s: %s", desc, objIdStr)); |
| return null; |
| } |
| } |
| |
| private RevCommit parseCommit(ObjectId objId, String desc) { |
| try { |
| return rw.parseCommit(objId); |
| } catch (MissingObjectException e) { |
| problem(String.format("Object missing: %s: %s", desc, objId.name())); |
| } catch (IncorrectObjectTypeException e) { |
| problem(String.format("Not a commit: %s: %s", desc, objId.name())); |
| } catch (IOException e) { |
| problem(String.format("Failed to look up: %s: %s", desc, objId.name())); |
| } |
| return null; |
| } |
| |
| private ProblemInfo problem(String msg) { |
| ProblemInfo p = new ProblemInfo(); |
| p.message = msg; |
| problems.add(p); |
| return p; |
| } |
| |
| private ProblemInfo lastProblem() { |
| return problems.get(problems.size() - 1); |
| } |
| |
| private boolean error(String msg, Throwable t) { |
| problem(msg); |
| // TODO(dborowitz): Expose stack trace to administrators. |
| warn(t); |
| return false; |
| } |
| |
| private void warn(Throwable t) { |
| log.warn("Error in consistency check of change " + change.getId(), t); |
| } |
| } |