Skip to content

Commit 53e156a

Browse files
committed
Feat: Remote Deployment and Running
Adds the ability to deploy and run GRIP on a remote platform. There is currently no GUI for it at the moment
1 parent 8ad6c1a commit 53e156a

6 files changed

Lines changed: 469 additions & 0 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ project(":ui") {
191191
compile project(path: ':core', configuration: 'shadow')
192192
ideProvider project(path: ':core', configuration: 'compile')
193193
compile group: 'org.controlsfx', name: 'controlsfx', version: '8.40.10'
194+
compile group: 'org.apache.ant', name: 'ant-jsch', version: '1.8.1'
195+
compile group: 'com.jcabi', name: 'jcabi-ssh', version: '1.5'
196+
compile group: 'org.jdeferred', name: 'jdeferred-core', version: '1.2.4'
194197
testCompile files(project(':core').sourceSets.test.output.classesDir)
195198
testCompile files(project(':core').sourceSets.test.output.resourcesDir)
196199
testCompile group: 'org.testfx', name: 'testfx-core', version: '4.0.+'
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package edu.wpi.grip.ui.util.deployment;
2+
3+
4+
import com.google.common.eventbus.EventBus;
5+
import com.jcabi.ssh.Shell;
6+
import com.jcraft.jsch.JSch;
7+
import edu.wpi.grip.core.StartStoppable;
8+
import edu.wpi.grip.core.events.StartedStoppedEvent;
9+
import edu.wpi.grip.core.events.UnexpectedThrowableEvent;
10+
import edu.wpi.grip.core.serialization.Project;
11+
import org.apache.commons.io.input.NullInputStream;
12+
import org.apache.commons.io.output.NullOutputStream;
13+
import org.apache.tools.ant.BuildException;
14+
import org.apache.tools.ant.taskdefs.optional.ssh.Scp;
15+
import org.jdeferred.DeferredCallable;
16+
import org.jdeferred.DeferredManager;
17+
import org.jdeferred.Promise;
18+
import org.jdeferred.impl.DefaultDeferredManager;
19+
20+
import java.io.File;
21+
import java.io.IOException;
22+
import java.io.OutputStream;
23+
import java.net.InetAddress;
24+
import java.net.URISyntaxException;
25+
import java.net.URLDecoder;
26+
import java.nio.file.Paths;
27+
import java.util.Optional;
28+
import java.util.function.Supplier;
29+
30+
import static com.google.common.base.Preconditions.checkNotNull;
31+
32+
/**
33+
* Controls an instance of GRIP running on a remote device.
34+
*/
35+
public class DeployedInstanceManager implements StartStoppable {
36+
37+
private final EventBus eventBus;
38+
private final File coreJar;
39+
private final File projectFile;
40+
private final SecureShellDetails details;
41+
private final DeploymentCommands deploymentCommands;
42+
private final Supplier<OutputStream> stdOut;
43+
private final Supplier<OutputStream> stdErr;
44+
private Optional<Thread> sshThread;
45+
46+
public static class Factory {
47+
private final EventBus eventBus;
48+
private final File coreJAR;
49+
private final Project project;
50+
private final SecureShellDetails.Factory secureShellDetailsFactory;
51+
private final DeploymentCommands.Factory deploymentCommandsFactory;
52+
53+
public Factory(EventBus eventBus, Project project, SecureShellDetails.Factory secureShellDetailsFactory, DeploymentCommands.Factory deploymentCommandsFactory) {
54+
this.eventBus = eventBus;
55+
try {
56+
this.coreJAR = new File(edu.wpi.grip.core.Main.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath());
57+
} catch (URISyntaxException e) {
58+
throw new IllegalStateException("Could not find the main class jar file", e);
59+
}
60+
this.project = project;
61+
this.secureShellDetailsFactory = secureShellDetailsFactory;
62+
this.deploymentCommandsFactory = deploymentCommandsFactory;
63+
}
64+
65+
public Factory(EventBus eventBus, Project project) {
66+
this(eventBus, project, new SecureShellDetails.Factory(), new DeploymentCommands.Factory());
67+
}
68+
69+
public DeployedInstanceManager createFRC(InetAddress address) {
70+
return createFRC(address, NullOutputStream::new, NullOutputStream::new);
71+
}
72+
73+
public DeployedInstanceManager createFRC(InetAddress address, Supplier<OutputStream> stdOut, Supplier<OutputStream> stdErr) {
74+
final File projectFile = project.getFile().get();
75+
return createFRC(address, projectFile, stdOut, stdErr);
76+
}
77+
78+
public DeployedInstanceManager createFRC(InetAddress address, File projectFile) {
79+
return createFRC(address, projectFile, NullOutputStream::new, NullOutputStream::new);
80+
}
81+
82+
public DeployedInstanceManager createFRC(InetAddress addresses, File projectFile, Supplier<OutputStream> stdOut, Supplier<OutputStream> stdErr) {
83+
return new DeployedInstanceManager(eventBus, coreJAR, projectFile, secureShellDetailsFactory.createFRC(addresses), deploymentCommandsFactory.createFRC(), stdOut, stdErr);
84+
}
85+
}
86+
87+
/**
88+
* @param eventBus
89+
* @param coreJar The jar with all of the core. This will be copied to the destination
90+
* @param projectFile The project file to send over
91+
* @param details The details regarding connecting to the secure shell
92+
* @param deploymentCommands The commands required to start and stop GRIP
93+
* @param stdOut Supplies the stream to be used for the standard output from the ssh command
94+
* @param stdErr Supplies the stream to be used for the standard error from the ssh command
95+
*/
96+
public DeployedInstanceManager(EventBus eventBus,
97+
File coreJar,
98+
File projectFile,
99+
SecureShellDetails details,
100+
DeploymentCommands deploymentCommands,
101+
Supplier<OutputStream> stdOut,
102+
Supplier<OutputStream> stdErr) {
103+
this.eventBus = checkNotNull(eventBus, "The event bus can not be null");
104+
this.coreJar = checkNotNull(coreJar, "The URI of the coreJar can not be null");
105+
this.projectFile = checkNotNull(projectFile, "The project file can not be null");
106+
this.details = checkNotNull(details, "The details can not be null");
107+
this.deploymentCommands = checkNotNull(deploymentCommands, "The deployment commands can not be null");
108+
this.stdOut = checkNotNull(stdOut, "The standard out stream supplier can not be null");
109+
this.stdErr = checkNotNull(stdErr, "The standard err stream supplier can not be null");
110+
sshThread = Optional.empty();
111+
}
112+
113+
/**
114+
* Deploys the coreJar and the project file to the remote device
115+
*
116+
*/
117+
public synchronized Promise<DeployedInstanceManager, Throwable, Double> deploy() {
118+
final DeployedInstanceManager self = this;
119+
JSch.setConfig("StrictHostKeyChecking", "no");
120+
final DeferredManager deferred = new DefaultDeferredManager();
121+
122+
return deferred.when(new DeferredCallable<DeployedInstanceManager, Double>() {
123+
@Override
124+
public DeployedInstanceManager call() throws Exception {
125+
notify(0.2);
126+
scpFileToTarget(coreJar);
127+
notify(0.5);
128+
scpFileToTarget(projectFile);
129+
notify(1.0);
130+
return self;
131+
}
132+
133+
});
134+
}
135+
136+
/**
137+
* @param file The file to send
138+
* @throws IOException If there is a problem sending the file.
139+
*/
140+
private void scpFileToTarget(File file) throws IOException {
141+
final String localFile = URLDecoder.decode(Paths.get(file.toURI()).toString());
142+
try {
143+
final Scp scp = details.createSCPRunner();
144+
scp.setLocalFile(localFile);
145+
scp.execute();
146+
} catch (BuildException e) {
147+
throw new IOException("Failed to deploy", e);
148+
}
149+
}
150+
151+
/**
152+
* Starts GRIP running on the device specified by the secure shell details
153+
*
154+
* @throws IOException
155+
*/
156+
public synchronized void start() throws IOException {
157+
if (isStarted()) {
158+
throw new IllegalStateException("The program has already been started and must be stopped before restarting");
159+
}
160+
// Ensure that the project isn't running from a previous instance.
161+
runStop();
162+
Thread launcher = new Thread(() -> {
163+
try {
164+
final Shell gripShell = new Shell.Safe(details.createSSHShell());
165+
gripShell.exec("nohup " + deploymentCommands.getJARLaunchCommand(coreJar.getName(), projectFile.getName()) + " &",
166+
new NullInputStream(0L),
167+
stdOut.get(),
168+
stdErr.get());
169+
} catch (IOException e) {
170+
throw new IllegalStateException("The program failed to start", e);
171+
} finally {
172+
// This thread is done, shut it down.
173+
synchronized (this) {
174+
sshThread = Optional.empty();
175+
}
176+
}
177+
}, "SSH Monitor Thread");
178+
launcher.setUncaughtExceptionHandler((thread, exception) -> {
179+
eventBus.post(new UnexpectedThrowableEvent(exception, "Failed to start the remote instance of the application"));
180+
try {
181+
runStop();
182+
} catch (IOException e) {
183+
eventBus.post(new UnexpectedThrowableEvent(e, "Failed to stop the remote instance of the program"));
184+
}
185+
});
186+
launcher.setDaemon(true);
187+
launcher.start();
188+
this.sshThread = Optional.of(launcher);
189+
eventBus.post(new StartedStoppedEvent(this));
190+
}
191+
192+
/**
193+
* Stops the program running on the remote device
194+
*
195+
* @throws IOException If the command fails to be delivered
196+
*/
197+
public synchronized void stop() throws IOException {
198+
if (!isStarted()) {
199+
throw new IllegalStateException("The program hasn't started yet.");
200+
}
201+
runStop();
202+
do {
203+
try {
204+
// Since we hold the mutex on this we can wait
205+
wait(50);
206+
} catch (InterruptedException e) {
207+
Thread.currentThread().interrupt();
208+
//TODO: Move this into a logging framework
209+
System.err.println("Caught Exception:");
210+
e.printStackTrace();
211+
}
212+
runStop();
213+
} while (isStarted() && !Thread.interrupted());
214+
eventBus.post(new StartedStoppedEvent(this));
215+
}
216+
217+
private void runStop() throws IOException {
218+
final Shell.Plain gripShell = new Shell.Plain(new Shell.Safe(details.createSSHShell()));
219+
gripShell.exec(deploymentCommands.getKillCommand(coreJar.getName()));
220+
}
221+
222+
@Override
223+
public synchronized boolean isStarted() {
224+
return sshThread.isPresent() && sshThread.get().isAlive();
225+
}
226+
227+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package edu.wpi.grip.ui.util.deployment;
2+
3+
4+
import java.util.function.Function;
5+
6+
import static com.google.common.base.Preconditions.checkNotNull;
7+
8+
9+
/**
10+
* The commands to be used to launch and kill the deployed program.
11+
*/
12+
public class DeploymentCommands {
13+
protected static final String DEFAULT_JAVA_COMMAND = "java";
14+
protected static final Function<String, String>
15+
DEFAULT_KILL_BY_NAME = name ->
16+
"kill $(ps aux | grep \"" + name + "\" | grep -v 'grep' | awk '{print $1}') || :";
17+
private final String javaCommand;
18+
private final Function<String, String> killByNameCommand;
19+
20+
public static class Factory {
21+
22+
public DeploymentCommands createFRC() {
23+
return new DeploymentCommands("/usr/local/frc/JRE/bin/java", DEFAULT_KILL_BY_NAME);
24+
}
25+
}
26+
27+
DeploymentCommands(String javaCommand, Function<String, String> killByNameCommand) {
28+
this.javaCommand = checkNotNull(javaCommand, "The java command can not be null");
29+
this.killByNameCommand = checkNotNull(killByNameCommand, "The kill by name consumer can not be null");
30+
}
31+
32+
public DeploymentCommands() {
33+
this(DEFAULT_JAVA_COMMAND, DEFAULT_KILL_BY_NAME);
34+
}
35+
36+
/**
37+
* @param jarFile The name of the jar file
38+
* @param projectFile The name of the project file
39+
* @return The launch command
40+
*/
41+
protected String getJARLaunchCommand(String jarFile, String projectFile) {
42+
return this.javaCommand + " -jar " + jarFile + " " + projectFile;
43+
}
44+
45+
/**
46+
* @param name The name of the process to kill
47+
* @return The command to kill the program running remotely
48+
*/
49+
protected String getKillCommand(String name) {
50+
return killByNameCommand.apply(name);
51+
}
52+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package edu.wpi.grip.ui.util.deployment;
2+
3+
import com.jcabi.ssh.SSHByPassword;
4+
import com.jcabi.ssh.Shell;
5+
import org.apache.tools.ant.Project;
6+
import org.apache.tools.ant.taskdefs.optional.ssh.Scp;
7+
8+
import java.net.InetAddress;
9+
import java.net.UnknownHostException;
10+
import java.util.Optional;
11+
12+
import static com.google.common.base.Preconditions.checkNotNull;
13+
14+
/**
15+
* Contains all of the details for securely copying grip core and a save file to another device.
16+
*/
17+
public class SecureShellDetails {
18+
private static final int DEFAULT_PORT = 22;
19+
20+
private final String userSSH;
21+
private final Optional<String> password;
22+
private final String host;
23+
private final Optional<String> remoteDir;
24+
private final int port;
25+
26+
public static class Factory {
27+
SecureShellDetails createFRC(InetAddress address) {
28+
return new SecureShellDetails("lvuser", address.getHostAddress());
29+
}
30+
}
31+
32+
/**
33+
* @param userSSH The username to connect to
34+
* @param password The password to use, nullable
35+
* @param host The host's address
36+
* @param remoteDir The remote directory to ssh into
37+
* @param port The port to to use.
38+
*/
39+
public SecureShellDetails(String userSSH, String password, String host, String remoteDir, int port) {
40+
this.userSSH = checkNotNull(userSSH, "userSSH can not be null");
41+
this.password = Optional.ofNullable(password);
42+
this.host = checkNotNull(host, "host can not be null");
43+
this.remoteDir = Optional.ofNullable(remoteDir);
44+
this.port = port;
45+
}
46+
47+
public SecureShellDetails(String userSSH, String password, String serverSSH, String remoteDir) {
48+
this(userSSH, password, serverSSH, remoteDir, DEFAULT_PORT);
49+
}
50+
51+
public SecureShellDetails(String userSSH, String host, String remoteDir) {
52+
this(userSSH, null, host, remoteDir, DEFAULT_PORT);
53+
}
54+
55+
public SecureShellDetails(String userSSH, String host) {
56+
this(userSSH, null, host, null, DEFAULT_PORT);
57+
}
58+
59+
60+
protected int getPort() {
61+
return port;
62+
}
63+
64+
protected String host() {
65+
return host;
66+
}
67+
68+
public String getUserSSH() {
69+
return userSSH;
70+
}
71+
72+
protected Optional<String> getPassword() {
73+
return password;
74+
}
75+
76+
protected Scp createSCPRunner() {
77+
Scp scp = new Scp();
78+
scp.setPort(getPort());
79+
scp.setPassword(getPassword().orElse(""));
80+
scp.setTodir(getToDir());
81+
scp.setProject(new Project());
82+
scp.setTrust(true);
83+
return scp;
84+
}
85+
86+
protected Shell createSSHShell() throws UnknownHostException {
87+
return new SSHByPassword(host(), getPort(), getUserSSH(), getPassword().orElse(""));
88+
}
89+
90+
/**
91+
* @return The directory to SCP the files to
92+
*/
93+
protected String getToDir() {
94+
// userSSH + ":" + password + "@" + srvrSSH + ":" + remoteDir;
95+
String sshDirCommand = userSSH;
96+
if (password.isPresent()) {
97+
sshDirCommand += (":" + password.get());
98+
}
99+
sshDirCommand += ("@" + host + ":");
100+
if (remoteDir.isPresent()) {
101+
sshDirCommand += remoteDir.get();
102+
}
103+
return sshDirCommand;
104+
}
105+
}

0 commit comments

Comments
 (0)