SLING-8568 Allow the API Regions runtime to be disabled using dynamic configuration

The API Regions fragment is enabled early using the framework property
  org.apache.sling.feature.apiregions.regions=*

This commit allows the API Regions fragment to be disabled dynamically
using ConfigAdmin configuration. To disable the API regions runtime set
the following configuration:
  PID: org.apache.sling.feature.apiregions.impl
  disable=true
diff --git a/README.md b/README.md
index 4200f52..91fa347 100644
--- a/README.md
+++ b/README.md
@@ -31,3 +31,20 @@
     org.apache.sling.feature.apiregions.regions=*
 
 If this framework property is not set the component will be disabled.
+
+### Runtime Configuration
+If this component runs in a framework with Configuration Admin present, and it is set to be enabled using the framework property, it can be disabled at runtime
+through Configuration Admin configuration.
+
+Runtime configuration supported:
+
+**PID**: `org.apache.sling.feature.apiregions.impl`
+
+Key | Value  
+--- | ---
+`disable` | if `true` then the API Regions component is disabled. Otherwise the component is enabled.
+
+No meta type is defined for this configuration since it's not a typical user setting. However, when using the web console
+the configuration can be created using `curl`: 
+
+    curl -u <user>:<pass> -X POST -d "apply=true" -d "propertylist=disable" -d "disable=true" http://localhost:8080/system/console/configMgr/org.apache.sling.feature.apiregions.impl
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index cc15dc0..a73104d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -67,6 +67,7 @@
                         <exclude>src/test/resources/*</exclude>
                         <exclude>src/test/resources/props1/*</exclude>
                         <exclude>src/test/resources/props2/*</exclude>
+                        <exclude>src/test/resources/props3/*</exclude>
                     </excludes>
                 </configuration>
             </plugin>
@@ -92,5 +93,11 @@
             <version>2.27.0</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.cmpn</artifactId>
+            <version>6.0.0</version>
+            <scope>test</scope>
+        </dependency>        
     </dependencies>
 </project>
diff --git a/src/main/java/org/apache/sling/feature/apiregions/impl/Activator.java b/src/main/java/org/apache/sling/feature/apiregions/impl/Activator.java
index e86c38a..190e29b 100644
--- a/src/main/java/org/apache/sling/feature/apiregions/impl/Activator.java
+++ b/src/main/java/org/apache/sling/feature/apiregions/impl/Activator.java
@@ -20,32 +20,166 @@
 
 import org.osgi.framework.BundleActivator;
 import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.FrameworkEvent;
+import org.osgi.framework.FrameworkListener;
+import org.osgi.framework.ServiceRegistration;
 import org.osgi.framework.hooks.resolver.ResolverHookFactory;
+import org.osgi.framework.namespace.PackageNamespace;
+import org.osgi.framework.wiring.BundleCapability;
+import org.osgi.framework.wiring.FrameworkWiring;
+import org.osgi.resource.Requirement;
+import org.osgi.resource.Resource;
 
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.Dictionary;
 import java.util.Hashtable;
+import java.util.Map;
 import java.util.logging.Level;
 
-public class Activator implements BundleActivator {
+public class Activator implements BundleActivator, FrameworkListener {
+    static final String MANAGED_SERVICE_PKG_NAME = "org.osgi.service.cm";
+    static final String MANAGED_SERVICE_CLASS_NAME = MANAGED_SERVICE_PKG_NAME + ".ManagedService";
     static final String REGIONS_PROPERTY_NAME = "org.apache.sling.feature.apiregions.regions";
 
+    BundleContext bundleContext;
+    ServiceRegistration<ResolverHookFactory> hookRegistration;
+
     @Override
     public synchronized void start(BundleContext context) throws Exception {
-        String regions = context.getProperty(REGIONS_PROPERTY_NAME);
+        bundleContext = context;
+
+        registerHook();
+
+        context.addFrameworkListener(this);
+    }
+
+    @Override
+    public synchronized void stop(BundleContext context) throws Exception {
+        // All services automatically get unregistered by the framework.
+    }
+
+    synchronized void registerHook() {
+        if (hookRegistration != null)
+            return; // There is already a hook, no need to re-register
+
+        String regions = bundleContext.getProperty(REGIONS_PROPERTY_NAME);
         if (regions == null)
             return; // Component not enabled
 
         Dictionary<String, Object> props = new Hashtable<>();
         try {
-            RegionEnforcer enforcer = new RegionEnforcer(context, props, regions);
-            context.registerService(ResolverHookFactory.class, enforcer, props);
+            RegionEnforcer enforcer = new RegionEnforcer(bundleContext, props, regions);
+            hookRegistration = bundleContext.registerService(ResolverHookFactory.class, enforcer, props);
         } catch (Exception e) {
             RegionEnforcer.LOG.log(Level.SEVERE, "Problem activating API Regions runtime enforcement component", e);
         }
     }
 
+    synchronized void unregisterHook() {
+        if (hookRegistration != null) {
+            hookRegistration.unregister();
+            hookRegistration = null;
+        }
+    }
+
     @Override
-    public synchronized void stop(BundleContext context) throws Exception {
-        // Nothing to do
+    public void frameworkEvent(FrameworkEvent event) {
+        if (event.getType() == FrameworkEvent.STARTED) {
+            bundleContext.removeFrameworkListener(this);
+
+            FrameworkWiring fw = bundleContext.getBundle().adapt(FrameworkWiring.class);
+            if (fw == null) {
+                RegionEnforcer.LOG.log(Level.WARNING, "The API Regions runtime fragment is not attached to the system bundle.");
+                return;
+            }
+
+            Requirement cmReq = createPackageRequirement();
+
+            // Reflectively register a Configuration Admin ManagedService, if the Config Admin API is available.
+            // Because this fragment is a framework extension, we need to use the wiring API to find the CM API.
+            Collection<BundleCapability> providers = fw.findProviders(cmReq);
+            for (BundleCapability cap : providers) {
+                try {
+                    ClassLoader loader = cap.getRevision().getWiring().getClassLoader();
+                    Class<?> msClass = loader.loadClass(MANAGED_SERVICE_CLASS_NAME);
+                    Object ms = Proxy.newProxyInstance(loader, new Class[] {msClass}, new InvocationHandler() {
+                        @Override
+                        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
+                            Class<?> mdDecl = method.getDeclaringClass();
+                            if (mdDecl.equals(Object.class)) {
+                                switch (method.getName()) {
+                                    case "equals" :
+                                        return proxy == args[0];
+                                    case "hashCode" :
+                                        return System.identityHashCode(proxy);
+                                    case "toString" :
+                                        return "Proxy for " + msClass;
+                                    default :
+                                        throw new UnsupportedOperationException("Method " + method
+                                            + " not supported on proxy for " + msClass);
+                                }
+                            }
+                            if ("updated".equals(method.getName())) {
+                                if (args.length == 1) {
+                                    Object arg = args[0];
+                                    if (arg == null) {
+                                        registerHook();
+                                    } else if (arg instanceof Dictionary) {
+                                        Dictionary<?,?> props = (Dictionary<?,?>) args[0];
+                                        Object disabled = props.get("disable");
+                                        if ("true".equals(disabled)) {
+                                            unregisterHook();
+                                        } else {
+                                            registerHook();
+                                        }
+                                    }
+                                }
+                            }
+                            return null;
+                        }
+                    });
+                    Dictionary<String, Object> props = new Hashtable<>();
+                    props.put(Constants.SERVICE_PID, getClass().getPackage().getName());
+                    bundleContext.registerService(MANAGED_SERVICE_CLASS_NAME, ms, props);
+
+                    return; // ManagedService registration successful. Exit method.
+                } catch (Exception e) {
+                    RegionEnforcer.LOG.log(Level.WARNING, "Problem attempting to register ManagedService from " + cap, e);
+                }
+            }
+            RegionEnforcer.LOG.log(Level.INFO, "No Configuration Admin API available");
+        }
+    }
+
+    static Requirement createPackageRequirement() {
+        Requirement cmReq = new Requirement() {
+            @Override
+            public String getNamespace() {
+                return PackageNamespace.PACKAGE_NAMESPACE;
+            }
+
+            @Override
+            public Map<String, String> getDirectives() {
+                return Collections.singletonMap("filter",
+                        "(" + PackageNamespace.PACKAGE_NAMESPACE + "=" + MANAGED_SERVICE_PKG_NAME + ")");
+            }
+
+            @Override
+            public Map<String, Object> getAttributes() {
+                return Collections.emptyMap();
+            }
+
+            @Override
+            public Resource getResource() {
+                return null;
+            }
+
+        };
+        return cmReq;
     }
 }
diff --git a/src/test/java/org/apache/sling/feature/apiregions/impl/ActivatorTest.java b/src/test/java/org/apache/sling/feature/apiregions/impl/ActivatorTest.java
index c2f13d1..7deb0ea 100644
--- a/src/test/java/org/apache/sling/feature/apiregions/impl/ActivatorTest.java
+++ b/src/test/java/org/apache/sling/feature/apiregions/impl/ActivatorTest.java
@@ -22,12 +22,28 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.Mockito;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkEvent;
+import org.osgi.framework.ServiceRegistration;
 import org.osgi.framework.hooks.resolver.ResolverHookFactory;
+import org.osgi.framework.namespace.PackageNamespace;
+import org.osgi.framework.wiring.BundleCapability;
+import org.osgi.framework.wiring.BundleRevision;
+import org.osgi.framework.wiring.BundleWiring;
+import org.osgi.framework.wiring.FrameworkWiring;
+import org.osgi.resource.Requirement;
+import org.osgi.service.cm.ManagedService;
 
 import java.io.File;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.Dictionary;
 import java.util.Hashtable;
+import java.util.List;
 import java.util.Properties;
 
 import static org.apache.sling.feature.apiregions.impl.RegionEnforcer.BUNDLE_FEATURE_FILENAME;
@@ -35,6 +51,9 @@
 import static org.apache.sling.feature.apiregions.impl.RegionEnforcer.IDBSNVER_FILENAME;
 import static org.apache.sling.feature.apiregions.impl.RegionEnforcer.PROPERTIES_RESOURCE_PREFIX;
 import static org.apache.sling.feature.apiregions.impl.RegionEnforcer.REGION_PACKAGE_FILENAME;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 
 public class ActivatorTest {
     private Properties savedProps;
@@ -82,5 +101,178 @@
                 Mockito.eq(ResolverHookFactory.class),
                 Mockito.isA(RegionEnforcer.class),
                 Mockito.eq(expectedProps));
+
+        Mockito.verify(bc).addFrameworkListener(a);
+    }
+
+    @Test
+    public void testRegistryHookNotEnabled() {
+        BundleContext bc = Mockito.mock(BundleContext.class);
+
+        Activator a = new Activator();
+        a.bundleContext = bc;
+        a.registerHook();
+
+        assertNull(a.hookRegistration);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testRegistryHookAlreadyPresent() {
+        BundleContext bc = Mockito.mock(BundleContext.class);
+
+        Activator a = new Activator();
+        a.bundleContext = bc;
+        a.hookRegistration = Mockito.mock(ServiceRegistration.class);
+        a.registerHook();
+
+        assertNotNull(a.hookRegistration);
+        Mockito.verifyZeroInteractions(bc);
+    }
+
+    @Test
+    public void testUnregisterHook() {
+        Activator a = new Activator();
+        a.unregisterHook(); // Should not throw an exception
+        assertNull(a.hookRegistration);
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    @Test
+    public void testUnregisterHook2() {
+        ServiceRegistration reg = Mockito.mock(ServiceRegistration.class);
+
+        Activator a = new Activator();
+        a.hookRegistration = reg;
+
+        a.unregisterHook();
+        Mockito.verify(reg).unregister();
+        assertNull(a.hookRegistration);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testFrameworkEvent() throws Exception {
+        String resourceDir = new File(getClass().getResource("/props3/idbsnver.properties").getFile()).getParent();
+
+        BundleWiring bw = Mockito.mock(BundleWiring.class);
+        Mockito.when(bw.getClassLoader()).thenReturn(getClass().getClassLoader());
+        BundleRevision rev = Mockito.mock(BundleRevision.class);
+        Mockito.when(rev.getWiring()).thenReturn(bw);
+        BundleCapability cap = Mockito.mock(BundleCapability.class);
+        Mockito.when(cap.getRevision()).thenReturn(rev);
+
+        FrameworkWiring wiring = Mockito.mock(FrameworkWiring.class);
+        Mockito.when(wiring.findProviders(Mockito.any(Requirement.class))).thenAnswer(
+            new Answer<Collection<BundleCapability>>() {
+                @Override
+                public Collection<BundleCapability> answer(InvocationOnMock invocation) throws Throwable {
+                    Requirement req = invocation.getArgument(0);
+                    if ("osgi.wiring.package".equals(req.getNamespace())) {
+                        if ("(osgi.wiring.package=org.osgi.service.cm)".equals(req.getDirectives().get("filter"))) {
+                            return Collections.singleton(cap);
+                        }
+                    }
+                    return null;
+                }
+            });
+
+
+        Bundle fw = Mockito.mock(Bundle.class);
+        Mockito.when(fw.adapt(FrameworkWiring.class)).thenReturn(wiring);
+
+        List<Object> managedServices = new ArrayList<Object>();
+        BundleContext bc = Mockito.mock(BundleContext.class);
+        Mockito.when(bc.getBundle()).thenReturn(fw);
+        Mockito.when(bc.getProperty(Activator.REGIONS_PROPERTY_NAME)).thenReturn("*");
+        Mockito.when(bc.getProperty(RegionEnforcer.PROPERTIES_FILE_LOCATION)).thenReturn(resourceDir);
+        Mockito.when(bc.registerService(
+            Mockito.eq("org.osgi.service.cm.ManagedService"),
+            Mockito.any(),
+            Mockito.any(Dictionary.class))).thenAnswer(new Answer<ServiceRegistration<?>>() {
+                @Override
+                public ServiceRegistration<?> answer(InvocationOnMock invocation) throws Throwable {
+                    Dictionary<String,?> dict = invocation.getArgument(2);
+                    if ("org.apache.sling.feature.apiregions.impl".equals(dict.get("service.pid"))) {
+                        managedServices.add(invocation.getArgument(1));
+                    }
+                    return Mockito.mock(ServiceRegistration.class);
+                }
+            });
+        Mockito.when(bc.registerService(
+                Mockito.eq(ResolverHookFactory.class),
+                Mockito.isA(RegionEnforcer.class),
+                Mockito.any(Dictionary.class))).thenReturn(Mockito.mock(ServiceRegistration.class));
+
+
+        Activator a = new Activator();
+        a.bundleContext = bc;
+
+        FrameworkEvent ev = Mockito.mock(FrameworkEvent.class);
+        Mockito.when(ev.getType()).thenReturn(FrameworkEvent.STARTED);
+
+        a.frameworkEvent(ev);
+        Mockito.verify(bc).removeFrameworkListener(a);
+        assertEquals(1, managedServices.size());
+        ManagedService managedService = (ManagedService) managedServices.get(0);
+
+        assertNull("Precondition", a.hookRegistration);
+        managedService.updated(null);
+        assertNotNull(a.hookRegistration);
+
+        ServiceRegistration<ResolverHookFactory> hookReg = a.hookRegistration;
+        Mockito.verifyZeroInteractions(hookReg);
+        managedService.updated(new Hashtable<>(Collections.singletonMap("disable", "true")));
+        Mockito.verify(hookReg).unregister();
+        assertNull(a.hookRegistration);
+
+        managedService.updated(new Hashtable<>(Collections.singletonMap("disable", "false")));
+        assertNotNull(a.hookRegistration);
+    }
+
+    @Test
+    public void testFrameworkEventNoCMProviders() {
+        FrameworkWiring wiring = Mockito.mock(FrameworkWiring.class);
+
+        Bundle fw = Mockito.mock(Bundle.class);
+        Mockito.when(fw.adapt(FrameworkWiring.class)).thenReturn(wiring);
+
+        BundleContext bc = Mockito.mock(BundleContext.class);
+        Mockito.when(bc.getBundle()).thenReturn(fw);
+
+        Activator a = new Activator();
+        a.bundleContext = bc;
+
+        FrameworkEvent ev = Mockito.mock(FrameworkEvent.class);
+        Mockito.when(ev.getType()).thenReturn(FrameworkEvent.STARTED);
+
+        a.frameworkEvent(ev);
+        Mockito.verify(bc).removeFrameworkListener(a);
+    }
+
+    @Test
+    public void testFrameworkEventNoSystemBundle() {
+        BundleContext bc = Mockito.mock(BundleContext.class);
+        Mockito.when(bc.getBundle()).thenReturn(Mockito.mock(Bundle.class));
+
+        Activator a = new Activator();
+        a.bundleContext = bc;
+
+        FrameworkEvent ev = Mockito.mock(FrameworkEvent.class);
+        Mockito.when(ev.getType()).thenReturn(FrameworkEvent.STARTED);
+
+        a.frameworkEvent(ev);
+        Mockito.verify(bc).removeFrameworkListener(a);
+    }
+
+    @Test
+    public void testCreatePackageRequirement() {
+        Requirement req = Activator.createPackageRequirement();
+        assertEquals(PackageNamespace.PACKAGE_NAMESPACE, req.getNamespace());
+        assertEquals(1, req.getDirectives().size());
+        String directive = req.getDirectives().get("filter");
+        assertEquals("(osgi.wiring.package=org.osgi.service.cm)", directive);
+        assertEquals(0, req.getAttributes().size());
+        assertNull(req.getResource());
     }
 }
diff --git a/src/test/resources/props3/bundles.properties b/src/test/resources/props3/bundles.properties
new file mode 100644
index 0000000..7497eaf
--- /dev/null
+++ b/src/test/resources/props3/bundles.properties
@@ -0,0 +1,5 @@
+#Generated at Sat Nov 03 10:58:58 GMT 2018
+#Sat Nov 03 10:58:58 GMT 2018
+org.sling\:b3\:1=some.other\:feature\:123,org.sling\:something\:1.2.3\:slingosgifeature\:myclassifier
+org.sling\:b2\:1=org.sling\:something\:1.2.3\:slingosgifeature\:myclassifier
+org.sling\:b1\:1=org.sling\:something\:1.2.3\:slingosgifeature\:myclassifier
diff --git a/src/test/resources/props3/features.properties b/src/test/resources/props3/features.properties
new file mode 100644
index 0000000..9efad8d
--- /dev/null
+++ b/src/test/resources/props3/features.properties
@@ -0,0 +1,4 @@
+#Generated at Sat Nov 03 11:10:29 GMT 2018
+#Sat Nov 03 11:10:29 GMT 2018
+an.other\:feature\:123=global
+org.sling\:something\:1.2.3=internal,global
diff --git a/src/test/resources/props3/idbsnver.properties b/src/test/resources/props3/idbsnver.properties
new file mode 100644
index 0000000..00a740d
--- /dev/null
+++ b/src/test/resources/props3/idbsnver.properties
@@ -0,0 +1,6 @@
+#Generated at Sat Nov 03 10:26:37 GMT 2018
+#Sat Nov 03 10:26:37 GMT 2018
+g\:b2\:1.2.3=b2~1.2.3
+g\:b1\:1=b1~1.0.0
+g2\:b2\:1.2.4=b2~1.2.3
+
diff --git a/src/test/resources/props3/regions.properties b/src/test/resources/props3/regions.properties
new file mode 100644
index 0000000..a4982d7
--- /dev/null
+++ b/src/test/resources/props3/regions.properties
@@ -0,0 +1,4 @@
+#Generated at Sat Nov 03 11:10:29 GMT 2018
+#Sat Nov 03 11:10:29 GMT 2018
+internal=xyz
+global=d.e.f,test,a.b.c