Skip to content

Use UUID for clouds#26233

Open
Raghav1428 wants to merge 18 commits intojenkinsci:masterfrom
Raghav1428:use-uuid-for-clouds
Open

Use UUID for clouds#26233
Raghav1428 wants to merge 18 commits intojenkinsci:masterfrom
Raghav1428:use-uuid-for-clouds

Conversation

@Raghav1428
Copy link

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:

  • Created three clouds with the same name
  • Verified each got a unique URL (cloud/cloudById/{uuid}/)
  • Deleted the first cloud — the other two remained accessible
  • Copied a cloud via the UI — the copy got a different UUID
  • Edited and saved a cloud — URL stayed the same

Screenshots (UI changes only)

N/A

Proposed changelog entries

  • Stabilize cloud configuration URLs by using persistent unique IDs instead of name or index-based routing

Proposed changelog category

/label bug

Proposed upgrade guidelines

N/A

Submitter checklist

  • The issue, if it exists, is well-described.
  • The changelog entries and upgrade guidelines are appropriate for the audience affected by the change (users or developers, depending on the change) and are in the imperative mood (see examples). Fill in the Proposed upgrade guidelines section only if there are breaking changes or changes that may require extra steps from users during upgrade.
  • There is automated testing or an explanation as to why this change has no tests.
  • New public classes, fields, and methods are annotated with @Restricted or have @since TODO Javadocs, as appropriate.
  • New deprecations are annotated with @Deprecated(since = "TODO") or @Deprecated(forRemoval = true, since = "TODO"), if applicable.
  • UI changes do not introduce regressions when enforcing the current default rules of Content Security Policy Plugin. In particular, new or substantially changed JavaScript is not defined inline and does not call eval to ease future introduction of Content Security Policy (CSP) directives (see documentation).
  • For dependency updates, there are links to external changelogs and, if possible, full differentials.
  • For new APIs and extension points, there is a link to at least one consumer.

Desired reviewers

@timja

Before the changes are marked as ready-for-merge:

Maintainer checklist

  • There are at least two (2) approvals for the pull request and no outstanding requests for change.
  • Conversations in the pull request are over, or it is explicit that a reviewer is not blocking the change.
  • Changelog entries in the pull request title and/or Proposed changelog entries are accurate, human-readable, and in the imperative mood.
  • Proper changelog labels are set so that the changelog can be generated automatically.
  • If the change needs additional upgrade steps from users, the upgrade-guide-needed label is set and there is a Proposed upgrade guidelines section in the pull request title (see example).
  • If it would make sense to backport the change to LTS, be a Bug or Improvement, and either the issue or pull request must be labeled as lts-candidate to be considered.

- 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
@comment-ops-bot comment-ops-bot bot added the bug For changelog: Minor bug. Will be listed after features label Jan 31, 2026

/**
* Stapler dispatcher that routes cloud requests by unique ID.
* Handles URL patterns like /cloud/cloudById/{uuid}/
Copy link
Member

Choose a reason for hiding this comment

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

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?

Copy link
Author

Choose a reason for hiding this comment

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

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
Copy link
Contributor

@mawinter69 mawinter69 left a comment

Choose a reason for hiding this comment

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

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:

  1. Jenkins starts with a cloud that has no uuid
  2. the cloud should now have an id
  3. restart Jenkins
  4. 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 {
Copy link
Contributor

@mawinter69 mawinter69 Feb 1, 2026

Choose a reason for hiding this comment

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

This is not testing the readResolve method. You test here that adding a cloud without id to Jenkins assigns a new id.

Copy link
Author

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

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}.
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.

// 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);
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 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

Copy link
Author

Choose a reason for hiding this comment

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

I will synchronize the method

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 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());
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we just use this.reconfigure here?

Copy link
Author

Choose a reason for hiding this comment

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

Yes, correct i will rectify that

Copy link
Contributor

Choose a reason for hiding this comment

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

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());
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

- 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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

Where is the readResolve called here?

// Simulate pre-uniqueId serialized form
Field field = Cloud.class.getDeclaredField("uniqueId");
field.setAccessible(true);
field.set(cloud, null);
Copy link
Contributor

Choose a reason for hiding this comment

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

That is not necessary I think. The uniqueId field is not set by the constructor so it should still be null.

Copy link
Author

Choose a reason for hiding this comment

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

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

Copy link
Author

Choose a reason for hiding this comment

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

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}.
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.

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.

Copy link
Author

Choose a reason for hiding this comment

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

Surei will change that

// 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);
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 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());
Copy link
Contributor

Choose a reason for hiding this comment

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

Note that this was wrong before already

* @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

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.

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) {
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)

// 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)

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

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.

- 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
@Raghav1428 Raghav1428 requested a review from mawinter69 February 4, 2026 13:57
Copy link
Contributor

@mawinter69 mawinter69 left a comment

Choose a reason for hiding this comment

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

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()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

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() {
Copy link
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Author

Choose a reason for hiding this comment

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

Okay i will implement that


@Override
public boolean add(Cloud c) {
if (getById(c.getUniqueId()) != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is never true as getUniqueId will create a uuid if not set.

Copy link
Author

Choose a reason for hiding this comment

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

I will check for that

// 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>", "");
Copy link
Contributor

Choose a reason for hiding this comment

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

This is dangerous. While extremely unlikely, but if a plugin would define itself a field with name uniqueId you would remove that as well.

@timja
Copy link
Member

timja commented Feb 4, 2026

And when working with configuration as code as then when the uuid is missing in the yaml,

You could put it in the databoundconstructor so its mandatory to be specified by jcasc users.
Edit: You would have to create a new constructor in core and then update plugins to call it

- 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
@Raghav1428 Raghav1428 requested a review from mawinter69 February 5, 2026 12:52
@github-actions github-actions bot added the unresolved-merge-conflict There is a merge conflict with the target branch. label Feb 7, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Feb 7, 2026

Please take a moment and address the merge conflicts of your pull request. Thanks!

@github-actions github-actions bot removed the unresolved-merge-conflict There is a merge conflict with the target branch. label Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug For changelog: Minor bug. Will be listed after features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cloudbyIndex approach to handle clouds with same name is broken

3 participants