All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.change.ConsistencyChecker Maven / Gradle / Ivy

There is a newer version: 3.11.0-rc3
Show newest version
// 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.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;

import com.google.auto.value.AutoValue;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmissionId;
import com.google.gerrit.exceptions.StorageException;
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.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.Accounts;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.PatchSetState;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
import com.google.gerrit.server.plugincontext.PluginItemContext;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.RepoContext;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.eclipse.jgit.errors.ConfigInvalidException;
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;

/**
 * Checks changes for various kinds of inconsistency and corruption.
 *
 * 

A single instance may be reused for checking multiple changes, but not concurrently. */ public class ConsistencyChecker { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); @AutoValue public abstract static class Result { private static Result create(ChangeNotes notes, List problems) { return new AutoValue_ConsistencyChecker_Result( notes.getChangeId(), notes.getChange(), ImmutableList.copyOf(problems)); } public abstract Change.Id id(); @Nullable public abstract Change change(); public abstract ImmutableList problems(); } private final ChangeNotes.Factory notesFactory; private final Accounts accounts; private final PluginItemContext accountPatchReviewStore; private final GitRepositoryManager repoManager; private final PatchSetInfoFactory patchSetInfoFactory; private final PatchSetInserter.Factory patchSetInserterFactory; private final PatchSetUtil psUtil; private final Provider user; private final Provider serverIdent; private final RetryHelper retryHelper; private final ChangeUtil changeUtil; private BatchUpdate.Factory updateFactory; private FixInput fix; private ChangeNotes notes; private Repository repo; private RevWalk rw; private ObjectInserter oi; private RevCommit tip; private SetMultimap patchSetsBySha; private PatchSet currPs; private RevCommit currPsCommit; private List problems; @Inject ConsistencyChecker( @GerritPersonIdent Provider serverIdent, ChangeNotes.Factory notesFactory, Accounts accounts, PluginItemContext accountPatchReviewStore, GitRepositoryManager repoManager, PatchSetInfoFactory patchSetInfoFactory, PatchSetInserter.Factory patchSetInserterFactory, PatchSetUtil psUtil, Provider user, RetryHelper retryHelper, ChangeUtil changeUtil) { this.accounts = accounts; this.accountPatchReviewStore = accountPatchReviewStore; this.notesFactory = notesFactory; this.patchSetInfoFactory = patchSetInfoFactory; this.patchSetInserterFactory = patchSetInserterFactory; this.psUtil = psUtil; this.repoManager = repoManager; this.retryHelper = retryHelper; this.serverIdent = serverIdent; this.user = user; this.changeUtil = changeUtil; reset(); } private void reset() { updateFactory = null; notes = null; repo = null; rw = null; problems = new ArrayList<>(); } private Change change() { return notes.getChange(); } public Result check(ChangeNotes notes, @Nullable FixInput f) { requireNonNull(notes); try { try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { return retryHelper .changeUpdate( "checkChangeConsistency", buf -> { try { reset(); this.updateFactory = buf; this.notes = notes; fix = f; checkImpl(); return result(); } finally { if (rw != null) { rw.getObjectReader().close(); rw.close(); oi.close(); } if (repo != null) { repo.close(); } } }) .call(); } } catch (RestApiException e) { return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage()); } catch (UpdateException e) { return logAndReturnOneProblem(e, notes, "Error checking change"); } } private Result logAndReturnOneProblem(Exception e, ChangeNotes notes, String problem) { logger.atWarning().withCause(e).log("Error checking change %s", notes.getChangeId()); return Result.create(notes, ImmutableList.of(problem(problem))); } private void checkImpl() { checkOwner(); checkCurrentPatchSetEntity(); // All checks that require the repo. if (!openRepo()) { return; } if (!checkPatchSets()) { return; } checkMerged(); } private void checkOwner() { try { if (!accounts.get(change().getOwner()).isPresent()) { problem("Missing change owner: " + change().getOwner()); } } catch (IOException | ConfigInvalidException e) { ProblemInfo problem = problem("Failed to look up owner"); logger.atWarning().withCause(e).log( "Error in consistency check of change %s: %s", notes.getChangeId(), problem); } } private void checkCurrentPatchSetEntity() { try { currPs = psUtil.current(notes); if (currPs == null) { problem( String.format("Current patch set %d not found", change().currentPatchSetId().get())); } } catch (StorageException e) { ProblemInfo problem = problem("Failed to look up current patch set"); logger.atWarning().withCause(e).log( "Error in consistency check of change %s: %s", notes.getChangeId(), problem); } } private boolean openRepo() { Project.NameKey project = change().getDest().project(); try { repo = repoManager.openRepository(project); oi = repo.newObjectInserter(); rw = new RevWalk(oi.newReader()); return true; } catch (RepositoryNotFoundException e) { ProblemInfo problem = problem("Destination repository not found: " + project); logger.atWarning().withCause(e).log( "Error in consistency check of change %s: %s", notes.getChangeId(), problem); return false; } catch (IOException e) { ProblemInfo problem = problem("Failed to open repository: " + project); logger.atWarning().withCause(e).log( "Error in consistency check of change %s: %s", notes.getChangeId(), problem); return false; } } private boolean checkPatchSets() { List all; try { // Iterate in descending order. all = PS_ID_ORDER.sortedCopy(psUtil.byChange(notes)); } catch (StorageException e) { ProblemInfo problem = problem("Failed to look up patch sets"); logger.atWarning().withCause(e).log( "Error in consistency check of change %s: %s", notes.getChangeId(), problem); return false; } patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build(); Map refs; try { refs = repo.getRefDatabase() .exactRef(all.stream().map(ps -> ps.id().toRefName()).toArray(String[]::new)); } catch (IOException e) { ProblemInfo problem = problem("Error reading refs"); logger.atWarning().withCause(e).log( "Error in consistency check of change %s: %s", notes.getChangeId(), problem); refs = Collections.emptyMap(); } List deletePatchSetOps = new ArrayList<>(); for (PatchSet ps : all) { // Check revision format. int psNum = ps.id().get(); String refName = ps.id().toRefName(); ObjectId objId = ps.commitId(); 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) { deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.id())); } continue; } else if (refProblem != null && fix != null) { fixPatchSetRef(refProblem, ps); } if (ps.id().equals(change().currentPatchSetId())) { currPsCommit = psCommit; } } // Delete any bad patch sets found above, in a single update. deletePatchSets(deletePatchSetOps); // Check for duplicates. for (Map.Entry> 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(), PatchSet::number))); } } return currPs != null && currPsCommit != null; } private void checkMerged() { String refName = change().getDest().branch(); 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.id().get() + " is merged"); return; } checkMergedBitMatchesStatus(currPs.id(), currPsCommit, merged); } } private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) { String refName = change().getDest().branch(); return problem( formatProblemMessage( "Patch set %d (%s) is merged into destination ref %s (%s), but change" + " status is %s", psId.get(), commit.name(), refName, tip.name())); } private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) { String refName = change().getDest().branch(); if (merged && !change().isMerged()) { ProblemInfo p = wrongChangeStatus(psId, commit); if (fix != null) { fixMerged(p); } } else if (!merged && change().isMerged()) { problem( formatProblemMessage( "Patch set %d (%s) is not merged into" + " destination ref %s (%s), but change status is %s", currPs.id().get(), commit.name(), refName, tip.name())); } } private String formatProblemMessage( String message, int psId, String commitName, String refName, String tipName) { return String.format( message, psId, commitName, refName, tipName, ChangeUtil.status(change()).toUpperCase(Locale.US)); } private void checkExpectMergedAs() { if (!ObjectId.isId(fix.expectMergedAs)) { problem("Invalid revision on expected merged commit: " + fix.expectMergedAs); return; } ObjectId objId = ObjectId.fromString(fix.expectMergedAs); RevCommit commit = parseCommit(objId, "expected merged commit"); if (commit == null) { 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().branch(), tip.name())); return; } List thisCommitPsIds = new ArrayList<>(); for (Ref ref : repo.getRefDatabase().getRefsByPrefix(REFS_CHANGES)) { if (!ref.getObjectId().equals(commit)) { continue; } PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName()); if (psId == null) { continue; } try { Change c = notesFactory.createChecked(change().getProject(), psId.changeId()).getChange(); if (!c.getDest().equals(change().getDest())) { continue; } } catch (StorageException e) { logger.atWarning().withCause(e).log( "Error in consistency check of change %s", notes.getChangeId()); // Include this patch set; should cause an error below, which is good. } thisCommitPsIds.add(psId); } switch (thisCommitPsIds.size()) { case 0: // No patch set for this commit; insert one. rw.parseBody(commit); String changeId = Iterables.getFirst(changeUtil.getChangeIdsFromFooter(commit), 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; } insertMergedPatchSet(commit, null, false); break; case 1: // Existing patch set ref pointing to this commit. PatchSet.Id id = thisCommitPsIds.get(0); if (id.equals(change().currentPatchSetId())) { // If it's the current patch set, we can just fix the status. fixMerged(wrongChangeStatus(id, commit)); } else if (id.get() > change().currentPatchSetId().get()) { // If it's newer than the current patch set, reuse this patch set // ID when inserting a new merged patch set. insertMergedPatchSet(commit, id, true); } else { // If it's older than the current patch set, just delete the old // ref, and use a new ID when inserting a new merged patch set. insertMergedPatchSet(commit, id, false); } break; default: problem( String.format( "Multiple patch sets for expected merged commit %s: %s", commit.name(), thisCommitPsIds.stream() .sorted(comparing(PatchSet.Id::get)) .collect(toImmutableList()))); break; } } catch (IOException e) { ProblemInfo problem = problem("Error looking up expected merged commit " + fix.expectMergedAs); logger.atWarning().withCause(e).log( "Error in consistency check of change %s: %s", notes.getChangeId(), problem); } } private void insertMergedPatchSet( final RevCommit commit, @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) { ProblemInfo notFound = problem("No patch set found for merged commit " + commit.name()); if (!user.get().isIdentifiedUser()) { notFound.status = Status.FIX_FAILED; notFound.outcome = "Must be called by an identified user to insert new patch set"; return; } ProblemInfo insertPatchSetProblem; ProblemInfo deleteOldPatchSetProblem; if (psIdToDelete == null) { insertPatchSetProblem = problem( String.format( "Expected merged commit %s has no associated patch set", commit.name())); deleteOldPatchSetProblem = null; } else { String msg = String.format( "Expected merge commit %s corresponds to patch set %s," + " not the current patch set %s", commit.name(), psIdToDelete.get(), change().currentPatchSetId().get()); // Maybe an identical problem, but different fix. deleteOldPatchSetProblem = reuseOldPsId ? null : problem(msg); insertPatchSetProblem = problem(msg); } List currProblems = new ArrayList<>(3); currProblems.add(notFound); if (deleteOldPatchSetProblem != null) { currProblems.add(deleteOldPatchSetProblem); } currProblems.add(insertPatchSetProblem); try { PatchSet.Id psId = (psIdToDelete != null && reuseOldPsId) ? psIdToDelete : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId()); PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, commit); try (BatchUpdate bu = newBatchUpdate()) { bu.setRepository(repo, rw, oi); if (psIdToDelete != null) { // Delete the given patch set ref. If reuseOldPsId is true, // PatchSetInserter will reinsert the same ref, making it a no-op. bu.addOp( notes.getChangeId(), new BatchUpdateOp() { @Override public void updateRepo(RepoContext ctx) throws IOException { ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName()); } }); if (!reuseOldPsId) { bu.addOp( notes.getChangeId(), new DeletePatchSetFromDbOp(requireNonNull(deleteOldPatchSetProblem), psIdToDelete)); } } bu.setNotify(NotifyResolver.Result.none()); bu.addOp( notes.getChangeId(), inserter .setValidate(false) .setFireRevisionCreated(false) .setAllowClosed(true) .setMessage("Patch set for merged commit inserted by consistency checker")); bu.addOp(notes.getChangeId(), new FixMergedOp(notFound)); bu.execute(); } notes = notesFactory.createChecked(inserter.getChange()); insertPatchSetProblem.status = Status.FIXED; insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get(); } catch (StorageException | IOException | UpdateException | RestApiException e) { logger.atWarning().withCause(e).log( "Error in consistency check of change %s", notes.getChangeId()); for (ProblemInfo pi : currProblems) { pi.status = Status.FIX_FAILED; pi.outcome = "Error inserting merged patch set"; } return; } } private static class FixMergedOp implements BatchUpdateOp { private final ProblemInfo p; private FixMergedOp(ProblemInfo p) { this.p = p; } @Override public boolean updateChange(ChangeContext ctx) { ctx.getChange().setStatus(Change.Status.MERGED); ctx.getUpdate(ctx.getChange().currentPatchSetId()) .fixStatusToMerged(new SubmissionId(ctx.getChange())); p.status = Status.FIXED; p.outcome = "Marked change as merged"; return true; } } private void fixMerged(ProblemInfo p) { try (BatchUpdate bu = newBatchUpdate()) { bu.setRepository(repo, rw, oi); bu.addOp(notes.getChangeId(), new FixMergedOp(p)); bu.execute(); } catch (UpdateException | RestApiException e) { logger.atWarning().withCause(e).log("Error marking %s as merged", notes.getChangeId()); p.status = Status.FIX_FAILED; p.outcome = "Error updating status to merged"; } } private BatchUpdate newBatchUpdate() { return updateFactory.create(change().getProject(), user.get(), TimeUtil.now()); } private void fixPatchSetRef(ProblemInfo p, PatchSet ps) { try { RefUpdate ru = repo.updateRef(ps.id().toRefName()); ru.setForceUpdate(true); ru.setNewObjectId(ps.commitId()); 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; case IO_FAILURE: case LOCK_FAILURE: case NOT_ATTEMPTED: case REJECTED: case REJECTED_CURRENT_BRANCH: case RENAMED: case REJECTED_MISSING_OBJECT: case REJECTED_OTHER_REASON: 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"; logger.atWarning().withCause(e).log("%s %s", msg, ps.id().toRefName()); p.status = Status.FIX_FAILED; p.outcome = msg; } } private void deletePatchSets(List ops) { try (BatchUpdate bu = newBatchUpdate()) { bu.setRepository(repo, rw, oi); for (DeletePatchSetFromDbOp op : ops) { checkArgument(op.psId.changeId().equals(notes.getChangeId())); bu.addOp(notes.getChangeId(), op); } bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops)); bu.execute(); } catch (NoPatchSetsWouldRemainException e) { for (DeletePatchSetFromDbOp op : ops) { op.p.status = Status.FIX_FAILED; op.p.outcome = e.getMessage(); } } catch (UpdateException | RestApiException e) { String msg = "Error deleting patch set"; logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.changeId()); for (DeletePatchSetFromDbOp op : ops) { // Overwrite existing statuses that were set before the transaction was // rolled back. op.p.status = Status.FIX_FAILED; op.p.outcome = msg; } } } private class DeletePatchSetFromDbOp implements BatchUpdateOp { private final ProblemInfo p; private final PatchSet.Id psId; private DeletePatchSetFromDbOp(ProblemInfo p, PatchSet.Id psId) { this.p = p; this.psId = psId; } @Override public boolean updateChange(ChangeContext ctx) throws PatchSetInfoNotAvailableException { // Delete dangling key references. accountPatchReviewStore.run(s -> s.clearReviewed(psId)); // For NoteDb setting the state to deleted is sufficient to filter everything out. ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED); p.status = Status.FIXED; p.outcome = "Deleted patch set"; return true; } } private static class NoPatchSetsWouldRemainException extends RestApiException { private static final long serialVersionUID = 1L; private NoPatchSetsWouldRemainException() { super("Cannot delete patch set; no patch sets would remain"); } } private class UpdateCurrentPatchSetOp implements BatchUpdateOp { private final Set toDelete; private UpdateCurrentPatchSetOp(List deleteOps) { toDelete = new HashSet<>(); for (DeletePatchSetFromDbOp op : deleteOps) { toDelete.add(op.psId); } } @Override public boolean updateChange(ChangeContext ctx) throws PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException { if (!toDelete.contains(ctx.getChange().currentPatchSetId())) { return false; } TreeSet all = new TreeSet<>(comparing(PatchSet.Id::get)); // Doesn't make any assumptions about the order in which deletes happen // and whether they are seen by this op; we are already given the full set // of patch sets that will eventually be deleted in this update. for (PatchSet ps : psUtil.byChange(ctx.getNotes())) { if (!toDelete.contains(ps.id())) { all.add(ps.id()); } } if (all.isEmpty()) { throw new NoPatchSetsWouldRemainException(); } ctx.getChange().setCurrentPatchSet(patchSetInfoFactory.get(ctx.getNotes(), all.last())); return true; } } private PersonIdent newRefLogIdent() { CurrentUser u = user.get(); if (u.isIdentifiedUser()) { return u.asIdentifiedUser().newRefLogIdent(); } return serverIdent.get(); } @Nullable 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; } @CanIgnoreReturnValue private ProblemInfo problem(String msg) { ProblemInfo p = new ProblemInfo(); p.message = requireNonNull(msg); problems.add(p); return p; } private ProblemInfo lastProblem() { return problems.get(problems.size() - 1); } private Result result() { return Result.create(notes, problems); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy