From 5d9abd773473961272f2acb33d34cca1d4b8f4cc Mon Sep 17 00:00:00 2001 From: Jonathan Leitschuh Date: Mon, 4 Jan 2016 15:05:25 -0500 Subject: [PATCH] Revert "Revert "Feat: Remote Deployment and Running"" --- build.gradle | 3 + .../src/main/java/edu/wpi/grip/core/Main.java | 7 +- .../edu/wpi/grip/ui/DeployerController.java | 130 ++++++++++ .../java/edu/wpi/grip/ui/GRIPUIModule.java | 5 + .../edu/wpi/grip/ui/MainWindowController.java | 19 ++ .../DeploymentOptionsController.java | 132 ++++++++++ .../DeploymentOptionsControllersFactory.java | 35 +++ ...RCAdvancedDeploymentOptionsController.java | 76 ++++++ .../FRCDeploymentOptionsController.java | 76 ++++++ .../wpi/grip/ui/pipeline/AddSourceView.java | 6 +- .../edu/wpi/grip/ui/util/SupplierWithIO.java | 14 ++ .../deployment/DeployedInstanceManager.java | 237 ++++++++++++++++++ .../util/deployment/DeploymentCommands.java | 54 ++++ .../util/deployment/SecureShellDetails.java | 107 ++++++++ .../edu/wpi/grip/ui/DeployerPane.fxml | 45 ++++ .../resources/edu/wpi/grip/ui/MainWindow.fxml | 9 + .../grip/ui/deployment/DeploymentOptions.fxml | 30 +++ .../DeployedInstanceManagerTest.java | 40 +++ .../deployment/LoadThreeStepsTestSave.grip | 42 ++++ 19 files changed, 1060 insertions(+), 7 deletions(-) create mode 100644 ui/src/main/java/edu/wpi/grip/ui/DeployerController.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsController.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsControllersFactory.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/deployment/FRCAdvancedDeploymentOptionsController.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/deployment/FRCDeploymentOptionsController.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/util/SupplierWithIO.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeployedInstanceManager.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeploymentCommands.java create mode 100644 ui/src/main/java/edu/wpi/grip/ui/util/deployment/SecureShellDetails.java create mode 100644 ui/src/main/resources/edu/wpi/grip/ui/DeployerPane.fxml create mode 100644 ui/src/main/resources/edu/wpi/grip/ui/deployment/DeploymentOptions.fxml create mode 100644 ui/src/test/java/edu/wpi/grip/ui/util/deployment/DeployedInstanceManagerTest.java create mode 100644 ui/src/test/resources/edu/wpi/grip/ui/util/deployment/LoadThreeStepsTestSave.grip diff --git a/build.gradle b/build.gradle index f4fb796727..7b84a38ca5 100644 --- a/build.gradle +++ b/build.gradle @@ -191,6 +191,9 @@ project(":ui") { compile project(path: ':core', configuration: 'shadow') ideProvider project(path: ':core', configuration: 'compile') compile group: 'org.controlsfx', name: 'controlsfx', version: '8.40.10' + compile group: 'org.apache.ant', name: 'ant-jsch', version: '1.8.1' + compile group: 'com.jcabi', name: 'jcabi-ssh', version: '1.5' + compile group: 'org.jdeferred', name: 'jdeferred-core', version: '1.2.4' testCompile files(project(':core').sourceSets.test.output.classesDir) testCompile files(project(':core').sourceSets.test.output.resourcesDir) testCompile group: 'org.testfx', name: 'testfx-core', version: '4.0.+' diff --git a/core/src/main/java/edu/wpi/grip/core/Main.java b/core/src/main/java/edu/wpi/grip/core/Main.java index 260dd2b5fa..448b951874 100644 --- a/core/src/main/java/edu/wpi/grip/core/Main.java +++ b/core/src/main/java/edu/wpi/grip/core/Main.java @@ -26,12 +26,12 @@ public class Main { @Inject private Logger logger; - @SuppressWarnings("PMD.SignatureDeclareThrowsException") - public static void main(String[] args) throws Exception { + public static void main(String[] args) throws IOException, InterruptedException { final Injector injector = Guice.createInjector(new GRIPCoreModule()); injector.getInstance(Main.class).start(args); } + @SuppressWarnings("PMD.SystemPrintln") public void start(String[] args) throws IOException, InterruptedException { if (args.length != 1) { System.err.println("Usage: GRIP.jar project.grip"); @@ -69,6 +69,9 @@ public void start(String[] args) throws IOException, InterruptedException { // Open a project from a .grip file specified on the command line project.open(new File(projectPath)); + + // This is done in order to indicate to the user using the deployment UI that this is running + System.out.println("SUCCESS! The project is running in headless mode!"); // There's nothing more to do in the main thread since we're in headless mode - sleep forever for (; ; ) { Thread.sleep(Integer.MAX_VALUE); diff --git a/ui/src/main/java/edu/wpi/grip/ui/DeployerController.java b/ui/src/main/java/edu/wpi/grip/ui/DeployerController.java new file mode 100644 index 0000000000..3bc3c1dd0e --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/DeployerController.java @@ -0,0 +1,130 @@ +package edu.wpi.grip.ui; + + +import com.google.common.base.Throwables; +import com.google.inject.Inject; +import edu.wpi.grip.ui.annotations.ParametrizedController; +import edu.wpi.grip.ui.components.StartStoppableButton; +import edu.wpi.grip.ui.deployment.DeploymentOptionsController; +import edu.wpi.grip.ui.deployment.DeploymentOptionsControllersFactory; +import edu.wpi.grip.ui.util.deployment.DeployedInstanceManager; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.scene.control.Accordion; +import javafx.scene.control.DialogPane; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.TextArea; +import javafx.scene.layout.HBox; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +@ParametrizedController(url = "DeployerPane.fxml") +public class DeployerController { + + @FXML + private DialogPane root; + + @FXML + private HBox controlsBox; + + @FXML + private Accordion deploymentMethods; + + @FXML + private TextArea stdOutStreamTextArea; + + @FXML + private TextArea stdErrStreamTextArea; + + @FXML + private ProgressBar progressIndicator; + + private final StartStoppableButton.Factory startStopButtonFactory; + private final DeploymentOptionsControllersFactory optionsControllersFactory; + + private class StreamToTextArea extends OutputStream { + private final TextArea outputArea; + + public StreamToTextArea(TextArea outputArea) { + super(); + this.outputArea = outputArea; + } + + public StreamToTextArea reset() { + outputArea.clear(); + return this; + } + + @Override + public void write(int i) throws IOException { + outputArea.appendText(String.valueOf((char) i)); + } + } + + + public interface Factory { + DeployerController create(); + } + + @Inject + DeployerController(StartStoppableButton.Factory startStopButtonFactory, DeploymentOptionsControllersFactory optionsControllersFactory) { + this.startStopButtonFactory = startStopButtonFactory; + this.optionsControllersFactory = optionsControllersFactory; + } + + @FXML + @SuppressWarnings("PMD.UnusedPrivateMethod") + private void initialize() { + final Supplier out = () -> + new PrintStream(new StreamToTextArea(stdOutStreamTextArea).reset(), false); + final Supplier err = () -> + new PrintStream(new StreamToTextArea(stdErrStreamTextArea).reset(), false); + deploymentMethods.getPanes().addAll( + optionsControllersFactory + .createControllers(this::onDeploy, out, err) + .stream() + .map(DeploymentOptionsController::getRoot) + .collect(Collectors.toList())); + } + + /** + * Calls {@link DeployedInstanceManager#deploy()} and displays the result to the UI. + * @param manager The manager to call deploy on + */ + private void onDeploy(DeployedInstanceManager manager) { + Platform.runLater(() -> { + progressIndicator.setProgress(0); + deploymentMethods.setDisable(true); + }); + manager.deploy() + .fail(throwable -> { + Platform.runLater(() -> { + stdErrStreamTextArea.setText("Failed to deploy\n" + + Throwables.getStackTraceAsString(throwable) + ); + deploymentMethods.setDisable(false); + }); + }) + .progress(percent -> { + Platform.runLater(() -> progressIndicator.setProgress(percent)); + }) + .done(deployedManager -> { + Platform.runLater(() -> { + controlsBox.getChildren().add(startStopButtonFactory.create(deployedManager)); + deploymentMethods.setDisable(true); + progressIndicator.setProgress(-1); + }); + }); + + } + + public DialogPane getRoot() { + return root; + } +} + + diff --git a/ui/src/main/java/edu/wpi/grip/ui/GRIPUIModule.java b/ui/src/main/java/edu/wpi/grip/ui/GRIPUIModule.java index 67f0c06130..fb5aa7b33c 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/GRIPUIModule.java +++ b/ui/src/main/java/edu/wpi/grip/ui/GRIPUIModule.java @@ -11,6 +11,8 @@ import edu.wpi.grip.ui.annotations.ParametrizedController; import edu.wpi.grip.ui.components.ExceptionWitnessResponderButton; import edu.wpi.grip.ui.components.StartStoppableButton; +import edu.wpi.grip.ui.deployment.FRCAdvancedDeploymentOptionsController; +import edu.wpi.grip.ui.deployment.FRCDeploymentOptionsController; import edu.wpi.grip.ui.pipeline.OutputSocketController; import edu.wpi.grip.ui.pipeline.SocketHandleView; import edu.wpi.grip.ui.pipeline.StepController; @@ -70,6 +72,9 @@ public void hear(final TypeLiteral typeLiteral, TypeEncounter typeEnco install(new FactoryModuleBuilder().build(OperationController.Factory.class)); install(new FactoryModuleBuilder().build(SocketHandleView.Factory.class)); install(new FactoryModuleBuilder().build(OutputSocketController.Factory.class)); + install(new FactoryModuleBuilder().build(DeployerController.Factory.class)); + install(new FactoryModuleBuilder().build(FRCDeploymentOptionsController.Factory.class)); + install(new FactoryModuleBuilder().build(FRCAdvancedDeploymentOptionsController.Factory.class)); // End arbitrary controllers // InputSocketController Factories diff --git a/ui/src/main/java/edu/wpi/grip/ui/MainWindowController.java b/ui/src/main/java/edu/wpi/grip/ui/MainWindowController.java index c5ad87d8b4..44c3c04813 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/MainWindowController.java +++ b/ui/src/main/java/edu/wpi/grip/ui/MainWindowController.java @@ -7,6 +7,7 @@ import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.Parent; +import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; import javafx.scene.control.Dialog; import javafx.scene.control.SplitPane; @@ -39,6 +40,8 @@ public class MainWindowController { private Palette palette; @Inject private Project project; + @Inject + private DeployerController.Factory deployerControllerFactoy; public void initialize() { pipelineView.prefHeightProperty().bind(bottomPane.heightProperty()); @@ -164,5 +167,21 @@ public void quit() { Platform.exit(); } } + + @FXML + public void deployFRC() { + if (project.getFile().isPresent()) { + final DeployerController deployerController = deployerControllerFactoy.create(); + final Dialog dialog = new Dialog(); + dialog.setDialogPane(deployerController.getRoot()); + dialog.setResizable(true); + dialog.showAndWait(); + } else { + final Alert alert = new Alert(Alert.AlertType.INFORMATION, + "You must have saved your project before it can be deployed to a remote device."); + alert.showAndWait(); + } + + } } diff --git a/ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsController.java b/ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsController.java new file mode 100644 index 0000000000..d597193db3 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsController.java @@ -0,0 +1,132 @@ +package edu.wpi.grip.ui.deployment; + +import edu.wpi.grip.ui.annotations.ParametrizedController; +import edu.wpi.grip.ui.util.SupplierWithIO; +import edu.wpi.grip.ui.util.deployment.DeployedInstanceManager; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.TitledPane; +import javafx.scene.layout.GridPane; +import org.jdeferred.DeferredCallable; +import org.jdeferred.DeferredManager; +import org.jdeferred.Promise; +import org.jdeferred.impl.DefaultDeferredManager; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.function.Consumer; + +/** + * A simple UI component that can be used to display options for deploying to a remote device using + * asynchronous callbacks. + */ +@ParametrizedController(url = "DeploymentOptions.fxml") +public abstract class DeploymentOptionsController { + + @FXML + private TitledPane root; + + @FXML + private GridPane optionsGrid; + + @FXML + private Label deployErrorText; + + @FXML + private ProgressIndicator deploySpinner; + + @FXML + private Button deployButton; + + private final String title; + private final Consumer onDeployCallback; + + DeploymentOptionsController(String title, Consumer onDeployCallback) { + this.title = title; + this.onDeployCallback = onDeployCallback; + } + + @FXML + protected final void initialize() { + root.setText(title); + postInit(); + } + + /** + * Called after the initialize method + */ + protected abstract void postInit(); + + protected abstract Promise onDeploy(); + + @FXML + @SuppressWarnings("PMD.UnusedPrivateMethod") + private void deploy() { + deploySpinner.setVisible(true); + deployButton.setDisable(true); + onDeploy() + .progress(this::setErrorText) + .fail((text) -> { + setErrorText(text); + Platform.runLater(() -> { + deploySpinner.setVisible(false); + deployButton.setDisable(false); + }); + }) + .done((t) -> { + onDeployCallback.accept(t); + Platform.runLater(() -> { + deploySpinner.setVisible(false); + deployButton.setDisable(false); + }); + }); + } + + private void setErrorText(String text) { + Platform.runLater(() -> { + deployErrorText.setText(text); + root.requestLayout(); + }); + } + + protected GridPane getOptionsGrid() { + return optionsGrid; + } + + protected Button getDeployButton() { + return deployButton; + } + + public TitledPane getRoot() { + return root; + } + + /** + * Checks that an InetAddress is reachable asynchronously + * + * @param address The address supplier to check + * @return A promise that is resolved when the InetAddress is determined to be reachable. + */ + protected static Promise checkInetAddressReachable(SupplierWithIO address) { + final DeferredManager checkAddressDeferred = new DefaultDeferredManager(); + return checkAddressDeferred.when(new DeferredCallable() { + @Override + public InetAddress call() throws IOException { + final InetAddress inetAddress = address.getWithIO(); + final int attemptCount = 5; + for (int i = 0; i < attemptCount; i++) { + if (inetAddress.isReachable(1000)) { + return inetAddress; + } else { + notify("Attempt " + i + "/" + attemptCount + " failed"); + } + } + throw new IOException("Failed to connect"); + } + }); + + } +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsControllersFactory.java b/ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsControllersFactory.java new file mode 100644 index 0000000000..3345493a2b --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/deployment/DeploymentOptionsControllersFactory.java @@ -0,0 +1,35 @@ +package edu.wpi.grip.ui.deployment; + + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import edu.wpi.grip.ui.util.deployment.DeployedInstanceManager; + +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Consumer; +import java.util.function.Supplier; + +@Singleton +public class DeploymentOptionsControllersFactory { + + private final FRCDeploymentOptionsController.Factory frcDeploymentOptionsControllerFactory; + private final FRCAdvancedDeploymentOptionsController.Factory frcAdvancedDeploymentOptionsControllerFactory; + + @Inject + DeploymentOptionsControllersFactory( + FRCDeploymentOptionsController.Factory frcDeploymentOptionsControllerFactory, + FRCAdvancedDeploymentOptionsController.Factory frcAdvancedDeploymentOptionsControllerFactory) { + this.frcDeploymentOptionsControllerFactory = frcDeploymentOptionsControllerFactory; + this.frcAdvancedDeploymentOptionsControllerFactory = frcAdvancedDeploymentOptionsControllerFactory; + } + + + public Collection createControllers(Consumer onDeployCallback, Supplier stdOut, Supplier stdErr) { + return Arrays.asList( + frcDeploymentOptionsControllerFactory.create(onDeployCallback, stdOut, stdErr), + frcAdvancedDeploymentOptionsControllerFactory.create(onDeployCallback, stdOut, stdErr) + ); + } +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/deployment/FRCAdvancedDeploymentOptionsController.java b/ui/src/main/java/edu/wpi/grip/ui/deployment/FRCAdvancedDeploymentOptionsController.java new file mode 100644 index 0000000000..bd097d9279 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/deployment/FRCAdvancedDeploymentOptionsController.java @@ -0,0 +1,76 @@ +package edu.wpi.grip.ui.deployment; + +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import edu.wpi.grip.ui.util.deployment.DeployedInstanceManager; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import org.jdeferred.Deferred; +import org.jdeferred.Promise; +import org.jdeferred.impl.DeferredObject; + +import java.io.OutputStream; +import java.net.InetAddress; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public class FRCAdvancedDeploymentOptionsController extends DeploymentOptionsController { + + + private final DeployedInstanceManager.Factory deployedInstanceManagerFactor; + private final Supplier stdOut, stdErr; + + private TextField address; + + public interface Factory { + FRCAdvancedDeploymentOptionsController create( + Consumer onDeployCallback, + @Assisted("stdOut") Supplier stdOut, + @Assisted("stdErr") Supplier stdErr + ); + } + + @Inject + FRCAdvancedDeploymentOptionsController(DeployedInstanceManager.Factory deployedInstanceManagerFactor, + @Assisted Consumer onDeployCallback, + @Assisted("stdOut") Supplier stdOut, + @Assisted("stdErr") Supplier stdErr) { + super("FRC Advanced", onDeployCallback); + this.deployedInstanceManagerFactor = deployedInstanceManagerFactor; + this.stdOut = stdOut; + this.stdErr = stdErr; + + } + + @Override + protected void postInit() { + this.address = new TextField(); + this.address.setPromptText("roborio-[team number]-frc.local"); + + this.address.textProperty().addListener((observable, oldValue, newValue) -> { + // Enable the "Deploy" button only if the user has entered something. + // Note: InetAddresses.isInetAddress only works for IP address not mdns names + if ("".equals(newValue)) { + getDeployButton().setDisable(true); + } else { + getDeployButton().setDisable(false); + } + }); + getDeployButton().setDisable(true); + + final Label textFieldInput = new Label("Address/IP:"); + textFieldInput.setLabelFor(this.address); + + getOptionsGrid().addRow(0, textFieldInput, this.address); + } + + @Override + protected Promise onDeploy() { + final Deferred deploy = new DeferredObject<>(); + checkInetAddressReachable(() -> InetAddress.getByName(address.getText())) + .progress(deploy::notify) + .fail(throwable -> deploy.reject(throwable.getMessage())) + .done((address) -> deploy.resolve(deployedInstanceManagerFactor.createFRC(address, stdOut, stdErr))); + return deploy.promise(); + } +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/deployment/FRCDeploymentOptionsController.java b/ui/src/main/java/edu/wpi/grip/ui/deployment/FRCDeploymentOptionsController.java new file mode 100644 index 0000000000..1c465e02b4 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/deployment/FRCDeploymentOptionsController.java @@ -0,0 +1,76 @@ +package edu.wpi.grip.ui.deployment; + +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import edu.wpi.grip.ui.util.deployment.DeployedInstanceManager; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import org.jdeferred.Deferred; +import org.jdeferred.Promise; +import org.jdeferred.impl.DeferredObject; + +import java.io.OutputStream; +import java.net.InetAddress; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class FRCDeploymentOptionsController extends DeploymentOptionsController { + + private Spinner teamNumberSpinner; + private final DeployedInstanceManager.Factory deployedInstanceManagerFactor; + private final Supplier stdOut, stdErr; + + public interface Factory { + FRCDeploymentOptionsController create( + Consumer onDeployCallback, + @Assisted("stdOut") Supplier stdOut, + @Assisted("stdErr") Supplier stdErr + ); + } + + @Inject + FRCDeploymentOptionsController(DeployedInstanceManager.Factory deployedInstanceManagerFactor, + @Assisted Consumer onDeployCallback, + @Assisted("stdOut") Supplier stdOut, + @Assisted("stdErr") Supplier stdErr) { + super("FRC", onDeployCallback); + this.deployedInstanceManagerFactor = deployedInstanceManagerFactor; + this.stdOut = stdOut; + this.stdErr = stdErr; + } + + @Override + @SuppressWarnings("PMD.IfElseStmtsMustUseBraces") + protected void postInit() { + final Label label = new Label("Team Number"); + final SpinnerValueFactory.IntegerSpinnerValueFactory spinnerValueFactory = new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Integer.MAX_VALUE, 190); + this.teamNumberSpinner = new Spinner(spinnerValueFactory); + this.teamNumberSpinner.setEditable(true); + // Ensure the value entered is only a number + this.teamNumberSpinner.getEditor().textProperty().addListener((observable, oldValue, newValue) -> { + if ("".equals(newValue)) { + teamNumberSpinner.getEditor().setText(Integer.toString(0)); + } else try { + int value = Integer.parseInt(newValue); + teamNumberSpinner.getEditor().setText(Integer.toString(value)); + } catch (NumberFormatException e) { + teamNumberSpinner.getEditor().setText(oldValue); + } + }); + getOptionsGrid().addRow(0, label, this.teamNumberSpinner); + } + + @Override + protected Promise onDeploy() { + final Deferred deferredDeploy = new DeferredObject<>(); + int teamNumber = checkNotNull(teamNumberSpinner.getValue(), "Team number can not be null"); + checkInetAddressReachable(() -> InetAddress.getByName("roborio-" + teamNumber + "-frc.local")) + .progress(deferredDeploy::notify) + .fail(throwable -> deferredDeploy.reject(throwable.getMessage())) + .done(address -> deferredDeploy.resolve(deployedInstanceManagerFactor.createFRC(address, stdOut, stdErr))); + return deferredDeploy.promise(); + } +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java index 8467929d15..c44311a1dd 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java @@ -9,6 +9,7 @@ import edu.wpi.grip.core.sources.ImageFileSource; import edu.wpi.grip.core.sources.MultiImageFileSource; import edu.wpi.grip.ui.util.DPIUtility; +import edu.wpi.grip.ui.util.SupplierWithIO; import javafx.application.Platform; import javafx.event.EventHandler; import javafx.scene.Parent; @@ -51,11 +52,6 @@ public class AddSourceView extends HBox { private final Button ipcamButton; private Optional activeDialog = Optional.empty(); - @FunctionalInterface - private interface SupplierWithIO { - T getWithIO() throws IOException; - } - private static class SourceDialog extends Dialog { private final Text errorText = new Text(); diff --git a/ui/src/main/java/edu/wpi/grip/ui/util/SupplierWithIO.java b/ui/src/main/java/edu/wpi/grip/ui/util/SupplierWithIO.java new file mode 100644 index 0000000000..f3c879bd0d --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/util/SupplierWithIO.java @@ -0,0 +1,14 @@ +package edu.wpi.grip.ui.util; + +import java.io.IOException; + + +/** + * A supplier that can throw an IO exception. Thus putting it on the caller to handle it instead of on + * the creator of the lambda function. + * @param The type that the supplier returns. + */ +@FunctionalInterface +public interface SupplierWithIO { + T getWithIO() throws IOException; +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeployedInstanceManager.java b/ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeployedInstanceManager.java new file mode 100644 index 0000000000..488b3602d8 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeployedInstanceManager.java @@ -0,0 +1,237 @@ +package edu.wpi.grip.ui.util.deployment; + + +import com.google.common.eventbus.EventBus; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.jcabi.ssh.Shell; +import com.jcraft.jsch.JSch; +import edu.wpi.grip.core.StartStoppable; +import edu.wpi.grip.core.events.StartedStoppedEvent; +import edu.wpi.grip.core.events.UnexpectedThrowableEvent; +import edu.wpi.grip.core.serialization.Project; +import org.apache.commons.io.input.NullInputStream; +import org.apache.commons.io.output.NullOutputStream; +import org.apache.tools.ant.BuildException; +import org.apache.tools.ant.taskdefs.optional.ssh.Scp; +import org.jdeferred.DeferredCallable; +import org.jdeferred.DeferredManager; +import org.jdeferred.Promise; +import org.jdeferred.impl.DefaultDeferredManager; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Controls an instance of GRIP running on a remote device. + */ +public class DeployedInstanceManager implements StartStoppable { + + private final Logger logger = Logger.getLogger(DeployedInstanceManager.class.getName()); + private final EventBus eventBus; + private final File coreJar; + private final File projectFile; + private final SecureShellDetails details; + private final DeploymentCommands deploymentCommands; + private final Supplier stdOut; + private final Supplier stdErr; + private Optional sshThread; + + @Singleton + public static class Factory { + private final EventBus eventBus; + private final File coreJAR; + private final Project project; + private final SecureShellDetails.Factory secureShellDetailsFactory; + private final DeploymentCommands.Factory deploymentCommandsFactory; + + @Inject + public Factory( + EventBus eventBus, + Project project, + SecureShellDetails.Factory secureShellDetailsFactory, + DeploymentCommands.Factory deploymentCommandsFactory) { + this.eventBus = eventBus; + try { + this.coreJAR = new File(edu.wpi.grip.core.Main.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath()); + } catch (URISyntaxException e) { + throw new IllegalStateException("Could not find the main class jar file", e); + } + this.project = project; + this.secureShellDetailsFactory = secureShellDetailsFactory; + this.deploymentCommandsFactory = deploymentCommandsFactory; + } + + public DeployedInstanceManager createFRC(InetAddress address) { + return createFRC(address, NullOutputStream::new, NullOutputStream::new); + } + + public DeployedInstanceManager createFRC(InetAddress address, Supplier stdOut, Supplier stdErr) { + final File projectFile = project.getFile().get(); + return createFRC(address, projectFile, stdOut, stdErr); + } + + public DeployedInstanceManager createFRC(InetAddress address, File projectFile) { + return createFRC(address, projectFile, NullOutputStream::new, NullOutputStream::new); + } + + public DeployedInstanceManager createFRC(InetAddress addresses, File projectFile, Supplier stdOut, Supplier stdErr) { + return new DeployedInstanceManager(eventBus, coreJAR, projectFile, secureShellDetailsFactory.createFRC(addresses), deploymentCommandsFactory.createFRC(), stdOut, stdErr); + } + } + + /** + * @param eventBus + * @param coreJar The jar with all of the core. This will be copied to the destination + * @param projectFile The project file to send over + * @param details The details regarding connecting to the secure shell + * @param deploymentCommands The commands required to start and stop GRIP + * @param stdOut Supplies the stream to be used for the standard output from the ssh command + * @param stdErr Supplies the stream to be used for the standard error from the ssh command + */ + private DeployedInstanceManager(EventBus eventBus, + File coreJar, + File projectFile, + SecureShellDetails details, + DeploymentCommands deploymentCommands, + Supplier stdOut, + Supplier stdErr) { + this.eventBus = checkNotNull(eventBus, "The event bus can not be null"); + this.coreJar = checkNotNull(coreJar, "The URI of the coreJar can not be null"); + this.projectFile = checkNotNull(projectFile, "The project file can not be null"); + this.details = checkNotNull(details, "The details can not be null"); + this.deploymentCommands = checkNotNull(deploymentCommands, "The deployment commands can not be null"); + this.stdOut = checkNotNull(stdOut, "The standard out stream supplier can not be null"); + this.stdErr = checkNotNull(stdErr, "The standard err stream supplier can not be null"); + sshThread = Optional.empty(); + } + + /** + * Deploys the coreJar and the project file to the remote device + * + */ + public synchronized Promise deploy() { + final DeployedInstanceManager self = this; + JSch.setConfig("StrictHostKeyChecking", "no"); + final DeferredManager deferred = new DefaultDeferredManager(); + + return deferred.when(new DeferredCallable() { + @Override + public DeployedInstanceManager call() throws IOException { + notify(0.2); + scpFileToTarget(coreJar); + notify(0.5); + scpFileToTarget(projectFile); + notify(1.0); + return self; + } + + }); + } + + /** + * @param file The file to send + * @throws IOException If there is a problem sending the file. + */ + private void scpFileToTarget(File file) throws IOException { + final String localFile = URLDecoder.decode(Paths.get(file.toURI()).toString()); + try { + final Scp scp = details.createSCPRunner(); + scp.setLocalFile(localFile); + scp.execute(); + } catch (BuildException e) { + throw new IOException("Failed to deploy", e); + } + } + + /** + * Starts GRIP running on the device specified by the secure shell details + * + * @throws IOException + */ + public synchronized void start() throws IOException { + if (isStarted()) { + throw new IllegalStateException("The program has already been started and must be stopped before restarting"); + } + // Ensure that the project isn't running from a previous instance. + runStop(); + Thread launcher = new Thread(() -> { + try { + final Shell gripShell = new Shell.Safe(details.createSSHShell()); + gripShell.exec("nohup " + deploymentCommands.getJARLaunchCommand(coreJar.getName(), projectFile.getName()) + " &", + new NullInputStream(0L), + stdOut.get(), + stdErr.get()); + } catch (IOException e) { + throw new IllegalStateException("The program failed to start", e); + } finally { + // This thread is done, shut it down. + synchronized (this) { + sshThread = Optional.empty(); + } + } + }, "SSH Monitor Thread"); + launcher.setUncaughtExceptionHandler((thread, exception) -> { + eventBus.post(new UnexpectedThrowableEvent(exception, "Failed to start the remote instance of the application")); + try { + runStop(); + } catch (IOException e) { + eventBus.post(new UnexpectedThrowableEvent(e, "Failed to stop the remote instance of the program")); + } + }); + launcher.setDaemon(true); + launcher.start(); + this.sshThread = Optional.of(launcher); + eventBus.post(new StartedStoppedEvent(this)); + } + + /** + * Stops the program running on the remote device + * + * @throws IOException If the command fails to be delivered + */ + public synchronized void stop() throws IOException { + if (!isStarted()) { + throw new IllegalStateException("The program hasn't started yet."); + } + runStop(); + do { + try { + // Since we hold the mutex on this we can wait + wait(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.log(Level.WARNING, "Wait interrupted", e); + } + runStop(); + } while (isStarted() && !Thread.interrupted()); + eventBus.post(new StartedStoppedEvent(this)); + } + + /** + * Makes an SSH connection to the remote and runs the kill command. + * Should block until the command has executed. + * @throws IOException If the shell fails. + */ + private void runStop() throws IOException { + final Shell.Plain gripShell = new Shell.Plain(new Shell.Safe(details.createSSHShell())); + gripShell.exec(deploymentCommands.getKillCommand(coreJar.getName())); + } + + @Override + public synchronized boolean isStarted() { + return sshThread.isPresent() && sshThread.get().isAlive(); + } + +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeploymentCommands.java b/ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeploymentCommands.java new file mode 100644 index 0000000000..e1048b13d1 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/util/deployment/DeploymentCommands.java @@ -0,0 +1,54 @@ +package edu.wpi.grip.ui.util.deployment; + + +import com.google.inject.Singleton; + +import java.util.function.Function; + +import static com.google.common.base.Preconditions.checkNotNull; + + +/** + * The commands to be used to launch and kill the deployed program. + */ +public class DeploymentCommands { + protected static final String DEFAULT_JAVA_COMMAND = "java"; + protected static final Function + DEFAULT_KILL_BY_NAME = name -> + "kill $(ps aux | grep \"" + name + "\" | grep -v 'grep' | awk '{print $1}') || :"; + private final String javaCommand; + private final Function killByNameCommand; + + @Singleton + public static class Factory { + public DeploymentCommands createFRC() { + return new DeploymentCommands("/usr/local/frc/JRE/bin/java", DEFAULT_KILL_BY_NAME); + } + } + + DeploymentCommands(String javaCommand, Function killByNameCommand) { + this.javaCommand = checkNotNull(javaCommand, "The java command can not be null"); + this.killByNameCommand = checkNotNull(killByNameCommand, "The kill by name consumer can not be null"); + } + + public DeploymentCommands() { + this(DEFAULT_JAVA_COMMAND, DEFAULT_KILL_BY_NAME); + } + + /** + * @param jarFile The name of the jar file + * @param projectFile The name of the project file + * @return The launch command + */ + protected String getJARLaunchCommand(String jarFile, String projectFile) { + return this.javaCommand + " -jar " + jarFile + " " + projectFile; + } + + /** + * @param name The name of the process to kill + * @return The command to kill the program running remotely + */ + protected String getKillCommand(String name) { + return killByNameCommand.apply(name); + } +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/util/deployment/SecureShellDetails.java b/ui/src/main/java/edu/wpi/grip/ui/util/deployment/SecureShellDetails.java new file mode 100644 index 0000000000..e2c21019ce --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/util/deployment/SecureShellDetails.java @@ -0,0 +1,107 @@ +package edu.wpi.grip.ui.util.deployment; + +import com.google.inject.Singleton; +import com.jcabi.ssh.SSHByPassword; +import com.jcabi.ssh.Shell; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.taskdefs.optional.ssh.Scp; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Optional; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Contains all of the details for securely copying grip core and a save file to another device. + */ +public class SecureShellDetails { + private static final int DEFAULT_PORT = 22; + + private final String userSSH; + private final Optional password; + private final String host; + private final Optional remoteDir; + private final int port; + + @Singleton + public static class Factory { + SecureShellDetails createFRC(InetAddress address) { + return new SecureShellDetails("lvuser", address.getHostAddress()); + } + } + + /** + * @param userSSH The username to connect to + * @param password The password to use, nullable + * @param host The host's address + * @param remoteDir The remote directory to ssh into + * @param port The port to to use. + */ + public SecureShellDetails(String userSSH, String password, String host, String remoteDir, int port) { + this.userSSH = checkNotNull(userSSH, "userSSH can not be null"); + this.password = Optional.ofNullable(password); + this.host = checkNotNull(host, "host can not be null"); + this.remoteDir = Optional.ofNullable(remoteDir); + this.port = port; + } + + public SecureShellDetails(String userSSH, String password, String serverSSH, String remoteDir) { + this(userSSH, password, serverSSH, remoteDir, DEFAULT_PORT); + } + + public SecureShellDetails(String userSSH, String host, String remoteDir) { + this(userSSH, null, host, remoteDir, DEFAULT_PORT); + } + + public SecureShellDetails(String userSSH, String host) { + this(userSSH, null, host, null, DEFAULT_PORT); + } + + + protected int getPort() { + return port; + } + + protected String host() { + return host; + } + + public String getUserSSH() { + return userSSH; + } + + protected Optional getPassword() { + return password; + } + + protected Scp createSCPRunner() { + Scp scp = new Scp(); + scp.setPort(getPort()); + scp.setPassword(getPassword().orElse("")); + scp.setTodir(getToDir()); + scp.setProject(new Project()); + scp.setTrust(true); + return scp; + } + + protected Shell createSSHShell() throws UnknownHostException { + return new SSHByPassword(host(), getPort(), getUserSSH(), getPassword().orElse("")); + } + + /** + * @return The directory to SCP the files to + */ + protected String getToDir() { + // userSSH + ":" + password + "@" + srvrSSH + ":" + remoteDir; + String sshDirCommand = userSSH; + if (password.isPresent()) { + sshDirCommand += (":" + password.get()); + } + sshDirCommand += ("@" + host + ":"); + if (remoteDir.isPresent()) { + sshDirCommand += remoteDir.get(); + } + return sshDirCommand; + } +} diff --git a/ui/src/main/resources/edu/wpi/grip/ui/DeployerPane.fxml b/ui/src/main/resources/edu/wpi/grip/ui/DeployerPane.fxml new file mode 100644 index 0000000000..ba6197545d --- /dev/null +++ b/ui/src/main/resources/edu/wpi/grip/ui/DeployerPane.fxml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + +
+ + + + +