Skip to content
Open
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
166 changes: 148 additions & 18 deletions core/src/main/java/hudson/slaves/Cloud.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,18 @@
import hudson.util.FormApply;
import jakarta.servlet.ServletException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.Future;
import jenkins.model.Jenkins;
import net.sf.json.JSONObject;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.DoNotUse;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.HttpRedirect;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.StaplerRequest;
Expand All @@ -66,26 +70,34 @@
import org.kohsuke.stapler.verb.POST;

/**
* Creates {@link Node}s to dynamically expand/shrink the agents attached to Hudson.
* Creates {@link Node}s to dynamically expand/shrink the agents attached to
* Hudson.
*
* <p>
* Put another way, this class encapsulates different communication protocols
* needed to start a new agent programmatically.
*
* <h2>Notes for implementers</h2>
* <h3>Automatically delete idle agents</h3>
* Nodes provisioned from a cloud do not automatically get released just because it's created from {@link Cloud}.
* Doing so requires a use of {@link RetentionStrategy}. Instantiate your {@link Slave} subtype with something
* like {@link CloudSlaveRetentionStrategy} so that it gets automatically deleted after some idle time.
* Nodes provisioned from a cloud do not automatically get released just because
* it's created from {@link Cloud}.
* Doing so requires a use of {@link RetentionStrategy}. Instantiate your
* {@link Slave} subtype with something
* like {@link CloudSlaveRetentionStrategy} so that it gets automatically
* deleted after some idle time.
*
* <h3>Freeing an external resource when an agent is removed</h3>
* Whether you do auto scale-down or not, you often want to release an external resource tied to a cloud-allocated
* Whether you do auto scale-down or not, you often want to release an external
* resource tied to a cloud-allocated
* agent when it is removed.
*
* <p>
* To do this, have your {@link Slave} subtype remember the necessary handle (such as EC2 instance ID)
* as a field. Such fields need to survive the user-initiated re-configuration of {@link Slave}, so you'll need to
* expose it in your {@link Slave} {@code configure-entries.jelly} and read it back in through {@link DataBoundConstructor}.
* To do this, have your {@link Slave} subtype remember the necessary handle
* (such as EC2 instance ID)
* as a field. Such fields need to survive the user-initiated re-configuration
* of {@link Slave}, so you'll need to
* expose it in your {@link Slave} {@code configure-entries.jelly} and read it
* back in through {@link DataBoundConstructor}.
*
* <p>
* You then implement your own {@link Computer} subtype, override {@link Slave#createComputer()}, and instantiate
Expand All @@ -109,6 +121,12 @@
*/
public abstract class Cloud extends Actionable implements ExtensionPoint, Describable<Cloud>, AccessControlled {

/**
* Unique identifier for this cloud instance.
* Used for stable URL routing when multiple clouds have the same name.
*/
private volatile String uniqueId;

/**
* Uniquely identifies this {@link Cloud} instance among other instances in {@link jenkins.model.Jenkins#clouds}.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That comment is not true. Should be adjusted accordingly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually in my previous commits i had transient, i have removed it now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant the comment in line 123 for the name which states that it uniquely identifies the cloud, which is wrong.

*
Expand All @@ -128,6 +146,60 @@ private static String validateNotEmpty(String name) {
return name;
}

/**
* Called after XStream deserialization to ensure uniqueId exists.
* This handles migration of existing configurations that don't have IDs.
*/
@SuppressWarnings("unused")
private Object readResolve() {
if (uniqueId == null) {
uniqueId = UUID.randomUUID().toString();
}
return this;
}

/**
* Gets the unique identifier for this cloud.
* Thread-safe with double-checked locking for performance.
* @return unique identifier string, never null
*/
@NonNull
public String getUniqueId() {
String id = uniqueId;
if (id == null) {
synchronized (this) {
id = uniqueId;
if (id == null) {
uniqueId = id = UUID.randomUUID().toString();
}
}
}
return id;
}

/**
* Sets the unique ID if it hasn't been set yet.
* Used during reconfiguration to preserve identity.
* @param id the unique identifier to set
*/
protected void setUniqueIdIfNotSet(String id) {
if (this.uniqueId == null && id != null) {
this.uniqueId = id;
}
}

/**
* Sets the unique ID from form submission.
* Only sets if the current uniqueId is null and the provided id is valid.
* @param id the unique identifier from form data
*/
@DataBoundSetter
public void setUniqueId(String id) {
if (this.uniqueId == null && id != null && !id.trim().isEmpty()) {
this.uniqueId = id;
}
}

@Override
public String getDisplayName() {
return name;
Expand All @@ -140,7 +212,7 @@ public String getDisplayName() {
* @return Jenkins relative URL.
*/
public @NonNull String getUrl() {
return "cloud/" + Util.rawEncode(name) + "/";
return "cloud/" + Util.rawEncode(getUniqueId()) + "/";
}

@Override
Expand Down Expand Up @@ -316,6 +388,17 @@ public HttpResponse doDoDelete() throws IOException {
return new HttpRedirect("..");
}

/*
* Accepts the update to the node configuration.
*/
/**
* Generates a new unique ID for this cloud instance.
* Useful when copying a cloud to ensure the copy has a distinct identity.
*/
public synchronized void provisionNewId() {
uniqueId = UUID.randomUUID().toString();
}

/**
* Accepts the update to the node configuration.
*/
Expand All @@ -324,21 +407,68 @@ public HttpResponse doConfigSubmit(StaplerRequest2 req, StaplerResponse2 rsp) th
checkPermission(Jenkins.ADMINISTER);

Jenkins j = Jenkins.get();
Cloud cloud = j.getCloud(this.name);
Cloud cloud = j.clouds.getById(this.getUniqueId());
if (cloud == null) {
throw new ServletException("No such cloud " + this.name);
// Fallback to name-based lookup for backwards compatibility
// but this could be problematic if duplicate names exist
cloud = j.getCloud(this.name);
if (cloud == null) {
throw new ServletException("No such cloud " + this.name);
}
}

JSONObject submittedForm = req.getSubmittedForm();

// Validate that the submitted UUID matches this cloud's identity
String submittedUuid = submittedForm.optString("uniqueId", null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this. The reconfigured variable will get the uuid injected via stapler so you can just check for reconfigured.uniqueId == this.uniqueId

if (submittedUuid != null && !submittedUuid.isEmpty()
&& !submittedUuid.equals(this.getUniqueId())) {
throw new Descriptor.FormException(
"Cloud identity mismatch. The cloud may have been modified by another user.",
"uniqueId");
}

Cloud reconfigured = this.reconfigure(req, submittedForm);

if (reconfigured == null) {
j.clouds.remove(cloud);
j.save();
return FormApply.success("../");
}
Cloud result = cloud.reconfigure(req, req.getSubmittedForm());
String proposedName = result.name;

reconfigured.setUniqueIdIfNotSet(this.getUniqueId());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed. The uniqueId should be set already. If it is not something went wrong.


String proposedName = reconfigured.name;
if (!proposedName.equals(this.name)
&& j.getCloud(proposedName) != null) {
throw new Descriptor.FormException(jenkins.agents.Messages.CloudSet_CloudAlreadyExists(proposedName), "name");
throw new Descriptor.FormException(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please restore the formatting to have the throw on one line. There is no real change here.

jenkins.agents.Messages.CloudSet_CloudAlreadyExists(proposedName),
"name");
}
j.clouds.replace(this, result);
j.save();
// take the user back to the cloud top page.
return FormApply.success("../" + result.name + '/');

// Use identity comparison to find the correct cloud to replace
// This avoids issues where equals() (often based on name) matches multiple
// clouds
synchronized (j.clouds) {
Copy link
Contributor

@mawinter69 mawinter69 Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be avoided to replace the complete list. See my general comment to include the uuid as a hidden field in the configure.jelly.
With this the reconfigured cloud should have a uuid already set. We can then just check if it is identical to the uuid of this if not throw an error, if identical we can just use j.clouds.replace(this, reconfigured)

List<Cloud> newClouds = new ArrayList<>(j.clouds);
boolean replaced = false;
for (int i = 0; i < newClouds.size(); i++) {
if (newClouds.get(i) == cloud) {
newClouds.set(i, reconfigured);
replaced = true;
break;
}
}

if (replaced) {
j.clouds.replaceBy(newClouds);
} else {
// Fallback to standard replace if identity match fails (unlikely)
j.clouds.replace(cloud, reconfigured);
}
}
j.save();
return FormApply.success("../" + Util.rawEncode(reconfigured.getUniqueId()) + '/');
}

/**
Expand Down
80 changes: 67 additions & 13 deletions core/src/main/java/jenkins/agents/CloudSet.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

package jenkins.agents;

import edu.umd.cs.findbugs.annotations.CheckForNull;
import hudson.Extension;
import hudson.ExtensionList;
import hudson.Functions;
Expand Down Expand Up @@ -73,7 +74,7 @@ public Descriptor<CloudSet> getDescriptor() {
}

public Cloud getDynamic(String token) {
return Jenkins.get().getCloud(token);
return getById(token);
}

@Override
Expand Down Expand Up @@ -113,11 +114,8 @@ public ManagementLink getManagementLink() {
@Restricted(DoNotUse.class) // stapler
public String getCloudUrl(StaplerRequest2 request, Jenkins jenkins, Cloud cloud) {
String context = Functions.getNearestAncestorUrl(request, jenkins);
if (Jenkins.get().getCloud(cloud.name) != cloud) { // this cloud is not the first occurrence with this name
return context + "/cloud/cloudByIndex/" + getClouds().indexOf(cloud) + "/";
} else {
return context + "/" + cloud.getUrl();
}
// Always use UUID-based URLs for stability across renames, reordering, and duplicates
return context + "/" + cloud.getUrl();
}

/**
Expand All @@ -130,10 +128,66 @@ public String getCloudUrl(StaplerRequest request, Jenkins jenkins, Cloud cloud)
return getCloudUrl(StaplerRequest.toStaplerRequest2(request), jenkins, cloud);
}

/**
* Dispatcher for cloud-by-ID routing. Enables Stapler to route URLs like
* /cloud/{uuid}/ to the correct cloud instance.
*
* @return dispatcher object for ID-based cloud lookup
*/
@SuppressWarnings("unused") // stapler
public CloudByIdDispatcher getCloudById() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that still needed? we don't have the cloudById in the url anymore.
THe UI works fine without this and the CloudByIdDispatcher class

return new CloudByIdDispatcher();
}

/**
* Gets a cloud by its unique ID.
*/
@CheckForNull
public Cloud getById(String id) {
if (id == null || id.trim().isEmpty()) {
return null;
}
for (Cloud c : Jenkins.get().clouds) {
if (id.equals(c.getUniqueId())) {
return c;
}
}
return null;
}

/**
* Stapler dispatcher that routes cloud requests by unique ID.
* Handles URL patterns like /cloud/{uuid}/
*/
public class CloudByIdDispatcher {
/**
* Looks up a cloud by its unique ID.
*
* @param id the unique identifier
* @return the cloud with the given ID, or null if not found
*/
public Cloud getDynamic(String id) {
return CloudSet.this.getById(id);
}
}

/**
* Gets a cloud by its index position.
*
* @param index the position in the cloud list
* @return the cloud at that index, or null if out of bounds
* @deprecated Use {@link #getById(String)} instead. Index-based lookup
* is unreliable when clouds are added or removed.
*/
@Deprecated
@SuppressWarnings("unused") // stapler
@Restricted(DoNotUse.class) // stapler
public Cloud getCloudByIndex(int index) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is safe to delete this method. I was only used in the UI of core. A search on github shows no usage of that method (was anyway annotated as DoNotUse)

return Jenkins.get().clouds.get(index);
List<Cloud> clouds = Jenkins.get().clouds;
if (index >= 0 && index < clouds.size()) {
return clouds.get(index);
}
return null;
}

@SuppressWarnings("unused") // stapler
Expand All @@ -153,8 +207,8 @@ public ModelObjectWithContextMenu.ContextMenu doChildrenContextMenu(StaplerReque
return m;
}

public Cloud getDynamic(String name, StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException {
return Jenkins.get().clouds.getByName(name);
public Cloud getDynamic(String token, StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException {
return getById(token);
}

@SuppressWarnings("unused") // stapler
Expand Down Expand Up @@ -206,8 +260,8 @@ public FormValidation doCheckName(@QueryParameter String value) {
*/
@RequirePOST
public synchronized void doCreate(StaplerRequest2 req, StaplerResponse2 rsp,
@QueryParameter String name, @QueryParameter String mode,
@QueryParameter String from) throws IOException, ServletException, Descriptor.FormException {
@QueryParameter String name, @QueryParameter String mode,
@QueryParameter String from) throws IOException, ServletException, Descriptor.FormException {
final Jenkins jenkins = Jenkins.get();
jenkins.checkPermission(Jenkins.ADMINISTER);

Expand Down Expand Up @@ -260,11 +314,11 @@ private void handleNewCloudPage(Descriptor<Cloud> descriptor, String name, Stapl
*/
@POST
public synchronized void doDoCreate(StaplerRequest2 req, StaplerResponse2 rsp,
@QueryParameter String cloudDescriptorName) throws IOException, ServletException, Descriptor.FormException {
@QueryParameter String cloudDescriptorName) throws IOException, ServletException, Descriptor.FormException {
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
Descriptor<Cloud> cloudDescriptor = Cloud.all().findByName(cloudDescriptorName);
if (cloudDescriptor == null) {
throw new Failure(String.format("No cloud type ‘%s’ is known", cloudDescriptorName));
throw new Failure(String.format("No cloud type '%s' is known", cloudDescriptorName));
}
Cloud cloud = cloudDescriptor.newInstance(req, req.getSubmittedForm());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already here the new uuid should be set I think

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, mostly it would be set but but the condition was there so i didnt remove that shall i omit it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that here you should call cloud.provisionNewId() before adding it to

if (!Jenkins.get().clouds.add(cloud)) {
Expand Down
Loading