diff --git a/README.md b/README.md index e517ad1..bd564f2 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,11 @@ ## Introduction -TODO Describe what your plugin does here +A Jenkins cloud plugin dynamically provisions Multipass VMs as Jenkins agent. ## Getting started -TODO Tell users how to configure your plugin here, include screenshots, pipeline examples and -configuration-as-code examples. +The plugin is still getting built. Instructions are soon to be filled. ## Issues diff --git a/src/main/java/io/hainenber/jenkins/multipass/MultipassAgent.java b/src/main/java/io/hainenber/jenkins/multipass/MultipassAgent.java new file mode 100644 index 0000000..49dfed4 --- /dev/null +++ b/src/main/java/io/hainenber/jenkins/multipass/MultipassAgent.java @@ -0,0 +1,78 @@ +package io.hainenber.jenkins.multipass; + +import hudson.model.Descriptor; +import hudson.model.TaskListener; +import hudson.slaves.AbstractCloudComputer; +import hudson.slaves.AbstractCloudSlave; +import hudson.slaves.ComputerLauncher; +import io.hainenber.jenkins.multipass.sdk.MultipassClient; +import jakarta.annotation.Nonnull; +import jenkins.model.Jenkins; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.Serial; +import java.util.Objects; + +public class MultipassAgent extends AbstractCloudSlave { + private static final Logger LOGGER = LoggerFactory.getLogger(MultipassAgent.class); + private final transient MultipassCloud cloud; + + @Serial + private static final long serialVersionUID = 2553788927582449937L; + + + /** + * Constructor + * @param cloud a {@link MultipassCloud} object; + * @param name the name of the agent. + * @param launcher a {@link hudson.slaves.ComputerLauncher} object. + * @throws hudson.model.Descriptor.FormException if any. + * @throws java.io.IOException if any. + */ + public MultipassAgent(MultipassCloud cloud, @Nonnull String name, @Nonnull ComputerLauncher launcher) throws Descriptor.FormException, IOException { + super(name, "/build", launcher); + this.cloud = cloud; + } + + /** + * Get cloud instance associated with this builder agent. + * @return a {@link MultipassCloud} object. + */ + public MultipassCloud getCloud() { + return cloud; + } + + /** + * {@inheritDoc} + */ + @Override + public AbstractCloudComputer createComputer() { + return new MultipassComputer(this); + } + + /** {@inheritDoc} */ + protected void _terminate(TaskListener listener) { + listener.getLogger().println("[multipass-cloud]: Terminating agent " + getDisplayName()); + + if (getLauncher() instanceof MultipassLauncher) { + var instanceName = Objects.requireNonNull(getComputer()).getName(); + if (StringUtils.isBlank(instanceName)) { + return; + } + + try { + LOGGER.info("[multipass-cloud]: Terminating instance named '{}'", instanceName); + MultipassClient multipassClient = cloud.getMultipassClient(); + multipassClient.terminateInstance(instanceName); + LOGGER.info("[multipass-cloud]: Terminated instance named '{}'", instanceName); + Jenkins.get().removeNode(this); + LOGGER.info("[multipass-cloud]: Removed Multipass instance named '{}' from Jenkins controller", instanceName); + } catch (IOException e) { + LOGGER.warn("[multipass-cloud]: Failed to terminate Multipass instance named '{}'", instanceName); + } + } + } +} diff --git a/src/main/java/io/hainenber/jenkins/multipass/MultipassCloud.java b/src/main/java/io/hainenber/jenkins/multipass/MultipassCloud.java new file mode 100644 index 0000000..3971298 --- /dev/null +++ b/src/main/java/io/hainenber/jenkins/multipass/MultipassCloud.java @@ -0,0 +1,225 @@ +package io.hainenber.jenkins.multipass; + +import hudson.model.Computer; +import hudson.model.Descriptor; +import hudson.model.Label; +import hudson.model.Node; +import hudson.model.labels.LabelAtom; +import hudson.slaves.Cloud; +import hudson.slaves.NodeProvisioner; +import io.hainenber.jenkins.multipass.sdk.MultipassClient; +import jakarta.annotation.Nonnull; +import jenkins.model.Jenkins; +import org.apache.commons.lang.RandomStringUtils; +import org.apache.commons.lang.StringUtils; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.Future; + +/** + * Root class that contains all configuration state about + * Multipass cloud agents. + * + * @author dotronghai + */ +public class MultipassCloud extends Cloud { + private static final Logger LOGGER = LoggerFactory.getLogger(MultipassCloud.class); + private static final int DEFAULT_AGENT_TIMEOUT = 120; + private static final String DEFAULT_AGENT_DISTRIBUTION_ALIAS = "noble"; + + private String[] labels; + private String distroAlias; + private Integer cpus; + private String memory; + private String disk; + private String cloudConfig; + + private transient MultipassClient client; + private transient long lastProvisionTime = 0; + + static { + clearAllNodes(); + } + + /** + * Constructor for MultipassCloud + * @param name the name of the cloud, will auto-generated if not inputted. + */ + @DataBoundConstructor + public MultipassCloud(String name, @Nonnull String projectName) { + super(StringUtils.isNotBlank(name) ? name: String.format("multipass_cloud_%s", jenkinsController().clouds.size())); + LOGGER.info("[multipass-cloud] Initializing Cloud {}", this); + } + + /** + * Get the active Jenkins instance. + * + * @return a {@link Jenkins} object. + */ + @Nonnull + protected static Jenkins jenkinsController() { + return Objects.requireNonNull(Jenkins.getInstanceOrNull()); + } + + /** + * Clear all nodes on bootup + */ + private static void clearAllNodes() { + List nodes = jenkinsController().getNodes(); + if (nodes.isEmpty()) { + return; + } + + LOGGER.info("[multipass-cloud]: Deleting all previous Multipass nodes..."); + + for (final Node node: nodes) { + if (node instanceof MultipassAgent) { + try { + ((MultipassAgent) node).terminate(); + } catch (InterruptedException | IOException e) { + LOGGER.error("[multipass-cloud]: Failed to terminate agent '{}'", node.getDisplayName(), e); + } + } + } + } + + /** Getter for the field client + * + * @return a {@link MultipassClient} + */ + public synchronized MultipassClient getMultipassClient() { + if (this.client == null) { + this.client = new MultipassClient(); + } + return this.client; + } + + public String getLabel() { + return ""; + } + + /** {@inheritDoc} */ + @Override + public synchronized Collection provision(CloudState cloudState, int excessWorkload) { + List nodeList = new ArrayList(); + Label label = cloudState.getLabel(); + + // Guard against non-matching labels + if (label != null && !label.matches(List.of(new LabelAtom(getLabel())))) { + return nodeList; + } + + // Guard against double-provisioning with a 500ms cooldown check + long timeDiff = System.currentTimeMillis() - lastProvisionTime; + if (timeDiff < 500) { + LOGGER.info("[multipass-cloud] Provision of {} skipped, still on cooldown ({}ms of 500ms)", + excessWorkload, + timeDiff + ); + return nodeList; + } + + String labelName = Objects.isNull(label) ? getLabel() : label.getDisplayName(); + long currentlyProvisioningInstanceCount = getCurrentlyProvisionedAgentCount(); + long numInstancesToLaunch = Math.max(excessWorkload - currentlyProvisioningInstanceCount, 0); + LOGGER.info("[multipass-cloud] Provisioning {} nodes for label '{}' ({} already provisioning)", + numInstancesToLaunch, + labelName, + currentlyProvisioningInstanceCount + ); + + // Initializing builder nodes and add to list of provisioned instances. + for (int i = 0; i < numInstancesToLaunch; i++) { + final String suffix = RandomStringUtils.randomAlphabetic(4); + final String displayName = String.format("%s-multipass", suffix); + final MultipassCloud cloud = this; + final Future nodeResolver = Computer.threadPoolForRemoting.submit(() -> { + MultipassLauncher launcher = new MultipassLauncher(cloud); + try { + MultipassAgent agent = new MultipassAgent(cloud, displayName, launcher); + jenkinsController().addNode(agent); + return agent; + } catch (Descriptor.FormException | IOException e) { + LOGGER.error("[multipass-cloud] Exception when initializing new Multipass agent: %s", e); + return null; + } + }); + nodeList.add(new NodeProvisioner.PlannedNode(displayName, nodeResolver, 1)); + } + + lastProvisionTime = System.currentTimeMillis(); + + return nodeList; + } + + /** + * Find the number of {@link MultipassAgent} instances still connecting + * to Jenkins controller + */ + private long getCurrentlyProvisionedAgentCount() { + return jenkinsController().getNodes() + .stream() + .filter(MultipassAgent.class::isInstance) + .map(MultipassAgent.class::cast) + .filter(a -> a.getLauncher().isLaunchSupported()) + .count(); + } + + /** + * + * @return int + */ + public String getName() { + return this.name; + } + + public String getCloudInitConfig() { + return this.cloudConfig; + } + + @DataBoundSetter + public void setCloudInitConfig(String cloudConfig) { + this.cloudConfig = cloudConfig; + } + + public Integer getCPUs() { + return this.cpus; + } + + @DataBoundSetter + public void setCpus(Integer cpus) { + this.cpus = cpus; + } + + public String getMemory() { + return this.memory; + } + + @DataBoundSetter + public void setMemory(String memory) { + this.memory = memory; + } + + public String getDisk() { + return this.disk; + } + + @DataBoundSetter + public void setDisk(String disk) { + this.disk = disk; + } + + public String getDistroAlias() { + return Objects.isNull(distroAlias) ? DEFAULT_AGENT_DISTRIBUTION_ALIAS : distroAlias; + } + + @DataBoundSetter + public void setDistroAlias(String distroAlias) { + this.distroAlias = distroAlias; + } +} diff --git a/src/main/java/io/hainenber/jenkins/multipass/MultipassComputer.java b/src/main/java/io/hainenber/jenkins/multipass/MultipassComputer.java new file mode 100644 index 0000000..051fd06 --- /dev/null +++ b/src/main/java/io/hainenber/jenkins/multipass/MultipassComputer.java @@ -0,0 +1,75 @@ +package io.hainenber.jenkins.multipass; + +import hudson.model.Computer; +import hudson.model.Executor; +import hudson.model.Queue; +import hudson.slaves.AbstractCloudComputer; +import jakarta.annotation.Nonnull; +import org.apache.commons.lang.time.DurationFormatUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +import java.util.StringJoiner; +import java.util.concurrent.Future; + +public class MultipassComputer extends AbstractCloudComputer { + private static final Logger LOGGER = LoggerFactory.getLogger(MultipassComputer.class); + + @Nonnull + private final MultipassCloud cloud; + + /** + * Constructor for MultipassComputer + * @param multipassAgent a {@link MultipassAgent} object. + */ + public MultipassComputer(MultipassAgent multipassAgent) { + super(multipassAgent); + this.cloud = multipassAgent.getCloud(); + } + + @Override + public void taskAccepted(Executor executor, Queue.Task task) { + super.taskAccepted(executor, task); + LOGGER.info("[multipass-cloud] [{}]: Task in job '{}' accepted", this, task.getFullDisplayName()); + } + + @Override + public void taskCompleted(Executor executor, Queue.Task task, long durationMS) { + super.taskCompleted(executor, task, durationMS); + LOGGER.info("[multipass-cloud] [{}]: Task in job '{}' completed in {}", this, task.getFullDisplayName(), DurationFormatUtils.formatDurationWords(durationMS, true, true)); + gracefulShutdown(); + } + + @Override + public void taskCompletedWithProblems(Executor executor, Queue.Task task, long durationMS, Throwable problems) { + super.taskCompletedWithProblems(executor, task, durationMS, problems); + LOGGER.info("[multipass-cloud] [{}]: Task in job '{}' completed with problems in {}", this, task.getFullDisplayName(), DurationFormatUtils.formatDurationWords(durationMS, true, true)); + gracefulShutdown(); + } + + @Override + public String toString() { + return new StringJoiner(", ", MultipassComputer.class.getSimpleName() + "[", "]") + .add("cloud=" + cloud) + .toString(); + } + + private void gracefulShutdown() { + // Mark the computer to no longer accept new tasks; + setAcceptingTasks(false); + + Future next = Computer.threadPoolForRemoting.submit(() -> { + LOGGER.info("[multipass-cloud] [{}]: Terminating agent after task.", this); + try { + Thread.sleep(500); + MultipassCloud.jenkinsController().removeNode(Objects.requireNonNull(getNode())); + } catch (Exception e) { + LOGGER.info("[multipass-cloud] [{}]: Error encounter when trying to terminate agent: {}", this, e.getClass()); + } + return null; + }); + + next.notify(); + } +} diff --git a/src/main/java/io/hainenber/jenkins/multipass/MultipassLauncher.java b/src/main/java/io/hainenber/jenkins/multipass/MultipassLauncher.java new file mode 100644 index 0000000..05b31a7 --- /dev/null +++ b/src/main/java/io/hainenber/jenkins/multipass/MultipassLauncher.java @@ -0,0 +1,72 @@ +package io.hainenber.jenkins.multipass; + +import hudson.model.Node; +import hudson.model.TaskListener; +import hudson.slaves.ComputerLauncher; +import hudson.slaves.SlaveComputer; +import jakarta.annotation.Nonnull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class MultipassLauncher extends ComputerLauncher { + private static final Logger LOGGER = LoggerFactory.getLogger(MultipassLauncher.class); + private final MultipassCloud cloud; + + /** + * Constructor for MultipassLauncher + * + * @param cloud + */ + public MultipassLauncher(MultipassCloud cloud) { + super(); + this.cloud = cloud; + } + + @Override + public void launch(@Nonnull SlaveComputer slaveComputer, @Nonnull TaskListener listener) throws IOException, InterruptedException { + try { + MultipassComputer computer = (MultipassComputer) slaveComputer; + launchScript(computer, listener); + } catch (IOException e) { + e.printStackTrace(listener.error(e.getMessage())); + LOGGER.info("[multipass-cloud] Terminating Multipass agent {} due to problem launching or connecting to it.", slaveComputer.getName()); + var multipassComputer = ((MultipassComputer) slaveComputer).getNode(); + if (multipassComputer != null) { + multipassComputer.terminate(); + } + } + } + + protected void launchScript(MultipassComputer computer, TaskListener listener) throws IOException, InterruptedException { + Node node = computer.getNode(); + if (node == null) { + LOGGER.info("[multipass-cloud] Not launching {} since it is missing a node.", computer); + return; + } + + LOGGER.info("[multipass-cloud] Launching {} with {}", computer, listener); + try { + cloud.getMultipassClient().createInstance( + cloud.getName(), + cloud.getCloudInitConfig(), + cloud.getCPUs(), + cloud.getMemory(), + cloud.getDisk(), + cloud.getDistroAlias() + ); + LOGGER.info("[multipass-cloud] Waiting for agent '{}' to be connected", computer); + + } catch (Exception e) { + LOGGER.error("[multipass-cloud] Exception when launching Multipass VM: {}", e.getMessage()); + listener.fatalError("[multipass-cloud] Exception when launching Multipass VM: %s", e.getMessage()); + + try { + MultipassCloud.jenkinsController().removeNode(node); + } catch (IOException e1) { + LOGGER.error("[multipass-cloud] Failed to terminate agent: {}", node.getDisplayName(), e); + } + } + } +} diff --git a/src/main/java/io/hainenber/jenkins/multipass/sdk/MultipassInstance.java b/src/main/java/io/hainenber/jenkins/multipass/sdk/MultipassInstance.java new file mode 100644 index 0000000..261adcb --- /dev/null +++ b/src/main/java/io/hainenber/jenkins/multipass/sdk/MultipassInstance.java @@ -0,0 +1,113 @@ +package io.hainenber.jenkins.multipass.sdk; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.annotation.Nullable; + +import java.util.List; +import java.util.Objects; + +public class MultipassInstance { + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public InstanceState getState() { + return state; + } + + public void setState(InstanceState state) { + this.state = state; + } + + public int getSnapshots() { + return snapshots; + } + + public void setSnapshots(int snapshots) { + this.snapshots = snapshots; + } + + @Nullable + public List getIpv4() { + return ipv4; + } + + public void setIpv4(@Nullable List ipv4) { + this.ipv4 = ipv4; + } + + public String getReleaseName() { + return releaseName; + } + + public void setReleaseName(String releaseName) { + this.releaseName = releaseName; + } + + public String getImageHash() { + return imageHash; + } + + public void setImageHash(String imageHash) { + this.imageHash = imageHash; + } + + public int getCpus() { + return cpus; + } + + public void setCpus(int cpus) { + this.cpus = cpus; + } + + @JsonProperty(value = "name", required = true) + private String name; + + @JsonProperty(value = "state", required = true) + private InstanceState state; + + @JsonProperty("snapshots") + private int snapshots; + + @JsonProperty("ipv4") + private List ipv4; + + @JsonProperty("release") + private String releaseName; + + @Nullable + @JsonProperty("image_hash") + private String imageHash; + + @JsonProperty("cpus") + private int cpus; + + public MultipassInstance() {} + + public MultipassInstance(String name, InstanceState state, int snapshots, List ipv4, String releaseName, String imageHash, int cpus) { + this.name = name; + this.state = state; + this.snapshots = snapshots; + this.ipv4 = ipv4; + this.releaseName = releaseName; + this.imageHash = imageHash; + this.cpus = cpus; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MultipassInstance that = (MultipassInstance) o; + return getSnapshots() == that.getSnapshots() && getCpus() == that.getCpus() && Objects.equals(getName(), that.getName()) && getState() == that.getState() && Objects.equals(getIpv4(), that.getIpv4()) && Objects.equals(getReleaseName(), that.getReleaseName()) && Objects.equals(getImageHash(), that.getImageHash()); + } + + @Override + public int hashCode() { + return Objects.hash(getName(), getState(), getSnapshots(), getIpv4(), getReleaseName(), getImageHash(), getCpus()); + } +} diff --git a/src/main/resources/hainenber/jenkins/multipass/MultipassCloud/config.jelly b/src/main/resources/hainenber/jenkins/multipass/MultipassCloud/config.jelly new file mode 100644 index 0000000..378479f --- /dev/null +++ b/src/main/resources/hainenber/jenkins/multipass/MultipassCloud/config.jelly @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/index.jelly b/src/main/resources/index.jelly index 35f37a7..80d8eac 100644 --- a/src/main/resources/index.jelly +++ b/src/main/resources/index.jelly @@ -1,4 +1,4 @@
- TODO + Multipass Cloud Plugin: Dynamically provision Multipass VM as Jenkins agent.