diff --git a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java index 80ad1da7b8..09f05e2c99 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java @@ -45,6 +45,8 @@ import java.nio.file.Path; public final class InstalledPlugin implements Plugin { + public static final String UNINSTALL_MARKER_FILENAME = "uninstall"; + /** * Constructs a new plugin wrapper. * @param descriptor wrapped plugin @@ -125,7 +127,23 @@ public final class InstalledPlugin implements Plugin return core; } - //~--- fields --------------------------------------------------------------- + public boolean isMarkedForUninstall() { + return markedForUninstall; + } + + public void setMarkedForUninstall(boolean markedForUninstall) { + this.markedForUninstall = markedForUninstall; + } + + public boolean isUninstallable() { + return uninstallable; + } + + public void setUninstallable(boolean uninstallable) { + this.uninstallable = uninstallable; + } + +//~--- fields --------------------------------------------------------------- /** plugin class loader */ private final ClassLoader classLoader; @@ -140,4 +158,7 @@ public final class InstalledPlugin implements Plugin private final WebResourceLoader webResourceLoader; private final boolean core; + + private boolean markedForUninstall = false; + private boolean uninstallable = false; } diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java index b7b8f69519..3d0ea94536 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java @@ -81,8 +81,16 @@ public interface PluginManager { */ void install(String name, boolean restartAfterInstallation); + /** + * Marks the plugin with the given name for uninstall. + * + * @param name plugin name + * @param restartAfterInstallation restart context after plugin has been marked to really uninstall the plugin + */ + void uninstall(String name, boolean restartAfterInstallation); + /** * Install all pending plugins and restart the scm context. */ - void installPendingAndRestart(); + void executePendingAndRestart(); } diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index 0f9694b5fb..c7612a8bf8 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -11,6 +11,7 @@ export type Plugin = { category: string, avatarUrl: string, pending: boolean, + markedForUninstall?: boolean, dependencies: string[], _links: Links }; @@ -31,5 +32,6 @@ export type PendingPlugins = { _embedded: { new: [], update: [], + uninstall: [] } } diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index d2cd2c6640..d4f3567059 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -29,20 +29,24 @@ "installedNavLink": "Installiert", "availableNavLink": "Verfügbar" }, - "executePending": "Austehende Plugin-Änderungen ausführen", + "executePending": "Ausstehende Plugin-Änderungen ausführen", "noPlugins": "Keine Plugins gefunden.", "modal": { "title": { "install": "{{name}} Plugin installieren", - "update": "{{name}} Plugin aktualisieren" + "update": "{{name}} Plugin aktualisieren", + "uninstall": "{{name}} Plugin deinstallieren" }, - "restart": "Neustarten um Plugin zu aktivieren", + "restart": "Neustarten, um Plugin-Änderungen wirksam zu machen", "install": "Installieren", "update": "Aktualisieren", + "uninstall": "Deinstallieren", "installQueue": "Werden installiert:", "updateQueue": "Werden aktualisiert:", + "uninstallQueue": "Werden deinstalliert:", "installAndRestart": "Installieren und Neustarten", "updateAndRestart": "Aktualisieren und Neustarten", + "uninstallAndRestart": "Deinstallieren and Neustarten", "executeAndRestart": "Ausführen und Neustarten", "abort": "Abbrechen", "author": "Autor", diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 63f6089b09..c23a88a920 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -34,15 +34,19 @@ "modal": { "title": { "install": "Install {{name}} Plugin", - "update": "Update {{name}} Plugin" + "update": "Update {{name}} Plugin", + "uninstall": "Uninstall {{name}} Plugin" }, - "restart": "Restart to activate", + "restart": "Restart to make plugin changes effective", "install": "Install", "update": "Update", + "uninstall": "Uninstall", "installQueue": "Will be installed:", "updateQueue": "Will be updated:", + "uninstallQueue": "Will be uninstalled:", "installAndRestart": "Install and Restart", "updateAndRestart": "Update and Restart", + "uninstallAndRestart": "Uninstall and Restart", "executeAndRestart": "Execute and Restart", "abort": "Abort", "author": "Author", diff --git a/scm-ui/src/admin/plugins/components/ExecutePendingModal.js b/scm-ui/src/admin/plugins/components/ExecutePendingModal.js index 673ddd18cb..b559413d2b 100644 --- a/scm-ui/src/admin/plugins/components/ExecutePendingModal.js +++ b/scm-ui/src/admin/plugins/components/ExecutePendingModal.js @@ -86,11 +86,9 @@ class ExecutePendingModal extends React.Component { <> {t("plugins.modal.installQueue")} )} @@ -107,11 +105,28 @@ class ExecutePendingModal extends React.Component { <> {t("plugins.modal.updateQueue")} + + )} + + ); + }; + + renderUninstallQueue = () => { + const { pendingPlugins, t } = this.props; + return ( + <> + {pendingPlugins._embedded && + pendingPlugins._embedded.uninstall.length > 0 && ( + <> + {t("plugins.modal.uninstallQueue")} + )} @@ -128,6 +143,7 @@ class ExecutePendingModal extends React.Component {

{t("plugins.modal.executePending")}

{this.renderInstallQueue()} {this.renderUpdateQueue()} + {this.renderUninstallQueue()}
{this.renderNotifications()}
diff --git a/scm-ui/src/admin/plugins/components/PluginEntry.js b/scm-ui/src/admin/plugins/components/PluginEntry.js index 0d17af0475..dbbce06d50 100644 --- a/scm-ui/src/admin/plugins/components/PluginEntry.js +++ b/scm-ui/src/admin/plugins/components/PluginEntry.js @@ -7,10 +7,10 @@ import PluginAvatar from "./PluginAvatar"; import classNames from "classnames"; import PluginModal from "./PluginModal"; - -const PluginAction = { +export const PluginAction = { INSTALL: "install", - UPDATE: "update" + UPDATE: "update", + UNINSTALL: "uninstall" }; type Props = { @@ -22,7 +22,9 @@ type Props = { }; type State = { - showModal: boolean + showInstallModal: boolean, + showUpdateModal: boolean, + showUninstallModal: boolean }; const styles = { @@ -45,6 +47,12 @@ const styles = { "& .level": { paddingBottom: "0.5rem" } + }, + actionbar: { + display: "flex", + "& span + span": { + marginLeft: "0.5rem" + } } }; @@ -53,7 +61,9 @@ class PluginEntry extends React.Component { super(props); this.state = { - showModal: false + showInstallModal: false, + showUpdateModal: false, + showUninstallModal: false }; } @@ -61,10 +71,9 @@ class PluginEntry extends React.Component { return ; }; - toggleModal = () => { - this.setState(prevState => ({ - showModal: !prevState.showModal - })); + toggleModal = (showModal: string) => { + const oldValue = this.state[showModal]; + this.setState({ [showModal]: !oldValue }); }; createFooterRight = (plugin: Plugin) => { @@ -81,83 +90,117 @@ class PluginEntry extends React.Component { return plugin._links && plugin._links.update && plugin._links.update.href; }; + isUninstallable = () => { + const { plugin } = this.props; + return ( + plugin._links && plugin._links.uninstall && plugin._links.uninstall.href + ); + }; + createActionbar = () => { const { classes } = this.props; - if (this.isInstallable()) { - return ( - - - - ); - } else if (this.isUpdatable()) { - return ( - - - - ); - } + return ( +
+ {this.isInstallable() && ( + this.toggleModal("showInstallModal")} + > + + + )} + {this.isUninstallable() && ( + this.toggleModal("showUninstallModal")} + > + + + )} + {this.isUpdatable() && ( + this.toggleModal("showUpdateModal")} + > + + + )} +
+ ); }; renderModal = () => { const { plugin, refresh } = this.props; - if (this.isInstallable()) { + if (this.state.showInstallModal && this.isInstallable()) { return ( this.toggleModal("showInstallModal")} /> ); - } else if (this.isUpdatable()) { + } else if (this.state.showUpdateModal && this.isUpdatable()) { return ( this.toggleModal("showUpdateModal")} /> ); + } else if (this.state.showUninstallModal && this.isUninstallable()) { + return ( + this.toggleModal("showUninstallModal")} + /> + ); + } else { + return null; } }; createPendingSpinner = () => { const { plugin, classes } = this.props; - if (plugin.pending) { - return ( - - - - ); - } - return null; + return ( + + + + ); }; render() { const { plugin, classes } = this.props; - const { showModal } = this.state; const avatar = this.createAvatar(plugin); const actionbar = this.createActionbar(); const footerRight = this.createFooterRight(plugin); - const modal = showModal ? this.renderModal() : null; + const modal = this.renderModal(); return ( <> this.toggleModal("showInstallModal") + : null + } avatar={avatar} title={plugin.displayName ? plugin.displayName : plugin.name} description={plugin.description} contentRight={ - plugin.pending ? this.createPendingSpinner() : actionbar + plugin.pending || plugin.markedForUninstall + ? this.createPendingSpinner() + : actionbar } footerRight={footerRight} /> diff --git a/scm-ui/src/admin/plugins/components/PluginModal.js b/scm-ui/src/admin/plugins/components/PluginModal.js index 024a9cf32e..b15a068fa2 100644 --- a/scm-ui/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/src/admin/plugins/components/PluginModal.js @@ -16,6 +16,7 @@ import { import classNames from "classnames"; import waitForRestart from "./waitForRestart"; import SuccessNotification from "./SuccessNotification"; +import { PluginAction } from "./PluginEntry"; type Props = { plugin: Plugin, @@ -44,7 +45,7 @@ const styles = { minWidth: "5.5em" }, userLabelMarginLarge: { - minWidth: "9em" + minWidth: "10em" }, userFieldFlex: { flexGrow: 4 @@ -99,10 +100,12 @@ class PluginModal extends React.Component { let pluginActionLink = ""; - if (pluginAction === "install") { + if (pluginAction === PluginAction.INSTALL) { pluginActionLink = plugin._links.install.href; - } else if (pluginAction === "update") { + } else if (pluginAction === PluginAction.UPDATE) { pluginActionLink = plugin._links.update.href; + } else if (pluginAction === PluginAction.UNINSTALL) { + pluginActionLink = plugin._links.uninstall.href; } return pluginActionLink + "?restart=" + restart.toString(); }; @@ -127,7 +130,7 @@ class PluginModal extends React.Component { const { pluginAction, onClose, t } = this.props; const { loading, error, restart, success } = this.state; - let color = "primary"; + let color = pluginAction === PluginAction.UNINSTALL ? "warning" : "primary"; let label = `plugins.modal.${pluginAction}`; if (restart) { color = "warning"; @@ -218,7 +221,7 @@ class PluginModal extends React.Component {
{ {plugin.author}
- {pluginAction === "install" && ( + {pluginAction === PluginAction.INSTALL && (
{
)} - {pluginAction === "update" && ( - <> -
-
- {t("plugins.modal.currentVersion")}: -
-
- {plugin.version} -
+ {(pluginAction === PluginAction.UPDATE || + pluginAction === PluginAction.UNINSTALL) && ( +
+
+ {t("plugins.modal.currentVersion")}:
-
-
- {t("plugins.modal.newVersion")}: -
-
- {plugin.newVersion} -
+
+ {plugin.version}
- +
+ )} + {pluginAction === PluginAction.UPDATE && ( +
+
+ {t("plugins.modal.newVersion")}: +
+
+ {plugin.newVersion} +
+
)} - {this.renderDependencies()}
diff --git a/scm-ui/src/repos/containers/ChangesetsRoot.js b/scm-ui/src/repos/containers/ChangesetsRoot.js index 8e0b44afbe..db440b17c0 100644 --- a/scm-ui/src/repos/containers/ChangesetsRoot.js +++ b/scm-ui/src/repos/containers/ChangesetsRoot.js @@ -43,8 +43,29 @@ type Props = { class ChangesetsRoot extends React.Component { componentDidMount() { this.props.fetchBranches(this.props.repository); + this.redirectToDefaultBranch(); } + redirectToDefaultBranch = () => { + if (this.shouldRedirectToDefaultBranch()) { + const defaultBranches = this.props.branches.filter( + b => b.defaultBranch === true + ); + if (defaultBranches.length > 0) { + this.branchSelected(defaultBranches[0]); + } + } + }; + + shouldRedirectToDefaultBranch = () => { + return ( + this.props.branches && + this.props.branches.length > 0 && + this.props.selected !== + this.props.branches.filter(b => b.defaultBranch === true)[0] + ); + }; + stripEndingSlash = (url: string) => { if (url.endsWith("/")) { return url.substring(0, url.length - 1); diff --git a/scm-ui/src/repos/sources/containers/Sources.js b/scm-ui/src/repos/sources/containers/Sources.js index d09e18e5be..065dfb5a51 100644 --- a/scm-ui/src/repos/sources/containers/Sources.js +++ b/scm-ui/src/repos/sources/containers/Sources.js @@ -80,20 +80,17 @@ class Sources extends React.Component { } redirectToDefaultBranch = () => { - const { branches, baseUrl } = this.props; - if (this.shouldRedirect()) { + const { branches } = this.props; + if (this.shouldRedirectToDefaultBranch()) { const defaultBranches = branches.filter(b => b.defaultBranch); if (defaultBranches.length > 0) { - this.props.history.push( - `${baseUrl}/${encodeURIComponent(defaultBranches[0].name)}/` - ); - this.setState({ selectedBranch: defaultBranches[0] }); + this.branchSelected(defaultBranches[0]); } } }; - shouldRedirect = () => { + shouldRedirectToDefaultBranch = () => { const { branches, revision } = this.props; return branches && !revision; }; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index 5b5d0f267b..f0dff26757 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -11,9 +11,11 @@ import sonia.scm.web.VndMediaType; import javax.inject.Inject; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import java.util.List; import java.util.Optional; @@ -80,4 +82,21 @@ public class InstalledPluginResource { throw notFound(entity("Plugin", name)); } } + + /** + * Triggers plugin uninstall. + * @param name plugin name + * @return HTTP Status. + */ + @POST + @Path("/{name}/uninstall") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response uninstallPlugin(@PathParam("name") String name, @QueryParam("restart") boolean restartAfterInstallation) { + PluginPermissions.manage().check(); + pluginManager.uninstall(name, restartAfterInstallation); + return Response.ok().build(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java index 0103f9835e..3997a5e7c5 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java @@ -61,19 +61,24 @@ public class PendingPluginResource { Stream updatePlugins = installed .stream() .filter(i -> contains(pending, i)); + Stream uninstallPlugins = installed + .stream() + .filter(InstalledPlugin::isMarkedForUninstall); Links.Builder linksBuilder = linkingTo().self(resourceLinks.pendingPluginCollection().self()); - List newPluginDtos = newPlugins.map(mapper::mapAvailable).collect(toList()); - List updatePluginDtos = updatePlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList()); + List installDtos = newPlugins.map(mapper::mapAvailable).collect(toList()); + List updateDtos = updatePlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList()); + List uninstallDtos = uninstallPlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList()); - if (newPluginDtos.size() > 0 || updatePluginDtos.size() > 0) { - linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().installPending())); + if (!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty()) { + linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().executePending())); } Embedded.Builder embedded = Embedded.embeddedBuilder(); - embedded.with("new", newPluginDtos); - embedded.with("update", updatePluginDtos); + embedded.with("new", installDtos); + embedded.with("update", updateDtos); + embedded.with("uninstall", uninstallDtos); return Response.ok(new HalRepresentation(linksBuilder.build(), embedded.build())).build(); } @@ -95,14 +100,14 @@ public class PendingPluginResource { } @POST - @Path("/install") + @Path("/execute") @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") }) - public Response installPending() { + public Response executePending() { PluginPermissions.manage().check(); - pluginManager.installPendingAndRestart(); + pluginManager.executePendingAndRestart(); return Response.ok().build(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index d9782643b2..19067b1865 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -27,6 +27,7 @@ public class PluginDto extends HalRepresentation { private boolean pending; @JsonInclude(JsonInclude.Include.NON_NULL) private Boolean core; + private Boolean markedForUninstall; private Set dependencies; public PluginDto(Links links) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 222e34832a..160ec22354 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -74,6 +74,12 @@ public abstract class PluginDtoMapper { ) { links.single(link("update", resourceLinks.availablePlugin().install(information.getName()))); } + if (plugin.isUninstallable() + && (!availablePlugin.isPresent() || !availablePlugin.get().isPending()) + && PluginPermissions.manage().isPermitted() + ) { + links.single(link("uninstall", resourceLinks.installedPlugin().uninstall(information.getName()))); + } PluginDto dto = new PluginDto(links.build()); @@ -83,6 +89,7 @@ public abstract class PluginDtoMapper { }); dto.setCore(plugin.isCore()); + dto.setMarkedForUninstall(plugin.isMarkedForUninstall()); return dto; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 3a797734ea..c36cfc09ad 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -666,6 +666,10 @@ class ResourceLinks { String self(String id) { return installedPluginLinkBuilder.method("installedPlugins").parameters().method("getInstalledPlugin").parameters(id).href(); } + + public String uninstall(String name) { + return installedPluginLinkBuilder.method("installedPlugins").parameters().method("uninstallPlugin").parameters(name).href(); + } } public InstalledPluginCollectionLinks installedPluginCollection() { @@ -731,8 +735,8 @@ class ResourceLinks { pendingPluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PendingPluginResource.class); } - String installPending() { - return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("installPending").parameters().href(); + String executePending() { + return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("executePending").parameters().href(); } String self() { diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java index ff8c28f51d..0382187c26 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java @@ -29,9 +29,11 @@ import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.nio.file.Path; import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.stream.Stream; public final class PluginBootstrap { @@ -78,12 +80,37 @@ public final class PluginBootstrap { LOG.info("core plugin extraction is disabled"); } + uninstallMarkedPlugins(pluginDirectory.toPath()); return PluginsInternal.collectPlugins(classLoaderLifeCycle, pluginDirectory.toPath()); } catch (IOException ex) { throw new PluginLoadException("could not load plugins", ex); } } + private void uninstallMarkedPlugins(Path pluginDirectory) { + try (Stream list = java.nio.file.Files.list(pluginDirectory)) { + list + .filter(java.nio.file.Files::isDirectory) + .filter(this::isMarkedForUninstall) + .forEach(this::uninstall); + } catch (IOException e) { + LOG.warn("error occurred while checking for plugins that should be uninstalled", e); + } + } + + private boolean isMarkedForUninstall(Path path) { + return java.nio.file.Files.exists(path.resolve(InstalledPlugin.UNINSTALL_MARKER_FILENAME)); + } + + private void uninstall(Path path) { + try { + LOG.info("deleting plugin directory {}", path); + IOUtil.delete(path.toFile()); + } catch (IOException e) { + LOG.warn("could not delete plugin directory {}", path, e); + } + } + private void renameOldPluginsFolder(File pluginDirectory) { if (new File(pluginDirectory, "classpath.xml").exists()) { File backupDirectory = new File(pluginDirectory.getParentFile(), "plugins.v1"); @@ -96,7 +123,6 @@ public final class PluginBootstrap { } } - private boolean isCorePluginExtractionDisabled() { return Boolean.getBoolean("sonia.scm.boot.disable-core-plugin-extraction"); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 2c3e92d618..d54645f10d 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -33,21 +33,21 @@ package sonia.scm.plugin; -//~--- non-JDK imports -------------------------------------------------------- - +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; -import sonia.scm.ScmConstraintViolationException; import sonia.scm.event.ScmEventBus; import sonia.scm.lifecycle.RestartEvent; import sonia.scm.version.Version; -//~--- JDK imports ------------------------------------------------------------ import javax.inject.Inject; +import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -57,6 +57,8 @@ import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; +//~--- JDK imports ------------------------------------------------------------ + /** * * @author Sebastian Sdorra @@ -70,7 +72,8 @@ public class DefaultPluginManager implements PluginManager { private final PluginLoader loader; private final PluginCenter center; private final PluginInstaller installer; - private final List pendingQueue = new ArrayList<>(); + private final Collection pendingQueue = new ArrayList<>(); + private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker(); @Inject public DefaultPluginManager(ScmEventBus eventBus, PluginLoader loader, PluginCenter center, PluginInstaller installer) { @@ -78,6 +81,17 @@ public class DefaultPluginManager implements PluginManager { this.loader = loader; this.center = center; this.installer = installer; + + this.computeInstallationDependencies(); + } + + @VisibleForTesting + synchronized void computeInstallationDependencies() { + loader.getInstalledPlugins() + .stream() + .map(InstalledPlugin::getDescriptor) + .forEach(dependencyTracker::addInstalled); + updateMayUninstallFlag(); } @Override @@ -153,6 +167,7 @@ public class DefaultPluginManager implements PluginManager { for (AvailablePlugin plugin : plugins) { try { PendingPluginInstallation pending = installer.install(plugin); + dependencyTracker.addInstalled(plugin.getDescriptor()); pendingInstallations.add(pending); } catch (PluginInstallException ex) { cancelPending(pendingInstallations); @@ -165,15 +180,54 @@ public class DefaultPluginManager implements PluginManager { restart("plugin installation"); } else { pendingQueue.addAll(pendingInstallations); + updateMayUninstallFlag(); } } } @Override - public void installPendingAndRestart() { + public void uninstall(String name, boolean restartAfterInstallation) { PluginPermissions.manage().check(); - if (!pendingQueue.isEmpty()) { - restart("install pending plugins"); + InstalledPlugin installed = getInstalled(name) + .orElseThrow(() -> NotFoundException.notFound(entity(InstalledPlugin.class, name))); + doThrow().violation("plugin is a core plugin and cannot be uninstalled").when(installed.isCore()); + + dependencyTracker.removeInstalled(installed.getDescriptor()); + installed.setMarkedForUninstall(true); + + createMarkerFile(installed, InstalledPlugin.UNINSTALL_MARKER_FILENAME); + + if (restartAfterInstallation) { + restart("plugin installation"); + } else { + updateMayUninstallFlag(); + } + } + + private void updateMayUninstallFlag() { + loader.getInstalledPlugins() + .forEach(p -> p.setUninstallable(isUninstallable(p))); + } + + private boolean isUninstallable(InstalledPlugin p) { + return !p.isCore() + && !p.isMarkedForUninstall() + && dependencyTracker.mayUninstall(p.getDescriptor().getInformation().getName()); + } + + private void createMarkerFile(InstalledPlugin plugin, String markerFile) { + try { + Files.createFile(plugin.getDirectory().resolve(markerFile)); + } catch (IOException e) { + throw new PluginException("could not mark plugin " + plugin.getId() + " in path " + plugin.getDirectory() + "as " + markerFile, e); + } + } + + @Override + public void executePendingAndRestart() { + PluginPermissions.manage().check(); + if (!pendingQueue.isEmpty() || getInstalled().stream().anyMatch(InstalledPlugin::isMarkedForUninstall)) { + restart("execute pending plugin changes"); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java new file mode 100644 index 0000000000..a68b391b97 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java @@ -0,0 +1,38 @@ +package sonia.scm.plugin; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; + +class PluginDependencyTracker { + + private final Map> plugins = new HashMap<>(); + + void addInstalled(PluginDescriptor plugin) { + if (plugin.getDependencies() != null) { + plugin.getDependencies().forEach(dependency -> addDependency(plugin.getInformation().getName(), dependency)); + } + } + + void removeInstalled(PluginDescriptor plugin) { + doThrow() + .violation("Plugin is needed as a dependency for other plugins", "plugin") + .when(!mayUninstall(plugin.getInformation().getName())); + plugin.getDependencies().forEach(dependency -> removeDependency(plugin.getInformation().getName(), dependency)); + } + + boolean mayUninstall(String name) { + return plugins.computeIfAbsent(name, x -> new HashSet<>()).isEmpty(); + } + + private void addDependency(String from, String to) { + plugins.computeIfAbsent(to, name -> new HashSet<>()).add(from); + } + + private void removeDependency(String from, String to) { + plugins.get(to).remove(from); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index e1f0367948..2bbab3c650 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -462,7 +462,7 @@ public final class PluginProcessor if (Files.exists(descriptorPath)) { - boolean core = Files.exists(directory.resolve("core")); + boolean core = Files.exists(directory.resolve(PluginConstants.FILE_CORE)); ClassLoader cl = createClassLoader(classLoader, smp); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java index 553f6457c7..e5587b78cd 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java @@ -30,6 +30,7 @@ import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; import static java.net.URI.create; +import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -122,8 +123,7 @@ class PendingPluginResourceTest { assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); assertThat(response.getContentAsString()).contains("\"new\":[{\"name\":\"pending-available-plugin\""); - assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/install\"}"); - System.out.println(response.getContentAsString()); + assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); } @Test @@ -139,18 +139,32 @@ class PendingPluginResourceTest { assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); assertThat(response.getContentAsString()).contains("\"update\":[{\"name\":\"available-plugin\""); - assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/install\"}"); - System.out.println(response.getContentAsString()); + assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); } @Test - void shouldInstallPendingPlugins() throws URISyntaxException { - MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/install"); + void shouldGetPendingUninstallPluginListWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { + when(pluginManager.getAvailable()).thenReturn(emptyList()); + InstalledPlugin installedPlugin = createInstalledPlugin("uninstalled-plugin"); + when(installedPlugin.isMarkedForUninstall()).thenReturn(true); + when(pluginManager.getInstalled()).thenReturn(singletonList(installedPlugin)); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getContentAsString()).contains("\"uninstall\":[{\"name\":\"uninstalled-plugin\""); + assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); + } + + @Test + void shouldExecutePendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/execute"); dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); - verify(pluginManager).installPendingAndRestart(); + verify(pluginManager).executePendingAndRestart(); } } @@ -175,17 +189,17 @@ class PendingPluginResourceTest { dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); - verify(pluginManager, never()).installPendingAndRestart(); + verify(pluginManager, never()).executePendingAndRestart(); } @Test - void shouldNotInstallPendingPlugins() throws URISyntaxException { - MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/install"); + void shouldNotExecutePendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/execute"); dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); - verify(pluginManager, never()).installPendingAndRestart(); + verify(pluginManager, never()).executePendingAndRestart(); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java index 5bd7c1199e..0da2331bd3 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java @@ -127,4 +127,15 @@ class PluginDtoMapperTest { PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getDependencies()).containsOnly("one", "two"); } + + @Test + void shouldAppendUninstallLink() { + when(subject.isPermitted("plugin:manage")).thenReturn(true); + InstalledPlugin plugin = createInstalled(createPluginInformation()); + when(plugin.isUninstallable()).thenReturn(true); + + PluginDto dto = mapper.mapInstalled(plugin, emptyList()); + assertThat(dto.getLinks().getLinkBy("uninstall").get().getHref()) + .isEqualTo("https://hitchhiker.com/v2/plugins/installed/scm-cas-plugin/uninstall"); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index 16e1e2d73a..057a05eb79 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -10,26 +10,38 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Answers; +import org.junitpioneer.jupiter.TempDirectory; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.NotFoundException; +import sonia.scm.ScmConstraintViolationException; import sonia.scm.event.ScmEventBus; import sonia.scm.lifecycle.RestartEvent; +import java.nio.file.Path; import java.util.List; import java.util.Optional; +import static java.util.Arrays.asList; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.in; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static sonia.scm.plugin.PluginTestHelper.createAvailable; import static sonia.scm.plugin.PluginTestHelper.createInstalled; @ExtendWith(MockitoExtension.class) +@ExtendWith(TempDirectory.class) class DefaultPluginManagerTest { @Mock @@ -271,14 +283,14 @@ class DefaultPluginManagerTest { when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); - manager.installPendingAndRestart(); + manager.executePendingAndRestart(); verify(eventBus).post(any(RestartEvent.class)); } @Test void shouldNotSendRestartEventWithoutPendingPlugins() { - manager.installPendingAndRestart(); + manager.executePendingAndRestart(); verify(eventBus, never()).post(any()); } @@ -305,6 +317,116 @@ class DefaultPluginManagerTest { assertThat(available.get(0).isPending()).isTrue(); } + @Test + void shouldThrowExceptionWhenUninstallingUnknownPlugin() { + assertThrows(NotFoundException.class, () -> manager.uninstall("no-such-plugin", false)); + } + + @Test + void shouldUseDependencyTrackerForUninstall() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(mailPlugin, reviewPlugin)); + manager.computeInstallationDependencies(); + + assertThrows(ScmConstraintViolationException.class, () -> manager.uninstall("scm-mail-plugin", false)); + } + + @Test + void shouldCreateUninstallFile(@TempDirectory.TempDir Path temp) { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.getDirectory()).thenReturn(temp); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + manager.uninstall("scm-mail-plugin", false); + + assertThat(temp.resolve("uninstall")).exists(); + } + + @Test + void shouldMarkPluginForUninstall(@TempDirectory.TempDir Path temp) { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.getDirectory()).thenReturn(temp); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + manager.uninstall("scm-mail-plugin", false); + + verify(mailPlugin).setMarkedForUninstall(true); + } + + @Test + void shouldThrowExceptionWhenUninstallingCorePlugin(@TempDirectory.TempDir Path temp) { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.getDirectory()).thenReturn(temp); + when(mailPlugin.isCore()).thenReturn(true); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + assertThrows(ScmConstraintViolationException.class, () -> manager.uninstall("scm-mail-plugin", false)); + + assertThat(temp.resolve("uninstall")).doesNotExist(); + } + + @Test + void shouldMarkUninstallablePlugins() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(asList(mailPlugin, reviewPlugin)); + + manager.computeInstallationDependencies(); + + verify(reviewPlugin).setUninstallable(true); + verify(mailPlugin).setUninstallable(false); + } + + @Test + void shouldUpdateMayUninstallFlagAfterDependencyIsUninstalled() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(asList(mailPlugin, reviewPlugin)); + + manager.computeInstallationDependencies(); + + manager.uninstall("scm-review-plugin", false); + + verify(mailPlugin).setUninstallable(true); + } + + @Test + void shouldUpdateMayUninstallFlagAfterDependencyIsInstalled() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + AvailablePlugin reviewPlugin = createAvailable("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + when(center.getAvailable()).thenReturn(singleton(reviewPlugin)); + + manager.computeInstallationDependencies(); + + manager.install("scm-review-plugin", false); + + verify(mailPlugin).setUninstallable(false); + } + + @Test + void shouldRestartWithUninstallOnly() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.isMarkedForUninstall()).thenReturn(true); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + manager.executePendingAndRestart(); + + verify(eventBus).post(any(RestartEvent.class)); + } } @Nested @@ -351,8 +473,13 @@ class DefaultPluginManagerTest { } @Test - void shouldThrowAuthorizationExceptionsForInstallPendingAndRestart() { - assertThrows(AuthorizationException.class, () -> manager.installPendingAndRestart()); + void shouldThrowAuthorizationExceptionsForUninstallMethod() { + assertThrows(AuthorizationException.class, () -> manager.uninstall("test", false)); + } + + @Test + void shouldThrowAuthorizationExceptionsForExecutePendingAndRestart() { + assertThrows(AuthorizationException.class, () -> manager.executePendingAndRestart()); } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginDependencyTrackerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginDependencyTrackerTest.java new file mode 100644 index 0000000000..150d4356e7 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginDependencyTrackerTest.java @@ -0,0 +1,81 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import sonia.scm.ScmConstraintViolationException; + +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static sonia.scm.plugin.PluginTestHelper.createInstalled; + +class PluginDependencyTrackerTest { + + @Test + void simpleInstalledPluginWithoutDependingPluginsCanBeUninstalled() { + PluginDescriptor mail = createInstalled("scm-mail-plugin").getDescriptor(); + when(mail.getDependencies()).thenReturn(emptySet()); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(mail); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isTrue(); + } + + @Test + void installedPluginWithDependingPluginCannotBeUninstalled() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isFalse(); + } + + @Test + void uninstallOfRequiredPluginShouldThrowException() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + + Assertions.assertThrows( + ScmConstraintViolationException.class, + () -> pluginDependencyTracker.removeInstalled(createInstalled("scm-mail-plugin").getDescriptor()) + ); + } + + @Test + void installedPluginWithDependingPluginCanBeUninstalledAfterDependingPluginIsUninstalled() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + pluginDependencyTracker.removeInstalled(review); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isTrue(); + } + + @Test + void installedPluginWithMultipleDependingPluginCannotBeUninstalledAfterOnlyOneDependingPluginIsUninstalled() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + PluginDescriptor jira = createInstalled("scm-jira-plugin").getDescriptor(); + when(jira.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + pluginDependencyTracker.addInstalled(jira); + pluginDependencyTracker.removeInstalled(review); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isFalse(); + } +}