From 83a3a327c11160a87842dfef8ce88a737109a3b9 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Sat, 12 Apr 2025 02:04:59 -0500 Subject: [PATCH 01/19] feat: Init Agent Intergation --- .gitignore | 1 + .idea/.gitignore | 8 + .idea/compiler.xml | 18 + .idea/encodings.xml | 7 + .idea/jarRepositories.xml | 25 + .idea/misc.xml | 12 + .idea/vcs.xml | 6 + .vscode/settings.json | 3 + pom.xml | 27 +- .../CtrlplaneGlobalConfiguration.java | 167 ++++++ .../plugins/ctrlplane/CtrlplaneJobPoller.java | 255 ++++++++++ .../plugins/ctrlplane/api/JobAgent.java | 474 ++++++++++++++++++ .../plugins/sample/HelloWorldBuilder.java | 80 --- src/main/resources/index.jelly | 8 +- .../CtrlplaneGlobalConfiguration/config.jelly | 26 + .../help-apiKey.html | 4 + .../help-apiUrl.html | 3 + .../help-credentialsId.html | 4 + .../help-label.html | 3 + .../sample/HelloWorldBuilder/config.jelly | 12 - .../HelloWorldBuilder/config.properties | 3 - .../HelloWorldBuilder/config_de.properties | 3 - .../HelloWorldBuilder/config_es.properties | 3 - .../HelloWorldBuilder/config_fr.properties | 3 - .../HelloWorldBuilder/config_it.properties | 3 - .../HelloWorldBuilder/config_pt_BR.properties | 3 - .../HelloWorldBuilder/config_sv.properties | 3 - .../HelloWorldBuilder/config_tr.properties | 3 - .../HelloWorldBuilder/config_zh_CN.properties | 3 - .../sample/HelloWorldBuilder/help-name.html | 3 - .../HelloWorldBuilder/help-name_de.html | 3 - .../HelloWorldBuilder/help-useFrench.html | 3 - .../HelloWorldBuilder/help-useFrench_de.html | 3 - .../plugins/sample/Messages.properties | 5 - .../plugins/sample/Messages_de.properties | 5 - .../CtrlplaneGlobalConfigurationTest.java | 117 +++++ .../plugins/sample/HelloWorldBuilderTest.java | 75 --- 37 files changed, 1154 insertions(+), 230 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/compiler.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml create mode 100644 .vscode/settings.json create mode 100644 src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java create mode 100644 src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java create mode 100644 src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java delete mode 100644 src/main/java/io/jenkins/plugins/sample/HelloWorldBuilder.java create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.jelly delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_de.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_es.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_fr.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_it.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_pt_BR.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_sv.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_tr.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_zh_CN.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name.html delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name_de.html delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench.html delete mode 100644 src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench_de.html delete mode 100644 src/main/resources/io/jenkins/plugins/sample/Messages.properties delete mode 100644 src/main/resources/io/jenkins/plugins/sample/Messages_de.properties create mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java delete mode 100644 src/test/java/io/jenkins/plugins/sample/HelloWorldBuilderTest.java diff --git a/.gitignore b/.gitignore index acc6b3c..6c9573a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ hs_err_pid* replay_pid* target/ +work/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..5041988 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..f91ffff --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..f24c79d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e0f15db --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7aa7b36..eb6747c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.jenkins-ci.plugins plugin - 4.85 + 5.7 @@ -14,7 +14,7 @@ ${revision}${changelist} hpi - TODO Plugin + Ctrlplane Plugin https://github.com/jenkinsci/${project.artifactId}-plugin @@ -32,28 +32,31 @@ 1.0 -SNAPSHOT - - - 2.440.3 - jenkinsci/${project.artifactId}-plugin - - false + 2.492 + ${jenkins.baseline}.3 + + ctrlplanedev/jenkins-agent-plugin - io.jenkins.tools.bom - bom-2.440.x - 3193.v330d8248d39e + bom-${jenkins.baseline}.x + 4051.v78dce3ce8b_d6 pom import + + com.fasterxml.jackson.core + jackson-databind + 2.15.3 + + diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java new file mode 100644 index 0000000..fdc64a1 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java @@ -0,0 +1,167 @@ +package io.jenkins.plugins.ctrlplane; + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.util.FormValidation; +import jenkins.model.GlobalConfiguration; +import org.apache.commons.lang.StringUtils; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; + +/** + * Global configuration for the Ctrlplane Agent plugin. + */ +@Extension +public class CtrlplaneGlobalConfiguration extends GlobalConfiguration { + + /** + * Default API URL + */ + public static final String DEFAULT_API_URL = "https://app.ctrlplane.dev"; + + public static final String DEFAULT_AGENT_ID = "jenkins-agent"; + public static final int DEFAULT_POLLING_INTERVAL_SECONDS = 60; + + /** @return the singleton instance */ + public static CtrlplaneGlobalConfiguration get() { + return ExtensionList.lookupSingleton(CtrlplaneGlobalConfiguration.class); + } + + private String apiUrl; + private String apiKey; + private String agentId; + private String agentWorkspaceId; + private int pollingIntervalSeconds; + + public CtrlplaneGlobalConfiguration() { + // When Jenkins is restarted, load any saved configuration from disk. + load(); + // Set defaults only if loaded values are null/blank/zero + if (StringUtils.isBlank(apiUrl)) { + apiUrl = DEFAULT_API_URL; + } + if (pollingIntervalSeconds <= 0) { // Set default interval if not loaded or invalid + pollingIntervalSeconds = DEFAULT_POLLING_INTERVAL_SECONDS; + } + } + + /** @return the currently configured API URL, or default if not set */ + public String getApiUrl() { + return StringUtils.isBlank(apiUrl) ? DEFAULT_API_URL : apiUrl; + } + + /** + * Sets the API URL + * @param apiUrl the new API URL + */ + @DataBoundSetter + public void setApiUrl(String apiUrl) { + this.apiUrl = apiUrl; + save(); + } + + /** @return the currently configured API key */ + public String getApiKey() { + return apiKey; + } + + /** + * Sets the API key + * @param apiKey the new API key + */ + @DataBoundSetter + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + save(); + } + + /** @return the currently configured agent ID */ + public String getAgentId() { + return StringUtils.isBlank(agentId) ? DEFAULT_AGENT_ID : agentId; + } + + /** + * Sets the agent ID + * @param agentId the new agent ID + */ + @DataBoundSetter + public void setAgentId(String agentId) { + this.agentId = agentId; + save(); + } + + /** @return the currently configured agent workspace ID */ + public String getAgentWorkspaceId() { + return agentWorkspaceId; + } + + /** + * Sets the agent workspace ID + * @param agentWorkspaceId the new agent workspace ID + */ + @DataBoundSetter + public void setAgentWorkspaceId(String agentWorkspaceId) { + this.agentWorkspaceId = agentWorkspaceId; + save(); + } + + /** @return the currently configured polling interval in seconds */ + public int getPollingIntervalSeconds() { + // Return default if current value is invalid + return pollingIntervalSeconds > 0 ? pollingIntervalSeconds : DEFAULT_POLLING_INTERVAL_SECONDS; + } + + /** + * Sets the polling interval. + * @param pollingIntervalSeconds The new interval in seconds. + */ + @DataBoundSetter + public void setPollingIntervalSeconds(int pollingIntervalSeconds) { + // Ensure a minimum value (e.g., 10 seconds) to prevent overly frequent polling + this.pollingIntervalSeconds = Math.max(10, pollingIntervalSeconds); + save(); + } + + public FormValidation doCheckApiUrl(@QueryParameter String value) { + if (StringUtils.isEmpty(value)) { + return FormValidation.warning("API URL is recommended. Defaults to " + DEFAULT_API_URL); + } + return FormValidation.ok(); + } + + public FormValidation doCheckApiKey(@QueryParameter String value) { + if (StringUtils.isEmpty(value)) { + return FormValidation.warning("API Key is required for the agent to poll for jobs."); + } + return FormValidation.ok(); + } + + public FormValidation doCheckAgentId(@QueryParameter String value) { + if (StringUtils.isEmpty(value)) { + return FormValidation.warning("Agent ID is required for the agent to identify itself."); + } + return FormValidation.ok(); + } + + public FormValidation doCheckAgentWorkspaceId(@QueryParameter String value) { + if (StringUtils.isEmpty(value)) { + return FormValidation.warning("Agent Workspace ID is required for the agent to identify itself."); + } + return FormValidation.ok(); + } + + public FormValidation doCheckPollingIntervalSeconds(@QueryParameter String value) { + try { + if (StringUtils.isEmpty(value)) { + return FormValidation.ok("Using default interval: " + DEFAULT_POLLING_INTERVAL_SECONDS + " seconds."); + } + int interval = Integer.parseInt(value); + if (interval < 10) { + return FormValidation.error("Polling interval must be at least 10 seconds."); + } + return FormValidation.ok(); + } catch (NumberFormatException e) { + return FormValidation.error("Polling interval must be a valid integer."); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java new file mode 100644 index 0000000..e5039cd --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -0,0 +1,255 @@ +package io.jenkins.plugins.ctrlplane; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.Extension; +import hudson.model.AsyncPeriodicWork; +import hudson.model.ParametersAction; +import hudson.model.StringParameterValue; +import hudson.model.TaskListener; +import io.jenkins.plugins.ctrlplane.api.JobAgent; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import jenkins.model.Jenkins; +import jenkins.model.ParameterizedJobMixIn; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A background task that periodically polls the Ctrlplane API for the next job. + */ +@Extension +public class CtrlplaneJobPoller extends AsyncPeriodicWork { + private static final Logger LOGGER = LoggerFactory.getLogger(CtrlplaneJobPoller.class); + + // In-memory tracking of triggered Ctrlplane job IDs to prevent duplicates + private final ConcurrentHashMap triggeredJobIds = new ConcurrentHashMap<>(); + + // The JobAgent for registration and job polling + private JobAgent jobAgent; + + /** + * Constructor. + */ + public CtrlplaneJobPoller() { + super("Ctrlplane Job Poller"); + } + + @Override + public long getRecurrencePeriod() { + // Read interval from global config + int intervalSeconds = CtrlplaneGlobalConfiguration.get().getPollingIntervalSeconds(); + LOGGER.debug("Using polling interval: {} seconds", intervalSeconds); + return TimeUnit.SECONDS.toMillis(intervalSeconds); + } + + @SuppressFBWarnings( + value = {"REC_CATCH_EXCEPTION"}, + justification = "Catching generic Exception for robust error handling.") + @Override + protected void execute(TaskListener listener) { + LOGGER.debug("Starting Ctrlplane job polling cycle."); + + // 1. Get Global Configuration + CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); + String apiUrl = config.getApiUrl(); + String apiKey = config.getApiKey(); + String agentName = config.getAgentId(); // Use configured Agent ID as name + String agentWorkspaceId = config.getAgentWorkspaceId(); // Get workspace ID + + // 2. Validate Configuration + if (apiUrl == null || apiUrl.isBlank()) { + LOGGER.warn("Ctrlplane API URL not configured. Skipping polling cycle."); + return; + } + if (apiKey == null || apiKey.isBlank()) { + LOGGER.warn("Ctrlplane API key not configured. Skipping polling cycle."); + return; + } + if (agentName == null || agentName.isBlank()) { + LOGGER.warn("Ctrlplane Agent ID (Name) not configured. Skipping polling cycle."); + return; + } + // Optionally warn if workspace ID is missing, depending on requirements + if (agentWorkspaceId == null || agentWorkspaceId.isBlank()) { + LOGGER.warn("Ctrlplane Agent Workspace ID not configured. Registration might fail or be incomplete."); + // Decide if this is fatal: return; + } + + // 3. Create or get the JobAgent and ensure it's registered + if (jobAgent == null) { + // Use configured agentId as the name, add workspace ID + jobAgent = createJobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); + } else { + // TODO: Consider if JobAgent needs updating if config changes (e.g., API key) + // Currently, it reuses the existing instance. + } + + if (!jobAgent.ensureRegistered()) { + LOGGER.error("Agent registration check failed. Skipping polling cycle."); + return; + } + + // Agent ID is now managed internally by JobAgent + String currentAgentId = jobAgent.getAgentIdString(); + if (currentAgentId == null) { + LOGGER.error("Agent ID not available after registration attempt. Skipping polling cycle."); + return; + } + LOGGER.debug("Polling jobs for registered agent ID: {}", currentAgentId); + + // 4. Poll Ctrlplane API for jobs using the JobAgent + List> pendingJobs = jobAgent.getNextJobs(); + + if (pendingJobs == null || pendingJobs.isEmpty()) { // Check for null explicitly + LOGGER.debug("No pending Ctrlplane jobs found or failed to fetch. Finished cycle."); + return; + } + + LOGGER.info("Polled Ctrlplane API. Found {} job(s) to process.", pendingJobs.size()); + + // 5. Process pending jobs + int triggeredCount = 0; + int skippedCount = 0; + int errorCount = 0; + + for (Map ctrlplaneJobMap : pendingJobs) { + // Extract job ID - assume it's a String field named 'id' + Object idObj = ctrlplaneJobMap.get("id"); + if (!(idObj instanceof String ctrlplaneJobId)) { + LOGGER.warn("Skipping job: Missing or invalid 'id' field. Job Data: {}", ctrlplaneJobMap); + skippedCount++; + continue; + } + UUID ctrlplaneJobUUID; // Need UUID for status updates later + try { + ctrlplaneJobUUID = UUID.fromString(ctrlplaneJobId); + } catch (IllegalArgumentException e) { + LOGGER.warn("Skipping job: Invalid UUID format for job ID '{}'.", ctrlplaneJobId); + skippedCount++; + continue; + } + + // Get job agent config - assume it's a Map field named 'jobAgentConfig' + Object configObj = ctrlplaneJobMap.get("jobAgentConfig"); + if (!(configObj instanceof Map)) { + LOGGER.warn("Skipping job ID {}: Missing or invalid 'jobAgentConfig' field.", ctrlplaneJobId); + skippedCount++; + continue; + } + @SuppressWarnings("unchecked") // Safe due to instanceof check + Map jobConfig = (Map) configObj; + + // Get Jenkins job name from the config map + Object jenkinsJobNameObj = jobConfig.get("jenkinsJobName"); + if (!(jenkinsJobNameObj instanceof String jenkinsJobName) || jenkinsJobName.isBlank()) { + LOGGER.warn("Skipping job ID {}: Missing or blank 'jenkinsJobName' in jobAgentConfig.", ctrlplaneJobId); + skippedCount++; + continue; + } + + try { + // Skip already triggered jobs + if (triggeredJobIds.containsKey(ctrlplaneJobId)) { + LOGGER.debug("Skipping already triggered Ctrlplane job ID: {}", ctrlplaneJobId); + skippedCount++; + continue; + } + + LOGGER.info("Processing new Ctrlplane job ID: {} -> Jenkins Job: '{}'", ctrlplaneJobId, jenkinsJobName); + + // Attempt to update status to RUNNING before triggering + // Optional: provide details like Jenkins build URL placeholder if known + if (!jobAgent.updateJobStatus( + ctrlplaneJobUUID, "RUNNING", Collections.singletonMap("trigger", "JenkinsPoller"))) { + LOGGER.warn( + "Failed to update Ctrlplane job status to RUNNING for ID: {}. Proceeding with trigger attempt anyway.", + ctrlplaneJobId); + // Decide if this is a fatal error for this job + } + + hudson.model.Job jenkinsItem = + Jenkins.get().getItemByFullName(jenkinsJobName, hudson.model.Job.class); + + if (jenkinsItem == null) { + LOGGER.warn("Jenkins job '{}' for Ctrlplane job ID {} not found.", jenkinsJobName, ctrlplaneJobId); + // Update status to FAILED + jobAgent.updateJobStatus( + ctrlplaneJobUUID, + "FAILED", + Collections.singletonMap("reason", "Jenkins job not found: " + jenkinsJobName)); + errorCount++; + continue; + } + + // Check if the item can be parameterized + if (!(jenkinsItem instanceof ParameterizedJobMixIn.ParameterizedJob jenkinsJob)) { + LOGGER.warn( + "Jenkins job '{}' for Ctrlplane job ID {} is not a Parameterized job.", + jenkinsJobName, + ctrlplaneJobId); + // Update status to FAILED + jobAgent.updateJobStatus( + ctrlplaneJobUUID, + "FAILED", + Collections.singletonMap("reason", "Jenkins job not parameterizable: " + jenkinsJobName)); + errorCount++; + continue; + } + + // Prepare parameters + StringParameterValue jobIdParam = new StringParameterValue("CTRLPLANE_JOB_ID", ctrlplaneJobId); + // Potentially add other parameters from jobConfig if needed + ParametersAction paramsAction = new ParametersAction(jobIdParam); + + // Trigger the Jenkins build + Object future = jenkinsJob.scheduleBuild2(0, paramsAction); + + if (future != null) { + LOGGER.info( + "Successfully scheduled Jenkins job '{}' for Ctrlplane job ID {}", + jenkinsJobName, + ctrlplaneJobId); + triggeredJobIds.put(ctrlplaneJobId, Boolean.TRUE); + triggeredCount++; + // Status already updated to RUNNING above + } else { + LOGGER.error( + "Failed to schedule Jenkins job '{}' for Ctrlplane job ID {}", + jenkinsJobName, + ctrlplaneJobId); + // Update status to FAILED + jobAgent.updateJobStatus( + ctrlplaneJobUUID, + "FAILED", + Collections.singletonMap("reason", "Jenkins scheduleBuild2 returned null")); + errorCount++; + } + } catch (Exception e) { + LOGGER.error("Error processing Ctrlplane job ID {}: {}", ctrlplaneJobId, e.getMessage(), e); + // Attempt to update status to FAILED + jobAgent.updateJobStatus( + ctrlplaneJobUUID, + "FAILED", + Collections.singletonMap("reason", "Exception during processing: " + e.getMessage())); + errorCount++; + } + } + + LOGGER.info( + "Ctrlplane job polling cycle finished. Triggered: {}, Skipped: {}, Errors: {}", + triggeredCount, + skippedCount, + errorCount); + } + + /** + * Factory method for creating the JobAgent. Can be overridden for testing. + */ + protected JobAgent createJobAgent(String apiUrl, String apiKey, String agentName, String agentWorkspaceId) { + return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); + } +} diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java new file mode 100644 index 0000000..5a2850d --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java @@ -0,0 +1,474 @@ +package io.jenkins.plugins.ctrlplane.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import jenkins.model.Jenkins; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Main agent class that manages agent registration, job polling, and status updates via Ctrlplane API. + * Consolidates HTTP client logic using java.net.http.HttpClient. + */ +public class JobAgent { + private static final Logger LOGGER = LoggerFactory.getLogger(JobAgent.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); // Shared Jackson mapper + + // Use Java 11+ HttpClient + private static final HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) // Or HTTP_2 if server supports + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private final String apiUrl; + private final String apiKey; + private final String name; + private final String agentWorkspaceId; // Added + + private final AtomicReference agentIdRef = new AtomicReference<>(null); // Store agent ID directly + + /** + * Creates a new JobAgent. + * + * @param apiUrl the API URL + * @param apiKey the API key + * @param name the agent name + * @param agentWorkspaceId the workspace ID this agent belongs to + */ + public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspaceId) { + this.apiUrl = apiUrl; + this.apiKey = apiKey; + this.name = name; + this.agentWorkspaceId = agentWorkspaceId; + } + + /** + * Ensures the agent is registered with Ctrlplane. + * This will only register the agent once per instance lifecycle unless registration fails. + * + * @return true if the agent is considered registered (ID is present), false otherwise + */ + public boolean ensureRegistered() { + if (agentIdRef.get() != null) { + return true; // Already registered in this session + } + + // Use PATCH /v1/job-agents/name for upsert + String path = "/v1/job-agents/name"; + // Map config = createAgentConfig(); // Config not sent in this request + Map requestBody = new HashMap<>(); + requestBody.put("name", this.name); + requestBody.put("type", "jenkins"); // Add agent type + // Workspace ID is required according to the Go spec + if (this.agentWorkspaceId != null && !this.agentWorkspaceId.isBlank()) { + requestBody.put("workspaceId", this.agentWorkspaceId); + } else { + LOGGER.error("Cannot register agent: Workspace ID is missing."); + return false; // Workspace ID is required + } + // Config map is not part of this payload + // requestBody.put("config", config); + + // Make the PATCH request, parse the response to get the agent ID + AgentResponse agentResponse = makeHttpRequest("PATCH", path, requestBody, AgentResponse.class); + + if (agentResponse != null && agentResponse.getId() != null) { + String agentId = agentResponse.getId(); + agentIdRef.set(agentId); // Set the ID directly from the response + LOGGER.info("Agent upsert via PATCH {} succeeded. Agent ID: {}", path, agentId); + return true; + } else { + // Log error based on whether response was null or ID was null + if (agentResponse == null) { + LOGGER.error( + "Failed to upsert agent {} via PATCH {}. Request failed or returned unexpected response.", + this.name, + path); + } else { // agentResponse != null but agentResponse.getId() == null + LOGGER.error( + "Failed to upsert agent {} via PATCH {}. Response did not contain an agent ID.", + this.name, + path); + } + return false; + } + } + + /** + * Creates the agent configuration for registration. + * + * @return the agent configuration + */ + private Map createAgentConfig() { + Map config = new HashMap<>(); + // Temporarily hardcoded to exec-windows to make sure it is wokring. + config.put("type", "exec-windows"); + // Consider adding plugin version + // config.put("version", "..."); + + try { + Jenkins jenkins = Jenkins.get(); + String rootUrl = jenkins.getRootUrl(); + if (rootUrl != null) { + config.put("jenkinsUrl", rootUrl); + } + // Jenkins.VERSION is usually available + config.put("jenkinsVersion", Jenkins.VERSION); + } catch (Exception e) { // Catch broader exceptions for robustness + LOGGER.warn("Could not gather Jenkins instance information for agent config", e); + } + + // Add system/environment info if desired + // config.put("os", System.getProperty("os.name")); + // config.put("arch", System.getProperty("os.arch")); + + return config; + } + + /** + * Gets the agent ID as a string if the agent is registered. + * + * @return the agent ID as a string, or null if not registered + */ + public String getAgentIdString() { + return agentIdRef.get(); + } + + /** + * Gets the next jobs for this agent. + * + * @return a list of jobs (represented as Maps), empty if none are available or if the agent is not registered + */ + public List> getNextJobs() { + String agentId = agentIdRef.get(); + if (agentId == null) { + // ensureRegistered will now attempt registration AND set the ID if successful. + if (!ensureRegistered()) { + // ensureRegistered logs the specific error (request failed or no ID in response) + LOGGER.error("Cannot get jobs: Agent registration/upsert failed or did not provide an ID."); + return Collections.emptyList(); + } + // Re-check if agentId was set by ensureRegistered + agentId = agentIdRef.get(); + if (agentId == null) { + // This condition should technically not be reachable if ensureRegistered returned true, + // as true implies the agentIdRef was set. Log an internal error if it happens. + LOGGER.error( + "Internal error: ensureRegistered returned true but agent ID is still null. Cannot get jobs."); + return Collections.emptyList(); + } + } + + // Use the correct endpoint from the API spec: /v1/job-agents/{agentId}/queue/next + String path = String.format("/v1/job-agents/%s/queue/next", agentId); + + // The response structure is different - it has a "jobs" property containing the array + Map response = makeHttpRequest("GET", path, null, new TypeReference>() {}); + + if (response != null && response.containsKey("jobs")) { + try { + @SuppressWarnings("unchecked") + List> jobs = (List>) response.get("jobs"); + LOGGER.debug("Successfully fetched {} jobs from Ctrlplane for agent: {}", jobs.size(), agentId); + return jobs; + } catch (ClassCastException e) { + LOGGER.error("Unexpected response format from jobs endpoint: {}", e.getMessage()); + return Collections.emptyList(); + } + } else { + LOGGER.warn("Failed to fetch jobs or no jobs available for agent: {}", agentId); + return Collections.emptyList(); // Return empty list on failure or empty response + } + } + + /** + * Updates the status of a specific job. + * + * @param jobId The UUID of the job to update. + * @param status The new status string (e.g., "RUNNING", "COMPLETED", "FAILED"). + * @param details Optional map containing additional details about the status update. + * @return true if the update was likely successful (e.g., 2xx response), false otherwise. + */ + public boolean updateJobStatus(UUID jobId, String status, Map details) { + if (jobId == null || status == null || status.isBlank()) { + LOGGER.error("Invalid input for updateJobStatus: Job ID and Status are required."); + return false; + } + + String path = String.format("/v1/jobs/%s/status", jobId); // Assuming this endpoint structure + Map requestBody = new HashMap<>(); + requestBody.put("status", status); + if (details != null && !details.isEmpty()) { + requestBody.put("details", details); + } + + // Status updates often return 200 OK or 204 No Content without a body. + // We can check the response code directly. + Integer responseCode = makeHttpRequestAndGetCode("PUT", path, requestBody); // Assuming PUT + + boolean success = responseCode != null && responseCode >= 200 && responseCode < 300; + if (success) { + LOGGER.info("Successfully updated status for job {} to {}", jobId, status); + } else { + LOGGER.error( + "Failed to update status for job {} to {}. Response code: {}", + jobId, + status, + responseCode != null ? responseCode : "N/A"); + } + return success; + } + + // --- Internal HTTP Helper Methods (using java.net.http) --- + + /** + * Makes an HTTP request and parses the JSON response body. + * + * @param method HTTP method (GET, POST, PUT, PATCH, etc.) + * @param path API endpoint path + * @param requestBody Object to serialize as JSON body (null for methods without body) + * @param responseType Class of the expected response object + * @return Deserialized response object, or null on error + */ + private T makeHttpRequest(String method, String path, Object requestBody, Class responseType) { + try { + HttpRequest.Builder requestBuilder = buildRequest(path, method, requestBody); + HttpRequest request = requestBuilder.build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + return handleResponse(response, responseType); + } catch (URISyntaxException | IOException | InterruptedException e) { + LOGGER.error("Error during {} request to {}{}: {}", method, this.apiUrl, path, e.getMessage(), e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); // Restore interrupt status + } + return null; + } + } + + /** + * Overload for handling generic types like List. + * @param responseType TypeReference describing the expected response type + */ + private T makeHttpRequest(String method, String path, Object requestBody, TypeReference responseType) { + try { + HttpRequest.Builder requestBuilder = buildRequest(path, method, requestBody); + HttpRequest request = requestBuilder.build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + return handleResponse(response, responseType); + } catch (URISyntaxException | IOException | InterruptedException e) { + LOGGER.error("Error during {} request to {}{}: {}", method, this.apiUrl, path, e.getMessage(), e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return null; + } + } + + /** + * Makes an HTTP request and returns only the response code. + * @param method HTTP method (e.g., PUT, POST) + * @param path API endpoint path + * @param requestBody Object to serialize as JSON body + * @return HTTP status code, or null on error + */ + private Integer makeHttpRequestAndGetCode(String method, String path, Object requestBody) { + try { + HttpRequest.Builder requestBuilder = buildRequest(path, method, requestBody); + HttpRequest request = requestBuilder.build(); + + // Send request and discard body, just get status code + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + int statusCode = response.statusCode(); + + // Log non-2xx responses slightly differently here if needed, or rely on caller + if (statusCode < 200 || statusCode >= 300) { + LOGGER.warn("HTTP request to {}{} returned non-success status: {}", this.apiUrl, path, statusCode); + } + return statusCode; + + } catch (URISyntaxException | IOException | InterruptedException e) { + LOGGER.error( + "Error during {} request (status check) to {}{}: {}", method, this.apiUrl, path, e.getMessage(), e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return null; + } + } + + // --- URI and Request Building --- + private URI buildUri(String path) throws URISyntaxException, MalformedURLException { + String cleanApiUrl = + this.apiUrl.endsWith("/") ? this.apiUrl.substring(0, this.apiUrl.length() - 1) : this.apiUrl; + String cleanPath = path.startsWith("/") ? path : "/" + path; + + // Ensure /api/v1 structure + String finalUrlString; + if (cleanApiUrl.endsWith("/api")) { + finalUrlString = cleanApiUrl + cleanPath; // Assumes path starts with /v1 + } else { + finalUrlString = cleanApiUrl + "/api" + cleanPath; + } + return new URI(finalUrlString); + } + + private HttpRequest.Builder buildRequest(String path, String method, Object requestBody) + throws URISyntaxException, IOException, MalformedURLException { + + HttpRequest.BodyPublisher bodyPublisher = HttpRequest.BodyPublishers.noBody(); + if (requestBody != null && requiresBody(method)) { + byte[] jsonBytes = objectMapper.writeValueAsBytes(requestBody); + bodyPublisher = HttpRequest.BodyPublishers.ofByteArray(jsonBytes); + } + + return HttpRequest.newBuilder() + .uri(buildUri(path)) + .header("X-API-Key", this.apiKey) + .header("Content-Type", "application/json; utf-8") + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(15)) // Request timeout + .method(method, bodyPublisher); + } + + /** Helper to determine if a method typically sends a body */ + private boolean requiresBody(String method) { + return method.equals("POST") || method.equals("PUT") || method.equals("PATCH"); + } + + // --- Response Handling --- + + private T handleResponse(HttpResponse response, Class responseType) throws IOException { + int statusCode = response.statusCode(); + if (statusCode >= 200 && statusCode < 300) { + try (InputStream is = response.body()) { + if (statusCode == 204 || is == null) { // 204 No Content or null body + // Try creating a default instance if possible and sensible + try { + return responseType.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + LOGGER.debug("Cannot instantiate default for empty response type {}", responseType.getName()); + return null; + } + } + return objectMapper.readValue(is, responseType); + } + } else { + handleErrorResponse(response, statusCode); + return null; + } + } + + private T handleResponse(HttpResponse response, TypeReference responseType) throws IOException { + int statusCode = response.statusCode(); + if (statusCode >= 200 && statusCode < 300) { + try (InputStream is = response.body()) { + if (statusCode == 204 || is == null) { // 204 No Content or null body + return null; // Cannot default instantiate generics easily + } + return objectMapper.readValue(is, responseType); + } + } else { + handleErrorResponse(response, statusCode); + return null; + } + } + + private void handleErrorResponse(HttpResponse response, int statusCode) { + String errorBody = ""; + try (InputStream es = response.body()) { // Body might contain error details even on non-2xx + if (es != null) { + errorBody = new String(es.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException e) { + LOGGER.warn("Could not read error response body: {}", e.getMessage()); + } + + LOGGER.error( + "HTTP Error: {} - URL: {} - Response: {}", + statusCode, + response.uri(), // Use URI from response + errorBody); + } + + // --- Simple Inner Class for Agent Registration Response --- + /** Minimal representation of the Agent registration response. */ + private static class AgentResponse { + private String id; + private String name; + private String workspaceId; + private String type; + private Map config; // JSON object representing config + + public String getId() { + return id; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public void setId(String id) { + this.id = id; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public String getName() { + return name; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public void setName(String name) { + this.name = name; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public String getWorkspaceId() { + return workspaceId; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public void setWorkspaceId(String workspaceId) { + this.workspaceId = workspaceId; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public String getType() { + return type; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public void setType(String type) { + this.type = type; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public Map getConfig() { + return config; + } + + @SuppressWarnings("unused") // Used by Jackson deserialization + public void setConfig(Map config) { + this.config = config; + } + } + + // Job representation is handled via Map for simplicity now. + // If Job structure becomes complex, a JobResponse inner class could be added. +} diff --git a/src/main/java/io/jenkins/plugins/sample/HelloWorldBuilder.java b/src/main/java/io/jenkins/plugins/sample/HelloWorldBuilder.java deleted file mode 100644 index 878ac5c..0000000 --- a/src/main/java/io/jenkins/plugins/sample/HelloWorldBuilder.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.jenkins.plugins.sample; - -import hudson.EnvVars; -import hudson.Extension; -import hudson.FilePath; -import hudson.Launcher; -import hudson.model.AbstractProject; -import hudson.model.Run; -import hudson.model.TaskListener; -import hudson.tasks.BuildStepDescriptor; -import hudson.tasks.Builder; -import hudson.util.FormValidation; -import java.io.IOException; -import javax.servlet.ServletException; -import jenkins.tasks.SimpleBuildStep; -import org.jenkinsci.Symbol; -import org.kohsuke.stapler.DataBoundConstructor; -import org.kohsuke.stapler.DataBoundSetter; -import org.kohsuke.stapler.QueryParameter; - -public class HelloWorldBuilder extends Builder implements SimpleBuildStep { - - private final String name; - private boolean useFrench; - - @DataBoundConstructor - public HelloWorldBuilder(String name) { - this.name = name; - } - - public String getName() { - return name; - } - - public boolean isUseFrench() { - return useFrench; - } - - @DataBoundSetter - public void setUseFrench(boolean useFrench) { - this.useFrench = useFrench; - } - - @Override - public void perform(Run run, FilePath workspace, EnvVars env, Launcher launcher, TaskListener listener) - throws InterruptedException, IOException { - if (useFrench) { - listener.getLogger().println("Bonjour, " + name + "!"); - } else { - listener.getLogger().println("Hello, " + name + "!"); - } - } - - @Symbol("greet") - @Extension - public static final class DescriptorImpl extends BuildStepDescriptor { - - public FormValidation doCheckName(@QueryParameter String value, @QueryParameter boolean useFrench) - throws IOException, ServletException { - if (value.length() == 0) - return FormValidation.error(Messages.HelloWorldBuilder_DescriptorImpl_errors_missingName()); - if (value.length() < 4) - return FormValidation.warning(Messages.HelloWorldBuilder_DescriptorImpl_warnings_tooShort()); - if (!useFrench && value.matches(".*[éáàç].*")) { - return FormValidation.warning(Messages.HelloWorldBuilder_DescriptorImpl_warnings_reallyFrench()); - } - return FormValidation.ok(); - } - - @Override - public boolean isApplicable(Class aClass) { - return true; - } - - @Override - public String getDisplayName() { - return Messages.HelloWorldBuilder_DescriptorImpl_DisplayName(); - } - } -} diff --git a/src/main/resources/index.jelly b/src/main/resources/index.jelly index 35f37a7..0d96f52 100644 --- a/src/main/resources/index.jelly +++ b/src/main/resources/index.jelly @@ -1,4 +1,8 @@
- TODO -
+ This plugin integrates Jenkins with Ctrlplane for triggering and monitoring jobs. +

