849 lines
32 KiB
Java
849 lines
32 KiB
Java
package io.jenkins.plugins.casc;
|
|
|
|
import com.google.common.annotations.VisibleForTesting;
|
|
import edu.umd.cs.findbugs.annotations.CheckForNull;
|
|
import edu.umd.cs.findbugs.annotations.NonNull;
|
|
import hudson.Extension;
|
|
import hudson.Functions;
|
|
import hudson.PluginManager;
|
|
import hudson.Util;
|
|
import hudson.init.InitMilestone;
|
|
import hudson.init.Initializer;
|
|
import hudson.model.ManagementLink;
|
|
import hudson.remoting.Which;
|
|
import hudson.security.ACL;
|
|
import hudson.security.ACLContext;
|
|
import hudson.security.Permission;
|
|
import hudson.util.FormValidation;
|
|
import io.jenkins.plugins.casc.impl.DefaultConfiguratorRegistry;
|
|
import io.jenkins.plugins.casc.model.CNode;
|
|
import io.jenkins.plugins.casc.model.Mapping;
|
|
import io.jenkins.plugins.casc.model.Scalar;
|
|
import io.jenkins.plugins.casc.model.Scalar.Format;
|
|
import io.jenkins.plugins.casc.model.Sequence;
|
|
import io.jenkins.plugins.casc.model.Source;
|
|
import io.jenkins.plugins.casc.yaml.YamlSource;
|
|
import io.jenkins.plugins.casc.yaml.YamlUtils;
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.File;
|
|
import java.io.IOException;
|
|
import java.io.OutputStream;
|
|
import java.io.OutputStreamWriter;
|
|
import java.io.Writer;
|
|
import java.net.URI;
|
|
import java.net.URISyntaxException;
|
|
import java.net.URL;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.FileSystems;
|
|
import java.nio.file.FileVisitOption;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.PathMatcher;
|
|
import java.nio.file.Paths;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Collection;
|
|
import java.util.Collections;
|
|
import java.util.Comparator;
|
|
import java.util.Date;
|
|
import java.util.HashMap;
|
|
import java.util.Iterator;
|
|
import java.util.LinkedHashSet;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.Set;
|
|
import java.util.TreeSet;
|
|
import java.util.logging.Level;
|
|
import java.util.logging.Logger;
|
|
import java.util.stream.IntStream;
|
|
import java.util.stream.Stream;
|
|
import javax.inject.Inject;
|
|
import javax.servlet.ServletContext;
|
|
import javax.servlet.http.HttpServletResponse;
|
|
import jenkins.model.GlobalConfiguration;
|
|
import jenkins.model.Jenkins;
|
|
import net.sf.json.JSONArray;
|
|
import net.sf.json.JSONObject;
|
|
import org.apache.commons.io.IOUtils;
|
|
import org.apache.commons.lang.StringUtils;
|
|
import org.kohsuke.accmod.Restricted;
|
|
import org.kohsuke.accmod.restrictions.NoExternalUse;
|
|
import org.kohsuke.stapler.QueryParameter;
|
|
import org.kohsuke.stapler.StaplerRequest;
|
|
import org.kohsuke.stapler.StaplerResponse;
|
|
import org.kohsuke.stapler.interceptor.RequirePOST;
|
|
import org.kohsuke.stapler.lang.Klass;
|
|
import org.kohsuke.stapler.verb.POST;
|
|
import org.yaml.snakeyaml.DumperOptions;
|
|
import org.yaml.snakeyaml.emitter.Emitter;
|
|
import org.yaml.snakeyaml.error.YAMLException;
|
|
import org.yaml.snakeyaml.nodes.MappingNode;
|
|
import org.yaml.snakeyaml.nodes.Node;
|
|
import org.yaml.snakeyaml.nodes.NodeTuple;
|
|
import org.yaml.snakeyaml.nodes.ScalarNode;
|
|
import org.yaml.snakeyaml.nodes.SequenceNode;
|
|
import org.yaml.snakeyaml.nodes.Tag;
|
|
import org.yaml.snakeyaml.resolver.Resolver;
|
|
import org.yaml.snakeyaml.serializer.Serializer;
|
|
|
|
import static io.jenkins.plugins.casc.SchemaGeneration.writeJSONSchema;
|
|
import static java.lang.String.format;
|
|
import static java.util.stream.Collectors.toList;
|
|
import static org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK;
|
|
import static org.yaml.snakeyaml.DumperOptions.ScalarStyle.DOUBLE_QUOTED;
|
|
import static org.yaml.snakeyaml.DumperOptions.ScalarStyle.LITERAL;
|
|
import static org.yaml.snakeyaml.DumperOptions.ScalarStyle.PLAIN;
|
|
|
|
/**
|
|
* {@linkplain #configure() Main entry point of the logic}.
|
|
*
|
|
* @author <a href="mailto:nicolas.deloof@gmail.com">Nicolas De Loof</a>
|
|
*/
|
|
@Extension
|
|
public class ConfigurationAsCode extends ManagementLink {
|
|
|
|
public static final String CASC_JENKINS_CONFIG_PROPERTY = "casc.jenkins.config";
|
|
public static final String CASC_JENKINS_CONFIG_ENV = "CASC_JENKINS_CONFIG";
|
|
public static final String DEFAULT_JENKINS_YAML_PATH = "jenkins.yaml";
|
|
public static final String YAML_FILES_PATTERN = "glob:**.{yml,yaml,YAML,YML}";
|
|
|
|
private static final Logger LOGGER = Logger.getLogger(ConfigurationAsCode.class.getName());
|
|
|
|
@Inject
|
|
private DefaultConfiguratorRegistry registry;
|
|
|
|
private long lastTimeLoaded;
|
|
|
|
private List<String> sources = Collections.emptyList();
|
|
|
|
@CheckForNull
|
|
@Override
|
|
public String getIconFileName() {
|
|
return "/plugin/configuration-as-code/img/logo-head.svg";
|
|
}
|
|
|
|
@CheckForNull
|
|
@Override
|
|
public String getDisplayName() {
|
|
return "Configuration as Code";
|
|
}
|
|
|
|
@CheckForNull
|
|
@Override
|
|
public String getUrlName() {
|
|
return "configuration-as-code";
|
|
}
|
|
|
|
@Override
|
|
public String getDescription() {
|
|
return "Reload your configuration or update configuration source";
|
|
}
|
|
|
|
/**
|
|
* Name of the category for this management link.
|
|
* TODO: Use getCategory when core requirement is greater or equal to 2.226
|
|
*/
|
|
public @NonNull String getCategoryName() {
|
|
return "CONFIGURATION";
|
|
}
|
|
|
|
@NonNull
|
|
@Override
|
|
public Permission getRequiredPermission() {
|
|
return Jenkins.SYSTEM_READ;
|
|
}
|
|
|
|
public Date getLastTimeLoaded() {
|
|
return new Date(lastTimeLoaded);
|
|
}
|
|
|
|
public List<String> getSources() {
|
|
return sources;
|
|
}
|
|
|
|
@RequirePOST
|
|
@Restricted(NoExternalUse.class)
|
|
public void doReload(StaplerRequest request, StaplerResponse response) throws Exception {
|
|
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
|
|
response.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
configure();
|
|
response.sendRedirect("");
|
|
}
|
|
|
|
@RequirePOST
|
|
@Restricted(NoExternalUse.class)
|
|
public void doReplace(StaplerRequest request, StaplerResponse response) throws Exception {
|
|
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
|
|
response.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
String newSource = request.getParameter("_.newSource");
|
|
String normalizedSource = Util.fixEmptyAndTrim(newSource);
|
|
File file = new File(Util.fixNull(normalizedSource));
|
|
if (file.exists() || ConfigurationAsCode.isSupportedURI(normalizedSource)) {
|
|
List<String> candidatePaths = Collections.singletonList(normalizedSource);
|
|
List<YamlSource> candidates = getConfigFromSources(candidatePaths);
|
|
if (canApplyFrom(candidates)) {
|
|
sources = candidatePaths;
|
|
configureWith(getConfigFromSources(getSources()));
|
|
CasCGlobalConfig config = GlobalConfiguration.all().get(CasCGlobalConfig.class);
|
|
if (config != null) {
|
|
config.setConfigurationPath(normalizedSource);
|
|
config.save();
|
|
}
|
|
LOGGER.log(Level.FINE, "Replace configuration with: " + normalizedSource);
|
|
} else {
|
|
LOGGER.log(Level.WARNING, "Provided sources could not be applied");
|
|
// todo: show message in UI
|
|
}
|
|
} else {
|
|
LOGGER.log(Level.FINE, "No such source exists, applying default");
|
|
// May be do nothing instead?
|
|
configure();
|
|
}
|
|
response.sendRedirect("");
|
|
}
|
|
|
|
private boolean canApplyFrom(List<YamlSource> yamlSources) {
|
|
try {
|
|
checkWith(yamlSources);
|
|
return true;
|
|
} catch (ConfiguratorException e) {
|
|
// ignore and return false
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@POST
|
|
@Restricted(NoExternalUse.class)
|
|
public FormValidation doCheckNewSource(@QueryParameter String newSource) {
|
|
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
|
|
String normalizedSource = Util.fixEmptyAndTrim(newSource);
|
|
File file = new File(Util.fixNull(normalizedSource));
|
|
if (normalizedSource == null) {
|
|
return FormValidation.ok(); // empty, do nothing
|
|
}
|
|
if (!file.exists() && !ConfigurationAsCode.isSupportedURI(normalizedSource)) {
|
|
return FormValidation.error("Configuration cannot be applied. File or URL cannot be parsed or do not exist.");
|
|
}
|
|
|
|
List<YamlSource> yamlSources = Collections.emptyList();
|
|
try {
|
|
List<String> sources = Collections.singletonList(normalizedSource);
|
|
yamlSources = getConfigFromSources(sources);
|
|
final Map<Source, String> issues = checkWith(yamlSources);
|
|
final JSONArray errors = collectProblems(issues, "error");
|
|
if (!errors.isEmpty()) {
|
|
return FormValidation.error(errors.toString());
|
|
}
|
|
final JSONArray warnings = collectProblems(issues, "warning");
|
|
if (!warnings.isEmpty()) {
|
|
return FormValidation.warning(warnings.toString());
|
|
}
|
|
return FormValidation.okWithMarkup("The configuration can be applied");
|
|
} catch (ConfiguratorException | IllegalArgumentException e) {
|
|
return FormValidation.error(e, e.getCause() == null ? e.getMessage() : e.getCause().getMessage());
|
|
}
|
|
}
|
|
|
|
private JSONArray collectProblems(Map<Source, String> issues, String severity) {
|
|
final JSONArray problems = new JSONArray();
|
|
issues.entrySet().stream().map(e -> new JSONObject().accumulate("line", e.getKey().line).accumulate(severity, e.getValue()))
|
|
.forEach(problems::add);
|
|
return problems;
|
|
}
|
|
|
|
private void appendSources(List<YamlSource> sources, String source) throws ConfiguratorException {
|
|
if (isSupportedURI(source)) {
|
|
sources.add(YamlSource.of(source));
|
|
} else {
|
|
sources.addAll(configs(source).stream()
|
|
.map(YamlSource::of)
|
|
.collect(toList()));
|
|
}
|
|
}
|
|
|
|
private List<YamlSource> getConfigFromSources(List<String> newSources) throws ConfiguratorException {
|
|
List<YamlSource> sources = new ArrayList<>();
|
|
|
|
for (String p : newSources) {
|
|
appendSources(sources, p);
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
/**
|
|
* Defaults to use a file in the current working directory with the name 'jenkins.yaml'
|
|
*
|
|
* Add the environment variable CASC_JENKINS_CONFIG to override the default. Accepts single file or a directory.
|
|
* If a directory is detected, we scan for all .yml and .yaml files
|
|
*
|
|
* @throws Exception when the file provided cannot be found or parsed
|
|
*/
|
|
@Restricted(NoExternalUse.class)
|
|
@Initializer(after = InitMilestone.SYSTEM_CONFIG_LOADED, before = InitMilestone.SYSTEM_CONFIG_ADAPTED)
|
|
public static void init() throws Exception {
|
|
detectVaultPluginMissing();
|
|
get().configure();
|
|
}
|
|
|
|
/**
|
|
* Main entry point to start configuration process.
|
|
* @throws ConfiguratorException Configuration error
|
|
*/
|
|
public void configure() throws ConfiguratorException {
|
|
configureWith(getStandardConfigSources());
|
|
}
|
|
|
|
private List<YamlSource> getStandardConfigSources() throws ConfiguratorException {
|
|
List<YamlSource> configs = new ArrayList<>();
|
|
|
|
List<String> standardConfig = getStandardConfig();
|
|
for (String p : standardConfig) {
|
|
appendSources(configs, p);
|
|
}
|
|
sources = Collections.unmodifiableList(standardConfig);
|
|
return configs;
|
|
}
|
|
|
|
private List<String> getStandardConfig() {
|
|
List<String> configParameters = getBundledCasCURIs();
|
|
CasCGlobalConfig casc = GlobalConfiguration.all().get(CasCGlobalConfig.class);
|
|
String cascPath = casc != null ? casc.getConfigurationPath() : null;
|
|
|
|
String configParameter = System.getProperty(
|
|
CASC_JENKINS_CONFIG_PROPERTY,
|
|
System.getenv(CASC_JENKINS_CONFIG_ENV)
|
|
);
|
|
|
|
// We prefer to rely on environment variable over global config
|
|
if (StringUtils.isNotBlank(cascPath) && StringUtils.isBlank(configParameter)) {
|
|
configParameter = cascPath;
|
|
}
|
|
|
|
if (configParameter == null) {
|
|
String fullPath = Jenkins.get().getRootDir() + File.separator + DEFAULT_JENKINS_YAML_PATH;
|
|
if (Files.exists(Paths.get(fullPath))) {
|
|
configParameter = fullPath;
|
|
}
|
|
}
|
|
|
|
if (configParameter != null) {
|
|
// Add external config parameter
|
|
configParameters.add(configParameter);
|
|
}
|
|
|
|
if (configParameters.isEmpty()) {
|
|
LOGGER.log(Level.FINE, "No configuration set nor default config file");
|
|
}
|
|
return configParameters;
|
|
}
|
|
|
|
@Restricted(NoExternalUse.class)
|
|
public List<String> getBundledCasCURIs() {
|
|
final String cascFile = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH;
|
|
final String cascDirectory = "/WEB-INF/" + DEFAULT_JENKINS_YAML_PATH + ".d/";
|
|
List<String> res = new ArrayList<>();
|
|
|
|
final ServletContext servletContext = Jenkins.get().servletContext;
|
|
try {
|
|
URL bundled = servletContext.getResource(cascFile);
|
|
if (bundled != null) {
|
|
res.add(bundled.toString());
|
|
}
|
|
} catch (IOException e) {
|
|
LOGGER.log(Level.WARNING, "Failed to load " + cascFile, e);
|
|
}
|
|
|
|
PathMatcher matcher = FileSystems.getDefault().getPathMatcher(YAML_FILES_PATTERN);
|
|
Set<String> resources = servletContext.getResourcePaths(cascDirectory);
|
|
if (resources!=null) {
|
|
// sort to execute them in a deterministic order
|
|
for (String cascItem : new TreeSet<>(resources)) {
|
|
try {
|
|
URL bundled = servletContext.getResource(cascItem);
|
|
if (bundled != null && matcher.matches(new File(bundled.getPath()).toPath())) {
|
|
res.add(bundled.toString());
|
|
} //TODO: else do some handling?
|
|
} catch (IOException e) {
|
|
LOGGER.log(Level.WARNING, "Failed to execute " + res, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
@RequirePOST
|
|
@Restricted(NoExternalUse.class)
|
|
public void doCheck(StaplerRequest req, StaplerResponse res) throws Exception {
|
|
|
|
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
|
|
res.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
|
|
final Map<Source, String> issues = checkWith(YamlSource.of(req));
|
|
res.setContentType("application/json");
|
|
final JSONArray warnings = new JSONArray();
|
|
issues.entrySet().stream().map(e -> new JSONObject().accumulate("line", e.getKey().line).accumulate("warning", e.getValue()))
|
|
.forEach(warnings::add);
|
|
warnings.write(res.getWriter());
|
|
}
|
|
|
|
@RequirePOST
|
|
@Restricted(NoExternalUse.class)
|
|
public void doApply(StaplerRequest req, StaplerResponse res) throws Exception {
|
|
|
|
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
|
|
res.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
configureWith(YamlSource.of(req));
|
|
}
|
|
|
|
/**
|
|
* Export live jenkins instance configuration as Yaml
|
|
* @throws Exception
|
|
*/
|
|
@RequirePOST
|
|
@Restricted(NoExternalUse.class)
|
|
public void doExport(StaplerRequest req, StaplerResponse res) throws Exception {
|
|
if (!Jenkins.get().hasPermission(Jenkins.SYSTEM_READ)) {
|
|
res.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
|
|
res.setContentType("application/x-yaml; charset=utf-8");
|
|
res.addHeader("Content-Disposition", "attachment; filename=jenkins.yaml");
|
|
export(res.getOutputStream());
|
|
}
|
|
|
|
/**
|
|
* Export JSONSchema to URL
|
|
* @throws Exception
|
|
*/
|
|
@Restricted(NoExternalUse.class)
|
|
public void doSchema(StaplerRequest req, StaplerResponse res) throws Exception {
|
|
if (!Jenkins.get().hasPermission(Jenkins.SYSTEM_READ)) {
|
|
res.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
|
|
res.setContentType("application/json; charset=utf-8");
|
|
res.getWriter().print(writeJSONSchema());
|
|
}
|
|
|
|
@RequirePOST
|
|
@Restricted(NoExternalUse.class)
|
|
public void doViewExport(StaplerRequest req, StaplerResponse res) throws Exception {
|
|
if (!Jenkins.get().hasPermission(Jenkins.SYSTEM_READ)) {
|
|
res.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
|
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
export(out);
|
|
|
|
req.setAttribute("export", out.toString(StandardCharsets.UTF_8.name()));
|
|
req.getView(this, "viewExport.jelly").forward(req, res);
|
|
}
|
|
|
|
@Restricted(NoExternalUse.class)
|
|
public void doReference(StaplerRequest req, StaplerResponse res) throws Exception {
|
|
if (!Jenkins.get().hasPermission(Jenkins.SYSTEM_READ)) {
|
|
res.sendError(HttpServletResponse.SC_FORBIDDEN);
|
|
return;
|
|
}
|
|
|
|
req.getView(this, "reference.jelly").forward(req, res);
|
|
}
|
|
|
|
@Restricted(NoExternalUse.class)
|
|
public void export(OutputStream out) throws Exception {
|
|
|
|
final List<NodeTuple> tuples = new ArrayList<>();
|
|
|
|
final ConfigurationContext context = new ConfigurationContext(registry);
|
|
for (RootElementConfigurator root : RootElementConfigurator.all()) {
|
|
final CNode config = root.describe(root.getTargetComponent(context), context);
|
|
final Node valueNode = toYaml(config);
|
|
if (valueNode == null) continue;
|
|
tuples.add(new NodeTuple(
|
|
new ScalarNode(Tag.STR, root.getName(), null, null, PLAIN),
|
|
valueNode));
|
|
}
|
|
|
|
MappingNode root = new MappingNode(Tag.MAP, tuples, BLOCK);
|
|
try (Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
|
|
serializeYamlNode(root, writer);
|
|
} catch (IOException e) {
|
|
throw new YAMLException(e);
|
|
}
|
|
}
|
|
|
|
@VisibleForTesting
|
|
@Restricted(NoExternalUse.class)
|
|
public static void serializeYamlNode(Node root, Writer writer) throws IOException {
|
|
DumperOptions options = new DumperOptions();
|
|
options.setDefaultFlowStyle(BLOCK);
|
|
options.setDefaultScalarStyle(PLAIN);
|
|
options.setSplitLines(true);
|
|
options.setPrettyFlow(true);
|
|
Serializer serializer = new Serializer(new Emitter(writer, options), new Resolver(),
|
|
options, null);
|
|
serializer.open();
|
|
serializer.serialize(root);
|
|
serializer.close();
|
|
}
|
|
|
|
@CheckForNull
|
|
@VisibleForTesting
|
|
@Restricted(NoExternalUse.class)
|
|
public Node toYaml(CNode config) throws ConfiguratorException {
|
|
|
|
if (config == null) return null;
|
|
|
|
switch (config.getType()) {
|
|
case MAPPING:
|
|
final Mapping mapping = config.asMapping();
|
|
final List<NodeTuple> tuples = new ArrayList<>();
|
|
final List<Map.Entry<String, CNode>> entries = new ArrayList<>(mapping.entrySet());
|
|
entries.sort(Comparator.comparing(Map.Entry::getKey));
|
|
for (Map.Entry<String, CNode> entry : entries) {
|
|
final Node valueNode = toYaml(entry.getValue());
|
|
if (valueNode == null) continue;
|
|
tuples.add(new NodeTuple(
|
|
new ScalarNode(Tag.STR, entry.getKey(), null, null, PLAIN),
|
|
valueNode));
|
|
|
|
}
|
|
if (tuples.isEmpty()) return null;
|
|
|
|
return new MappingNode(Tag.MAP, tuples, BLOCK);
|
|
|
|
case SEQUENCE:
|
|
final Sequence sequence = config.asSequence();
|
|
List<Node> nodes = new ArrayList<>();
|
|
for (CNode cNode : sequence) {
|
|
final Node valueNode = toYaml(cNode);
|
|
if (valueNode == null) continue;
|
|
nodes.add(valueNode);
|
|
}
|
|
if (nodes.isEmpty()) return null;
|
|
return new SequenceNode(Tag.SEQ, nodes, BLOCK);
|
|
|
|
case SCALAR:
|
|
default:
|
|
final Scalar scalar = config.asScalar();
|
|
final String value = scalar.getValue();
|
|
if (value == null || value.length() == 0) return null;
|
|
|
|
final DumperOptions.ScalarStyle style;
|
|
if (scalar.getFormat().equals(Format.MULTILINESTRING) && !scalar.isRaw()) {
|
|
style = LITERAL;
|
|
} else if (scalar.isRaw()) {
|
|
style = PLAIN;
|
|
} else {
|
|
style = DOUBLE_QUOTED;
|
|
}
|
|
|
|
return new ScalarNode(getTag(scalar.getFormat()), value, null, null, style);
|
|
}
|
|
}
|
|
|
|
private Tag getTag(Scalar.Format format) {
|
|
switch (format) {
|
|
case NUMBER:
|
|
return Tag.INT;
|
|
case FLOATING:
|
|
return Tag.FLOAT;
|
|
case BOOLEAN:
|
|
return Tag.BOOL;
|
|
case STRING:
|
|
case MULTILINESTRING:
|
|
default:
|
|
return Tag.STR;
|
|
}
|
|
}
|
|
|
|
public void configure(String ... configParameters) throws ConfiguratorException {
|
|
configure(Arrays.asList(configParameters));
|
|
}
|
|
|
|
public void configure(Collection<String> configParameters) throws ConfiguratorException {
|
|
|
|
List<YamlSource> configs = new ArrayList<>();
|
|
|
|
for (String p : configParameters) {
|
|
appendSources(configs, p);
|
|
}
|
|
sources = Collections.unmodifiableList(configParameters.stream().collect(toList()));
|
|
configureWith(configs);
|
|
lastTimeLoaded = System.currentTimeMillis();
|
|
}
|
|
|
|
public static boolean isSupportedURI(String configurationParameter) {
|
|
if(configurationParameter == null) {
|
|
return false;
|
|
}
|
|
final List<String> supportedProtocols = Arrays.asList("https","http","file");
|
|
URI uri;
|
|
try {
|
|
uri = new URI(configurationParameter);
|
|
} catch (URISyntaxException ex) {
|
|
return false;
|
|
}
|
|
if(uri.getScheme() == null) {
|
|
return false;
|
|
}
|
|
return supportedProtocols.contains(uri.getScheme());
|
|
}
|
|
|
|
@Restricted(NoExternalUse.class)
|
|
public void configureWith(YamlSource source) throws ConfiguratorException {
|
|
final List<YamlSource> sources = getStandardConfigSources();
|
|
sources.add(source);
|
|
configureWith(sources);
|
|
}
|
|
|
|
private void configureWith(List<YamlSource> sources) throws ConfiguratorException {
|
|
lastTimeLoaded = System.currentTimeMillis();
|
|
ConfigurationContext context = new ConfigurationContext(registry);
|
|
configureWith(YamlUtils.loadFrom(sources, context), context);
|
|
}
|
|
|
|
@Restricted(NoExternalUse.class)
|
|
public Map<Source, String> checkWith(YamlSource source) throws ConfiguratorException {
|
|
final List<YamlSource> sources = getStandardConfigSources();
|
|
sources.add(source);
|
|
return checkWith(sources);
|
|
}
|
|
|
|
private Map<Source, String> checkWith(List<YamlSource> sources) throws ConfiguratorException {
|
|
if (sources.isEmpty()) return Collections.emptyMap();
|
|
ConfigurationContext context = new ConfigurationContext(registry);
|
|
return checkWith(YamlUtils.loadFrom(sources, context), context);
|
|
}
|
|
|
|
/**
|
|
* Recursive search for all {@link #YAML_FILES_PATTERN} in provided base path
|
|
*
|
|
* @param path base path to start (can be file or directory)
|
|
* @return list of all paths matching pattern. Only base file itself if it is a file matching pattern
|
|
*/
|
|
@Restricted(NoExternalUse.class)
|
|
public List<Path> configs(String path) throws ConfiguratorException {
|
|
final Path root = Paths.get(path);
|
|
|
|
if (!Files.exists(root)) {
|
|
throw new ConfiguratorException("Invalid configuration: '"+path+"' isn't a valid path.");
|
|
}
|
|
|
|
if (Files.isRegularFile(root) && Files.isReadable(root)) {
|
|
return Collections.singletonList(root);
|
|
}
|
|
|
|
final PathMatcher matcher = FileSystems.getDefault().getPathMatcher(YAML_FILES_PATTERN);
|
|
try (Stream<Path> stream = Files.find(Paths.get(path), Integer.MAX_VALUE,
|
|
(next, attrs) -> !attrs.isDirectory() && !isHidden(next) && matcher.matches(next), FileVisitOption.FOLLOW_LINKS)) {
|
|
return stream.sorted().collect(toList());
|
|
} catch (IOException e) {
|
|
throw new IllegalStateException("failed config scan for " + path, e);
|
|
}
|
|
}
|
|
|
|
private static boolean isHidden(Path path) {
|
|
return IntStream.range(0, path.getNameCount())
|
|
.mapToObj(path::getName)
|
|
.anyMatch(subPath -> subPath.toString().startsWith("."));
|
|
}
|
|
|
|
@FunctionalInterface
|
|
private interface ConfiguratorOperation {
|
|
|
|
Object apply(RootElementConfigurator configurator, CNode node) throws ConfiguratorException;
|
|
}
|
|
|
|
/**
|
|
* Configuration with help of {@link RootElementConfigurator}s.
|
|
* Corresponding configurator is searched by entry key, passing entry value as object with all required properties.
|
|
*
|
|
* @param entries key-value pairs, where key should match to root configurator and value have all required properties
|
|
* @throws ConfiguratorException configuration error
|
|
*/
|
|
private static void invokeWith(Mapping entries, ConfiguratorOperation function) throws ConfiguratorException {
|
|
|
|
// Run configurators by order, consuming entries until all have found a matching configurator.
|
|
// Configurators order is important so that io.jenkins.plugins.casc.plugins.PluginManagerConfigurator run
|
|
// before any other, and can install plugins required by other configuration to successfully parse yaml data
|
|
for (RootElementConfigurator configurator : RootElementConfigurator.all()) {
|
|
final Iterator<Map.Entry<String, CNode>> it = entries.entrySet().iterator();
|
|
while (it.hasNext()) {
|
|
Map.Entry<String, CNode> entry = it.next();
|
|
if (! entry.getKey().equalsIgnoreCase(configurator.getName())) {
|
|
continue;
|
|
}
|
|
try {
|
|
function.apply(configurator, entry.getValue());
|
|
it.remove();
|
|
break;
|
|
} catch (ConfiguratorException e) {
|
|
throw new ConfiguratorException(
|
|
configurator,
|
|
format("error configuring '%s' with %s configurator", entry.getKey(), configurator.getClass()), e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!entries.isEmpty()) {
|
|
List<String> unknownKeys = new ArrayList<>();
|
|
entries.entrySet().iterator().forEachRemaining(next -> {
|
|
String key = next.getKey();
|
|
if (isNotAliasEntry(key)) {
|
|
unknownKeys.add(key);
|
|
}
|
|
});
|
|
|
|
if (!unknownKeys.isEmpty()) {
|
|
throw new ConfiguratorException(format("No configurator for the following root elements %s", String.join(", ", unknownKeys)));
|
|
}
|
|
}
|
|
}
|
|
|
|
static boolean isNotAliasEntry(String key) {
|
|
return key != null && !key.startsWith("x-");
|
|
}
|
|
|
|
private static void detectVaultPluginMissing() {
|
|
PluginManager pluginManager = Jenkins.get().getPluginManager();
|
|
Set<String> envKeys = System.getenv().keySet();
|
|
if (envKeys.stream().anyMatch(s -> s.startsWith("CASC_VAULT_"))
|
|
&& pluginManager.getPlugin("hashicorp-vault-plugin") == null) {
|
|
LOGGER.log(Level.SEVERE,
|
|
"Vault secret resolver is not installed, consider installing hashicorp-vault-plugin v2.4.0 or higher\nor consider removing any 'CASC_VAULT_' variables");
|
|
}
|
|
}
|
|
|
|
private void configureWith(Mapping entries,
|
|
ConfigurationContext context) throws ConfiguratorException {
|
|
// Initialize secret sources
|
|
SecretSource.all().forEach(SecretSource::init);
|
|
|
|
// Check input before actually applying changes,
|
|
// so we don't let master in a weird state after some ConfiguratorException has been thrown
|
|
final Mapping clone = entries.clone();
|
|
checkWith(clone, context);
|
|
|
|
final ObsoleteConfigurationMonitor monitor = ObsoleteConfigurationMonitor.get();
|
|
monitor.reset();
|
|
context.clearListeners();
|
|
context.addListener(monitor::record);
|
|
try (ACLContext acl = ACL.as(ACL.SYSTEM)) {
|
|
invokeWith(entries, (configurator, config) -> configurator.configure(config, context));
|
|
}
|
|
}
|
|
|
|
public Map<Source, String> checkWith(Mapping entries,
|
|
ConfigurationContext context) throws ConfiguratorException {
|
|
Map<Source, String> issues = new HashMap<>();
|
|
context.addListener( (node,message) -> issues.put(node.getSource(), message) );
|
|
invokeWith(entries, (configurator, config) -> configurator.check(config, context));
|
|
return issues;
|
|
}
|
|
|
|
|
|
public static ConfigurationAsCode get() {
|
|
return Jenkins.get().getExtensionList(ConfigurationAsCode.class).get(0);
|
|
}
|
|
|
|
/**
|
|
* Used for documentation generation in index.jelly
|
|
*/
|
|
public Collection<?> getRootConfigurators() {
|
|
return new LinkedHashSet<>(RootElementConfigurator.all());
|
|
}
|
|
|
|
/**
|
|
* Used for documentation generation in index.jelly
|
|
*/
|
|
public Collection<?> getConfigurators() {
|
|
List<RootElementConfigurator> roots = RootElementConfigurator.all();
|
|
final ConfigurationContext context = new ConfigurationContext(registry);
|
|
Set<Object> elements = new LinkedHashSet<>(roots);
|
|
for (RootElementConfigurator root : roots) {
|
|
listElements(elements, root.describe(), context);
|
|
}
|
|
return elements;
|
|
}
|
|
|
|
/**
|
|
* Recursive configurators tree walk (DFS).
|
|
* Collects all configurators starting from root ones in {@link #getConfigurators()}
|
|
* @param elements linked set (to save order) of visited elements
|
|
* @param attributes siblings to find associated configurators and dive to next tree levels
|
|
* @param context
|
|
*/
|
|
private void listElements(Set<Object> elements, Set<Attribute<?,?>> attributes, ConfigurationContext context) {
|
|
attributes.stream()
|
|
.map(Attribute::getType)
|
|
.map(context::lookup)
|
|
.filter(Objects::nonNull)
|
|
.map(c -> c.getConfigurators(context))
|
|
.flatMap(Collection::stream)
|
|
.forEach(configurator -> {
|
|
if (elements.add(configurator)) {
|
|
listElements(elements, ((Configurator)configurator).describe(), context); // some unexpected type erasure force to cast here
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- UI helper methods
|
|
|
|
/**
|
|
* Retrieve the html help tip associated to an attribute, used in documentation.jelly
|
|
* FIXME would prefer <st:include page="help-${a.name}.html" class="${c.target}" optional="true"/>
|
|
* @param attribute to get help for
|
|
* @return String that shows help. May be empty
|
|
* @throws IOException if the resource cannot be read
|
|
*/
|
|
@Restricted(NoExternalUse.class)
|
|
@NonNull
|
|
public String getHtmlHelp(Class type, String attribute) throws IOException {
|
|
final URL resource = Klass.java(type).getResource("help-" + attribute + ".html");
|
|
if (resource != null) {
|
|
return IOUtils.toString(resource.openStream(), StandardCharsets.UTF_8);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Retrieve which plugin do provide this extension point, used in documentation.jelly
|
|
*
|
|
* @return String representation of the extension source, usually artifactId.
|
|
*/
|
|
@Restricted(NoExternalUse.class)
|
|
@CheckForNull
|
|
public String getExtensionSource(Configurator c) throws IOException {
|
|
final Class e = c.getImplementedAPI();
|
|
final String jar = Which.jarFile(e).getName();
|
|
if (jar.startsWith("jenkins-core-")) { // core jar has version in name
|
|
return "jenkins-core";
|
|
}
|
|
return jar.substring(0, jar.lastIndexOf('.'));
|
|
}
|
|
|
|
@Restricted(NoExternalUse.class)
|
|
public static String printThrowable(@NonNull Throwable t) {
|
|
String s = Functions.printThrowable(t)
|
|
.split("at io.jenkins.plugins.casc.ConfigurationAsCode.export")[0]
|
|
.replaceAll("\t", " ");
|
|
return s.substring(0, s.lastIndexOf(")") + 1);
|
|
}
|
|
|
|
}
|