You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by no...@apache.org on 2020/06/25 03:35:03 UTC

[lucene-solr] branch branch_8x updated: SOLR-14404 CoreContainer level custom requesthandlers

This is an automated email from the ASF dual-hosted git repository.

noble pushed a commit to branch branch_8x
in repository https://gitbox.apache.org/repos/asf/lucene-solr.git


The following commit(s) were added to refs/heads/branch_8x by this push:
     new 8966a1e  SOLR-14404 CoreContainer level custom requesthandlers
8966a1e is described below

commit 8966a1e36529debc7331320a69ed1bccc12d3e9a
Author: Noble Paul <no...@users.noreply.github.com>
AuthorDate: Thu Jun 25 13:08:51 2020 +1000

    SOLR-14404 CoreContainer level custom requesthandlers
---
 solr/CHANGES.txt                                   |   2 +
 .../src/java/org/apache/solr/api/AnnotatedApi.java | 113 +++++---
 solr/core/src/java/org/apache/solr/api/ApiBag.java |   9 +
 .../apache/solr/api/CustomContainerPlugins.java    | 313 +++++++++++++++++++++
 .../src/java/org/apache/solr/api/PayloadObj.java   |  35 ++-
 .../src/java/org/apache/solr/api/V2HttpCall.java   |  29 +-
 .../java/org/apache/solr/core/CoreContainer.java   |  24 +-
 .../src/java/org/apache/solr/core/PluginInfo.java  |   4 +-
 .../solr/handler/admin/ContainerPluginsApi.java    | 179 ++++++++++++
 .../java/org/apache/solr/pkg/PackageLoader.java    |   8 +
 .../runtimecode/MyPlugin.java}                     |  28 +-
 .../runtimecode/containerplugin.v.1.jar.bin        | Bin 0 -> 867 bytes
 .../runtimecode/containerplugin.v.2.jar.bin        | Bin 0 -> 867 bytes
 solr/core/src/test-files/runtimecode/sig.txt       |   8 +
 .../solr/filestore/TestDistribPackageStore.java    |  15 +-
 .../apache/solr/handler/TestContainerPlugin.java   | 305 ++++++++++++++++++++
 .../solr/handler/admin/TestApiFramework.java       |  34 +--
 .../src/test/org/apache/solr/pkg/TestPackages.java |   2 +-
 .../client/solrj/request/beans/PluginMeta.java     |  58 ++++
 .../java/org/apache/solr/common/util/PathTrie.java |  48 +++-
 .../org/apache/solr/common/util/TestPathTrie.java  |   8 +
 21 files changed, 1110 insertions(+), 112 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 5625b6b..fbc4425 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -26,6 +26,8 @@ New Features
 
 * SOLR-14470: Add streaming expressions to /export handler. (ab, Joel Bernstein)
 
+* SOLR-14404: CoreContainer level custom requesthandlers (noble)
+
 Improvements
 ---------------------
 * SOLR-14316: Remove unchecked type conversion warning in JavaBinCodec's readMapEntry's equals() method
diff --git a/solr/core/src/java/org/apache/solr/api/AnnotatedApi.java b/solr/core/src/java/org/apache/solr/api/AnnotatedApi.java
index f563472..b3d65d0 100644
--- a/solr/core/src/java/org/apache/solr/api/AnnotatedApi.java
+++ b/solr/core/src/java/org/apache/solr/api/AnnotatedApi.java
@@ -18,6 +18,8 @@
 package org.apache.solr.api;
 
 
+import java.io.Closeable;
+import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -58,7 +60,7 @@ import org.slf4j.LoggerFactory;
  * The third parameter is only valid if it is using a json command payload
  */
 
