Skip to content

Commit

Permalink
feat(*): draft version v1.0 for jenkins-multipass-cloud that pass com…
Browse files Browse the repository at this point in the history
…pilation and unit test
  • Loading branch information
hainenber committed Nov 24, 2024
1 parent 79a2324 commit e7ef1d9
Show file tree
Hide file tree
Showing 8 changed files with 585 additions and 4 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 78 additions & 0 deletions src/main/java/io/hainenber/jenkins/multipass/MultipassAgent.java
Original file line number Diff line number Diff line change
@@ -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<MultipassAgent> 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);
}
}
}
}
225 changes: 225 additions & 0 deletions src/main/java/io/hainenber/jenkins/multipass/MultipassCloud.java
Original file line number Diff line number Diff line change
@@ -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<Node> 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 <code>client</code>
*
* @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<NodeProvisioner.PlannedNode> provision(CloudState cloudState, int excessWorkload) {
List<NodeProvisioner.PlannedNode> nodeList = new ArrayList<NodeProvisioner.PlannedNode>();
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<Node> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<MultipassAgent> {
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<Object> 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();
}
}
Loading

0 comments on commit e7ef1d9

Please sign in to comment.