From 9b1ab632521243e2f0bb1164731fe8f07c9e4ea3 Mon Sep 17 00:00:00 2001 From: Zhao Xiaojie Date: Mon, 23 Dec 2019 09:44:09 +0800 Subject: [PATCH 1/2] Add support to backup and restore automatically --- README.md | 1 + docs/features/auto-backup.md | 39 ++++++ plugin/pom.xml | 17 +++ .../plugins/casc/ConfigurationContext.java | 10 +- .../jenkins/plugins/casc/auto/CasCBackup.java | 86 ++++++++++++ .../plugins/casc/auto/PatchConfig.java | 132 ++++++++++++++++++ 6 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 docs/features/auto-backup.md create mode 100644 plugin/src/main/java/io/jenkins/plugins/casc/auto/CasCBackup.java create mode 100644 plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java diff --git a/README.md b/README.md index 53d2e2614f..46eb34240a 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ You can find more documentation about JCasC here: - [Exporting configurations](./docs/features/configExport.md) - [Validating configurations](./docs/features/jsonSchema.md) - [Triggering Configuration Reload](./docs/features/configurationReload.md) +- [Auto backup](./docs/features/auto-backup.md) The configuration file format depends on the version of jenkins-core and installed plugins. Documentation is generated from a live instance, as well as a JSON schema you can use to validate configuration file diff --git a/docs/features/auto-backup.md b/docs/features/auto-backup.md new file mode 100644 index 0000000000..7ee8345df2 --- /dev/null +++ b/docs/features/auto-backup.md @@ -0,0 +1,39 @@ +This feature provides a solution to allow users to upgrade their Jenkins Configuration-as-Code config file. + +## Use case + +For the users who wants to build a Jenkins distribution, configuration-as-code could be a good + option to provide a initial configuration which lets Jenkins has the feature of out-of-the-box. + +But there's one problem here, after the Jenkins distribution runs for a while. User must wants to + change the configuration base on his use case. So there're two YAML config files needed. + One is the initial one which we call it `system.yaml` here, another one belongs to user's data + which is `user.yaml`. + +The behaviour of generating the user's configuration automatically is still + [working in progress](https://github.com/jenkinsci/configuration-as-code-plugin/pull/1218). + +## How does it work? + +First, check if there's a new version of the initial config file which is + `${JENKINS_HOME}/war/jenkins.yaml`. If there isn't, skip all the following steps. + +Second, check if there's a user data file. If it exists, than calculate the diff between + the previous config file and the user file. Or just replace the old file simply and skip + all the following steps. + +Third, apply the patch into the new config file as the result of user file. + +Finally, replace the old config file with the new one and delete the new config file. + +We deal with three config files: + +|Config file path|Description| +|---|---| +|`${JENKINS_HOME}/war/jenkins.yaml`|Initial config file, put the new config files in here| +|`${JENKINS_HOME}/war/WEB-INF/jenkins.yaml`|Should be the last version of config file| +|`${JENKINS_HOME}/war/WEB-INF/jenkins.yaml.d/user.yaml`|All current config file, auto generate it when a user change the config| + +## TODO + +- let the name of config file can be configurable diff --git a/plugin/pom.xml b/plugin/pom.xml index 6dacc0d4af..a940dc6dee 100644 --- a/plugin/pom.xml +++ b/plugin/pom.xml @@ -69,6 +69,23 @@ 0.10.2 + + + com.flipkart.zjsonpatch + zjsonpatch + 0.4.9 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + 2.10.1 + + + com.fasterxml.jackson.core + jackson-core + 2.10.1 + + com.github.stefanbirkner system-rules diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationContext.java b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationContext.java index b10eb4d27f..529afda113 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationContext.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationContext.java @@ -17,6 +17,7 @@ public class ConfigurationContext implements ConfiguratorRegistry { private Deprecation deprecation = Deprecation.reject; private Restriction restriction = Restriction.reject; private Unknown unknown = Unknown.reject; + private boolean enableBackup = false; /** * the model-introspection model to be applied by configuration-as-code. @@ -50,6 +51,10 @@ public void warning(@NonNull CNode node, @NonNull String message) { public Unknown getUnknown() { return unknown; } + public boolean isEnableBackup() { + return enableBackup; + } + public void setDeprecated(Deprecation deprecation) { this.deprecation = deprecation; } @@ -62,8 +67,11 @@ public void setUnknown(Unknown unknown) { this.unknown = unknown; } + public void setEnableBackup(boolean enableBackup) { + this.enableBackup = enableBackup; + } - // --- delegate methods for ConfigurationContext +// --- delegate methods for ConfigurationContext @Override diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/auto/CasCBackup.java b/plugin/src/main/java/io/jenkins/plugins/casc/auto/CasCBackup.java new file mode 100644 index 0000000000..855fabf8ef --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/auto/CasCBackup.java @@ -0,0 +1,86 @@ +package io.jenkins.plugins.casc.auto; + +import hudson.Extension; +import hudson.XmlFile; +import hudson.model.Saveable; +import hudson.model.listeners.SaveableListener; +import io.jenkins.plugins.casc.ConfigurationAsCode; +import io.jenkins.plugins.casc.ConfigurationContext; +import io.jenkins.plugins.casc.impl.DefaultConfiguratorRegistry; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.servlet.ServletContext; +import jenkins.model.GlobalConfiguration; +import jenkins.model.Jenkins; + +@Extension(ordinal = 100) +public class CasCBackup extends SaveableListener { + private static final Logger LOGGER = Logger.getLogger(CasCBackup.class.getName()); + + private static final String DEFAULT_JENKINS_YAML_PATH = "jenkins.yaml"; + private static final String cascDirectory = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH + ".d/"; + + @Inject + private DefaultConfiguratorRegistry registry; + + @Override + public void onChange(Saveable o, XmlFile file) { + ConfigurationContext context = new ConfigurationContext(registry); + if (!context.isEnableBackup()) { + return; + } + + // only take care of the configuration which controlled by casc + if (!(o instanceof GlobalConfiguration)) { + return; + } + + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + try { + ConfigurationAsCode.get().export(buf); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "error happen when exporting the whole config into a YAML", e); + return; + } + + final ServletContext servletContext = Jenkins.getInstance().servletContext; + try { + URL bundled = servletContext.getResource(cascDirectory); + if (bundled != null) { + File cascDir = new File(bundled.getFile()); + + boolean hasDir = false; + if(!cascDir.exists()) { + hasDir = cascDir.mkdirs(); + } else if (cascDir.isFile()) { + LOGGER.severe(String.format("%s is a regular file", cascDir)); + } else { + hasDir = true; + } + + if(hasDir) { + File backupFile = new File(cascDir, "user.yaml"); + try (OutputStream writer = new FileOutputStream(backupFile)) { + writer.write(buf.toByteArray()); + + LOGGER.fine(String.format("backup file was saved, %s", backupFile.getAbsolutePath())); + } catch (IOException e) { + LOGGER.log(Level.WARNING, String.format("error happen when saving %s", backupFile.getAbsolutePath()), e); + } + } else { + LOGGER.severe(String.format("cannot create casc backup directory %s", cascDir)); + } + } + } catch (MalformedURLException e) { + LOGGER.log(Level.WARNING, String.format("error happen when finding %s", cascDirectory), e); + } + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java b/plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java new file mode 100644 index 0000000000..85b45ba5a5 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java @@ -0,0 +1,132 @@ +package io.jenkins.plugins.casc.auto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.flipkart.zjsonpatch.JsonDiff; +import com.flipkart.zjsonpatch.JsonPatch; +import hudson.init.InitMilestone; +import hudson.init.Initializer; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.servlet.ServletContext; +import jenkins.model.Jenkins; +import org.apache.commons.io.IOUtils; + +/** + * Apply the patch between two versions of the initial config files + */ +public class PatchConfig { + private static final Logger LOGGER = Logger.getLogger(CasCBackup.class.getName()); + + final static String DEFAULT_JENKINS_YAML_PATH = "jenkins.yaml"; + final static String cascFile = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH; + final static String cascDirectory = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH + ".d/"; + final static String cascUserConfigFile = "user.yaml"; + + @Initializer(after= InitMilestone.STARTED, fatal=false) + public static void patchConfig() { + LOGGER.fine("start to calculate the patch of casc"); + + URL newSystemConfig = findConfig("/" + DEFAULT_JENKINS_YAML_PATH); + URL systemConfig = findConfig(cascFile); + URL userConfig = findConfig(cascDirectory + cascUserConfigFile); + URL userConfigDir = findConfig(cascDirectory); + + if (newSystemConfig == null || userConfigDir == null) { + LOGGER.warning("no need to upgrade the configuration of Jenkins"); + return; + } + + JsonNode patch = null; + if (systemConfig != null && userConfig != null) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + JsonNode source = objectMapper.readTree(yamlToJson(systemConfig.openStream())); + JsonNode target = objectMapper.readTree(yamlToJson(userConfig.openStream())); + + patch = JsonDiff.asJson(source, target); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "error happen when calculate the patch", e); + return; + } + + try { + // give systemConfig a real path + PatchConfig.copyAndDelSrc(newSystemConfig, systemConfig); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "error happen when copy the new system config", e); + return; + } + } + + if (patch != null) { + File userYamlFile = new File(userConfigDir.getFile(), "user.yaml"); + File userJSONFile = new File(userConfigDir.getFile(), "user.json"); + + try (InputStream newSystemInput = systemConfig.openStream(); + OutputStream userFileOutput = new FileOutputStream(userYamlFile); + OutputStream patchFileOutput = new FileOutputStream(userJSONFile)){ + ObjectMapper jsonReader = new ObjectMapper(); + JsonNode target = JsonPatch.apply(patch, jsonReader.readTree(yamlToJson(newSystemInput))); + + String userYaml = jsonToYaml(new ByteArrayInputStream(target.toString().getBytes())); + + userFileOutput.write(userYaml.getBytes()); + patchFileOutput.write(patch.toString().getBytes()); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "error happen when copy the new system config", e); + } + } else { + LOGGER.warning("there's no patch of casc"); + } + } + + private static URL findConfig(String path) { + final ServletContext servletContext = Jenkins.getInstance().servletContext; + try { + return servletContext.getResource(path); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, String.format("error happen when finding path %s", path), e); + } + return null; + } + + private static void copyAndDelSrc(URL src, URL target) throws IOException { + try { + PatchConfig.copy(src, target); + } finally { + boolean result = new File(src.getFile()).delete(); + LOGGER.fine("src file delete " + result); + } + } + + private static void copy(URL src, URL target) throws IOException { + IOUtils.copy(src.openStream(), new FileOutputStream(target.getFile())); + } + + private static String jsonToYaml(InputStream input) throws IOException { + ObjectMapper yamlReader = new ObjectMapper(new YAMLFactory()); + ObjectMapper jsonReader = new ObjectMapper(); + + Object obj = jsonReader.readValue(input, Object.class); + + return yamlReader.writeValueAsString(obj); + } + + private static String yamlToJson(InputStream input) throws IOException { + ObjectMapper yamlReader = new ObjectMapper(new YAMLFactory()); + ObjectMapper jsonReader = new ObjectMapper(); + + Object obj = yamlReader.readValue(input, Object.class); + + return jsonReader.writeValueAsString(obj); + } +} From 02e18f608709069a80ca5e6c605155e2c71efb49 Mon Sep 17 00:00:00 2001 From: Zhao Xiaojie Date: Mon, 23 Dec 2019 15:49:42 +0800 Subject: [PATCH 2/2] Fix the potential output resource leak --- .../main/java/io/jenkins/plugins/casc/auto/PatchConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java b/plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java index 85b45ba5a5..fe72071bec 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/auto/PatchConfig.java @@ -109,7 +109,10 @@ private static void copyAndDelSrc(URL src, URL target) throws IOException { } private static void copy(URL src, URL target) throws IOException { - IOUtils.copy(src.openStream(), new FileOutputStream(target.getFile())); + try (InputStream input = src.openStream(); + OutputStream output = new FileOutputStream(target.getFile())) { + IOUtils.copy(input, output); + } } private static String jsonToYaml(InputStream input) throws IOException {