diff --git a/core/src/main/java/jenkins/install/SetupWizard.java b/core/src/main/java/jenkins/install/SetupWizard.java index b2faacbeb4..08d2d69626 100644 --- a/core/src/main/java/jenkins/install/SetupWizard.java +++ b/core/src/main/java/jenkins/install/SetupWizard.java @@ -1,5 +1,6 @@ package jenkins.install; +import edu.umd.cs.findbugs.annotations.CheckForNull; import java.io.IOException; import java.util.Locale; import java.util.UUID; @@ -26,7 +27,9 @@ import org.kohsuke.stapler.StaplerResponse; import hudson.BulkChange; import hudson.FilePath; +import hudson.ProxyConfiguration; import hudson.model.UpdateCenter; +import hudson.model.UpdateSite; import hudson.model.User; import hudson.security.FullControlOnceLoggedInAuthorizationStrategy; import hudson.security.HudsonPrivateSecurityRealm; @@ -35,8 +38,21 @@ import hudson.security.csrf.DefaultCrumbIssuer; import hudson.util.HttpResponses; import hudson.util.PluginServletFilter; import hudson.util.VersionNumber; +import java.io.File; +import java.net.HttpRetryException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.util.Iterator; import jenkins.model.Jenkins; import jenkins.security.s2m.AdminWhitelistRule; +import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.apache.commons.io.FileUtils; +import static org.apache.commons.io.FileUtils.readFileToString; +import org.apache.commons.io.IOUtils; +import static org.apache.commons.lang.StringUtils.defaultIfBlank; +import org.kohsuke.accmod.restrictions.DoNotUse; /** * A Jenkins instance used during first-run to provide a limited set of services while @@ -51,7 +67,7 @@ public class SetupWizard { */ public static String initialSetupAdminUserName = "admin"; - private final Logger LOGGER = Logger.getLogger(SetupWizard.class.getName()); + private final static Logger LOGGER = Logger.getLogger(SetupWizard.class.getName()); private final Jenkins jenkins; @@ -192,6 +208,168 @@ public class SetupWizard { } } + /*package*/ static void completeUpgrade(Jenkins jenkins) throws IOException { + setCurrentLevel(Jenkins.getVersion()); + //TODO: restore when https://github.com/jenkinsci/jenkins/pull/2281 gets merged + // jenkins.getInstallState().proceedToNextState(); + } + + /*package*/ static void setCurrentLevel(VersionNumber v) throws IOException { + FileUtils.writeStringToFile(getUpdateStateFile(), v.toString()); + } + + /** + * File that captures the state of upgrade. + * + * This file records the version number that the installation has upgraded to. + */ + /*package*/ static File getUpdateStateFile() { + return new File(Jenkins.getInstance().getRootDir(),"jenkins.install.UpgradeWizard.state"); + } + + /** + * What is the version the upgrade wizard has run the last time and upgraded to?. + * If {@link #getUpdateStateFile()} is missing, presumes the baseline is 1.0 + * @return Current baseline. {@code null} if it cannot be retrieved. + */ + @Restricted(NoExternalUse.class) + @CheckForNull + public static VersionNumber getCurrentLevel() { + VersionNumber from = new VersionNumber("1.0"); + File state = getUpdateStateFile(); + if (state.exists()) { + try { + from = new VersionNumber(defaultIfBlank(readFileToString(state), "1.0").trim()); + } catch (IOException ex) { + LOGGER.log(Level.SEVERE, "Cannot read the current version file", ex); + return null; + } + } + return from; + } + + /** + * Returns the initial plugin list in JSON format + */ + @Restricted(DoNotUse.class) // WebOnly + public HttpResponse doPlatformPluginList() throws IOException { + jenkins.install.SetupWizard setupWizard = Jenkins.getInstance().getSetupWizard(); + if (setupWizard != null) { + if (InstallState.UPGRADE.equals(Jenkins.getInstance().getInstallState())) { + JSONArray initialPluginData = getPlatformPluginUpdates(); + if(initialPluginData != null) { + return HttpResponses.okJSON(initialPluginData); + } + } else { + JSONArray initialPluginData = getPlatformPluginList(); + if(initialPluginData != null) { + return HttpResponses.okJSON(initialPluginData); + } + } + } + return HttpResponses.okJSON(); + } + + /** + * Provides the list of platform plugin updates from the last time + * the upgrade was run. + * @return {@code null} if the version range cannot be retrieved. + */ + @CheckForNull + /*package*/ JSONArray getPlatformPluginUpdates() { + final VersionNumber version = getCurrentLevel(); + if (version == null) { + return null; + } + return getPlatformPluginsForUpdate(version, Jenkins.getVersion()); + } + + /** + * Gets the suggested plugin list from the update sites, falling back to a local version + * @return JSON array with the categorized plugon list + */ + @CheckForNull + /*package*/ JSONArray getPlatformPluginList() { + Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER); + JSONArray initialPluginList = null; + updateSiteList: for (UpdateSite updateSite : Jenkins.getInstance().getUpdateCenter().getSiteList()) { + String updateCenterJsonUrl = updateSite.getUrl(); + String suggestedPluginUrl = updateCenterJsonUrl.replace("/update-center.json", "/platform-plugins.json"); + try { + URLConnection connection = ProxyConfiguration.open(new URL(suggestedPluginUrl)); + + try { + if(connection instanceof HttpURLConnection) { + int responseCode = ((HttpURLConnection)connection).getResponseCode(); + if(HttpURLConnection.HTTP_OK != responseCode) { + throw new HttpRetryException("Invalid response code (" + responseCode + ") from URL: " + suggestedPluginUrl, responseCode); + } + } + + String initialPluginJson = IOUtils.toString(connection.getInputStream(), "utf-8"); + initialPluginList = JSONArray.fromObject(initialPluginJson); + break updateSiteList; + } catch(Exception e) { + // not found or otherwise unavailable + LOGGER.log(Level.FINE, e.getMessage(), e); + continue updateSiteList; + } + } catch(Exception e) { + LOGGER.log(Level.FINE, e.getMessage(), e); + } + } + if (initialPluginList == null) { + // fall back to local file + try { + ClassLoader cl = getClass().getClassLoader(); + URL localPluginData = cl.getResource("jenkins/install/platform-plugins.json"); + String initialPluginJson = IOUtils.toString(localPluginData.openStream(), "utf-8"); + initialPluginList = JSONArray.fromObject(initialPluginJson); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, e.getMessage(), e); + } + } + return initialPluginList; + } + + /** + * Get the platform plugins added in the version range + */ + /*package*/ JSONArray getPlatformPluginsForUpdate(VersionNumber from, VersionNumber to) { + JSONArray pluginCategories = JSONArray.fromObject(getPlatformPluginList().toString()); + for (Iterator categoryIterator = pluginCategories.iterator(); categoryIterator.hasNext();) { + Object category = categoryIterator.next(); + if (category instanceof JSONObject) { + JSONObject cat = (JSONObject)category; + JSONArray plugins = cat.getJSONArray("plugins"); + + nextPlugin: for (Iterator pluginIterator = plugins.iterator(); pluginIterator.hasNext();) { + Object pluginData = pluginIterator.next(); + if (pluginData instanceof JSONObject) { + JSONObject plugin = (JSONObject)pluginData; + if (plugin.has("added")) { + String sinceVersion = plugin.getString("added"); + if (sinceVersion != null) { + VersionNumber v = new VersionNumber(sinceVersion); + if(v.compareTo(to) <= 0 && v.compareTo(from) > 0) { + plugin.put("suggested", false); + continue nextPlugin; + } + } + } + } + + pluginIterator.remove(); + } + + if (plugins.isEmpty()) { + categoryIterator.remove(); + } + } + } + return pluginCategories; + } + /** * Gets the file used to store the initial admin password */ @@ -211,6 +389,11 @@ public class SetupWizard { static void completeSetup(Jenkins jenkins) { jenkins.setInstallState(InstallState.INITIAL_SETUP_COMPLETED); InstallUtil.saveLastExecVersion(); + try { + completeUpgrade(jenkins); + } catch(IOException ex) { + LOGGER.log(Level.SEVERE, "Cannot update the upgrade status", ex); + } // Also, clean up the setup wizard if it's completed jenkins.setSetupWizard(null); } diff --git a/war/src/main/js/api/plugins.js b/core/src/main/resources/jenkins/install/platform-plugins.json similarity index 58% rename from war/src/main/js/api/plugins.js rename to core/src/main/resources/jenkins/install/platform-plugins.json index 4e3a7420cf..16b9bc1e63 100644 --- a/war/src/main/js/api/plugins.js +++ b/core/src/main/resources/jenkins/install/platform-plugins.json @@ -1,45 +1,10 @@ -// -// TODO: Get all of this information from the Update Center via a REST API. -// - -// -// TODO: Decide on what the real "recommended" plugin set is. This is just a 1st stab. -// Also remember, the user ultimately has full control as they can easily customize -// away from these. -// -exports.recommendedPlugins = [ - "ant", - "antisamy-markup-formatter", - "build-timeout", - "cloudbees-folder", - "credentials-binding", - "email-ext", - "git", - "gradle", - "ldap", - "mailer", - "matrix-auth", - "pam-auth", - "pipeline-stage-view", - "ssh-slaves", - "subversion", - "timestamper", - "workflow-aggregator", - "github-organization-folder", - "ws-cleanup" -]; - -// -// A Categorized list of the plugins offered for install in the wizard. -// This is a community curated list. -// -exports.availablePlugins = [ +[ { "category":"Organization and Administration", "plugins": [ { "name": "dashboard-view" }, - { "name": "cloudbees-folder" }, - { "name": "antisamy-markup-formatter" } + { "name": "cloudbees-folder", "suggested": true }, + { "name": "antisamy-markup-formatter", "suggested": true } ] }, { @@ -47,22 +12,22 @@ exports.availablePlugins = [ "description":"Add general purpose features to your jobs", "plugins": [ { "name": "build-name-setter" }, - { "name": "build-timeout" }, + { "name": "build-timeout", "suggested": true }, { "name": "config-file-provider" }, - { "name": "credentials-binding" }, + { "name": "credentials-binding", "suggested": true }, { "name": "embeddable-build-status" }, { "name": "rebuild" }, { "name": "ssh-agent" }, { "name": "throttle-concurrents" }, - { "name": "timestamper" }, - { "name": "ws-cleanup" } + { "name": "timestamper", "suggested": true }, + { "name": "ws-cleanup", "suggested": true } ] }, { "category":"Build Tools", "plugins": [ - { "name": "ant" }, - { "name": "gradle" }, + { "name": "ant", "suggested": true }, + { "name": "gradle", "suggested": true }, { "name": "msbuild" }, { "name": "nodejs" } ] @@ -81,9 +46,9 @@ exports.availablePlugins = [ { "category":"Pipelines and Continuous Delivery", "plugins": [ - { "name": "workflow-aggregator" }, - { "name": "github-organization-folder" }, - { "name": "pipeline-stage-view" }, + { "name": "workflow-aggregator", "suggested": true, "added": "2.0" }, + { "name": "github-organization-folder", "suggested": true, "added": "2.0" }, + { "name": "pipeline-stage-view", "suggested": true, "added": "2.0" }, { "name": "build-pipeline-plugin" }, { "name": "conditional-buildstep" }, { "name": "jenkins-multijob-plugin" }, @@ -97,13 +62,13 @@ exports.availablePlugins = [ { "name": "bitbucket" }, { "name": "clearcase" }, { "name": "cvs" }, - { "name": "git" }, + { "name": "git", "suggested": true }, { "name": "git-parameter" }, { "name": "github" }, { "name": "gitlab-plugin" }, { "name": "p4" }, { "name": "repo" }, - { "name": "subversion" }, + { "name": "subversion", "suggested": true }, { "name": "teamconcert" }, { "name": "tfs" } ] @@ -112,16 +77,16 @@ exports.availablePlugins = [ "category":"Distributed Builds", "plugins": [ { "name": "matrix-project" }, - { "name": "ssh-slaves" }, + { "name": "ssh-slaves", "suggested": true }, { "name": "windows-slaves" } ] }, { "category":"User Management and Security", "plugins": [ - { "name": "matrix-auth" }, - { "name": "pam-auth" }, - { "name": "ldap" }, + { "name": "matrix-auth", "suggested": true }, + { "name": "pam-auth", "suggested": true }, + { "name": "ldap", "suggested": true }, { "name": "role-strategy" }, { "name": "active-directory" } ] @@ -129,12 +94,11 @@ exports.availablePlugins = [ { "category":"Notifications and Publishing", "plugins": [ - { "name": "email-ext" }, + { "name": "email-ext", "suggested": true }, { "name": "emailext-template" }, - { "name": "mailer" }, + { "name": "mailer", "suggested": true }, { "name": "publish-over-ssh" }, - // { "name": "slack" }, // JENKINS-33571, https://github.com/jenkinsci/slack-plugin/issues/191 { "name": "ssh" } ] } -]; +] diff --git a/test/src/test/java/jenkins/install/SetupWizardTest.java b/test/src/test/java/jenkins/install/SetupWizardTest.java new file mode 100644 index 0000000000..25eacea870 --- /dev/null +++ b/test/src/test/java/jenkins/install/SetupWizardTest.java @@ -0,0 +1,161 @@ +/* + * The MIT License + * + * Copyright (c) 2016 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.install; + +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import hudson.FilePath; +import hudson.model.UpdateSite; +import hudson.security.ACL; +import hudson.security.AuthorizationStrategy; +import hudson.security.SecurityRealm; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import org.apache.commons.io.FileUtils; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.Credentials; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.impl.client.BasicCredentialsProvider; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import static org.junit.Assert.assertThat; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.Issue; + +/** + * Tests of {@link SetupWizard}. + * @author Oleg Nenashev + */ +public class SetupWizardTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Rule + public TemporaryFolder tmpdir = new TemporaryFolder(); + + BasicCredentialsProvider adminCredentialsProvider; + + @Before + public void initSetupWizard() throws IOException, InterruptedException { + final SetupWizard wizard = new SetupWizard(j.jenkins); + j.jenkins.setSetupWizard(wizard); + + // Retrieve admin credentials + final FilePath adminPassFile = wizard.getInitialAdminPasswordFile(); + ByteArrayOutputStream ostream = new ByteArrayOutputStream(); + adminPassFile.copyTo(ostream); + final String password = ostream.toString(); + + adminCredentialsProvider = new BasicCredentialsProvider() { + @Override + public Credentials getCredentials(AuthScope authscope) { + return new UsernamePasswordCredentials("admin", password); + } + }; + } + + @Test + public void shouldReturnPluginListsByDefault() throws Exception { + JenkinsRule.WebClient wc = j.createWebClient(); + // TODO: This is a hack, wc.login does not work with the form + j.jenkins.setSecurityRealm(SecurityRealm.NO_AUTHENTICATION); + j.jenkins.setAuthorizationStrategy(AuthorizationStrategy.UNSECURED); + // wc.setCredentialsProvider(adminCredentialsProvider); + // wc.login("admin"); + + String response = jsonRequest(wc, "setupWizard/platformPluginList"); + assertThat("Missing plugin is suggestions ", response, containsString("active-directory")); + assertThat("Missing category is suggestions ", response, containsString("Pipelines and Continuous Delivery")); + } + + @Test + @Issue("JENKINS-34833") + public void shouldReturnUpdateSiteJSONIfSpecified() throws Exception { + // Init the update site + CustomUpdateSite us = new CustomUpdateSite(tmpdir); + us.init(); + j.jenkins.getUpdateCenter().getSites().add(us); + + // Prepare the connection + JenkinsRule.WebClient wc = j.createWebClient(); + // TODO: This is a hack, wc.login does not work with the form + j.jenkins.setSecurityRealm(SecurityRealm.NO_AUTHENTICATION); + j.jenkins.setAuthorizationStrategy(AuthorizationStrategy.UNSECURED); + // wc.setCredentialsProvider(adminCredentialsProvider); + // wc.login("admin"); + + String response = jsonRequest(wc, "setupWizard/platformPluginList"); + assertThat("Missing plugin is suggestions ", response, containsString("antisamy-markup-formatter")); + assertThat("Missing category is suggestions ", response, containsString("Organization and Administration")); + assertThat("Missing plugin is suggestions ", response, not(containsString("active-directory"))); + assertThat("Missing category is suggestions ", response, not(containsString("Pipelines and Continuous Delivery"))); + } + + @Test + public void shouldProhibitAccessToPluginListWithoutAuth() throws Exception { + JenkinsRule.WebClient wc = j.createWebClient(); + wc.assertFails("setupWizard/platformPluginList", 403); + wc.assertFails("setupWizard/createAdminUaser", 403); + wc.assertFails("setupWizard/completeInstall", 403); + } + + private String jsonRequest(JenkinsRule.WebClient wc, String path) throws Exception { + // Try to call the actions method to retrieve the data + final Page res; + try { + res = wc.goTo(path, null); + } catch (Exception ex) { + ex.getMessage(); + throw new AssertionError("Cannot get a response from " + path, ex); + } + final String responseJSON = res.getWebResponse().getContentAsString(); + return responseJSON; + } + + private static final class CustomUpdateSite extends UpdateSite { + + private final TemporaryFolder tmpdir; + + public CustomUpdateSite(TemporaryFolder tmpdir) throws MalformedURLException { + super("custom-uc", tmpdir.getRoot().toURI().toURL().toString() + "update-center.json"); + this.tmpdir = tmpdir; + } + + public void init() throws IOException { + File newFile = tmpdir.newFile("platform-plugins.json"); + FileUtils.write(newFile, "[ { " + + "\"category\":\"Organization and Administration\", " + + "\"plugins\": [ { \"name\": \"dashboard-view\"}, { \"name\": \"antisamy-markup-formatter\" } ]" + + "} ]"); + } + + } +} diff --git a/war/src/main/js/api/pluginManager.js b/war/src/main/js/api/pluginManager.js index c2b5febcd9..346a761cfd 100644 --- a/war/src/main/js/api/pluginManager.js +++ b/war/src/main/js/api/pluginManager.js @@ -7,18 +7,49 @@ var jenkins = require('../util/jenkins'); // TODO: Get plugin info (plugins + recommended plugin list) from update center. // For now, we statically store them in the wizard. -var plugins = require('./plugins.js'); -plugins.names =[]; -for (var i = 0; i < plugins.availablePlugins.length; i++) { - var pluginCategory = plugins.availablePlugins[i]; - var categoryPlugins = pluginCategory.plugins; - for (var ii = 0; ii < categoryPlugins.length; ii++) { - var pluginName = categoryPlugins[ii].name; - if (plugins.names.indexOf(pluginName) === -1) { - plugins.names.push(pluginName); - } - } -} +//Get plugin info (plugins + recommended plugin list) from update centers. +var plugins; + +exports.initialPluginList = function(handler) { + jenkins.get('/setupWizard/platformPluginList', function(response) { + if(response.status !== 'ok') { + handler.call({ isError: true, data: response.data }); + return; + } + + handler.call({ isError: false }, response.data); + }, { + timeout: pluginManagerErrorTimeoutMillis, + error: function(xhr, textStatus, errorThrown) { + handler.call({ isError: true, errorMessage: errorThrown }); + } + }); +}; + +// Call this to initialize the plugin list +exports.init = function(handler) { + exports.initialPluginList(function(initialPluginCategories) { + plugins = {}; + plugins.names = []; + plugins.recommendedPlugins = []; + plugins.availablePlugins = initialPluginCategories; + for (var i = 0; i < initialPluginCategories.length; i++) { + var pluginCategory = initialPluginCategories[i]; + var categoryPlugins = pluginCategory.plugins; + for (var ii = 0; ii < categoryPlugins.length; ii++) { + var plugin = categoryPlugins[ii]; + var pluginName = plugin.name; + if (plugins.names.indexOf(pluginName) === -1) { + plugins.names.push(pluginName); + if (plugin.suggested) { + plugins.recommendedPlugins.push(pluginName); + } + } + } + } + handler(); + }); +}; // default 10 seconds for AJAX responses to return before triggering an error condition var pluginManagerErrorTimeoutMillis = 10 * 1000; @@ -200,3 +231,4 @@ exports.restartJenkins = function(handler) { } }); }; + diff --git a/war/src/main/js/pluginSetupWizardGui.js b/war/src/main/js/pluginSetupWizardGui.js index 8d3468dcf9..3755652731 100644 --- a/war/src/main/js/pluginSetupWizardGui.js +++ b/war/src/main/js/pluginSetupWizardGui.js @@ -136,11 +136,19 @@ var createPluginSetupWizard = function(appendTarget) { success.apply(this, arguments); }; }; + + var pluginList; + var allPluginNames; + var selectedPluginNames; + + // Initialize the plugin manager after connectivity checks + pluginManager.init(handleGenericError(function() { + pluginList = pluginManager.plugins(); + allPluginNames = pluginManager.pluginNames(); + selectedPluginNames = pluginManager.recommendedPluginNames(); + })); // state variables for plugin data, selected plugins, etc.: - var pluginList = pluginManager.plugins(); - var allPluginNames = pluginManager.pluginNames(); - var selectedPluginNames = pluginManager.recommendedPluginNames(); var visibleDependencies = {}; var categories = []; var availablePlugins = {}; @@ -900,7 +908,7 @@ var createPluginSetupWizard = function(appendTarget) { } return; } - + // check for updates when first loaded... pluginManager.installStatus(handleGenericError(function(data) { var jobs = data.jobs; diff --git a/war/src/test/js/pluginSetupWizard-spec.js b/war/src/test/js/pluginSetupWizard-spec.js index 2674db2593..b8e806dab1 100644 --- a/war/src/test/js/pluginSetupWizard-spec.js +++ b/war/src/test/js/pluginSetupWizard-spec.js @@ -9,10 +9,6 @@ var getJQuery = function() { return $; }; -var pluginList = jsTest.requireSrcModule('api/plugins'); - -pluginList.recommendedPlugins = ['subversion']; - // Iterates through all responses until the end and returns the last response repeatedly var LastResponse = function(responses) { var counter = 0; @@ -98,8 +94,43 @@ var ajaxMocks = function(responseMappings) { } }, '/jenkins/pluginManager/installPlugins': { + status: 'ok', + data: 'RANDOM_UUID_1234' + }, + '/jenkins/setupWizard/platformPluginList': { status: 'ok', - data: 'RANDOM_UUID_1234' + data: [ + { + "category":"Stuff", + "plugins": [ + { "name": "mailer" }, + { "name": "junit" }, + { "name": "workflow-aggregator", "added": "2.0" }, + ] + }, + { + "category":"Source Code Management", + "plugins": [ + { "name": "git" }, + { "name": "subversion", "suggested": true } + ] + }, + { + "category":"User Management and Security", + "plugins": [ + { "name": "matrix-auth" }, + { "name": "pam-auth" }, + { "name": "ldap" } + ] + }, + { + "category":"Notifications and Publishing", + "plugins": [ + { "name": "email-ext" }, + { "name": "ssh" } + ] + } + ] } };