Skip to content

Commit 9b1ab63

Browse files
committed
Add support to backup and restore automatically
1 parent 58ed8c3 commit 9b1ab63

File tree

6 files changed

+284
-1
lines changed

6 files changed

+284
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ You can find more documentation about JCasC here:
136136
- [Exporting configurations](./docs/features/configExport.md)
137137
- [Validating configurations](./docs/features/jsonSchema.md)
138138
- [Triggering Configuration Reload](./docs/features/configurationReload.md)
139+
- [Auto backup](./docs/features/auto-backup.md)
139140

140141
The configuration file format depends on the version of jenkins-core and installed plugins.
141142
Documentation is generated from a live instance, as well as a JSON schema you can use to validate configuration file

docs/features/auto-backup.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
This feature provides a solution to allow users to upgrade their Jenkins Configuration-as-Code config file.
2+
3+
## Use case
4+
5+
For the users who wants to build a Jenkins distribution, configuration-as-code could be a good
6+
option to provide a initial configuration which lets Jenkins has the feature of out-of-the-box.
7+
8+
But there's one problem here, after the Jenkins distribution runs for a while. User must wants to
9+
change the configuration base on his use case. So there're two YAML config files needed.
10+
One is the initial one which we call it `system.yaml` here, another one belongs to user's data
11+
which is `user.yaml`.
12+
13+
The behaviour of generating the user's configuration automatically is still
14+
[working in progress](https://github.com/jenkinsci/configuration-as-code-plugin/pull/1218).
15+
16+
## How does it work?
17+
18+
First, check if there's a new version of the initial config file which is
19+
`${JENKINS_HOME}/war/jenkins.yaml`. If there isn't, skip all the following steps.
20+
21+
Second, check if there's a user data file. If it exists, than calculate the diff between
22+
the previous config file and the user file. Or just replace the old file simply and skip
23+
all the following steps.
24+
25+
Third, apply the patch into the new config file as the result of user file.
26+
27+
Finally, replace the old config file with the new one and delete the new config file.
28+
29+
We deal with three config files:
30+
31+
|Config file path|Description|
32+
|---|---|
33+
|`${JENKINS_HOME}/war/jenkins.yaml`|Initial config file, put the new config files in here|
34+
|`${JENKINS_HOME}/war/WEB-INF/jenkins.yaml`|Should be the last version of config file|
35+
|`${JENKINS_HOME}/war/WEB-INF/jenkins.yaml.d/user.yaml`|All current config file, auto generate it when a user change the config|
36+
37+
## TODO
38+
39+
- let the name of config file can be configurable

plugin/pom.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@
6969
<version>0.10.2</version>
7070
</dependency>
7171

72+
73+
<dependency>
74+
<groupId>com.flipkart.zjsonpatch</groupId>
75+
<artifactId>zjsonpatch</artifactId>
76+
<version>0.4.9</version>
77+
</dependency>
78+
<dependency>
79+
<groupId>com.fasterxml.jackson.dataformat</groupId>
80+
<artifactId>jackson-dataformat-yaml</artifactId>
81+
<version>2.10.1</version>
82+
</dependency>
83+
<dependency>
84+
<groupId>com.fasterxml.jackson.core</groupId>
85+
<artifactId>jackson-core</artifactId>
86+
<version>2.10.1</version>
87+
</dependency>
88+
7289
<dependency>
7390
<groupId>com.github.stefanbirkner</groupId>
7491
<artifactId>system-rules</artifactId>

plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationContext.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class ConfigurationContext implements ConfiguratorRegistry {
1717
private Deprecation deprecation = Deprecation.reject;
1818
private Restriction restriction = Restriction.reject;
1919
private Unknown unknown = Unknown.reject;
20+
private boolean enableBackup = false;
2021

2122
/**
2223
* the model-introspection model to be applied by configuration-as-code.
@@ -50,6 +51,10 @@ public void warning(@NonNull CNode node, @NonNull String message) {
5051

5152
public Unknown getUnknown() { return unknown; }
5253

54+
public boolean isEnableBackup() {
55+
return enableBackup;
56+
}
57+
5358
public void setDeprecated(Deprecation deprecation) {
5459
this.deprecation = deprecation;
5560
}
@@ -62,8 +67,11 @@ public void setUnknown(Unknown unknown) {
6267
this.unknown = unknown;
6368
}
6469

70+
public void setEnableBackup(boolean enableBackup) {
71+
this.enableBackup = enableBackup;
72+
}
6573

66-
// --- delegate methods for ConfigurationContext
74+
// --- delegate methods for ConfigurationContext
6775

6876

6977
@Override
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package io.jenkins.plugins.casc.auto;
2+
3+
import hudson.Extension;
4+
import hudson.XmlFile;
5+
import hudson.model.Saveable;
6+
import hudson.model.listeners.SaveableListener;
7+
import io.jenkins.plugins.casc.ConfigurationAsCode;
8+
import io.jenkins.plugins.casc.ConfigurationContext;
9+
import io.jenkins.plugins.casc.impl.DefaultConfiguratorRegistry;
10+
import java.io.ByteArrayOutputStream;
11+
import java.io.File;
12+
import java.io.FileOutputStream;
13+
import java.io.IOException;
14+
import java.io.OutputStream;
15+
import java.net.MalformedURLException;
16+
import java.net.URL;
17+
import java.util.logging.Level;
18+
import java.util.logging.Logger;
19+
import javax.inject.Inject;
20+
import javax.servlet.ServletContext;
21+
import jenkins.model.GlobalConfiguration;
22+
import jenkins.model.Jenkins;
23+
24+
@Extension(ordinal = 100)
25+
public class CasCBackup extends SaveableListener {
26+
private static final Logger LOGGER = Logger.getLogger(CasCBackup.class.getName());
27+
28+
private static final String DEFAULT_JENKINS_YAML_PATH = "jenkins.yaml";
29+
private static final String cascDirectory = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH + ".d/";
30+
31+
@Inject
32+
private DefaultConfiguratorRegistry registry;
33+
34+
@Override
35+
public void onChange(Saveable o, XmlFile file) {
36+
ConfigurationContext context = new ConfigurationContext(registry);
37+
if (!context.isEnableBackup()) {
38+
return;
39+
}
40+
41+
// only take care of the configuration which controlled by casc
42+
if (!(o instanceof GlobalConfiguration)) {
43+
return;
44+
}
45+
46+
ByteArrayOutputStream buf = new ByteArrayOutputStream();
47+
try {
48+
ConfigurationAsCode.get().export(buf);
49+
} catch (Exception e) {
50+
LOGGER.log(Level.WARNING, "error happen when exporting the whole config into a YAML", e);
51+
return;
52+
}
53+
54+
final ServletContext servletContext = Jenkins.getInstance().servletContext;
55+
try {
56+
URL bundled = servletContext.getResource(cascDirectory);
57+
if (bundled != null) {
58+
File cascDir = new File(bundled.getFile());
59+
60+
boolean hasDir = false;
61+
if(!cascDir.exists()) {
62+
hasDir = cascDir.mkdirs();
63+
} else if (cascDir.isFile()) {
64+
LOGGER.severe(String.format("%s is a regular file", cascDir));
65+
} else {
66+
hasDir = true;
67+
}
68+
69+
if(hasDir) {
70+
File backupFile = new File(cascDir, "user.yaml");
71+
try (OutputStream writer = new FileOutputStream(backupFile)) {
72+
writer.write(buf.toByteArray());
73+
74+
LOGGER.fine(String.format("backup file was saved, %s", backupFile.getAbsolutePath()));
75+
} catch (IOException e) {
76+
LOGGER.log(Level.WARNING, String.format("error happen when saving %s", backupFile.getAbsolutePath()), e);
77+
}
78+
} else {
79+
LOGGER.severe(String.format("cannot create casc backup directory %s", cascDir));
80+
}
81+
}
82+
} catch (MalformedURLException e) {
83+
LOGGER.log(Level.WARNING, String.format("error happen when finding %s", cascDirectory), e);
84+
}
85+
}
86+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package io.jenkins.plugins.casc.auto;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
6+
import com.flipkart.zjsonpatch.JsonDiff;
7+
import com.flipkart.zjsonpatch.JsonPatch;
8+
import hudson.init.InitMilestone;
9+
import hudson.init.Initializer;
10+
import java.io.ByteArrayInputStream;
11+
import java.io.File;
12+
import java.io.FileOutputStream;
13+
import java.io.IOException;
14+
import java.io.InputStream;
15+
import java.io.OutputStream;
16+
import java.net.URL;
17+
import java.util.logging.Level;
18+
import java.util.logging.Logger;
19+
import javax.servlet.ServletContext;
20+
import jenkins.model.Jenkins;
21+
import org.apache.commons.io.IOUtils;
22+
23+
/**
24+
* Apply the patch between two versions of the initial config files
25+
*/
26+
public class PatchConfig {
27+
private static final Logger LOGGER = Logger.getLogger(CasCBackup.class.getName());
28+
29+
final static String DEFAULT_JENKINS_YAML_PATH = "jenkins.yaml";
30+
final static String cascFile = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH;
31+
final static String cascDirectory = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH + ".d/";
32+
final static String cascUserConfigFile = "user.yaml";
33+
34+
@Initializer(after= InitMilestone.STARTED, fatal=false)
35+
public static void patchConfig() {
36+
LOGGER.fine("start to calculate the patch of casc");
37+
38+
URL newSystemConfig = findConfig("/" + DEFAULT_JENKINS_YAML_PATH);
39+
URL systemConfig = findConfig(cascFile);
40+
URL userConfig = findConfig(cascDirectory + cascUserConfigFile);
41+
URL userConfigDir = findConfig(cascDirectory);
42+
43+
if (newSystemConfig == null || userConfigDir == null) {
44+
LOGGER.warning("no need to upgrade the configuration of Jenkins");
45+
return;
46+
}
47+
48+
JsonNode patch = null;
49+
if (systemConfig != null && userConfig != null) {
50+
ObjectMapper objectMapper = new ObjectMapper();
51+
try {
52+
JsonNode source = objectMapper.readTree(yamlToJson(systemConfig.openStream()));
53+
JsonNode target = objectMapper.readTree(yamlToJson(userConfig.openStream()));
54+
55+
patch = JsonDiff.asJson(source, target);
56+
} catch (IOException e) {
57+
LOGGER.log(Level.SEVERE, "error happen when calculate the patch", e);
58+
return;
59+
}
60+
61+
try {
62+
// give systemConfig a real path
63+
PatchConfig.copyAndDelSrc(newSystemConfig, systemConfig);
64+
} catch (IOException e) {
65+
LOGGER.log(Level.SEVERE, "error happen when copy the new system config", e);
66+
return;
67+
}
68+
}
69+
70+
if (patch != null) {
71+
File userYamlFile = new File(userConfigDir.getFile(), "user.yaml");
72+
File userJSONFile = new File(userConfigDir.getFile(), "user.json");
73+
74+
try (InputStream newSystemInput = systemConfig.openStream();
75+
OutputStream userFileOutput = new FileOutputStream(userYamlFile);
76+
OutputStream patchFileOutput = new FileOutputStream(userJSONFile)){
77+
ObjectMapper jsonReader = new ObjectMapper();
78+
JsonNode target = JsonPatch.apply(patch, jsonReader.readTree(yamlToJson(newSystemInput)));
79+
80+
String userYaml = jsonToYaml(new ByteArrayInputStream(target.toString().getBytes()));
81+
82+
userFileOutput.write(userYaml.getBytes());
83+
patchFileOutput.write(patch.toString().getBytes());
84+
} catch (Exception e) {
85+
LOGGER.log(Level.SEVERE, "error happen when copy the new system config", e);
86+
}
87+
} else {
88+
LOGGER.warning("there's no patch of casc");
89+
}
90+
}
91+
92+
private static URL findConfig(String path) {
93+
final ServletContext servletContext = Jenkins.getInstance().servletContext;
94+
try {
95+
return servletContext.getResource(path);
96+
} catch (IOException e) {
97+
LOGGER.log(Level.SEVERE, String.format("error happen when finding path %s", path), e);
98+
}
99+
return null;
100+
}
101+
102+
private static void copyAndDelSrc(URL src, URL target) throws IOException {
103+
try {
104+
PatchConfig.copy(src, target);
105+
} finally {
106+
boolean result = new File(src.getFile()).delete();
107+
LOGGER.fine("src file delete " + result);
108+
}
109+
}
110+
111+
private static void copy(URL src, URL target) throws IOException {
112+
IOUtils.copy(src.openStream(), new FileOutputStream(target.getFile()));
113+
}
114+
115+
private static String jsonToYaml(InputStream input) throws IOException {
116+
ObjectMapper yamlReader = new ObjectMapper(new YAMLFactory());
117+
ObjectMapper jsonReader = new ObjectMapper();
118+
119+
Object obj = jsonReader.readValue(input, Object.class);
120+
121+
return yamlReader.writeValueAsString(obj);
122+
}
123+
124+
private static String yamlToJson(InputStream input) throws IOException {
125+
ObjectMapper yamlReader = new ObjectMapper(new YAMLFactory());
126+
ObjectMapper jsonReader = new ObjectMapper();
127+
128+
Object obj = yamlReader.readValue(input, Object.class);
129+
130+
return jsonReader.writeValueAsString(obj);
131+
}
132+
}

0 commit comments

Comments
 (0)