-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
Use UUID for clouds #26233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Use UUID for clouds #26233
Changes from all commits
f82800f
66c1f56
7addb81
7054e1f
9554917
5283a32
6873377
02c38c4
1b5fcbf
51d55ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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 | ||
|
|
@@ -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}. | ||
| * | ||
|
|
@@ -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; | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
| */ | ||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please restore the formatting to have the |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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()) + '/'); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |
|
|
||
| package jenkins.agents; | ||
|
|
||
| import edu.umd.cs.findbugs.annotations.CheckForNull; | ||
| import hudson.Extension; | ||
| import hudson.ExtensionList; | ||
| import hudson.Functions; | ||
|
|
@@ -73,7 +74,7 @@ public Descriptor<CloudSet> getDescriptor() { | |
| } | ||
|
|
||
| public Cloud getDynamic(String token) { | ||
| return Jenkins.get().getCloud(token); | ||
| return getById(token); | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -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(); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is that still needed? we don't have the |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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); | ||
|
|
||
|
|
@@ -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()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already here the new uuid should be set I think
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean that here you should call |
||
| if (!Jenkins.get().clouds.add(cloud)) { | ||
|
|
||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
namewhich states that it uniquely identifies the cloud, which is wrong.