/*******************************************************************************
* Copyright (c) 2015, 2017 Pivotal, Inc.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Pivotal, Inc. - initial API and implementation
*******************************************************************************/
package org.springframework.ide.eclipse.boot.dash.model;
import java.net.URI;
import java.time.Duration;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.debug.core.ILaunch;
import org.eclipse.debug.core.ILaunchConfiguration;
import org.eclipse.debug.core.ILaunchConfigurationWorkingCopy;
import org.eclipse.debug.core.ILaunchManager;
import org.eclipse.debug.ui.DebugUITools;
import org.eclipse.debug.ui.IDebugUIConstants;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.launching.SocketUtil;
import org.eclipse.swt.widgets.Display;
import org.springframework.ide.eclipse.boot.dash.livexp.PollingLiveExp;
import org.springframework.ide.eclipse.boot.dash.metadata.IPropertyStore;
import org.springframework.ide.eclipse.boot.dash.metadata.PropertyStoreApi;
import org.springframework.ide.eclipse.boot.dash.metadata.PropertyStoreFactory;
import org.springframework.ide.eclipse.boot.dash.model.requestmappings.ActuatorClient;
import org.springframework.ide.eclipse.boot.dash.model.requestmappings.JMXActuatorClient;
import org.springframework.ide.eclipse.boot.dash.model.requestmappings.RequestMapping;
import org.springframework.ide.eclipse.boot.dash.ngrok.NGROKClient;
import org.springframework.ide.eclipse.boot.dash.ngrok.NGROKLaunchTracker;
import org.springframework.ide.eclipse.boot.dash.ngrok.NGROKTunnel;
import org.springframework.ide.eclipse.boot.dash.util.CollectionUtils;
import org.springframework.ide.eclipse.boot.dash.util.DebugUtil;
import org.springframework.ide.eclipse.boot.dash.util.LaunchConfRunStateTracker;
import org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.RunStateListener;
import org.springframework.ide.eclipse.boot.launch.BootLaunchConfigurationDelegate;
import org.springframework.ide.eclipse.boot.launch.cli.CloudCliServiceLaunchConfigurationDelegate;
import org.springframework.ide.eclipse.boot.launch.util.BootLaunchUtils;
import org.springframework.ide.eclipse.boot.launch.util.SpringApplicationLifeCycleClientManager;
import org.springframework.ide.eclipse.boot.launch.util.SpringApplicationLifecycleClient;
import org.springframework.ide.eclipse.boot.util.Log;
import org.springframework.ide.eclipse.boot.util.RetryUtil;
import org.springsource.ide.eclipse.commons.frameworks.core.maintype.MainTypeFinder;
import org.springsource.ide.eclipse.commons.livexp.core.AsyncLiveExpression;
import org.springsource.ide.eclipse.commons.livexp.core.DisposeListener;
import org.springsource.ide.eclipse.commons.livexp.core.LiveExpression;
import org.springsource.ide.eclipse.commons.livexp.ui.Disposable;
import org.springsource.ide.eclipse.commons.livexp.util.ExceptionUtil;
import org.springsource.ide.eclipse.commons.ui.launch.LaunchUtils;
import com.google.common.collect.ImmutableSet;
/**
* Abstracts out the commonalities between {@link BootProjectDashElement} and {@link LaunchConfDashElement}. Each can
* be viewed as representing a collection of launch configuration.
* <p>
* A {@link BootProjectDashElement} element represents all the launch configurations associated with a given project whereas as
* {@link LaunchConfDashElement} represent a single launch configuration (i.e. a singleton collection).
*
* @author Kris De Volder
*/
public abstract class AbstractLaunchConfigurationsDashElement<T> extends WrappingBootDashElement<T> implements Duplicatable<LaunchConfDashElement> {
private static final boolean DEBUG = DebugUtil.isDevelopment();
private static void debug(String string) {
if (DEBUG) {
System.out.println(string);
}
}
public static final EnumSet<RunState> READY_STATES = EnumSet.of(RunState.RUNNING, RunState.DEBUGGING);
private static final Duration REQUEST_MAPPING_REFRESH_TIMEOUT = Duration.ofMinutes(2);
private LiveExpression<RunState> runState;
private LiveExpression<Integer> livePort;
private LiveExpression<Integer> actuatorPort;
private LiveExpression<Integer> actualInstances;
private PropertyStoreApi persistentProperties;
private LiveExpression<URI> actuatorUrl;
private PollingLiveExp<List<RequestMapping>> liveRequestMappings;
public AbstractLaunchConfigurationsDashElement(LocalBootDashModel bootDashModel, T delegate) {
super(bootDashModel, delegate);
this.runState = createRunStateExp();
this.livePort = createLivePortExp(runState, "local.server.port");
this.actuatorPort = createLivePortExp(runState, "local.management.port");
this.actualInstances = createActualInstancesExp();
addElementNotifier(livePort);
addElementNotifier(runState);
addElementNotifier(actualInstances);
}
protected abstract IPropertyStore createPropertyStore();
@Override
public abstract ImmutableSet<ILaunchConfiguration> getLaunchConfigs();
@Override
public abstract IProject getProject();
@Override
public abstract String getName();
@Override
public RunState getRunState() {
return runState.getValue();
}
@Override
public String toString() {
return this.getClass().getSimpleName()+"("+getName()+")";
}
@Override
public RunTarget getTarget() {
return getBootDashModel().getRunTarget();
}
@Override
public int getLivePort() {
return livePort.getValue();
}
@Override
public String getLiveHost() {
return "localhost";
}
@Override
public ILaunchConfiguration getActiveConfig() {
ILaunchConfiguration single = CollectionUtils.getSingle(getLaunchConfigs());
if (single!=null) {
return single;
}
return null;
}
@Override
public void stopAsync(UserInteractions ui) {
try {
stop(false);
} catch (Exception e) {
//Asynch case shouldn't really throw exceptions.
Log.log(e);
}
}
private void stop(boolean sync) throws Exception {
debug("Stopping: "+this+" "+(sync?"...":""));
final CompletableFuture<Void> done = sync?new CompletableFuture<>():null;
try {
ImmutableSet<ILaunch> launches = getLaunches();
if (sync) {
LaunchUtils.whenTerminated(launches, new Runnable() {
public void run() {
done.complete(null);
}
});
}
try {
BootLaunchUtils.terminate(launches);
shutdownExpose();
} catch (Exception e) {
//why does terminating process with Eclipse debug UI fail so #$%# often?
Log.log(new Error("Termination of "+this+" failed", e));
}
} catch (Exception e) {
Log.log(e);
}
if (sync) {
//Eclipse waits for 5 seconds before timing out. So we use a similar timeout but slightly
// larger. Windows case termination seem to fail silently sometimes so its up to us
// to handle here.
done.get(6, TimeUnit.SECONDS);
debug("Stopping: "+this+" "+"DONE");
}
}
/**
* Get the launches associated with this element.
* <p>
* Note, we could implement it here by taking the union of all launches for all launch confs,
* but subclass can provide more efficient implementation so we make this abstract.
*/
public abstract ImmutableSet<ILaunch> getLaunches();
@Override
public void restart(RunState runningOrDebugging, UserInteractions ui) throws Exception {
switch (runningOrDebugging) {
case RUNNING:
restart(ILaunchManager.RUN_MODE, ui);
break;
case DEBUGGING:
restart(ILaunchManager.DEBUG_MODE, ui);
break;
default:
throw new IllegalArgumentException("Restart expects RUNNING or DEBUGGING as 'goal' state");
}
}
public void restart(final String runMode, UserInteractions ui) throws Exception {
stopSync();
start(runMode, ui);
}
public void stopSync() throws Exception {
try {
stop(true);
} catch (TimeoutException e) {
Log.info("Termination of '"+this.getName()+"' timed-out. Retrying");
//Try it one more time. On windows this times out occasionally... and then
// it works the next time.
stop(true);
}
}
private void start(final String runMode, UserInteractions ui) {
try {
ILaunchConfiguration conf = getOrCreateLaunchConfig(ui);
if (conf!=null) {
launch(runMode, conf);
}
} catch (Exception e) {
Log.log(e);
}
}
private ILaunchConfiguration getOrCreateLaunchConfig(UserInteractions ui) throws Exception {
ILaunchConfiguration conf = null;
ImmutableSet<ILaunchConfiguration> configs = getLaunchConfigs();
if (configs.isEmpty()) {
IType mainType = chooseMainType(ui);
if (mainType!=null) {
RunTarget target = getTarget();
IJavaProject jp = getJavaProject();
conf = target.createLaunchConfig(jp, mainType);
}
} else {
conf = chooseConfig(ui, configs);
}
return conf;
}
private IType chooseMainType(UserInteractions ui) throws CoreException {
IType[] mainTypes = guessMainTypes();
if (mainTypes.length==0) {
ui.errorPopup("Problem launching", "Couldn't find a main type in '"+getName()+"'");
return null;
} else if (mainTypes.length==1){
return mainTypes[0];
} else {
return ui.chooseMainType(mainTypes, "Choose Main Type", "Choose main type for '"+getName()+"'");
}
}
protected IType[] guessMainTypes() throws CoreException {
return MainTypeFinder.guessMainTypes(getJavaProject(), new NullProgressMonitor());
}
protected void launch(final String runMode, final ILaunchConfiguration conf) {
Display.getDefault().syncExec(new Runnable() {
public void run() {
DebugUITools.launch(conf, runMode);
}
});
}
@Override
public void openConfig(UserInteractions ui) {
try {
IProject p = getProject();
if (p!=null) {
ILaunchConfiguration conf;
ImmutableSet<ILaunchConfiguration> configs = getLaunchConfigs();
if (configs.isEmpty()) {
conf = createLaunchConfigForEditing();
} else {
conf = chooseConfig(ui, configs);
}
if (conf!=null) {
ui.openLaunchConfigurationDialogOnGroup(conf, getLaunchGroup());
}
}
} catch (Exception e) {
ui.errorPopup("Couldn't open config for "+getName(), ExceptionUtil.getMessage(e));
}
}
@Override
public boolean canDuplicate() {
return getLaunchConfigs().size()==1;
}
@Override
public LaunchConfDashElement duplicate(UserInteractions ui) {
try {
ILaunchConfiguration conf = CollectionUtils.getSingle(getLaunchConfigs());
if (conf!=null) {
ILaunchConfiguration newConf = BootLaunchConfigurationDelegate.duplicate(conf);
return getBootDashModel().getLaunchConfElementFactory().createOrGet(newConf);
}
} catch (Exception e) {
Log.log(e);
ui.errorPopup("Couldn't duplicate config", ExceptionUtil.getMessage(e));
}
return null;
}
@Override
public int getDesiredInstances() {
//special case for no launch configs (a single launch conf is created on demand,
//so we should treat it as if it already has one).
return Math.max(1, getLaunchConfigs().size());
}
@Override
public int getActualInstances() {
return actualInstances.getValue();
}
@Override
public PropertyStoreApi getPersistentProperties() {
if (persistentProperties==null) {
IPropertyStore backingStore = createPropertyStore();
this.persistentProperties = PropertyStoreFactory.createApi(backingStore);
}
return persistentProperties;
}
private LaunchConfRunStateTracker runStateTracker() {
return getBootDashModel().getLaunchConfRunStateTracker();
}
protected void refreshRunState() {
runState.refresh();
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public ILaunchConfiguration createLaunchConfigForEditing() throws Exception {
IJavaProject jp = getJavaProject();
RunTarget target = getTarget();
IType[] mainTypes = guessMainTypes();
return target.createLaunchConfig(jp, mainTypes.length==1?mainTypes[0]:null);
}
protected ILaunchConfiguration chooseConfig(UserInteractions ui, Collection<ILaunchConfiguration> configs) {
//TODO: this should probably be removed. Actions etc. should either apply to all the elements at once,
// or be disabled if that seems ill-conceived. In such a ui there should be no need to popup a dialog
// to choose a configuration.
ILaunchConfiguration conf = chooseConfigurationDialog(configs,
"Choose Launch Configuration",
"Several launch configurations are associated with '"+getName()+"' "+
"Choose one.", ui);
return conf;
}
private ILaunchConfiguration chooseConfigurationDialog(Collection<ILaunchConfiguration> configs, String dialogTitle, String message, UserInteractions ui) {
if (configs.size()==1) {
return CollectionUtils.getSingle(configs);
} else if (configs.size()>0) {
ILaunchConfiguration chosen = ui.chooseConfigurationDialog(dialogTitle, message, configs);
return chosen;
}
return null;
}
private String getLaunchGroup() {
switch (getRunState()) {
case RUNNING:
return IDebugUIConstants.ID_RUN_LAUNCH_GROUP;
case DEBUGGING:
return IDebugUIConstants.ID_DEBUG_LAUNCH_GROUP;
default:
return IDebugUIConstants.ID_DEBUG_LAUNCH_GROUP;
}
}
public int getActuatorPort() {
return actuatorPort.getValue();
}
private LiveExpression<RunState> createRunStateExp() {
final LaunchConfRunStateTracker tracker = runStateTracker();
final LiveExpression<RunState> exp = new LiveExpression<RunState>() {
protected RunState compute() {
AbstractLaunchConfigurationsDashElement<T> it = AbstractLaunchConfigurationsDashElement.this;
debug("Computing runstate for "+it);
LaunchConfRunStateTracker tracker = runStateTracker();
RunState state = RunState.INACTIVE;
for (ILaunchConfiguration conf : getLaunchConfigs()) {
RunState confState = tracker.getState(conf);
debug("state for conf "+conf+" = "+confState);
state = state.merge(confState);
}
debug("runstate for "+it+" => "+state);
return state;
}
@Override
public void dispose() {
super.dispose();
}
};
final RunStateListener<ILaunchConfiguration> runStateListener = new RunStateListener<ILaunchConfiguration>() {
@Override
public void stateChanged(ILaunchConfiguration changedConf) {
if (getLaunchConfigs().contains(changedConf)) {
exp.refresh();
}
}
};
tracker.addListener(runStateListener);
exp.onDispose(new DisposeListener() {
public void disposed(Disposable disposed) {
tracker.removeListener(runStateListener);
}
});
addDisposableChild(exp);
exp.refresh();
return exp;
}
private LiveExpression<Integer> createActualInstancesExp() {
final LaunchConfRunStateTracker tracker = runStateTracker();
final LiveExpression<Integer> exp = new LiveExpression<Integer>(0) {
protected Integer compute() {
int activeCount = 0;
for (ILaunchConfiguration c : getLaunchConfigs()) {
if (READY_STATES.contains(tracker.getState(c))) {
activeCount++;
}
}
return activeCount;
}
};
final RunStateListener<ILaunchConfiguration> runStateListener = new RunStateListener<ILaunchConfiguration>() {
@Override
public void stateChanged(ILaunchConfiguration changedConf) {
if (getLaunchConfigs().contains(changedConf)) {
exp.refresh();
}
}
};
tracker.addListener(runStateListener);
exp.onDispose(new DisposeListener() {
public void disposed(Disposable disposed) {
tracker.removeListener(runStateListener);
}
});
addDisposableChild(exp);
exp.refresh();
return exp;
}
private LiveExpression<Integer> createLivePortExp(final LiveExpression<RunState> runState, final String propName) {
AsyncLiveExpression<Integer> exp = new AsyncLiveExpression<Integer>(-1, "Refreshing port info ("+propName+") for "+getName()) {
{
//Doesn't really depend on runState, but should be recomputed when runState changes.
dependsOn(runState);
}
@Override
protected Integer compute() {
return getLivePort(propName);
}
};
addDisposableChild(exp);
return exp;
}
protected ActuatorClient getActuatorClient() {
return new JMXActuatorClient(getTypeLookup(), this::getJmxPort);
}
@Override
public List<RequestMapping> getLiveRequestMappings() {
synchronized (this) {
if (liveRequestMappings==null) {
ActuatorClient client = getActuatorClient();
liveRequestMappings = PollingLiveExp.create(client::getRequestMappings);
addElementState(liveRequestMappings);
addDisposableChild(liveRequestMappings);
runState.addListener((e, runstate) -> {
if (READY_STATES.contains(runstate)) {
liveRequestMappings.refreshFor(REQUEST_MAPPING_REFRESH_TIMEOUT);
} else {
liveRequestMappings.refreshOnce();
}
});
}
return liveRequestMappings.getValue();
}
}
private int getJmxPort() {
for (ILaunchConfiguration c : getLaunchConfigs()) {
for (ILaunch l : LaunchUtils.getLaunches(c)) {
if (!l.isTerminated()) {
int port = BootLaunchConfigurationDelegate.getJMXPortAsInt(l);
if (port>0) {
return port;
}
}
}
}
return -1;
}
private int getLivePort(String propName) {
debug("["+this.getName()+"] getLivePort("+propName+")");
ILaunchConfiguration conf = getActiveConfig();
debug("["+this.getName()+"] getLivePort("+propName+") conf = "+conf);
if (conf!=null && READY_STATES.contains(getRunState())) {
debug("["+this.getName()+"] getLivePort("+propName+") runstate ok");
if (BootLaunchConfigurationDelegate.canUseLifeCycle(conf) || CloudCliServiceLaunchConfigurationDelegate.canUseLifeCycle(conf)) {
debug("["+this.getName()+"] getLivePort("+propName+") canUseLifeCycle ok");
//TODO: what if there are several launches? Right now we ignore all but the first
// non-terminated launch.
for (ILaunch l : BootLaunchUtils.getLaunches(conf)) {
if (!l.isTerminated()) {
debug("["+this.getName()+"] getLivePort("+propName+") found a launch");
int jmxPort = BootLaunchConfigurationDelegate.getJMXPortAsInt(l);
debug("["+this.getName()+"] getLivePort("+propName+") jmxPort = "+jmxPort);
if (jmxPort>0) {
SpringApplicationLifeCycleClientManager cm = null;
try {
cm = new SpringApplicationLifeCycleClientManager(() -> jmxPort);
SpringApplicationLifecycleClient c = cm.getLifeCycleClient();
debug("["+this.getName()+"] getLivePort("+propName+") lifeCycleClient = "+c);
if (c!=null) {
//Just because lifecycle bean is ready does not mean that the port property has already been set.
//To avoid race condition we should wait here until the port is set (some apps aren't web apps and
//may never get a port set, so we shouldn't wait indefinitely!)
return RetryUtil.retry(100, 1000, () -> {
debug("["+this.getName()+"] getLivePort("+propName+") trying to get...");
int port = c.getProperty(propName, -1);
debug("["+this.getName()+"] getLivePort("+propName+") port = "+ port);
if (port<=0) {
throw new IllegalStateException("port not (yet) set");
}
return port;
});
}
} catch (Exception e) {
debug(ExceptionUtil.getMessage(e));
//most likely this just means the app isn't running so ignore
} finally {
if (cm!=null) {
cm.disposeClient();
}
}
}
}
}
}
}
debug("["+this.getName()+"] getLivePort("+propName+") => -1");
return -1;
}
public void restartAndExpose(RunState runMode, NGROKClient ngrokClient, String eurekaInstance, UserInteractions ui) throws Exception {
String launchMode = null;
if (RunState.RUNNING.equals(runMode)) {
launchMode = ILaunchManager.RUN_MODE;
}
else if (RunState.DEBUGGING.equals(runMode)) {
launchMode = ILaunchManager.DEBUG_MODE;
}
else {
throw new IllegalArgumentException("Restart and expose expects RUNNING or DEBUGGING as 'goal' state");
}
int port = getLivePort();
stopSync();
if (port <= 0) {
port = SocketUtil.findFreePort();
}
ILaunchConfiguration launchConfig = getOrCreateLaunchConfig(ui);
if (launchConfig != null) {
String tunnelName = launchConfig.getName();
NGROKTunnel tunnel = ngrokClient.startTunnel("http", Integer.toString(port));
NGROKLaunchTracker.add(tunnelName, ngrokClient, tunnel);
if (tunnel == null) {
ui.errorPopup("ngrok tunnel not started", "there was a problem starting the ngrok tunnel, try again or start a tunnel manually.");
return;
}
String tunnelURL = tunnel.getPublic_url();
if (tunnelURL.startsWith("http://")) {
tunnelURL = tunnelURL.substring(7);
}
Map<String, String> extraAttributes = new HashMap<>();
extraAttributes.put("spring.boot.prop.server.port", "1" + Integer.toString(port));
extraAttributes.put("spring.boot.prop.eureka.instance.hostname", "1" + tunnelURL);
extraAttributes.put("spring.boot.prop.eureka.instance.nonSecurePort", "1" + "80");
extraAttributes.put("spring.boot.prop.eureka.client.service-url.defaultZone", "1" + eurekaInstance);
start(launchMode, launchConfig, extraAttributes);
}
}
private void start(final String runMode, ILaunchConfiguration launchConfig, Map<String, String> extraAttributes) {
try {
if (launchConfig != null) {
ILaunchConfigurationWorkingCopy workingCopy = launchConfig.getWorkingCopy();
removeOverriddenAttributes(workingCopy, extraAttributes);
addAdditionalAttributes(workingCopy, extraAttributes);
launch(runMode, workingCopy);
}
} catch (Exception e) {
Log.log(e);
}
}
private void addAdditionalAttributes(ILaunchConfigurationWorkingCopy workingCopy, Map<String, String> extraAttributes) {
if (extraAttributes != null && extraAttributes.size() > 0) {
Iterator<String> iterator = extraAttributes.keySet().iterator();
while (iterator.hasNext()) {
String key = iterator.next();
String value = extraAttributes.get(key);
workingCopy.setAttribute(key, value);
}
}
}
private void removeOverriddenAttributes(ILaunchConfigurationWorkingCopy workingCopy, Map<String, String> attributesToOverride) {
try {
Map<String, Object> attributes = workingCopy.getAttributes();
Set<String> keys = attributes.keySet();
Iterator<String> iter = keys.iterator();
while (iter.hasNext()) {
String existingKey = iter.next();
if (containsSimilarKey(attributesToOverride, existingKey)) {
workingCopy.removeAttribute(existingKey);
}
}
} catch (CoreException e) {
e.printStackTrace();
}
}
private boolean containsSimilarKey(Map<String, String> attributesToOverride, String existingKey) {
Iterator<String> iter = attributesToOverride.keySet().iterator();
while (iter.hasNext()) {
String overridingKey = iter.next();
if (existingKey.startsWith(overridingKey)) {
return true;
}
}
return false;
}
public void shutdownExpose() {
ImmutableSet<ILaunchConfiguration> launchConfigs = getLaunchConfigs();
for (ILaunchConfiguration launchConfig : launchConfigs) {
String tunnelName = launchConfig.getName();
NGROKClient client = NGROKLaunchTracker.get(tunnelName);
if (client != null) {
client.shutdown();
NGROKLaunchTracker.remove(tunnelName);
}
}
}
@Override
public void dispose() {
super.dispose();
}
public void refreshLivePorts() {
refresh(livePort, actuatorPort);
}
private void refresh(LiveExpression<?>... exps) {
for (LiveExpression<?> e : exps) {
if (e!=null) {
e.refresh();
}
}
}
@Override
public LocalBootDashModel getBootDashModel() {
return (LocalBootDashModel) super.getBootDashModel();
}
@Override
public EnumSet<RunState> supportedGoalStates() {
return RunTargets.LOCAL_RUN_GOAL_STATES;
}
}