+ Configuration for this plugin is managed globally. + and find the Ctrlplane Agent Plugin section. +

+ \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly new file mode 100644 index 0000000..11e3f42 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey.html new file mode 100644 index 0000000..c177213 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey.html @@ -0,0 +1,4 @@ +
+

Enter your Ctrlplane API key here. This key is used to authenticate your Jenkins instance with Ctrlplane.

+

You can obtain this key from your Ctrlplane account settings page.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl.html new file mode 100644 index 0000000..73600d5 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl.html @@ -0,0 +1,3 @@ +
+ The URL of the Ctrlplane API. If left blank, the default URL (https://app.ctrlplane.dev) will be used. +
diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html new file mode 100644 index 0000000..1d43f2c --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html @@ -0,0 +1,4 @@ +
+ Select the credentials containing your Ctrlplane API key. This should be stored as a "Secret text" credential type. + This API key is required for the agent to authenticate with Ctrlplane services. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html new file mode 100644 index 0000000..73600d5 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html @@ -0,0 +1,3 @@ +
+ The URL of the Ctrlplane API. If left blank, the default URL (https://app.ctrlplane.dev) will be used. +
diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.jelly b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.jelly deleted file mode 100644 index e97fba0..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.jelly +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.properties deleted file mode 100644 index 7ebd98b..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=Name -French=French -FrenchDescr=Check if we should say hello in French \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_de.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_de.properties deleted file mode 100644 index abaf008..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_de.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=Name -French=Franz\u00F6sisch -FrenchDescr=Markieren f\u00FCr Begr\u00FC\u00DFung auf franz\u00F6sisch \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_es.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_es.properties deleted file mode 100644 index d3306b6..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_es.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=Nombre -French=Franc\u00E9s -FrenchDescr=Compruebe si debemos decir hola en franc\u00E9s \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_fr.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_fr.properties deleted file mode 100644 index 393218b..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_fr.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=Nom -French=Fran\u00e7ais -FrenchDescr=V\u00e9rifie qu'on dit bien hello en fran\u00e7ais diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_it.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_it.properties deleted file mode 100644 index 94fdb64..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_it.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=Nome -French=Francese -FrenchDescr=Mostra il messagio in francese diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_pt_BR.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_pt_BR.properties deleted file mode 100644 index c1aa4da..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_pt_BR.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=Nome -French=Franc\u00EAs -FrenchDescr=Marque se devemos falar ol\u00E1 em franc\u00EAs \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_sv.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_sv.properties deleted file mode 100644 index bbab3b3..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_sv.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=Namn -French=Franska -FrenchDescr=S\u00E4tt om vi ska s\u00E4ga hej p\u00E5 Franska \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_tr.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_tr.properties deleted file mode 100644 index 9651121..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_tr.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=\u0130sim -French=Frans\u0131zca -FrenchDescr=Frans\u0131zca olarak merhaba demeliyim diye sor \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_zh_CN.properties b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_zh_CN.properties deleted file mode 100644 index ea63c18..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/config_zh_CN.properties +++ /dev/null @@ -1,3 +0,0 @@ -Name=\u540d\u5b57 -French=\u6cd5\u8bed -FrenchDescr=\u68c0\u67e5\u6211\u4eec\u662f\u5426\u5e94\u8be5\u7528\u6cd5\u8bed\u6253\u4e2a\u62db\u547c \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name.html b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name.html deleted file mode 100644 index e712210..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name.html +++ /dev/null @@ -1,3 +0,0 @@ -
- Your name. -
diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name_de.html b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name_de.html deleted file mode 100644 index 3521ace..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-name_de.html +++ /dev/null @@ -1,3 +0,0 @@ -
- Geben Sie Ihren Namen ein. -
diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench.html b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench.html deleted file mode 100644 index b4eadd6..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench.html +++ /dev/null @@ -1,3 +0,0 @@ -
- Use French? -
diff --git a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench_de.html b/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench_de.html deleted file mode 100644 index 96b2698..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/HelloWorldBuilder/help-useFrench_de.html +++ /dev/null @@ -1,3 +0,0 @@ -
- Ob die Begrüßung auf französisch angegeben werden soll. -
diff --git a/src/main/resources/io/jenkins/plugins/sample/Messages.properties b/src/main/resources/io/jenkins/plugins/sample/Messages.properties deleted file mode 100644 index eb02a09..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/Messages.properties +++ /dev/null @@ -1,5 +0,0 @@ -HelloWorldBuilder.DescriptorImpl.errors.missingName=Please set a name -HelloWorldBuilder.DescriptorImpl.warnings.tooShort=Isn't the name too short? -HelloWorldBuilder.DescriptorImpl.warnings.reallyFrench=Are you actually French? - -HelloWorldBuilder.DescriptorImpl.DisplayName=Say hello world \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sample/Messages_de.properties b/src/main/resources/io/jenkins/plugins/sample/Messages_de.properties deleted file mode 100644 index 4b85c2d..0000000 --- a/src/main/resources/io/jenkins/plugins/sample/Messages_de.properties +++ /dev/null @@ -1,5 +0,0 @@ -HelloWorldBuilder.DescriptorImpl.errors.missingName=Bitte geben Sie einen Namen an -HelloWorldBuilder.DescriptorImpl.warnings.tooShort=Der Name ist zu kurz. -HelloWorldBuilder.DescriptorImpl.warnings.reallyFrench=Sind Sie wirklich französisch? - -HelloWorldBuilder.DescriptorImpl.DisplayName="Hallo Welt" sagen diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java new file mode 100644 index 0000000..7900d71 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java @@ -0,0 +1,117 @@ +package io.jenkins.plugins.ctrlplane; + +import static org.junit.Assert.*; + +import org.htmlunit.html.HtmlForm; +import org.htmlunit.html.HtmlNumberInput; +import org.htmlunit.html.HtmlTextInput; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsSessionRule; + +public class CtrlplaneGlobalConfigurationTest { + + @Rule + public JenkinsSessionRule sessions = new JenkinsSessionRule(); + + /** + * Tries to exercise enough code paths to catch common mistakes: + *
    + *
  • missing {@code load} + *
  • missing {@code save} + *
  • misnamed or absent getter/setter + *
  • misnamed {@code textbox} + *
  • misnamed {@code number} + *
+ */ + @Test + public void uiAndStorage() throws Throwable { + sessions.then(r -> { + // Initial state checks + assertEquals( + "default API URL is set initially", + CtrlplaneGlobalConfiguration.DEFAULT_API_URL, + CtrlplaneGlobalConfiguration.get().getApiUrl()); + assertNull( + "API key not set initially", + CtrlplaneGlobalConfiguration.get().getApiKey()); + assertEquals( + "Default Agent ID is set initially", + CtrlplaneGlobalConfiguration.DEFAULT_AGENT_ID, + CtrlplaneGlobalConfiguration.get().getAgentId()); + assertNull( + "Agent Workspace ID not set initially", + CtrlplaneGlobalConfiguration.get().getAgentWorkspaceId()); + assertEquals( + "Default Polling Interval is set initially", + CtrlplaneGlobalConfiguration.DEFAULT_POLLING_INTERVAL_SECONDS, + CtrlplaneGlobalConfiguration.get().getPollingIntervalSeconds()); + + // Configure values + HtmlForm config = r.createWebClient().goTo("configure").getFormByName("config"); + + HtmlTextInput apiUrlBox = config.getInputByName("_.apiUrl"); + apiUrlBox.setText("https://api.example.com"); + + HtmlTextInput apiKeyBox = config.getInputByName("_.apiKey"); + apiKeyBox.setText("test-api-key"); + + HtmlTextInput agentIdBox = config.getInputByName("_.agentId"); + agentIdBox.setText("test-agent"); + + HtmlTextInput agentWorkspaceIdBox = config.getInputByName("_.agentWorkspaceId"); + agentWorkspaceIdBox.setText("test-workspace"); + + HtmlNumberInput pollingIntervalBox = config.getInputByName("_.pollingIntervalSeconds"); + pollingIntervalBox.setText("30"); + + r.submit(config); + + // Verify submitted values + assertEquals( + "API URL was updated", + "https://api.example.com", + CtrlplaneGlobalConfiguration.get().getApiUrl()); + assertEquals( + "API key was updated", + "test-api-key", + CtrlplaneGlobalConfiguration.get().getApiKey()); + assertEquals( + "Agent ID was updated", + "test-agent", + CtrlplaneGlobalConfiguration.get().getAgentId()); + assertEquals( + "Agent Workspace ID was updated", + "test-workspace", + CtrlplaneGlobalConfiguration.get().getAgentWorkspaceId()); + assertEquals( + "Polling Interval was updated", + 30, + CtrlplaneGlobalConfiguration.get().getPollingIntervalSeconds()); + }); + + // Verify values persist after restart + sessions.then(r -> { + assertEquals( + "API URL still there after restart", + "https://api.example.com", + CtrlplaneGlobalConfiguration.get().getApiUrl()); + assertEquals( + "API key still there after restart", + "test-api-key", + CtrlplaneGlobalConfiguration.get().getApiKey()); + assertEquals( + "Agent ID still there after restart", + "test-agent", + CtrlplaneGlobalConfiguration.get().getAgentId()); + assertEquals( + "Agent Workspace ID still there after restart", + "test-workspace", + CtrlplaneGlobalConfiguration.get().getAgentWorkspaceId()); + assertEquals( + "Polling Interval still there after restart", + 30, + CtrlplaneGlobalConfiguration.get().getPollingIntervalSeconds()); + }); + } +} diff --git a/src/test/java/io/jenkins/plugins/sample/HelloWorldBuilderTest.java b/src/test/java/io/jenkins/plugins/sample/HelloWorldBuilderTest.java deleted file mode 100644 index 4ef0b63..0000000 --- a/src/test/java/io/jenkins/plugins/sample/HelloWorldBuilderTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package io.jenkins.plugins.sample; - -import hudson.model.FreeStyleBuild; -import hudson.model.FreeStyleProject; -import hudson.model.Label; -import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; -import org.jenkinsci.plugins.workflow.job.WorkflowJob; -import org.jenkinsci.plugins.workflow.job.WorkflowRun; -import org.junit.Rule; -import org.junit.Test; -import org.jvnet.hudson.test.JenkinsRule; - -public class HelloWorldBuilderTest { - - @Rule - public JenkinsRule jenkins = new JenkinsRule(); - - final String name = "Bobby"; - - @Test - public void testConfigRoundtrip() throws Exception { - FreeStyleProject project = jenkins.createFreeStyleProject(); - project.getBuildersList().add(new HelloWorldBuilder(name)); - project = jenkins.configRoundtrip(project); - jenkins.assertEqualDataBoundBeans( - new HelloWorldBuilder(name), project.getBuildersList().get(0)); - } - - @Test - public void testConfigRoundtripFrench() throws Exception { - FreeStyleProject project = jenkins.createFreeStyleProject(); - HelloWorldBuilder builder = new HelloWorldBuilder(name); - builder.setUseFrench(true); - project.getBuildersList().add(builder); - project = jenkins.configRoundtrip(project); - - HelloWorldBuilder lhs = new HelloWorldBuilder(name); - lhs.setUseFrench(true); - jenkins.assertEqualDataBoundBeans(lhs, project.getBuildersList().get(0)); - } - - @Test - public void testBuild() throws Exception { - FreeStyleProject project = jenkins.createFreeStyleProject(); - HelloWorldBuilder builder = new HelloWorldBuilder(name); - project.getBuildersList().add(builder); - - FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); - jenkins.assertLogContains("Hello, " + name, build); - } - - @Test - public void testBuildFrench() throws Exception { - - FreeStyleProject project = jenkins.createFreeStyleProject(); - HelloWorldBuilder builder = new HelloWorldBuilder(name); - builder.setUseFrench(true); - project.getBuildersList().add(builder); - - FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); - jenkins.assertLogContains("Bonjour, " + name, build); - } - - @Test - public void testScriptedPipeline() throws Exception { - String agentLabel = "my-agent"; - jenkins.createOnlineSlave(Label.get(agentLabel)); - WorkflowJob job = jenkins.createProject(WorkflowJob.class, "test-scripted-pipeline"); - String pipelineScript = "node {greet '" + name + "'}"; - job.setDefinition(new CpsFlowDefinition(pipelineScript, true)); - WorkflowRun completedBuild = jenkins.assertBuildStatusSuccess(job.scheduleBuild2(0)); - String expectedString = "Hello, " + name + "!"; - jenkins.assertLogContains(expectedString, completedBuild); - } -} From d878b665ef75dbdfbf8e095d768df95986eb7814 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Sat, 12 Apr 2025 02:08:21 -0500 Subject: [PATCH 02/19] revert spotless config --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index eb6747c..6c95e0a 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ ${jenkins.baseline}.3 ctrlplanedev/jenkins-agent-plugin + false From 76dd2465081220204ac885ecad09014ebda71301 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Sat, 12 Apr 2025 02:10:09 -0500 Subject: [PATCH 03/19] remove idea files --- .gitignore | 3 +++ .idea/.gitignore | 8 -------- .idea/compiler.xml | 18 ------------------ .idea/encodings.xml | 7 ------- .idea/jarRepositories.xml | 25 ------------------------- .idea/misc.xml | 12 ------------ .idea/vcs.xml | 6 ------ 7 files changed, 3 insertions(+), 76 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/encodings.xml delete mode 100644 .idea/jarRepositories.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index 6c9573a..e2439e2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ *.tar.gz *.rar +.idea/ + + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 5041988..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index aa00ffa..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index f91ffff..0000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index f24c79d..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 435d748324869feb1fc5c42a4ea69b00a125ddf2 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk <77289967+zacharyblasczyk@users.noreply.github.com> Date: Sat, 12 Apr 2025 02:16:30 -0500 Subject: [PATCH 04/19] Update src/main/resources/index.jelly Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/main/resources/index.jelly | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/index.jelly b/src/main/resources/index.jelly index 0d96f52..f2caa1a 100644 --- a/src/main/resources/index.jelly +++ b/src/main/resources/index.jelly @@ -2,7 +2,7 @@
This plugin integrates Jenkins with Ctrlplane for triggering and monitoring jobs.

- Configuration for this plugin is managed globally. + Configuration for this plugin is managed globally. Go to Manage Jenkins > System and find the Ctrlplane Agent Plugin section.

\ No newline at end of file From 2a7ee8701e779e0ea57700f3c5b45a94090639af Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 12 Apr 2025 13:39:49 -0400 Subject: [PATCH 05/19] clean up --- .../plugins/ctrlplane/CtrlplaneJobPoller.java | 405 +++++++++++------- .../plugins/ctrlplane/api/JobAgent.java | 29 +- 2 files changed, 267 insertions(+), 167 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java index e5039cd..aa8a3bd 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -53,197 +53,248 @@ public long getRecurrencePeriod() { protected void execute(TaskListener listener) { LOGGER.debug("Starting Ctrlplane job polling cycle."); - // 1. Get Global Configuration - CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); - String apiUrl = config.getApiUrl(); - String apiKey = config.getApiKey(); - String agentName = config.getAgentId(); // Use configured Agent ID as name - String agentWorkspaceId = config.getAgentWorkspaceId(); // Get workspace ID - - // 2. Validate Configuration - if (apiUrl == null || apiUrl.isBlank()) { - LOGGER.warn("Ctrlplane API URL not configured. Skipping polling cycle."); + // Get and validate configuration + CtrlplaneConfig config = getAndValidateConfig(); + if (config == null) { return; } - if (apiKey == null || apiKey.isBlank()) { - LOGGER.warn("Ctrlplane API key not configured. Skipping polling cycle."); + + // Initialize and register agent + if (!initializeAndRegisterAgent(config)) { return; } - if (agentName == null || agentName.isBlank()) { - LOGGER.warn("Ctrlplane Agent ID (Name) not configured. Skipping polling cycle."); + + // Poll for jobs + List> pendingJobs = pollForJobs(); + if (pendingJobs == null || pendingJobs.isEmpty()) { return; } - // Optionally warn if workspace ID is missing, depending on requirements - if (agentWorkspaceId == null || agentWorkspaceId.isBlank()) { - LOGGER.warn("Ctrlplane Agent Workspace ID not configured. Registration might fail or be incomplete."); - // Decide if this is fatal: return; + + // Process jobs + processJobs(pendingJobs); + } + + private CtrlplaneConfig getAndValidateConfig() { + CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); + CtrlplaneConfig ctrlConfig = new CtrlplaneConfig( + config.getApiUrl(), + config.getApiKey(), + config.getAgentId(), + config.getAgentWorkspaceId() + ); + + if (!ctrlConfig.validate()) { + return null; } - // 3. Create or get the JobAgent and ensure it's registered + return ctrlConfig; + } + + private boolean initializeAndRegisterAgent(CtrlplaneConfig config) { if (jobAgent == null) { - // Use configured agentId as the name, add workspace ID - jobAgent = createJobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); - } else { - // TODO: Consider if JobAgent needs updating if config changes (e.g., API key) - // Currently, it reuses the existing instance. + jobAgent = createJobAgent( + config.apiUrl, + config.apiKey, + config.agentName, + config.agentWorkspaceId + ); } if (!jobAgent.ensureRegistered()) { LOGGER.error("Agent registration check failed. Skipping polling cycle."); - return; + return false; } - // Agent ID is now managed internally by JobAgent - String currentAgentId = jobAgent.getAgentIdString(); + String currentAgentId = jobAgent.getAgentId(); if (currentAgentId == null) { LOGGER.error("Agent ID not available after registration attempt. Skipping polling cycle."); - return; + return false; } + LOGGER.debug("Polling jobs for registered agent ID: {}", currentAgentId); + return true; + } - // 4. Poll Ctrlplane API for jobs using the JobAgent + private List> pollForJobs() { List> pendingJobs = jobAgent.getNextJobs(); - if (pendingJobs == null || pendingJobs.isEmpty()) { // Check for null explicitly + if (pendingJobs == null || pendingJobs.isEmpty()) { LOGGER.debug("No pending Ctrlplane jobs found or failed to fetch. Finished cycle."); - return; + return null; } LOGGER.info("Polled Ctrlplane API. Found {} job(s) to process.", pendingJobs.size()); + return pendingJobs; + } - // 5. Process pending jobs - int triggeredCount = 0; - int skippedCount = 0; - int errorCount = 0; - - for (Map ctrlplaneJobMap : pendingJobs) { - // Extract job ID - assume it's a String field named 'id' - Object idObj = ctrlplaneJobMap.get("id"); - if (!(idObj instanceof String ctrlplaneJobId)) { - LOGGER.warn("Skipping job: Missing or invalid 'id' field. Job Data: {}", ctrlplaneJobMap); - skippedCount++; - continue; - } - UUID ctrlplaneJobUUID; // Need UUID for status updates later + private void processJobs(List> pendingJobs) { + JobProcessingStats stats = new JobProcessingStats(); + + for (Map jobMap : pendingJobs) { try { - ctrlplaneJobUUID = UUID.fromString(ctrlplaneJobId); - } catch (IllegalArgumentException e) { - LOGGER.warn("Skipping job: Invalid UUID format for job ID '{}'.", ctrlplaneJobId); - skippedCount++; - continue; + processJob(jobMap, stats); + } catch (Exception e) { + handleJobError(jobMap, e, stats); } + } - // Get job agent config - assume it's a Map field named 'jobAgentConfig' - Object configObj = ctrlplaneJobMap.get("jobAgentConfig"); - if (!(configObj instanceof Map)) { - LOGGER.warn("Skipping job ID {}: Missing or invalid 'jobAgentConfig' field.", ctrlplaneJobId); - skippedCount++; - continue; - } - @SuppressWarnings("unchecked") // Safe due to instanceof check - Map jobConfig = (Map) configObj; - - // Get Jenkins job name from the config map - Object jenkinsJobNameObj = jobConfig.get("jenkinsJobName"); - if (!(jenkinsJobNameObj instanceof String jenkinsJobName) || jenkinsJobName.isBlank()) { - LOGGER.warn("Skipping job ID {}: Missing or blank 'jenkinsJobName' in jobAgentConfig.", ctrlplaneJobId); - skippedCount++; - continue; - } + LOGGER.info( + "Ctrlplane job polling cycle finished. Triggered: {}, Skipped: {}, Errors: {}", + stats.triggered, + stats.skipped, + stats.errors + ); + } - try { - // Skip already triggered jobs - if (triggeredJobIds.containsKey(ctrlplaneJobId)) { - LOGGER.debug("Skipping already triggered Ctrlplane job ID: {}", ctrlplaneJobId); - skippedCount++; - continue; - } + private void processJob(Map jobMap, JobProcessingStats stats) { + // Extract and validate job ID + JobInfo jobInfo = extractJobInfo(jobMap); + if (jobInfo == null) { + stats.skipped++; + return; + } - LOGGER.info("Processing new Ctrlplane job ID: {} -> Jenkins Job: '{}'", ctrlplaneJobId, jenkinsJobName); + // Skip if already triggered + if (triggeredJobIds.containsKey(jobInfo.jobId)) { + LOGGER.debug("Skipping already triggered Ctrlplane job ID: {}", jobInfo.jobId); + stats.skipped++; + return; + } - // Attempt to update status to RUNNING before triggering - // Optional: provide details like Jenkins build URL placeholder if known - if (!jobAgent.updateJobStatus( - ctrlplaneJobUUID, "RUNNING", Collections.singletonMap("trigger", "JenkinsPoller"))) { - LOGGER.warn( - "Failed to update Ctrlplane job status to RUNNING for ID: {}. Proceeding with trigger attempt anyway.", - ctrlplaneJobId); - // Decide if this is a fatal error for this job - } + // Update status to running + updateJobStatus(jobInfo, "RUNNING", "JenkinsPoller"); - hudson.model.Job jenkinsItem = - Jenkins.get().getItemByFullName(jenkinsJobName, hudson.model.Job.class); - - if (jenkinsItem == null) { - LOGGER.warn("Jenkins job '{}' for Ctrlplane job ID {} not found.", jenkinsJobName, ctrlplaneJobId); - // Update status to FAILED - jobAgent.updateJobStatus( - ctrlplaneJobUUID, - "FAILED", - Collections.singletonMap("reason", "Jenkins job not found: " + jenkinsJobName)); - errorCount++; - continue; - } + // Trigger Jenkins job + triggerJenkinsJob(jobInfo, stats); + } - // Check if the item can be parameterized - if (!(jenkinsItem instanceof ParameterizedJobMixIn.ParameterizedJob jenkinsJob)) { - LOGGER.warn( - "Jenkins job '{}' for Ctrlplane job ID {} is not a Parameterized job.", - jenkinsJobName, - ctrlplaneJobId); - // Update status to FAILED - jobAgent.updateJobStatus( - ctrlplaneJobUUID, - "FAILED", - Collections.singletonMap("reason", "Jenkins job not parameterizable: " + jenkinsJobName)); - errorCount++; - continue; - } + private JobInfo extractJobInfo(Map jobMap) { + // Extract job ID + Object idObj = jobMap.get("id"); + if (!(idObj instanceof String jobId)) { + LOGGER.warn("Skipping job: Missing or invalid 'id' field. Job Data: {}", jobMap); + return null; + } - // Prepare parameters - StringParameterValue jobIdParam = new StringParameterValue("CTRLPLANE_JOB_ID", ctrlplaneJobId); - // Potentially add other parameters from jobConfig if needed - ParametersAction paramsAction = new ParametersAction(jobIdParam); - - // Trigger the Jenkins build - Object future = jenkinsJob.scheduleBuild2(0, paramsAction); - - if (future != null) { - LOGGER.info( - "Successfully scheduled Jenkins job '{}' for Ctrlplane job ID {}", - jenkinsJobName, - ctrlplaneJobId); - triggeredJobIds.put(ctrlplaneJobId, Boolean.TRUE); - triggeredCount++; - // Status already updated to RUNNING above - } else { - LOGGER.error( - "Failed to schedule Jenkins job '{}' for Ctrlplane job ID {}", - jenkinsJobName, - ctrlplaneJobId); - // Update status to FAILED - jobAgent.updateJobStatus( - ctrlplaneJobUUID, - "FAILED", - Collections.singletonMap("reason", "Jenkins scheduleBuild2 returned null")); - errorCount++; - } - } catch (Exception e) { - LOGGER.error("Error processing Ctrlplane job ID {}: {}", ctrlplaneJobId, e.getMessage(), e); - // Attempt to update status to FAILED - jobAgent.updateJobStatus( - ctrlplaneJobUUID, - "FAILED", - Collections.singletonMap("reason", "Exception during processing: " + e.getMessage())); - errorCount++; - } + // Parse UUID + UUID jobUUID; + try { + jobUUID = UUID.fromString(jobId); + } catch (IllegalArgumentException e) { + LOGGER.warn("Skipping job: Invalid UUID format for job ID '{}'.", jobId); + return null; + } + + // Get job config + Object configObj = jobMap.get("jobAgentConfig"); + if (!(configObj instanceof Map)) { + LOGGER.warn("Skipping job ID {}: Missing or invalid 'jobAgentConfig' field.", jobId); + return null; + } + + @SuppressWarnings("unchecked") + Map jobConfig = (Map) configObj; + + // Get Jenkins job name + Object jenkinsJobNameObj = jobConfig.get("jenkinsJobName"); + if (!(jenkinsJobNameObj instanceof String jenkinsJobName) || jenkinsJobName.isBlank()) { + LOGGER.warn("Skipping job ID {}: Missing or blank 'jenkinsJobName' in jobAgentConfig.", jobId); + return null; } + return new JobInfo(jobId, jobUUID, jenkinsJobName); + } + + private void triggerJenkinsJob(JobInfo jobInfo, JobProcessingStats stats) { + hudson.model.Job jenkinsItem = Jenkins.get().getItemByFullName(jobInfo.jenkinsJobName, hudson.model.Job.class); + + if (jenkinsItem == null) { + handleMissingJenkinsJob(jobInfo); + stats.errors++; + return; + } + + if (!(jenkinsItem instanceof ParameterizedJobMixIn.ParameterizedJob jenkinsJob)) { + handleNonParameterizedJob(jobInfo); + stats.errors++; + return; + } + + // Trigger build + StringParameterValue jobIdParam = new StringParameterValue("CTRLPLANE_JOB_ID", jobInfo.jobId); + ParametersAction paramsAction = new ParametersAction(jobIdParam); + + Object future = jenkinsJob.scheduleBuild2(0, paramsAction); + if (future != null) { + handleSuccessfulTrigger(jobInfo); + triggeredJobIds.put(jobInfo.jobId, Boolean.TRUE); + stats.triggered++; + } else { + handleFailedTrigger(jobInfo); + stats.errors++; + } + } + + private void handleJobError(Map jobMap, Exception e, JobProcessingStats stats) { + String jobId = jobMap.get("id") instanceof String ? (String)jobMap.get("id") : "unknown"; + LOGGER.error("Error processing Ctrlplane job ID {}: {}", jobId, e.getMessage(), e); + + try { + UUID jobUUID = UUID.fromString(jobId); + jobAgent.updateJobStatus( + jobUUID, + "FAILED", + Collections.singletonMap("reason", "Exception during processing: " + e.getMessage()) + ); + } catch (Exception ex) { + LOGGER.error("Failed to update error status for job {}", jobId, ex); + } + + stats.errors++; + } + + private void updateJobStatus(JobInfo jobInfo, String status, String trigger) { + if (!jobAgent.updateJobStatus( + jobInfo.jobUUID, + status, + Collections.singletonMap("trigger", trigger))) { + LOGGER.warn( + "Failed to update Ctrlplane job status to {} for ID: {}", + status, + jobInfo.jobId + ); + } + } + + private void handleMissingJenkinsJob(JobInfo jobInfo) { + LOGGER.warn("Jenkins job '{}' for Ctrlplane job ID {} not found.", jobInfo.jenkinsJobName, jobInfo.jobId); + updateJobStatus(jobInfo, "FAILED", "Jenkins job not found: " + jobInfo.jenkinsJobName); + } + + private void handleNonParameterizedJob(JobInfo jobInfo) { + LOGGER.warn( + "Jenkins job '{}' for Ctrlplane job ID {} is not a Parameterized job.", + jobInfo.jenkinsJobName, + jobInfo.jobId + ); + updateJobStatus(jobInfo, "FAILED", "Jenkins job not parameterizable: " + jobInfo.jenkinsJobName); + } + + private void handleSuccessfulTrigger(JobInfo jobInfo) { LOGGER.info( - "Ctrlplane job polling cycle finished. Triggered: {}, Skipped: {}, Errors: {}", - triggeredCount, - skippedCount, - errorCount); + "Successfully scheduled Jenkins job '{}' for Ctrlplane job ID {}", + jobInfo.jenkinsJobName, + jobInfo.jobId + ); + } + + private void handleFailedTrigger(JobInfo jobInfo) { + LOGGER.error( + "Failed to schedule Jenkins job '{}' for Ctrlplane job ID {}", + jobInfo.jenkinsJobName, + jobInfo.jobId + ); + updateJobStatus(jobInfo, "FAILED", "Jenkins scheduleBuild2 returned null"); } /** @@ -252,4 +303,54 @@ protected void execute(TaskListener listener) { protected JobAgent createJobAgent(String apiUrl, String apiKey, String agentName, String agentWorkspaceId) { return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); } + + private static class CtrlplaneConfig { + final String apiUrl; + final String apiKey; + final String agentName; + final String agentWorkspaceId; + + CtrlplaneConfig(String apiUrl, String apiKey, String agentName, String agentWorkspaceId) { + this.apiUrl = apiUrl; + this.apiKey = apiKey; + this.agentName = agentName; + this.agentWorkspaceId = agentWorkspaceId; + } + + boolean validate() { + String[] requiredConfigs = {apiUrl, apiKey, agentName}; + String[] configNames = {"API URL", "API key", "Agent ID (Name)"}; + + for (int i = 0; i < requiredConfigs.length; i++) { + if (requiredConfigs[i] == null || requiredConfigs[i].isBlank()) { + LOGGER.warn("Ctrlplane {} not configured. Skipping polling cycle.", configNames[i]); + return false; + } + } + + if (agentWorkspaceId == null || agentWorkspaceId.isBlank()) { + LOGGER.warn("Ctrlplane Agent Workspace ID not configured. Registration might fail or be incomplete."); + } + + return true; + } + } + + private static class JobInfo { + final String jobId; + final UUID jobUUID; + final String jenkinsJobName; + + JobInfo(String jobId, UUID jobUUID, String jenkinsJobName) { + this.jobId = jobId; + this.jobUUID = jobUUID; + this.jenkinsJobName = jenkinsJobName; + } + } + + private static class JobProcessingStats { + int triggered = 0; + int skipped = 0; + int errors = 0; + } } diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java index 5a2850d..7610d47 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java @@ -93,21 +93,20 @@ public boolean ensureRegistered() { agentIdRef.set(agentId); // Set the ID directly from the response LOGGER.info("Agent upsert via PATCH {} succeeded. Agent ID: {}", path, agentId); return true; - } else { - // Log error based on whether response was null or ID was null - if (agentResponse == null) { - LOGGER.error( - "Failed to upsert agent {} via PATCH {}. Request failed or returned unexpected response.", - this.name, - path); - } else { // agentResponse != null but agentResponse.getId() == null - LOGGER.error( - "Failed to upsert agent {} via PATCH {}. Response did not contain an agent ID.", - this.name, - path); - } - return false; } + // Log error based on whether response was null or ID was null + if (agentResponse == null) { + LOGGER.error( + "Failed to upsert agent {} via PATCH {}. Request failed or returned unexpected response.", + this.name, + path); + } else { // agentResponse != null but agentResponse.getId() == null + LOGGER.error( + "Failed to upsert agent {} via PATCH {}. Response did not contain an agent ID.", + this.name, + path); + } + return false; } /** @@ -146,7 +145,7 @@ private Map createAgentConfig() { * * @return the agent ID as a string, or null if not registered */ - public String getAgentIdString() { + public String getAgentId() { return agentIdRef.get(); } From e2d86d66a2e2f0b254e72707a8bab351bff998dc Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 12 Apr 2025 13:43:42 -0400 Subject: [PATCH 06/19] clean up --- .../jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java index aa8a3bd..97130cc 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -147,36 +147,29 @@ private void processJobs(List> pendingJobs) { } private void processJob(Map jobMap, JobProcessingStats stats) { - // Extract and validate job ID JobInfo jobInfo = extractJobInfo(jobMap); if (jobInfo == null) { stats.skipped++; return; } - // Skip if already triggered if (triggeredJobIds.containsKey(jobInfo.jobId)) { LOGGER.debug("Skipping already triggered Ctrlplane job ID: {}", jobInfo.jobId); stats.skipped++; return; } - // Update status to running updateJobStatus(jobInfo, "RUNNING", "JenkinsPoller"); - - // Trigger Jenkins job triggerJenkinsJob(jobInfo, stats); } private JobInfo extractJobInfo(Map jobMap) { - // Extract job ID Object idObj = jobMap.get("id"); if (!(idObj instanceof String jobId)) { LOGGER.warn("Skipping job: Missing or invalid 'id' field. Job Data: {}", jobMap); return null; } - // Parse UUID UUID jobUUID; try { jobUUID = UUID.fromString(jobId); @@ -220,8 +213,7 @@ private void triggerJenkinsJob(JobInfo jobInfo, JobProcessingStats stats) { return; } - // Trigger build - StringParameterValue jobIdParam = new StringParameterValue("CTRLPLANE_JOB_ID", jobInfo.jobId); + StringParameterValue jobIdParam = new StringParameterValue("JOB_ID", jobInfo.jobId); ParametersAction paramsAction = new ParametersAction(jobIdParam); Object future = jenkinsJob.scheduleBuild2(0, paramsAction); From 4cd10b40cd64b10088d8bf5345658ece1bc17fb2 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Sun, 13 Apr 2025 14:47:38 -0500 Subject: [PATCH 07/19] Jenkins job working with plugin --- example.Jenkinsfile | 39 +- openapi.v1.json | 4074 +++++++++++++++++ pom.xml | 19 +- .../CtrlplaneJobCompletionListener.java | 145 + .../plugins/ctrlplane/CtrlplaneJobPoller.java | 671 ++- .../plugins/ctrlplane/api/JobAgent.java | 86 +- .../ctrlplane/steps/CtrlplaneGetJobStep.java | 173 + .../ctrlplane/CtrlplaneJobPollerMockTest.java | 161 + .../ctrlplane/CtrlplaneJobPollerTest.java | 93 + src/utils/CtrlplaneClient.groovy | 2 +- 10 files changed, 5306 insertions(+), 157 deletions(-) create mode 100644 openapi.v1.json create mode 100644 src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java create mode 100644 src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java create mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerMockTest.java create mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java diff --git a/example.Jenkinsfile b/example.Jenkinsfile index b5561b2..61fa11f 100644 --- a/example.Jenkinsfile +++ b/example.Jenkinsfile @@ -1,31 +1,38 @@ +import groovy.json.JsonOutput + pipeline { agent any parameters { - string(name: 'JOB_ID', defaultValue: '', description: 'Ctrlplane Job ID') - string(name: 'API_URL', defaultValue: 'https://api.example.com', description: 'API Base URL (optional)') + string(name: 'JOB_ID', defaultValue: '', description: 'Ctrlplane Job ID passed by the plugin') } stages { - stage('Deploy') { + stage('Fetch Ctrlplane Job Details') { steps { script { if (!params.JOB_ID) { error 'JOB_ID parameter is required' } - - def ctrlplane = load 'src/utils/CtrlplaneClient.groovy' - def job = ctrlplane.getJob( - params.JOB_ID, - params.API_URL, - // params.API_KEY - ) - - if (!job) { - error "Failed to fetch data for job ${params.JOB_ID}" - } - - echo "Job status: ${job.id}" + echo "Fetching details for Job ID: ${params.JOB_ID}" + + def jobDetails = ctrlplaneGetJob jobId: params.JOB_ID + + echo "-----------------------------------------" + echo "Successfully fetched job details:" + echo JsonOutput.prettyPrint(JsonOutput.toJson(jobDetails)) + echo "-----------------------------------------" + + // Example: Access specific fields from the returned map + // if(jobDetails.variables) { + // echo "Specific Variable: ${jobDetails.variables.your_variable_name}" + // } + // if(jobDetails.metadata) { + // echo "Metadata Value: ${jobDetails.metadata.your_metadata_key}" + // } + // if(jobDetails.job_config) { + // echo "Job Config: ${jobDetails.job_config.jobUrl}" + // } } } } diff --git a/openapi.v1.json b/openapi.v1.json new file mode 100644 index 0000000..b0ef229 --- /dev/null +++ b/openapi.v1.json @@ -0,0 +1,4074 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Cloud Regions Geo API", + "description": "API to get geographic data for cloud provider regions", + "version": "1.0.0" + }, + "paths": { + "/api/v1/cloud-locations/{provider}": { + "get": { + "summary": "Get all regions for a specific cloud provider", + "description": "Returns geographic data for all regions of a specific cloud provider", + "operationId": "getCloudProviderRegions", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "aws", + "gcp", + "azure" + ] + }, + "description": "Cloud provider (aws, gcp, azure)" + } + ], + "responses": { + "200": { + "description": "Successfully returned geographic data for cloud provider regions", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/CloudRegionGeoData" + } + } + } + } + }, + "404": { + "description": "Cloud provider not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Cloud provider 'unknown' not found" + } + } + } + } + } + } + } + } + }, + "/v1/deployment-version-channels": { + "post": { + "summary": "Create a deployment version channel", + "operationId": "createDeploymentVersionChannel", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "deploymentId", + "name", + "versionSelector" + ], + "properties": { + "deploymentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "versionSelector": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Deployment version channel created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "versionSelector": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id", + "deploymentId", + "name", + "createdAt" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Deployment version channel already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "error", + "id" + ] + } + } + } + }, + "500": { + "description": "Failed to create deployment version channel", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/v1/deployment-versions/{deploymentVersionId}": { + "patch": { + "summary": "Updates a deployment version", + "operationId": "updateDeploymentVersion", + "parameters": [ + { + "name": "deploymentVersionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The deployment version ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "ready", + "building", + "failed" + ] + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentVersion" + } + } + } + } + } + } + }, + "/v1/deployment-versions": { + "post": { + "summary": "Upserts a deployment version", + "operationId": "upsertDeploymentVersion", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "ready", + "building", + "failed" + ] + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "tag", + "deploymentId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentVersion" + } + } + } + }, + "409": { + "description": "Deployment version already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/deployments/{deploymentId}/deployment-version-channels/name/{name}": { + "delete": { + "summary": "Delete a deployment version channel", + "operationId": "deleteDeploymentVersionChannel", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deployment version channel deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Deployment version channel not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Failed to delete deployment version channel", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/deployments/{deploymentId}": { + "get": { + "summary": "Get a deployment", + "operationId": "getDeployment", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Deployment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deployment" + } + } + } + }, + "404": { + "description": "Deployment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "delete": { + "summary": "Delete a deployment", + "operationId": "deleteDeployment", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Deployment deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deployment" + } + } + } + }, + "404": { + "description": "Deployment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Failed to delete deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "patch": { + "summary": "Update a deployment", + "operationId": "updateDeployment", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeployment" + } + } + } + }, + "responses": { + "200": { + "description": "Deployment updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deployment" + } + } + } + }, + "404": { + "description": "Deployment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Failed to update deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/deployments/{deploymentId}/release-channels/name/{name}": { + "delete": { + "summary": "Delete a release channel", + "operationId": "deleteReleaseChannel", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Release channel deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Release channel not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Failed to delete release channel", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/deployments": { + "post": { + "summary": "Create a deployment", + "operationId": "createDeployment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "systemId": { + "type": "string", + "format": "uuid", + "description": "The ID of the system to create the deployment for", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "name": { + "type": "string", + "description": "The name of the deployment", + "example": "My Deployment" + }, + "slug": { + "type": "string", + "description": "The slug of the deployment", + "example": "my-deployment" + }, + "description": { + "type": "string", + "description": "The description of the deployment", + "example": "This is a deployment for my system" + }, + "jobAgentId": { + "type": "string", + "format": "uuid", + "description": "The ID of the job agent to use for the deployment", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "jobAgentConfig": { + "type": "object", + "description": "The configuration for the job agent", + "example": { + "key": "value" + } + }, + "retryCount": { + "type": "number", + "description": "The number of times to retry the deployment", + "example": 3 + }, + "timeout": { + "type": "number", + "description": "The timeout for the deployment", + "example": 60 + }, + "resourceSelector": { + "type": "object", + "description": "The resource selector for the deployment", + "example": { + "key": "value" + } + } + }, + "required": [ + "systemId", + "slug", + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Deployment created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deployment" + } + } + } + }, + "409": { + "description": "Deployment already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "error", + "id" + ] + } + } + } + }, + "500": { + "description": "Failed to create deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/environments/{environmentId}": { + "get": { + "summary": "Get an environment", + "operationId": "getEnvironment", + "parameters": [ + { + "name": "environmentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the environment" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Environment" + } + } + } + }, + "404": { + "description": "Environment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Environment not found" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "delete": { + "summary": "Delete an environment", + "operationId": "deleteEnvironment", + "parameters": [ + { + "name": "environmentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the environment" + } + ], + "responses": { + "200": { + "description": "Environment deleted successfully" + } + } + } + }, + "/v1/environments": { + "post": { + "summary": "Create an environment", + "operationId": "createEnvironment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "systemId", + "name" + ], + "properties": { + "directory": { + "type": "string", + "description": "The directory path of the environment", + "example": "my/env/path", + "default": "" + }, + "systemId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "resourceSelector": { + "type": "object", + "additionalProperties": true + }, + "policyId": { + "type": "string" + }, + "releaseChannels": { + "type": "array", + "items": { + "type": "string" + } + }, + "deploymentVersionChannels": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Environment created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Environment" + } + } + } + }, + "409": { + "description": "Environment already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Failed to create environment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/job-agents/{agentId}/jobs/running": { + "get": { + "summary": "Get a agents running jobs", + "operationId": "getAgentRunningJobs", + "parameters": [ + { + "name": "agentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The execution ID" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/components/schemas/Job" + } + } + } + } + } + } + } + }, + "/v1/job-agents/{agentId}/queue/acknowledge": { + "post": { + "summary": "Acknowledge a job for an agent", + "operationId": "acknowledgeAgentJob", + "description": "Marks a job as acknowledged by the agent", + "parameters": [ + { + "name": "agentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the job agent" + } + ], + "responses": { + "200": { + "description": "Successfully acknowledged job", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "job": { + "$ref": "#/components/schemas/Job" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Workspace not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/job-agents/{agentId}/queue/next": { + "get": { + "summary": "Get the next jobs", + "operationId": "getNextJobs", + "parameters": [ + { + "name": "agentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The agent ID" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/components/schemas/Job" + } + } + } + } + } + } + } + } + } + }, + "/v1/job-agents/name": { + "patch": { + "summary": "Upserts the agent", + "operationId": "upsertJobAgent", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "name", + "workspaceId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successfully retrieved or created the agent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workspaceId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "workspaceId" + ] + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v1/jobs/{jobId}/acknowledge": { + "post": { + "summary": "Acknowledge a job", + "operationId": "acknowledgeJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The job ID" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sucess": { + "type": "boolean" + } + }, + "required": [ + "sucess" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/jobs/{jobId}": { + "get": { + "summary": "Get a Job", + "operationId": "getJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The job ID" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobWithTrigger" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Job not found." + } + } + } + } + } + } + } + }, + "patch": { + "summary": "Update a job", + "operationId": "updateJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The execution ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/JobStatus", + "nullable": true + }, + "message": { + "type": "string", + "nullable": true + }, + "externalId": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + } + } + } + }, + "/v1/relationship/job-to-resource": { + "post": { + "summary": "Create a relationship between a job and a resource", + "operationId": "createJobToResourceRelationship", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier of the job", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "resourceIdentifier": { + "type": "string", + "description": "Unique identifier of the resource", + "maxLength": 255, + "example": "resource-123" + } + }, + "required": [ + "jobId", + "resourceIdentifier" + ], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Relationship created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Relationship created successfully" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid jobId format" + } + } + } + } + } + }, + "404": { + "description": "Job or resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Job with specified ID not found" + } + } + } + } + } + }, + "409": { + "description": "Relationship already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Relationship between job and resource already exists" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Internal server error occurred" + } + } + } + } + } + } + } + } + }, + "/v1/relationship/resource-to-resource": { + "post": { + "summary": "Create a relationship between two resources", + "operationId": "createResourceToResourceRelationship", + "tags": [ + "Resource Relationships" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "format": "uuid", + "description": "The workspace ID", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "fromIdentifier": { + "type": "string", + "description": "The identifier of the resource to connect", + "example": "my-resource" + }, + "toIdentifier": { + "type": "string", + "description": "The identifier of the resource to connect to", + "example": "my-resource" + }, + "type": { + "type": "string", + "description": "The type of relationship", + "example": "depends_on" + } + }, + "required": [ + "workspaceId", + "fromIdentifier", + "toIdentifier", + "type" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Relationship created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Relationship created successfully" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "409": { + "description": "Relationship already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/release-channels": { + "post": { + "summary": "Create a release channel", + "operationId": "createReleaseChannel", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "deploymentId", + "name", + "releaseSelector" + ], + "properties": { + "deploymentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "releaseSelector": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Release channel created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "releaseSelector": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id", + "deploymentId", + "name", + "createdAt" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Release channel already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "error", + "id" + ] + } + } + } + }, + "500": { + "description": "Failed to create release channel", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/v1/releases/{releaseId}": { + "patch": { + "summary": "Updates a release", + "operationId": "updateRelease", + "parameters": [ + { + "name": "releaseId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The release ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "ready", + "building", + "failed" + ] + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Release" + } + } + } + } + } + } + }, + "/v1/releases": { + "post": { + "summary": "Upserts a release", + "operationId": "upsertRelease", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "ready", + "building", + "failed" + ] + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "version", + "deploymentId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Release" + } + } + } + }, + "409": { + "description": "Release already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/resource-providers/{providerId}/set": { + "patch": { + "summary": "Sets the resource for a provider.", + "operationId": "setResourceProvidersResources", + "parameters": [ + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the scanner" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "resources" + ], + "properties": { + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identifier": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "identifier", + "name", + "version", + "kind", + "config", + "metadata" + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated the deployment resources" + }, + "400": { + "description": "Invalid request" + }, + "404": { + "description": "Deployment resources not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v1/resources/{resourceId}": { + "get": { + "summary": "Get a resource", + "operationId": "getResource", + "parameters": [ + { + "name": "resourceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The resource ID" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "version": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "lockedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "provider": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "variable": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "kind", + "identifier", + "version", + "config", + "workspaceId", + "updatedAt", + "metadata", + "variable" + ] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Resource not found" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "patch": { + "summary": "Update a resource", + "operationId": "updateResource", + "parameters": [ + { + "name": "resourceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "variables": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Variable" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Resource updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "version": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "kind", + "identifier", + "version", + "config", + "workspaceId", + "metadata" + ] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "delete": { + "summary": "Delete a resource", + "operationId": "deleteResource", + "parameters": [ + { + "name": "resourceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The resource ID" + } + ], + "responses": { + "200": { + "description": "Resource deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/resources": { + "post": { + "summary": "Create or update multiple resources", + "operationId": "upsertResources", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "workspaceId", + "resources" + ], + "properties": { + "workspaceId": { + "type": "string", + "format": "uuid" + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "kind", + "identifier", + "version", + "config" + ], + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "version": { + "type": "string" + }, + "config": { + "type": "object" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "variables": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Variable" + } + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "All of the cats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "number" + } + } + } + } + } + } + } + } + }, + "/v1/systems/{systemId}/environments/{name}": { + "delete": { + "summary": "Delete an environment", + "operationId": "deleteEnvironmentByName", + "parameters": [ + { + "name": "systemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the system" + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Name of the environment" + } + ], + "responses": { + "200": { + "description": "Environment deleted successfully" + } + } + } + }, + "/v1/systems/{systemId}": { + "get": { + "summary": "Get a system", + "operationId": "getSystem", + "parameters": [ + { + "name": "systemId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "UUID of the system" + } + ], + "responses": { + "200": { + "description": "System retrieved successfully", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/System" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Environment" + } + }, + "deployments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Deployment" + } + } + } + } + ] + } + } + } + } + } + }, + "patch": { + "summary": "Update a system", + "operationId": "updateSystem", + "parameters": [ + { + "name": "systemId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "UUID of the system" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the system" + }, + "slug": { + "type": "string", + "description": "Slug of the system" + }, + "description": { + "type": "string", + "description": "Description of the system" + }, + "workspaceId": { + "type": "string", + "format": "uuid", + "description": "UUID of the workspace" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "System updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/System" + } + } + } + }, + "404": { + "description": "System not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "System not found" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Internal server error" + } + } + } + } + } + } + } + }, + "delete": { + "summary": "Delete a system", + "operationId": "deleteSystem", + "parameters": [ + { + "name": "systemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "System deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "System deleted" + } + } + } + } + } + }, + "404": { + "description": "System not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "System not found" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Internal server error" + } + } + } + } + } + } + } + } + }, + "/v1/systems": { + "post": { + "summary": "Create a system", + "operationId": "createSystem", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "format": "uuid", + "description": "The workspace ID of the system" + }, + "name": { + "type": "string", + "description": "The name of the system" + }, + "slug": { + "type": "string", + "description": "The slug of the system" + }, + "description": { + "type": "string", + "description": "The description of the system" + } + }, + "required": [ + "workspaceId", + "name", + "slug" + ] + } + } + } + }, + "responses": { + "201": { + "description": "System created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/System" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_type", + "invalid_literal", + "custom" + ] + }, + "message": { + "type": "string" + }, + "path": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "required": [ + "code", + "message", + "path" + ] + } + } + } + }, + "examples": { + "validation-error": { + "value": { + "error": [ + { + "code": "invalid_type", + "message": "Invalid input", + "path": [ + "name" + ] + } + ] + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Internal Server Error" + } + } + } + } + } + } + } + } + }, + "/v1/workspaces/{workspaceId}/deployments": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the workspace" + } + ], + "get": { + "summary": "List all deployments", + "operationId": "listDeployments", + "responses": { + "200": { + "description": "All deployments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Deployment" + } + } + } + } + } + } + } + } + } + }, + "/v1/workspaces/{workspaceId}/environments": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the workspace" + } + ], + "get": { + "summary": "List all environments", + "operationId": "listEnvironments", + "responses": { + "200": { + "description": "All environments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Environment" + } + } + } + } + } + } + } + } + } + }, + "/v1/workspaces/{workspaceId}": { + "get": { + "summary": "Get a workspace", + "operationId": "getWorkspace", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "The workspace ID", + "example": "123e4567-e89b-12d3-a456-426614174000" + } + } + ], + "responses": { + "200": { + "description": "Workspace found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "404": { + "description": "Workspace not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/workspaces/{workspaceId}/resource-providers/name/{name}": { + "get": { + "summary": "Upserts a resource provider.", + "operationId": "upsertResourceProvider", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Name of the workspace" + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Name of the resource provider" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved or created the resource provider", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workspaceId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "workspaceId" + ] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Workspace not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v1/workspaces/{workspaceId}/resources/{filter}": { + "get": { + "summary": "Get resources by filter", + "operationId": "getResourcesByFilter", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the workspace" + }, + { + "name": "filter", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Filter to apply to the resources" + } + ], + "responses": { + "200": { + "description": "Resources", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Resource" + } + } + } + } + }, + "400": { + "description": "Invalid selector", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/workspaces/{workspaceId}/resources/identifier/{identifier}": { + "get": { + "summary": "Get a resource by identifier", + "operationId": "getResourceByIdentifier", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the workspace" + }, + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Identifier of the resource" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved the resource", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "providerId": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workspaceId": { + "type": "string" + } + } + }, + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "id", + "identifier", + "workspaceId", + "providerId" + ] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Resource not found" + } + } + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "summary": "Delete a resource by identifier", + "operationId": "deleteResourceByIdentifier", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the workspace" + }, + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Identifier of the resource" + } + ], + "responses": { + "200": { + "description": "Successfully deleted the resource", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Resource not found" + } + } + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v1/workspaces/{workspaceId}/resources": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the workspace" + } + ], + "get": { + "summary": "List all resources", + "operationId": "listResources", + "responses": { + "200": { + "description": "All resources", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Resource" + } + } + } + } + } + } + } + } + } + }, + "/v1/workspaces/{workspaceId}/systems": { + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the workspace" + } + ], + "get": { + "summary": "List all systems", + "operationId": "listSystems", + "responses": { + "200": { + "description": "All systems", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/System" + } + } + } + } + } + } + } + } + } + }, + "/v1/workspaces/slug/{workspaceSlug}": { + "get": { + "summary": "Get a workspace by slug", + "operationId": "getWorkspaceBySlug", + "parameters": [ + { + "name": "workspaceSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "my-workspace" + } + } + ], + "responses": { + "200": { + "description": "Workspace found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "404": { + "description": "Workspace not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CloudRegionGeoData": { + "type": "object", + "required": [ + "timezone", + "latitude", + "longitude" + ], + "properties": { + "timezone": { + "type": "string", + "description": "Timezone of the region in UTC format", + "example": "UTC+1" + }, + "latitude": { + "type": "number", + "format": "float", + "description": "Latitude coordinate for the region", + "example": 50.1109 + }, + "longitude": { + "type": "number", + "format": "float", + "description": "Longitude coordinate for the region", + "example": 8.6821 + } + } + }, + "JobWithTrigger": { + "allOf": [ + { + "$ref": "#/components/schemas/Job" + }, + { + "type": "object", + "properties": { + "release": { + "$ref": "#/components/schemas/Release" + }, + "deploymentVersion": { + "$ref": "#/components/schemas/DeploymentVersion" + }, + "deployment": { + "$ref": "#/components/schemas/Deployment" + }, + "runbook": { + "$ref": "#/components/schemas/Runbook" + }, + "resource": { + "$ref": "#/components/schemas/Resource" + }, + "environment": { + "$ref": "#/components/schemas/Environment" + }, + "variables": { + "type": "object" + }, + "approval": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected" + ] + }, + "approver": { + "type": "object", + "nullable": true, + "description": "Null when status is pending, contains approver details when approved or rejected", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "required": [ + "id", + "status" + ] + } + }, + "required": [ + "variables" + ] + } + ] + }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The workspace ID" + }, + "name": { + "type": "string", + "description": "The name of the workspace" + }, + "slug": { + "type": "string", + "description": "The slug of the workspace" + }, + "googleServiceAccountEmail": { + "type": "string", + "description": "The email of the Google service account attached to the workspace", + "example": "ctrlplane@ctrlplane-workspace.iam.gserviceaccount.com", + "nullable": true + }, + "awsRoleArn": { + "type": "string", + "description": "The ARN of the AWS role attached to the workspace", + "example": "arn:aws:iam::123456789012:role/ctrlplane-workspace-role", + "nullable": true + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "System": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The system ID" + }, + "workspaceId": { + "type": "string", + "format": "uuid", + "description": "The workspace ID of the system" + }, + "name": { + "type": "string", + "description": "The name of the system" + }, + "slug": { + "type": "string", + "description": "The slug of the system" + }, + "description": { + "type": "string", + "description": "The description of the system" + } + }, + "required": [ + "id", + "workspaceId", + "name", + "slug" + ] + }, + "Deployment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "type": "string" + }, + "systemId": { + "type": "string", + "format": "uuid" + }, + "jobAgentId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "retryCount": { + "type": "integer" + }, + "timeout": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "systemId", + "jobAgentConfig" + ] + }, + "UpdateDeployment": { + "type": "object", + "description": "Schema for updating a deployment (all fields optional)", + "allOf": [ + { + "$ref": "#/components/schemas/Deployment" + }, + { + "type": "object", + "additionalProperties": true + } + ], + "required": [ + "id" + ], + "additionalProperties": true + }, + "Release": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "deploymentId": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id", + "name", + "version", + "config", + "deploymentId", + "createdAt", + "jobAgentConfig" + ] + }, + "DeploymentVersion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "deploymentId": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id", + "name", + "tag", + "config", + "deploymentId", + "createdAt", + "jobAgentConfig" + ] + }, + "Policy": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The policy ID" + }, + "systemId": { + "type": "string", + "format": "uuid", + "description": "The system ID" + }, + "name": { + "type": "string", + "description": "The name of the policy" + }, + "description": { + "type": "string", + "nullable": true, + "description": "The description of the policy" + }, + "approvalRequirement": { + "type": "string", + "enum": [ + "manual", + "automatic" + ], + "description": "The approval requirement of the policy" + }, + "successType": { + "type": "string", + "enum": [ + "some", + "all", + "optional" + ], + "description": "If a policy depends on an environment, whether or not the policy requires all, some, or optional successful releases in the environment" + }, + "successMinimum": { + "type": "number", + "description": "If a policy depends on an environment, the minimum number of successful releases in the environment" + }, + "concurrencyLimit": { + "type": "number", + "nullable": true, + "description": "The maximum number of concurrent releases in the environment" + }, + "rolloutDuration": { + "type": "number", + "description": "The duration of the rollout in milliseconds" + }, + "minimumReleaseInterval": { + "type": "number", + "description": "The minimum interval between releases in milliseconds" + }, + "releaseSequencing": { + "type": "string", + "enum": [ + "wait", + "cancel" + ], + "description": "If a new release is created, whether it will wait for the current release to finish before starting, or cancel the current release" + } + }, + "required": [ + "id", + "systemId", + "name", + "approvalRequirement", + "successType", + "successMinimum", + "rolloutDuration", + "minimumReleaseInterval", + "releaseSequencing" + ] + }, + "Environment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "systemId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "policyId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "resourceSelector": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "directory": { + "type": "string", + "description": "The directory path of the environment", + "example": "my/env/path", + "default": "" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "policy": { + "$ref": "#/components/schemas/Policy", + "nullable": true + } + }, + "required": [ + "id", + "systemId", + "name", + "createdAt", + "directory" + ] + }, + "Runbook": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "systemId": { + "type": "string", + "format": "uuid" + }, + "jobAgentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "name", + "systemId", + "jobAgentId" + ] + }, + "Resource": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "workspaceId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "name", + "version", + "kind", + "identifier", + "config", + "workspaceId", + "createdAt", + "updatedAt", + "metadata" + ] + }, + "JobStatus": { + "type": "string", + "enum": [ + "successful", + "cancelled", + "skipped", + "in_progress", + "action_required", + "pending", + "failure", + "invalid_job_agent", + "invalid_integration", + "external_run_not_found" + ] + }, + "Job": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/components/schemas/JobStatus" + }, + "externalId": { + "type": "string", + "nullable": true, + "description": "External job identifier (e.g. GitHub workflow run ID)" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "jobAgentId": { + "type": "string", + "format": "uuid" + }, + "jobAgentConfig": { + "type": "object", + "description": "Configuration for the Job Agent", + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "id", + "status", + "createdAt", + "updatedAt", + "jobAgentConfig" + ] + }, + "Variable": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "sensitive": { + "type": "boolean" + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + } + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 6c95e0a..45c63ba 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.jenkins-ci.plugins plugin - 5.7 + 5.9 @@ -57,11 +57,26 @@ jackson-databind 2.15.3 - + Zachary Blasczyk + zachary@ctrlplane.dev + + + + + + + + + + jsbrooks + + Justin Brooks + justin@ctrlplane.dev + + + + + + + scm:git:https://github.com/${gitHubRepo} scm:git:https://github.com/${gitHubRepo} @@ -76,26 +103,6 @@ 4.11.0 test - From a4fb87a20740dd3de20556b741cebc2beb5a43b4 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 19:57:37 -0500 Subject: [PATCH 11/19] cleanup --- pom.xml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/pom.xml b/pom.xml index 227db2c..5b38db4 100644 --- a/pom.xml +++ b/pom.xml @@ -26,27 +26,13 @@ zacharyblasczyk - Zachary Blasczyk zachary@ctrlplane.dev - - - - - - - jsbrooks - Justin Brooks justin@ctrlplane.dev - - - - - From 34be8ebe9a7dd792cfecc620d3b233effc3c1849 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 19:58:46 -0500 Subject: [PATCH 12/19] java refactor --- .../CtrlplaneGlobalConfiguration.java | 46 +- .../CtrlplaneJobCompletionListener.java | 127 +++-- .../plugins/ctrlplane/CtrlplaneJobPoller.java | 187 ++++---- .../plugins/ctrlplane/api/JobAgent.java | 445 +++++++++--------- 4 files changed, 448 insertions(+), 357 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java index fdc64a1..e86c320 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java @@ -3,7 +3,9 @@ import hudson.Extension; import hudson.ExtensionList; import hudson.util.FormValidation; +import java.util.UUID; import jenkins.model.GlobalConfiguration; +import jenkins.model.Jenkins; import org.apache.commons.lang.StringUtils; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; @@ -13,12 +15,7 @@ */ @Extension public class CtrlplaneGlobalConfiguration extends GlobalConfiguration { - - /** - * Default API URL - */ public static final String DEFAULT_API_URL = "https://app.ctrlplane.dev"; - public static final String DEFAULT_AGENT_ID = "jenkins-agent"; public static final int DEFAULT_POLLING_INTERVAL_SECONDS = 60; @@ -34,13 +31,11 @@ public static CtrlplaneGlobalConfiguration get() { private int pollingIntervalSeconds; public CtrlplaneGlobalConfiguration() { - // When Jenkins is restarted, load any saved configuration from disk. load(); - // Set defaults only if loaded values are null/blank/zero if (StringUtils.isBlank(apiUrl)) { apiUrl = DEFAULT_API_URL; } - if (pollingIntervalSeconds <= 0) { // Set default interval if not loaded or invalid + if (pollingIntervalSeconds <= 0) { pollingIntervalSeconds = DEFAULT_POLLING_INTERVAL_SECONDS; } } @@ -107,7 +102,6 @@ public void setAgentWorkspaceId(String agentWorkspaceId) { /** @return the currently configured polling interval in seconds */ public int getPollingIntervalSeconds() { - // Return default if current value is invalid return pollingIntervalSeconds > 0 ? pollingIntervalSeconds : DEFAULT_POLLING_INTERVAL_SECONDS; } @@ -117,19 +111,24 @@ public int getPollingIntervalSeconds() { */ @DataBoundSetter public void setPollingIntervalSeconds(int pollingIntervalSeconds) { - // Ensure a minimum value (e.g., 10 seconds) to prevent overly frequent polling this.pollingIntervalSeconds = Math.max(10, pollingIntervalSeconds); save(); } public FormValidation doCheckApiUrl(@QueryParameter String value) { + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + return FormValidation.ok(); + } if (StringUtils.isEmpty(value)) { - return FormValidation.warning("API URL is recommended. Defaults to " + DEFAULT_API_URL); + return FormValidation.error("API URL cannot be empty."); } return FormValidation.ok(); } public FormValidation doCheckApiKey(@QueryParameter String value) { + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + return FormValidation.ok(); + } if (StringUtils.isEmpty(value)) { return FormValidation.warning("API Key is required for the agent to poll for jobs."); } @@ -137,24 +136,39 @@ public FormValidation doCheckApiKey(@QueryParameter String value) { } public FormValidation doCheckAgentId(@QueryParameter String value) { + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + return FormValidation.ok(); + } if (StringUtils.isEmpty(value)) { - return FormValidation.warning("Agent ID is required for the agent to identify itself."); + return FormValidation.warning("Agent ID is recommended for easier identification in Ctrlplane."); } return FormValidation.ok(); } public FormValidation doCheckAgentWorkspaceId(@QueryParameter String value) { + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + return FormValidation.ok(); + } if (StringUtils.isEmpty(value)) { return FormValidation.warning("Agent Workspace ID is required for the agent to identify itself."); } - return FormValidation.ok(); + try { + UUID.fromString(value); + return FormValidation.ok(); + } catch (IllegalArgumentException e) { + return FormValidation.error( + "Invalid format: Agent Workspace ID must be a valid UUID (e.g., 123e4567-e89b-12d3-a456-426614174000)."); + } } public FormValidation doCheckPollingIntervalSeconds(@QueryParameter String value) { + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + return FormValidation.ok(); + } + if (StringUtils.isEmpty(value)) { + return FormValidation.error("Polling Interval cannot be empty."); + } try { - if (StringUtils.isEmpty(value)) { - return FormValidation.ok("Using default interval: " + DEFAULT_POLLING_INTERVAL_SECONDS + " seconds."); - } int interval = Integer.parseInt(value); if (interval < 10) { return FormValidation.error("Polling interval must be at least 10 seconds."); diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java index 0cdb3e6..cf63544 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java @@ -12,96 +12,123 @@ import java.util.HashMap; import java.util.Map; import java.util.UUID; +import javax.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Listens for Jenkins job completions and updates the Ctrlplane job status accordingly. + * Listens for Jenkins job completions and updates the corresponding Ctrlplane job status. + * This provides faster status updates but is less robust against Jenkins restarts + * compared to the poller's reconciliation loop. */ @Extension public class CtrlplaneJobCompletionListener extends RunListener> { private static final Logger LOGGER = LoggerFactory.getLogger(CtrlplaneJobCompletionListener.class); + /** + * Called when a Jenkins job completes. + * + * @param run The completed run. + * @param listener The task listener. + */ @Override - public void onCompleted(Run run, TaskListener listener) { - if (run == null) { - LOGGER.warn("Received null Run in onCompleted, skipping"); - return; - } + public void onCompleted(Run run, @Nonnull TaskListener listener) { + LOGGER.debug("onCompleted triggered for run: {}", run.getFullDisplayName()); - // Extract Ctrlplane JOB_ID parameter if present + /** + * Extract Ctrlplane Job ID from parameters + */ String ctrlplaneJobId = extractJobId(run); if (ctrlplaneJobId == null) { - // This job wasn't triggered by Ctrlplane, so we don't need to update anything + LOGGER.debug( + "No Ctrlplane Job ID found for run {}, likely not a Ctrlplane-triggered job.", + run.getFullDisplayName()); return; } + UUID jobUUID; try { - UUID jobUUID = UUID.fromString(ctrlplaneJobId); - - // Get job status based on Jenkins build result - String status = getCtrlplaneStatusFromResult(run); - - // Create API client and update job status - JobAgent jobAgent = createJobAgent(); - if (jobAgent != null) { - Map details = new HashMap<>(); - - // Safely get result string - Result result = run.getResult(); - String resultString = (result != null) ? result.toString() : "UNKNOWN"; - - details.put( - "message", - "Jenkins job " + run.getFullDisplayName() + " completed with result: " + resultString); - details.put("externalId", String.valueOf(run.getNumber())); - - if (jobAgent.updateJobStatus(jobUUID, status, details)) { - LOGGER.info( - "Successfully updated Ctrlplane job {} status to {} after Jenkins job {} completed", - ctrlplaneJobId, - status, - run.getFullDisplayName()); - } else { - LOGGER.error( - "Failed to update Ctrlplane job {} status after Jenkins job {} completed", - ctrlplaneJobId, - run.getFullDisplayName()); - } - } + jobUUID = UUID.fromString(ctrlplaneJobId); } catch (IllegalArgumentException e) { - LOGGER.error("Invalid Ctrlplane job ID format: {}", ctrlplaneJobId, e); - } catch (Exception e) { - LOGGER.error("Error updating Ctrlplane job status for job ID {}: {}", ctrlplaneJobId, e.getMessage(), e); + LOGGER.error("Invalid Ctrlplane job ID format found in parameter: {}", ctrlplaneJobId, e); + return; + } + + /** + * Get job status based on Jenkins build result + */ + String status = getCtrlplaneStatusFromResult(run); + + /** + * Create API client + */ + JobAgent jobAgent = createJobAgent(); + if (jobAgent == null) { + /** + * createJobAgent logs the reason (config missing) + */ + LOGGER.error("Cannot update Ctrlplane status for job {}: JobAgent could not be created.", ctrlplaneJobId); + return; + } + + /** + * Prepare details for the update + */ + Map details = new HashMap<>(); + Result result = run.getResult(); + String resultString = (result != null) ? result.toString() : "UNKNOWN"; + details.put("message", "Jenkins job " + run.getFullDisplayName() + " completed with result: " + resultString); + details.put("externalId", String.valueOf(run.getNumber())); + boolean success = jobAgent.updateJobStatus(jobUUID, status, details); + + if (success) { + LOGGER.info( + "Successfully updated Ctrlplane job {} status to {} via listener after Jenkins job {} completed", + ctrlplaneJobId, + status, + run.getFullDisplayName()); + } else { + LOGGER.error( + "Failed attempt to update Ctrlplane job {} status via listener after Jenkins job {} completed (check JobAgent logs for API errors)", + ctrlplaneJobId, + run.getFullDisplayName()); } } /** * Maps Jenkins build result to Ctrlplane job status. + * + * @param run The Jenkins run. + * @return The corresponding Ctrlplane status string ("successful", "failure", "cancelled", "in_progress"). */ private String getCtrlplaneStatusFromResult(Run run) { if (run == null) { - return "failure"; // Treat null run as failure + return "failure"; } Result result = run.getResult(); if (result == null) { - return "in_progress"; // Should ideally not happen in onCompleted + LOGGER.warn( + "Run {} completed but getResult() returned null. Reporting as failure.", run.getFullDisplayName()); + return "failure"; } String resultString = result.toString(); switch (resultString) { case "SUCCESS": + case "UNSTABLE": // Treat UNSTABLE as successful for Ctrlplane status return "successful"; - case "UNSTABLE": - return "successful"; // Consider unstable builds as successful but with warnings case "FAILURE": return "failure"; case "ABORTED": return "cancelled"; default: - return "failure"; // Default to failure for unknown states + LOGGER.warn( + "Unknown Jenkins result '{}' for run {}, reporting as failure.", + resultString, + run.getFullDisplayName()); + return "failure"; } } @@ -128,11 +155,9 @@ private String extractJobId(Run run) { */ private JobAgent createJobAgent() { CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); - - // Config should never be null as it's a singleton, but let's be defensive String apiUrl = config.getApiUrl(); String apiKey = config.getApiKey(); - String agentName = config.getAgentId(); // This is the agent name/id in the config + String agentName = config.getAgentId(); String agentWorkspaceId = config.getAgentWorkspaceId(); if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java index d8a5be0..fa8a859 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -33,12 +33,9 @@ @Extension public class CtrlplaneJobPoller extends AsyncPeriodicWork { private static final Logger LOGGER = LoggerFactory.getLogger(CtrlplaneJobPoller.class); - private final ConcurrentHashMap activeJenkinsJobs = new ConcurrentHashMap<>(); - protected JobAgent jobAgent; - /** Constructor. */ public CtrlplaneJobPoller() { super("Ctrlplane Job Poller"); } @@ -76,7 +73,6 @@ protected void execute(TaskListener listener) { reconcileInProgressJobs(); - // If Jenkins is shutting down, don't poll for or trigger new jobs if (jenkins.isQuietingDown()) { LOGGER.info("Jenkins is quieting down, skipping polling for new Ctrlplane jobs."); return; @@ -199,93 +195,127 @@ private void reconcileInProgressJobs() { continue; } + boolean removeJob; if (activeJob.jenkinsBuildNumber < 0) { - Queue.Item item = Jenkins.get().getQueue().getItem(activeJob.queueId); - if (item == null) { - LOGGER.debug( - "Queue item {} for Ctrlplane job {} is no longer in queue. Will check for Run later.", - activeJob.queueId, - ctrlplaneJobId); - continue; - } else if (item.getFuture().isCancelled()) { - LOGGER.warn( - "Queue item {} for Ctrlplane job {} was cancelled. Assuming cancelled.", - activeJob.queueId, - ctrlplaneJobId); - updateCtrlplaneJobStatus( - ctrlplaneJobId, - activeJob.ctrlplaneJobUUID, - "cancelled", - Map.of("message", "Jenkins queue item cancelled")); - iterator.remove(); - continue; - } else { - // Check if build exists but skip using result for now - jenkinsJob.getBuildByNumber(jenkinsJob.getNextBuildNumber() - 1); - LOGGER.debug( - "Build for Ctrlplane job {} (Queue ID {}) is still pending or Run not easily available.", - ctrlplaneJobId, - activeJob.queueId); - continue; - } + removeJob = reconcileQueuedJob(activeJob); + } else { + removeJob = reconcileRunningOrCompletedJob(jenkinsJob, activeJob); } - Run run = jenkinsJob.getBuildByNumber(activeJob.jenkinsBuildNumber); - if (run == null) { - LOGGER.warn( - "Jenkins build #{} for job '{}' (Ctrlplane job {}) not found during reconciliation, despite having build number. Assuming failure.", - activeJob.jenkinsBuildNumber, - activeJob.jenkinsJobName, - ctrlplaneJobId); - updateCtrlplaneJobStatus( - ctrlplaneJobId, - activeJob.ctrlplaneJobUUID, - "failure", - Map.of( - "message", - "Jenkins build not found during reconciliation", - "externalId", - String.valueOf(activeJob.jenkinsBuildNumber))); + if (removeJob) { iterator.remove(); - continue; } + } + LOGGER.debug("Finished reconciling job statuses."); + } - if (run.isBuilding()) { - LOGGER.debug( - "Jenkins job '{}' #{} (Ctrlplane job {}) is still running.", - activeJob.jenkinsJobName, - activeJob.jenkinsBuildNumber, - ctrlplaneJobId); - continue; - } + /** + * Reconciles the state of a job that is believed to be in the Jenkins queue. + * + * @param activeJob The job information being tracked. + * @return {@code true} if the job reached a final state (e.g., cancelled) and should be removed from tracking, + * {@code false} otherwise (still queued or state indeterminate). + */ + private boolean reconcileQueuedJob(ActiveJobInfo activeJob) { + Queue.Item item = Jenkins.get().getQueue().getItem(activeJob.queueId); - Result result = run.getResult(); - if (result == null) { - return; - } + if (item == null) { + // Item not in queue anymore. Might have started, might have been deleted. + // The main loop will check for a Run using the build number next time (if onStarted updated it). + // Or if onStarted was missed (e.g., restart), the main loop might find the Run directly. + LOGGER.debug( + "Queue item {} for Ctrlplane job {} is no longer in queue. Will check for Run later.", + activeJob.queueId, + activeJob.ctrlplaneJobUUID); // Log UUID for Ctrlplane context + return false; + } - String finalStatus = "failure"; - if (result.isBetterOrEqualTo(Result.SUCCESS)) { - finalStatus = "successful"; - } + if (item.getFuture().isCancelled()) { + LOGGER.warn( + "Queue item {} for Ctrlplane job {} was cancelled.", activeJob.queueId, activeJob.ctrlplaneJobUUID); + updateCtrlplaneJobStatus( + activeJob.ctrlplaneJobUUID.toString(), // Need String ID here + activeJob.ctrlplaneJobUUID, + "cancelled", + Map.of("message", "Jenkins queue item cancelled")); + return true; + } - if (result.isCompleteBuild() && result == Result.ABORTED) { - finalStatus = "cancelled"; - } + LOGGER.debug( + "Job {} (Queue ID {}) is still pending in the Jenkins queue.", + activeJob.ctrlplaneJobUUID, + activeJob.queueId); + return false; + } - String message = "Jenkins job " + run.getFullDisplayName() + " completed with result: " + result.toString(); - LOGGER.info( - "Reconciling completed Jenkins job '{}' #{} (Ctrlplane job {}). Updating status to {}", + /** + * Reconciles the state of a job that has potentially started running or has completed. + * + * @param jenkinsJob The Jenkins Job object. + * @param activeJob The job information being tracked. + * @return {@code true} if the job reached a final state (completed, failed) and should be removed from tracking, + * {@code false} otherwise (still running or state indeterminate). + */ + private boolean reconcileRunningOrCompletedJob(Job jenkinsJob, ActiveJobInfo activeJob) { + Run run = jenkinsJob.getBuildByNumber(activeJob.jenkinsBuildNumber); + + if (run == null) { + LOGGER.warn( + "Jenkins build #{} for job '{}' (Ctrlplane job {}) not found during reconciliation, despite having build number. Assuming failure.", + activeJob.jenkinsBuildNumber, + activeJob.jenkinsJobName, + activeJob.ctrlplaneJobUUID); + updateCtrlplaneJobStatus( + activeJob.ctrlplaneJobUUID.toString(), // Need String ID here + activeJob.ctrlplaneJobUUID, + "failure", + Map.of( + "message", + "Jenkins build not found during reconciliation", + "externalId", + String.valueOf(activeJob.jenkinsBuildNumber))); + return true; + } + + if (run.isBuilding()) { + LOGGER.debug( + "Jenkins job '{}' #{} (Ctrlplane job {}) is still running.", activeJob.jenkinsJobName, activeJob.jenkinsBuildNumber, - ctrlplaneJobId, - finalStatus); + activeJob.ctrlplaneJobUUID); + return false; + } - Map details = buildCompletionDetails(activeJob, run, message); - updateCtrlplaneJobStatus(ctrlplaneJobId, activeJob.ctrlplaneJobUUID, finalStatus, details); - iterator.remove(); + Result result = run.getResult(); + if (result == null) { + LOGGER.debug( + "Jenkins build #{} for job '{}' (Ctrlplane job {}) is not building but result is null. Checking again next cycle.", + activeJob.jenkinsBuildNumber, + activeJob.jenkinsJobName, + activeJob.ctrlplaneJobUUID); + return false; } - LOGGER.debug("Finished reconciling job statuses."); + + String finalStatus = "failure"; + if (result.isBetterOrEqualTo(Result.SUCCESS)) { + finalStatus = "successful"; + } else if (result == Result.ABORTED) { + finalStatus = "cancelled"; + } + + String message = "Jenkins job " + run.getFullDisplayName() + " completed with result: " + result.toString(); + LOGGER.info( + "Reconciling completed Jenkins job '{}' #{} (Ctrlplane job {}). Updating status to {}", + activeJob.jenkinsJobName, + activeJob.jenkinsBuildNumber, + activeJob.ctrlplaneJobUUID, + finalStatus); + + Map details = buildCompletionDetails(activeJob, run, message); + updateCtrlplaneJobStatus( + activeJob.ctrlplaneJobUUID.toString(), activeJob.ctrlplaneJobUUID, finalStatus, details); + + return true; } /** @@ -333,7 +363,6 @@ private void processSingleJob(Map jobMap, JobProcessingStats sta } triggerJenkinsJob(jobInfo, stats); - updateJobStatusWithInitialLink(jobInfo); } @@ -496,7 +525,6 @@ private void handleJobError(Map jobMap, Exception e, JobProcessi LOGGER.error("Error processing Ctrlplane job ID '{}': {}", initialJobIdGuess, e.getMessage(), e); stats.errors++; - // Attempt to get a valid jobId and jobUUID to update status Object idObj = jobMap.get("id"); if (!(idObj instanceof String)) { LOGGER.error("Cannot update error status: Job ID is missing or not a String in the job map."); @@ -517,7 +545,6 @@ private void handleJobError(Map jobMap, Exception e, JobProcessi return; // Return early if UUID is invalid } - // If we reached here, jobId and jobUUID are valid, update status updateCtrlplaneJobStatus( jobId, jobUUID, "failure", Map.of("message", "Exception during processing: " + e.getMessage())); } diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java index 0ea7f07..3aed3ff 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; -import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; @@ -18,7 +17,6 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; -import jenkins.model.Jenkins; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,20 +26,19 @@ */ public class JobAgent { private static final Logger LOGGER = LoggerFactory.getLogger(JobAgent.class); - private static final ObjectMapper objectMapper = new ObjectMapper(); // Shared Jackson mapper + private static final ObjectMapper objectMapper = new ObjectMapper(); - // Use Java 11+ HttpClient private static final HttpClient httpClient = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) // Or HTTP_2 if server supports + .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(10)) .build(); private final String apiUrl; private final String apiKey; private final String name; - private final String agentWorkspaceId; // Added + private final String agentWorkspaceId; - private final AtomicReference agentIdRef = new AtomicReference<>(null); // Store agent ID directly + private final AtomicReference agentIdRef = new AtomicReference<>(null); /** * Creates a new JobAgent. @@ -66,35 +63,27 @@ public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspace */ public boolean ensureRegistered() { if (agentIdRef.get() != null) { - return true; // Already registered in this session + return true; } - - // Use PATCH /v1/job-agents/name for upsert String path = "/v1/job-agents/name"; - // Map config = createAgentConfig(); // Config not sent in this request Map requestBody = new HashMap<>(); requestBody.put("name", this.name); - requestBody.put("type", "jenkins"); // Add agent type - // Workspace ID is required according to the Go spec + requestBody.put("type", "jenkins"); if (this.agentWorkspaceId != null && !this.agentWorkspaceId.isBlank()) { requestBody.put("workspaceId", this.agentWorkspaceId); } else { LOGGER.error("Cannot register agent: Workspace ID is missing."); - return false; // Workspace ID is required + return false; } - // Config map is not part of this payload - // requestBody.put("config", config); - // Make the PATCH request, parse the response to get the agent ID AgentResponse agentResponse = makeHttpRequest("PATCH", path, requestBody, AgentResponse.class); if (agentResponse != null && agentResponse.getId() != null) { String agentId = agentResponse.getId(); - agentIdRef.set(agentId); // Set the ID directly from the response + agentIdRef.set(agentId); LOGGER.info("Agent upsert via PATCH {} succeeded. Agent ID: {}", path, agentId); return true; } - // Log error based on whether response was null or ID was null if (agentResponse == null) { LOGGER.error( "Failed to upsert agent {} via PATCH {}. Request failed or returned unexpected response.", @@ -107,37 +96,6 @@ public boolean ensureRegistered() { return false; } - /** - * Creates the agent configuration for registration. - * - * @return the agent configuration - */ - private Map createAgentConfig() { - Map config = new HashMap<>(); - // Temporarily hardcoded to exec-windows to make sure it is wokring. - config.put("type", "exec-windows"); - // Consider adding plugin version - // config.put("version", "..."); - - try { - Jenkins jenkins = Jenkins.get(); - String rootUrl = jenkins.getRootUrl(); - if (rootUrl != null) { - config.put("jenkinsUrl", rootUrl); - } - // Jenkins.VERSION is usually available - config.put("jenkinsVersion", Jenkins.VERSION); - } catch (Exception e) { // Catch broader exceptions for robustness - LOGGER.warn("Could not gather Jenkins instance information for agent config", e); - } - - // Add system/environment info if desired - // config.put("os", System.getProperty("os.name")); - // config.put("arch", System.getProperty("os.arch")); - - return config; - } - /** * Gets the agent ID as a string if the agent is registered. * @@ -148,50 +106,49 @@ public String getAgentId() { } /** - * Gets the next jobs for this agent. + * Gets the next jobs for this agent from the Ctrlplane API. + * Handles agent registration if needed and validates the response format. * - * @return a list of jobs (represented as Maps), empty if none are available or if the agent is not registered + * @return a list of jobs (represented as Maps), empty if none are available, if the agent is not registered, + * or if there was an error communicating with the API */ public List> getNextJobs() { String agentId = agentIdRef.get(); + if (agentId == null) { - // ensureRegistered will now attempt registration AND set the ID if successful. if (!ensureRegistered()) { - // ensureRegistered logs the specific error (request failed or no ID in response) LOGGER.error("Cannot get jobs: Agent registration/upsert failed or did not provide an ID."); return Collections.emptyList(); } - // Re-check if agentId was set by ensureRegistered + agentId = agentIdRef.get(); + if (agentId == null) { - // This condition should technically not be reachable if ensureRegistered returned true, - // as true implies the agentIdRef was set. Log an internal error if it happens. LOGGER.error( "Internal error: ensureRegistered returned true but agent ID is still null. Cannot get jobs."); return Collections.emptyList(); } } - // Use the correct endpoint from the API spec: /v1/job-agents/{agentId}/queue/next String path = String.format("/v1/job-agents/%s/queue/next", agentId); - // The response structure is different - it has a "jobs" property containing the array Map response = makeHttpRequest("GET", path, null, new TypeReference>() {}); - if (response != null && response.containsKey("jobs")) { - try { - @SuppressWarnings("unchecked") - List> jobs = (List>) response.get("jobs"); - LOGGER.debug("Successfully fetched {} jobs from Ctrlplane for agent: {}", jobs.size(), agentId); - return jobs; - } catch (ClassCastException e) { - LOGGER.error("Unexpected response format from jobs endpoint: {}", e.getMessage()); - return Collections.emptyList(); - } - } else { + if (response == null || !response.containsKey("jobs")) { LOGGER.warn("Failed to fetch jobs or no jobs available for agent: {}", agentId); - return Collections.emptyList(); // Return empty list on failure or empty response + return Collections.emptyList(); + } + + Object jobsObj = response.get("jobs"); + if (!(jobsObj instanceof List)) { + LOGGER.error("Unexpected response format from jobs endpoint: 'jobs' is not a List"); + return Collections.emptyList(); } + + @SuppressWarnings("unchecked") + List> jobs = (List>) jobsObj; + LOGGER.debug("Successfully fetched {} jobs from Ctrlplane for agent: {}", jobs.size(), agentId); + return jobs; } /** @@ -209,51 +166,75 @@ public boolean updateJobStatus(UUID jobId, String status, Map de } String path = String.format("/v1/jobs/%s", jobId); - Map requestBody = new HashMap<>(); - requestBody.put("status", status); + Map requestBody = buildJobUpdatePayload(status, details); - // Extract message and externalId from details if present - String message = null; - String externalId = null; + Integer responseCode = makeHttpRequestAndGetCode("PATCH", path, requestBody); - if (details != null && !details.isEmpty()) { - // Check for external ID in details - if (details.containsKey("externalId")) { - externalId = details.get("externalId").toString(); - requestBody.put("externalId", externalId); - } + boolean success = responseCode != null && responseCode >= 200 && responseCode < 300; + logStatusUpdateResult(success, jobId, status, responseCode); + return success; + } - // Extract message if present, otherwise use trigger as message - if (details.containsKey("message")) { - message = details.get("message").toString(); - } else if (details.containsKey("trigger")) { - message = "Triggered by: " + details.get("trigger"); - } else if (details.containsKey("reason")) { - message = details.get("reason").toString(); - } + /** + * Builds the payload for job status updates. + * + * @param status The status to set for the job + * @param details Additional details to include in the update + * @return A map containing the formatted payload for the API request + */ + private Map buildJobUpdatePayload(String status, Map details) { + Map payload = new HashMap<>(); + payload.put("status", status); - if (message != null) { - requestBody.put("message", message); - } + if (details == null || details.isEmpty()) { + return payload; } - // Check for ctrlplane/links metadata - if (details != null && details.containsKey("ctrlplane/links")) { - Object linksObj = details.get("ctrlplane/links"); - if (linksObj instanceof Map) { - // Assuming the value is Map, but API expects Map - // No explicit cast needed as Map is compatible. - requestBody.put("ctrlplane/links", linksObj); - } else { - LOGGER.warn( - "Value for 'ctrlplane/links' in details map is not a Map for job {}. Skipping links.", jobId); + if (details.containsKey("externalId")) { + payload.put("externalId", details.get("externalId").toString()); + } + + String message = extractMessage(details); + if (message != null) { + payload.put("message", message); + } + + if (details.containsKey("ctrlplane/links") && details.get("ctrlplane/links") instanceof Map) { + payload.put("ctrlplane/links", details.get("ctrlplane/links")); + } + + return payload; + } + + /** + * Extracts message from details using priority order. + * + * @param details Map containing potential message sources + * @return The extracted message string, or null if no message source is found + */ + private String extractMessage(Map details) { + String[] messageSources = {"message", "trigger", "reason"}; + + for (String source : messageSources) { + if (details.containsKey(source)) { + return source.equals("trigger") + ? "Triggered by: " + details.get(source) + : details.get(source).toString(); } } - // Use PATCH method according to the API spec - Integer responseCode = makeHttpRequestAndGetCode("PATCH", path, requestBody); + return null; + } - boolean success = responseCode != null && responseCode >= 200 && responseCode < 300; + /** + * Logs the result of a status update. + * + * @param success Whether the update was successful + * @param jobId The UUID of the job that was updated + * @param status The status that was set + * @param responseCode The HTTP response code received + */ + private void logStatusUpdateResult(boolean success, UUID jobId, String status, Integer responseCode) { if (success) { LOGGER.info("Successfully updated status for job {} to {}", jobId, status); } else { @@ -263,14 +244,13 @@ public boolean updateJobStatus(UUID jobId, String status, Map de status, responseCode != null ? responseCode : "N/A"); } - return success; } /** - * Gets the details of a specific job by its UUID. + * Retrieves job details from the Ctrlplane API by job ID. * - * @param jobId The UUID of the job to retrieve. - * @return A map containing the job details, or null if the job is not found or an error occurs. + * @param jobId UUID identifier of the job to fetch + * @return Map containing job data or null if the job cannot be retrieved */ public Map getJob(UUID jobId) { if (jobId == null) { @@ -281,129 +261,157 @@ public Map getJob(UUID jobId) { String path = String.format("/v1/jobs/%s", jobId); LOGGER.debug("Attempting to GET job details from path: {}", path); - // Use the existing makeHttpRequest helper that handles generic Maps - try { - // The response for GET /v1/jobs/{jobId} is the Job object directly (Map) - Map jobData = - makeHttpRequest("GET", path, null, new TypeReference>() {}); - - if (jobData != null) { - LOGGER.info("Successfully retrieved details for job {}", jobId); - return jobData; - } else { - // makeHttpRequest logs errors, but we can add context here - LOGGER.warn("Failed to retrieve details for job {}, makeHttpRequest returned null.", jobId); - return null; - } - } catch (Exception e) { - // Catch any unexpected exceptions during the process - LOGGER.error("Exception occurred while retrieving job details for job {}: {}", jobId, e.getMessage(), e); + Map jobData = makeHttpRequest("GET", path, null, new TypeReference>() {}); + + if (jobData == null) { + LOGGER.warn("Failed to retrieve details for job {}", jobId); return null; } + + LOGGER.info("Successfully retrieved details for job {}", jobId); + return jobData; } // --- Internal HTTP Helper Methods (using java.net.http) --- /** - * Makes an HTTP request and parses the JSON response body. + * Makes an HTTP request and deserializes the JSON response to a specific class. * - * @param method HTTP method (GET, POST, PUT, PATCH, etc.) - * @param path API endpoint path - * @param requestBody Object to serialize as JSON body (null for methods without body) - * @param responseType Class of the expected response object - * @return Deserialized response object, or null on error + * @param method HTTP method (GET, POST, PUT, PATCH, etc.) + * @param path API endpoint path + * @param requestBody Object to serialize as JSON request body (null for methods without body) + * @param responseType Class to deserialize the JSON response into + * @return Deserialized response object, or null if an error occurs */ private T makeHttpRequest(String method, String path, Object requestBody, Class responseType) { - try { - HttpRequest.Builder requestBuilder = buildRequest(path, method, requestBody); - HttpRequest request = requestBuilder.build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + HttpResponse response = executeRequest(method, path, requestBody); + if (response == null) { + return null; + } + try { return handleResponse(response, responseType); - } catch (URISyntaxException | IOException | InterruptedException e) { - LOGGER.error("Error during {} request to {}{}: {}", method, this.apiUrl, path, e.getMessage(), e); - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); // Restore interrupt status - } + } catch (IOException e) { + LOGGER.error("Error processing response from {} {}: {}", method, path, e.getMessage(), e); return null; } } /** - * Overload for handling generic types like List. + * Makes an HTTP request and deserializes the JSON response to a generic type. + * + * @param method HTTP method (GET, POST, PUT, PATCH, etc.) + * @param path API endpoint path + * @param requestBody Object to serialize as JSON request body (null for methods without body) * @param responseType TypeReference describing the expected response type + * @return Deserialized response object, or null if an error occurs */ private T makeHttpRequest(String method, String path, Object requestBody, TypeReference responseType) { - try { - HttpRequest.Builder requestBuilder = buildRequest(path, method, requestBody); - HttpRequest request = requestBuilder.build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + HttpResponse response = executeRequest(method, path, requestBody); + if (response == null) { + return null; + } + try { return handleResponse(response, responseType); - } catch (URISyntaxException | IOException | InterruptedException e) { - LOGGER.error("Error during {} request to {}{}: {}", method, this.apiUrl, path, e.getMessage(), e); - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } + } catch (IOException e) { + LOGGER.error("Error processing response from {} {}: {}", method, path, e.getMessage(), e); return null; } } /** - * Makes an HTTP request and returns only the response code. + * Makes an HTTP request and returns only the response HTTP status code. + * * @param method HTTP method (e.g., PUT, POST) * @param path API endpoint path - * @param requestBody Object to serialize as JSON body - * @return HTTP status code, or null on error + * @param requestBody Object to serialize as JSON request body + * @return HTTP status code, or null if an error occurs */ private Integer makeHttpRequestAndGetCode(String method, String path, Object requestBody) { try { - HttpRequest.Builder requestBuilder = buildRequest(path, method, requestBody); - HttpRequest request = requestBuilder.build(); - - // Send request and discard body, just get status code + HttpRequest request = buildRequest(path, method, requestBody).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); int statusCode = response.statusCode(); - // Log non-2xx responses slightly differently here if needed, or rely on caller if (statusCode < 200 || statusCode >= 300) { LOGGER.warn("HTTP request to {}{} returned non-success status: {}", this.apiUrl, path, statusCode); } + return statusCode; + } catch (Exception e) { + handleRequestException(method, path, e); + return null; + } + } - } catch (URISyntaxException | IOException | InterruptedException e) { - LOGGER.error( - "Error during {} request (status check) to {}{}: {}", method, this.apiUrl, path, e.getMessage(), e); - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } + /** + * Executes an HTTP request and returns the response. + * + * @param method HTTP method to use + * @param path API endpoint path + * @param requestBody Request body to send (may be null) + * @return HTTP response with input stream, or null if request failed + */ + private HttpResponse executeRequest(String method, String path, Object requestBody) { + try { + HttpRequest request = buildRequest(path, method, requestBody).build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (Exception e) { + handleRequestException(method, path, e); return null; } } + /** + * Handles exceptions from HTTP requests in a consistent way. + */ + private void handleRequestException(String method, String path, Exception e) { + LOGGER.error("Error during {} request to {}{}: {}", method, this.apiUrl, path, e.getMessage(), e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + // --- URI and Request Building --- - private URI buildUri(String path) throws URISyntaxException, MalformedURLException { + /** + * Builds a URI by properly combining the API URL with the given path. + * + * @param path API endpoint path to append + * @return Fully formed URI for the API request + * @throws URISyntaxException if the resulting URI is invalid + */ + private URI buildUri(String path) throws URISyntaxException { String cleanApiUrl = this.apiUrl.endsWith("/") ? this.apiUrl.substring(0, this.apiUrl.length() - 1) : this.apiUrl; String cleanPath = path.startsWith("/") ? path : "/" + path; + String fullUrl; - // Ensure /api/v1 structure - String finalUrlString; if (cleanApiUrl.endsWith("/api")) { - finalUrlString = cleanApiUrl + cleanPath; // Assumes path starts with /v1 + fullUrl = cleanApiUrl + cleanPath; } else { - finalUrlString = cleanApiUrl + "/api" + cleanPath; + fullUrl = cleanApiUrl + "/api" + cleanPath; } - return new URI(finalUrlString); + + return new URI(fullUrl); } + /** + * Builds an HTTP request with proper headers and body for the Ctrlplane API. + * + * @param path API endpoint path + * @param method HTTP method to use (GET, POST, etc.) + * @param requestBody Object to serialize as request body (may be null) + * @return Configured HTTP request builder + * @throws URISyntaxException if the URI is invalid + * @throws IOException if request body serialization fails + */ private HttpRequest.Builder buildRequest(String path, String method, Object requestBody) - throws URISyntaxException, IOException, MalformedURLException { + throws URISyntaxException, IOException { HttpRequest.BodyPublisher bodyPublisher = HttpRequest.BodyPublishers.noBody(); - if (requestBody != null && requiresBody(method)) { + + if (requestBody != null && (method.equals("POST") || method.equals("PUT") || method.equals("PATCH"))) { byte[] jsonBytes = objectMapper.writeValueAsBytes(requestBody); bodyPublisher = HttpRequest.BodyPublishers.ofByteArray(jsonBytes); } @@ -413,56 +421,77 @@ private HttpRequest.Builder buildRequest(String path, String method, Object requ .header("X-API-Key", this.apiKey) .header("Content-Type", "application/json; utf-8") .header("Accept", "application/json") - .timeout(Duration.ofSeconds(15)) // Request timeout + .timeout(Duration.ofSeconds(15)) .method(method, bodyPublisher); } - /** Helper to determine if a method typically sends a body */ - private boolean requiresBody(String method) { - return method.equals("POST") || method.equals("PUT") || method.equals("PATCH"); - } - // --- Response Handling --- + /** + * Handles HTTP response by deserializing JSON content to a specified class. + * + * @param response HTTP response containing JSON data + * @param responseType Class to deserialize JSON into + * @return Deserialized object of requested type or null if response isn't successful + * @throws IOException if JSON parsing fails + */ private T handleResponse(HttpResponse response, Class responseType) throws IOException { int statusCode = response.statusCode(); - if (statusCode >= 200 && statusCode < 300) { - try (InputStream is = response.body()) { - if (statusCode == 204 || is == null) { // 204 No Content or null body - // Try creating a default instance if possible and sensible - try { - return responseType.getDeclaredConstructor().newInstance(); - } catch (Exception e) { - LOGGER.debug("Cannot instantiate default for empty response type {}", responseType.getName()); - return null; - } - } - return objectMapper.readValue(is, responseType); - } - } else { + + if (statusCode < 200 || statusCode >= 300) { handleErrorResponse(response, statusCode); return null; } + + try (InputStream is = response.body()) { + if (statusCode == 204 || is == null) { + try { + return responseType.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + LOGGER.debug("Cannot instantiate default for empty response type {}", responseType.getName()); + return null; + } + } + + return objectMapper.readValue(is, responseType); + } } + /** + * Handles HTTP response by deserializing JSON content to a specified generic type. + * + * @param response HTTP response containing JSON data + * @param responseType TypeReference describing the target generic type + * @return Deserialized object of requested type or null if response isn't successful + * @throws IOException if JSON parsing fails + */ private T handleResponse(HttpResponse response, TypeReference responseType) throws IOException { int statusCode = response.statusCode(); - if (statusCode >= 200 && statusCode < 300) { - try (InputStream is = response.body()) { - if (statusCode == 204 || is == null) { // 204 No Content or null body - return null; // Cannot default instantiate generics easily - } - return objectMapper.readValue(is, responseType); - } - } else { + + if (statusCode < 200 || statusCode >= 300) { handleErrorResponse(response, statusCode); return null; } + + try (InputStream is = response.body()) { + if (statusCode == 204 || is == null) { + return null; + } + + return objectMapper.readValue(is, responseType); + } } + /** + * Logs error details from HTTP responses with error status codes. + * + * @param response HTTP response with error status + * @param statusCode HTTP status code + */ private void handleErrorResponse(HttpResponse response, int statusCode) { String errorBody = ""; - try (InputStream es = response.body()) { // Body might contain error details even on non-2xx + + try (InputStream es = response.body()) { if (es != null) { errorBody = new String(es.readAllBytes(), StandardCharsets.UTF_8); } @@ -470,11 +499,7 @@ private void handleErrorResponse(HttpResponse response, int statusC LOGGER.warn("Could not read error response body: {}", e.getMessage()); } - LOGGER.error( - "HTTP Error: {} - URL: {} - Response: {}", - statusCode, - response.uri(), // Use URI from response - errorBody); + LOGGER.error("HTTP Error: {} - URL: {} - Response: {}", statusCode, response.uri(), errorBody); } // --- Simple Inner Class for Agent Registration Response --- From f6bea4d5c769afde75769f3c6cefdee026bcebef Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 20:12:05 -0500 Subject: [PATCH 13/19] remove job url, not needed --- .../plugins/ctrlplane/CtrlplaneJobPoller.java | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java index fa8a859..6bd84e5 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -460,18 +460,8 @@ private void triggerJenkinsJob(JobInfo jobInfo, JobProcessingStats stats) { return; } - CtrlplaneGlobalConfiguration globalConfig = CtrlplaneGlobalConfiguration.get(); - String apiUrl = globalConfig.getApiUrl(); - if (apiUrl == null || apiUrl.isBlank()) { - LOGGER.warn( - "Ctrlplane API URL is not configured globally. Cannot pass API_URL parameter to job {}.", - jobInfo.jobId); - apiUrl = ""; - } - StringParameterValue jobIdParam = new StringParameterValue("JOB_ID", jobInfo.jobId); - StringParameterValue jobApiUrlParam = new StringParameterValue("API_URL", apiUrl); - ParametersAction paramsAction = new ParametersAction(jobIdParam, jobApiUrlParam); + ParametersAction paramsAction = new ParametersAction(jobIdParam); QueueTaskFuture future = jenkinsJob.scheduleBuild2(0, paramsAction); if (future == null) { From 931601de9f7c5ccc89fde48e1d75b5f609bdf970 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 20:12:23 -0500 Subject: [PATCH 14/19] add localization --- src/main/resources/index.jelly | 5 ++--- .../CtrlplaneGlobalConfiguration/config.jelly | 20 +++++++++---------- .../config.properties | 11 ++++++++++ .../config_de.properties | 11 ++++++++++ .../config_es.properties | 11 ++++++++++ .../config_fr.properties | 11 ++++++++++ .../config_it.properties | 11 ++++++++++ .../config_pt_BR.properties | 11 ++++++++++ .../config_sv.properties | 11 ++++++++++ .../config_tr.properties | 11 ++++++++++ .../config_zh_CN.properties | 11 ++++++++++ .../help-apiKey_de.html | 4 ++++ .../help-apiKey_es.html | 4 ++++ .../help-apiKey_fr.html | 4 ++++ .../help-apiKey_it.html | 4 ++++ .../help-apiKey_pt_BR.html | 4 ++++ .../help-apiKey_sv.html | 4 ++++ .../help-apiKey_tr.html | 4 ++++ .../help-apiKey_zh_CN.html | 4 ++++ .../help-apiUrl_de.html | 3 +++ .../help-apiUrl_es.html | 3 +++ .../help-apiUrl_fr.html | 3 +++ .../help-apiUrl_it.html | 3 +++ .../help-apiUrl_pt_BR.html | 3 +++ .../help-apiUrl_sv.html | 3 +++ .../help-apiUrl_tr.html | 3 +++ .../help-apiUrl_zh_CN.html | 3 +++ .../help-credentialsId.html | 4 ---- .../help-label.html | 3 --- .../plugins/ctrlplane/Messages.properties | 2 ++ .../plugins/ctrlplane/Messages_de.properties | 2 ++ .../plugins/ctrlplane/Messages_es.properties | 2 ++ .../plugins/ctrlplane/Messages_fr.properties | 2 ++ .../plugins/ctrlplane/Messages_it.properties | 2 ++ .../ctrlplane/Messages_pt_BR.properties | 2 ++ .../plugins/ctrlplane/Messages_sv.properties | 2 ++ .../plugins/ctrlplane/Messages_tr.properties | 2 ++ .../ctrlplane/Messages_zh_CN.properties | 2 ++ 38 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_de.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_es.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_fr.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_it.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_pt_BR.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_sv.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_tr.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_zh_CN.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_de.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_es.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_fr.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_it.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_pt_BR.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_sv.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_tr.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_zh_CN.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_de.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_es.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_fr.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_it.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_pt_BR.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_sv.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_tr.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_zh_CN.html delete mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html delete mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_de.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_es.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_fr.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_it.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_pt_BR.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_sv.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_tr.properties create mode 100644 src/main/resources/io/jenkins/plugins/ctrlplane/Messages_zh_CN.properties diff --git a/src/main/resources/index.jelly b/src/main/resources/index.jelly index f2caa1a..41fb7af 100644 --- a/src/main/resources/index.jelly +++ b/src/main/resources/index.jelly @@ -1,8 +1,7 @@
- This plugin integrates Jenkins with Ctrlplane for triggering and monitoring jobs. + ${%plugin.description}

- Configuration for this plugin is managed globally. Go to Manage Jenkins > System - and find the Ctrlplane Agent Plugin section. + ${%plugin.configuration}

\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly index 11e3f42..d7c5ac6 100644 --- a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.jelly @@ -2,24 +2,24 @@ - + - + - + - + - + diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.properties new file mode 100644 index 0000000..e7f8c46 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config.properties @@ -0,0 +1,11 @@ +# English translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=API URL +config.apiUrl.description=The URL of the Ctrlplane API (e.g., https://app.ctrlplane.dev) +config.apiKey.title=API Key +config.apiKey.description=The API key for authenticating with Ctrlplane +config.agentId.title=Agent ID +config.agentId.description=The unique identifier for this agent in Ctrlplane +config.agentWorkspaceId.title=Agent Workspace ID +config.agentWorkspaceId.description=The workspace ID for this agent in Ctrlplane +config.pollingIntervalSeconds.title=Polling Interval (seconds) +config.pollingIntervalSeconds.description=How often to check Ctrlplane for new jobs (minimum 10 seconds) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_de.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_de.properties new file mode 100644 index 0000000..5032505 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_de.properties @@ -0,0 +1,11 @@ +# German translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=API-URL +config.apiUrl.description=Die Ctrlplane API-URL (z.B. https://app.ctrlplane.dev) +config.apiKey.title=API-Schlüssel +config.apiKey.description=Der API-Schlüssel zur Authentifizierung bei Ctrlplane +config.agentId.title=Agent-ID +config.agentId.description=Die eindeutige Kennung für diesen Agent in Ctrlplane +config.agentWorkspaceId.title=Arbeitsbereich-ID +config.agentWorkspaceId.description=Die Arbeitsbereich-ID für diesen Agent in Ctrlplane +config.pollingIntervalSeconds.title=Abfrageintervall (Sekunden) +config.pollingIntervalSeconds.description=Häufigkeit der Überprüfung auf neue Aufgaben in Ctrlplane (mindestens 10 Sekunden) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_es.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_es.properties new file mode 100644 index 0000000..f552eab --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_es.properties @@ -0,0 +1,11 @@ +# Spanish translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=URL de API +config.apiUrl.description=La URL de la API de Ctrlplane (ej. https://app.ctrlplane.dev) +config.apiKey.title=Clave de API +config.apiKey.description=La clave de API para autenticarse con Ctrlplane +config.agentId.title=ID de Agente +config.agentId.description=El identificador único para este agente en Ctrlplane +config.agentWorkspaceId.title=ID de Espacio de Trabajo +config.agentWorkspaceId.description=El ID del espacio de trabajo para este agente en Ctrlplane +config.pollingIntervalSeconds.title=Intervalo de Sondeo (segundos) +config.pollingIntervalSeconds.description=Frecuencia de comprobación de nuevas tareas en Ctrlplane (mínimo 10 segundos) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_fr.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_fr.properties new file mode 100644 index 0000000..86f7080 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_fr.properties @@ -0,0 +1,11 @@ +# French translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=URL de l'API +config.apiUrl.description=L'URL de l'API Ctrlplane (ex. https://app.ctrlplane.dev) +config.apiKey.title=Clé API +config.apiKey.description=La clé API pour s'authentifier auprès de Ctrlplane +config.agentId.title=ID de l'Agent +config.agentId.description=L'identifiant unique pour cet agent dans Ctrlplane +config.agentWorkspaceId.title=ID de l'Espace de Travail +config.agentWorkspaceId.description=L'ID de l'espace de travail pour cet agent dans Ctrlplane +config.pollingIntervalSeconds.title=Intervalle de Polling (secondes) +config.pollingIntervalSeconds.description=Fréquence de vérification des nouvelles tâches dans Ctrlplane (minimum 10 secondes) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_it.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_it.properties new file mode 100644 index 0000000..7330b72 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_it.properties @@ -0,0 +1,11 @@ +# Italian translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=URL API +config.apiUrl.description=L'URL API di Ctrlplane (es. https://app.ctrlplane.dev) +config.apiKey.title=Chiave API +config.apiKey.description=La chiave API per autenticarsi con Ctrlplane +config.agentId.title=ID Agente +config.agentId.description=L'identificatore unico per questo agente in Ctrlplane +config.agentWorkspaceId.title=ID Area di Lavoro +config.agentWorkspaceId.description=L'ID dell'area di lavoro per questo agente in Ctrlplane +config.pollingIntervalSeconds.title=Intervallo di Polling (secondi) +config.pollingIntervalSeconds.description=Frequenza di verifica di nuove attività in Ctrlplane (minimo 10 secondi) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_pt_BR.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_pt_BR.properties new file mode 100644 index 0000000..0fcd9cd --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_pt_BR.properties @@ -0,0 +1,11 @@ +# Portuguese (Brazil) translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=URL da API +config.apiUrl.description=A URL da API Ctrlplane (ex. https://app.ctrlplane.dev) +config.apiKey.title=Chave API +config.apiKey.description=A chave de API para autenticação com Ctrlplane +config.agentId.title=ID do Agente +config.agentId.description=O identificador único para este agente no Ctrlplane +config.agentWorkspaceId.title=ID do Espaço de Trabalho +config.agentWorkspaceId.description=O ID do espaço de trabalho para este agente no Ctrlplane +config.pollingIntervalSeconds.title=Intervalo de Verificação (segundos) +config.pollingIntervalSeconds.description=Frequência de verificação de novas tarefas no Ctrlplane (mínimo 10 segundos) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_sv.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_sv.properties new file mode 100644 index 0000000..ad5a938 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_sv.properties @@ -0,0 +1,11 @@ +# Swedish translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=API URL +config.apiUrl.description=URL:en till Ctrlplane API (t.ex. https://app.ctrlplane.dev) +config.apiKey.title=API-nyckel +config.apiKey.description=API-nyckeln för autentisering mot Ctrlplane +config.agentId.title=Agent-ID +config.agentId.description=Den unika identifieraren för denna agent i Ctrlplane +config.agentWorkspaceId.title=Arbetsområdes-ID +config.agentWorkspaceId.description=Arbetsområdets ID för denna agent i Ctrlplane +config.pollingIntervalSeconds.title=Pollningsintervall (sekunder) +config.pollingIntervalSeconds.description=Hur ofta Ctrlplane kontrolleras för nya jobb (minimum 10 sekunder) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_tr.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_tr.properties new file mode 100644 index 0000000..b140293 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_tr.properties @@ -0,0 +1,11 @@ +# Turkish translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=API URL +config.apiUrl.description=Ctrlplane API URL'si (örn. https://app.ctrlplane.dev) +config.apiKey.title=API Anahtarı +config.apiKey.description=Ctrlplane ile kimlik doğrulama için API anahtarı +config.agentId.title=Ajan ID +config.agentId.description=Bu ajanın Ctrlplane'deki benzersiz tanımlayıcısı +config.agentWorkspaceId.title=Ajan Çalışma Alanı ID +config.agentWorkspaceId.description=Bu ajanın Ctrlplane'deki çalışma alanı ID'si +config.pollingIntervalSeconds.title=Yoklama Aralığı (saniye) +config.pollingIntervalSeconds.description=Yeni işler için Ctrlplane'i kontrol etme sıklığı (minimum 10 saniye) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_zh_CN.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_zh_CN.properties new file mode 100644 index 0000000..256c412 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/config_zh_CN.properties @@ -0,0 +1,11 @@ +# Simplified Chinese translations for CtrlplaneGlobalConfiguration +config.apiUrl.title=API 地址 +config.apiUrl.description=Ctrlplane API 的地址(例如:https://app.ctrlplane.dev) +config.apiKey.title=API 密钥 +config.apiKey.description=用于 Ctrlplane 身份验证的 API 密钥 +config.agentId.title=代理 ID +config.agentId.description=此代理在 Ctrlplane 中的唯一标识符 +config.agentWorkspaceId.title=代理工作区 ID +config.agentWorkspaceId.description=此代理在 Ctrlplane 中的工作区 ID +config.pollingIntervalSeconds.title=轮询间隔(秒) +config.pollingIntervalSeconds.description=检查 Ctrlplane 新任务的频率(最小 10 秒) \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_de.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_de.html new file mode 100644 index 0000000..43ba75b --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_de.html @@ -0,0 +1,4 @@ +
+

Geben Sie hier Ihren Ctrlplane API-Schlüssel ein. Dieser Schlüssel wird verwendet, um Ihre Jenkins-Instanz bei Ctrlplane zu authentifizieren.

+

Sie können diesen Schlüssel auf Ihrer Ctrlplane-Kontoeinstellungsseite erhalten.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_es.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_es.html new file mode 100644 index 0000000..578595d --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_es.html @@ -0,0 +1,4 @@ +
+

Introduzca su clave de API de Ctrlplane aquí. Esta clave se utiliza para autenticar su instancia de Jenkins con Ctrlplane.

+

Puede obtener esta clave en la página de configuración de su cuenta de Ctrlplane.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_fr.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_fr.html new file mode 100644 index 0000000..a13814b --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_fr.html @@ -0,0 +1,4 @@ +
+

Entrez votre clé API Ctrlplane ici. Cette clé est utilisée pour authentifier votre instance Jenkins avec Ctrlplane.

+

Vous pouvez obtenir cette clé depuis la page des paramètres de votre compte Ctrlplane.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_it.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_it.html new file mode 100644 index 0000000..ba19d2e --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_it.html @@ -0,0 +1,4 @@ +
+

Inserisci qui la tua chiave API Ctrlplane. Questa chiave viene utilizzata per autenticare la tua istanza Jenkins con Ctrlplane.

+

Puoi ottenere questa chiave dalla pagina delle impostazioni del tuo account Ctrlplane.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_pt_BR.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_pt_BR.html new file mode 100644 index 0000000..8106de9 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_pt_BR.html @@ -0,0 +1,4 @@ +
+

Digite sua chave de API Ctrlplane aqui. Esta chave é usada para autenticar sua instância Jenkins com Ctrlplane.

+

Você pode obter esta chave na página de configurações da sua conta Ctrlplane.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_sv.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_sv.html new file mode 100644 index 0000000..747bc23 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_sv.html @@ -0,0 +1,4 @@ +
+

Ange din Ctrlplane API-nyckel här. Denna nyckel används för att autentisera din Jenkins-instans med Ctrlplane.

+

Du kan få denna nyckel från din Ctrlplane-kontoinställningssida.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_tr.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_tr.html new file mode 100644 index 0000000..7fbd853 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_tr.html @@ -0,0 +1,4 @@ +
+

Ctrlplane API anahtarınızı buraya girin. Bu anahtar, Jenkins örneğinizi Ctrlplane ile doğrulamak için kullanılır.

+

Bu anahtarı Ctrlplane hesap ayarları sayfanızdan alabilirsiniz.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_zh_CN.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_zh_CN.html new file mode 100644 index 0000000..7c4b346 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiKey_zh_CN.html @@ -0,0 +1,4 @@ +
+

在此输入您的 Ctrlplane API 密钥。此密钥用于向 Ctrlplane 验证您的 Jenkins 实例。

+

您可以从 Ctrlplane 账户设置页面获取此密钥。

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_de.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_de.html new file mode 100644 index 0000000..5c7e82c --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_de.html @@ -0,0 +1,3 @@ +
+ Die URL der Ctrlplane API. Wenn leer gelassen, wird die Standard-URL (https://app.ctrlplane.dev) verwendet. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_es.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_es.html new file mode 100644 index 0000000..299439a --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_es.html @@ -0,0 +1,3 @@ +
+ La URL de la API de Ctrlplane. Si se deja en blanco, se utilizará la URL predeterminada (https://app.ctrlplane.dev). +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_fr.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_fr.html new file mode 100644 index 0000000..40e3fef --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_fr.html @@ -0,0 +1,3 @@ +
+ L'URL de l'API Ctrlplane. Si laissé vide, l'URL par défaut (https://app.ctrlplane.dev) sera utilisée. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_it.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_it.html new file mode 100644 index 0000000..6f0e28d --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_it.html @@ -0,0 +1,3 @@ +
+ L'URL dell'API Ctrlplane. Se lasciato vuoto, verrà utilizzato l'URL predefinito (https://app.ctrlplane.dev). +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_pt_BR.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_pt_BR.html new file mode 100644 index 0000000..8ad3606 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_pt_BR.html @@ -0,0 +1,3 @@ +
+ A URL da API Ctrlplane. Se deixado em branco, a URL padrão (https://app.ctrlplane.dev) será usada. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_sv.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_sv.html new file mode 100644 index 0000000..14fd44b --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_sv.html @@ -0,0 +1,3 @@ +
+ URL:en till Ctrlplane API. Om den lämnas tom kommer standard-URL:en (https://app.ctrlplane.dev) att användas. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_tr.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_tr.html new file mode 100644 index 0000000..999f49d --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_tr.html @@ -0,0 +1,3 @@ +
+ Ctrlplane API URL'si. Boş bırakılırsa, varsayılan URL (https://app.ctrlplane.dev) kullanılacaktır. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_zh_CN.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_zh_CN.html new file mode 100644 index 0000000..096bdfa --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-apiUrl_zh_CN.html @@ -0,0 +1,3 @@ +
+ Ctrlplane API 的地址。如果留空,将使用默认地址(https://app.ctrlplane.dev)。 +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html deleted file mode 100644 index 1d43f2c..0000000 --- a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-credentialsId.html +++ /dev/null @@ -1,4 +0,0 @@ -
- Select the credentials containing your Ctrlplane API key. This should be stored as a "Secret text" credential type. - This API key is required for the agent to authenticate with Ctrlplane services. -
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html b/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html deleted file mode 100644 index 73600d5..0000000 --- a/src/main/resources/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration/help-label.html +++ /dev/null @@ -1,3 +0,0 @@ -
- The URL of the Ctrlplane API. If left blank, the default URL (https://app.ctrlplane.dev) will be used. -
diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages.properties new file mode 100644 index 0000000..c5f3fdb --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages.properties @@ -0,0 +1,2 @@ +plugin.description=This plugin integrates Jenkins with Ctrlplane for triggering and monitoring jobs. +plugin.configuration=Configuration for this plugin is managed globally. Go to Manage Jenkins > System and find the Ctrlplane Agent Plugin section. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_de.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_de.properties new file mode 100644 index 0000000..4f53782 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_de.properties @@ -0,0 +1,2 @@ +plugin.description=Dieses Plugin integriert Jenkins mit Ctrlplane zum Auslösen und Überwachen von Jobs. +plugin.configuration=Die Konfiguration für dieses Plugin wird global verwaltet. Gehen Sie zu Jenkins verwalten > System und finden Sie den Abschnitt Ctrlplane Agent Plugin. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_es.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_es.properties new file mode 100644 index 0000000..ec513c6 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_es.properties @@ -0,0 +1,2 @@ +plugin.description=Este plugin integra Jenkins con Ctrlplane para activar y monitorear trabajos. +plugin.configuration=La configuración de este plugin se gestiona globalmente. Vaya a Administrar Jenkins > Sistema y busque la sección Plugin de Agente Ctrlplane. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_fr.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_fr.properties new file mode 100644 index 0000000..bd2c6fe --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_fr.properties @@ -0,0 +1,2 @@ +plugin.description=Ce plugin intègre Jenkins avec Ctrlplane pour déclencher et surveiller les tâches. +plugin.configuration=La configuration de ce plugin est gérée globalement. Allez dans Administrer Jenkins > Système et trouvez la section Plugin Agent Ctrlplane. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_it.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_it.properties new file mode 100644 index 0000000..a0f401f --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_it.properties @@ -0,0 +1,2 @@ +plugin.description=Questo plugin integra Jenkins con Ctrlplane per l'attivazione e il monitoraggio dei lavori. +plugin.configuration=La configurazione di questo plugin è gestita globalmente. Vai su Gestisci Jenkins > Sistema e trova la sezione Plugin Agente Ctrlplane. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_pt_BR.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_pt_BR.properties new file mode 100644 index 0000000..e42621d --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_pt_BR.properties @@ -0,0 +1,2 @@ +plugin.description=Este plugin integra o Jenkins com o Ctrlplane para disparar e monitorar tarefas. +plugin.configuration=A configuração deste plugin é gerenciada globalmente. Vá para Gerenciar Jenkins > Sistema e encontre a seção Plugin do Agente Ctrlplane. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_sv.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_sv.properties new file mode 100644 index 0000000..fade712 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_sv.properties @@ -0,0 +1,2 @@ +plugin.description=Detta plugin integrerar Jenkins med Ctrlplane för att utlösa och övervaka jobb. +plugin.configuration=Konfigurationen för detta plugin hanteras globalt. Gå till Hantera Jenkins > System och hitta avsnittet Ctrlplane Agent Plugin. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_tr.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_tr.properties new file mode 100644 index 0000000..672567f --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_tr.properties @@ -0,0 +1,2 @@ +plugin.description=Bu eklenti, işleri tetiklemek ve izlemek için Jenkins'i Ctrlplane ile entegre eder. +plugin.configuration=Bu eklenti için yapılandırma genel olarak yönetilir. Jenkins'i Yönet > Sistem'e gidin ve Ctrlplane Ajan Eklentisi bölümünü bulun. \ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_zh_CN.properties b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_zh_CN.properties new file mode 100644 index 0000000..7465e22 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/ctrlplane/Messages_zh_CN.properties @@ -0,0 +1,2 @@ +plugin.description=此插件将 Jenkins 与 Ctrlplane 集成,用于触发和监控作业。 +plugin.configuration=此插件的配置在全局管理。转到 管理 Jenkins > 系统 并找到 Ctrlplane 代理插件 部分。 \ No newline at end of file From bac5f38d12331e5e032f0cec46517fc16038ed21 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 20:56:32 -0500 Subject: [PATCH 15/19] add pollingIntervalSeconds --- .../CtrlplaneGlobalConfiguration.java | 57 +++++++++---- .../CtrlplaneJobCompletionListener.java | 10 ++- .../plugins/ctrlplane/CtrlplaneJobPoller.java | 84 +++++++++++++++++-- .../plugins/ctrlplane/api/JobAgent.java | 17 +++- .../ctrlplane/steps/CtrlplaneGetJobStep.java | 3 +- .../ctrlplane/CtrlplaneJobPollerMockTest.java | 10 +-- 6 files changed, 149 insertions(+), 32 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java index e86c320..bb6e613 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java @@ -9,6 +9,7 @@ import org.apache.commons.lang.StringUtils; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.POST; /** * Global configuration for the Ctrlplane Agent plugin. @@ -115,6 +116,13 @@ public void setPollingIntervalSeconds(int pollingIntervalSeconds) { save(); } + /** + * Validates the API URL field from the configuration form. + * + * @param value The API URL to validate + * @return FormValidation result + */ + @POST public FormValidation doCheckApiUrl(@QueryParameter String value) { if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { return FormValidation.ok(); @@ -125,6 +133,13 @@ public FormValidation doCheckApiUrl(@QueryParameter String value) { return FormValidation.ok(); } + /** + * Validates the API Key field from the configuration form. + * + * @param value The API key to validate + * @return FormValidation result + */ + @POST public FormValidation doCheckApiKey(@QueryParameter String value) { if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { return FormValidation.ok(); @@ -135,6 +150,13 @@ public FormValidation doCheckApiKey(@QueryParameter String value) { return FormValidation.ok(); } + /** + * Validates the Agent ID field from the configuration form. + * + * @param value The agent ID to validate + * @return FormValidation result + */ + @POST public FormValidation doCheckAgentId(@QueryParameter String value) { if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { return FormValidation.ok(); @@ -145,37 +167,44 @@ public FormValidation doCheckAgentId(@QueryParameter String value) { return FormValidation.ok(); } - public FormValidation doCheckAgentWorkspaceId(@QueryParameter String value) { + /** + * Validates the polling interval field from the configuration form. + * + * @param value The polling interval to validate + * @return FormValidation result + */ + @POST + public FormValidation doCheckPollingIntervalSeconds(@QueryParameter String value) { if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { return FormValidation.ok(); } if (StringUtils.isEmpty(value)) { - return FormValidation.warning("Agent Workspace ID is required for the agent to identify itself."); + return FormValidation.error("Polling Interval cannot be empty."); } try { - UUID.fromString(value); + int interval = Integer.parseInt(value); + if (interval < 10) { + return FormValidation.error("Polling interval must be at least 10 seconds."); + } return FormValidation.ok(); - } catch (IllegalArgumentException e) { - return FormValidation.error( - "Invalid format: Agent Workspace ID must be a valid UUID (e.g., 123e4567-e89b-12d3-a456-426614174000)."); + } catch (NumberFormatException e) { + return FormValidation.error("Polling interval must be a valid integer."); } } - public FormValidation doCheckPollingIntervalSeconds(@QueryParameter String value) { + public FormValidation doCheckAgentWorkspaceId(@QueryParameter String value) { if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { return FormValidation.ok(); } if (StringUtils.isEmpty(value)) { - return FormValidation.error("Polling Interval cannot be empty."); + return FormValidation.warning("Agent Workspace ID is required for the agent to identify itself."); } try { - int interval = Integer.parseInt(value); - if (interval < 10) { - return FormValidation.error("Polling interval must be at least 10 seconds."); - } + UUID.fromString(value); return FormValidation.ok(); - } catch (NumberFormatException e) { - return FormValidation.error("Polling interval must be a valid integer."); + } catch (IllegalArgumentException e) { + return FormValidation.error( + "Invalid format: Agent Workspace ID must be a valid UUID (e.g., 123e4567-e89b-12d3-a456-426614174000)."); } } } diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java index cf63544..c65efc9 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java @@ -159,12 +159,18 @@ private JobAgent createJobAgent() { String apiKey = config.getApiKey(); String agentName = config.getAgentId(); String agentWorkspaceId = config.getAgentWorkspaceId(); + int pollingIntervalSeconds = config.getPollingIntervalSeconds(); if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { - LOGGER.error("Ctrlplane API URL or API key not configured"); + LOGGER.error("Cannot create JobAgent: API URL or API Key not configured in Jenkins global settings"); return null; } - return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); + if (agentName == null || agentName.isBlank()) { + agentName = CtrlplaneGlobalConfiguration.DEFAULT_AGENT_ID; + LOGGER.warn("Agent name not configured, using default: {}", agentName); + } + + return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId, pollingIntervalSeconds); } } diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java index 6bd84e5..c28af53 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -16,6 +16,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -34,7 +35,12 @@ public class CtrlplaneJobPoller extends AsyncPeriodicWork { private static final Logger LOGGER = LoggerFactory.getLogger(CtrlplaneJobPoller.class); private final ConcurrentHashMap activeJenkinsJobs = new ConcurrentHashMap<>(); - protected JobAgent jobAgent; + private JobAgent jobAgent; + private String lastApiUrl; + private String lastApiKey; + private String lastAgentName; + private String lastWorkspaceId; + private int lastPollingIntervalSeconds; public CtrlplaneJobPoller() { super("Ctrlplane Job Poller"); @@ -100,7 +106,8 @@ private CtrlplaneConfig getAndValidateConfig() { globalConfig.getApiUrl(), globalConfig.getApiKey(), globalConfig.getAgentId(), - globalConfig.getAgentWorkspaceId()); + globalConfig.getAgentWorkspaceId(), + globalConfig.getPollingIntervalSeconds()); if (!ctrlConfig.validate()) { return null; @@ -115,9 +122,30 @@ private CtrlplaneConfig getAndValidateConfig() { * @return true if initialization and registration are successful, false otherwise. */ private boolean initializeAndRegisterAgent(CtrlplaneConfig config) { - if (jobAgent == null) { - jobAgent = createJobAgent(config.apiUrl, config.apiKey, config.agentName, config.agentWorkspaceId); - LOGGER.debug("Created new JobAgent instance"); + if (jobAgent == null + || configurationChanged( + config.apiUrl, + config.apiKey, + config.agentName, + config.agentWorkspaceId, + config.pollingIntervalSeconds)) { + if (jobAgent != null) { + LOGGER.info("Configuration changed, re-initializing JobAgent"); + } + jobAgent = createJobAgent( + config.apiUrl, + config.apiKey, + config.agentName, + config.agentWorkspaceId, + config.pollingIntervalSeconds); + updateLastConfiguration( + config.apiUrl, + config.apiKey, + config.agentName, + config.agentWorkspaceId, + config.pollingIntervalSeconds); + LOGGER.debug( + "Initialized JobAgent instance with polling interval of {} seconds", config.pollingIntervalSeconds); } boolean registered = jobAgent.ensureRegistered(); @@ -136,6 +164,37 @@ private boolean initializeAndRegisterAgent(CtrlplaneConfig config) { return true; } + /** + * Checks if the current configuration differs from the last used configuration. + * + * @param apiUrl Current API URL + * @param apiKey Current API key + * @param agentName Current agent name + * @param workspaceId Current workspace ID + * @param pollingIntervalSeconds Current polling interval + * @return true if any configuration parameters have changed + */ + private boolean configurationChanged( + String apiUrl, String apiKey, String agentName, String workspaceId, int pollingIntervalSeconds) { + return !Objects.equals(apiUrl, lastApiUrl) + || !Objects.equals(apiKey, lastApiKey) + || !Objects.equals(agentName, lastAgentName) + || !Objects.equals(workspaceId, lastWorkspaceId) + || pollingIntervalSeconds != lastPollingIntervalSeconds; + } + + /** + * Updates tracked configuration values. + */ + private void updateLastConfiguration( + String apiUrl, String apiKey, String agentName, String workspaceId, int pollingIntervalSeconds) { + lastApiUrl = apiUrl; + lastApiKey = apiKey; + lastAgentName = agentName; + lastWorkspaceId = workspaceId; + lastPollingIntervalSeconds = pollingIntervalSeconds; + } + /** * Polls the Ctrlplane API for the next available jobs. * @return A list of pending jobs (can be empty), or null if a critical error occurred. @@ -598,11 +657,12 @@ private void handleFailedTrigger(JobInfo jobInfo) { * Factory method for creating the JobAgent. Can be overridden for testing. * Throws IllegalStateException if required configuration is missing. */ - protected JobAgent createJobAgent(String apiUrl, String apiKey, String agentName, String agentWorkspaceId) { + protected JobAgent createJobAgent( + String apiUrl, String apiKey, String agentName, String agentWorkspaceId, int pollingIntervalSeconds) { if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { throw new IllegalStateException("Cannot create JobAgent: API URL or API Key is missing."); } - return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); + return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId, pollingIntervalSeconds); } /** @@ -664,12 +724,15 @@ private static class CtrlplaneConfig { final String apiKey; final String agentName; final String agentWorkspaceId; + final int pollingIntervalSeconds; - CtrlplaneConfig(String apiUrl, String apiKey, String agentName, String agentWorkspaceId) { + CtrlplaneConfig( + String apiUrl, String apiKey, String agentName, String agentWorkspaceId, int pollingIntervalSeconds) { this.apiUrl = apiUrl; this.apiKey = apiKey; this.agentName = agentName; this.agentWorkspaceId = agentWorkspaceId; + this.pollingIntervalSeconds = pollingIntervalSeconds; } /** Validates that essential configuration fields are present. */ @@ -689,6 +752,11 @@ boolean validate() { if (agentWorkspaceId == null || agentWorkspaceId.isBlank()) { LOGGER.warn("Ctrlplane Agent Workspace ID not configured. Agent registration might fail."); } + if (pollingIntervalSeconds <= 9) { + LOGGER.warn( + "Ctrlplane polling interval is not configured or is non-positive. Using default of 60 seconds."); + return false; + } return true; } } diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java index 3aed3ff..5da7115 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java @@ -37,6 +37,7 @@ public class JobAgent { private final String apiKey; private final String name; private final String agentWorkspaceId; + private final int pollingIntervalSeconds; private final AtomicReference agentIdRef = new AtomicReference<>(null); @@ -47,12 +48,26 @@ public class JobAgent { * @param apiKey the API key * @param name the agent name * @param agentWorkspaceId the workspace ID this agent belongs to + * @param pollingIntervalSeconds the polling interval in seconds */ - public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspaceId) { + public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspaceId, int pollingIntervalSeconds) { this.apiUrl = apiUrl; this.apiKey = apiKey; this.name = name; this.agentWorkspaceId = agentWorkspaceId; + this.pollingIntervalSeconds = pollingIntervalSeconds; + } + + /** + * Creates a JobAgent with the default polling interval. + * + * @param apiUrl the URL of the Ctrlplane API + * @param apiKey the API key for authentication + * @param name the name of this agent + * @param agentWorkspaceId the workspace ID for this agent + */ + public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspaceId) { + this(apiUrl, apiKey, name, agentWorkspaceId, 60); // 60 seconds is the default polling interval } /** diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java b/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java index d7347de..04f2877 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java @@ -107,6 +107,7 @@ protected Map run() throws Exception { String apiKey = config.getApiKey(); String agentName = config.getAgentId(); String workspaceId = config.getAgentWorkspaceId(); + int pollingIntervalSeconds = config.getPollingIntervalSeconds(); if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { throw new AbortException("Ctrlplane API URL or API Key not configured in Jenkins global settings."); @@ -120,7 +121,7 @@ protected Map run() throws Exception { listener.getLogger().println("Warning: Ctrlplane Agent Workspace ID not configured globally."); } - JobAgent jobAgent = new JobAgent(apiUrl, apiKey, agentName, workspaceId); + JobAgent jobAgent = new JobAgent(apiUrl, apiKey, agentName, workspaceId, pollingIntervalSeconds); Map jobData = jobAgent.getJob(jobUUID); diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerMockTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerMockTest.java index 161d432..3b6665d 100644 --- a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerMockTest.java +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerMockTest.java @@ -33,16 +33,16 @@ public TestableCtrlplaneJobPoller(JobAgent mockJobAgent) { } @Override - protected JobAgent createJobAgent(String apiUrl, String apiKey, String agentName, String agentWorkspaceId) { + protected JobAgent createJobAgent( + String apiUrl, String apiKey, String agentName, String agentWorkspaceId, int pollingIntervalSeconds) { return mockJobAgent; } - // Helper method for testing external ID updates public void updateJobStatusWithExternalId(JobInfo jobInfo, String status, String trigger, String externalId) { Map details = new HashMap<>(); details.put("trigger", trigger); details.put("externalId", externalId); - jobAgent.updateJobStatus(jobInfo.jobUUID, status, details); + this.mockJobAgent.updateJobStatus(jobInfo.jobUUID, status, details); } } @@ -52,9 +52,6 @@ public void updateJobStatusWithExternalId(JobInfo jobInfo, String status, String public void setUp() throws Exception { MockitoAnnotations.openMocks(this); jobPoller = new TestableCtrlplaneJobPoller(jobAgent); - - // Initialize the job agent field explicitly - jobPoller.jobAgent = jobAgent; } @Test @@ -138,6 +135,7 @@ public void testJobStatusUpdateWithExternalId() { String externalId = "123"; // Capture the arguments passed to updateJobStatus + @SuppressWarnings("unchecked") ArgumentCaptor> detailsCaptor = ArgumentCaptor.forClass(Map.class); // When - use our test helper to update the status with external ID From c4346567deada447e9b37f9c3ee89a83ef2381bc Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 21:23:41 -0500 Subject: [PATCH 16/19] docs update --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 158388d..f139d4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,8 +7,8 @@ We welcome pull requests! Please follow these steps: 1. **Fork the Repository:** Create your own fork of the [ctrlplanedev/jenkins-plugin](https://github.com/ctrlplanedev/jenkins-plugin) repository. 2. **Create a Branch:** Create a new branch in your fork for your changes (e.g., `git checkout -b feature/my-new-feature` or `git checkout -b fix/bug-description`). 3. **Make Changes:** Implement your fix or feature. - * Adhere to the existing code style. Consider using `mvn spotless:apply verify` to format your code. - * Add unit tests for new functionality or bug fixes if applicable. + * Adhere to the existing code style. Run `mvn spotless:apply` to format your code, and `mvn verify` to check code style and run tests. + * Add unit tests for new functionality or bug fixes, if applicable. 4. **Test:** Build the plugin (`mvn clean package`) and test your changes in a local Jenkins instance if possible. 5. **Commit:** Commit your changes with clear and concise commit messages. 6. **Push:** Push your branch to your fork (`git push origin feature/my-new-feature`). From 1986e9874580a0754e58590f04aa9cb2fd3e67a3 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 21:24:18 -0500 Subject: [PATCH 17/19] Adding new tests --- .../CtrlplaneJobCompletionListener.java | 3 +- .../plugins/ctrlplane/CtrlplaneJobPoller.java | 2 +- .../plugins/ctrlplane/api/JobAgent.java | 17 +- .../ctrlplane/steps/CtrlplaneGetJobStep.java | 3 +- .../ctrlplane/CtrlplaneConfigChangeTest.java | 183 +++++++++++++++ .../CtrlplaneGlobalConfigurationTest.java | 4 - .../ctrlplane/CtrlplaneJobPollerTest.java | 24 +- .../ctrlplane/CtrlplaneJobProcessingTest.java | 210 ++++++++++++++++++ .../plugins/ctrlplane/CtrlplaneStepTest.java | 82 +++++++ 9 files changed, 480 insertions(+), 48 deletions(-) create mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneConfigChangeTest.java create mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobProcessingTest.java create mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java index c65efc9..87176b2 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java @@ -159,7 +159,6 @@ private JobAgent createJobAgent() { String apiKey = config.getApiKey(); String agentName = config.getAgentId(); String agentWorkspaceId = config.getAgentWorkspaceId(); - int pollingIntervalSeconds = config.getPollingIntervalSeconds(); if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { LOGGER.error("Cannot create JobAgent: API URL or API Key not configured in Jenkins global settings"); @@ -171,6 +170,6 @@ private JobAgent createJobAgent() { LOGGER.warn("Agent name not configured, using default: {}", agentName); } - return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId, pollingIntervalSeconds); + return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); } } diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java index c28af53..7eb077f 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -662,7 +662,7 @@ protected JobAgent createJobAgent( if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { throw new IllegalStateException("Cannot create JobAgent: API URL or API Key is missing."); } - return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId, pollingIntervalSeconds); + return new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); } /** diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java index 5da7115..3aed3ff 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java @@ -37,7 +37,6 @@ public class JobAgent { private final String apiKey; private final String name; private final String agentWorkspaceId; - private final int pollingIntervalSeconds; private final AtomicReference agentIdRef = new AtomicReference<>(null); @@ -48,26 +47,12 @@ public class JobAgent { * @param apiKey the API key * @param name the agent name * @param agentWorkspaceId the workspace ID this agent belongs to - * @param pollingIntervalSeconds the polling interval in seconds */ - public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspaceId, int pollingIntervalSeconds) { + public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspaceId) { this.apiUrl = apiUrl; this.apiKey = apiKey; this.name = name; this.agentWorkspaceId = agentWorkspaceId; - this.pollingIntervalSeconds = pollingIntervalSeconds; - } - - /** - * Creates a JobAgent with the default polling interval. - * - * @param apiUrl the URL of the Ctrlplane API - * @param apiKey the API key for authentication - * @param name the name of this agent - * @param agentWorkspaceId the workspace ID for this agent - */ - public JobAgent(String apiUrl, String apiKey, String name, String agentWorkspaceId) { - this(apiUrl, apiKey, name, agentWorkspaceId, 60); // 60 seconds is the default polling interval } /** diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java b/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java index 04f2877..d7347de 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java @@ -107,7 +107,6 @@ protected Map run() throws Exception { String apiKey = config.getApiKey(); String agentName = config.getAgentId(); String workspaceId = config.getAgentWorkspaceId(); - int pollingIntervalSeconds = config.getPollingIntervalSeconds(); if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { throw new AbortException("Ctrlplane API URL or API Key not configured in Jenkins global settings."); @@ -121,7 +120,7 @@ protected Map run() throws Exception { listener.getLogger().println("Warning: Ctrlplane Agent Workspace ID not configured globally."); } - JobAgent jobAgent = new JobAgent(apiUrl, apiKey, agentName, workspaceId, pollingIntervalSeconds); + JobAgent jobAgent = new JobAgent(apiUrl, apiKey, agentName, workspaceId); Map jobData = jobAgent.getJob(jobUUID); diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneConfigChangeTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneConfigChangeTest.java new file mode 100644 index 0000000..2205689 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneConfigChangeTest.java @@ -0,0 +1,183 @@ +package io.jenkins.plugins.ctrlplane; + +import static org.junit.Assert.*; + +import io.jenkins.plugins.ctrlplane.api.JobAgent; +import java.util.Objects; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests that configuration changes are properly detected and handled. + */ +@RunWith(JUnit4.class) +public class CtrlplaneConfigChangeTest { + + /** + * Test configuration class that mirrors CtrlplaneConfig + */ + private static class TestConfig { + public String apiUrl; + public String apiKey; + public String agentName; + public String agentWorkspaceId; + public int pollingIntervalSeconds; + } + + private TestableCtrlplaneJobPoller jobPoller; + + /** + * Extends CtrlplaneJobPoller to access protected methods and + * track JobAgent recreations. + */ + private static class TestableCtrlplaneJobPoller extends CtrlplaneJobPoller { + public int jobAgentCreationCount = 0; + public String lastApiUrl; + public String lastApiKey; + public String lastAgentName; + public String lastWorkspaceId; + public int lastPollingIntervalSeconds; + private JobAgent testJobAgent; + + @Override + protected JobAgent createJobAgent( + String apiUrl, String apiKey, String agentName, String agentWorkspaceId, int pollingIntervalSeconds) { + jobAgentCreationCount++; + lastApiUrl = apiUrl; + lastApiKey = apiKey; + lastAgentName = agentName; + lastWorkspaceId = agentWorkspaceId; + lastPollingIntervalSeconds = pollingIntervalSeconds; + + JobAgent agent = new JobAgent(apiUrl, apiKey, agentName, agentWorkspaceId); + testJobAgent = agent; + return agent; + } + + /** + * Simplified test version that doesn't need to access internal CtrlplaneConfig + */ + public boolean initializeAndRegisterAgent(TestConfig config) { + if (config.apiUrl == null || config.apiUrl.isBlank() || config.apiKey == null || config.apiKey.isBlank()) { + return false; + } + + String agentName = config.agentName; + if (agentName == null || agentName.isBlank()) { + agentName = CtrlplaneGlobalConfiguration.DEFAULT_AGENT_ID; + } + + if (testJobAgent == null + || !config.apiUrl.equals(lastApiUrl) + || !config.apiKey.equals(lastApiKey) + || !agentName.equals(lastAgentName) + || !Objects.equals(config.agentWorkspaceId, lastWorkspaceId) + || config.pollingIntervalSeconds != lastPollingIntervalSeconds) { + + JobAgent newAgent = createJobAgent( + config.apiUrl, + config.apiKey, + agentName, + config.agentWorkspaceId, + config.pollingIntervalSeconds); + + try { + java.lang.reflect.Field field = CtrlplaneJobPoller.class.getDeclaredField("jobAgent"); + field.setAccessible(true); + field.set(this, newAgent); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + return true; + } + } + + @Before + public void setUp() { + jobPoller = new TestableCtrlplaneJobPoller(); + } + + @Test + public void testInitialAgentSetup() { + // Create test configuration + TestConfig config = new TestConfig(); + config.apiUrl = "https://api.example.com"; + config.apiKey = "test-key"; + config.agentName = "test-agent"; + config.agentWorkspaceId = "test-workspace"; + config.pollingIntervalSeconds = 120; + + // Initialize agent + boolean result = jobPoller.initializeAndRegisterAgent(config); + + // Verify + assertTrue("Agent initialization should succeed", result); + assertEquals("Agent should be created once", 1, jobPoller.jobAgentCreationCount); + assertEquals("API URL should match", "https://api.example.com", jobPoller.lastApiUrl); + assertEquals("API key should match", "test-key", jobPoller.lastApiKey); + assertEquals("Agent name should match", "test-agent", jobPoller.lastAgentName); + assertEquals("Workspace ID should match", "test-workspace", jobPoller.lastWorkspaceId); + assertEquals("Polling interval should match", 120, jobPoller.lastPollingIntervalSeconds); + } + + @Test + public void testAgentReInitializationOnConfigChange() { + // First initialization + TestConfig config = new TestConfig(); + config.apiUrl = "https://api.example.com"; + config.apiKey = "test-key"; + config.agentName = "test-agent"; + config.agentWorkspaceId = "test-workspace"; + config.pollingIntervalSeconds = 120; + + jobPoller.initializeAndRegisterAgent(config); + assertEquals("Agent should be created once", 1, jobPoller.jobAgentCreationCount); + + // Same config - should not recreate + jobPoller.initializeAndRegisterAgent(config); + assertEquals("Agent should not be recreated for same config", 1, jobPoller.jobAgentCreationCount); + + // Change API URL - should recreate + config.apiUrl = "https://new-api.example.com"; + jobPoller.initializeAndRegisterAgent(config); + assertEquals("Agent should be recreated when API URL changes", 2, jobPoller.jobAgentCreationCount); + assertEquals("New API URL should be used", "https://new-api.example.com", jobPoller.lastApiUrl); + + // Change API key - should recreate + config.apiKey = "new-test-key"; + jobPoller.initializeAndRegisterAgent(config); + assertEquals("Agent should be recreated when API key changes", 3, jobPoller.jobAgentCreationCount); + assertEquals("New API key should be used", "new-test-key", jobPoller.lastApiKey); + + // Change polling interval - should recreate + config.pollingIntervalSeconds = 180; + jobPoller.initializeAndRegisterAgent(config); + assertEquals("Agent should be recreated when polling interval changes", 4, jobPoller.jobAgentCreationCount); + assertEquals("New polling interval should be used", 180, jobPoller.lastPollingIntervalSeconds); + } + + @Test + public void testDefaultAgentNameUsedWhenBlank() { + TestConfig config = new TestConfig(); + config.apiUrl = "https://api.example.com"; + config.apiKey = "test-key"; + config.agentName = ""; // Blank agent name + config.agentWorkspaceId = "test-workspace"; + config.pollingIntervalSeconds = 120; + + jobPoller.initializeAndRegisterAgent(config); + + // Should use default agent name + assertFalse( + "Agent name should not be blank", jobPoller.lastAgentName == null || jobPoller.lastAgentName.isEmpty()); + assertEquals( + "Default agent name should be used", + CtrlplaneGlobalConfiguration.DEFAULT_AGENT_ID, + jobPoller.lastAgentName); + } +} diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java index 7900d71..971008b 100644 --- a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java @@ -27,7 +27,6 @@ public class CtrlplaneGlobalConfigurationTest { @Test public void uiAndStorage() throws Throwable { sessions.then(r -> { - // Initial state checks assertEquals( "default API URL is set initially", CtrlplaneGlobalConfiguration.DEFAULT_API_URL, @@ -47,7 +46,6 @@ public void uiAndStorage() throws Throwable { CtrlplaneGlobalConfiguration.DEFAULT_POLLING_INTERVAL_SECONDS, CtrlplaneGlobalConfiguration.get().getPollingIntervalSeconds()); - // Configure values HtmlForm config = r.createWebClient().goTo("configure").getFormByName("config"); HtmlTextInput apiUrlBox = config.getInputByName("_.apiUrl"); @@ -67,7 +65,6 @@ public void uiAndStorage() throws Throwable { r.submit(config); - // Verify submitted values assertEquals( "API URL was updated", "https://api.example.com", @@ -90,7 +87,6 @@ public void uiAndStorage() throws Throwable { CtrlplaneGlobalConfiguration.get().getPollingIntervalSeconds()); }); - // Verify values persist after restart sessions.then(r -> { assertEquals( "API URL still there after restart", diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java index 9722590..848b076 100644 --- a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java @@ -67,27 +67,5 @@ public void testExtractJobNameFromUrlWithParameters() { } @Test - public void testTriggerJenkinsJobWithParameters() { - // This is a simple example of how you might test passing parameters to a Jenkins job - // In a real test, you would need to mock Jenkins.get() and other external dependencies - - /* - * Example implementation using mocking framework (not included): - * - * // Setup - * Jenkins jenkinsMock = mock(Jenkins.class); - * ParameterizedJobMixIn.ParameterizedJob jobMock = mock(ParameterizedJobMixIn.ParameterizedJob.class); - * when(jenkinsMock.getItemByFullName("test-job", hudson.model.Job.class)).thenReturn(jobMock); - * when(jobMock.scheduleBuild2(eq(0), any(ParametersAction.class))).thenReturn(mock(QueueTaskFuture.class)); - * - * // Execute - * JobInfo jobInfo = new JobInfo("test-uuid", UUID.randomUUID(), "http://jenkins/job/test-job/"); - * jobPoller.triggerJenkinsJob(jobInfo, new CtrlplaneJobPoller.JobProcessingStats()); - * - * // Verify - * verify(jobMock).scheduleBuild2(eq(0), paramActionCaptor.capture()); - * ParametersAction capturedAction = paramActionCaptor.getValue(); - * assertEquals("test-uuid", ((StringParameterValue)capturedAction.getParameters().get(0)).getValue()); - */ - } + public void testTriggerJenkinsJobWithParameters() {} } diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobProcessingTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobProcessingTest.java new file mode 100644 index 0000000..1ec86e6 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobProcessingTest.java @@ -0,0 +1,210 @@ +package io.jenkins.plugins.ctrlplane; + +import static org.junit.Assert.*; + +import hudson.model.Result; +import hudson.model.Run; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mockito; + +/** + * Tests job processing logic in the CtrlplaneJobPoller class. + */ +@RunWith(JUnit4.class) +public class CtrlplaneJobProcessingTest { + + /** + * Test version of JobInfo to use in tests + */ + public static class TestJobInfo { + public final String jobId; + public final UUID jobUUID; + public final String jobUrl; + + public TestJobInfo(String jobId, UUID jobUUID, String jobUrl) { + this.jobId = jobId; + this.jobUUID = jobUUID; + this.jobUrl = jobUrl; + } + } + + /** + * Test version of ActiveJobInfo to use in tests + */ + public static class TestActiveJobInfo { + public final TestJobInfo jobInfo; + public final String externalId; + + @SuppressWarnings("rawtypes") + public final Run run; + + @SuppressWarnings("rawtypes") + public TestActiveJobInfo(TestJobInfo jobInfo, String externalId, Run run) { + this.jobInfo = jobInfo; + this.externalId = externalId; + this.run = run; + } + } + + /** + * Simple class to track job processing statistics for testing + */ + public static class TestJobProcessingStats { + public int processedJobs = 0; + public int errors = 0; + public int skippedJobs = 0; + } + + private static class TestableCtrlplaneJobPoller extends CtrlplaneJobPoller { + public final ConcurrentHashMap testActiveJobs = new ConcurrentHashMap<>(); + public final List> jobStatusUpdates = new ArrayList<>(); + + public TestJobInfo testExtractJobInfo(Map jobMap) { + String status = (String) jobMap.get("status"); + if (!"pending".equals(status)) { + return null; + } + + String jobId = (String) jobMap.get("id"); + UUID jobUUID = UUID.fromString(jobId); + + @SuppressWarnings("unchecked") + Map jobConfig = (Map) jobMap.get("jobAgentConfig"); + String jobUrl = (String) jobConfig.get("jobUrl"); + + return new TestJobInfo(jobId, jobUUID, jobUrl); + } + + public void updateJobStatus(TestJobInfo jobInfo, String status, String reason) { + jobStatusUpdates.add(Map.of( + "jobId", jobInfo.jobId, + "status", status, + "reason", reason)); + } + + public void handleCompletedJob(TestActiveJobInfo activeJob) { + String jobId = activeJob.jobInfo.jobId; + Result result = activeJob.run.getResult(); + String status = (Result.SUCCESS.equals(result)) ? "successful" : "failure"; + updateJobStatus(activeJob.jobInfo, status, "Job completed with status: " + result.toString()); + testActiveJobs.remove(jobId); + } + } + + private TestableCtrlplaneJobPoller jobPoller; + + @Before + public void setUp() { + jobPoller = new TestableCtrlplaneJobPoller(); + } + + @Test + public void testExtractJobInfoFromValidMap() { + // Create a valid job map + Map jobConfig = new HashMap<>(); + jobConfig.put("jobUrl", "http://jenkins-server/job/test-job/"); + + Map jobMap = new HashMap<>(); + String jobId = UUID.randomUUID().toString(); + jobMap.put("id", jobId); + jobMap.put("status", "pending"); + jobMap.put("jobAgentConfig", jobConfig); + + // Extract job info + TestJobInfo jobInfo = jobPoller.testExtractJobInfo(jobMap); + + // Verify extraction + assertNotNull("JobInfo should not be null", jobInfo); + assertEquals("Job ID should match", jobId, jobInfo.jobId); + assertEquals("Job URL should match", "http://jenkins-server/job/test-job/", jobInfo.jobUrl); + } + + @Test + public void testSkipNonPendingJobs() { + // Create a job map with non-pending status + Map jobConfig = new HashMap<>(); + jobConfig.put("jobUrl", "http://jenkins-server/job/test-job/"); + + Map jobMap = new HashMap<>(); + String jobId = UUID.randomUUID().toString(); + jobMap.put("id", jobId); + jobMap.put("status", "in_progress"); // Non-pending status + jobMap.put("jobAgentConfig", jobConfig); + + // Extract job info + TestJobInfo jobInfo = jobPoller.testExtractJobInfo(jobMap); + + // Verify null for non-pending jobs + assertNull("Should return null for non-pending jobs", jobInfo); + } + + @Test + public void testJobProcessingStats() { + TestJobProcessingStats stats = new TestJobProcessingStats(); + assertEquals("Should have 0 processed jobs initially", 0, stats.processedJobs); + assertEquals("Should have 0 errors initially", 0, stats.errors); + assertEquals("Should have 0 skipped jobs initially", 0, stats.skippedJobs); + + // Increment counters + stats.processedJobs++; + stats.errors++; + stats.skippedJobs += 2; + + assertEquals("Should have 1 processed job", 1, stats.processedJobs); + assertEquals("Should have 1 error", 1, stats.errors); + assertEquals("Should have 2 skipped jobs", 2, stats.skippedJobs); + } + + @Test + public void testHandleCompletedJobWithSuccessResult() { + String jobId = UUID.randomUUID().toString(); + UUID jobUUID = UUID.randomUUID(); + String externalId = "build-123"; + + @SuppressWarnings("rawtypes") + Run mockRun = Mockito.mock(Run.class); + Mockito.when(mockRun.getResult()).thenReturn(Result.SUCCESS); + + TestJobInfo jobInfo = new TestJobInfo(jobId, jobUUID, "http://jenkins-server/job/test-job/"); + TestActiveJobInfo activeJob = new TestActiveJobInfo(jobInfo, externalId, mockRun); + jobPoller.testActiveJobs.put(jobId, activeJob); + + jobPoller.handleCompletedJob(activeJob); + + assertEquals("Should have 1 status update", 1, jobPoller.jobStatusUpdates.size()); + Map update = jobPoller.jobStatusUpdates.get(0); + assertEquals("Job ID should match", jobId, update.get("jobId")); + assertEquals("Status should be successful", "successful", update.get("status")); + + assertFalse("Job should be removed from active jobs", jobPoller.testActiveJobs.containsKey(jobId)); + } + + @Test + public void testHandleCompletedJobWithFailureResult() { + String jobId = UUID.randomUUID().toString(); + UUID jobUUID = UUID.randomUUID(); + String externalId = "build-456"; + + @SuppressWarnings("rawtypes") + Run mockRun = Mockito.mock(Run.class); + Mockito.when(mockRun.getResult()).thenReturn(Result.FAILURE); + + TestJobInfo jobInfo = new TestJobInfo(jobId, jobUUID, "http://jenkins-server/job/test-job/"); + TestActiveJobInfo activeJob = new TestActiveJobInfo(jobInfo, externalId, mockRun); + jobPoller.testActiveJobs.put(jobId, activeJob); + + jobPoller.handleCompletedJob(activeJob); + + assertEquals("Should have 1 status update", 1, jobPoller.jobStatusUpdates.size()); + Map update = jobPoller.jobStatusUpdates.get(0); + assertEquals("Job ID should match", jobId, update.get("jobId")); + assertEquals("Status should be failure", "failure", update.get("status")); + + assertFalse("Job should be removed from active jobs", jobPoller.testActiveJobs.containsKey(jobId)); + } +} diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java new file mode 100644 index 0000000..bcee674 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java @@ -0,0 +1,82 @@ +package io.jenkins.plugins.ctrlplane; + +import static org.junit.Assert.*; + +import hudson.model.TaskListener; +import io.jenkins.plugins.ctrlplane.steps.CtrlplaneGetJobStep; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.Map; +import java.util.UUID; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.jvnet.hudson.test.JenkinsRule; +import org.mockito.Mockito; + +/** + * Tests for Ctrlplane pipeline steps. + */ +public class CtrlplaneStepTest { + + @Rule + public JenkinsRule jenkins = new JenkinsRule(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + /** + * Tests that the step validates UUID format. + */ + @Test + public void testInvalidUUIDRejection() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("UUID"); + new CtrlplaneGetJobStep("not-a-uuid"); + } + + /** + * Test that null/empty job ID is rejected. + */ + @Test + public void testEmptyJobIdRejection() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("cannot be empty"); + new CtrlplaneGetJobStep(""); + } + + /** + * Test that valid UUIDs are accepted. + */ + @Test + public void testValidUUIDAcceptance() { + String validUuid = UUID.randomUUID().toString(); + CtrlplaneGetJobStep step = new CtrlplaneGetJobStep(validUuid); + assertEquals("Job ID should be stored", validUuid, step.getJobId()); + } + + /** + * Simple test for parameter handling. + */ + @Test + public void testParameterHandling() { + Map params = Map.of( + "PARAM1", "value1", + "PARAM2", "value2"); + assertEquals("Parameter values should be retrievable", "value1", params.get("PARAM1")); + assertEquals("Parameter values should be retrievable", "value2", params.get("PARAM2")); + } + + /** + * Test that logging works as expected. + */ + @Test + public void testLogging() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(baos); + TaskListener listener = Mockito.mock(TaskListener.class); + Mockito.when(listener.getLogger()).thenReturn(ps); + listener.getLogger().println("Test message"); + assertEquals("Log message should be captured", "Test message" + System.lineSeparator(), baos.toString()); + } +} From 28c7565cc9812c05405f89491060c2d87c3063f8 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 21:31:22 -0500 Subject: [PATCH 18/19] move and add some more tests --- .../plugins/ctrlplane/CtrlplaneStepTest.java | 82 ------------ .../steps/CtrlplaneGetJobStepTest.java | 122 ++++++++++++++++++ 2 files changed, 122 insertions(+), 82 deletions(-) delete mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java create mode 100644 src/test/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStepTest.java diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java deleted file mode 100644 index bcee674..0000000 --- a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneStepTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package io.jenkins.plugins.ctrlplane; - -import static org.junit.Assert.*; - -import hudson.model.TaskListener; -import io.jenkins.plugins.ctrlplane.steps.CtrlplaneGetJobStep; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.util.Map; -import java.util.UUID; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.jvnet.hudson.test.JenkinsRule; -import org.mockito.Mockito; - -/** - * Tests for Ctrlplane pipeline steps. - */ -public class CtrlplaneStepTest { - - @Rule - public JenkinsRule jenkins = new JenkinsRule(); - - @Rule - public ExpectedException thrown = ExpectedException.none(); - - /** - * Tests that the step validates UUID format. - */ - @Test - public void testInvalidUUIDRejection() { - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("UUID"); - new CtrlplaneGetJobStep("not-a-uuid"); - } - - /** - * Test that null/empty job ID is rejected. - */ - @Test - public void testEmptyJobIdRejection() { - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("cannot be empty"); - new CtrlplaneGetJobStep(""); - } - - /** - * Test that valid UUIDs are accepted. - */ - @Test - public void testValidUUIDAcceptance() { - String validUuid = UUID.randomUUID().toString(); - CtrlplaneGetJobStep step = new CtrlplaneGetJobStep(validUuid); - assertEquals("Job ID should be stored", validUuid, step.getJobId()); - } - - /** - * Simple test for parameter handling. - */ - @Test - public void testParameterHandling() { - Map params = Map.of( - "PARAM1", "value1", - "PARAM2", "value2"); - assertEquals("Parameter values should be retrievable", "value1", params.get("PARAM1")); - assertEquals("Parameter values should be retrievable", "value2", params.get("PARAM2")); - } - - /** - * Test that logging works as expected. - */ - @Test - public void testLogging() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - PrintStream ps = new PrintStream(baos); - TaskListener listener = Mockito.mock(TaskListener.class); - Mockito.when(listener.getLogger()).thenReturn(ps); - listener.getLogger().println("Test message"); - assertEquals("Log message should be captured", "Test message" + System.lineSeparator(), baos.toString()); - } -} diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStepTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStepTest.java new file mode 100644 index 0000000..007f33a --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStepTest.java @@ -0,0 +1,122 @@ +package io.jenkins.plugins.ctrlplane.steps; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import hudson.model.TaskListener; +import io.jenkins.plugins.ctrlplane.CtrlplaneGlobalConfiguration; +import io.jenkins.plugins.ctrlplane.api.JobAgent; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.Map; +import java.util.UUID; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +/** + * Tests for the CtrlplaneGetJobStep pipeline step. + */ +public class CtrlplaneGetJobStepTest { + + @Rule + public JenkinsRule jenkins = new JenkinsRule(); + + /** + * Tests that the step validates UUID format. + */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidUUIDRejection() { + new CtrlplaneGetJobStep("not-a-uuid"); + } + + /** + * Test that null/empty job ID is rejected. + */ + @Test(expected = IllegalArgumentException.class) + public void testEmptyJobIdRejection() { + new CtrlplaneGetJobStep(""); + } + + /** + * Test that valid UUIDs are accepted. + */ + @Test + public void testValidUUIDAcceptance() { + String validUuid = UUID.randomUUID().toString(); + CtrlplaneGetJobStep step = new CtrlplaneGetJobStep(validUuid); + assertEquals("Job ID should be stored", validUuid, step.getJobId()); + } + + /** + * Simple test for parameter handling. + */ + @Test + public void testParameterHandling() { + Map params = Map.of( + "PARAM1", "value1", + "PARAM2", "value2"); + assertEquals("Parameter values should be retrievable", "value1", params.get("PARAM1")); + assertEquals("Parameter values should be retrievable", "value2", params.get("PARAM2")); + } + + /** + * Test that logging works as expected. + */ + @Test + public void testLogging() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(baos); + TaskListener listener = mock(TaskListener.class); + when(listener.getLogger()).thenReturn(ps); + listener.getLogger().println("Test message"); + assertEquals("Log message should be captured", "Test message" + System.lineSeparator(), baos.toString()); + } + + /** + * Test that the step properly creates execution. + */ + @Test + public void testStepExecution() throws Exception { + String validUuid = UUID.randomUUID().toString(); + CtrlplaneGetJobStep step = new CtrlplaneGetJobStep(validUuid); + + StepContext context = mock(StepContext.class); + + assertNotNull("Step should create a valid execution", step.start(context)); + } + + /** + * Test that configuration is properly accessed. + */ + @Test + public void testConfigurationAccess() { + CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); + // Just verify we can access the configuration + assertNotNull("Global configuration should be accessible", config); + } + + /** + * Test that the JobAgent can be instantiated with parameters. + */ + @Test + public void testJobAgentCreation() { + JobAgent agent = new JobAgent("https://api.example.com", "test-api-key", "test-agent", "test-workspace-id"); + + assertNotNull("JobAgent should be created successfully", agent); + } + + /** + * Test that the step's function name is correctly set. + */ + @Test + public void testStepFunctionName() { + CtrlplaneGetJobStep.DescriptorImpl descriptor = new CtrlplaneGetJobStep.DescriptorImpl(); + assertEquals("ctrlplaneGetJob", descriptor.getFunctionName()); + assertEquals("Get Ctrlplane Job Details", descriptor.getDisplayName()); + assertTrue( + "TaskListener should be in required context", + descriptor.getRequiredContext().contains(TaskListener.class)); + } +} From 9cbe2781eab503c6c51fcfbb488cd14f94939ba2 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Mon, 14 Apr 2025 21:52:42 -0500 Subject: [PATCH 19/19] fix doCheckAgentWorkspaceId --- .../plugins/ctrlplane/CtrlplaneGlobalConfiguration.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java index bb6e613..ef85373 100644 --- a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java @@ -192,6 +192,13 @@ public FormValidation doCheckPollingIntervalSeconds(@QueryParameter String value } } + /** + * Validates the Agent Workspace ID field from the configuration form. + * + * @param value The agent workspace ID to validate + * @return FormValidation result + */ + @POST public FormValidation doCheckAgentWorkspaceId(@QueryParameter String value) { if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { return FormValidation.ok();