merge with 2.0.0-m3

This commit is contained in:
Florian Scholdei
2019-09-19 11:30:32 +02:00
23 changed files with 659 additions and 153 deletions

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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: []
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -86,11 +86,9 @@ class ExecutePendingModal extends React.Component<Props, State> {
<>
<strong>{t("plugins.modal.installQueue")}</strong>
<ul>
{pendingPlugins._embedded.new
.filter(plugin => plugin.pending)
.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
{pendingPlugins._embedded.new.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
@@ -107,11 +105,28 @@ class ExecutePendingModal extends React.Component<Props, State> {
<>
<strong>{t("plugins.modal.updateQueue")}</strong>
<ul>
{pendingPlugins._embedded.update
.filter(plugin => plugin.pending)
.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
{pendingPlugins._embedded.update.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderUninstallQueue = () => {
const { pendingPlugins, t } = this.props;
return (
<>
{pendingPlugins._embedded &&
pendingPlugins._embedded.uninstall.length > 0 && (
<>
<strong>{t("plugins.modal.uninstallQueue")}</strong>
<ul>
{pendingPlugins._embedded.uninstall.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
@@ -128,6 +143,7 @@ class ExecutePendingModal extends React.Component<Props, State> {
<p>{t("plugins.modal.executePending")}</p>
{this.renderInstallQueue()}
{this.renderUpdateQueue()}
{this.renderUninstallQueue()}
</div>
</div>
<div className="media">{this.renderNotifications()}</div>

View File

@@ -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<Props, State> {
super(props);
this.state = {
showModal: false
showInstallModal: false,
showUpdateModal: false,
showUninstallModal: false
};
}
@@ -61,10 +71,9 @@ class PluginEntry extends React.Component<Props, State> {
return <PluginAvatar plugin={plugin} />;
};
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<Props, State> {
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 (
<span
className={classNames(classes.link, classes.topRight, "level-item")}
onClick={this.toggleModal}
>
<i className="fas fa-download has-text-info" />
</span>
);
} else if (this.isUpdatable()) {
return (
<span
className={classNames(classes.link, classes.topRight, "level-item")}
onClick={this.toggleModal}
>
<i className="fas fa-sync-alt has-text-info" />
</span>
);
}
return (
<div className={classNames(classes.actionbar, classes.topRight)}>
{this.isInstallable() && (
<span
className={classNames(classes.link, "level-item")}
onClick={() => this.toggleModal("showInstallModal")}
>
<i className="fas fa-download has-text-info" />
</span>
)}
{this.isUninstallable() && (
<span
className={classNames(classes.link, "level-item")}
onClick={() => this.toggleModal("showUninstallModal")}
>
<i className="fas fa-trash has-text-info" />
</span>
)}
{this.isUpdatable() && (
<span
className={classNames(classes.link, "level-item")}
onClick={() => this.toggleModal("showUpdateModal")}
>
<i className="fas fa-sync-alt has-text-info" />
</span>
)}
</div>
);
};
renderModal = () => {
const { plugin, refresh } = this.props;
if (this.isInstallable()) {
if (this.state.showInstallModal && this.isInstallable()) {
return (
<PluginModal
plugin={plugin}
pluginAction={PluginAction.INSTALL}
refresh={refresh}
onClose={this.toggleModal}
onClose={() => this.toggleModal("showInstallModal")}
/>
);
} else if (this.isUpdatable()) {
} else if (this.state.showUpdateModal && this.isUpdatable()) {
return (
<PluginModal
plugin={plugin}
pluginAction={PluginAction.UPDATE}
refresh={refresh}
onClose={this.toggleModal}
onClose={() => this.toggleModal("showUpdateModal")}
/>
);
} else if (this.state.showUninstallModal && this.isUninstallable()) {
return (
<PluginModal
plugin={plugin}
pluginAction={PluginAction.UNINSTALL}
refresh={refresh}
onClose={() => this.toggleModal("showUninstallModal")}
/>
);
} else {
return null;
}
};
createPendingSpinner = () => {
const { plugin, classes } = this.props;
if (plugin.pending) {
return (
<span className={classes.topRight}>
<i className="fas fa-spinner fa-spin has-text-info" />
</span>
);
}
return null;
return (
<span className={classes.topRight}>
<i
className={classNames(
"fas fa-spinner fa-lg fa-spin",
plugin.markedForUninstall ? "has-text-danger" : "has-text-info"
)}
/>
</span>
);
};
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 (
<>
<CardColumn
className={classes.layout}
action={this.isInstallable() ? this.toggleModal : null}
action={
this.isInstallable()
? () => 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}
/>

View File

@@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
<div
className={classNames(
classes.userLabelAlignment,
pluginAction === "install"
pluginAction === PluginAction.INSTALL
? classes.userLabelMarginSmall
: classes.userLabelMarginLarge,
"field-label is-inline-flex"
@@ -235,7 +238,7 @@ class PluginModal extends React.Component<Props, State> {
{plugin.author}
</div>
</div>
{pluginAction === "install" && (
{pluginAction === PluginAction.INSTALL && (
<div className="field is-horizontal">
<div
className={classNames(
@@ -256,49 +259,49 @@ class PluginModal extends React.Component<Props, State> {
</div>
</div>
)}
{pluginAction === "update" && (
<>
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
classes.userLabelMarginLarge,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.currentVersion")}:
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.version}
</div>
{(pluginAction === PluginAction.UPDATE ||
pluginAction === PluginAction.UNINSTALL) && (
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
classes.userLabelMarginLarge,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.currentVersion")}:
</div>
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
classes.userLabelMarginLarge,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.newVersion")}:
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.newVersion}
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.version}
</div>
</>
</div>
)}
{pluginAction === PluginAction.UPDATE && (
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
classes.userLabelMarginLarge,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.newVersion")}:
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.newVersion}
</div>
</div>
)}
{this.renderDependencies()}
</div>
</div>

View File

@@ -43,8 +43,29 @@ type Props = {
class ChangesetsRoot extends React.Component<Props> {
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);

View File

@@ -80,20 +80,17 @@ class Sources extends React.Component<Props, State> {
}
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;
};

View File

@@ -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();
}
}

View File

@@ -61,19 +61,24 @@ public class PendingPluginResource {
Stream<InstalledPlugin> updatePlugins = installed
.stream()
.filter(i -> contains(pending, i));
Stream<InstalledPlugin> uninstallPlugins = installed
.stream()
.filter(InstalledPlugin::isMarkedForUninstall);
Links.Builder linksBuilder = linkingTo().self(resourceLinks.pendingPluginCollection().self());
List<PluginDto> newPluginDtos = newPlugins.map(mapper::mapAvailable).collect(toList());
List<PluginDto> updatePluginDtos = updatePlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList());
List<PluginDto> installDtos = newPlugins.map(mapper::mapAvailable).collect(toList());
List<PluginDto> updateDtos = updatePlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList());
List<PluginDto> 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();
}
}

View File

@@ -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<String> dependencies;
public PluginDto(Links links) {

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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<Path> 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");
}

View File

@@ -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<PendingPluginInstallation> pendingQueue = new ArrayList<>();
private final Collection<PendingPluginInstallation> 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");
}
}

View File

@@ -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<String, Collection<String>> 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);
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}