diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f4a7d33..d62c7ff 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @jenkinsci/ctrlplanexw-plugin-developers +* @zacharyblasczyk @jsbroks diff --git a/.gitignore b/.gitignore index acc6b3c..e2439e2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,12 @@ *.tar.gz *.rar +.idea/ + + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* target/ +work/ \ 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f139d4e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing to the Ctrlplane Jenkins Plugin + +Thank you for considering contributing to the Ctrlplane Jenkins Plugin! + +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. 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`). +7. **Open a Pull Request:** Go to the original repository and open a pull request from your branch to the `main` branch of `ctrlplanedev/jenkins-plugin`. + * Provide a clear title and description for your pull request, explaining the changes and referencing any related issues (e.g., "Fixes #123"). + +## Code Style + +This project uses [Spotless Maven Plugin](https://github.com/diffplug/spotless/tree/main/plugin-maven) to enforce code style. Please run `mvn spotless:apply` before committing to format your code automatically. + +## Questions? + +If you have questions about contributing, feel free to open an issue. + +Thank you for your contributions! diff --git a/Jenkinsfile b/Jenkinsfile index 09032da..8d22b8b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,9 +3,10 @@ https://github.com/jenkins-infra/pipeline-library/ */ buildPlugin( - forkCount: '1C', // run this number of tests in parallel for faster feedback. If the number terminates with a 'C', the value will be multiplied by the number of available CPU cores - useContainerAgent: true, // Set to `false` if you need to use Docker for containerized tests + forkCount: '1C', + useContainerAgent: true, configurations: [ [platform: 'linux', jdk: 21], [platform: 'windows', jdk: 17], -]) + ], +) diff --git a/README.md b/README.md index fcef52f..f5df894 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,24 @@ ## Introduction -TODO Describe what your plugin does here +This plugin integrates Jenkins with Ctrlplane by acting as a Job Agent. +It allows Ctrlplane to trigger specific Jenkins pipeline jobs as part of a Deployment workflow. +The plugin polls Ctrlplane for pending jobs assigned to it and injects job context (like the Ctrlplane Job ID) into the triggered Jenkins pipeline. ## Getting started -TODO Tell users how to configure your plugin here, include screenshots, pipeline -examples and configuration-as-code examples. +For detailed installation, configuration, and usage instructions, please refer to the official documentation: -## Issues +[**Ctrlplane Jenkins Integration Documentation**](https://docs.ctrlplane.dev/integrations/saas/jenkins) -TODO Decide where you're going to host your issues, the default is Jenkins JIRA, -but you can also enable GitHub issues, If you use GitHub issues there's no need -for this section; else add the following line: +## Issues -Report issues and enhancements in the [Jenkins issue tracker](https://issues.jenkins.io/). +Report issues and enhancements on the [GitHub Issues page](https://github.com/ctrlplanedev/jenkins-plugin/issues). ## Contributing -TODO review the default -[CONTRIBUTING](https://github.com/jenkinsci/.github/blob/master/CONTRIBUTING.md) -file and make sure it is appropriate for your plugin, if not then add your own -one adapted from the base file - Refer to our [contribution guidelines](https://github.com/jenkinsci/.github/blob/master/CONTRIBUTING.md) ## LICENSE -Licensed under MIT, see [LICENSE](LICENSE.md) +Licensed under MIT, see [LICENSE](LICENSE) 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/pom.xml b/pom.xml index 7aa7b36..5b38db4 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.jenkins-ci.plugins plugin - 4.85 + 5.9 @@ -14,7 +14,7 @@ ${revision}${changelist} hpi - TODO Plugin + Ctrlplane Plugin https://github.com/jenkinsci/${project.artifactId}-plugin @@ -22,6 +22,19 @@ https://opensource.org/license/mit/ + + + + zacharyblasczyk + Zachary Blasczyk + zachary@ctrlplane.dev + + + jsbrooks + Justin Brooks + justin@ctrlplane.dev + + scm:git:https://github.com/${gitHubRepo} scm:git:https://github.com/${gitHubRepo} @@ -32,49 +45,48 @@ 1.0 -SNAPSHOT - - - 2.440.3 - jenkinsci/${project.artifactId}-plugin - + 2.492 + ${jenkins.baseline}.3 + + ctrlplanedev/jenkins-agent-plugin false - io.jenkins.tools.bom - bom-2.440.x - 3193.v330d8248d39e + bom-${jenkins.baseline}.x + 4051.v78dce3ce8b_d6 pom import + - org.jenkins-ci.plugins - structs + com.fasterxml.jackson.core + jackson-databind + 2.15.3 - org.jenkins-ci.plugins.workflow - workflow-basic-steps - test + org.jenkins-ci.plugins + structs org.jenkins-ci.plugins.workflow - workflow-cps - test + workflow-step-api - org.jenkins-ci.plugins.workflow - workflow-durable-task-step + org.jenkins-ci.main + jenkins-test-harness test - org.jenkins-ci.plugins.workflow - workflow-job + org.mockito + mockito-core + 4.11.0 test 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..ef85373 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfiguration.java @@ -0,0 +1,217 @@ +package io.jenkins.plugins.ctrlplane; + +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; +import org.kohsuke.stapler.verb.POST; + +/** + * Global configuration for the Ctrlplane Agent plugin. + */ +@Extension +public class CtrlplaneGlobalConfiguration extends GlobalConfiguration { + 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() { + load(); + if (StringUtils.isBlank(apiUrl)) { + apiUrl = DEFAULT_API_URL; + } + if (pollingIntervalSeconds <= 0) { + 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 pollingIntervalSeconds > 0 ? pollingIntervalSeconds : DEFAULT_POLLING_INTERVAL_SECONDS; + } + + /** + * Sets the polling interval. + * @param pollingIntervalSeconds The new interval in seconds. + */ + @DataBoundSetter + public void setPollingIntervalSeconds(int pollingIntervalSeconds) { + this.pollingIntervalSeconds = Math.max(10, 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(); + } + if (StringUtils.isEmpty(value)) { + return FormValidation.error("API URL cannot be empty."); + } + 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(); + } + if (StringUtils.isEmpty(value)) { + return FormValidation.warning("API Key is required for the agent to poll for jobs."); + } + 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(); + } + if (StringUtils.isEmpty(value)) { + return FormValidation.warning("Agent ID is recommended for easier identification in Ctrlplane."); + } + return FormValidation.ok(); + } + + /** + * 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.error("Polling Interval cannot be empty."); + } + try { + 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."); + } + } + + /** + * 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(); + } + if (StringUtils.isEmpty(value)) { + return FormValidation.warning("Agent Workspace ID is required for the agent to identify itself."); + } + 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)."); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java new file mode 100644 index 0000000..87176b2 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobCompletionListener.java @@ -0,0 +1,175 @@ +package io.jenkins.plugins.ctrlplane; + +import hudson.Extension; +import hudson.model.ParameterValue; +import hudson.model.ParametersAction; +import hudson.model.Result; +import hudson.model.Run; +import hudson.model.StringParameterValue; +import hudson.model.TaskListener; +import hudson.model.listeners.RunListener; +import io.jenkins.plugins.ctrlplane.api.JobAgent; +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 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, @Nonnull TaskListener listener) { + LOGGER.debug("onCompleted triggered for run: {}", run.getFullDisplayName()); + + /** + * Extract Ctrlplane Job ID from parameters + */ + String ctrlplaneJobId = extractJobId(run); + if (ctrlplaneJobId == null) { + LOGGER.debug( + "No Ctrlplane Job ID found for run {}, likely not a Ctrlplane-triggered job.", + run.getFullDisplayName()); + return; + } + + UUID jobUUID; + try { + jobUUID = UUID.fromString(ctrlplaneJobId); + } catch (IllegalArgumentException 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"; + } + + Result result = run.getResult(); + if (result == null) { + 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 "FAILURE": + return "failure"; + case "ABORTED": + return "cancelled"; + default: + LOGGER.warn( + "Unknown Jenkins result '{}' for run {}, reporting as failure.", + resultString, + run.getFullDisplayName()); + return "failure"; + } + } + + /** + * Extracts the Ctrlplane JOB_ID parameter from a Jenkins run. + */ + private String extractJobId(Run run) { + if (run == null) { + return null; + } + + ParametersAction parametersAction = run.getAction(ParametersAction.class); + if (parametersAction != null) { + ParameterValue jobIdParam = parametersAction.getParameter("JOB_ID"); + if (jobIdParam instanceof StringParameterValue) { + return ((StringParameterValue) jobIdParam).getValue(); + } + } + return null; + } + + /** + * Creates a JobAgent with configuration from global settings. + */ + private JobAgent createJobAgent() { + CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); + String apiUrl = config.getApiUrl(); + String apiKey = config.getApiKey(); + String agentName = config.getAgentId(); + String agentWorkspaceId = config.getAgentWorkspaceId(); + + 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"); + return null; + } + + 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); + } +} 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..7eb077f --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPoller.java @@ -0,0 +1,858 @@ +package io.jenkins.plugins.ctrlplane; + +import hudson.Extension; +import hudson.model.AsyncPeriodicWork; +import hudson.model.Job; +import hudson.model.ParameterValue; +import hudson.model.ParametersAction; +import hudson.model.Queue; +import hudson.model.Result; +import hudson.model.Run; +import hudson.model.StringParameterValue; +import hudson.model.TaskListener; +import hudson.model.queue.QueueTaskFuture; +import io.jenkins.plugins.ctrlplane.api.JobAgent; +import java.util.HashMap; +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; +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. + * It also includes logic to reconcile the status of Jenkins jobs that were triggered + * by this poller but might have finished while Jenkins was restarting, ensuring their + * final status is reported back to Ctrlplane. + */ +@Extension +public class CtrlplaneJobPoller extends AsyncPeriodicWork { + private static final Logger LOGGER = LoggerFactory.getLogger(CtrlplaneJobPoller.class); + private final ConcurrentHashMap activeJenkinsJobs = new ConcurrentHashMap<>(); + private JobAgent jobAgent; + private String lastApiUrl; + private String lastApiKey; + private String lastAgentName; + private String lastWorkspaceId; + private int lastPollingIntervalSeconds; + + public CtrlplaneJobPoller() { + super("Ctrlplane Job Poller"); + } + + @Override + public long getRecurrencePeriod() { + CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); + int intervalSeconds = config.getPollingIntervalSeconds(); + LOGGER.debug("Using polling interval: {} seconds", intervalSeconds); + if (intervalSeconds < 10) { + LOGGER.warn("Polling interval {}s is too low, using minimum 10s.", intervalSeconds); + intervalSeconds = 10; + } + return TimeUnit.SECONDS.toMillis(intervalSeconds); + } + + @Override + protected void execute(TaskListener listener) { + Jenkins jenkins = Jenkins.get(); + if (jenkins.isTerminating()) { + LOGGER.info("Jenkins is terminating, skipping Ctrlplane job polling cycle."); + return; + } + + LOGGER.debug("Starting Ctrlplane job polling cycle."); + + CtrlplaneConfig config = getAndValidateConfig(); + if (config == null) { + return; + } + + if (!initializeAndRegisterAgent(config)) { + return; + } + + reconcileInProgressJobs(); + + if (jenkins.isQuietingDown()) { + LOGGER.info("Jenkins is quieting down, skipping polling for new Ctrlplane jobs."); + return; + } + + List> pendingJobs = pollForJobs(); + if (pendingJobs == null || pendingJobs.isEmpty()) { + return; + } + LOGGER.info("Polled Ctrlplane API. Found {} job(s) to process.", pendingJobs.size()); + + processJobs(pendingJobs); + + LOGGER.debug("Finished Ctrlplane job polling cycle."); + } + + /** + * Fetches and validates the global configuration. + * @return A valid CtrlplaneConfig instance, or null if configuration is invalid. + */ + private CtrlplaneConfig getAndValidateConfig() { + CtrlplaneGlobalConfiguration globalConfig = CtrlplaneGlobalConfiguration.get(); + + CtrlplaneConfig ctrlConfig = new CtrlplaneConfig( + globalConfig.getApiUrl(), + globalConfig.getApiKey(), + globalConfig.getAgentId(), + globalConfig.getAgentWorkspaceId(), + globalConfig.getPollingIntervalSeconds()); + + if (!ctrlConfig.validate()) { + return null; + } + return ctrlConfig; + } + + /** + * Initializes the JobAgent if needed and ensures it's registered. + * Uses early returns on failure. + * @param config The validated Ctrlplane configuration. + * @return true if initialization and registration are successful, false otherwise. + */ + private boolean initializeAndRegisterAgent(CtrlplaneConfig config) { + 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(); + if (!registered) { + LOGGER.error("Agent registration check failed."); + return false; + } + + String currentAgentId = jobAgent.getAgentId(); + if (currentAgentId == null || currentAgentId.isBlank()) { + LOGGER.error("Agent ID not available after registration attempt."); + return false; + } + + LOGGER.debug("Polling jobs for registered agent ID: {}", currentAgentId); + 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. + */ + private List> pollForJobs() { + if (jobAgent == null) { + LOGGER.error("JobAgent not initialized before polling for jobs."); + return null; + } + + List> jobs = jobAgent.getNextJobs(); + if (jobs == null) { + LOGGER.warn("Polling for jobs failed or returned null."); + return null; + } + + if (jobs.isEmpty()) { + LOGGER.debug("No pending Ctrlplane jobs found."); + } + + return jobs; + } + + /** + * Checks the status of Jenkins jobs that were previously triggered by this poller. + * If a job has completed (potentially during a Jenkins restart when the RunListener + * wouldn't fire), this method updates its status in Ctrlplane. + * This method logs errors but does not throw exceptions, as reconciliation is best-effort. + */ + private void reconcileInProgressJobs() { + if (activeJenkinsJobs.isEmpty()) { + LOGGER.debug("No active Jenkins jobs to reconcile."); + return; + } + + LOGGER.debug("Reconciling status of {} active Jenkins job(s)...", activeJenkinsJobs.size()); + Iterator> iterator = + activeJenkinsJobs.entrySet().iterator(); + + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + String ctrlplaneJobId = entry.getKey(); + ActiveJobInfo activeJob = entry.getValue(); + + Job jenkinsJob = Jenkins.get().getItemByFullName(activeJob.jenkinsJobName, Job.class); + if (jenkinsJob == null) { + LOGGER.warn( + "Jenkins job '{}' for Ctrlplane job {} not found during reconciliation. Assuming failure.", + activeJob.jenkinsJobName, + ctrlplaneJobId); + updateCtrlplaneJobStatus( + ctrlplaneJobId, + activeJob.ctrlplaneJobUUID, + "failure", + Map.of("message", "Jenkins job not found during reconciliation")); + iterator.remove(); + continue; + } + + boolean removeJob; + if (activeJob.jenkinsBuildNumber < 0) { + removeJob = reconcileQueuedJob(activeJob); + } else { + removeJob = reconcileRunningOrCompletedJob(jenkinsJob, activeJob); + } + + if (removeJob) { + iterator.remove(); + } + } + LOGGER.debug("Finished reconciling job statuses."); + } + + /** + * 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); + + 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; + } + + 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; + } + + LOGGER.debug( + "Job {} (Queue ID {}) is still pending in the Jenkins queue.", + activeJob.ctrlplaneJobUUID, + activeJob.queueId); + return false; + } + + /** + * 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, + activeJob.ctrlplaneJobUUID); + return false; + } + + 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; + } + + 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; + } + + /** + * Processes the list of pending jobs polled from Ctrlplane. + * Uses a try-catch around each job's processing to isolate errors. + */ + private void processJobs(List> pendingJobs) { + JobProcessingStats stats = new JobProcessingStats(); + for (Map jobMap : pendingJobs) { + try { + processSingleJob(jobMap, stats); + } catch (Exception e) { + handleJobError(jobMap, e, stats); + } + } + LOGGER.info( + "Ctrlplane job processing finished. Triggered: {}, Skipped: {}, Errors: {}", + stats.triggered, + stats.skipped, + stats.errors); + } + + /** + * Processes a single job fetched from Ctrlplane using early returns. + */ + private void processSingleJob(Map jobMap, JobProcessingStats stats) { + JobInfo jobInfo = extractJobInfo(jobMap); + if (jobInfo == null) { + stats.skipped++; + return; + } + + if (activeJenkinsJobs.containsKey(jobInfo.jobId)) { + LOGGER.debug("Skipping already tracked active Ctrlplane job ID: {}", jobInfo.jobId); + stats.skipped++; + return; + } + + boolean statusUpdated = updateCtrlplaneJobStatus( + jobInfo.jobId, jobInfo.jobUUID, "in_progress", Map.of("message", "Triggering Jenkins job")); + if (!statusUpdated) { + LOGGER.warn( + "Failed to update Ctrlplane status to in_progress for job {}, proceeding with trigger attempt anyway.", + jobInfo.jobId); + } + + triggerJenkinsJob(jobInfo, stats); + updateJobStatusWithInitialLink(jobInfo); + } + + /** + * Extracts and validates job information from the raw map data. + * Uses early returns for validation checks. + */ + protected JobInfo extractJobInfo(Map jobMap) { + Object idObj = jobMap.get("id"); + if (!(idObj instanceof String)) { + LOGGER.warn("Skipping job: Missing or invalid 'id' field type. Job Data: {}", jobMap); + return null; + } + String jobId = (String) idObj; + if (jobId.isBlank()) { + LOGGER.warn("Skipping job: Blank 'id' field. Job Data: {}", jobMap); + return null; + } + + UUID jobUUID; + try { + jobUUID = UUID.fromString(jobId); + } catch (IllegalArgumentException e) { + LOGGER.warn("Skipping job: Invalid UUID format for job ID '{}'.", jobId); + return null; + } + + Object statusObj = jobMap.get("status"); + if (!(statusObj instanceof String status) || !"pending".equals(status)) { + LOGGER.debug( + "Skipping job ID {}: Status is not pending. Current status: {}", + jobId, + statusObj != null ? statusObj.toString() : "unknown"); + return null; + } + + 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; + + Object jobUrlObj = jobConfig.get("jobUrl"); + if (!(jobUrlObj instanceof String)) { + LOGGER.warn("Skipping job ID {}: Missing or invalid 'jobUrl' field type in jobAgentConfig.", jobId); + return null; + } + String jobUrl = (String) jobUrlObj; + if (jobUrl.isBlank()) { + LOGGER.warn("Skipping job ID {}: Blank 'jobUrl' in jobAgentConfig.", jobId); + return null; + } + + if (extractJobNameFromUrl(jobUrl) == null) { + LOGGER.warn("Skipping job ID {}: Invalid Jenkins job URL format: {}", jobId, jobUrl); + return null; + } + + return new JobInfo(jobId, jobUUID, jobUrl); + } + + /** + * Triggers the corresponding Jenkins job for a Ctrlplane job. + * Handles validation, job lookup, parameter creation, and queuing. + * Updates stats and Ctrlplane status based on success/failure. + * + * @param jobInfo The Ctrlplane job information + * @param stats Statistics object to track processing results + */ + private void triggerJenkinsJob(JobInfo jobInfo, JobProcessingStats stats) { + String jenkinsJobName = extractJobNameFromUrl(jobInfo.jobUrl); + if (jenkinsJobName == null) { + LOGGER.error("Internal error: Jenkins job name is null after validation for URL: {}", jobInfo.jobUrl); + updateCtrlplaneJobStatus( + jobInfo.jobId, + jobInfo.jobUUID, + "failure", + Map.of("message", "Internal error parsing Jenkins job URL")); + stats.errors++; + return; + } + + Job jenkinsItem = Jenkins.get().getItemByFullName(jenkinsJobName, Job.class); + if (jenkinsItem == null) { + handleMissingJenkinsJob(jobInfo); + stats.errors++; + return; + } + + if (!(jenkinsItem instanceof ParameterizedJobMixIn.ParameterizedJob jenkinsJob)) { + handleNonParameterizedJob(jobInfo); + stats.errors++; + return; + } + + StringParameterValue jobIdParam = new StringParameterValue("JOB_ID", jobInfo.jobId); + ParametersAction paramsAction = new ParametersAction(jobIdParam); + + QueueTaskFuture future = jenkinsJob.scheduleBuild2(0, paramsAction); + if (future == null) { + handleFailedTrigger(jobInfo); + stats.errors++; + return; + } + + Queue.Item scheduledItem = null; + Queue queue = Jenkins.get().getQueue(); + for (Queue.Item item : queue.getItems(jenkinsJob)) { + ParametersAction action = item.getAction(ParametersAction.class); + if (action != null) { + ParameterValue param = action.getParameter("JOB_ID"); + if (param instanceof StringParameterValue + && jobInfo.jobId.equals(((StringParameterValue) param).getValue())) { + scheduledItem = item; + break; + } + } + } + + if (scheduledItem == null) { + LOGGER.error( + "Failed to find matching Queue.Item for job '{}', Ctrlplane job ID {}, even though scheduleBuild2 returned a future.", + jenkinsJobName, + jobInfo.jobId); + handleFailedTrigger(jobInfo); + stats.errors++; + return; + } + + ActiveJobInfo activeJob = + new ActiveJobInfo(jobInfo.jobUUID, jenkinsJobName, scheduledItem.getId(), jobInfo.jobUrl); + activeJenkinsJobs.put(jobInfo.jobId, activeJob); + handleSuccessfulTrigger(jobInfo, -1); + stats.triggered++; + } + + /** + * Handles errors caught during the processing loop for a single job. + * Updates stats and attempts to update Ctrlplane status to failure. + */ + private void handleJobError(Map jobMap, Exception e, JobProcessingStats stats) { + // Log the primary error and increment stats immediately + String initialJobIdGuess = "unknown"; // Best guess for logging before validation + Object initialIdObj = jobMap.get("id"); + if (initialIdObj instanceof String) { + initialJobIdGuess = (String) initialIdObj; + } + LOGGER.error("Error processing Ctrlplane job ID '{}': {}", initialJobIdGuess, e.getMessage(), e); + stats.errors++; + + 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."); + return; + } + + String jobId = (String) idObj; + if (jobId.isBlank()) { + LOGGER.error("Cannot update error status: Job ID is blank."); + return; + } + + UUID jobUUID; + try { + jobUUID = UUID.fromString(jobId); + } catch (IllegalArgumentException idEx) { + LOGGER.error("Cannot update error status: Invalid UUID format '{}'.", jobId); + return; // Return early if UUID is invalid + } + + updateCtrlplaneJobStatus( + jobId, jobUUID, "failure", Map.of("message", "Exception during processing: " + e.getMessage())); + } + + /** + * Helper method to update Ctrlplane job status. Includes null check for jobAgent. + * Returns true if the API call was attempted successfully (regardless of API response code). + * Returns false if jobAgent is null or an exception occurs during the call. + */ + private boolean updateCtrlplaneJobStatus( + String ctrlplaneJobId, UUID ctrlplaneJobUUID, String status, Map details) { + if (jobAgent == null) { + LOGGER.error("JobAgent not initialized. Cannot update status for job {}", ctrlplaneJobId); + return false; + } + + return jobAgent.updateJobStatus(ctrlplaneJobUUID, status, details); + } + + /** Handler for when the Jenkins job cannot be found. Updates status and logs. */ + private void handleMissingJenkinsJob(JobInfo jobInfo) { + String jenkinsJobName = extractJobNameFromUrl(jobInfo.jobUrl); + String msg = "Jenkins job not found: " + (jenkinsJobName != null ? jenkinsJobName : jobInfo.jobUrl); + LOGGER.warn("{} (Ctrlplane job ID {})", msg, jobInfo.jobId); + updateCtrlplaneJobStatus(jobInfo.jobId, jobInfo.jobUUID, "failure", Map.of("message", msg)); + } + + /** Handler for when the Jenkins job is not parameterized. Updates status and logs. */ + private void handleNonParameterizedJob(JobInfo jobInfo) { + String jenkinsJobName = extractJobNameFromUrl(jobInfo.jobUrl); + String msg = "Jenkins job not parameterizable: " + (jenkinsJobName != null ? jenkinsJobName : jobInfo.jobUrl); + LOGGER.warn("{} (Ctrlplane job ID {})", msg, jobInfo.jobId); + updateCtrlplaneJobStatus(jobInfo.jobId, jobInfo.jobUUID, "failure", Map.of("message", msg)); + } + + /** Handler for logging successful scheduling. */ + private void handleSuccessfulTrigger(JobInfo jobInfo, int buildNumber) { + if (buildNumber > 0) { + LOGGER.info( + "Successfully scheduled Jenkins job '{}' (build #{}) for Ctrlplane job ID {}", + jobInfo.jobUrl, + buildNumber, + jobInfo.jobId); + return; + } + + LOGGER.info( + "Successfully scheduled Jenkins job '{}' (Ctrlplane job ID {}) - waiting in queue.", + jobInfo.jobUrl, + jobInfo.jobId); + } + + /** Handler for when scheduleBuild2 returns null or queue item cannot be found/retrieved. Updates status and logs. */ + private void handleFailedTrigger(JobInfo jobInfo) { + String msg = "Failed to schedule Jenkins job '" + jobInfo.jobUrl + "' or retrieve queue item ID."; + LOGGER.error("{} (Ctrlplane job ID {})", msg, jobInfo.jobId); + updateCtrlplaneJobStatus(jobInfo.jobId, jobInfo.jobUUID, "failure", Map.of("message", msg)); + } + + /** + * 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, 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); + } + + /** + * Extracts the Jenkins job name from a Jenkins job URL. + * Returns null and logs a warning if the URL is invalid or cannot be parsed. + * Uses early returns for validation. + */ + protected String extractJobNameFromUrl(String jobUrl) { + if (jobUrl == null || jobUrl.isBlank()) { + LOGGER.warn("Cannot extract job name from null or blank URL."); + return null; + } + + java.net.URL url; + try { + url = new java.net.URL(jobUrl); + } catch (java.net.MalformedURLException e) { + LOGGER.warn("Invalid URL format, cannot parse job name: {}", jobUrl, e); + return null; + } + + String path = url.getPath(); + if (path == null || path.isBlank() || !path.contains("/job/")) { + LOGGER.warn("URL path is invalid or does not contain '/job/': {}", jobUrl); + return null; + } + + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + + int jobSegmentIndex = path.indexOf("/job/"); + + path = path.substring(jobSegmentIndex); + + if (!path.startsWith("/job/")) { + LOGGER.error("Internal logic error: Path processing failed for URL {}", jobUrl); + return null; + } + + String jobPath = path.substring(5); + if (jobPath.isBlank()) { + LOGGER.warn("Extracted blank job path component from URL: {}", jobUrl); + return null; + } + + String fullName = jobPath.replace("/job/", "/"); + if (fullName.isBlank() || fullName.contains("//") || fullName.startsWith("/") || fullName.endsWith("/")) { + LOGGER.warn("Extracted invalid full job name '{}' from URL: {}", fullName, jobUrl); + return null; + } + + return fullName; + } + + /** Configuration details for the poller. */ + private static class CtrlplaneConfig { + final String apiUrl; + final String apiKey; + final String agentName; + final String agentWorkspaceId; + final int pollingIntervalSeconds; + + 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. */ + boolean validate() { + if (apiUrl == null || apiUrl.isBlank()) { + LOGGER.error("Ctrlplane API URL not configured. Skipping polling cycle."); + return false; + } + if (apiKey == null || apiKey.isBlank()) { + LOGGER.error("Ctrlplane API key not configured. Skipping polling cycle."); + return false; + } + if (agentName == null || agentName.isBlank()) { + LOGGER.error("Ctrlplane Agent ID (Name) not configured. Skipping polling cycle."); + return false; + } + 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; + } + } + + /** Information about a job received from Ctrlplane. */ + public static class JobInfo { + final String jobId; + final UUID jobUUID; + final String jobUrl; + + JobInfo(String jobId, UUID jobUUID, String jobUrl) { + this.jobId = jobId; + this.jobUUID = jobUUID; + this.jobUrl = jobUrl; + } + } + + /** Information needed to track an active Jenkins job triggered by Ctrlplane. */ + private static class ActiveJobInfo { + final UUID ctrlplaneJobUUID; + final String jenkinsJobName; + final String jobUrl; + final long queueId; + int jenkinsBuildNumber = -1; + + ActiveJobInfo(UUID ctrlplaneJobUUID, String jenkinsJobName, long queueId, String jobUrl) { + this.ctrlplaneJobUUID = ctrlplaneJobUUID; + this.jenkinsJobName = jenkinsJobName; + this.queueId = queueId; + this.jobUrl = jobUrl; + } + } + + /** Statistics for a single polling cycle. */ + public static class JobProcessingStats { + int triggered = 0; + int skipped = 0; + int errors = 0; + } + + /** + * Builds the details map for sending completion status to Ctrlplane, + * including the message, externalId, and constructed Jenkins links. + */ + private Map buildCompletionDetails(ActiveJobInfo activeJob, Run run, String message) { + Map details = new HashMap<>(); + String buildNumberStr = String.valueOf(run.getNumber()); + details.put("message", message); + details.put("externalId", buildNumberStr); + + if (activeJob.jobUrl == null || activeJob.jobUrl.isBlank() || buildNumberStr.isBlank()) { + LOGGER.warn( + "Cannot construct Jenkins links for job UUID {}: Missing original jobUrl ('{}') or build number ('{}')", + activeJob.ctrlplaneJobUUID, + activeJob.jobUrl, + buildNumberStr); + return details; + } + + String baseUrl = activeJob.jobUrl.endsWith("/") ? activeJob.jobUrl : activeJob.jobUrl + "/"; + String statusUrl = baseUrl + buildNumberStr + "/"; + String consoleUrl = statusUrl + "console"; + + Map links = Map.of("Status", statusUrl, "Logs", consoleUrl); + details.put("ctrlplane/links", links); + + return details; + } + + /** + * Updates the Ctrlplane job status immediately after triggering/queuing in Jenkins + * to provide an initial link to the main Jenkins job page. + * + * @param jobInfo Information about the Ctrlplane job. + */ + private void updateJobStatusWithInitialLink(JobInfo jobInfo) { + ActiveJobInfo activeJob = activeJenkinsJobs.get(jobInfo.jobId); + if (activeJob == null) { + LOGGER.warn( + "Could not find ActiveJobInfo for {} immediately after triggering to add initial link.", + jobInfo.jobId); + return; + } + + if (activeJob.jobUrl == null || activeJob.jobUrl.isBlank()) { + LOGGER.warn("ActiveJobInfo for {} is missing the jobUrl, cannot add initial link.", jobInfo.jobId); + return; + } + + Map initialLinks = Map.of("Job", activeJob.jobUrl); + Map queuedDetails = new HashMap<>(); + queuedDetails.put("message", "Jenkins job queued"); // Update message to reflect queued status + queuedDetails.put("ctrlplane/links", initialLinks); + // Note: externalId (build number) is not known yet. + + // Update status again, keeping it in_progress, but adding the link + updateCtrlplaneJobStatus(jobInfo.jobId, jobInfo.jobUUID, "in_progress", queuedDetails); + } +} 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..3aed3ff --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/api/JobAgent.java @@ -0,0 +1,566 @@ +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.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 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(); + + private static final HttpClient httpClient = HttpClient.newBuilder() + .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; + + private final AtomicReference agentIdRef = new AtomicReference<>(null); + + /** + * 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; + } + String path = "/v1/job-agents/name"; + Map requestBody = new HashMap<>(); + requestBody.put("name", this.name); + 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; + } + + AgentResponse agentResponse = makeHttpRequest("PATCH", path, requestBody, AgentResponse.class); + + if (agentResponse != null && agentResponse.getId() != null) { + String agentId = agentResponse.getId(); + agentIdRef.set(agentId); + LOGGER.info("Agent upsert via PATCH {} succeeded. Agent ID: {}", path, agentId); + return true; + } + 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; + } + + /** + * 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 getAgentId() { + return agentIdRef.get(); + } + + /** + * 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, 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) { + if (!ensureRegistered()) { + LOGGER.error("Cannot get jobs: Agent registration/upsert failed or did not provide an ID."); + return Collections.emptyList(); + } + + agentId = agentIdRef.get(); + + if (agentId == null) { + LOGGER.error( + "Internal error: ensureRegistered returned true but agent ID is still null. Cannot get jobs."); + return Collections.emptyList(); + } + } + + String path = String.format("/v1/job-agents/%s/queue/next", agentId); + + Map response = makeHttpRequest("GET", path, null, new TypeReference>() {}); + + if (response == null || !response.containsKey("jobs")) { + LOGGER.warn("Failed to fetch jobs or no jobs available for agent: {}", agentId); + 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; + } + + /** + * Updates the status of a specific job. + * + * @param jobId The UUID of the job to update. + * @param status The new status string (e.g., "in_progress", "successful", "failure"). + * @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", jobId); + Map requestBody = buildJobUpdatePayload(status, details); + + Integer responseCode = makeHttpRequestAndGetCode("PATCH", path, requestBody); + + boolean success = responseCode != null && responseCode >= 200 && responseCode < 300; + logStatusUpdateResult(success, jobId, status, responseCode); + return success; + } + + /** + * 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 (details == null || details.isEmpty()) { + return payload; + } + + 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(); + } + } + + return null; + } + + /** + * 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 { + LOGGER.error( + "Failed to update status for job {} to {}. Response code: {}", + jobId, + status, + responseCode != null ? responseCode : "N/A"); + } + } + + /** + * Retrieves job details from the Ctrlplane API by job ID. + * + * @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) { + LOGGER.error("Invalid input for getJob: Job ID cannot be null."); + return null; + } + + String path = String.format("/v1/jobs/%s", jobId); + LOGGER.debug("Attempting to GET job details from path: {}", path); + + 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 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 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) { + HttpResponse response = executeRequest(method, path, requestBody); + if (response == null) { + return null; + } + + try { + return handleResponse(response, responseType); + } catch (IOException e) { + LOGGER.error("Error processing response from {} {}: {}", method, path, e.getMessage(), e); + return null; + } + } + + /** + * 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) { + HttpResponse response = executeRequest(method, path, requestBody); + if (response == null) { + return null; + } + + try { + return handleResponse(response, responseType); + } 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 HTTP status code. + * + * @param method HTTP method (e.g., PUT, POST) + * @param path API endpoint path + * @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 request = buildRequest(path, method, requestBody).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + int statusCode = response.statusCode(); + + 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; + } + } + + /** + * 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 --- + /** + * 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; + + if (cleanApiUrl.endsWith("/api")) { + fullUrl = cleanApiUrl + cleanPath; + } else { + fullUrl = cleanApiUrl + "/api" + cleanPath; + } + + 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 { + + HttpRequest.BodyPublisher bodyPublisher = HttpRequest.BodyPublishers.noBody(); + + if (requestBody != null && (method.equals("POST") || method.equals("PUT") || method.equals("PATCH"))) { + 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)) + .method(method, bodyPublisher); + } + + // --- 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) { + 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) { + 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()) { + 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(), 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/ctrlplane/steps/CtrlplaneGetJobStep.java b/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java new file mode 100644 index 0000000..d7347de --- /dev/null +++ b/src/main/java/io/jenkins/plugins/ctrlplane/steps/CtrlplaneGetJobStep.java @@ -0,0 +1,173 @@ +package io.jenkins.plugins.ctrlplane.steps; + +import com.google.common.collect.ImmutableSet; +import hudson.AbortException; +import hudson.Extension; +import hudson.model.TaskListener; +import io.jenkins.plugins.ctrlplane.CtrlplaneGlobalConfiguration; +import io.jenkins.plugins.ctrlplane.api.JobAgent; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nonnull; +import org.jenkinsci.plugins.workflow.steps.*; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepDescriptor; +import org.jenkinsci.plugins.workflow.steps.StepExecution; +import org.jenkinsci.plugins.workflow.steps.SynchronousStepExecution; +import org.kohsuke.stapler.DataBoundConstructor; + +/** Pipeline step to fetch job details from the Ctrlplane API. */ +public class CtrlplaneGetJobStep extends Step { + + private final String jobId; + + /** + * Constructor for the Ctrlplane Get Job step. + * + * @param jobId The UUID of the job to fetch from Ctrlplane + * @throws IllegalArgumentException if jobId is null, blank, or not a valid UUID + */ + @DataBoundConstructor + public CtrlplaneGetJobStep(@Nonnull String jobId) { + if (jobId == null || jobId.isBlank()) { + throw new IllegalArgumentException("Job ID cannot be empty for ctrlplaneGetJob step."); + } + try { + UUID.fromString(jobId); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid Job ID format (must be UUID): " + jobId, e); + } + this.jobId = jobId; + } + + /** + * Gets the job ID for this step. + * + * @return The job ID as a string + */ + public String getJobId() { + return jobId; + } + + /** + * Starts the execution of this step. + * + * @param context The step context + * @return A step execution + * @throws Exception if an error occurs during execution + */ + @Override + public StepExecution start(StepContext context) throws Exception { + return new Execution(context, this); + } + + /** + * Inner class that handles the actual execution logic for the step. + */ + private static class Execution extends SynchronousStepExecution> { + + private static final long serialVersionUID = 1L; + + private final transient CtrlplaneGetJobStep step; + + /** + * Constructor for the execution. + * + * @param context The step context + * @param step The step being executed + */ + Execution(StepContext context, CtrlplaneGetJobStep step) { + super(context); + this.step = step; + } + + /** + * Executes the step and returns the job data. + * + * @return A map containing the job data + * @throws Exception if an error occurs during execution + */ + @Override + protected Map run() throws Exception { + StepContext context = getContext(); + TaskListener listener = context.get(TaskListener.class); + UUID jobUUID; + + try { + jobUUID = UUID.fromString(step.getJobId()); + } catch (IllegalArgumentException e) { + throw new AbortException("Invalid Job ID format passed to step execution: " + step.getJobId()); + } + + listener.getLogger().println("Ctrlplane Step: Fetching job details for " + step.getJobId()); + + CtrlplaneGlobalConfiguration config = CtrlplaneGlobalConfiguration.get(); + String apiUrl = config.getApiUrl(); + String apiKey = config.getApiKey(); + String agentName = config.getAgentId(); + String workspaceId = config.getAgentWorkspaceId(); + + if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) { + throw new AbortException("Ctrlplane API URL or API Key not configured in Jenkins global settings."); + } + + if (agentName == null || agentName.isBlank()) { + listener.getLogger().println("Warning: Ctrlplane Agent Name not configured globally."); + agentName = "jenkins-pipeline-step-agent"; + } + if (workspaceId == null || workspaceId.isBlank()) { + listener.getLogger().println("Warning: Ctrlplane Agent Workspace ID not configured globally."); + } + + JobAgent jobAgent = new JobAgent(apiUrl, apiKey, agentName, workspaceId); + + Map jobData = jobAgent.getJob(jobUUID); + + if (jobData == null) { + throw new AbortException("Failed to fetch job details from Ctrlplane API for job " + step.getJobId()); + } + + listener.getLogger().println("Ctrlplane Step: Successfully fetched job details."); + return jobData; + } + } + + /** + * Descriptor for the Ctrlplane Get Job step. + */ + @Extension + public static class DescriptorImpl extends StepDescriptor { + + /** + * Gets the function name used in pipeline scripts. + * + * @return The function name + */ + @Override + public String getFunctionName() { + return "ctrlplaneGetJob"; + } + + /** + * Gets the display name shown in UI snippets. + * + * @return The display name + */ + @Nonnull + @Override + public String getDisplayName() { + return "Get Ctrlplane Job Details"; + } + + /** + * Gets the required context for this step. + * + * @return A set of required context classes + */ + @Override + public Set> getRequiredContext() { + return ImmutableSet.of(TaskListener.class); + } + } +} 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..41fb7af 100644 --- a/src/main/resources/index.jelly +++ b/src/main/resources/index.jelly @@ -1,4 +1,7 @@
- TODO -
+ ${%plugin.description} +

+ ${%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 new file mode 100644 index 0000000..d7c5ac6 --- /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/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.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-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.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-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/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 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/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 new file mode 100644 index 0000000..971008b --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneGlobalConfigurationTest.java @@ -0,0 +1,113 @@ +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 -> { + 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()); + + 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); + + 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()); + }); + + 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/ctrlplane/CtrlplaneJobPollerMockTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerMockTest.java new file mode 100644 index 0000000..3b6665d --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerMockTest.java @@ -0,0 +1,159 @@ +package io.jenkins.plugins.ctrlplane; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import io.jenkins.plugins.ctrlplane.CtrlplaneJobPoller.JobInfo; +import io.jenkins.plugins.ctrlplane.api.JobAgent; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class CtrlplaneJobPollerMockTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Mock + private JobAgent jobAgent; + + // Simple subclass that exposes protected methods for testing + private static class TestableCtrlplaneJobPoller extends CtrlplaneJobPoller { + private final JobAgent mockJobAgent; + + public TestableCtrlplaneJobPoller(JobAgent mockJobAgent) { + this.mockJobAgent = mockJobAgent; + } + + @Override + protected JobAgent createJobAgent( + String apiUrl, String apiKey, String agentName, String agentWorkspaceId, int pollingIntervalSeconds) { + return mockJobAgent; + } + + public void updateJobStatusWithExternalId(JobInfo jobInfo, String status, String trigger, String externalId) { + Map details = new HashMap<>(); + details.put("trigger", trigger); + details.put("externalId", externalId); + this.mockJobAgent.updateJobStatus(jobInfo.jobUUID, status, details); + } + } + + private TestableCtrlplaneJobPoller jobPoller; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + jobPoller = new TestableCtrlplaneJobPoller(jobAgent); + } + + @Test + public void testExtractJobNameFromUrl() { + // Test basic URL parsing + assertEquals("simple-job", jobPoller.extractJobNameFromUrl("http://jenkins-server/job/simple-job/")); + assertEquals( + "folder/subfolder/job", + jobPoller.extractJobNameFromUrl("http://jenkins-server/job/folder/job/subfolder/job/job/")); + assertNull(jobPoller.extractJobNameFromUrl("http://not-a-jenkins-url")); + } + + @Test + public void testJobStatusUpdating() { + // Given + String jobId = "test-job-id"; + UUID jobUUID = UUID.randomUUID(); + String jobUrl = "http://jenkins-server/job/test-job/"; + JobInfo jobInfo = new JobInfo(jobId, jobUUID, jobUrl); + String externalId = "123"; + + // When + Map expectedDetails = new HashMap<>(); + expectedDetails.put("trigger", "Test"); + expectedDetails.put("externalId", externalId); + when(jobAgent.updateJobStatus(eq(jobUUID), eq("in_progress"), eq(expectedDetails))) + .thenReturn(true); + jobPoller.updateJobStatusWithExternalId(jobInfo, "in_progress", "Test", externalId); + + // Then + verify(jobAgent).updateJobStatus(eq(jobUUID), eq("in_progress"), eq(expectedDetails)); + } + + @Test + public void testExtractJobInfo() { + // Create test job data + 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 + JobInfo jobInfo = jobPoller.extractJobInfo(jobMap); + + // Verify + 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 testExtractJobInfo_SkipNonPendingJobs() { + // Create test job data 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 should return null for non-pending jobs + JobInfo jobInfo = jobPoller.extractJobInfo(jobMap); + + // Verify + assertNull("JobInfo should be null for non-pending jobs", jobInfo); + } + + @Test + public void testJobStatusUpdateWithExternalId() { + // Given + String jobId = "test-job-id"; + UUID jobUUID = UUID.randomUUID(); + String jobUrl = "http://jenkins-server/job/test-job/"; + JobInfo jobInfo = new JobInfo(jobId, jobUUID, jobUrl); + 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 + when(jobAgent.updateJobStatus(eq(jobUUID), eq("in_progress"), detailsCaptor.capture())) + .thenReturn(true); + jobPoller.updateJobStatusWithExternalId(jobInfo, "in_progress", "Test", externalId); + + // Then + verify(jobAgent).updateJobStatus(eq(jobUUID), eq("in_progress"), detailsCaptor.capture()); + + // Get the captured details map + Map capturedDetails = detailsCaptor.getValue(); + + // Verify details contain trigger and externalId + assertNotNull("Details map should not be null", capturedDetails); + assertTrue("Details should contain trigger", capturedDetails.containsKey("trigger")); + assertEquals("Trigger should match", "Test", capturedDetails.get("trigger")); + assertTrue("Details should contain externalId", capturedDetails.containsKey("externalId")); + assertEquals("ExternalId should match", externalId, capturedDetails.get("externalId")); + } +} diff --git a/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java new file mode 100644 index 0000000..848b076 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/ctrlplane/CtrlplaneJobPollerTest.java @@ -0,0 +1,71 @@ +package io.jenkins.plugins.ctrlplane; + +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +public class CtrlplaneJobPollerTest { + + private CtrlplaneJobPoller jobPoller; + + @Before + public void setUp() { + jobPoller = new CtrlplaneJobPoller(); + } + + @Test + public void testExtractJobNameFromSimpleUrl() { + String url = "http://jenkins-server/job/simple-job/"; + String expected = "simple-job"; + assertEquals(expected, jobPoller.extractJobNameFromUrl(url)); + } + + @Test + public void testExtractJobNameFromNestedUrl() { + String url = "http://jenkins-server/job/folder/job/subfolder/job/nested-job/"; + String expected = "folder/subfolder/nested-job"; + assertEquals(expected, jobPoller.extractJobNameFromUrl(url)); + } + + @Test + public void testExtractJobNameFromUrlWithContext() { + String url = "http://jenkins-server/jenkins/job/context-job/"; + String expected = "context-job"; + assertEquals(expected, jobPoller.extractJobNameFromUrl(url)); + } + + @Test + public void testExtractJobNameFromComplexUrl() { + String url = + "https://jenkins.example.com:8080/jenkins/job/team-folder/job/project/job/component/job/complex-job/"; + String expected = "team-folder/project/component/complex-job"; + assertEquals(expected, jobPoller.extractJobNameFromUrl(url)); + } + + @Test + public void testExtractJobNameFromInvalidUrl() { + String url = "http://not-a-jenkins-url/something-else"; + assertNull(jobPoller.extractJobNameFromUrl(url)); + } + + @Test + public void testExtractJobNameFromNullUrl() { + assertNull(jobPoller.extractJobNameFromUrl(null)); + } + + @Test + public void testExtractJobNameFromBlankUrl() { + assertNull(jobPoller.extractJobNameFromUrl(" ")); + } + + @Test + public void testExtractJobNameFromUrlWithParameters() { + String url = "http://jenkins-server/job/parameterized-job/?param1=value1¶m2=value2"; + String expected = "parameterized-job"; + assertEquals(expected, jobPoller.extractJobNameFromUrl(url)); + } + + @Test + 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/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)); + } +} 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); - } -} diff --git a/src/utils/CtrlplaneClient.groovy b/src/utils/CtrlplaneClient.groovy deleted file mode 100644 index 67b0943..0000000 --- a/src/utils/CtrlplaneClient.groovy +++ /dev/null @@ -1,71 +0,0 @@ -import groovy.json.JsonSlurper - -// Example usage with complex responses: -/* -def response = makeHttpRequest('job-123') - -// Nested object example -println response.data.details.id - -// Array example -response.items.each { item -> - println "Found item: ${item}" -} - -// Array of objects example -def pendingJobs = response.jobs.findAll { it.status == "pending" } -def jobIds = response.jobs.collect { it.id } -*/ - -// Example usage: -// def response = makeHttpRequest('job-123') -// def response = makeHttpRequest('job-123', 'https://custom-api.example.com') - -/** - * Client for interacting with the Ctrlplane API - * - * Example response structure: - * { - * "id": "job-123", - * "status": "running", - * "created_at": "2024-03-20T10:00:00Z" - * } - * - * @param jobId The ID of the job to fetch - * @param baseUrl Optional base URL for the API - * @param apiKey Optional API key for authentication - * @return Map containing the job data or null if request failed - */ -def getJob(String jobId, String baseUrl = 'https://api.example.com', String apiKey = null) { - if (!jobId) { - return null - } - - def url = "${baseUrl}/jobs/${jobId}" - def connection = new URL(url).openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.setRequestProperty('Accept', 'application/json') - - // Add API key if provided - if (apiKey) { - connection.setRequestProperty('Authorization', "Bearer ${apiKey}") - } - - try { - connection.connect() - - if (connection.responseCode != 200) { - return null - } - - def responseStream = connection.inputStream - def responseBody = responseStream.text - - def jsonSlurper = new JsonSlurper() - return jsonSlurper.parseText(responseBody) - } catch (Exception e) { - return null - } finally { - connection.disconnect() - } -} \ No newline at end of file