Merged in feature/abort_plugin_installation (pull request #326)

Feature/abort plugin installation
This commit is contained in:
Rene Pfeuffer
2019-10-02 05:04:10 +00:00
29 changed files with 656 additions and 167 deletions

View File

@@ -5,7 +5,7 @@ import com.google.common.base.Preconditions;
public class AvailablePlugin implements Plugin { public class AvailablePlugin implements Plugin {
private final AvailablePluginDescriptor pluginDescriptor; private final AvailablePluginDescriptor pluginDescriptor;
private final boolean pending; private boolean pending;
public AvailablePlugin(AvailablePluginDescriptor pluginDescriptor) { public AvailablePlugin(AvailablePluginDescriptor pluginDescriptor) {
this(pluginDescriptor, false); this(pluginDescriptor, false);
@@ -25,6 +25,10 @@ public class AvailablePlugin implements Plugin {
return pending; return pending;
} }
public void cancelInstallation() {
this.pending = false;
}
public AvailablePlugin install() { public AvailablePlugin install() {
Preconditions.checkState(!pending, "installation is already pending"); Preconditions.checkState(!pending, "installation is already pending");
return new AvailablePlugin(pluginDescriptor, true); return new AvailablePlugin(pluginDescriptor, true);

View File

@@ -73,6 +73,13 @@ public interface PluginManager {
*/ */
List<AvailablePlugin> getAvailable(); List<AvailablePlugin> getAvailable();
/**
* Returns all updatable plugins.
*
* @return a list of updatable plugins.
*/
List<InstalledPlugin> getUpdatable();
/** /**
* Installs the plugin with the given name from the list of available plugins. * Installs the plugin with the given name from the list of available plugins.
* *
@@ -93,4 +100,14 @@ public interface PluginManager {
* Install all pending plugins and restart the scm context. * Install all pending plugins and restart the scm context.
*/ */
void executePendingAndRestart(); void executePendingAndRestart();
/**
* Cancel all pending plugins.
*/
void cancelPending();
/**
* Update all installed plugins.
*/
void updateAll();
} }

View File

@@ -14,7 +14,7 @@ class ButtonGroup extends React.Component<Props> {
const childWrapper = []; const childWrapper = [];
React.Children.forEach(children, child => { React.Children.forEach(children, child => {
if (child) { if (child) {
childWrapper.push(<p className="control" key={childWrapper.length}>{child}</p>); childWrapper.push(<div className="control" key={childWrapper.length}>{child}</div>);
} }
}); });

View File

@@ -17,6 +17,7 @@ export type Plugin = {
}; };
export type PluginCollection = Collection & { export type PluginCollection = Collection & {
_links: Links,
_embedded: { _embedded: {
plugins: Plugin[] | string[] plugins: Plugin[] | string[]
} }

View File

@@ -29,7 +29,11 @@
"installedNavLink": "Installiert", "installedNavLink": "Installiert",
"availableNavLink": "Verfügbar" "availableNavLink": "Verfügbar"
}, },
"executePending": "Ausstehende Plugin-Änderungen ausführen", "executePending": "Änderungen ausführen",
"outdatedPlugins": "{{count}} veraltetes Plugin",
"outdatedPlugins_plural": "{{count}} veraltete Plugins",
"updateAll": "Alle Plugins aktualisieren",
"cancelPending": "Änderungen abbrechen",
"noPlugins": "Keine Plugins gefunden.", "noPlugins": "Keine Plugins gefunden.",
"modal": { "modal": {
"title": { "title": {
@@ -48,6 +52,7 @@
"updateAndRestart": "Aktualisieren und Neustarten", "updateAndRestart": "Aktualisieren und Neustarten",
"uninstallAndRestart": "Deinstallieren and Neustarten", "uninstallAndRestart": "Deinstallieren and Neustarten",
"executeAndRestart": "Ausführen und Neustarten", "executeAndRestart": "Ausführen und Neustarten",
"updateAll": "Alle Plugins aktualisieren",
"abort": "Abbrechen", "abort": "Abbrechen",
"author": "Autor", "author": "Autor",
"version": "Version", "version": "Version",
@@ -58,7 +63,9 @@
"successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:", "successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:",
"reload": "jetzt neu laden", "reload": "jetzt neu laden",
"restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet.", "restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet.",
"executePending": "Die folgenden Plugin-Änderungen werden ausgeführt. Anschließend wird der SCM-Manager Kontext neu gestartet." "executePending": "Die folgenden Plugin-Änderungen werden ausgeführt. Anschließend wird der SCM-Manager Kontext neu gestartet.",
"cancelPending": "Die folgenden Plugin-Änderungen werden abgebrochen und zurückgesetzt.",
"updateAllInfo": "Die folgenden Plugins werden aktualisiert. Die Änderungen werden nach dem nächsten Neustart wirksam."
} }
}, },
"repositoryRole": { "repositoryRole": {

View File

@@ -29,7 +29,11 @@
"installedNavLink": "Installed", "installedNavLink": "Installed",
"availableNavLink": "Available" "availableNavLink": "Available"
}, },
"executePending": "Execute pending plugin changes", "executePending": "Execute changes",
"outdatedPlugins": "{{count}} outdated plugin",
"outdatedPlugins_plural": "{{count}} outdated plugins",
"updateAll": "Update all plugins",
"cancelPending": "Cancel changes",
"noPlugins": "No plugins found.", "noPlugins": "No plugins found.",
"modal": { "modal": {
"title": { "title": {
@@ -48,6 +52,7 @@
"updateAndRestart": "Update and Restart", "updateAndRestart": "Update and Restart",
"uninstallAndRestart": "Uninstall and Restart", "uninstallAndRestart": "Uninstall and Restart",
"executeAndRestart": "Execute and Restart", "executeAndRestart": "Execute and Restart",
"updateAll": "Update all plugins",
"abort": "Abort", "abort": "Abort",
"author": "Author", "author": "Author",
"version": "Version", "version": "Version",
@@ -58,7 +63,9 @@
"successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:", "successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:",
"reload": "reload now", "reload": "reload now",
"restartNotification": "You should only restart the scm-manager context if no one else is currently working with it.", "restartNotification": "You should only restart the scm-manager context if no one else is currently working with it.",
"executePending": "The following plugin changes will be executed and after that the scm-manager context will be restarted." "executePending": "The following plugin changes will be executed and after that the scm-manager context will be restarted.",
"cancelPending": "The following plugin changes will be canceled.",
"updateAllInfo": "The following plugin changes will be executed. You need to restart the scm-manager to make these changes effective."
} }
}, },
"repositoryRole": { "repositoryRole": {

View File

@@ -30,6 +30,8 @@
"availableNavLink": "Disponibles" "availableNavLink": "Disponibles"
}, },
"executePending": "Ejecutar los complementos pendientes", "executePending": "Ejecutar los complementos pendientes",
"updateAll": "Actualizar todos los complementos",
"cancelPending": "Cancelar los complementos pendientes",
"noPlugins": "No se han encontrado complementos.", "noPlugins": "No se han encontrado complementos.",
"modal": { "modal": {
"title": { "title": {

View File

@@ -0,0 +1,42 @@
// @flow
import React from "react";
import PluginActionModal from "./PluginActionModal";
import type { PendingPlugins } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
onClose: () => void,
refresh: () => void,
pendingPlugins: PendingPlugins,
// context props
t: string => string
};
class CancelPendingActionModal extends React.Component<Props> {
render() {
const { onClose, pendingPlugins, t } = this.props;
return (
<PluginActionModal
description={t("plugins.modal.cancelPending")}
label={t("plugins.cancelPending")}
onClose={onClose}
pendingPlugins={pendingPlugins}
execute={this.cancelPending}
/>
);
}
cancelPending = () => {
const { pendingPlugins, refresh, onClose } = this.props;
return apiClient
.post(pendingPlugins._links.cancel.href)
.then(refresh)
.then(onClose);
};
}
export default translate("admin")(CancelPendingActionModal);

View File

@@ -1,68 +0,0 @@
// @flow
import React from "react";
import { Button } from "@scm-manager/ui-components";
import type { PendingPlugins } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import ExecutePendingModal from "./ExecutePendingModal";
type Props = {
pendingPlugins: PendingPlugins,
// context props
t: string => string
};
type State = {
showModal: boolean
};
class ExecutePendingAction extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
showModal: false
};
}
openModal = () => {
this.setState({
showModal: true
});
};
closeModal = () => {
this.setState({
showModal: false
});
};
renderModal = () => {
const { showModal } = this.state;
const { pendingPlugins } = this.props;
if (showModal) {
return (
<ExecutePendingModal
pendingPlugins={pendingPlugins}
onClose={this.closeModal}
/>
);
}
return null;
};
render() {
const { t } = this.props;
return (
<>
{this.renderModal()}
<Button
color="primary"
label={t("plugins.executePending")}
action={this.openModal}
/>
</>
);
}
}
export default translate("admin")(ExecutePendingAction);

View File

@@ -0,0 +1,45 @@
// @flow
import React from "react";
import PluginActionModal from "./PluginActionModal";
import type { PendingPlugins } from "@scm-manager/ui-types";
import waitForRestart from "./waitForRestart";
import { apiClient, Notification } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
onClose: () => void,
pendingPlugins: PendingPlugins,
// context props
t: string => string
};
class ExecutePendingActionModal extends React.Component<Props> {
render() {
const { onClose, pendingPlugins, t } = this.props;
return (
<PluginActionModal
description={t("plugins.modal.executePending")}
label={t("plugins.modal.executeAndRestart")}
onClose={onClose}
pendingPlugins={pendingPlugins}
execute={this.executeAndRestart}
>
<Notification type="warning">
{t("plugins.modal.restartNotification")}
</Notification>
</PluginActionModal>
);
}
executeAndRestart = () => {
const { pendingPlugins } = this.props;
return apiClient
.post(pendingPlugins._links.execute.href)
.then(waitForRestart);
};
}
export default translate("admin")(ExecutePendingActionModal);

View File

@@ -1,21 +1,26 @@
// @flow // @flow
import React from "react"; import * as React from "react";
import { import {
apiClient,
Button, Button,
ButtonGroup, ButtonGroup,
ErrorNotification, ErrorNotification,
Modal, Modal
Notification
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import type { PendingPlugins } from "@scm-manager/ui-types"; import type { PendingPlugins, PluginCollection } from "@scm-manager/ui-types";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import waitForRestart from "./waitForRestart";
import SuccessNotification from "./SuccessNotification"; import SuccessNotification from "./SuccessNotification";
type Props = { type Props = {
onClose: () => void, onClose: () => void,
pendingPlugins: PendingPlugins, actionType: string,
pendingPlugins?: PendingPlugins,
installedPlugins?: PluginCollection,
refresh: () => void,
execute: () => Promise<any>,
description: string,
label: string,
children?: React.Node,
// context props // context props
t: string => string t: string => string
@@ -27,7 +32,7 @@ type State = {
error?: Error error?: Error
}; };
class ExecutePendingModal extends React.Component<Props, State> { class PluginActionModal extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
@@ -37,35 +42,28 @@ class ExecutePendingModal extends React.Component<Props, State> {
} }
renderNotifications = () => { renderNotifications = () => {
const { t } = this.props; const { children } = this.props;
const { error, success } = this.state; const { error, success } = this.state;
if (error) { if (error) {
return <ErrorNotification error={error} />; return <ErrorNotification error={error} />;
} else if (success) { } else if (success) {
return <SuccessNotification />; return <SuccessNotification />;
} else { } else {
return ( return children;
<Notification type="warning">
{t("plugins.modal.restartNotification")}
</Notification>
);
} }
}; };
executeAndRestart = () => { executeAction = () => {
const { pendingPlugins } = this.props;
this.setState({ this.setState({
loading: true loading: true
}); });
apiClient this.props
.post(pendingPlugins._links.execute.href) .execute()
.then(waitForRestart)
.then(() => { .then(() => {
this.setState({ this.setState({
success: true, success: true,
loading: false, loading: false
error: undefined
}); });
}) })
.catch(error => { .catch(error => {
@@ -77,11 +75,45 @@ class ExecutePendingModal extends React.Component<Props, State> {
}); });
}; };
renderModalContent = () => {
return (
<>
{this.renderUpdatable()}
{this.renderInstallQueue()}
{this.renderUpdateQueue()}
{this.renderUninstallQueue()}
</>
);
};
renderUpdatable = () => {
const { installedPlugins, t } = this.props;
return (
<>
{installedPlugins &&
installedPlugins._embedded &&
installedPlugins._embedded.plugins && (
<>
<strong>{t("plugins.modal.updateQueue")}</strong>
<ul>
{installedPlugins._embedded.plugins
.filter(plugin => plugin._links && plugin._links.update)
.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderInstallQueue = () => { renderInstallQueue = () => {
const { pendingPlugins, t } = this.props; const { pendingPlugins, t } = this.props;
return ( return (
<> <>
{pendingPlugins._embedded && {pendingPlugins &&
pendingPlugins._embedded &&
pendingPlugins._embedded.new.length > 0 && ( pendingPlugins._embedded.new.length > 0 && (
<> <>
<strong>{t("plugins.modal.installQueue")}</strong> <strong>{t("plugins.modal.installQueue")}</strong>
@@ -100,7 +132,8 @@ class ExecutePendingModal extends React.Component<Props, State> {
const { pendingPlugins, t } = this.props; const { pendingPlugins, t } = this.props;
return ( return (
<> <>
{pendingPlugins._embedded && {pendingPlugins &&
pendingPlugins._embedded &&
pendingPlugins._embedded.update.length > 0 && ( pendingPlugins._embedded.update.length > 0 && (
<> <>
<strong>{t("plugins.modal.updateQueue")}</strong> <strong>{t("plugins.modal.updateQueue")}</strong>
@@ -119,7 +152,8 @@ class ExecutePendingModal extends React.Component<Props, State> {
const { pendingPlugins, t } = this.props; const { pendingPlugins, t } = this.props;
return ( return (
<> <>
{pendingPlugins._embedded && {pendingPlugins &&
pendingPlugins._embedded &&
pendingPlugins._embedded.uninstall.length > 0 && ( pendingPlugins._embedded.uninstall.length > 0 && (
<> <>
<strong>{t("plugins.modal.uninstallQueue")}</strong> <strong>{t("plugins.modal.uninstallQueue")}</strong>
@@ -135,15 +169,12 @@ class ExecutePendingModal extends React.Component<Props, State> {
}; };
renderBody = () => { renderBody = () => {
const { t } = this.props;
return ( return (
<> <>
<div className="media"> <div className="media">
<div className="content"> <div className="content">
<p>{t("plugins.modal.executePending")}</p> <p>{this.props.description}</p>
{this.renderInstallQueue()} {this.renderModalContent()}
{this.renderUpdateQueue()}
{this.renderUninstallQueue()}
</div> </div>
</div> </div>
<div className="media">{this.renderNotifications()}</div> <div className="media">{this.renderNotifications()}</div>
@@ -158,9 +189,9 @@ class ExecutePendingModal extends React.Component<Props, State> {
<ButtonGroup> <ButtonGroup>
<Button <Button
color="warning" color="warning"
label={t("plugins.modal.executeAndRestart")} label={this.props.label}
loading={loading} loading={loading}
action={this.executeAndRestart} action={this.executeAction}
disabled={error || success} disabled={error || success}
/> />
<Button label={t("plugins.modal.abort")} action={onClose} /> <Button label={t("plugins.modal.abort")} action={onClose} />
@@ -169,10 +200,10 @@ class ExecutePendingModal extends React.Component<Props, State> {
}; };
render() { render() {
const { onClose, t } = this.props; const { onClose } = this.props;
return ( return (
<Modal <Modal
title={t("plugins.modal.executeAndRestart")} title={this.props.label}
closeFunction={onClose} closeFunction={onClose}
body={this.renderBody()} body={this.renderBody()}
footer={this.renderFooter()} footer={this.renderFooter()}
@@ -182,4 +213,4 @@ class ExecutePendingModal extends React.Component<Props, State> {
} }
} }
export default translate("admin")(ExecutePendingModal); export default translate("admin")(PluginActionModal);

View File

@@ -121,6 +121,7 @@ class PluginModal extends React.Component<Props, State> {
.catch(error => { .catch(error => {
this.setState({ this.setState({
loading: false, loading: false,
success: false,
error: error error: error
}); });
}); });

View File

@@ -0,0 +1,42 @@
// @flow
import React from "react";
import PluginActionModal from "./PluginActionModal";
import type { PluginCollection } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
onClose: () => void,
refresh: () => void,
installedPlugins: PluginCollection,
// context props
t: string => string
};
class UpdateAllActionModal extends React.Component<Props> {
render() {
const { onClose, installedPlugins, t } = this.props;
return (
<PluginActionModal
description={t("plugins.modal.updateAll")}
label={t("plugins.updateAll")}
onClose={onClose}
installedPlugins={installedPlugins}
execute={this.updateAll}
/>
);
}
updateAll = () => {
const { installedPlugins, refresh, onClose } = this.props;
return apiClient
.post(installedPlugins._links.update.href)
.then(refresh)
.then(onClose);
};
}
export default translate("admin")(UpdateAllActionModal);

View File

@@ -5,11 +5,13 @@ import { translate } from "react-i18next";
import { compose } from "redux"; import { compose } from "redux";
import type { PendingPlugins, PluginCollection } from "@scm-manager/ui-types"; import type { PendingPlugins, PluginCollection } from "@scm-manager/ui-types";
import { import {
ButtonGroup,
ErrorNotification, ErrorNotification,
Loading, Loading,
Notification, Notification,
Subtitle, Subtitle,
Title Title,
Button
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { import {
fetchPendingPlugins, fetchPendingPlugins,
@@ -27,7 +29,9 @@ import {
} from "../../../modules/indexResource"; } from "../../../modules/indexResource";
import PluginTopActions from "../components/PluginTopActions"; import PluginTopActions from "../components/PluginTopActions";
import PluginBottomActions from "../components/PluginBottomActions"; import PluginBottomActions from "../components/PluginBottomActions";
import ExecutePendingAction from "../components/ExecutePendingAction"; import ExecutePendingActionModal from "../components/ExecutePendingActionModal";
import CancelPendingActionModal from "../components/CancelPendingActionModal";
import UpdateAllActionModal from "../components/UpdateAllActionModal";
type Props = { type Props = {
loading: boolean, loading: boolean,
@@ -41,14 +45,29 @@ type Props = {
pendingPlugins: PendingPlugins, pendingPlugins: PendingPlugins,
// context objects // context objects
t: string => string, t: (key: string, params?: Object) => string,
// dispatched functions // dispatched functions
fetchPluginsByLink: (link: string) => void, fetchPluginsByLink: (link: string) => void,
fetchPendingPlugins: (link: string) => void fetchPendingPlugins: (link: string) => void
}; };
class PluginsOverview extends React.Component<Props> { type State = {
showPendingModal: boolean,
showUpdateAllModal: boolean,
showCancelModal: boolean
};
class PluginsOverview extends React.Component<Props, State> {
constructor(props: Props, context: *) {
super(props, context);
this.state = {
showPendingModal: false,
showUpdateAllModal: false,
showCancelModal: false
};
}
componentDidMount() { componentDidMount() {
this.fetchPlugins(); this.fetchPlugins();
} }
@@ -102,17 +121,72 @@ class PluginsOverview extends React.Component<Props> {
}; };
createActions = () => { createActions = () => {
const { pendingPlugins } = this.props; const { pendingPlugins, collection, t } = this.props;
const buttons = [];
if ( if (
pendingPlugins && pendingPlugins &&
pendingPlugins._links && pendingPlugins._links &&
pendingPlugins._links.execute pendingPlugins._links.execute
) { ) {
return <ExecutePendingAction pendingPlugins={pendingPlugins} />; buttons.push(
<Button
color="primary"
reducedMobile={true}
key={"executePending"}
icon={"arrow-circle-right"}
label={t("plugins.executePending")}
action={() => this.setState({ showPendingModal: true })}
/>
);
}
if (
pendingPlugins &&
pendingPlugins._links &&
pendingPlugins._links.cancel
) {
buttons.push(
<Button
color="primary"
reducedMobile={true}
key={"cancelPending"}
icon={"times"}
label={t("plugins.cancelPending")}
action={() => this.setState({ showCancelModal: true })}
/>
);
}
if (collection && collection._links && collection._links.update) {
buttons.push(
<Button
color="primary"
reducedMobile={true}
key={"updateAll"}
icon={"sync-alt"}
label={this.computeUpdateAllSize()}
action={() => this.setState({ showUpdateAllModal: true })}
/>
);
}
if (buttons.length > 0) {
return <ButtonGroup>{buttons}</ButtonGroup>;
} }
return null; return null;
}; };
computeUpdateAllSize = () => {
const { collection, t } = this.props;
const outdatedPlugins = collection._embedded.plugins.filter(
p => p._links.update
).length;
return t("plugins.outdatedPlugins", {
count: outdatedPlugins
});
};
render() { render() {
const { loading, error, collection } = this.props; const { loading, error, collection } = this.props;
@@ -131,10 +205,47 @@ class PluginsOverview extends React.Component<Props> {
<hr className="header-with-actions" /> <hr className="header-with-actions" />
{this.renderPluginsList()} {this.renderPluginsList()}
{this.renderFooter(actions)} {this.renderFooter(actions)}
{this.renderModals()}
</> </>
); );
} }
renderModals = () => {
const { collection, pendingPlugins } = this.props;
const {
showPendingModal,
showCancelModal,
showUpdateAllModal
} = this.state;
if (showPendingModal) {
return (
<ExecutePendingActionModal
onClose={() => this.setState({ showPendingModal: false })}
pendingPlugins={pendingPlugins}
/>
);
}
if (showCancelModal) {
return (
<CancelPendingActionModal
onClose={() => this.setState({ showCancelModal: false })}
refresh={this.fetchPlugins}
pendingPlugins={pendingPlugins}
/>
);
}
if (showUpdateAllModal) {
return (
<UpdateAllActionModal
onClose={() => this.setState({ showUpdateAllModal: false })}
refresh={this.fetchPlugins}
installedPlugins={collection}
/>
);
}
};
renderPluginsList() { renderPluginsList() {
const { collection, t } = this.props; const { collection, t } = this.props;

View File

@@ -56,6 +56,21 @@ public class InstalledPluginResource {
return Response.ok(collectionMapper.mapInstalled(plugins, available)).build(); return Response.ok(collectionMapper.mapInstalled(plugins, available)).build();
} }
/**
* Updates all installed plugins.
*/
@POST
@Path("/update")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(CollectionDto.class)
public Response updateAll() {
pluginManager.updateAll();
return Response.ok().build();
}
/** /**
* Returns the installed plugin with the given id. * Returns the installed plugin with the given id.
* *

View File

@@ -46,8 +46,6 @@ public class PendingPluginResource {
}) })
@Produces(VndMediaType.PLUGIN_COLLECTION) @Produces(VndMediaType.PLUGIN_COLLECTION)
public Response getPending() { public Response getPending() {
PluginPermissions.manage().check();
List<AvailablePlugin> pending = pluginManager List<AvailablePlugin> pending = pluginManager
.getAvailable() .getAvailable()
.stream() .stream()
@@ -71,8 +69,12 @@ public class PendingPluginResource {
List<PluginDto> updateDtos = updatePlugins.map(i -> mapper.mapInstalled(i, pending)).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()); List<PluginDto> uninstallDtos = uninstallPlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList());
if (!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty()) { if (
PluginPermissions.manage().isPermitted() &&
(!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty())
) {
linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().executePending())); linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().executePending()));
linksBuilder.single(link("cancel", resourceLinks.pendingPluginCollection().cancelPending()));
} }
Embedded.Builder embedded = Embedded.embeddedBuilder(); Embedded.Builder embedded = Embedded.embeddedBuilder();
@@ -106,8 +108,18 @@ public class PendingPluginResource {
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
public Response executePending() { public Response executePending() {
PluginPermissions.manage().check();
pluginManager.executePendingAndRestart(); pluginManager.executePendingAndRestart();
return Response.ok().build(); return Response.ok().build();
} }
@POST
@Path("/cancel")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
public Response cancelPending() {
pluginManager.cancelPending();
return Response.ok().build();
}
} }

View File

@@ -3,15 +3,15 @@ package sonia.scm.api.v2.resources;
import com.google.inject.Inject; import com.google.inject.Inject;
import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.plugin.PluginPermissions; import sonia.scm.plugin.PluginManager;
import java.util.List; import java.util.List;
import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Embedded.embeddedBuilder;
import static de.otto.edison.hal.Link.link;
import static de.otto.edison.hal.Links.linkingTo; import static de.otto.edison.hal.Links.linkingTo;
import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toList;
@@ -19,11 +19,13 @@ public class PluginDtoCollectionMapper {
private final ResourceLinks resourceLinks; private final ResourceLinks resourceLinks;
private final PluginDtoMapper mapper; private final PluginDtoMapper mapper;
private final PluginManager manager;
@Inject @Inject
public PluginDtoCollectionMapper(ResourceLinks resourceLinks, PluginDtoMapper mapper) { public PluginDtoCollectionMapper(ResourceLinks resourceLinks, PluginDtoMapper mapper, PluginManager manager) {
this.resourceLinks = resourceLinks; this.resourceLinks = resourceLinks;
this.mapper = mapper; this.mapper = mapper;
this.manager = manager;
} }
public HalRepresentation mapInstalled(List<InstalledPlugin> plugins, List<AvailablePlugin> availablePlugins) { public HalRepresentation mapInstalled(List<InstalledPlugin> plugins, List<AvailablePlugin> availablePlugins) {
@@ -44,6 +46,11 @@ public class PluginDtoCollectionMapper {
Links.Builder linksBuilder = linkingTo() Links.Builder linksBuilder = linkingTo()
.with(Links.linkingTo().self(baseUrl).build()); .with(Links.linkingTo().self(baseUrl).build());
if (!manager.getUpdatable().isEmpty()) {
linksBuilder.single(link("update", resourceLinks.installedPluginCollection().update()));
}
return linksBuilder.build(); return linksBuilder.build();
} }

View File

@@ -686,6 +686,10 @@ class ResourceLinks {
String self() { String self() {
return installedPluginCollectionLinkBuilder.method("installedPlugins").parameters().method("getInstalledPlugins").parameters().href(); return installedPluginCollectionLinkBuilder.method("installedPlugins").parameters().method("getInstalledPlugins").parameters().href();
} }
String update() {
return installedPluginCollectionLinkBuilder.method("installedPlugins").parameters().method("updateAll").parameters().href();
}
} }
public AvailablePluginLinks availablePlugin() { public AvailablePluginLinks availablePlugin() {
@@ -739,6 +743,10 @@ class ResourceLinks {
return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("executePending").parameters().href(); return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("executePending").parameters().href();
} }
String cancelPending() {
return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("cancelPending").parameters().href();
}
String self() { String self() {
return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("getPending").parameters().href(); return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("getPending").parameters().href();
} }

View File

@@ -44,8 +44,8 @@ import sonia.scm.lifecycle.RestartEvent;
import sonia.scm.version.Version; import sonia.scm.version.Version;
import javax.inject.Inject; import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@@ -72,7 +72,8 @@ public class DefaultPluginManager implements PluginManager {
private final PluginLoader loader; private final PluginLoader loader;
private final PluginCenter center; private final PluginCenter center;
private final PluginInstaller installer; private final PluginInstaller installer;
private final Collection<PendingPluginInstallation> pendingQueue = new ArrayList<>(); private final Collection<PendingPluginInstallation> pendingInstallQueue = new ArrayList<>();
private final Collection<PendingPluginUninstallation> pendingUninstallQueue = new ArrayList<>();
private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker(); private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker();
@Inject @Inject
@@ -106,7 +107,7 @@ public class DefaultPluginManager implements PluginManager {
} }
private Optional<AvailablePlugin> getPending(String name) { private Optional<AvailablePlugin> getPending(String name) {
return pendingQueue return pendingInstallQueue
.stream() .stream()
.map(PendingPluginInstallation::getPlugin) .map(PendingPluginInstallation::getPlugin)
.filter(filterByName(name)) .filter(filterByName(name))
@@ -138,6 +139,15 @@ public class DefaultPluginManager implements PluginManager {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override
public List<InstalledPlugin> getUpdatable() {
return getInstalled()
.stream()
.filter(p -> isUpdatable(p.getDescriptor().getInformation().getName()))
.filter(p -> !p.isMarkedForUninstall())
.collect(Collectors.toList());
}
private <T extends Plugin> Predicate<T> filterByName(String name) { private <T extends Plugin> Predicate<T> filterByName(String name) {
return plugin -> name.equals(plugin.getDescriptor().getInformation().getName()); return plugin -> name.equals(plugin.getDescriptor().getInformation().getName());
} }
@@ -179,7 +189,7 @@ public class DefaultPluginManager implements PluginManager {
if (restartAfterInstallation) { if (restartAfterInstallation) {
restart("plugin installation"); restart("plugin installation");
} else { } else {
pendingQueue.addAll(pendingInstallations); pendingInstallQueue.addAll(pendingInstallations);
updateMayUninstallFlag(); updateMayUninstallFlag();
} }
} }
@@ -192,10 +202,7 @@ public class DefaultPluginManager implements PluginManager {
.orElseThrow(() -> NotFoundException.notFound(entity(InstalledPlugin.class, name))); .orElseThrow(() -> NotFoundException.notFound(entity(InstalledPlugin.class, name)));
doThrow().violation("plugin is a core plugin and cannot be uninstalled").when(installed.isCore()); doThrow().violation("plugin is a core plugin and cannot be uninstalled").when(installed.isCore());
dependencyTracker.removeInstalled(installed.getDescriptor()); markForUninstall(installed);
installed.setMarkedForUninstall(true);
createMarkerFile(installed, InstalledPlugin.UNINSTALL_MARKER_FILENAME);
if (restartAfterInstallation) { if (restartAfterInstallation) {
restart("plugin installation"); restart("plugin installation");
@@ -215,18 +222,22 @@ public class DefaultPluginManager implements PluginManager {
&& dependencyTracker.mayUninstall(p.getDescriptor().getInformation().getName()); && dependencyTracker.mayUninstall(p.getDescriptor().getInformation().getName());
} }
private void createMarkerFile(InstalledPlugin plugin, String markerFile) { private void markForUninstall(InstalledPlugin plugin) {
dependencyTracker.removeInstalled(plugin.getDescriptor());
try { try {
Files.createFile(plugin.getDirectory().resolve(markerFile)); Path file = Files.createFile(plugin.getDirectory().resolve(InstalledPlugin.UNINSTALL_MARKER_FILENAME));
} catch (IOException e) { pendingUninstallQueue.add(new PendingPluginUninstallation(plugin, file));
throw new PluginException("could not mark plugin " + plugin.getId() + " in path " + plugin.getDirectory() + "as " + markerFile, e); plugin.setMarkedForUninstall(true);
} catch (Exception e) {
dependencyTracker.addInstalled(plugin.getDescriptor());
throw new PluginException("could not mark plugin " + plugin.getId() + " in path " + plugin.getDirectory() + "as " + InstalledPlugin.UNINSTALL_MARKER_FILENAME, e);
} }
} }
@Override @Override
public void executePendingAndRestart() { public void executePendingAndRestart() {
PluginPermissions.manage().check(); PluginPermissions.manage().check();
if (!pendingQueue.isEmpty() || getInstalled().stream().anyMatch(InstalledPlugin::isMarkedForUninstall)) { if (!pendingInstallQueue.isEmpty() || getInstalled().stream().anyMatch(InstalledPlugin::isMarkedForUninstall)) {
restart("execute pending plugin changes"); restart("execute pending plugin changes");
} }
} }
@@ -269,4 +280,25 @@ public class DefaultPluginManager implements PluginManager {
private boolean isUpdatable(String name) { private boolean isUpdatable(String name) {
return getAvailable(name).isPresent() && !getPending(name).isPresent(); return getAvailable(name).isPresent() && !getPending(name).isPresent();
} }
@Override
public void cancelPending() {
PluginPermissions.manage().check();
pendingUninstallQueue.forEach(PendingPluginUninstallation::cancel);
pendingInstallQueue.forEach(PendingPluginInstallation::cancel);
pendingUninstallQueue.clear();
pendingInstallQueue.clear();
updateMayUninstallFlag();
}
@Override
public void updateAll() {
PluginPermissions.manage().check();
for (InstalledPlugin installedPlugin : getInstalled()) {
String pluginName = installedPlugin.getDescriptor().getInformation().getName();
if (isUpdatable(pluginName)) {
install(pluginName, false);
}
}
}
} }

View File

@@ -28,8 +28,9 @@ class PendingPluginInstallation {
LOG.info("cancel installation of plugin {}", name); LOG.info("cancel installation of plugin {}", name);
try { try {
Files.delete(file); Files.delete(file);
plugin.cancelInstallation();
} catch (IOException ex) { } catch (IOException ex) {
throw new PluginFailedToCancelInstallationException("failed to cancel installation of plugin " + name, ex); throw new PluginFailedToCancelInstallationException("failed to cancel plugin installation ", name, ex);
} }
} }
} }

View File

@@ -0,0 +1,32 @@
package sonia.scm.plugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
class PendingPluginUninstallation {
private static final Logger LOG = LoggerFactory.getLogger(PendingPluginUninstallation.class);
private final InstalledPlugin plugin;
private final Path uninstallFile;
PendingPluginUninstallation(InstalledPlugin plugin, Path uninstallFile) {
this.plugin = plugin;
this.uninstallFile = uninstallFile;
}
void cancel() {
String name = plugin.getDescriptor().getInformation().getName();
LOG.info("cancel uninstallation of plugin {}", name);
try {
Files.delete(uninstallFile);
plugin.setMarkedForUninstall(false);
} catch (IOException ex) {
throw new PluginFailedToCancelInstallationException("failed to cancel uninstallation", name, ex);
}
}
}

View File

@@ -33,6 +33,10 @@ class PluginDependencyTracker {
} }
private void removeDependency(String from, String to) { private void removeDependency(String from, String to) {
plugins.get(to).remove(from); Collection<String> dependencies = plugins.get(to);
if (dependencies == null) {
throw new NullPointerException("inverse dependencies not found for " + to);
}
dependencies.remove(from);
} }
} }

View File

@@ -1,7 +1,16 @@
package sonia.scm.plugin; package sonia.scm.plugin;
public class PluginFailedToCancelInstallationException extends RuntimeException { import sonia.scm.ExceptionWithContext;
public PluginFailedToCancelInstallationException(String message, Throwable cause) {
super(message, cause); import static sonia.scm.ContextEntry.ContextBuilder.entity;
public class PluginFailedToCancelInstallationException extends ExceptionWithContext {
public PluginFailedToCancelInstallationException(String message, String name, Exception cause) {
super(entity("plugin", name).build(), message, cause);
}
@Override
public String getCode() {
return "65RdZ5atX1";
} }
} }

View File

@@ -175,6 +175,10 @@
"40RaYIeeR1": { "40RaYIeeR1": {
"displayName": "Es wurden keine Änderungen durchgeführt", "displayName": "Es wurden keine Änderungen durchgeführt",
"description": "Das Repository wurde nicht verändert. Daher konnte kein neuer Commit erzeugt werden." "description": "Das Repository wurde nicht verändert. Daher konnte kein neuer Commit erzeugt werden."
},
"65RdZ5atX1": {
"displayName": "Fehler beim Löschen von Plugin-Dateien",
"description": "Einige Dateien für die Plugin-Deinstallation konnten nicht gelöscht werden. Dieses kann zu Inkonsistenzen führen, so dass der SCM-Manager nicht mehr korrekt starten kann. Bitte prüfen Sie die Logs und bereinigen Sie das Plugin-Verzeichnis des SCM-Managers manuell. Um die Installation eines Plugins abzubrechen, löschen Sie die zugehörige smp Datei aus dem Plugin-Verzeichnis. Um ein Entfernen eines Plugins zu verhindern, entfernen Sie die Datei namens 'uninstall' aus dem entsprechenden Verzeichnis des Plugins."
} }
}, },
"namespaceStrategies": { "namespaceStrategies": {

View File

@@ -175,6 +175,10 @@
"40RaYIeeR1": { "40RaYIeeR1": {
"displayName": "No changes were made", "displayName": "No changes were made",
"description": "No changes were made to the files of the repository. Therefor no new commit could be created." "description": "No changes were made to the files of the repository. Therefor no new commit could be created."
},
"65RdZ5atX1": {
"displayName": "Error removing plugin files",
"description": "Some files to cancel the plugin (un)installation could not be deleted. This can lead to inconsistencies so that the SCM-Manager cannot restart properly. Please check the logs and clean up the plugin folder manually. To cancel the installation of a plugin, remove the corresponding smp file. To cancel the uninstallation, remove the file named 'uninstall' inside the directory for this plugin."
} }
}, },
"namespaceStrategies": { "namespaceStrategies": {

View File

@@ -34,11 +34,8 @@ import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -74,12 +71,12 @@ class PendingPluginResourceTest {
void mockMapper() { void mockMapper() {
lenient().when(mapper.mapAvailable(any())).thenAnswer(invocation -> { lenient().when(mapper.mapAvailable(any())).thenAnswer(invocation -> {
PluginDto dto = new PluginDto(); PluginDto dto = new PluginDto();
dto.setName(((AvailablePlugin)invocation.getArgument(0)).getDescriptor().getInformation().getName()); dto.setName(((AvailablePlugin) invocation.getArgument(0)).getDescriptor().getInformation().getName());
return dto; return dto;
}); });
lenient().when(mapper.mapInstalled(any(), any())).thenAnswer(invocation -> { lenient().when(mapper.mapInstalled(any(), any())).thenAnswer(invocation -> {
PluginDto dto = new PluginDto(); PluginDto dto = new PluginDto();
dto.setName(((InstalledPlugin)invocation.getArgument(0)).getDescriptor().getInformation().getName()); dto.setName(((InstalledPlugin) invocation.getArgument(0)).getDescriptor().getInformation().getName());
return dto; return dto;
}); });
} }
@@ -90,7 +87,7 @@ class PendingPluginResourceTest {
@BeforeEach @BeforeEach
void bindSubject() { void bindSubject() {
ThreadContext.bind(subject); ThreadContext.bind(subject);
doNothing().when(subject).checkPermission("plugin:manage"); lenient().when(subject.isPermitted("plugin:manage")).thenReturn(true);
} }
@AfterEach @AfterEach
@@ -113,7 +110,7 @@ class PendingPluginResourceTest {
} }
@Test @Test
void shouldGetPendingAvailablePluginListWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { void shouldGetPendingAvailablePluginListWithInstallAndCancelLink() throws URISyntaxException, UnsupportedEncodingException {
AvailablePlugin availablePlugin = createAvailablePlugin("pending-available-plugin"); AvailablePlugin availablePlugin = createAvailablePlugin("pending-available-plugin");
when(availablePlugin.isPending()).thenReturn(true); when(availablePlugin.isPending()).thenReturn(true);
when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin));
@@ -124,6 +121,7 @@ class PendingPluginResourceTest {
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
assertThat(response.getContentAsString()).contains("\"new\":[{\"name\":\"pending-available-plugin\""); assertThat(response.getContentAsString()).contains("\"new\":[{\"name\":\"pending-available-plugin\"");
assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}");
assertThat(response.getContentAsString()).contains("\"cancel\":{\"href\":\"/v2/plugins/pending/cancel\"}");
} }
@Test @Test
@@ -166,6 +164,17 @@ class PendingPluginResourceTest {
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
verify(pluginManager).executePendingAndRestart(); verify(pluginManager).executePendingAndRestart();
} }
@Test
void shouldCancelPendingPlugins() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/cancel");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
verify(pluginManager).cancelPending();
}
} }
@Nested @Nested
@@ -174,7 +183,7 @@ class PendingPluginResourceTest {
@BeforeEach @BeforeEach
void bindSubject() { void bindSubject() {
ThreadContext.bind(subject); ThreadContext.bind(subject);
doThrow(new ShiroException()).when(subject).checkPermission("plugin:manage"); when(subject.isPermitted("plugin:manage")).thenReturn(false);
} }
@AfterEach @AfterEach
@@ -183,23 +192,18 @@ class PendingPluginResourceTest {
} }
@Test @Test
void shouldNotListPendingPlugins() throws URISyntaxException { void shouldGetPendingAvailablePluginListWithoutInstallAndCancelLink() throws URISyntaxException, UnsupportedEncodingException {
AvailablePlugin availablePlugin = createAvailablePlugin("pending-available-plugin");
when(availablePlugin.isPending()).thenReturn(true);
when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin));
MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending"); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending");
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
verify(pluginManager, never()).executePendingAndRestart(); assertThat(response.getContentAsString()).contains("\"new\":[{\"name\":\"pending-available-plugin\"");
} assertThat(response.getContentAsString()).doesNotContain("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}");
assertThat(response.getContentAsString()).doesNotContain("\"cancel\":{\"href\":\"/v2/plugins/pending/cancel\"}");
@Test
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()).executePendingAndRestart();
} }
} }

View File

@@ -10,15 +10,19 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.AvailablePluginDescriptor; import sonia.scm.plugin.AvailablePluginDescriptor;
import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.InstalledPluginDescriptor;
import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginInformation;
import sonia.scm.plugin.PluginManager;
import java.net.URI; import java.net.URI;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -34,6 +38,9 @@ class PluginDtoCollectionMapperTest {
@InjectMocks @InjectMocks
PluginDtoMapperImpl pluginDtoMapper; PluginDtoMapperImpl pluginDtoMapper;
@Mock
PluginManager manager;
Subject subject = mock(Subject.class); Subject subject = mock(Subject.class);
ThreadState subjectThreadState = new SubjectThreadState(subject); ThreadState subjectThreadState = new SubjectThreadState(subject);
@@ -43,6 +50,11 @@ class PluginDtoCollectionMapperTest {
ThreadContext.bind(subject); ThreadContext.bind(subject);
} }
@BeforeEach
void mockPluginManager() {
lenient().when(manager.getUpdatable()).thenReturn(new ArrayList<>());
}
@AfterEach @AfterEach
public void unbindSubject() { public void unbindSubject() {
ThreadContext.unbindSubject(); ThreadContext.unbindSubject();
@@ -51,7 +63,7 @@ class PluginDtoCollectionMapperTest {
@Test @Test
void shouldMapInstalledPluginsWithoutUpdateWhenNoNewerVersionIsAvailable() { void shouldMapInstalledPluginsWithoutUpdateWhenNoNewerVersionIsAvailable() {
PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager);
HalRepresentation result = mapper.mapInstalled( HalRepresentation result = mapper.mapInstalled(
singletonList(createInstalledPlugin("scm-some-plugin", "1")), singletonList(createInstalledPlugin("scm-some-plugin", "1")),
@@ -66,7 +78,7 @@ class PluginDtoCollectionMapperTest {
@Test @Test
void shouldSetNewVersionInInstalledPluginWhenAvailableVersionIsNewer() { void shouldSetNewVersionInInstalledPluginWhenAvailableVersionIsNewer() {
PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper,manager);
HalRepresentation result = mapper.mapInstalled( HalRepresentation result = mapper.mapInstalled(
singletonList(createInstalledPlugin("scm-some-plugin", "1")), singletonList(createInstalledPlugin("scm-some-plugin", "1")),
@@ -80,7 +92,7 @@ class PluginDtoCollectionMapperTest {
@Test @Test
void shouldNotAddInstallLinkForNewVersionWhenNotPermitted() { void shouldNotAddInstallLinkForNewVersionWhenNotPermitted() {
when(subject.isPermitted("plugin:manage")).thenReturn(false); when(subject.isPermitted("plugin:manage")).thenReturn(false);
PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager);
HalRepresentation result = mapper.mapInstalled( HalRepresentation result = mapper.mapInstalled(
singletonList(createInstalledPlugin("scm-some-plugin", "1")), singletonList(createInstalledPlugin("scm-some-plugin", "1")),
@@ -93,7 +105,7 @@ class PluginDtoCollectionMapperTest {
@Test @Test
void shouldNotAddInstallLinkForNewVersionWhenInstallationIsPending() { void shouldNotAddInstallLinkForNewVersionWhenInstallationIsPending() {
when(subject.isPermitted("plugin:manage")).thenReturn(true); when(subject.isPermitted("plugin:manage")).thenReturn(true);
PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager);
AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2"); AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2");
when(availablePlugin.isPending()).thenReturn(true); when(availablePlugin.isPending()).thenReturn(true);
@@ -108,7 +120,7 @@ class PluginDtoCollectionMapperTest {
@Test @Test
void shouldAddInstallLinkForNewVersionWhenPermitted() { void shouldAddInstallLinkForNewVersionWhenPermitted() {
when(subject.isPermitted("plugin:manage")).thenReturn(true); when(subject.isPermitted("plugin:manage")).thenReturn(true);
PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager);
HalRepresentation result = mapper.mapInstalled( HalRepresentation result = mapper.mapInstalled(
singletonList(createInstalledPlugin("scm-some-plugin", "1")), singletonList(createInstalledPlugin("scm-some-plugin", "1")),
@@ -121,7 +133,7 @@ class PluginDtoCollectionMapperTest {
@Test @Test
void shouldSetInstalledPluginPendingWhenCorrespondingAvailablePluginIsPending() { void shouldSetInstalledPluginPendingWhenCorrespondingAvailablePluginIsPending() {
when(subject.isPermitted("plugin:manage")).thenReturn(true); when(subject.isPermitted("plugin:manage")).thenReturn(true);
PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager);
AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2"); AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2");
when(availablePlugin.isPending()).thenReturn(true); when(availablePlugin.isPending()).thenReturn(true);

View File

@@ -20,6 +20,8 @@ import sonia.scm.ScmConstraintViolationException;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
import sonia.scm.lifecycle.RestartEvent; import sonia.scm.lifecycle.RestartEvent;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -30,11 +32,13 @@ import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.any; import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static sonia.scm.plugin.PluginTestHelper.createAvailable; import static sonia.scm.plugin.PluginTestHelper.createAvailable;
@@ -358,6 +362,24 @@ class DefaultPluginManagerTest {
verify(mailPlugin).setMarkedForUninstall(true); verify(mailPlugin).setMarkedForUninstall(true);
} }
@Test
void shouldNotChangeStateWhenUninstallFileCouldNotBeCreated() {
InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin");
InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin");
when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin"));
when(reviewPlugin.getDirectory()).thenThrow(new PluginException("when the file could not be written an exception like this is thrown"));
when(loader.getInstalledPlugins()).thenReturn(asList(mailPlugin, reviewPlugin));
manager.computeInstallationDependencies();
assertThrows(PluginException.class, () -> manager.uninstall("scm-review-plugin", false));
verify(mailPlugin, never()).setMarkedForUninstall(true);
assertThrows(ScmConstraintViolationException.class, () -> manager.uninstall("scm-mail-plugin", false));
}
@Test @Test
void shouldThrowExceptionWhenUninstallingCorePlugin(@TempDirectory.TempDir Path temp) { void shouldThrowExceptionWhenUninstallingCorePlugin(@TempDirectory.TempDir Path temp) {
InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin");
@@ -427,6 +449,71 @@ class DefaultPluginManagerTest {
verify(eventBus).post(any(RestartEvent.class)); verify(eventBus).post(any(RestartEvent.class));
} }
@Test
void shouldUndoPendingInstallations(@TempDirectory.TempDir Path temp) throws IOException {
InstalledPlugin mailPlugin = createInstalled("scm-ssh-plugin");
Path mailPluginPath = temp.resolve("scm-mail-plugin");
Files.createDirectories(mailPluginPath);
when(mailPlugin.getDirectory()).thenReturn(mailPluginPath);
when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin));
ArgumentCaptor<Boolean> uninstallCaptor = ArgumentCaptor.forClass(Boolean.class);
doNothing().when(mailPlugin).setMarkedForUninstall(uninstallCaptor.capture());
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(git));
PendingPluginInstallation gitPendingPluginInformation = mock(PendingPluginInstallation.class);
when(installer.install(git)).thenReturn(gitPendingPluginInformation);
manager.install("scm-git-plugin", false);
manager.uninstall("scm-ssh-plugin", false);
manager.cancelPending();
assertThat(mailPluginPath.resolve("uninstall")).doesNotExist();
verify(gitPendingPluginInformation).cancel();
Boolean lasUninstallMarkerSet = uninstallCaptor.getAllValues().get(uninstallCaptor.getAllValues().size() - 1);
assertThat(lasUninstallMarkerSet).isFalse();
Files.createFile(mailPluginPath.resolve("uninstall"));
manager.cancelPending();
verify(gitPendingPluginInformation, times(1)).cancel();
assertThat(mailPluginPath.resolve("uninstall")).exists();
}
@Test
void shouldUpdateAllPlugins() {
InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin");
InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(mailPlugin, reviewPlugin));
AvailablePlugin newMailPlugin = createAvailable("scm-mail-plugin", "2.0.0");
AvailablePlugin newReviewPlugin = createAvailable("scm-review-plugin", "2.0.0");
when(center.getAvailable()).thenReturn(ImmutableSet.of(newMailPlugin, newReviewPlugin));
manager.updateAll();
verify(installer).install(newMailPlugin);
verify(installer).install(newReviewPlugin);
}
@Test
void shouldNotUpdateToOldPluginVersions() {
InstalledPlugin scriptPlugin = createInstalled("scm-script-plugin");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(scriptPlugin));
AvailablePlugin oldScriptPlugin = createAvailable("scm-script-plugin", "0.9");
when(center.getAvailable()).thenReturn(ImmutableSet.of(oldScriptPlugin));
manager.updateAll();
verify(installer, never()).install(oldScriptPlugin);
}
} }
@Nested @Nested
@@ -482,5 +569,14 @@ class DefaultPluginManagerTest {
assertThrows(AuthorizationException.class, () -> manager.executePendingAndRestart()); assertThrows(AuthorizationException.class, () -> manager.executePendingAndRestart());
} }
@Test
void shouldThrowAuthorizationExceptionsForCancelPending() {
assertThrows(AuthorizationException.class, () -> manager.cancelPending());
}
@Test
void shouldThrowAuthorizationExceptionsForUpdateAll() {
assertThrows(AuthorizationException.class, () -> manager.updateAll());
}
} }
} }

View File

@@ -14,6 +14,13 @@ public class PluginTestHelper {
return createAvailable(information); return createAvailable(information);
} }
public static AvailablePlugin createAvailable(String name, String version) {
PluginInformation information = new PluginInformation();
information.setName(name);
information.setVersion(version);
return createAvailable(information);
}
public static InstalledPlugin createInstalled(String name) { public static InstalledPlugin createInstalled(String name) {
PluginInformation information = new PluginInformation(); PluginInformation information = new PluginInformation();
information.setName(name); information.setName(name);