Closes #153

Add Jenkinsfile for CI build

This capture the current Jenkins configuration, so that is repeatable and documented.
diff --git a/software/cm/salt/src/main/java/org/apache/brooklyn/entity/cm/salt/impl/SaltLifecycleEffectorTasks.java b/software/cm/salt/src/main/java/org/apache/brooklyn/entity/cm/salt/impl/SaltLifecycleEffectorTasks.java
new file mode 100644
index 0000000..b79c92d
--- /dev/null
+++ b/software/cm/salt/src/main/java/org/apache/brooklyn/entity/cm/salt/impl/SaltLifecycleEffectorTasks.java
@@ -0,0 +1,55 @@
+/*
+ * 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.brooklyn.entity.cm.salt.impl;
+
+import org.apache.brooklyn.api.location.MachineLocation;
+import org.apache.brooklyn.entity.cm.salt.SaltConfig;
+import org.apache.brooklyn.entity.software.base.lifecycle.MachineLifecycleEffectorTasks;
+
+import com.google.common.annotations.Beta;
+import com.google.common.base.Supplier;
+
+/**
+ * Kept only for rebinding to historic persisted state; not used.
+ * Not preserving the functionality of any such persisted entities; just ensuring it deserializes.
+ */
+@Beta
+class SaltLifecycleEffectorTasks extends MachineLifecycleEffectorTasks implements SaltConfig {
+
+    @Override
+    protected String startProcessesAtMachine(Supplier<MachineLocation> machineS) {
+        throw new UnsupportedOperationException("Legacy SaltEntity no longer supported");
+    }
+
+    @Override
+    protected String stopProcessesAtMachine() {
+        throw new UnsupportedOperationException("Legacy SaltEntity no longer supported");
+    }
+    
+    @SuppressWarnings("unused")
+    private void startWithSshAsync() {
+        new Runnable() {
+            @Override
+            public void run() {
+                throw new UnsupportedOperationException("Legacy SaltEntity no longer supported");
+            }
+        };
+        throw new UnsupportedOperationException("Legacy SaltEntity no longer supported");
+    }
+}
diff --git a/software/nosql/src/main/java/org/apache/brooklyn/entity/nosql/couchbase/CouchbaseClusterImpl.java b/software/nosql/src/main/java/org/apache/brooklyn/entity/nosql/couchbase/CouchbaseClusterImpl.java
index 1c9a37c..ed8b7ee 100644
--- a/software/nosql/src/main/java/org/apache/brooklyn/entity/nosql/couchbase/CouchbaseClusterImpl.java
+++ b/software/nosql/src/main/java/org/apache/brooklyn/entity/nosql/couchbase/CouchbaseClusterImpl.java
@@ -290,6 +290,7 @@
     private final static class ListOfHostAndPort implements Function<Set<Entity>, List<String>> {
         @Override public List<String> apply(Set<Entity> input) {
             List<String> addresses = Lists.newArrayList();
+            if (input == null) return addresses;
             for (Entity entity : input) {
                 addresses.add(String.format("%s",
                         BrooklynAccessUtils.getBrooklynAccessibleAddress(entity, entity.getAttribute(CouchbaseNode.COUCHBASE_WEB_ADMIN_PORT))));
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractControllerImpl.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractControllerImpl.java
index 34c324e..bd5bc15 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractControllerImpl.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractControllerImpl.java
@@ -33,6 +33,7 @@
 import org.apache.brooklyn.api.policy.Policy;
 import org.apache.brooklyn.api.policy.PolicySpec;
 import org.apache.brooklyn.api.sensor.AttributeSensor;
+import org.apache.brooklyn.core.annotation.EffectorParam;
 import org.apache.brooklyn.core.entity.Attributes;
 import org.apache.brooklyn.core.entity.Entities;
 import org.apache.brooklyn.core.entity.EntityInternal;
@@ -148,9 +149,25 @@
     protected void removeServerPoolMemberTrackingPolicy() {
         if (serverPoolMemberTrackerPolicy != null) {
             policies().remove(serverPoolMemberTrackerPolicy);
+            serverPoolMemberTrackerPolicy = null;
         }
     }
     
+    protected boolean hasServerPoolMemberTrackingPolicy() {
+        if (serverPoolMemberTrackerPolicy != null) return true;
+
+        // On rebind, might not have set the field yet
+        for (Policy p: policies()) {
+            if (p instanceof ServerPoolMemberTrackerPolicy) {
+                LOG.info(this+" picking up "+p+" as the tracker (already set, often due to rebind)");
+                serverPoolMemberTrackerPolicy = (ServerPoolMemberTrackerPolicy) p;
+                return true;
+            }
+        }
+        
+        return false;
+    }
+
     public static class ServerPoolMemberTrackerPolicy extends AbstractMembershipTrackingPolicy {
         @Override
         protected void onEntityEvent(EventType type, Entity entity) {
@@ -411,8 +428,9 @@
     public Task<?> updateAsync() {
         synchronized (serverPoolAddresses) {
             Task<?> result = null;
-            if (!isActive()) updateNeeded = true;
-            else {
+            if (!isActive()) {
+                updateNeeded = true;
+            } else {
                 updateNeeded = false;
                 LOG.debug("Updating {} in response to changes", this);
                 LOG.info("Updating {}, server pool targets {}", new Object[] {this, getAttribute(SERVER_POOL_TARGETS)});
@@ -425,6 +443,20 @@
         }
     }
 
+    @Override
+    public void changeServerPool(String groupId) {
+        Group newGroup = (Group) getManagementContext().getEntityManager().getEntity(groupId);
+        if (newGroup == null) {
+            throw new IllegalArgumentException("Group '"+groupId+"' not found");
+        }
+        
+        config().set(SERVER_POOL, newGroup);
+        if (hasServerPoolMemberTrackingPolicy()) {
+            addServerPoolMemberTrackingPolicy();
+        }
+        updateNeeded();
+    }
+
     protected void onServerPoolMemberChanged(Entity member) {
         synchronized (serverPoolAddresses) {
             if (LOG.isTraceEnabled()) LOG.trace("For {}, considering membership of {} which is in locations {}", 
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java
index 7ad7224..24d57fa 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java
@@ -39,6 +39,7 @@
 import org.apache.brooklyn.core.feed.ConfigToAttributes;
 import org.apache.brooklyn.entity.group.AbstractMembershipTrackingPolicy;
 import org.apache.brooklyn.entity.proxy.AbstractControllerImpl.MapAttribute;
+import org.apache.brooklyn.entity.proxy.AbstractControllerImpl.ServerPoolMemberTrackerPolicy;
 import org.apache.brooklyn.util.core.task.Tasks;
 import org.apache.brooklyn.util.exceptions.Exceptions;
 import org.slf4j.Logger;
@@ -137,6 +138,9 @@
         sensors().set(MAIN_URI, URI.create(inferUrl()));
         sensors().set(ROOT_URL, inferUrl());
         addServerPoolMemberTrackingPolicy();
+        
+        isActive = true;
+        update();
     }
     
     protected void preStop() {
@@ -212,9 +216,25 @@
     protected void removeServerPoolMemberTrackingPolicy() {
         if (serverPoolMemberTrackerPolicy != null) {
             policies().remove(serverPoolMemberTrackerPolicy);
+            serverPoolMemberTrackerPolicy = null;
         }
     }
     
+    protected boolean hasServerPoolMemberTrackingPolicy() {
+        if (serverPoolMemberTrackerPolicy != null) return true;
+
+        // On rebind, might not have set the field yet
+        for (Policy p: policies()) {
+            if (p instanceof ServerPoolMemberTrackerPolicy) {
+                LOG.info(this+" picking up "+p+" as the tracker (already set, often due to rebind)");
+                serverPoolMemberTrackerPolicy = (ServerPoolMemberTrackerPolicy) p;
+                return true;
+            }
+        }
+        
+        return false;
+    }
+
     public static class ServerPoolMemberTrackerPolicy extends AbstractMembershipTrackingPolicy {
         @Override
         protected void onEntityEvent(EventType type, Entity entity) {
@@ -260,8 +280,9 @@
     public Task<?> updateAsync() {
         synchronized (mutex) {
             Task<?> result = null;
-            if (!isActive()) updateNeeded = true;
-            else {
+            if (!isActive()) {
+                updateNeeded = true;
+            } else {
                 updateNeeded = false;
                 LOG.debug("Updating {} in response to changes", this);
                 LOG.info("Updating {}, server pool targets {}", new Object[] {this, getAttribute(SERVER_POOL_TARGETS)});
@@ -273,7 +294,20 @@
         }
     }
 
-    
+    @Override
+    public void changeServerPool(String groupId) {
+        Group newGroup = (Group) getManagementContext().getEntityManager().getEntity(groupId);
+        if (newGroup == null) {
+            throw new IllegalArgumentException("Group '"+groupId+"' not found");
+        }
+        
+        config().set(SERVER_POOL, newGroup);
+        if (hasServerPoolMemberTrackingPolicy()) {
+            addServerPoolMemberTrackingPolicy();
+        }
+        updateNeeded();
+    }
+
     protected void onServerPoolMemberChanged(Entity member) {
         synchronized (mutex) {
             if (LOG.isTraceEnabled()) LOG.trace("For {}, considering membership of {} which is in locations {}", 
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancer.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancer.java
index 6ff9cb4..689f22b 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancer.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancer.java
@@ -27,6 +27,7 @@
 import org.apache.brooklyn.api.sensor.AttributeSensor;
 import org.apache.brooklyn.config.ConfigKey;
 import org.apache.brooklyn.core.annotation.Effector;
+import org.apache.brooklyn.core.annotation.EffectorParam;
 import org.apache.brooklyn.core.config.BasicConfigKey;
 import org.apache.brooklyn.core.effector.MethodEffector;
 import org.apache.brooklyn.core.entity.Attributes;
@@ -132,6 +133,10 @@
     
     public static final MethodEffector<Void> UPDATE = new MethodEffector<Void>(LoadBalancer.class, "update");
 
+    @Effector(description="Change the target server pool")
+    public void changeServerPool(
+            @EffectorParam(name="groupId") String groupId);
+
     @Effector(description="Forces reload of the configuration")
     public void reload();
 
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancerClusterImpl.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancerClusterImpl.java
index ff23089..863b7ed 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancerClusterImpl.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/LoadBalancerClusterImpl.java
@@ -21,6 +21,7 @@
 import java.util.Map;
 
 import org.apache.brooklyn.api.entity.Entity;
+import org.apache.brooklyn.api.entity.Group;
 import org.apache.brooklyn.entity.group.DynamicClusterImpl;
 
 /**
@@ -73,4 +74,18 @@
             }
         }
     }
+    
+    @Override
+    public void changeServerPool(String groupId) {
+        Group newGroup = (Group) getManagementContext().getEntityManager().getEntity(groupId);
+        if (newGroup == null) {
+            throw new IllegalArgumentException("Group '"+groupId+"' not found");
+        }
+        
+        for (Entity member : getMembers()) {
+            if (member instanceof LoadBalancer) {
+                ((LoadBalancer)member).changeServerPool(groupId);
+            }
+        }
+    }
 }
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Driver.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Driver.java
index d8ba9d7..b6c07f9 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Driver.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Driver.java
@@ -20,5 +20,9 @@
 
 import org.apache.brooklyn.entity.webapp.JavaWebAppDriver;
 
+/**
+ * @deprecated since 1.0.0; JBoss 6 is EOF
+ */
+@Deprecated
 public interface JBoss6Driver extends JavaWebAppDriver {
 }
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Server.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Server.java
index 73100b2..eb59cfc 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Server.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6Server.java
@@ -29,6 +29,12 @@
 import org.apache.brooklyn.util.core.flags.SetFromFlag;
 import org.apache.brooklyn.util.time.Duration;
 
+import com.google.gson.annotations.Since;
+
+/**
+ * @deprecated since 1.0.0; JBoss 6 is EOF
+ */
+@Deprecated
 @Catalog(name="JBoss Application Server 6", description="AS6: an open source Java application server from JBoss", iconUrl="classpath:///jboss-logo.png")
 @ImplementedBy(JBoss6ServerImpl.class)
 public interface JBoss6Server extends JavaWebAppSoftwareProcess, UsesJmx {
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6ServerImpl.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6ServerImpl.java
index ecbd0c5..6520c09 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6ServerImpl.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6ServerImpl.java
@@ -30,6 +30,10 @@
 
 import com.google.common.base.Functions;
 
+/**
+ * @deprecated since 1.0.0; JBoss 6 is EOF
+ */
+@Deprecated
 public class JBoss6ServerImpl extends JavaWebAppSoftwareProcessImpl implements JBoss6Server {
 
     public static final Logger log = LoggerFactory.getLogger(JBoss6ServerImpl.class);
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6SshDriver.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6SshDriver.java
index 1ed0e64..0efc517 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6SshDriver.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss6SshDriver.java
@@ -39,6 +39,10 @@
 import org.apache.brooklyn.util.ssh.BashCommands;
 import org.apache.brooklyn.util.time.Duration;
 
+/**
+ * @deprecated since 1.0.0; JBoss 6 is EOF
+ */
+@Deprecated
 public class JBoss6SshDriver extends JavaWebAppSshDriver implements JBoss6Driver {
 
     public static final String SERVER_TYPE = "standard";
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Driver.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Driver.java
index ad5e101..2bd858d 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Driver.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Driver.java
@@ -20,6 +20,10 @@
 
 import org.apache.brooklyn.entity.webapp.JavaWebAppDriver;
 
+/**
+ * @deprecated since 1.0.0; JBoss 7 is EOF
+ */
+@Deprecated
 public interface JBoss7Driver extends JavaWebAppDriver{
 
     /**
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Server.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Server.java
index 7cb3e1b..af84504 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Server.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7Server.java
@@ -33,6 +33,10 @@
 import org.apache.brooklyn.util.core.flags.SetFromFlag;
 import org.apache.brooklyn.util.javalang.JavaClassNames;
 
+/**
+ * @deprecated since 1.0.0; JBoss 7 is EOF
+ */
+@Deprecated
 @Catalog(name="JBoss Application Server 7", description="AS7: an open source Java application server from JBoss", iconUrl="classpath:///jboss-logo.png")
 @ImplementedBy(JBoss7ServerImpl.class)
 public interface JBoss7Server extends JavaWebAppSoftwareProcess, HasShortName {
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7ServerImpl.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7ServerImpl.java
index 198cafa..b1dfba8 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7ServerImpl.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7ServerImpl.java
@@ -37,6 +37,10 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.net.HostAndPort;
 
+/**
+ * @deprecated since 1.0.0; JBoss 7 is EOF
+ */
+@Deprecated
 public class JBoss7ServerImpl extends JavaWebAppSoftwareProcessImpl implements JBoss7Server {
 
     public static final Logger log = LoggerFactory.getLogger(JBoss7ServerImpl.class);
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7SshDriver.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7SshDriver.java
index 081cd11..7a079d7 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7SshDriver.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/webapp/jboss/JBoss7SshDriver.java
@@ -45,6 +45,10 @@
 import com.google.common.hash.Hashing;
 import com.google.common.io.BaseEncoding;
 
+/**
+ * @deprecated since 1.0.0; JBoss 7 is EOF
+ */
+@Deprecated
 public class JBoss7SshDriver extends JavaWebAppSshDriver implements JBoss7Driver {
 
     private static final Logger LOG = LoggerFactory.getLogger(JBoss7SshDriver.class);
@@ -123,15 +127,31 @@
         List<String> urls = resolver.getTargets();
         String saveAs = resolver.getFilename();
 
-        List<String> commands = new LinkedList<String>();
-        commands.addAll(BashCommands.commandsToDownloadUrlsAs(urls, saveAs));
-        commands.add(BashCommands.INSTALL_TAR);
-        commands.add("tar xzfv " + saveAs);
+        List<String> installCommands = new LinkedList<String>();
+        installCommands.addAll(BashCommands.commandsToDownloadUrlsAs(urls, saveAs));
+        installCommands.add(BashCommands.INSTALL_TAR);
+        installCommands.add("tar xzfv " + saveAs);
 
         newScript(INSTALLING)
                 // don't set vars yet -- it resolves dependencies (e.g. DB) which we don't want until we start
                 .environmentVariablesReset()
-                .body.append(commands)
+                .body.append(installCommands)
+                .execute();
+
+        // The jboss-modules.jar that comes with jBoss 7.1.1 is of version 1.1.1.GA. However, this has issue on initialisation.
+        // Hopefully, version 1.1.5.GA fixes the init issue. However, as jBoss 7.1.1 is EOF, we need to download and replace
+        // this jar before starting up the server
+        // see: https://stackoverflow.com/questions/48403832/javax-xml-parsers-factoryconfigurationerror-running-jboss-as-7-1-with-java-7-upd
+        String installDir = getExpandedInstallDir();
+        List<String> fixBugCommands = new LinkedList<String>();
+        fixBugCommands.add(BashCommands.INSTALL_WGET);
+        fixBugCommands.add(format("rm %s/jboss-modules.jar", installDir));
+        fixBugCommands.add(format("wget http://repo1.maven.org/maven2/org/jboss/modules/jboss-modules/1.1.5.GA/jboss-modules-1.1.5.GA.jar -O %s/jboss-modules.jar", installDir));
+
+        newScript("fix JBoss 7.1.1 init bug")
+                // don't set vars yet -- it resolves dependencies (e.g. DB) which we don't want until we start
+                .environmentVariablesReset()
+                .body.append(fixBugCommands)
                 .execute();
     }
 
diff --git a/software/webapp/src/main/resources/catalog.bom b/software/webapp/src/main/resources/catalog.bom
index 38ab15e..9d99854 100644
--- a/software/webapp/src/main/resources/catalog.bom
+++ b/software/webapp/src/main/resources/catalog.bom
@@ -28,8 +28,9 @@
       iconUrl: classpath:///jboss_logo.png
       item:
         type: org.apache.brooklyn.entity.webapp.jboss.JBoss7Server
-        name: JBoss Application Server 7
+        name: "[DEPRECATED] JBoss Application Server 7"
         description: AS7 - an open source Java application server from JBoss
+        deprecated: true
     - id: org.apache.brooklyn.entity.proxy.nginx.UrlMapping
       item:
         type: org.apache.brooklyn.entity.proxy.nginx.UrlMapping
@@ -48,8 +49,9 @@
       iconUrl: classpath:///jboss_logo.png
       item:
         type: org.apache.brooklyn.entity.webapp.jboss.JBoss6Server
-        name: JBoss Application Server 6
+        name: "[DEPRECATED] JBoss Application Server 6"
         description: AS6 -  an open source Java application server from JBoss
+        deprecated: true
     - id: org.apache.brooklyn.entity.webapp.tomcat.Tomcat8Server
       iconUrl: classpath:///tomcat-logo.png
       item:
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractAbstractControllerTest.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractAbstractControllerTest.java
new file mode 100644
index 0000000..a9065c5
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractAbstractControllerTest.java
@@ -0,0 +1,340 @@
+/*
+ * 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.brooklyn.entity.proxy;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+import java.net.Inet4Address;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.brooklyn.api.entity.Entity;
+import org.apache.brooklyn.api.entity.EntitySpec;
+import org.apache.brooklyn.api.location.Location;
+import org.apache.brooklyn.api.location.LocationSpec;
+import org.apache.brooklyn.api.location.MachineLocation;
+import org.apache.brooklyn.api.location.MachineProvisioningLocation;
+import org.apache.brooklyn.api.location.NoMachinesAvailableException;
+import org.apache.brooklyn.api.sensor.AttributeSensor;
+import org.apache.brooklyn.core.entity.Attributes;
+import org.apache.brooklyn.core.entity.trait.Startable;
+import org.apache.brooklyn.core.location.HasSubnetHostname;
+import org.apache.brooklyn.core.location.Machines;
+import org.apache.brooklyn.core.test.BrooklynAppUnitTestSupport;
+import org.apache.brooklyn.core.test.entity.TestEntity;
+import org.apache.brooklyn.core.test.entity.TestEntityImpl;
+import org.apache.brooklyn.entity.group.Cluster;
+import org.apache.brooklyn.entity.group.DynamicCluster;
+import org.apache.brooklyn.location.byon.FixedListMachineProvisioningLocation;
+import org.apache.brooklyn.location.ssh.SshMachineLocation;
+import org.apache.brooklyn.test.Asserts;
+import org.apache.brooklyn.util.collections.MutableMap;
+import org.apache.brooklyn.util.collections.MutableSet;
+import org.apache.brooklyn.util.core.flags.SetFromFlag;
+import org.apache.brooklyn.util.exceptions.Exceptions;
+import org.apache.brooklyn.util.guava.Maybe;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+
+/**
+ * Sub-classes are concrete tests of {@link AbstractController} and {@link AbstractNonProvisionedController},
+ * hence the weird double-abstract name!
+ */
+public abstract class AbstractAbstractControllerTest<T extends LoadBalancer> extends BrooklynAppUnitTestSupport {
+
+    private static final Logger log = LoggerFactory.getLogger(AbstractAbstractControllerTest.class);
+    
+    protected FixedListMachineProvisioningLocation<?> loc;
+    protected Cluster cluster;
+    protected T controller;
+    
+    @BeforeMethod(alwaysRun = true)
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        
+        List<SshMachineLocation> machines = new ArrayList<SshMachineLocation>();
+        for (int i = 1; i <= 10; i++) {
+            SshMachineLocation machine = mgmt.getLocationManager().createLocation(LocationSpec.create(SshMachineLocation.class)
+                    .configure("address", Inet4Address.getByName("1.1.1."+i)));
+            machines.add(machine);
+        }
+        loc = mgmt.getLocationManager().createLocation(LocationSpec.create(FixedListMachineProvisioningLocation.class)
+                .configure("machines", machines));
+        
+        cluster = app.addChild(EntitySpec.create(DynamicCluster.class)
+                .configure("initialSize", 0)
+                .configure("memberSpec", EntitySpec.create(TestEntity.class).impl(WebServerEntity.class)));
+        
+        controller = newController();
+        
+        app.start(ImmutableList.of(loc));
+    }
+    
+    /**
+     * Called during {@link #setUp()}, after app and cluster are created (but before app.start is called).
+     */
+    protected abstract T newController();
+    
+    protected abstract List<Collection<String>> getUpdates(T controller);
+    
+    // Fixes bug where entity that wrapped an AS7 entity was never added to nginx because hostname+port
+    // was set after service_up. Now we listen to those changes and reset the nginx pool when these
+    // values change.
+    @Test
+    public void testUpdateCalledWhenChildHostnameAndPortChanges() throws Exception {
+        log.info("adding child (no effect until up)");
+        TestEntity child = cluster.addChild(EntitySpec.create(TestEntity.class));
+        cluster.addMember(child);
+
+        List<Collection<String>> u = Lists.newArrayList(getUpdates(controller));
+        assertTrue(u.isEmpty(), "expected no updates, but got "+u);
+        
+        log.info("setting child service_up");
+        child.sensors().set(Startable.SERVICE_UP, true);
+        // above may trigger error logged about no hostname, but should update again with the settings below
+        
+        log.info("setting mymachine:1234");
+        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine");
+        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine");
+        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine:1234"));
+        
+        /* a race failure has been observed, https://issues.apache.org/jira/browse/BROOKLYN-206
+         * but now (two months later) i (alex) can't see how it could happen. 
+         * probably optimistic but maybe it is fixed. if not we'll need the debug logs to see what is happening.
+         * i've confirmed:
+         * * the policy is attached and active during setup, before start completes
+         * * the child is added as a member synchronously
+         * * the policy which is "subscribed to members" is in fact subscribed to everything
+         *   then filtered for members, not ideal, but there shouldn't be a race in the policy getting notices
+         * * the handling of those events are both processed in order and look up the current values
+         *   rather than relying on the published values; either should be sufficient to cause the addresses to change
+         * there was a sleep(100) marked "Ugly sleep to allow AbstractController to detect node having been added"
+         * from the test's addition by aled in early 2014, but can't see why that would be necessary
+         */
+        
+        log.info("setting mymachine2:1234");
+        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine2");
+        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine2");
+        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1234"));
+        
+        log.info("setting mymachine2:1235");
+        child.sensors().set(WebServerEntity.HTTP_PORT, 1235);
+        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1235"));
+        
+        log.info("clearing");
+        child.sensors().set(WebServerEntity.HOSTNAME, null);
+        child.sensors().set(Attributes.SUBNET_HOSTNAME, null);
+        assertEventuallyExplicitAddressesMatch(ImmutableList.<String>of());
+    }
+
+    @Test
+    public void testUpdateCalledWithAddressesOfNewChildren() {
+        // First child
+        cluster.resize(1);
+        Entity child = Iterables.getOnlyElement(cluster.getMembers());
+        
+        List<Collection<String>> u = Lists.newArrayList(getUpdates(controller));
+        assertTrue(u.isEmpty(), "expected empty list but got "+u);
+        
+        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+        child.sensors().set(Startable.SERVICE_UP, true);
+        assertEventuallyAddressesMatchCluster();
+
+        // Second child
+        cluster.resize(2);
+        Asserts.succeedsEventually(new Runnable() {
+            @Override
+            public void run() {
+                assertEquals(cluster.getMembers().size(), 2);
+            }});
+        Entity child2 = Iterables.getOnlyElement(MutableSet.<Entity>builder().addAll(cluster.getMembers()).remove(child).build());
+        
+        child2.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+        child2.sensors().set(Startable.SERVICE_UP, true);
+        assertEventuallyAddressesMatchCluster();
+        
+        // And remove all children; expect all addresses to go away
+        cluster.resize(0);
+        assertEventuallyAddressesMatchCluster();
+    }
+
+    @Test(groups = "Integration", invocationCount=10)
+    public void testUpdateCalledWithAddressesOfNewChildrenManyTimes() {
+        testUpdateCalledWithAddressesOfNewChildren();
+    }
+    
+    @Test
+    public void testUpdateCalledWithAddressesRemovedForStoppedChildren() {
+        // Get some children, so we can remove one...
+        cluster.resize(2);
+        for (Entity it: cluster.getMembers()) { 
+            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+            it.sensors().set(Startable.SERVICE_UP, true);
+        }
+        assertEventuallyAddressesMatchCluster();
+
+        // Now remove one child
+        cluster.resize(1);
+        assertEquals(cluster.getMembers().size(), 1);
+        assertEventuallyAddressesMatchCluster();
+    }
+
+    @Test
+    public void testUpdateCalledWithAddressesRemovedForServiceDownChildrenThatHaveClearedHostnamePort() {
+        // Get some children, so we can remove one...
+        cluster.resize(2);
+        for (Entity it: cluster.getMembers()) { 
+            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+            it.sensors().set(Startable.SERVICE_UP, true);
+        }
+        assertEventuallyAddressesMatchCluster();
+
+        // Now unset host/port, and remove children
+        // Note the unsetting of hostname is done in SoftwareProcessImpl.stop(), so this is realistic
+        for (Entity it : cluster.getMembers()) {
+            it.sensors().set(WebServerEntity.HTTP_PORT, null);
+            it.sensors().set(WebServerEntity.HOSTNAME, null);
+            it.sensors().set(Startable.SERVICE_UP, false);
+        }
+        assertEventuallyAddressesMatch(ImmutableList.<Entity>of());
+    }
+
+    @Test
+    public void testUpdateCalledWhenServerPoolGroupSwapped() {
+        // Second cluster with one child
+        DynamicCluster cluster2 = app.addChild(EntitySpec.create(DynamicCluster.class)
+                .configure("initialSize", 1)
+                .configure("memberSpec", EntitySpec.create(TestEntity.class).impl(WebServerEntity.class)));
+        cluster2.start(ImmutableList.of());
+        
+        Entity child = Iterables.getOnlyElement(cluster2.getMembers());
+        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+        child.sensors().set(Startable.SERVICE_UP, true);
+
+        // Reconfigure the controller to point at the new cluster
+        controller.changeServerPool(cluster2.getId());
+        assertEquals(controller.config().get(LoadBalancer.SERVER_POOL), cluster2);
+        assertEventuallyAddressesMatchCluster(cluster2);
+
+        // And remove all children; expect all addresses to go away
+        cluster2.resize(0);
+        assertEventuallyAddressesMatchCluster(cluster2);
+    }
+
+    protected void assertEventuallyAddressesMatchCluster() {
+        assertEventuallyAddressesMatchCluster(cluster);
+    }
+
+    protected void assertEventuallyAddressesMatchCluster(Cluster cluster) {
+        assertEventuallyAddressesMatch(cluster.getMembers());
+    }
+
+    protected void assertEventuallyAddressesMatch(final Collection<Entity> expectedMembers) {
+        Asserts.succeedsEventually(new Runnable() {
+                @Override public void run() {
+                    assertAddressesMatch(locationsToAddresses(1234, expectedMembers));
+                }} );
+    }
+
+    protected void assertEventuallyExplicitAddressesMatch(final Collection<String> expectedAddresses) {
+        Asserts.succeedsEventually(new Runnable() {
+            @Override public void run() {
+                assertAddressesMatch(expectedAddresses);
+            }} );
+    }
+
+    protected void assertAddressesMatch(final Collection<String> expectedAddresses) {
+        List<Collection<String>> u = Lists.newArrayList(getUpdates(controller));
+        Collection<String> last = Iterables.getLast(u, null);
+        log.debug("test "+u.size()+" updates, expecting "+expectedAddresses+"; actual "+last);
+        assertTrue(!u.isEmpty(), "no updates; expecting "+expectedAddresses);
+        assertEquals(ImmutableSet.copyOf(last), ImmutableSet.copyOf(expectedAddresses), "actual="+last+" expected="+expectedAddresses);
+        assertEquals(last.size(), expectedAddresses.size(), "actual="+last+" expected="+expectedAddresses);
+    }
+
+    protected Collection<String> locationsToAddresses(int port, Collection<Entity> entities) {
+        Set<String> result = MutableSet.of();
+        for (Entity e : entities) {
+            SshMachineLocation machine = Machines.findUniqueMachineLocation(e.getLocations(), SshMachineLocation.class).get();
+            result.add(machine.getAddress().getHostName()+":"+port);
+        }
+        return result;
+    }
+
+    public static class SshMachineLocationWithSubnetHostname extends SshMachineLocation implements HasSubnetHostname {
+        @Override public String getSubnetHostname() {
+            return getSubnetIp();
+        }
+        @Override public String getSubnetIp() {
+            Set<String> addrs = getPrivateAddresses();
+            return (addrs.isEmpty()) ? getAddress().getHostAddress() : Iterables.get(addrs, 0);
+        }
+    }
+    
+    public static class WebServerEntity extends TestEntityImpl {
+        @SetFromFlag("hostname")
+        public static final AttributeSensor<String> HOSTNAME = Attributes.HOSTNAME;
+        
+        @SetFromFlag("port")
+        public static final AttributeSensor<Integer> HTTP_PORT = Attributes.HTTP_PORT;
+        
+        @SetFromFlag("hostAndPort")
+        public static final AttributeSensor<String> HOST_AND_PORT = Attributes.HOST_AND_PORT;
+        
+        MachineProvisioningLocation<MachineLocation> provisioner;
+        
+        @Override
+        public void start(Collection<? extends Location> locs) {
+            provisioner = (MachineProvisioningLocation<MachineLocation>) locs.iterator().next();
+            MachineLocation machine;
+            try {
+                machine = provisioner.obtain(MutableMap.of());
+            } catch (NoMachinesAvailableException e) {
+                throw Exceptions.propagate(e);
+            }
+            addLocations(Arrays.asList(machine));
+            sensors().set(HOSTNAME, machine.getAddress().getHostName());
+            sensors().set(Attributes.SUBNET_HOSTNAME, machine.getAddress().getHostName());
+            sensors().set(Attributes.MAIN_URI_MAPPED_SUBNET, URI.create(machine.getAddress().getHostName()));
+            sensors().set(Attributes.MAIN_URI_MAPPED_PUBLIC, URI.create("http://8.8.8.8:" + sensors().get(HTTP_PORT)));
+        }
+
+        @Override
+        public void stop() {
+            Maybe<MachineLocation> machine = Machines.findUniqueMachineLocation(getLocations(), MachineLocation.class);
+            if (provisioner != null) {
+                provisioner.release(machine.get());
+            }
+        }
+    }
+}
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java
index ea4b4d8..25627d8 100644
--- a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java
@@ -18,210 +18,46 @@
  */
 package org.apache.brooklyn.entity.proxy;
 
-import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertTrue;
 
 import java.net.Inet4Address;
 import java.net.URI;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
-import java.util.Set;
 
-import org.apache.brooklyn.api.entity.Entity;
 import org.apache.brooklyn.api.entity.EntitySpec;
 import org.apache.brooklyn.api.location.Location;
 import org.apache.brooklyn.api.location.LocationSpec;
-import org.apache.brooklyn.api.location.MachineLocation;
-import org.apache.brooklyn.api.location.MachineProvisioningLocation;
-import org.apache.brooklyn.api.location.NoMachinesAvailableException;
-import org.apache.brooklyn.api.sensor.AttributeSensor;
 import org.apache.brooklyn.core.entity.Attributes;
 import org.apache.brooklyn.core.entity.EntityAsserts;
 import org.apache.brooklyn.core.entity.trait.Startable;
-import org.apache.brooklyn.core.location.HasSubnetHostname;
-import org.apache.brooklyn.core.location.Machines;
 import org.apache.brooklyn.core.location.PortRanges;
-import org.apache.brooklyn.core.test.BrooklynAppUnitTestSupport;
 import org.apache.brooklyn.core.test.entity.TestEntity;
-import org.apache.brooklyn.core.test.entity.TestEntityImpl;
-import org.apache.brooklyn.entity.group.Cluster;
-import org.apache.brooklyn.entity.group.DynamicCluster;
-import org.apache.brooklyn.location.byon.FixedListMachineProvisioningLocation;
 import org.apache.brooklyn.location.ssh.SshMachineLocation;
-import org.apache.brooklyn.test.Asserts;
-import org.apache.brooklyn.util.collections.MutableMap;
-import org.apache.brooklyn.util.collections.MutableSet;
-import org.apache.brooklyn.util.core.flags.SetFromFlag;
 import org.apache.brooklyn.util.exceptions.Exceptions;
-import org.apache.brooklyn.util.guava.Maybe;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 
-public class AbstractControllerTest extends BrooklynAppUnitTestSupport {
+public class AbstractControllerTest extends AbstractAbstractControllerTest<TrackingAbstractController> {
 
     private static final Logger log = LoggerFactory.getLogger(AbstractControllerTest.class);
     
-    FixedListMachineProvisioningLocation<?> loc;
-    Cluster cluster;
-    TrackingAbstractController controller;
-    
-    @BeforeMethod(alwaysRun = true)
     @Override
-    public void setUp() throws Exception {
-        super.setUp();
-        
-        List<SshMachineLocation> machines = new ArrayList<SshMachineLocation>();
-        for (int i = 1; i <= 10; i++) {
-            SshMachineLocation machine = mgmt.getLocationManager().createLocation(LocationSpec.create(SshMachineLocation.class)
-                    .configure("address", Inet4Address.getByName("1.1.1."+i)));
-            machines.add(machine);
-        }
-        loc = mgmt.getLocationManager().createLocation(LocationSpec.create(FixedListMachineProvisioningLocation.class)
-                .configure("machines", machines));
-        
-        cluster = app.addChild(EntitySpec.create(DynamicCluster.class)
-                .configure("initialSize", 0)
-                .configure("memberSpec", EntitySpec.create(TestEntity.class).impl(WebServerEntity.class)));
-        
-        controller = app.addChild(EntitySpec.create(TrackingAbstractController.class)
+    protected TrackingAbstractController newController() {
+        return app.addChild(EntitySpec.create(TrackingAbstractController.class)
                 .configure("serverPool", cluster) 
                 .configure("portNumberSensor", WebServerEntity.HTTP_PORT)
                 .configure("domain", "mydomain"));
-        
-        app.start(ImmutableList.of(loc));
     }
     
-    // Fixes bug where entity that wrapped an AS7 entity was never added to nginx because hostname+port
-    // was set after service_up. Now we listen to those changes and reset the nginx pool when these
-    // values change.
-    @Test
-    public void testUpdateCalledWhenChildHostnameAndPortChanges() throws Exception {
-        log.info("adding child (no effect until up)");
-        TestEntity child = cluster.addChild(EntitySpec.create(TestEntity.class));
-        cluster.addMember(child);
-
-        List<Collection<String>> u = Lists.newArrayList(controller.getUpdates());
-        assertTrue(u.isEmpty(), "expected no updates, but got "+u);
-        
-        log.info("setting child service_up");
-        child.sensors().set(Startable.SERVICE_UP, true);
-        // above may trigger error logged about no hostname, but should update again with the settings below
-        
-        log.info("setting mymachine:1234");
-        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine");
-        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine");
-        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine:1234"));
-        
-        /* a race failure has been observed, https://issues.apache.org/jira/browse/BROOKLYN-206
-         * but now (two months later) i (alex) can't see how it could happen. 
-         * probably optimistic but maybe it is fixed. if not we'll need the debug logs to see what is happening.
-         * i've confirmed:
-         * * the policy is attached and active during setup, before start completes
-         * * the child is added as a member synchronously
-         * * the policy which is "subscribed to members" is in fact subscribed to everything
-         *   then filtered for members, not ideal, but there shouldn't be a race in the policy getting notices
-         * * the handling of those events are both processed in order and look up the current values
-         *   rather than relying on the published values; either should be sufficient to cause the addresses to change
-         * there was a sleep(100) marked "Ugly sleep to allow AbstractController to detect node having been added"
-         * from the test's addition by aled in early 2014, but can't see why that would be necessary
-         */
-        
-        log.info("setting mymachine2:1234");
-        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine2");
-        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine2");
-        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1234"));
-        
-        log.info("setting mymachine2:1235");
-        child.sensors().set(WebServerEntity.HTTP_PORT, 1235);
-        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1235"));
-        
-        log.info("clearing");
-        child.sensors().set(WebServerEntity.HOSTNAME, null);
-        child.sensors().set(Attributes.SUBNET_HOSTNAME, null);
-        assertEventuallyExplicitAddressesMatch(ImmutableList.<String>of());
-    }
-
-    @Test
-    public void testUpdateCalledWithAddressesOfNewChildren() {
-        // First child
-        cluster.resize(1);
-        Entity child = Iterables.getOnlyElement(cluster.getMembers());
-        
-        List<Collection<String>> u = Lists.newArrayList(controller.getUpdates());
-        assertTrue(u.isEmpty(), "expected empty list but got "+u);
-        
-        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-        child.sensors().set(Startable.SERVICE_UP, true);
-        assertEventuallyAddressesMatchCluster();
-
-        // Second child
-        cluster.resize(2);
-        Asserts.succeedsEventually(new Runnable() {
-            @Override
-            public void run() {
-                assertEquals(cluster.getMembers().size(), 2);
-            }});
-        Entity child2 = Iterables.getOnlyElement(MutableSet.<Entity>builder().addAll(cluster.getMembers()).remove(child).build());
-        
-        child2.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-        child2.sensors().set(Startable.SERVICE_UP, true);
-        assertEventuallyAddressesMatchCluster();
-        
-        // And remove all children; expect all addresses to go away
-        cluster.resize(0);
-        assertEventuallyAddressesMatchCluster();
-    }
-
-    @Test(groups = "Integration", invocationCount=10)
-    public void testUpdateCalledWithAddressesOfNewChildrenManyTimes() {
-        testUpdateCalledWithAddressesOfNewChildren();
-    }
-    
-    @Test
-    public void testUpdateCalledWithAddressesRemovedForStoppedChildren() {
-        // Get some children, so we can remove one...
-        cluster.resize(2);
-        for (Entity it: cluster.getMembers()) { 
-            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-            it.sensors().set(Startable.SERVICE_UP, true);
-        }
-        assertEventuallyAddressesMatchCluster();
-
-        // Now remove one child
-        cluster.resize(1);
-        assertEquals(cluster.getMembers().size(), 1);
-        assertEventuallyAddressesMatchCluster();
-    }
-
-    @Test
-    public void testUpdateCalledWithAddressesRemovedForServiceDownChildrenThatHaveClearedHostnamePort() {
-        // Get some children, so we can remove one...
-        cluster.resize(2);
-        for (Entity it: cluster.getMembers()) { 
-            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-            it.sensors().set(Startable.SERVICE_UP, true);
-        }
-        assertEventuallyAddressesMatchCluster();
-
-        // Now unset host/port, and remove children
-        // Note the unsetting of hostname is done in SoftwareProcessImpl.stop(), so this is realistic
-        for (Entity it : cluster.getMembers()) {
-            it.sensors().set(WebServerEntity.HTTP_PORT, null);
-            it.sensors().set(WebServerEntity.HOSTNAME, null);
-            it.sensors().set(Startable.SERVICE_UP, false);
-        }
-        assertEventuallyAddressesMatch(ImmutableList.<Entity>of());
+    @Override
+    protected List<Collection<String>> getUpdates(TrackingAbstractController controller) {
+        return controller.getUpdates();
     }
 
     @Test
@@ -333,86 +169,4 @@
         EntityAsserts.assertAttributeEquals(controller2, Attributes.MAIN_URI_MAPPED_PUBLIC, URI.create("http://1.1.1.1:8081/"));
         EntityAsserts.assertAttributeEquals(controller2, Attributes.MAIN_URI_MAPPED_SUBNET, URI.create("http://2.2.2.2:8081/"));
     }
-    public static class SshMachineLocationWithSubnetHostname extends SshMachineLocation implements HasSubnetHostname {
-        @Override public String getSubnetHostname() {
-            return getSubnetIp();
-        }
-        @Override public String getSubnetIp() {
-            Set<String> addrs = getPrivateAddresses();
-            return (addrs.isEmpty()) ? getAddress().getHostAddress() : Iterables.get(addrs, 0);
-        }
-    }
-    
-    private void assertEventuallyAddressesMatchCluster() {
-        assertEventuallyAddressesMatch(cluster.getMembers());
-    }
-
-    private void assertEventuallyAddressesMatch(final Collection<Entity> expectedMembers) {
-        Asserts.succeedsEventually(new Runnable() {
-                @Override public void run() {
-                    assertAddressesMatch(locationsToAddresses(1234, expectedMembers));
-                }} );
-    }
-
-    private void assertEventuallyExplicitAddressesMatch(final Collection<String> expectedAddresses) {
-        Asserts.succeedsEventually(new Runnable() {
-            @Override public void run() {
-                assertAddressesMatch(expectedAddresses);
-            }} );
-    }
-
-    private void assertAddressesMatch(final Collection<String> expectedAddresses) {
-        List<Collection<String>> u = Lists.newArrayList(controller.getUpdates());
-        Collection<String> last = Iterables.getLast(u, null);
-        log.debug("test "+u.size()+" updates, expecting "+expectedAddresses+"; actual "+last);
-        assertTrue(!u.isEmpty(), "no updates; expecting "+expectedAddresses);
-        assertEquals(ImmutableSet.copyOf(last), ImmutableSet.copyOf(expectedAddresses), "actual="+last+" expected="+expectedAddresses);
-        assertEquals(last.size(), expectedAddresses.size(), "actual="+last+" expected="+expectedAddresses);
-    }
-
-    private Collection<String> locationsToAddresses(int port, Collection<Entity> entities) {
-        Set<String> result = MutableSet.of();
-        for (Entity e : entities) {
-            SshMachineLocation machine = Machines.findUniqueMachineLocation(e.getLocations(), SshMachineLocation.class).get();
-            result.add(machine.getAddress().getHostName()+":"+port);
-        }
-        return result;
-    }
-
-    public static class WebServerEntity extends TestEntityImpl {
-        @SetFromFlag("hostname")
-        public static final AttributeSensor<String> HOSTNAME = Attributes.HOSTNAME;
-        
-        @SetFromFlag("port")
-        public static final AttributeSensor<Integer> HTTP_PORT = Attributes.HTTP_PORT;
-        
-        @SetFromFlag("hostAndPort")
-        public static final AttributeSensor<String> HOST_AND_PORT = Attributes.HOST_AND_PORT;
-        
-        MachineProvisioningLocation<MachineLocation> provisioner;
-        
-        @Override
-        public void start(Collection<? extends Location> locs) {
-            provisioner = (MachineProvisioningLocation<MachineLocation>) locs.iterator().next();
-            MachineLocation machine;
-            try {
-                machine = provisioner.obtain(MutableMap.of());
-            } catch (NoMachinesAvailableException e) {
-                throw Exceptions.propagate(e);
-            }
-            addLocations(Arrays.asList(machine));
-            sensors().set(HOSTNAME, machine.getAddress().getHostName());
-            sensors().set(Attributes.SUBNET_HOSTNAME, machine.getAddress().getHostName());
-            sensors().set(Attributes.MAIN_URI_MAPPED_SUBNET, URI.create(machine.getAddress().getHostName()));
-            sensors().set(Attributes.MAIN_URI_MAPPED_PUBLIC, URI.create("http://8.8.8.8:" + sensors().get(HTTP_PORT)));
-        }
-
-        @Override
-        public void stop() {
-            Maybe<MachineLocation> machine = Machines.findUniqueMachineLocation(getLocations(), MachineLocation.class);
-            if (provisioner != null) {
-                provisioner.release(machine.get());
-            }
-        }
-    }
 }
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerTest.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerTest.java
new file mode 100644
index 0000000..88094f4
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.brooklyn.entity.proxy;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.brooklyn.api.entity.EntitySpec;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AbstractNonProvisionedControllerTest extends AbstractAbstractControllerTest<TrackingAbstractNonProvisionedController> {
+
+    // TODO Duplication of AbstractControllerTest
+    
+    private static final Logger log = LoggerFactory.getLogger(AbstractNonProvisionedControllerTest.class);
+    
+    @Override
+    protected TrackingAbstractNonProvisionedController newController() {
+        return app.addChild(EntitySpec.create(TrackingAbstractNonProvisionedController.class)
+                .configure("serverPool", cluster) 
+                .configure("portNumberSensor", WebServerEntity.HTTP_PORT)
+                .configure("domain", "mydomain"));
+    }
+    
+    @Override
+    protected List<Collection<String>> getUpdates(TrackingAbstractNonProvisionedController controller) {
+        return controller.getUpdates();
+    }
+}
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedController.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedController.java
new file mode 100644
index 0000000..f0c6606
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedController.java
@@ -0,0 +1,29 @@
+/*
+ * 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.brooklyn.entity.proxy;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.brooklyn.api.entity.ImplementedBy;
+
+@ImplementedBy(TrackingAbstractNonProvisionedControllerImpl.class)
+public interface TrackingAbstractNonProvisionedController extends AbstractNonProvisionedController {
+    List<Collection<String>> getUpdates();
+}
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedControllerImpl.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedControllerImpl.java
new file mode 100644
index 0000000..896e5d6
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedControllerImpl.java
@@ -0,0 +1,81 @@
+/*
+ * 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.brooklyn.entity.proxy;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.brooklyn.api.location.Location;
+import org.apache.brooklyn.util.text.Identifiers;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.collect.Lists;
+
+public class TrackingAbstractNonProvisionedControllerImpl extends AbstractNonProvisionedControllerImpl implements TrackingAbstractNonProvisionedController {
+    
+    private static final Logger log = LoggerFactory.getLogger(TrackingAbstractNonProvisionedControllerImpl.class);
+
+    private final List<Collection<String>> updates = Lists.newCopyOnWriteArrayList();
+
+    @Override
+    public void start(Collection<? extends Location> locations) {
+        sensors().set(HOSTNAME, Identifiers.makeRandomId(8) + ".test.brooklyn.apache.org");
+        super.start(locations);
+    }
+    
+    @Override
+    public List<Collection<String>> getUpdates() {
+        return updates;
+    }
+    
+    @Override
+    protected void reconfigureService() {
+        Set<String> addresses = getServerPoolAddresses();
+        log.info("test controller reconfigure, targets "+addresses);
+        if ((!addresses.isEmpty() && updates.isEmpty()) || (!updates.isEmpty() && addresses != updates.get(updates.size()-1))) {
+            updates.add(addresses);
+        }
+    }
+
+    @Override
+    public void reload() {
+        // no-op
+    }
+
+    @Override
+    public void restart() {
+        // no-op
+    }
+
+    @Override
+    protected String inferProtocol() {
+        String result = config().get(PROTOCOL);
+        return (result == null ? result : result.toLowerCase());
+    }
+
+    @Override
+    protected String inferUrl() {
+        String scheme = inferProtocol();
+        Integer port = sensors().get(PROXY_HTTP_PORT);
+        String domainName = sensors().get(HOSTNAME);
+        return scheme + "://" + domainName + ":" + port;
+    }
+}