-public class AnnotatedApi extends Api implements PermissionNameProvider {
+public class AnnotatedApi extends Api implements PermissionNameProvider , Closeable {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
   public static final String ERR = "Error executing commands :";
@@ -67,11 +69,29 @@ public class AnnotatedApi extends Api implements PermissionNameProvider {
   private final Cmd singletonCommand;
   private final Api fallback;
 
+  @Override
+  public void close() throws IOException {
+    for (Cmd value : commands.values()) {
+      if (value.obj instanceof Closeable) {
+        ((Closeable) value.obj).close();
+      }
+      break;// all objects are same so close only one
+    }
+
+  }
+
+  public EndPoint getEndPoint() {
+    return endPoint;
+  }
+
   public static List<Api> getApis(Object obj) {
-    Class<? extends Object> klas = obj.getClass();
+    return getApis(obj.getClass(), obj);
+  }
+  public static List<Api> getApis(Class<? extends Object> klas , Object obj) {
     if (!Modifier.isPublic(klas.getModifiers())) {
-      throw new RuntimeException(obj.getClass().getName() + " is not public");
+      throw new RuntimeException(klas.getName() + " is not public");
     }
+
     if (klas.getAnnotation(EndPoint.class) != null) {
       EndPoint endPoint = klas.getAnnotation(EndPoint.class);
       List<Method> methods = new ArrayList<>();
@@ -87,7 +107,7 @@ public class AnnotatedApi extends Api implements PermissionNameProvider {
         }
       }
       if (commands.isEmpty()) {
-        throw new RuntimeException("No method with @Command in class: " + obj.getClass().getName());
+        throw new RuntimeException("No method with @Command in class: " + klas.getName());
       }
       SpecProvider specProvider = readSpec(endPoint, methods);
       return Collections.singletonList(new AnnotatedApi(specProvider, endPoint, commands, null));
@@ -104,8 +124,9 @@ public class AnnotatedApi extends Api implements PermissionNameProvider {
         apis.add(new AnnotatedApi(specProvider, endPoint, Collections.singletonMap("", cmd), null));
       }
       if (apis.isEmpty()) {
-        throw new RuntimeException("Invalid Class : " + obj.getClass().getName() + " No @EndPoints");
+        throw new RuntimeException("Invalid Class : " + klas.getName() + " No @EndPoints");
       }
+
       return apis;
     }
   }
@@ -207,30 +228,17 @@ public class AnnotatedApi extends Api implements PermissionNameProvider {
         this.method = method;
         Class<?>[] parameterTypes = method.getParameterTypes();
         paramsCount = parameterTypes.length;
-        if (parameterTypes[0] != SolrQueryRequest.class || parameterTypes[1] != SolrQueryResponse.class) {
-          throw new RuntimeException("Invalid params for method " + method);
-        }
-        if (parameterTypes.length == 3) {
-          Type t = method.getGenericParameterTypes()[2];
-          if (t instanceof ParameterizedType) {
-            ParameterizedType typ = (ParameterizedType) t;
-            if (typ.getRawType() == PayloadObj.class) {
-              isWrappedInPayloadObj = true;
-              Type t1 = typ.getActualTypeArguments()[0];
-              if (t1 instanceof ParameterizedType) {
-                ParameterizedType parameterizedType = (ParameterizedType) t1;
-                c = (Class) parameterizedType.getRawType();
-              } else {
-                c = (Class) typ.getActualTypeArguments()[0];
-              }
-            }
-          } else {
-            c = (Class) t;
+        if (parameterTypes.length == 1) {
+          readPayloadType(method.getGenericParameterTypes()[0]);
+        } else if (parameterTypes.length == 3) {
+          if (parameterTypes[0] != SolrQueryRequest.class || parameterTypes[1] != SolrQueryResponse.class) {
+            throw new RuntimeException("Invalid params for method " + method);
           }
+          Type t = method.getGenericParameterTypes()[2];
+          readPayloadType(t);
         }
         if (parameterTypes.length > 3) {
           throw new RuntimeException("Invalid params count for method " + method);
-
         }
       } else {
         throw new RuntimeException(method.toString() + " is not a public static method");
@@ -238,10 +246,43 @@ public class AnnotatedApi extends Api implements PermissionNameProvider {
 
     }
 
+    private void readPayloadType(Type t) {
+      if (t instanceof ParameterizedType) {
+        ParameterizedType typ = (ParameterizedType) t;
+        if (typ.getRawType() == PayloadObj.class) {
+          isWrappedInPayloadObj = true;
+          if(typ.getActualTypeArguments().length == 0){
+            //this is a raw type
+            c = Map.class;
+            return;
+          }
+          Type t1 = typ.getActualTypeArguments()[0];
+          if (t1 instanceof ParameterizedType) {
+            ParameterizedType parameterizedType = (ParameterizedType) t1;
+            c = (Class) parameterizedType.getRawType();
+          } else {
+            c = (Class) typ.getActualTypeArguments()[0];
+          }
+        }
+      } else {
+        c = (Class) t;
+      }
+    }
+
+
     @SuppressWarnings({"unchecked"})
     void invoke(SolrQueryRequest req, SolrQueryResponse rsp, CommandOperation cmd) {
       try {
-        if (paramsCount == 2) {
+        if(paramsCount ==1) {
+          Object o = cmd.getCommandData();
+          if (o instanceof Map && c != null && c != Map.class) {
+            o = mapper.readValue(Utils.toJSONString(o), c);
+          }
+          PayloadObj<Object> payloadObj = new PayloadObj<>(cmd.name, cmd.getCommandData(), o, req, rsp);
+          cmd = payloadObj;
+          method.invoke(obj, payloadObj);
+          checkForErrorInPayload(cmd);
+        } else if (paramsCount == 2) {
           method.invoke(obj, req, rsp);
         } else {
           Object o = cmd.getCommandData();
@@ -249,16 +290,13 @@ public class AnnotatedApi extends Api implements PermissionNameProvider {
             o = mapper.readValue(Utils.toJSONString(o), c);
           }
           if (isWrappedInPayloadObj) {
-            PayloadObj<Object> payloadObj = new PayloadObj<>(cmd.name, cmd.getCommandData(), o);
+            PayloadObj<Object> payloadObj = new PayloadObj<>(cmd.name, cmd.getCommandData(), o, req, rsp);
             cmd = payloadObj;
             method.invoke(obj, req, rsp, payloadObj);
           } else {
             method.invoke(obj, req, rsp, o);
           }
-          if (cmd.hasError()) {
-            throw new ApiBag.ExceptionWithErrObject(SolrException.ErrorCode.BAD_REQUEST, "Error executing command",
-                CommandOperation.captureErrors(Collections.singletonList(cmd)));
-          }
+          checkForErrorInPayload(cmd);
         }
 
 
@@ -274,12 +312,21 @@ public class AnnotatedApi extends Api implements PermissionNameProvider {
       }
 
     }
+
+    private void checkForErrorInPayload(CommandOperation cmd) {
+      if (cmd.hasError()) {
+        throw new ApiBag.ExceptionWithErrObject(SolrException.ErrorCode.BAD_REQUEST, "Error executing command",
+            CommandOperation.captureErrors(Collections.singletonList(cmd)));
+      }
+    }
   }
 
   public static Map<String, Object> createSchema(Method m) {
     Type[] types = m.getGenericParameterTypes();
-    if (types.length == 3) {
-      Type t = types[2];
+    Type t = null;
+    if (types.length == 3) t = types[2]; // (SolrQueryRequest req, SolrQueryResponse rsp, PayloadObj<PluginMeta>)
+    if(types.length == 1) t = types[0];// (PayloadObj<PluginMeta>)
+    if (t != null) {
       if (t instanceof ParameterizedType) {
         ParameterizedType typ = (ParameterizedType) t;
         if (typ.getRawType() == PayloadObj.class) {
diff --git a/solr/core/src/java/org/apache/solr/api/ApiBag.java b/solr/core/src/java/org/apache/solr/api/ApiBag.java
index 5740755..8b64829 100644
--- a/solr/core/src/java/org/apache/solr/api/ApiBag.java
+++ b/solr/core/src/java/org/apache/solr/api/ApiBag.java
@@ -31,6 +31,7 @@ import java.util.stream.Collectors;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import org.apache.solr.client.solrj.SolrRequest;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SpecProvider;
 import org.apache.solr.common.util.CommandOperation;
@@ -150,6 +151,14 @@ public class ApiBag {
     registry.insert(copy, substitutes, introspect);
   }
 
+  public synchronized Api unregister(SolrRequest.METHOD method, String path) {
+    List<String> l = PathTrie.getPathSegments(path);
+    List<String> introspectPath = new ArrayList<>(l);
+    introspectPath.add("_introspect");
+    getRegistry(method.toString()).remove(introspectPath);
+    return getRegistry(method.toString()).remove(l);
+  }
+
   public static class IntrospectApi extends Api {
     Api baseApi;
     final boolean isCoreSpecific;
diff --git a/solr/core/src/java/org/apache/solr/api/CustomContainerPlugins.java b/solr/core/src/java/org/apache/solr/api/CustomContainerPlugins.java
new file mode 100644
index 0000000..9246dac
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/api/CustomContainerPlugins.java
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.api;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.request.beans.PluginMeta;
+import org.apache.solr.common.annotation.JsonProperty;
+import org.apache.solr.common.cloud.ClusterPropertiesListener;
+import org.apache.solr.common.util.Pair;
+import org.apache.solr.common.util.PathTrie;
+import org.apache.solr.common.util.ReflectMapWriter;
+import org.apache.solr.common.util.StrUtils;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.admin.ContainerPluginsApi;
+import org.apache.solr.pkg.PackageLoader;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.util.SolrJacksonAnnotationInspector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.lucene.util.IOUtils.closeWhileHandlingException;
+
+public class CustomContainerPlugins implements ClusterPropertiesListener {
+  private final ObjectMapper mapper = SolrJacksonAnnotationInspector.createObjectMapper();
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  final CoreContainer coreContainer;
+  final ApiBag containerApiBag;
+
+  private final Map<String, ApiInfo> currentPlugins = new HashMap<>();
+
+  @Override
+  public boolean onChange(Map<String, Object> properties) {
+    refresh();
+    return false;
+  }
+  public CustomContainerPlugins(CoreContainer coreContainer, ApiBag apiBag) {
+    this.coreContainer = coreContainer;
+    this.containerApiBag = apiBag;
+  }
+
+  public synchronized void refresh() {
+    Map<String, Object> pluginInfos = null;
+    try {
+      pluginInfos = ContainerPluginsApi.plugins(coreContainer.zkClientSupplier);
+    } catch (IOException e) {
+      log.error("Could not read plugins data", e);
+      return;
+    }
+    Map<String,PluginMeta> newState = new HashMap<>(pluginInfos.size());
+    for (Map.Entry<String, Object> e : pluginInfos.entrySet()) {
+      try {
+        newState.put(e.getKey(),
+            mapper.readValue(Utils.toJSON(e.getValue()), PluginMeta.class));
+      } catch (Exception exp) {
+        log.error("Invalid apiInfo configuration :", exp);
+      }
+    }
+
+    Map<String, PluginMeta> currentState = new HashMap<>();
+    for (Map.Entry<String, ApiInfo> e : currentPlugins.entrySet()) {
+      currentState.put(e.getKey(), e.getValue().info);
+    }
+    Map<String, Diff> diff = compareMaps(currentState, newState);
+    if (diff == null) return;//nothing has changed
+    for (Map.Entry<String, Diff> e : diff.entrySet()) {
+      if (e.getValue() == Diff.UNCHANGED) continue;
+      if (e.getValue() == Diff.REMOVED) {
+        ApiInfo apiInfo = currentPlugins.remove(e.getKey());
+        if (apiInfo == null) continue;
+        for (ApiHolder holder : apiInfo.holders) {
+          Api old = containerApiBag.unregister(holder.api.getEndPoint().method()[0], holder.api.getEndPoint().path()[0]);
+          if (old instanceof Closeable) {
+            closeWhileHandlingException((Closeable) old);
+          }
+        }
+      } else {
+        //ADDED or UPDATED
+        PluginMeta info = newState.get(e.getKey());
+        ApiInfo apiInfo = null;
+        List<String> errs = new ArrayList<>();
+        apiInfo = new ApiInfo(info, errs);
+        if (!errs.isEmpty()) {
+          log.error(StrUtils.join(errs, ','));
+          continue;
+        }
+        try {
+          apiInfo.init();
+        } catch (Exception exp) {
+          log.error("Cannot install apiInfo ", exp);
+          continue;
+        }
+        if (e.getValue() == Diff.ADDED) {
+          for (ApiHolder holder : apiInfo.holders) {
+            containerApiBag.register(holder, Collections.singletonMap("plugin-name", e.getKey()));
+          }
+          currentPlugins.put(e.getKey(), apiInfo);
+        } else {
+          ApiInfo old = currentPlugins.put(e.getKey(), apiInfo);
+          List<ApiHolder> replaced = new ArrayList<>();
+          for (ApiHolder holder : apiInfo.holders) {
+            Api oldApi = containerApiBag.lookup(holder.getPath(),
+                holder.getMethod().toString(), null);
+            if (oldApi instanceof ApiHolder) {
+              replaced.add((ApiHolder) oldApi);
+            }
+            containerApiBag.register(holder, Collections.singletonMap("plugin-name", e.getKey()));
+          }
+          if (old != null) {
+            for (ApiHolder holder : old.holders) {
+              if (replaced.contains(holder)) continue;// this path is present in the new one as well. so it already got replaced
+              containerApiBag.unregister(holder.getMethod(), holder.getPath());
+            }
+            if (old instanceof Closeable) {
+              closeWhileHandlingException((Closeable) old);
+            }
+          }
+        }
+      }
+
+    }
+  }
+
+  private static class ApiHolder extends Api {
+    final AnnotatedApi api;
+
+    protected ApiHolder(AnnotatedApi api) {
+      super(api);
+      this.api = api;
+    }
+
+    @Override
+    public void call(SolrQueryRequest req, SolrQueryResponse rsp) {
+      api.call(req, rsp);
+    }
+
+    public String getPath(){
+      return api.getEndPoint().path()[0];
+    }
+
+    public SolrRequest.METHOD getMethod(){
+      return api.getEndPoint().method()[0];
+
+    }
+  }
+
+  @SuppressWarnings({"rawtypes"})
+  public class ApiInfo implements ReflectMapWriter {
+    List<ApiHolder> holders;
+    @JsonProperty
+    private final PluginMeta info;
+
+    @JsonProperty(value = "package")
+    public final String pkg;
+
+    private PackageLoader.Package.Version pkgVersion;
+    private Class klas;
+    Object instance;
+
+
+    @SuppressWarnings({"unchecked","rawtypes"})
+    public ApiInfo(PluginMeta info, List<String> errs) {
+      this.info = info;
+      Pair<String, String> klassInfo = org.apache.solr.core.PluginInfo.parseClassName(info.klass);
+      pkg = klassInfo.first();
+      if (pkg != null) {
+        PackageLoader.Package p = coreContainer.getPackageLoader().getPackage(pkg);
+        if (p == null) {
+          errs.add("Invalid package " + klassInfo.first());
+          return;
+        }
+        this.pkgVersion = p.getVersion(info.version);
+        if (pkgVersion == null) {
+          errs.add("No such package version:" + pkg + ":" + info.version + " . available versions :" + p.allVersions());
+          return;
+        }
+        try {
+          klas = pkgVersion.getLoader().findClass(klassInfo.second(), Object.class);
+        } catch (Exception e) {
+          log.error("Error loading class", e);
+          errs.add("Error loading class " + e.toString());
+          return;
+        }
+      } else {
+        try {
+          klas = Class.forName(klassInfo.second());
+        } catch (ClassNotFoundException e) {
+          errs.add("Error loading class " + e.toString());
+          return;
+        }
+        pkgVersion = null;
+      }
+      if (!Modifier.isPublic(klas.getModifiers())) {
+        errs.add("Class must be public and static : " + klas.getName());
+        return;
+      }
+
+      try {
+        List<Api> apis = AnnotatedApi.getApis(klas, null);
+        for (Object api : apis) {
+          EndPoint endPoint = ((AnnotatedApi) api).getEndPoint();
+          if (endPoint.path().length > 1 || endPoint.method().length > 1) {
+            errs.add("Only one HTTP method and url supported for each API");
+          }
+          if (endPoint.method().length != 1 || endPoint.path().length != 1) {
+            errs.add("The @EndPint must have exactly one method and path attributes");
+          }
+          List<String> pathSegments = StrUtils.splitSmart(endPoint.path()[0], '/', true);
+          PathTrie.replaceTemplates(pathSegments, Collections.singletonMap("plugin-name", info.name));
+          if (V2HttpCall.knownPrefixes.contains(pathSegments.get(0))) {
+            errs.add("path must not have a prefix: "+pathSegments.get(0));
+          }
+
+        }
+      } catch (Exception e) {
+        errs.add(e.toString());
+      }
+      if (!errs.isEmpty()) return;
+
+      Constructor constructor = klas.getConstructors()[0];
+      if (constructor.getParameterTypes().length > 1 ||
+          (constructor.getParameterTypes().length == 1 && constructor.getParameterTypes()[0] != CoreContainer.class)) {
+        errs.add("Must have a no-arg constructor or CoreContainer constructor and it must not be a non static inner class");
+        return;
+      }
+      if (!Modifier.isPublic(constructor.getModifiers())) {
+        errs.add("Must have a public constructor ");
+        return;
+      }
+    }
+
+    @SuppressWarnings({"rawtypes"})
+    public void init() throws Exception {
+      if (this.holders != null) return;
+      Constructor constructor = klas.getConstructors()[0];
+      if (constructor.getParameterTypes().length == 0) {
+        instance = constructor.newInstance();
+      } else if (constructor.getParameterTypes().length == 1 && constructor.getParameterTypes()[0] == CoreContainer.class) {
+        instance = constructor.newInstance(coreContainer);
+      } else {
+        throw new RuntimeException("Must have a no-arg constructor or CoreContainer constructor ");
+      }
+      this.holders = new ArrayList<>();
+      for (Api api : AnnotatedApi.getApis(instance)) {
+        holders.add(new ApiHolder((AnnotatedApi) api));
+      }
+    }
+  }
+
+  public ApiInfo createInfo(PluginMeta info, List<String> errs) {
+    return new ApiInfo(info, errs);
+
+  }
+
+  public enum Diff {
+    ADDED, REMOVED, UNCHANGED, UPDATED;
+  }
+
+  public static Map<String, Diff> compareMaps(Map<String,? extends Object> a, Map<String,? extends Object> b) {
+    if(a.isEmpty() && b.isEmpty()) return null;
+    Map<String, Diff> result = new HashMap<>(Math.max(a.size(), b.size()));
+    a.forEach((k, v) -> {
+      Object newVal = b.get(k);
+      if (newVal == null) {
+        result.put(k, Diff.REMOVED);
+        return;
+      }
+      result.put(k, Objects.equals(v, newVal) ?
+          Diff.UNCHANGED :
+          Diff.UPDATED);
+    });
+
+    b.forEach((k, v) -> {
+      if (a.get(k) == null) result.put(k, Diff.ADDED);
+    });
+
+    for (Diff value : result.values()) {
+      if (value != Diff.UNCHANGED) return result;
+    }
+
+    return null;
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/api/PayloadObj.java b/solr/core/src/java/org/apache/solr/api/PayloadObj.java
index c09c442..7941304 100644
--- a/solr/core/src/java/org/apache/solr/api/PayloadObj.java
+++ b/solr/core/src/java/org/apache/solr/api/PayloadObj.java
@@ -18,18 +18,35 @@
 package org.apache.solr.api;
 
 import org.apache.solr.common.util.CommandOperation;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
 
-public  class PayloadObj<T> extends CommandOperation {
+/**
+ * Holds the deserialized object for each command and also holds request , response objects
+ */
+public class PayloadObj<T> extends CommandOperation {
+
+    //the deserialized object parameter
+    private T obj;
+    final SolrQueryRequest req;
+    final SolrQueryResponse rsp;
 
-  private T obj;
+    public PayloadObj(String operationName, Object metaData, T obj, SolrQueryRequest req, SolrQueryResponse rsp) {
+        super(operationName, metaData);
+        this.obj = obj;
+        this.req = req;
+        this.rsp = rsp;
+    }
 
+    public T get() {
+        return obj;
+    }
 
-  public PayloadObj(String operationName, Object metaData, T obj) {
-    super(operationName, metaData);
-    this.obj = obj;
-  }
+    public SolrQueryRequest getRequest() {
+        return req;
+    }
 
-  public T get(){
-    return obj;
-  }
+    public SolrQueryResponse getResponse() {
+        return rsp;
+    }
 }
diff --git a/solr/core/src/java/org/apache/solr/api/V2HttpCall.java b/solr/core/src/java/org/apache/solr/api/V2HttpCall.java
index c55a08b..5eae502 100644
--- a/solr/core/src/java/org/apache/solr/api/V2HttpCall.java
+++ b/solr/core/src/java/org/apache/solr/api/V2HttpCall.java
@@ -17,18 +17,6 @@
 
 package org.apache.solr.api;
 
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.lang.invoke.MethodHandles;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Supplier;
-
 import com.google.common.collect.ImmutableSet;
 import org.apache.solr.client.solrj.SolrRequest;
 import org.apache.solr.common.SolrException;
@@ -53,11 +41,15 @@ import org.apache.solr.servlet.SolrRequestParsers;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.invoke.MethodHandles;
+import java.util.*;
+import java.util.function.Supplier;
+
 import static org.apache.solr.common.cloud.ZkStateReader.COLLECTION_PROP;
 import static org.apache.solr.common.util.PathTrie.getPathSegments;
-import static org.apache.solr.servlet.SolrDispatchFilter.Action.ADMIN;
-import static org.apache.solr.servlet.SolrDispatchFilter.Action.PROCESS;
-import static org.apache.solr.servlet.SolrDispatchFilter.Action.REMOTEQUERY;
+import static org.apache.solr.servlet.SolrDispatchFilter.Action.*;
 
 // class that handle the '/v2' path
 @SolrThreadSafe
@@ -130,6 +122,13 @@ public class V2HttpCall extends HttpSolrCall {
       } else if ("cores".equals(prefix)) {
         origCorename = pieces.get(1);
         core = cores.getCore(origCorename);
+      } else {
+        api = getApiInfo(cores.getRequestHandlers(), path, req.getMethod(), fullPath, parts);
+        if(api != null) {
+          //custom plugin
+          initAdminRequest(path);
+          return;
+        }
       }
       if (core == null) {
         log.error(">> path: '{}'", path);
diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
index bb13179..601fbaa 100644
--- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java
+++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
@@ -40,6 +40,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import java.util.function.Supplier;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableMap;
@@ -52,6 +53,7 @@ import org.apache.lucene.index.CorruptIndexException;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.search.BooleanQuery;
 import org.apache.lucene.store.Directory;
+import org.apache.solr.api.CustomContainerPlugins;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.cloud.SolrCloudManager;
 import org.apache.solr.client.solrj.embedded.EmbeddedSolrServer;
@@ -74,6 +76,7 @@ import org.apache.solr.common.SolrException.ErrorCode;
 import org.apache.solr.common.cloud.DocCollection;
 import org.apache.solr.common.cloud.Replica;
 import org.apache.solr.common.cloud.Replica.State;
+import org.apache.solr.common.cloud.SolrZkClient;
 import org.apache.solr.common.cloud.ZkStateReader;
 import org.apache.solr.common.util.ExecutorUtil;
 import org.apache.solr.common.util.IOUtils;
@@ -89,6 +92,7 @@ import org.apache.solr.handler.SnapShooter;
 import org.apache.solr.handler.admin.AutoscalingHistoryHandler;
 import org.apache.solr.handler.admin.CollectionsHandler;
 import org.apache.solr.handler.admin.ConfigSetsHandler;
+import org.apache.solr.handler.admin.ContainerPluginsApi;
 import org.apache.solr.handler.admin.CoreAdminHandler;
 import org.apache.solr.handler.admin.HealthCheckHandler;
 import org.apache.solr.handler.admin.InfoHandler;
@@ -164,6 +168,15 @@ public class CoreContainer {
     }
   }
 
+  private volatile PluginBag<SolrRequestHandler> containerHandlers = new PluginBag<>(SolrRequestHandler.class, null);
+
+  /**
+   * Minimize exposure to CoreContainer. Mostly only ZK interface is required
+   */
+  public final Supplier<SolrZkClient> zkClientSupplier = () -> getZkController().getZkClient();
+
+  private final CustomContainerPlugins customContainerPlugins =  new CustomContainerPlugins(this, containerHandlers.getApiBag());
+
   protected final Map<String, CoreLoadFailure> coreInitFailures = new ConcurrentHashMap<>();
 
   protected volatile CoreAdminHandler coreAdminHandler = null;
@@ -204,8 +217,6 @@ public class CoreContainer {
 
   private final BlobRepository blobRepository = new BlobRepository(this);
 
-  private volatile PluginBag<SolrRequestHandler> containerHandlers = new PluginBag<>(SolrRequestHandler.class, null);
-
   private volatile boolean asyncSolrCoreLoad;
 
   protected volatile SecurityConfHandler securityConfHandler;
@@ -862,6 +873,11 @@ public class CoreContainer {
     }
 
     if (isZooKeeperAware()) {
+      customContainerPlugins.refresh();
+      getZkController().zkStateReader.registerClusterPropertiesListener(customContainerPlugins);
+      ContainerPluginsApi containerPluginsApi = new ContainerPluginsApi(this);
+      containerHandlers.getApiBag().registerObject(containerPluginsApi.readAPI);
+      containerHandlers.getApiBag().registerObject(containerPluginsApi.editAPI);
       zkSys.getZkController().checkOverseerDesignate();
       // initialize this handler here when SolrCloudManager is ready
       autoScalingHandler = new AutoScalingHandler(getZkController().getSolrCloudManager(), loader);
@@ -2101,6 +2117,10 @@ public class CoreContainer {
     return tragicException != null;
   }
 
+  public CustomContainerPlugins getCustomContainerPlugins(){
+    return customContainerPlugins;
+  }
+
   static {
     ExecutorUtil.addThreadLocalProvider(SolrRequestInfo.getInheritableThreadLocalProvider());
   }
diff --git a/solr/core/src/java/org/apache/solr/core/PluginInfo.java b/solr/core/src/java/org/apache/solr/core/PluginInfo.java
index 428d72c..cc6615f 100644
--- a/solr/core/src/java/org/apache/solr/core/PluginInfo.java
+++ b/solr/core/src/java/org/apache/solr/core/PluginInfo.java
@@ -64,9 +64,9 @@ public class PluginInfo implements MapSerializable {
 
   /** class names can be prefixed with package name e.g: my_package:my.pkg.Class
    * This checks if it is a package name prefixed classname.
-   * the return value has first = package name & second = class name
+   * the return value has first = package name and second = class name
    */
-  static Pair<String,String > parseClassName(String name) {
+  public static Pair<String,String > parseClassName(String name) {
     String pkgName = null;
     String className = name;
     if (name != null) {
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/ContainerPluginsApi.java b/solr/core/src/java/org/apache/solr/handler/admin/ContainerPluginsApi.java
new file mode 100644
index 0000000..21c16e3
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/admin/ContainerPluginsApi.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.handler.admin;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.solr.api.AnnotatedApi;
+import org.apache.solr.api.Command;
+import org.apache.solr.api.CustomContainerPlugins;
+import org.apache.solr.api.EndPoint;
+import org.apache.solr.api.PayloadObj;
+import org.apache.solr.client.solrj.SolrRequest.METHOD;
+import org.apache.solr.client.solrj.request.beans.PluginMeta;
+import org.apache.solr.common.cloud.SolrZkClient;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.security.PermissionNameProvider;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.data.Stat;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.lucene.util.IOUtils.closeWhileHandlingException;
+
+
+public class ContainerPluginsApi {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  public static final String PLUGIN = "plugin";
+  private final Supplier<SolrZkClient> zkClientSupplier;
+  private final CoreContainer coreContainer;
+  public final Read readAPI = new Read();
+  public final Edit editAPI = new Edit();
+
+  public ContainerPluginsApi(CoreContainer coreContainer) {
+    this.zkClientSupplier = coreContainer.zkClientSupplier;
+    this.coreContainer = coreContainer;
+  }
+
+  public class Read {
+    @EndPoint(method = METHOD.GET,
+        path = "/cluster/plugin",
+        permission = PermissionNameProvider.Name.COLL_READ_PERM)
+    public void list(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException {
+      rsp.add(PLUGIN, plugins(zkClientSupplier));
+    }
+  }
+
+  @EndPoint(method = METHOD.POST,
+      path = "/cluster/plugin",
+      permission = PermissionNameProvider.Name.COLL_EDIT_PERM)
+  public class Edit {
+
+    @Command(name = "add")
+    public void add(PayloadObj<PluginMeta> payload) throws IOException {
+      PluginMeta info = payload.get();
+      validateConfig(payload, info);
+      if(payload.hasError()) return;
+      persistPlugins(map -> {
+        if (map.containsKey(info.name)) {
+          payload.addError(info.name + " already exists");
+          return null;
+        }
+        map.put(info.name, info);
+        return map;
+      });
+    }
+
+    @Command(name = "remove")
+    public void remove(PayloadObj<String> payload) throws IOException {
+      persistPlugins(map -> {
+        if (map.remove(payload.get()) == null) {
+          payload.addError("No such plugin: " + payload.get());
+          return null;
+        }
+        return map;
+      });
+    }
+
+    @Command(name = "update")
+    @SuppressWarnings("unchecked")
+    public void update(PayloadObj<PluginMeta> payload) throws IOException {
+      PluginMeta info = payload.get();
+      validateConfig(payload, info);
+      if(payload.hasError()) return;
+      persistPlugins(map -> {
+        Map<String, Object> existing = (Map<String, Object>) map.get(info.name);
+        if (existing == null) {
+          payload.addError("No such plugin: " + info.name);
+          return null;
+        } else {
+          map.put(info.name, info);
+          return map;
+        }
+      });
+    }
+  }
+
+  private void validateConfig(PayloadObj<PluginMeta> payload, PluginMeta info) {
+    if (info.klass.indexOf(':') > 0) {
+      if (info.version == null) {
+        payload.addError("Using package. must provide a packageVersion");
+        return;
+      }
+    }
+    List<String> errs = new ArrayList<>();
+    CustomContainerPlugins.ApiInfo apiInfo = coreContainer.getCustomContainerPlugins().createInfo(info, errs);
+    if (!errs.isEmpty()) {
+      for (String err : errs) payload.addError(err);
+      return;
+    }
+    AnnotatedApi api = null ;
+    try {
+      apiInfo.init();
+    } catch (Exception e) {
+      log.error("Error instantiating plugin ", e);
+      errs.add(e.getMessage());
+      return;
+    } finally {
+      closeWhileHandlingException(api);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public static Map<String, Object> plugins(Supplier<SolrZkClient> zkClientSupplier) throws IOException {
+    SolrZkClient zkClient = zkClientSupplier.get();
+    try {
+      Map<String, Object> clusterPropsJson = (Map<String, Object>) Utils.fromJSON(zkClient.getData(ZkStateReader.CLUSTER_PROPS, null, new Stat(), true));
+      return (Map<String, Object>) clusterPropsJson.computeIfAbsent(PLUGIN, Utils.NEW_LINKED_HASHMAP_FUN);
+    } catch (KeeperException.NoNodeException e) {
+      return new LinkedHashMap<>();
+    } catch (KeeperException | InterruptedException e) {
+      throw new IOException("Error reading cluster property", SolrZkClient.checkInterrupted(e));
+    }
+  }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  private void persistPlugins(Function<Map<String,Object>, Map<String,Object>> modifier) throws IOException {
+    try {
+      zkClientSupplier.get().atomicUpdate(ZkStateReader.CLUSTER_PROPS, bytes -> {
+        Map rawJson = bytes == null ? new LinkedHashMap() :
+            (Map) Utils.fromJSON(bytes);
+        Map pluginsModified = modifier.apply((Map) rawJson.computeIfAbsent(PLUGIN, Utils.NEW_LINKED_HASHMAP_FUN));
+        if (pluginsModified == null) return null;
+        rawJson.put(PLUGIN, pluginsModified);
+        return Utils.toJSON(rawJson);
+      });
+    } catch (KeeperException | InterruptedException e) {
+      throw new IOException("Error reading cluster property", SolrZkClient.checkInterrupted(e));
+    }
+  }
+
+
+}
diff --git a/solr/core/src/java/org/apache/solr/pkg/PackageLoader.java b/solr/core/src/java/org/apache/solr/pkg/PackageLoader.java
index 8ff12a0..1089288 100644
--- a/solr/core/src/java/org/apache/solr/pkg/PackageLoader.java
+++ b/solr/core/src/java/org/apache/solr/pkg/PackageLoader.java
@@ -158,6 +158,10 @@ public class PackageLoader implements Closeable {
       return deleted;
     }
 
+    public Set<String> allVersions() {
+      return myVersions.keySet();
+    }
+
 
     private synchronized void updateVersions(List<PackageAPI.PkgVersion> modified) {
       for (PackageAPI.PkgVersion v : modified) {
@@ -210,6 +214,10 @@ public class PackageLoader implements Closeable {
       return latest == null ? null : myVersions.get(latest);
     }
 
+    public Version getVersion(String version) {
+      return myVersions.get(version);
+    }
+
     public Version getLatest(String lessThan) {
       if (lessThan == null) {
         return getLatest();
diff --git a/solr/core/src/java/org/apache/solr/api/PayloadObj.java b/solr/core/src/test-files/runtimecode/MyPlugin.java
similarity index 51%
copy from solr/core/src/java/org/apache/solr/api/PayloadObj.java
copy to solr/core/src/test-files/runtimecode/MyPlugin.java
index c09c442..cbaa347 100644
--- a/solr/core/src/java/org/apache/solr/api/PayloadObj.java
+++ b/solr/core/src/test-files/runtimecode/MyPlugin.java
@@ -15,21 +15,29 @@
  * limitations under the License.
  */
 
-package org.apache.solr.api;
+package org.apache.solr.handler;
 
-import org.apache.solr.common.util.CommandOperation;
+import org.apache.solr.api.Command;
+import org.apache.solr.api.EndPoint;
+import org.apache.solr.client.solrj.SolrRequest.METHOD;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.security.PermissionNameProvider;
 
-public  class PayloadObj<T> extends CommandOperation {
+@EndPoint(path = "/plugin/my/path",
+    method = METHOD.GET,
+    permission = PermissionNameProvider.Name.CONFIG_READ_PERM)
+public class MyPlugin {
 
-  private T obj;
+  private final CoreContainer coreContainer;
 
-
-  public PayloadObj(String operationName, Object metaData, T obj) {
-    super(operationName, metaData);
-    this.obj = obj;
+  public MyPlugin(CoreContainer coreContainer) {
+    this.coreContainer = coreContainer;
   }
 
-  public T get(){
-    return obj;
+  @Command
+  public void call(SolrQueryRequest req, SolrQueryResponse rsp){
+    rsp.add("myplugin.version", "2.0");
   }
 }
diff --git a/solr/core/src/test-files/runtimecode/containerplugin.v.1.jar.bin b/solr/core/src/test-files/runtimecode/containerplugin.v.1.jar.bin
new file mode 100644
index 0000000..f9053c8
Binary files /dev/null and b/solr/core/src/test-files/runtimecode/containerplugin.v.1.jar.bin differ
diff --git a/solr/core/src/test-files/runtimecode/containerplugin.v.2.jar.bin b/solr/core/src/test-files/runtimecode/containerplugin.v.2.jar.bin
new file mode 100644
index 0000000..4d1cd99
Binary files /dev/null and b/solr/core/src/test-files/runtimecode/containerplugin.v.2.jar.bin differ
diff --git a/solr/core/src/test-files/runtimecode/sig.txt b/solr/core/src/test-files/runtimecode/sig.txt
index 74bb942..59beb47 100644
--- a/solr/core/src/test-files/runtimecode/sig.txt
+++ b/solr/core/src/test-files/runtimecode/sig.txt
@@ -69,6 +69,14 @@ openssl dgst -sha1 -sign ../cryptokeys/priv_key512.pem expressible.jar.bin | ope
 
 ZOT11arAiPmPZYOHzqodiNnxO9pRyRozWZEBX8XGjU1/HJptFnZK+DI7eXnUtbNaMcbXE2Ze8hh4M/eGyhY8BQ==
 
+openssl dgst -sha1 -sign priv_key512.pem containerplugin.v.1.jar.bin | openssl enc -base64 | sed 's/+/%2B/g' | tr -d \\n | sed
+
+pmrmWCDafdNpYle2rueAGnU2J6NYlcAey9mkZYbqh+5RdYo2Ln+llLF9voyRj+DDivK9GV1XdtKvD9rgCxlD7Q==
+
+openssl dgst -sha1 -sign ../cryptokeys/priv_key512.pem containerplugin.v.2.jar.bin | openssl enc -base64 | sed 's/+/%2B/g' | tr -d \\n | sed
+
+StR3DmqaUSL7qjDOeVEiCqE+ouiZAkW99fsL48F9oWG047o7NGgwwZ36iGgzDC3S2tPaFjRAd9Zg4UK7OZLQzg==
+
 ====================sha512====================
 
 openssl dgst -sha512 runtimelibs.jar.bin
diff --git a/solr/core/src/test/org/apache/solr/filestore/TestDistribPackageStore.java b/solr/core/src/test/org/apache/solr/filestore/TestDistribPackageStore.java
index 71bdd0a..94076e2 100644
--- a/solr/core/src/test/org/apache/solr/filestore/TestDistribPackageStore.java
+++ b/solr/core/src/test/org/apache/solr/filestore/TestDistribPackageStore.java
@@ -19,6 +19,7 @@ package org.apache.solr.filestore;
 
 import java.io.FileInputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.nio.ByteBuffer;
 import java.nio.file.Paths;
 import java.util.List;
@@ -53,6 +54,7 @@ import org.junit.Before;
 
 import static org.apache.solr.common.util.Utils.JAVABINCONSUMER;
 import static org.apache.solr.core.TestDynamicLoading.getFileContent;
+import static org.hamcrest.CoreMatchers.containsString;
 
 @LogLevel("org.apache.solr.filestore.PackageStoreAPI=DEBUG;org.apache.solr.filestore.DistribPackageStore=DEBUG")
 public class TestDistribPackageStore extends SolrCloudTestCase {
@@ -88,7 +90,7 @@ public class TestDistribPackageStore extends SolrCloudTestCase {
         );
         fail("should have failed because of wrong signature ");
       } catch (RemoteExecutionException e) {
-        assertTrue(e.getMessage().contains("Signature does not match"));
+        assertThat(e.getMessage(), containsString("Signature does not match"));
       }
 
       postFile(cluster.getSolrClient(), getFileContent("runtimecode/runtimelibs.jar.bin"),
@@ -192,11 +194,12 @@ public class TestDistribPackageStore extends SolrCloudTestCase {
     }
   }
 
+
   @SuppressWarnings({"rawtypes"})
-  static class Fetcher implements Callable {
+  public static class Fetcher implements Callable {
     String url;
     JettySolrRunner jetty;
-    Fetcher(String s, JettySolrRunner jettySolrRunner){
+    public Fetcher(String s, JettySolrRunner jettySolrRunner){
       this.url = s;
       this.jetty = jettySolrRunner;
     }
@@ -288,6 +291,12 @@ public class TestDistribPackageStore extends SolrCloudTestCase {
     assertEquals(name, rsp.getResponse().get(CommonParams.FILE));
   }
 
+  /**
+   * Read and return the contents of the file-like resource
+   * @param fname the name of the resource to read
+   * @return the bytes of the resource
+   * @throws IOException if there is an I/O error reading the contents
+   */
   public static byte[] readFile(String fname) throws IOException {
     byte[] buf = null;
     try (FileInputStream fis = new FileInputStream(getFile(fname))) {
diff --git a/solr/core/src/test/org/apache/solr/handler/TestContainerPlugin.java b/solr/core/src/test/org/apache/solr/handler/TestContainerPlugin.java
new file mode 100644
index 0000000..da9696c
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/TestContainerPlugin.java
@@ -0,0 +1,305 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.handler;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.Callable;
+
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.solr.api.Command;
+import org.apache.solr.api.EndPoint;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.embedded.JettySolrRunner;
+import org.apache.solr.client.solrj.impl.BaseHttpSolrClient.RemoteExecutionException;
+import org.apache.solr.client.solrj.request.V2Request;
+import org.apache.solr.client.solrj.request.beans.Package;
+import org.apache.solr.client.solrj.request.beans.PluginMeta;
+import org.apache.solr.client.solrj.response.V2Response;
+import org.apache.solr.cloud.MiniSolrCloudCluster;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.NavigableObject;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.filestore.PackageStoreAPI;
+import org.apache.solr.filestore.TestDistribPackageStore;
+import org.apache.solr.filestore.TestDistribPackageStore.Fetcher;
+import org.apache.solr.pkg.TestPackages;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.security.PermissionNameProvider;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET;
+import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
+import static org.apache.solr.filestore.TestDistribPackageStore.readFile;
+import static org.apache.solr.filestore.TestDistribPackageStore.uploadKey;
+
+public class TestContainerPlugin extends SolrCloudTestCase {
+
+  @Before
+  public void setup() {
+    System.setProperty("enable.packages", "true");
+  }
+
+  @After
+  public void teardown() {
+    System.clearProperty("enable.packages");
+  }
+
+  @Test
+  public void testApi() throws Exception {
+    MiniSolrCloudCluster cluster =
+        configureCluster(4)
+            .withJettyConfig(jetty -> jetty.enableV2(true))
+            .configure();
+    String errPath = "/error/details[0]/errorMessages[0]";
+    try {
+      PluginMeta plugin = new PluginMeta();
+      plugin.name = "testplugin";
+      plugin.klass = C2.class.getName();
+      //test with an invalid class
+      V2Request req = new V2Request.Builder("/cluster/plugin")
+          .forceV2(true)
+          .withMethod(POST)
+          .withPayload(singletonMap("add", plugin))
+          .build();
+      expectError(req, cluster.getSolrClient(), errPath, "No method with @Command in class");
+
+      //test with an invalid class
+      plugin.klass = C1.class.getName();
+      expectError(req, cluster.getSolrClient(), errPath, "No @EndPoints");
+
+      //test with a valid class. This should succeed now
+      plugin.klass = C3.class.getName();
+      req.process(cluster.getSolrClient());
+
+      //just check if the plugin is indeed registered
+      V2Request readPluginState = new V2Request.Builder("/cluster/plugin")
+          .forceV2(true)
+          .withMethod(GET)
+          .build();
+      V2Response rsp = readPluginState.process(cluster.getSolrClient());
+      assertEquals(C3.class.getName(), rsp._getStr("/plugin/testplugin/class", null));
+
+      //let's test the plugin
+      TestDistribPackageStore.assertResponseValues(10,
+          () -> new V2Request.Builder("/plugin/my/plugin")
+              .forceV2(true)
+              .withMethod(GET)
+              .build().process(cluster.getSolrClient()),
+          ImmutableMap.of("/testkey", "testval"));
+
+      //now remove the plugin
+      new V2Request.Builder("/cluster/plugin")
+          .withMethod(POST)
+          .forceV2(true)
+          .withPayload("{remove : testplugin}")
+          .build()
+          .process(cluster.getSolrClient());
+
+      //verify it is removed
+      rsp = readPluginState.process(cluster.getSolrClient());
+      assertEquals(null, rsp._get("/plugin/testplugin/class", null));
+
+      //test with a class  @EndPoint methods. This also uses a template in the path name
+      plugin.klass = C4.class.getName();
+      plugin.name = "collections";
+      expectError(req, cluster.getSolrClient(), errPath, "path must not have a prefix: collections");
+
+      plugin.name = "my-random-name";
+      req.process(cluster.getSolrClient());
+
+      //let's test the plugin
+      TestDistribPackageStore.assertResponseValues(10,
+          () -> new V2Request.Builder("/my-random-name/my/plugin")
+              .forceV2(true)
+              .withMethod(GET)
+              .build().process(cluster.getSolrClient()),
+          ImmutableMap.of("/method.name", "m1"));
+
+  TestDistribPackageStore.assertResponseValues(10,
+          () -> new V2Request.Builder("/my-random-name/their/plugin")
+              .forceV2(true)
+              .withMethod(GET)
+              .build().process(cluster.getSolrClient()),
+          ImmutableMap.of("/method.name", "m2"));
+
+    } finally {
+      cluster.shutdown();
+    }
+  }
+  @Test
+  public void testApiFromPackage() throws Exception {
+    MiniSolrCloudCluster cluster =
+        configureCluster(4)
+            .withJettyConfig(jetty -> jetty.enableV2(true))
+            .configure();
+    String FILE1 = "/myplugin/v1.jar";
+    String FILE2 = "/myplugin/v2.jar";
+
+    String errPath = "/error/details[0]/errorMessages[0]";
+    try {
+      byte[] derFile = readFile("cryptokeys/pub_key512.der");
+      uploadKey(derFile, PackageStoreAPI.KEYS_DIR+"/pub_key512.der", cluster);
+      TestPackages.postFileAndWait(cluster, "runtimecode/containerplugin.v.1.jar.bin", FILE1,
+          "pmrmWCDafdNpYle2rueAGnU2J6NYlcAey9mkZYbqh+5RdYo2Ln+llLF9voyRj+DDivK9GV1XdtKvD9rgCxlD7Q==");
+     TestPackages.postFileAndWait(cluster, "runtimecode/containerplugin.v.2.jar.bin", FILE2,
+          "StR3DmqaUSL7qjDOeVEiCqE+ouiZAkW99fsL48F9oWG047o7NGgwwZ36iGgzDC3S2tPaFjRAd9Zg4UK7OZLQzg==");
+
+     // We have two versions of the plugin in 2 different jar files. they are already uploaded to the package store
+      Package.AddVersion add = new Package.AddVersion();
+      add.version = "1.0";
+      add.pkg = "mypkg";
+      add.files = singletonList(FILE1);
+      V2Request addPkgVersionReq = new V2Request.Builder("/cluster/package")
+          .forceV2(true)
+          .withMethod(POST)
+          .withPayload(singletonMap("add", add))
+          .build();
+      addPkgVersionReq.process(cluster.getSolrClient());
+
+      waitForAllNodesToSync(cluster, "/cluster/package", Utils.makeMap(
+              ":result:packages:mypkg[0]:version", "1.0",
+              ":result:packages:mypkg[0]:files[0]", FILE1
+      ));
+
+      // Now lets create a plugin using v1 jar file
+      PluginMeta plugin = new PluginMeta();
+      plugin.name = "myplugin";
+      plugin.klass = "mypkg:org.apache.solr.handler.MyPlugin";
+      plugin.version = add.version;
+      V2Request req1 = new V2Request.Builder("/cluster/plugin")
+          .forceV2(true)
+          .withMethod(POST)
+          .withPayload(singletonMap("add", plugin))
+          .build();
+      req1.process(cluster.getSolrClient());
+      //verify the plugin creation
+      TestDistribPackageStore.assertResponseValues(10,
+          () -> new V2Request.Builder("/cluster/plugin").
+              withMethod(GET)
+              .build().process(cluster.getSolrClient()),
+          ImmutableMap.of(
+              "/plugin/myplugin/class", plugin.klass,
+              "/plugin/myplugin/version", plugin.version
+          ));
+      //let's test this now
+      Callable<NavigableObject> invokePlugin = () -> new V2Request.Builder("/plugin/my/path")
+          .forceV2(true)
+          .withMethod(GET)
+          .build().process(cluster.getSolrClient());
+      TestDistribPackageStore.assertResponseValues(10,
+          invokePlugin,
+          ImmutableMap.of("/myplugin.version", "1.0"));
+
+      //now let's upload the jar file for version 2.0 of the plugin
+      add.version = "2.0";
+      add.files = singletonList(FILE2);
+      addPkgVersionReq.process(cluster.getSolrClient());
+
+      //here the plugin version is updated
+      plugin.version = add.version;
+      new V2Request.Builder("/cluster/plugin")
+          .forceV2(true)
+          .withMethod(POST)
+          .withPayload(singletonMap("update", plugin))
+          .build()
+      .process(cluster.getSolrClient());
+
+      //now verify if it is indeed updated
+      TestDistribPackageStore.assertResponseValues(10,
+          () -> new V2Request.Builder("/cluster/plugin").
+              withMethod(GET)
+              .build().process(cluster.getSolrClient()),
+          ImmutableMap.of(
+              "/plugin/myplugin/class", plugin.klass,
+              "/plugin/myplugin/version", "2.0"
+          ));
+      // invoke the plugin and test thye output
+      TestDistribPackageStore.assertResponseValues(10,
+          invokePlugin,
+          ImmutableMap.of("/myplugin.version", "2.0"));
+    } finally {
+      cluster.shutdown();
+    }
+  }
+
+  public static class C1 {
+
+  }
+
+  @EndPoint(
+      method = GET,
+      path = "/plugin/my/plugin",
+      permission = PermissionNameProvider.Name.COLL_READ_PERM)
+  public class C2 {
+
+
+  }
+
+  @EndPoint(
+      method = GET,
+      path = "/plugin/my/plugin",
+      permission = PermissionNameProvider.Name.COLL_READ_PERM)
+  public static class C3 {
+    @Command
+    public void read(SolrQueryRequest req, SolrQueryResponse rsp) {
+      rsp.add("testkey", "testval");
+    }
+
+  }
+
+  public static class C4 {
+
+    @EndPoint(method = GET,
+        path = "$plugin-name/my/plugin",
+        permission = PermissionNameProvider.Name.READ_PERM)
+    public void m1(SolrQueryRequest req, SolrQueryResponse rsp) {
+      rsp.add("method.name", "m1");
+    }
+
+    @EndPoint(method = GET,
+        path = "$plugin-name/their/plugin",
+        permission = PermissionNameProvider.Name.READ_PERM)
+    public void m2(SolrQueryRequest req, SolrQueryResponse rsp) {
+      rsp.add("method.name", "m2");
+    }
+
+  }
+
+  @SuppressWarnings("unchecked")
+  public static void waitForAllNodesToSync(MiniSolrCloudCluster cluster, String path, Map<String,Object> expected) throws Exception {
+    for (JettySolrRunner jettySolrRunner : cluster.getJettySolrRunners()) {
+      String baseUrl = jettySolrRunner.getBaseUrl().toString().replace("/solr", "/api");
+      String url = baseUrl + path + "?wt=javabin";
+      TestDistribPackageStore.assertResponseValues(10, new Fetcher(url, jettySolrRunner), expected);
+    }
+  }
+
+  private void expectError(V2Request req, SolrClient client, String errPath, String expectErrorMsg) throws IOException, SolrServerException {
+    RemoteExecutionException e = expectThrows(RemoteExecutionException.class, () -> req.process(client));
+    String msg = e.getMetaData()._getStr(errPath, "");
+    assertTrue(expectErrorMsg, msg.contains(expectErrorMsg));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java b/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
index 3c66c7b..5f0a57a 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
@@ -17,36 +17,15 @@
 
 package org.apache.solr.handler.admin;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 import org.apache.solr.SolrTestCaseJ4;
-import org.apache.solr.api.Api;
-import org.apache.solr.api.ApiBag;
-import org.apache.solr.api.Command;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.api.V2HttpCall;
+import org.apache.solr.api.*;
 import org.apache.solr.api.V2HttpCall.CompositeApi;
 import org.apache.solr.client.solrj.SolrRequest;
 import org.apache.solr.common.annotation.JsonProperty;
 import org.apache.solr.common.params.MapSolrParams;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.params.SolrParams;
-import org.apache.solr.common.util.CommandOperation;
-import org.apache.solr.common.util.ContentStream;
-import org.apache.solr.common.util.ContentStreamBase;
-import org.apache.solr.common.util.JsonSchemaValidator;
-import org.apache.solr.common.util.PathTrie;
-import org.apache.solr.common.util.StrUtils;
-import org.apache.solr.common.util.Utils;
-import org.apache.solr.common.util.ValidatingJsonMap;
+import org.apache.solr.common.util.*;
 import org.apache.solr.core.CoreContainer;
 import org.apache.solr.core.PluginBag;
 import org.apache.solr.handler.PingRequestHandler;
@@ -59,13 +38,16 @@ import org.apache.solr.request.SolrRequestHandler;
 import org.apache.solr.response.SolrQueryResponse;
 import org.apache.solr.security.PermissionNameProvider;
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.*;
+
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.solr.api.ApiBag.EMPTY_SPEC;
 import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET;
 import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
-import static org.apache.solr.common.params.CommonParams.COLLECTIONS_HANDLER_PATH;
-import static org.apache.solr.common.params.CommonParams.CONFIGSETS_HANDLER_PATH;
-import static org.apache.solr.common.params.CommonParams.CORES_HANDLER_PATH;
+import static org.apache.solr.common.params.CommonParams.*;
 import static org.apache.solr.common.util.ValidatingJsonMap.NOT_NULL;
 
 public class TestApiFramework extends SolrTestCaseJ4 {
diff --git a/solr/core/src/test/org/apache/solr/pkg/TestPackages.java b/solr/core/src/test/org/apache/solr/pkg/TestPackages.java
index 7714a50..a0539ad 100644
--- a/solr/core/src/test/org/apache/solr/pkg/TestPackages.java
+++ b/solr/core/src/test/org/apache/solr/pkg/TestPackages.java
@@ -635,7 +635,7 @@ public class TestPackages extends SolrCloudTestCase {
     }
   }
 
-  static void postFileAndWait(MiniSolrCloudCluster cluster, String fname, String path, String sig) throws Exception {
+  public static void postFileAndWait(MiniSolrCloudCluster cluster, String fname, String path, String sig) throws Exception {
     ByteBuffer fileContent = getFileContent(fname);
     String sha512 = DigestUtils.sha512Hex(fileContent.array());
 
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/PluginMeta.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/PluginMeta.java
new file mode 100644
index 0000000..cb4f0aa
--- /dev/null
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/PluginMeta.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.client.solrj.request.beans;
+
+import java.util.Objects;
+
+import org.apache.solr.common.annotation.JsonProperty;
+import org.apache.solr.common.util.ReflectMapWriter;
+
+/**
+ * POJO for a plugin metadata used in container plugins
+ */
+@SuppressWarnings({"overrides"})
+public class PluginMeta implements ReflectMapWriter {
+  @JsonProperty(required = true)
+  public String name;
+
+  @JsonProperty(value = "class", required = true)
+  public String klass;
+
+  @JsonProperty
+  public String version;
+
+
+  public PluginMeta copy() {
+    PluginMeta result = new PluginMeta();
+    result.name = name;
+    result.klass = klass;
+    result.version = version;
+    return result;
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof PluginMeta) {
+      PluginMeta that = (PluginMeta) obj;
+      return Objects.equals(this.name, that.name) &&
+          Objects.equals(this.klass, that.klass) &&
+          Objects.equals(this.version, that.version);
+    }
+    return false;
+  }
+}
diff --git a/solr/solrj/src/java/org/apache/solr/common/util/PathTrie.java b/solr/solrj/src/java/org/apache/solr/common/util/PathTrie.java
index 742c59d..e5a7e73e 100644
--- a/solr/solrj/src/java/org/apache/solr/common/util/PathTrie.java
+++ b/solr/solrj/src/java/org/apache/solr/common/util/PathTrie.java
@@ -32,7 +32,7 @@ import static java.util.Collections.emptyList;
  */
 public class PathTrie<T> {
   private final Set<String> reserved = new HashSet<>();
-  Node root = new Node(emptyList(), null);
+  Node root = new Node(emptyList(), null, null);
 
   public PathTrie() {
   }
@@ -52,7 +52,11 @@ public class PathTrie<T> {
       root.obj = o;
       return;
     }
+    replaceTemplates(parts, replacements);
+    root.insert(parts, o);
+  }
 
+  public static void replaceTemplates(List<String> parts, Map<String, String> replacements) {
     for (int i = 0; i < parts.size(); i++) {
       String part = parts.get(i);
       if (part.charAt(0) == '$') {
@@ -64,8 +68,6 @@ public class PathTrie<T> {
         parts.set(i, replacement);
       }
     }
-
-    root.insert(parts, o);
   }
 
   // /a/b/c will be returned as ["a","b","c"]
@@ -82,6 +84,22 @@ public class PathTrie<T> {
     return parts;
   }
 
+  public T remove(List<String> path) {
+    Node node = root.lookupNode(path, 0, null, null);
+    T result = null;
+    if (node != null) {
+      result = node.obj;
+      node.obj = null;
+      if (node.children == null || node.children.isEmpty()) {
+        if (node.parent != null) {
+          node.parent.children.remove(node.name);
+        }
+      }
+      return result;
+    }
+    return result;
+
+  }
 
   public T lookup(String path, Map<String, String> templateValues) {
     return root.lookup(getPathSegments(path), 0, templateValues);
@@ -107,8 +125,10 @@ public class PathTrie<T> {
     Map<String, Node> children;
     T obj;
     String templateName;
+    final Node parent;
 
-    Node(List<String> path, T o) {
+    Node(List<String> path, T o, Node parent) {
+      this.parent = parent;
       if (path.isEmpty()) {
         obj = o;
         return;
@@ -133,7 +153,7 @@ public class PathTrie<T> {
 
       matchedChild = children.get(key);
       if (matchedChild == null) {
-        children.put(key, matchedChild = new Node(path, o));
+        children.put(key, matchedChild = new Node(path, o, this));
       }
       if (varName != null) {
         if (!matchedChild.templateName.equals(varName)) {
@@ -179,17 +199,23 @@ public class PathTrie<T> {
      * @param availableSubPaths If not null , available sub paths will be returned in this set
      */
     public T lookup(List<String> pathSegments, int index, Map<String, String> templateVariables, Set<String> availableSubPaths) {
-      if (templateName != null) templateVariables.put(templateName, pathSegments.get(index - 1));
+      Node node = lookupNode(pathSegments, index, templateVariables, availableSubPaths);
+      return node == null ? null : node.obj;
+    }
+
+    Node lookupNode(List<String> pathSegments, int index, Map<String, String> templateVariables, Set<String> availableSubPaths) {
+      if (templateName != null && templateVariables != null)
+        templateVariables.put(templateName, pathSegments.get(index - 1));
       if (pathSegments.size() < index + 1) {
         findAvailableChildren("", availableSubPaths);
         if (obj == null) {//this is not a leaf node
           Node n = children.get("*");
           if (n != null) {
-            return n.obj;
+            return n;
           }
 
         }
-        return obj;
+        return this;
       }
       String piece = pathSegments.get(index);
       if (children == null) {
@@ -204,15 +230,15 @@ public class PathTrie<T> {
           for (int i = index; i < pathSegments.size(); i++) {
             sb.append("/").append(pathSegments.get(i));
           }
-          templateVariables.put("*", sb.toString());
-          return n.obj;
+          if (templateVariables != null) templateVariables.put("*", sb.toString());
+          return n;
 
         }
       }
       if (n == null) {
         return null;
       }
-      return n.lookup(pathSegments, index + 1, templateVariables, availableSubPaths);
+      return n.lookupNode(pathSegments, index + 1, templateVariables, availableSubPaths);
     }
   }
 
diff --git a/solr/solrj/src/test/org/apache/solr/common/util/TestPathTrie.java b/solr/solrj/src/test/org/apache/solr/common/util/TestPathTrie.java
index 5a56821..2b1ad42 100644
--- a/solr/solrj/src/test/org/apache/solr/common/util/TestPathTrie.java
+++ b/solr/solrj/src/test/org/apache/solr/common/util/TestPathTrie.java
@@ -71,5 +71,13 @@ public class TestPathTrie extends SolrTestCaseJ4 {
     templateValues.clear();
     assertEquals("W" ,pathTrie.lookup("/aa/bb/somepart/tt/hello/world/from/solr", templateValues));
     assertEquals(templateValues.get("*"), "/hello/world/from/solr");
+
+    pathTrie.insert("/1/2/{x}/4", emptyMap(), "a");
+    assertEquals("a", pathTrie.lookup("/1/2/3/4", null));
+    templateValues.clear();
+    assertEquals("a", pathTrie.lookup("/1/2/3/4", templateValues));
+    assertEquals(templateValues.get("x"), "3");
+    pathTrie.remove(PathTrie.getPathSegments("/1/2/3/4"));
+    assertEquals(null, pathTrie.lookup("/1/2/3/4", null));
   }
 }