Conversation
- Add unique ID field to Cloud class for stable identification - Implement cloud/byId URL routing to prevent URL breakage - Add CloudByIdDispatcher in CloudSet and Jenkins.CloudList - Preserve cloud identity across reconfigurations - Add comprehensive tests for unique ID behavior
… URLs instead of name-based encoded URLs
|
|
||
| /** | ||
| * Stapler dispatcher that routes cloud requests by unique ID. | ||
| * Handles URL patterns like /cloud/cloudById/{uuid}/ |
There was a problem hiding this comment.
can't we either do:
/cloud/id/{uuid}
or just:
/cloud/{uuid} and we can route it by checking if the name is a UUID / it matches a cloud that we have a uuid for?
There was a problem hiding this comment.
Ok i will change it to cloud/{uuud}
-Update getDynamic methods in CloudSet to consistently use getById() instead of mixing name-based and UUID-based lookups
There was a problem hiding this comment.
Maybe we should include the uuid in the configure.jelly as a hidden field.
In the doConfigsubmit method we check if the uuids of this and the new cloud are identical. If not we throw an error. Don't know if that works though. Would require a DataBoundSetter setUniqueId
Might avoid all the logic we have in the doConfigSubmit at the beginning
I think we need a test for following scenario:
- Jenkins starts with a cloud that has no uuid
- the cloud should now have an id
- restart Jenkins
- the uuid you get in step 2 shouldn't change.
The problem right now might be that after the call to readResolve during startup there is no save of the Jenkins configuration happening which could lead to a loss of the uuid.
There is work in one of my plugins jenkinsci/agent-maintenance-plugin#320 that would greatly benefit from that uuid
| } | ||
|
|
||
| @Test | ||
| void testReadResolveMigrationAssignsId() throws Exception { |
There was a problem hiding this comment.
This is not testing the readResolve method. You test here that adding a cloud without id to Jenkins assigns a new id.
There was a problem hiding this comment.
In this method i am setting the uniqueId field to null and then adding it so the readResolve is called, i have created a new method namely testUuidPersistsAcrossRestartAfterMigration() that triggers the readResolve and also verifies the UUID persists across restart
There was a problem hiding this comment.
Where is the readResolve called here?
| private volatile String uniqueId; | ||
|
|
||
| /** | ||
| * Uniquely identifies this {@link Cloud} instance among other instances in {@link jenkins.model.Jenkins#clouds}. |
There was a problem hiding this comment.
That comment is not true. Should be adjusted accordingly.
There was a problem hiding this comment.
Actually in my previous commits i had transient, i have removed it now
There was a problem hiding this comment.
I meant the comment in line 123 for the name which states that it uniquely identifies the cloud, which is wrong.
| // Use identity comparison to find the correct cloud to replace | ||
| // This avoids issues where equals() (often based on name) matches multiple | ||
| // clouds | ||
| List<Cloud> newClouds = new ArrayList<>(j.clouds); |
There was a problem hiding this comment.
Is that threadsafe? Assume one user changes a cloud and another user deletes a cloud at the same time. Unlikely but possible. One of the changes might then be lost when you replace the complete cloud list below
There was a problem hiding this comment.
I will synchronize the method
There was a problem hiding this comment.
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 loop over all clouds and find the one with the same id, if identical we can just use j.clouds.replace(this, reconfigured)
| } | ||
| } | ||
|
|
||
| Cloud reconfigured = cloud.reconfigure(req, req.getSubmittedForm()); |
There was a problem hiding this comment.
Should we just use this.reconfigure here?
There was a problem hiding this comment.
Yes, correct i will rectify that
There was a problem hiding this comment.
Note that this was wrong before already
| 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()); |
There was a problem hiding this comment.
Already here the new uuid should be set I think
There was a problem hiding this comment.
Yes, mostly it would be set but but the condition was there so i didnt remove that shall i omit it?
There was a problem hiding this comment.
I mean that here you should call cloud.provisionNewId() before adding it to
- Assign a UUID to each Cloud on creation and via readResolve migration - Persist UUID across Jenkins restarts by saving config after assignment - Synchronize cloud list updates in doConfigSubmit to avoid race conditions - Use this.reconfigure instead of re-fetching the cloud instance - Set UUID eagerly in CloudSet.newInstance to avoid conditional checks - Add tests for UUID migration and persistence across restarts - add hidden field for UUID and validate it
| } | ||
|
|
||
| @Test | ||
| void testReadResolveMigrationAssignsId() throws Exception { |
There was a problem hiding this comment.
Where is the readResolve called here?
| // Simulate pre-uniqueId serialized form | ||
| Field field = Cloud.class.getDeclaredField("uniqueId"); | ||
| field.setAccessible(true); | ||
| field.set(cloud, null); |
There was a problem hiding this comment.
That is not necessary I think. The uniqueId field is not set by the constructor so it should still be null.
There was a problem hiding this comment.
the method testReadResolveMigrationAssignsId() doesn't actually uses the readResolve because it doesn't simulate deserialization, and right the constructor doesn't set the uniqueTd so it is redundant and can be removed
There was a problem hiding this comment.
I am removing the method testReadResolveMigrationAssignsId as the new method testUuidPersistsAcrossRestartAfterMigration handles readResolve calling and verifies the uuid persists across restart
| private volatile String uniqueId; | ||
|
|
||
| /** | ||
| * Uniquely identifies this {@link Cloud} instance among other instances in {@link jenkins.model.Jenkins#clouds}. |
There was a problem hiding this comment.
I meant the comment in line 123 for the name which states that it uniquely identifies the cloud, which is wrong.
| if (!proposedName.equals(this.name) | ||
| && j.getCloud(proposedName) != null) { | ||
| throw new Descriptor.FormException(jenkins.agents.Messages.CloudSet_CloudAlreadyExists(proposedName), "name"); | ||
| throw new Descriptor.FormException( |
There was a problem hiding this comment.
Please restore the formatting to have the throw on one line. There is no real change here.
| // Use identity comparison to find the correct cloud to replace | ||
| // This avoids issues where equals() (often based on name) matches multiple | ||
| // clouds | ||
| List<Cloud> newClouds = new ArrayList<>(j.clouds); |
There was a problem hiding this comment.
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 loop over all clouds and find the one with the same id, if identical we can just use j.clouds.replace(this, reconfigured)
| } | ||
| } | ||
|
|
||
| Cloud reconfigured = cloud.reconfigure(req, req.getSubmittedForm()); |
There was a problem hiding this comment.
Note that this was wrong before already
| * @return dispatcher object for ID-based cloud lookup | ||
| */ | ||
| @SuppressWarnings("unused") // stapler | ||
| public CloudByIdDispatcher getCloudById() { |
There was a problem hiding this comment.
Is that still needed? we don't have the cloudById in the url anymore.
THe UI works fine without this and the CloudByIdDispatcher class
| 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()); |
There was a problem hiding this comment.
I mean that here you should call cloud.provisionNewId() before adding it to
| @Deprecated | ||
| @SuppressWarnings("unused") // stapler | ||
| @Restricted(DoNotUse.class) // stapler | ||
| public Cloud getCloudByIndex(int index) { |
There was a problem hiding this comment.
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)
| // 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) { |
There was a problem hiding this comment.
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)
| JSONObject submittedForm = req.getSubmittedForm(); | ||
|
|
||
| // Validate that the submitted UUID matches this cloud's identity | ||
| String submittedUuid = submittedForm.optString("uniqueId", null); |
There was a problem hiding this comment.
No need for this. The reconfigured variable will get the uuid injected via stapler so you can just check for reconfigured.uniqueId == this.uniqueId
| Cloud result = cloud.reconfigure(req, req.getSubmittedForm()); | ||
| String proposedName = result.name; | ||
|
|
||
| reconfigured.setUniqueIdIfNotSet(this.getUniqueId()); |
There was a problem hiding this comment.
Not needed. The uniqueId should be set already. If it is not something went wrong.
- Remove getCloudById() and CloudByIdDispatcher from CloudSet (redundant; getDynamic already handles UUID routing) - Remove deprecated getCloudByIndex() from CloudSet (no external usage, was DoNotUse) - Remove setUniqueIdIfNotSet() from Cloud (uniqueId now injected via @DataBoundSetter from hidden form field) - Add provisionNewId() call when copying clouds via XStream to ensure distinct identity - Simplify doConfigSubmit() to directly compare uniqueId fields - Update Javadoc for Cloud.name field to reflect it's a display name, not unique identifier - Remove obsolete tests for deleted internal methods
- add provisionNewId() to doDoCreate
There was a problem hiding this comment.
One thing that is not good at the moment is that we we can still create clouds with duplicate uuids. Nobody stops you from modifying the config.xml and change the uuid to something else.
And when working with configuration as code as then when the uuid is missing in the yaml, it is not created upon applying the yaml file with the cloud configurations. The uuid would only be created once you access the UI. So users that configure clouds will need to set a uuid for each cloud to ensure the uuid is stable across Jenkins restarts.
Ideally clouds are no longer persisted as part of the Jenkins global config.xml but in a separate folder and then use the name as folder name. But that would require some mechanism to deduplicate names where I don't know if that can be done so easily without breaking any cloud implementation. It would also require that cloud names are good names.
E.g. with google compute you can create a cloud that is named gce-<google:!*> which would not be a valid file system name
That would require some research on each cloud implementation if the name is used for anything else. Afaik there is at least one cloud that set a hard coded value for the name.
| @SuppressWarnings("unused") // stapler | ||
| @Restricted(DoNotUse.class) // stapler | ||
| public Cloud getCloudById(String id) { | ||
| if (id == null || id.trim().isEmpty()) { |
There was a problem hiding this comment.
The body of the method can be simplified to return clouds.getById(id)
| * This handles migration of existing configurations that don't have IDs. | ||
| */ | ||
| @SuppressWarnings("unused") | ||
| private Object readResolve() { |
There was a problem hiding this comment.
Unfortunately this doesn't work. I tried this with a google cloud and the method is not called on Jenkins startup. You need to add
clouds.stream().forEach(c -> c.getUniqueId());
to the readResolve method in the Jenkins class.
This also ensures that the new ids are persisted
|
|
||
| @Override | ||
| public boolean add(Cloud c) { | ||
| if (getById(c.getUniqueId()) != null) { |
There was a problem hiding this comment.
This is never true as getUniqueId will create a uuid if not set.
| // Not great, but cloud name is final | ||
| xml = xml.replace("<name>" + src.name + "</name>", "<name>" + name + "</name>"); | ||
| // Remove uniqueId so the copy gets a new identity via provisionNewId() | ||
| xml = xml.replaceAll("<uniqueId>[^<]*</uniqueId>", ""); |
There was a problem hiding this comment.
This is dangerous. While extremely unlikely, but if a plugin would define itself a field with name uniqueId you would remove that as well.
You could put it in the databoundconstructor so its mandatory to be specified by jcasc users. |
- Add duplicate UUID detection in Jenkins.readResolve() to handle manually edited config.xml files with duplicate UUIDs - Ensure all clouds get UUIDs on startup, fixing JCasc configs that don't specify UUIDs - Simplify getCloudById() to delegate to clouds.getById() - Remove dangerous regex in CloudSet.doCreate() that could affect plugin fields; CloudList.add() handles duplicate prevention - Update CloudUniqueIdTest to use clouds.replace() for realistic form submission simulation
|
Please take a moment and address the merge conflicts of your pull request. Thanks! |
Fixes #26187
Cloud configuration URLs break when clouds are deleted or reordered. If you have three clouds and delete the first one, the URLs for the remaining two shift, causing 404 errors. This is especially problematic when multiple clouds share the same name since there's no way to distinguish them by URL alone.
The fix assigns each cloud a persistent unique ID (UUID) and routes URLs through that ID instead of the cloud name or index. This way, deleting or reordering clouds doesn't affect the URLs of other clouds.
Testing done
Wrote 14 unit tests in CloudUniqueIdTest covering:
UUID generation and uniqueness across 200 clouds
Thread safety of UUID generation (50 concurrent threads)
Duplicate name clouds can be individually accessed by their unique IDs
Deleting a cloud doesn't affect the IDs of remaining clouds
Copied clouds automatically get a new UUID via the overridden add() in CloudList
Input validation on getCloudById (null, empty, blank, missing)
Backward compatibility with cloudByIndex
Also manually tested on localhost:
Screenshots (UI changes only)
N/A
Proposed changelog entries
Proposed changelog category
/label bug
Proposed upgrade guidelines
N/A
Submitter checklist
@Restrictedor have@since TODOJavadocs, as appropriate.@Deprecated(since = "TODO")or@Deprecated(forRemoval = true, since = "TODO"), if applicable.evalto ease future introduction of Content Security Policy (CSP) directives (see documentation).Desired reviewers
@timja
Before the changes are marked as
ready-for-merge:Maintainer checklist
upgrade-guide-neededlabel is set and there is a Proposed upgrade guidelines section in the pull request title (see example).lts-candidateto be considered.