Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,6 @@ on:
# Note: Change this default to true,
# if the checkbox should be checked by default.
default: false
# If you don't want any automatic trigger in general, then
# the following check_run trigger lines should all be commented.
# Note: Consider the use case #2 config for 'validate_only' below
# as an alternative option!
check_run:
types:
- completed

permissions:
Comment on lines 17 to 21
checks: read
Expand Down
4 changes: 2 additions & 2 deletions Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ buildPlugin(
failFast: false,
timeout: 360,
configurations: [
[platform: 'linux', jdk: 17],
[platform: 'linux', jdk: 21],
[platform: 'windows', jdk: 17],
[platform: 'linux', jdk: 25],
[platform: 'windows', jdk: 21],
])
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jenkins.plugins.openstack.compute;

import hudson.Extension;
import hudson.Functions;
import hudson.model.AsyncPeriodicWork;
import hudson.model.Executor;
import hudson.model.Result;
Expand Down Expand Up @@ -42,10 +43,7 @@ public JCloudsCleanupThread() {

@Override
public long getRecurrencePeriod() {
// fixed value: 1000 millis
long cleanFreq = 1000;

return cleanFreq;
return Functions.getIsUnitTest() ? Long.MAX_VALUE : 1000;
}

@Override
Expand Down Expand Up @@ -105,7 +103,10 @@ private void terminateNodesPendingDeletion() {
if ((System.currentTimeMillis() - cloud.getLastCleanTime()) < cloud.getCleanfreqToMillis()) continue;
if (!comp.isIdle()) continue;

final OfflineCause offlineCause = comp.getNode().getFatalOfflineCause();
final JCloudsSlave node = comp.getNode();
if (node == null) continue;

final OfflineCause offlineCause = node.getFatalOfflineCause();
if (comp.isPendingDelete()) {
LOGGER.log(
Level.INFO, "Deleting pending node " + comp.getName() + ". Reason: " + comp.getOfflineCause());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,12 @@ OfflineCause getFatalOfflineCause() {
// Computer might be gone yet, so use the offline cause attached to node when that happens
OfflineCause oc = computer != null ? computer.getOfflineCause() : getTemporaryOfflineCause();

if (isLaunchTimedOut() && (oc instanceof OfflineCause.LaunchFailed)) return oc;
// After startTimeout, keep treating the agent as failed even if Jenkins
// cleared LaunchFailed while retrying SSH. Otherwise cleanup never
// destroys the server.
if (isLaunchTimedOut() && (oc == null || oc instanceof OfflineCause.LaunchFailed)) {
return oc != null ? oc : new OfflineCause.LaunchFailed();
}

return oc instanceof DiskSpaceMonitorDescriptor.DiskSpace || oc instanceof OfflineCause.ChannelTermination
? oc
Expand Down
142 changes: 133 additions & 9 deletions plugin/src/test/java/jenkins/plugins/openstack/PluginTestRule.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -71,6 +73,7 @@
import jenkins.plugins.openstack.nodeproperties.NodePropertyTwo;
import org.hamcrest.Matchers;
import org.hamcrest.TypeSafeMatcher;
import org.jenkinsci.plugins.cloudstats.CloudStatistics;
import org.jenkinsci.plugins.configfiles.GlobalConfigFiles;
import org.jenkinsci.plugins.resourcedisposer.AsyncResourceDisposer;
import org.junit.runner.Description;
Expand Down Expand Up @@ -281,6 +284,12 @@ public void write(int b) throws IOException {
* Force idle slave cleanup now.
*/
public void triggerOpenstackSlaveCleanup() {
// lastCleanTime is initialized at cloud construction. Without resetting
// it, this "force" path is skipped whenever cleanup last ran inside the
// configured cleanfreq window.
for (JCloudsCloud cloud : JCloudsCloud.getClouds()) {
cloud.setLastCleanTime(0);
}
jenkins.getExtensionList(AsyncPeriodicWork.class)
.get(JCloudsCleanupThread.class)
.execute(TaskListener.NULL);
Expand Down Expand Up @@ -464,6 +473,10 @@ public JCloudsSlave provision(JCloudsCloud cloud, String label)
for (CloudProvisioningListener cl : CloudProvisioningListener.all()) {
cl.onComplete(plannedNode, slave);
}
// CloudStatistics.ProvisioningListener.onComplete(PlannedNode, Node) only
// schedules rename() asynchronously. Call the synchronous path so tests
// observing activity names do not race the Timer thread.
CloudStatistics.ProvisioningListener.get().onComplete(slave.getId(), slave);
jenkins.addNode(slave);
// Wait for node to be added fully - for computer to be created. This does not necessarily wait for it to be
// online
Expand Down Expand Up @@ -618,15 +631,21 @@ public Statement apply(final Statement base, Description description) {
final Statement inner = new Statement() {
@Override
public void evaluate() throws Throwable {
base.evaluate();

// ProcessTree is expected to be called from Remoting thread so we set the result here to prevent
// failure in detecting
Field vetoersExist = ProcessTree.class.getDeclaredField("vetoersExist");
vetoersExist.setAccessible(true);
vetoersExist.set(null, Boolean.FALSE);
for (Map.Entry<String, Proc> slave : slavesToKill.entrySet()) {
killJnlpAgentProcess(slave.getKey(), slave.getValue());
try {
base.evaluate();
} finally {
// Stop JNLP agents first so ComputerListeners cannot persist
// CloudStatistics.xml while JenkinsRule deletes $JENKINS_HOME.
Field vetoersExist = ProcessTree.class.getDeclaredField("vetoersExist");
vetoersExist.setAccessible(true);
vetoersExist.set(null, Boolean.FALSE);
for (Map.Entry<String, Proc> slave : slavesToKill.entrySet()) {
killJnlpAgentProcess(slave.getKey(), slave.getValue());
}
slavesToKill.clear();

cleanupProvisionedAgents();
flushCloudStatistics();
}
}
};
Expand All @@ -641,11 +660,116 @@ public void evaluate() throws Throwable {
};
}

@Override
public void after() throws Exception {
File root = jenkins != null ? jenkins.getRootDir() : null;
try {
super.after();
} catch (IOException e) {
if (root != null && isCloudStatisticsTeardownRace(e)) {
deleteRecursively(root);
return;
}
throw e;
}
}

private static boolean isCloudStatisticsTeardownRace(IOException e) {
if (mentionsCloudStatisticsXml(e)) {
return true;
}
for (Throwable suppressed : e.getSuppressed()) {
if (mentionsCloudStatisticsXml(suppressed)) {
return true;
}
}
return false;
}

private static boolean mentionsCloudStatisticsXml(Throwable t) {
while (t != null) {
String message = t.getMessage();
if (message != null && message.contains("CloudStatistics.xml")) {
return true;
}
t = t.getCause();
}
return false;
}

private static void deleteRecursively(File root) {
if (!root.exists()) {
return;
}
try (var walk = Files.walk(root.toPath())) {
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// Best-effort cleanup of a shutdown race leftover
}
});
} catch (IOException ignored) {
// Best-effort cleanup of a shutdown race leftover
}
}

/**
* Tear down cloud agents before JenkinsRule deletes its temporary home.
* Leftover JNLP processes keep files open and cause DirectoryNotEmptyException
* on Java 21+ (especially macOS).
*/
private void cleanupProvisionedAgents() {
if (jenkins == null) {
return;
}
for (Node node : new ArrayList<>(jenkins.getNodes())) {
if (node instanceof JCloudsSlave slave) {
try {
slave.terminate();
} catch (Exception e) {
System.err.println("Failed to clean up agent " + node.getNodeName() + ": " + e);
}
}
}
try {
AsyncResourceDisposer disposer = AsyncResourceDisposer.get();
for (int i = 0; i < 20 && disposer.isActivated(); i++) {
Thread.sleep(100);
}
} catch (Exception e) {
// Jenkins may already be shutting down
}
}

/**
* CloudStatistics.save() is also invoked from Timer threads (onComplete) and
* ComputerListeners. A write that lands while TemporaryDirectoryAllocator is
* deleting $JENKINS_HOME leaves org.jenkinsci.plugins.cloudstats.CloudStatistics.xml
* behind and fails the test with DirectoryNotEmptyException.
*/
private void flushCloudStatistics() {
if (jenkins == null) {
return;
}
try {
CloudStatistics.get().save();
// Let already-queued Timer persist() calls finish, then write a final
// snapshot so JenkinsRule.after() does not race a late XML rewrite.
Thread.sleep(300);
CloudStatistics.get().save();
} catch (Exception e) {
// Jenkins may already be shutting down
}
}

private void killJnlpAgentProcess(String name, Proc p) throws IOException, InterruptedException {
while (p.isAlive()) {
System.err.println("Killing agent " + p + " for " + name);
p.kill();
}
// Give the OS a moment to release file handles before JenkinsRule deletes $JENKINS_HOME
Thread.sleep(100);
}

@Extension
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import hudson.ExtensionList;
import hudson.model.Item;
import hudson.model.Label;
import hudson.model.Node;
import hudson.model.UnprotectedRootAction;
import hudson.model.User;
import hudson.security.ACL;
Expand Down Expand Up @@ -378,10 +379,12 @@ public void doProvision() throws Exception {
final JCloudsCloud cloudProvision = getCloudWhereUserIsAuthorizedTo(Cloud.PROVISION, template);
cloudProvision.setCleanfreq(120); // to be sure not runned during test
j.executeOnServer(new DoProvision(cloudProvision, template));
waitForNodeCount(1);

final JCloudsCloud itemConfigure = getCloudWhereUserIsAuthorizedTo(Item.CONFIGURE, template);
itemConfigure.setCleanfreq(120); // to be sure not runned during test
j.executeOnServer(new DoProvision(itemConfigure, template));
waitForNodeCount(2);

final JCloudsCloud jenkinsRead = getCloudWhereUserIsAuthorizedTo(Jenkins.READ, template);
jenkinsRead.setCleanfreq(120); // to be sure not runned during test
Expand All @@ -391,6 +394,14 @@ public void doProvision() throws Exception {
} catch (AccessDeniedException3 ex) {
// Expected
}

// Finish CloudStatistics persist while Jenkins is still running. Otherwise an in-flight
// Timer persist rewrites CloudStatistics.xml during JenkinsRule teardown.
for (Node node : new ArrayList<>(j.jenkins.getNodes())) {
if (node instanceof JCloudsSlave slave) {
slave.terminate();
}
}
}

@Test
Expand Down Expand Up @@ -566,6 +577,17 @@ public void cachedOpenstackInstanceInvalidatedIfPasswordChanges() throws Excepti
any(Long.class));
}

private static void waitForNodeCount(int expected) throws InterruptedException {
final long deadline = System.currentTimeMillis() + 20_000;
while (Jenkins.get().getNodes().size() < expected) {
if (System.currentTimeMillis() > deadline) {
fail("Timed out waiting for " + expected + " node(s), have "
+ Jenkins.get().getNodes());
}
Thread.sleep(200);
}
}

private JCloudsCloud getCloudWhereUserIsAuthorizedTo(
final Permission authorized, final JCloudsSlaveTemplate template) {
return j.configureSlaveLaunchingWithFloatingIP(new AclControllingJCloudsCloud(template, authorized));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import jenkins.model.Jenkins;
import jenkins.plugins.openstack.PluginTestRule;
import jenkins.plugins.openstack.PluginTestRule.NetworkAddress;
Expand Down Expand Up @@ -417,14 +416,8 @@ public void timeoutLaunchingSsh() throws Exception {
// instanceOf(OfflineCause.LaunchFailed.class));
assertThat("Cause not fatal after ms " + aliveFor, ofc, instanceOf(OfflineCause.LaunchFailed.class));

TimeUnit.SECONDS.sleep(3); // 3 seconds in order to go over cleanFreq
j.triggerOpenstackSlaveCleanup();

// Wait for the server to be disposed
AsyncResourceDisposer disposer = AsyncResourceDisposer.get();
while (!disposer.getBacklog().isEmpty()) {
Thread.sleep(1000);
}
waitForAsyncResourceDisposer();
verify(cloud.getOpenstack()).destroyServer(any(Server.class));
}

Expand Down Expand Up @@ -522,8 +515,11 @@ private void verifyPreferredAddressUsed(String expectedAddress, Collection<Netwo
assertThat(
computer.buildEnvironment(TaskListener.NULL).get("OPENSTACK_PUBLIC_IP"), startsWith(expectedAddress));
assertThat(cs.getActivities(), Matchers.iterableWithSize(1));
// CloudStatistics renames the activity from the template name to the node
// display name on a Timer thread; wait so this assertion is not racy.
waitForActivityName(slave);
assertEquals(
computer.getName(),
slave.getDisplayName(),
CloudStatistics.get().getActivityFor(computer).getName());

ProvisioningActivity activity = cs.getActivities().get(0);
Expand All @@ -545,6 +541,31 @@ private void verifyPreferredAddressUsed(String expectedAddress, Collection<Netwo
assertThat(activity.getCurrentPhase(), equalTo(ProvisioningActivity.Phase.COMPLETED));
}

/**
* Waits until CloudStatistics has applied the node display name to the activity.
*/
private static void waitForActivityName(JCloudsSlave slave) throws InterruptedException {
final String expected = slave.getDisplayName();
final int millisecondsToWaitBetweenPolls = 100;
final int maxTimeToWaitInMilliseconds = 20000;
final long timestampBeforeWaiting = System.nanoTime();
while (true) {
ProvisioningActivity activity = CloudStatistics.get().getActivityFor(slave);
String actual = activity != null ? activity.getName() : null;
if (expected.equals(actual)) {
return;
}
final long timestampNow = System.nanoTime();
final long timeSpentWaitingInMilliseconds = (timestampNow - timestampBeforeWaiting) / 1000000L;
if (timeSpentWaitingInMilliseconds >= maxTimeToWaitInMilliseconds) {
fail("Timed out waiting " + timeSpentWaitingInMilliseconds + " milliseconds, for activity name of "
+ slave.getId() + " to become " + expected + ". Actually " + actual);
}
Thread.sleep(Math.min(
millisecondsToWaitBetweenPolls, maxTimeToWaitInMilliseconds - timeSpentWaitingInMilliseconds));
}
}

/**
* Waits for CloudStatistics to catch up with the test thread.
* CloudStatistics runs asynchronously, so we have to wait for it to catch
Expand Down
Loading
Loading