
org.ligoj.app.plugin.build.jenkins.JenkinsPluginResource Maven / Gradle / Ivy
/*
* Licensed under MIT (https://github.com/ligoj/ligoj/blob/master/LICENSE)
*/
package org.ligoj.app.plugin.build.jenkins;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.commons.lang3.stream.Streams;
import org.ligoj.app.api.SubscriptionStatusWithData;
import org.ligoj.app.iam.IamProvider;
import org.ligoj.app.plugin.build.BuildResource;
import org.ligoj.app.plugin.build.BuildServicePlugin;
import org.ligoj.app.resource.NormalizeFormat;
import org.ligoj.app.resource.plugin.AbstractToolPluginResource;
import org.ligoj.app.resource.plugin.XmlUtils;
import org.ligoj.bootstrap.core.curl.CurlProcessor;
import org.ligoj.bootstrap.core.curl.CurlRequest;
import org.ligoj.bootstrap.core.curl.HeaderHttpResponseCallback;
import org.ligoj.bootstrap.core.curl.OnlyRedirectHttpResponseCallback;
import org.ligoj.bootstrap.core.resource.BusinessException;
import org.ligoj.bootstrap.core.validation.ValidationJsonException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.xml.DomUtils;
import org.springframework.web.util.UriUtils;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Jenkins resource.
*/
@Path(JenkinsPluginResource.URL)
@Service
@Produces(MediaType.APPLICATION_JSON)
@Slf4j
public class JenkinsPluginResource extends AbstractToolPluginResource implements BuildServicePlugin {
/**
* Plug-in key.
*/
public static final String URL = BuildResource.SERVICE_URL + "/jenkins";
/**
* Plug-in key.
*/
public static final String KEY = URL.replace('/', ':').substring(1);
/**
* Default maximum returned branches.
*/
public static final int DEFAULT_MAX_BRANCHES = 10;
/**
* Jenkins username able to connect to instance.
*/
public static final String PARAMETER_USER = KEY + ":user";
/**
* Jenkins' user api-token able to connect to instance.
*/
public static final String PARAMETER_TOKEN = KEY + ":api-token";
/**
* Jenkins job's name.
*/
public static final String PARAMETER_JOB = KEY + ":job";
/**
* Jenkins job's name.
*/
public static final String PARAMETER_TEMPLATE_JOB = KEY + ":template-job";
/**
* Web site URL
*/
public static final String PARAMETER_URL = KEY + ":url";
/**
* Maximum returned branches.
*/
public static final String PARAMETER_MAX_BRANCHES = KEY + ":max-branches";
/**
* Jenkins version callback to extract the header.
*/
private static final HeaderHttpResponseCallback VERSION_CALLBACK = new HeaderHttpResponseCallback("x-jenkins");
/**
* Maximum depth for job searches.
*/
public static final String PARAMETER_MAX_DEPTH = KEY + ":max-depth";
/**
* Maximum Jenkins depth.
*/
private static final int MAX_DEPTH = 5;
/**
* Marker of recursive query text.
*/
private static final String XML_RECURRING_MARKER = "__XML_RECURRING__";
/**
* Template query for Jenkins XML tree.
*/
private static final String XML_TEMPLATE_QUERY = "displayName,fullName,color,lastBuild[timestamp],property[branch[head]]" + XML_RECURRING_MARKER;
/**
* Public server URL used to fetch the last available version of the product.
*/
@Value("${service-build-jenkins-server:https://mirrors.jenkins-ci.org}")
private String publicServer;
@Autowired
protected IamProvider[] iamProvider;
@Autowired
protected XmlUtils xml;
/**
* Used to launch the job for the subscription.
*
* @param subscription the subscription to use to locate the Jenkins instance.
*/
@POST
@Path("build/{subscription:\\d+}")
public void build(@PathParam("subscription") final int subscription) {
final var parameters = subscriptionResource.getParameters(subscription);
// Check the instance is available
validateAdminAccess(parameters);
if (!build(parameters, "build") && !build(parameters, "buildWithParameters")) {
throw new BusinessException("Launching the job for the subscription {} failed.", subscription);
}
}
/**
* Launch the job with the URL.
*
* @param parameters Parameters used to define the job
* @param url URL added to the jenkins's URL to launch the job (can be build or buildWithParameters)
* @return The result of the processing.
*/
protected boolean build(final Map parameters, final String url) {
try (var processor = new JenkinsCurlProcessor(parameters)) {
final var jenkinsBaseUrl = StringUtils.appendIfMissing(parameters.get(PARAMETER_URL), "/");
final var jobName = parameters.get(PARAMETER_JOB);
return processor.process(new CurlRequest("POST", jenkinsBaseUrl + "job/" + jobName + "/" + url, null));
}
}
@Override
public boolean checkStatus(final Map parameters) {
// Status is UP <=> Administration access is UP
validateAdminAccess(parameters);
return true;
}
@Override
public SubscriptionStatusWithData checkSubscriptionStatus(final Map parameters)
throws IOException, ParserConfigurationException, SAXException {
final var nodeStatusWithData = new SubscriptionStatusWithData();
nodeStatusWithData.put("job", validateJob(parameters));
return nodeStatusWithData;
}
@Override
public void create(final int subscription) throws IOException {
final var parameters = subscriptionResource.getParameters(subscription);
// Validate the node settings
validateAdminAccess(parameters);
// Get Template configuration
final var templateJob = parameters.get(PARAMETER_TEMPLATE_JOB);
final var templateConfigXml = getResource(parameters, "job/" + encode(templateJob) + "/config.xml");
// update template
final var project = subscriptionRepository.findOneExpected(subscription).getProject();
final var teamLeader = iamProvider[0].getConfiguration().getUserRepository()
.findById(project.getTeamLeader());
final String configXml = templateConfigXml
.replaceFirst("true ", "false ")
.replaceAll("ligoj-saas", project.getPkey())
.replaceAll("[email protected]", teamLeader.getMails().get(0))
.replaceFirst("().*?( )", "$1" + project.getName() + "$2")
.replaceFirst("().*?( )", "$1" + project.getDescription() + "$2");
// create new job
final var job = parameters.get(PARAMETER_JOB);
final var jenkinsBaseUrl = StringUtils.appendIfMissing(parameters.get(PARAMETER_URL), "/");
final var curlRequest = new CurlRequest(HttpMethod.POST,
jenkinsBaseUrl + "createItem?name=" + encode(job), configXml, "Content-Type:application/xml");
try (var curl = new JenkinsCurlProcessor(parameters)) {
if (!curl.process(curlRequest)) {
throw new BusinessException("Creating the job for the subscription {} failed.", subscription);
}
}
}
@Override
public void delete(final int subscription, final boolean deleteRemoteData) {
if (deleteRemoteData) {
final var parameters = subscriptionResource.getParameters(subscription);
// Validate the node settings
validateAdminAccess(parameters);
// delete the job
final var job = parameters.get(PARAMETER_JOB);
final var jenkinsBaseUrl = StringUtils.appendIfMissing(parameters.get(PARAMETER_URL), "/");
final var curlRequest = new CurlRequest(HttpMethod.POST,
jenkinsBaseUrl + "job/" + encode(job) + "/doDelete", StringUtils.EMPTY);
try (var curl = new JenkinsCurlProcessor(parameters, new OnlyRedirectHttpResponseCallback())) {
if (!curl.process(curlRequest)) {
throw new BusinessException("Deleting the job for the subscription {} failed.", subscription);
}
}
}
}
private String encode(final String job) {
return UriUtils.encode(job, "UTF-8");
}
/**
* Search the Jenkins's jobs matching to the given criteria. Name, display name and description are considered.
*
* @param node the node to be tested with given parameters.
* @param criteria the search criteria.
* @return job names matching the criteria.
* @throws SAXException When Jenkins project cannot be validated.
* @throws IOException When Jenkins project cannot be read.
* @throws ParserConfigurationException When Jenkins project cannot be parsed.
*/
@GET
@Path("{node}/{criteria}")
@Consumes(MediaType.APPLICATION_JSON)
public List findAllByName(@PathParam("node") final String node, @PathParam("criteria") final String criteria)
throws SAXException, IOException, ParserConfigurationException {
return findAllByName(node, criteria, null);
}
/**
* Search the Jenkins's jobs matching to the given criteria. Name, display name and description are considered.
*
* @param node the node to be tested with given parameters.
* @param criteria the search criteria.
* @param view The optional view URL.
* @return job names matching the criteria.
*/
private List findAllByName(final String node, final String criteria, final String view)
throws SAXException, IOException, ParserConfigurationException {
// Build Jenkins query
var query = "jobs[" + XML_TEMPLATE_QUERY + "]";
final int maxDepth = configuration.get(PARAMETER_MAX_DEPTH, MAX_DEPTH);
for (var depth = 1; depth < maxDepth; depth++) {
query = query.replace(XML_RECURRING_MARKER, ",jobs[" + XML_TEMPLATE_QUERY + "]");
}
// End of the recursion
query = query.replace(XML_RECURRING_MARKER, "");
// Prepare the context, an ordered set of jobs
final var format = new NormalizeFormat();
final var formatCriteria = format.format(criteria);
final var parameters = pvResource.getNodeParameters(node);
// Get the jobs and parse them
final var url = StringUtils.trimToEmpty(view) + "api/xml?tree=" + query;
final var jobsAsXml = Objects.toString(getResource(parameters, url), "");
final var jobsAsInput = IOUtils.toInputStream(jobsAsXml, StandardCharsets.UTF_8);
final var hudson = xml.parse(jobsAsInput).getDocumentElement();
final var result = new TreeMap();
getRecursiveJobs(hudson)
.filter(job ->
format.format(Objects.toString(job.getId(), "")).contains(formatCriteria)
|| format.format(Objects.toString(job.getName(), "")).contains(formatCriteria)
|| format.format(Objects.toString(job.getDescription(), "")).contains(formatCriteria))
.forEach(job -> result.put(format.format(ObjectUtils.defaultIfNull(job.getName(), job.getId())), job));
return new ArrayList<>(result.values());
}
private Stream getRecursiveJobs(Element e) {
return Stream.concat(Stream.of(newJob(e)), DomUtils.getChildElementsByTagName(e, "job").stream().flatMap(this::getRecursiveJobs));
}
/**
* Search the Jenkins's template jobs matching to the given criteria. Name, display name and description are
* considered.
*
* @param node the node to be tested with given parameters.
* @param criteria the search criteria.
* @return template job names matching the criteria.
* @throws SAXException When Jenkins project cannot be validated.
* @throws IOException When Jenkins project cannot be read.
* @throws ParserConfigurationException When Jenkins project cannot be parsed.
*/
@GET
@Path("template/{node}/{criteria}")
@Consumes(MediaType.APPLICATION_JSON)
public List findAllTemplateByName(@PathParam("node") final String node,
@PathParam("criteria") final String criteria)
throws SAXException, IOException, ParserConfigurationException {
return findAllByName(node, criteria, "view/Templates/");
}
/**
* Get Jenkins job name by id.
*
* @param node the node to be tested with given parameters.
* @param id The job name/identifier.
* @return job names matching the criteria.
* @throws MalformedURLException When the Jenkins base URL is malformed.
*/
@GET
@Path("{node}/job/{id}")
@Consumes(MediaType.APPLICATION_JSON)
public Job findById(@PathParam("node") final String node, @PathParam("id") final String id)
throws IOException, ParserConfigurationException, SAXException {
// Prepare the context, an ordered set of jobs
final var parameters = pvResource.getNodeParameters(node);
parameters.put(PARAMETER_JOB, id);
return validateJob(parameters);
}
@Override
public String getKey() {
return KEY;
}
@Override
public String getLastVersion() {
// Get the download index from the default repository
return getLastVersion(publicServer + "/war/");
}
/**
* Return the last version available for Jenkins for the given repository URL.
*
* @param repo The path of the index containing the available versions.
* @return The last Jenkins version.
*/
protected String getLastVersion(final String repo) {
// Get the download index
try (var curl = new CurlProcessor()) {
final var downloadPage = ObjectUtils.defaultIfNull(curl.get(repo), "");
// Find the last download link
final var matcher = Pattern.compile("href=\"([\\d.]+)/\"").matcher(downloadPage);
String lastVersion = null;
while (matcher.find()) {
final var cVersion = matcher.group(1);
if (lastVersion == null || cVersion.compareTo(lastVersion) > 0) {
lastVersion = cVersion;
}
}
// Return the last read version
return lastVersion;
}
}
/**
* Return a Jenkins's resource. Return null
when the resource is not found.
*/
private String getResource(final CurlProcessor processor, final String url, final String resource) {
// Get the resource using the preempted authentication
return processor.get(StringUtils.appendIfMissing(url, "/") + resource);
}
/**
* Return a Jenkins's resource. Return null
when the resource is not found.
*
* @param parameters The subscription parameters.
* @param resource The requested Jenkins resource.
* @return The Jenkins resource's content.
*/
protected String getResource(final Map parameters, final String resource) {
return getResource(new JenkinsCurlProcessor(parameters), parameters.get(PARAMETER_URL), resource);
}
@Override
public String getVersion(final Map parameters) {
// Check the user has enough rights to get the master configuration and
// get the master configuration and
return getResource(new JenkinsCurlProcessor(parameters, VERSION_CALLBACK), parameters.get(PARAMETER_URL),
"api/json?tree=numExecutors");
}
@Override
public void link(final int subscription) throws IOException, ParserConfigurationException, SAXException {
final var parameters = subscriptionResource.getParameters(subscription);
// Validate the node settings
validateAdminAccess(parameters);
// Validate the job settings
validateJob(parameters);
}
/**
* Validate the basic REST connectivity to Jenkins.
*
* @param parameters the server parameters.
* @return the detected Jenkins version.
*/
protected String validateAdminAccess(final Map parameters) {
CurlProcessor.validateAndClose(StringUtils.appendIfMissing(parameters.get(PARAMETER_URL), "/") + "login",
PARAMETER_URL, "jenkins-connection");
// Check the user can log in to Jenkins with the preempted
// authentication processor
if (getResource(parameters, "api/xml") == null) {
throw new ValidationJsonException(PARAMETER_USER, "jenkins-login");
}
// Check the user has enough rights to get the master configuration and
// return the version
final var version = getVersion(parameters);
if (version == null) {
throw new ValidationJsonException(PARAMETER_USER, "jenkins-rights");
}
return version;
}
/**
* Validate the administration connectivity.
*
* @param parameters the administration parameters.
* @return job name.
* @throws MalformedURLException When the Jenkins base URL is malformed.
*/
protected Job validateJob(final Map parameters) throws IOException, ParserConfigurationException, SAXException {
final var job = parameters.get(PARAMETER_JOB);
final var jobAsXml = getResource(parameters,
"job/" + Streams.of(job.split("/")).map(this::encode).collect(Collectors.joining("/job/"))
+ "/api/xml?tree=" + XML_TEMPLATE_QUERY.replace(XML_RECURRING_MARKER, ",jobs[" + XML_TEMPLATE_QUERY).replace(XML_RECURRING_MARKER, "]"));
if (jobAsXml == null || " ".equals(jobAsXml)) {
// Invalid couple PKEY and id
throw new ValidationJsonException(PARAMETER_JOB, "jenkins-job", job);
}
final var jobsAsInput = IOUtils.toInputStream(jobAsXml, StandardCharsets.UTF_8);
final var root = xml.parse(jobsAsInput).getDocumentElement();
final var result = newJob(root);
final int maxBranches = NumberUtils.toInt(getParameter(parameters, PARAMETER_MAX_BRANCHES, String.valueOf(DEFAULT_MAX_BRANCHES)));
result.setJobs(DomUtils.getChildElementsByTagName(root, "job").stream()
.map(this::newJob)
.filter(j -> !"disabled".equals(j.getStatus()))
.sorted((b1, b2) -> {
// Sort the branches by their activities
if (b1.getLastBuild() == null) {
return 1;
}
if (b2.getLastBuild() == null) {
return -1;
}
return (int) (b2.getLastBuild() - b1.getLastBuild());
})
.limit(maxBranches)
.collect(Collectors.toList()));
return result;
}
private String getNodeContent(final Element root, final String tag) {
return StringUtils.trimToNull(DomUtils.getChildElementValueByTagName(root, tag));
}
private Job newJob(final Element root) {
final var result = new Job();
// Extract string data from this job
result.setId(Optional.ofNullable(getNodeContent(root, "fullName")).orElseGet(() -> getNodeContent(root, "name")));
result.setName(getNodeContent(root, "displayName"));
result.setDescription(getNodeContent(root, "description"));
result.setLastBuild(Optional.ofNullable(DomUtils.getChildElementByTagName(root, "lastBuild"))
.map(l -> getNodeContent(l, "timestamp"))
.map(Long::valueOf).orElse(null));
// Retrieve description, status, display name and branch type
final var statusNode = Objects.toString(getNodeContent(root, "color"), "disabled");
result.setStatus(StringUtils.removeEnd(statusNode, "_anime"));
result.setBuilding(statusNode.endsWith("_anime"));
result.setPullRequestBranch(DomUtils.getChildElementsByTagName(root, "property").stream()
.filter(p -> "org.jenkinsci.plugins.workflow.multibranch.BranchJobProperty".equals(p.getAttribute("_class")))
.flatMap(p -> DomUtils.getChildElementsByTagName(p, "branch").stream())
.flatMap(b -> DomUtils.getChildElementsByTagName(b, "head").stream())
.anyMatch(h -> "org.jenkinsci.plugins.github_branch_source.PullRequestSCMHead".equals(h.getAttribute("_class"))));
return result;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy