Skip to content

Commit 1cbb3e3

Browse files
committed
Add Change Password API
1 parent 3ccfcb6 commit 1cbb3e3

File tree

6 files changed

+212
-16
lines changed

6 files changed

+212
-16
lines changed

src/main/kotlin/io/codemc/api/jenkins/jenkins.kt

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,59 @@ import com.cdancy.jenkins.rest.JenkinsClient
66
import io.codemc.api.*
77
import kotlinx.coroutines.Dispatchers
88
import kotlinx.coroutines.runBlocking
9-
import kotlinx.coroutines.withContext
109
import org.jetbrains.annotations.VisibleForTesting
1110
import java.net.http.HttpRequest
1211

12+
/**
13+
* The [JenkinsConfig] instance.
14+
*/
1315
var jenkinsConfig: JenkinsConfig = JenkinsConfig("", "", "")
1416
set(value) {
1517
field = value
1618

1719
val client0 = JenkinsClient.builder()
1820
.endPoint(value.url)
1921

20-
if (value.username.isNotEmpty() && value.password.isNotEmpty())
21-
client0.credentials("${value.username}:${value.password}")
22+
if (value.username.isNotEmpty() && value.token.isNotEmpty())
23+
client0.credentials("${value.username}:${value.token}")
2224

2325
client = client0.build()
2426
}
2527

2628
private lateinit var client: JenkinsClient
2729

30+
/**
31+
* The Jenkins configuration.
32+
* @param url The URL to the Jenkins Instance.
33+
* @param username The Jenkins username.
34+
* @param token The Jenkins API token.
35+
*/
2836
data class JenkinsConfig(
2937
val url: String,
3038
val username: String,
31-
val password: String
39+
val token: String
3240
)
3341

42+
/**
43+
* Pings the Jenkins server.
44+
* @return `true` if the server is reachable, `false` otherwise.
45+
*/
3446
fun ping(): Boolean =
3547
client.api().systemApi().systemInfo().jenkinsVersion() != null
3648

49+
// Credentials API Documentation:
50+
// https://github.com/jenkinsci/credentials-plugin/blob/master/docs/user.adoc
51+
52+
/**
53+
* The Jenkins Credentials ID for the Nexus Credentials.
54+
*/
55+
const val NEXUS_CREDENTIALS_ID = "nexus-repository"
56+
57+
/**
58+
* The Jenkins Credentials Description for the Nexus Credentials.
59+
*/
60+
const val NEXUS_CREDENTIALS_DESCRIPTION = "Your Nexus Login Details"
61+
3762
internal suspend fun createCredentials(username: String, password: String): Boolean {
3863
// Create Credentials Domain
3964
val domainConfig = RESOURCE_CACHE[CREDENTIALS_DOMAIN] ?: return false
@@ -48,6 +73,8 @@ internal suspend fun createCredentials(username: String, password: String): Bool
4873

4974
// Create Credentials Store
5075
val storeConfig = (RESOURCE_CACHE[CREDENTIALS] ?: return false)
76+
.replace("{ID}", NEXUS_CREDENTIALS_ID)
77+
.replace("{DESCRIPTION}", NEXUS_CREDENTIALS_DESCRIPTION)
5178
.replace("{USERNAME}", username.lowercase())
5279
.replace("{PASSWORD}", password)
5380

@@ -61,6 +88,35 @@ internal suspend fun createCredentials(username: String, password: String): Bool
6188
return store.statusCode() == 200
6289
}
6390

91+
/**
92+
* Changes the Jenkins password for a user.
93+
* @param username The username of the user.
94+
* @param newPassword The new password.
95+
* @return `true` if the password was changed, `false` otherwise.
96+
*/
97+
suspend fun changeJenkinsPassword(username: String, newPassword: String): Boolean {
98+
val config = (RESOURCE_CACHE[CREDENTIALS] ?: return false)
99+
.replace("{ID}", NEXUS_CREDENTIALS_ID)
100+
.replace("{DESCRIPTION}", NEXUS_CREDENTIALS_DESCRIPTION)
101+
.replace("{USERNAME}", username.lowercase())
102+
.replace("{PASSWORD}", newPassword)
103+
104+
val res = req("${jenkinsConfig.url}/job/$username/credentials/store/folder/domain/Services/credential/$NEXUS_CREDENTIALS_ID/config.xml") {
105+
POST(HttpRequest.BodyPublishers.ofString(config))
106+
107+
header("Authorization", "Basic ${client.authValue()}")
108+
header("Content-Type", "application/xml")
109+
}
110+
111+
return res.statusCode() == 200
112+
}
113+
114+
/**
115+
* Creates a Jenkins user.
116+
* @param username The username of the user.
117+
* @param password The password of the user.
118+
* @return `true` if the user was created, `false` otherwise.
119+
*/
64120
fun createJenkinsUser(username: String, password: String): Boolean = runBlocking(Dispatchers.IO) {
65121
val config0 = RESOURCE_CACHE[USER_CONFIG] ?: return@runBlocking false
66122

@@ -76,14 +132,31 @@ fun createJenkinsUser(username: String, password: String): Boolean = runBlocking
76132
return@runBlocking createCredentials(username, password)
77133
}
78134

135+
/**
136+
* Gets a Jenkins user's configuration.
137+
* @param username The username of the user.
138+
* @return The user's configuration in XML format.
139+
*/
79140
fun getJenkinsUser(username: String): String {
80141
val user = client.api().jobsApi().config("/", username)
81142
return user ?: ""
82143
}
83144

145+
/**
146+
* Gets all Jenkins users.
147+
* @return A list of all Jenkins users mapped by their username.
148+
*/
84149
fun getAllJenkinsUsers(): List<String>
85150
= client.api().jobsApi().jobList("/").jobs().map { it.name() }
86151

152+
/**
153+
* Creates a Jenkins job.
154+
* @param username The username of the user to create the job at.
155+
* @param jobName The name of the job.
156+
* @param repoLink The Git URL to the repository.
157+
* @param isFreestyle `true` if the job is a freestyle job, `false` otherwise. A freestyle job is defined as a job that isn't built with Maven.
158+
* @param config A function to modify the XML configuration of the job.
159+
*/
87160
@JvmOverloads
88161
fun createJenkinsJob(
89162
username: String,
@@ -110,11 +183,23 @@ internal fun getJenkinsJob(username: String, jobName: String): String {
110183
return job ?: ""
111184
}
112185

186+
/**
187+
* Gets the information of a Jenkins job.
188+
* @param username The username of the user.
189+
* @param jobName The name of the job.
190+
* @return The job information, or `null` if the job doesn't exist.
191+
*/
113192
fun getJobInfo(username: String, jobName: String): JenkinsJob? {
114193
val job = client.api().jobsApi().jobInfo("/", "$username/job/$jobName")
115194
return if (job == null) null else JenkinsJob(job)
116195
}
117196

197+
/**
198+
* Triggers a build for a Jenkins job.
199+
* @param username The username of the user.
200+
* @param jobName The name of the job.
201+
* @return `true` if the build was triggered, `false` otherwise.
202+
*/
118203
fun triggerBuild(username: String, jobName: String): Boolean {
119204
val status = client.api().jobsApi().build("/", "$username/job/$jobName")
120205
if (status.errors().isNotEmpty()) {
@@ -131,6 +216,11 @@ internal fun isBuilding(username: String, jobName: String): Boolean {
131216
return (job.color() ?: "").contains("anime") || job.inQueue() || (job.lastBuild()?.building() ?: false)
132217
}
133218

219+
/**
220+
* Deletes a Jenkins user.
221+
* @param username The username of the user.
222+
* @return `true` if the user was deleted, `false` otherwise.
223+
*/
134224
fun deleteUser(username: String): Boolean {
135225
val status = client.api().jobsApi().delete("/", username)
136226

@@ -140,6 +230,11 @@ fun deleteUser(username: String): Boolean {
140230
return status.value()
141231
}
142232

233+
/**
234+
* Deletes a Jenkins job.
235+
* @param username The username of the user.
236+
* @param jobName The name of the job.
237+
*/
143238
fun deleteJob(username: String, jobName: String): Boolean {
144239
val status = client.api().jobsApi().delete("/", "$username/job/$jobName")
145240

@@ -154,6 +249,9 @@ private val nonFreestyles = listOf(
154249
"dependency-reduced-pom.xml"
155250
)
156251

157-
suspend fun isFreestyle(url: String): Boolean = withContext(Dispatchers.IO) {
158-
!filesExists(url, nonFreestyles)
159-
}
252+
/**
253+
* Checks if a Git repository is a freestyle project.
254+
* @param url The URL to the Git repository.
255+
* @return `true` if the project is a freestyle project, `false` otherwise.
256+
*/
257+
suspend fun isFreestyle(url: String): Boolean = !filesExists(url, nonFreestyles)

src/main/kotlin/io/codemc/api/nexus/nexus.kt

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,34 +14,68 @@ import java.util.*
1414

1515
// Schema
1616

17+
/**
18+
* The Nexus configuration.
19+
* @param url The URL to the Nexus Instance.
20+
* @param username The username of the admin user.
21+
* @parma password The password of the admin user.
22+
*/
1723
data class NexusConfig(
1824
val url: String,
1925
val username: String,
2026
val password: String
2127
) {
28+
/**
29+
* The Base64-Encoded value of `$username:$password`
30+
*/
2231
private val token: String
2332
get() = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
2433

34+
/**
35+
* The authorization header based on the [username] and [password].
36+
*/
2537
val authorization
2638
get() = "Basic $token"
2739
}
2840

2941
// Fields
3042

43+
/**
44+
* The [NexusConfig] instance.
45+
*/
3146
lateinit var nexusConfig: NexusConfig
3247

3348
// Implementation
3449

35-
private suspend fun nexus(url: String, request: HttpRequest.Builder.() -> Unit = { GET() }) = req(url) {
50+
/**
51+
* Sends an HTTP request using the [NexusConfig.authorization] header.
52+
* @param url The URL to send the request to.
53+
* @param request The builder modifier on the HTTP Request.
54+
* @return An HTTP Response.
55+
* @see [req]
56+
*/
57+
suspend fun nexus(url: String, request: HttpRequest.Builder.() -> Unit = { GET() }) = req(url) {
3658
header("Authorization", nexusConfig.authorization)
3759
request(this)
3860
}
3961

62+
/**
63+
* Pings the Nexus Instance.
64+
* @return `true` if currently available, `false` otherwise
65+
*/
4066
suspend fun ping(): Boolean {
4167
val text = nexus("$API_URL/status")
4268
return text.statusCode() == 200
4369
}
4470

71+
/**
72+
* Creates a Nexus User with the necessary data.
73+
*
74+
* This creates the user account, role, and repository with necessary credentials.
75+
* @param name The name of the user
76+
* @param password The password for the user
77+
* @return `true` if successfully created, `false` otherwise.
78+
*/
4579
@OptIn(ExperimentalSerializationApi::class)
4680
suspend fun createNexus(name: String, password: String) = withContext(Dispatchers.IO) {
4781
// Create User Repository
@@ -91,16 +125,38 @@ suspend fun createNexus(name: String, password: String) = withContext(Dispatcher
91125
return@withContext userRes.statusCode() == 200
92126
}
93127

128+
/**
129+
* Changes the password linked to the Nexus User.
130+
* @param name The name of the user to change
131+
* @param newPassword The new password for the user
132+
* @return `true` if the change was successful, `false` otherwise
133+
*/
134+
suspend fun changeNexusPassword(name: String, newPassword: String) = withContext(Dispatchers.IO) {
135+
val id = name.lowercase()
136+
val res = nexus("$API_URL/security/users/$id/change-password") {
137+
PUT(HttpRequest.BodyPublishers.ofString(newPassword))
138+
139+
header("Content-Type", "text/plain")
140+
}
141+
142+
return@withContext res.statusCode() == 204
143+
}
144+
145+
/**
146+
* Deletes a Nexus user and its data, removing all artifacts from its repository.
147+
* @param name The name of the user to delete.
148+
* @return `true` if the deletion was successful, `false` otherwise
149+
*/
94150
suspend fun deleteNexus(name: String) = withContext(Dispatchers.IO) {
95-
val repoName = name.lowercase()
151+
val id = name.lowercase()
96152

97-
val repoRes = nexus("$API_URL/repositories/$repoName") { DELETE() }
153+
val repoRes = nexus("$API_URL/repositories/$id") { DELETE() }
98154
if (repoRes.statusCode() != 204) return@withContext false
99155

100-
val roleRes = nexus("$API_URL/security/roles/$repoName") { DELETE() }
156+
val roleRes = nexus("$API_URL/security/roles/$id") { DELETE() }
101157
if (roleRes.statusCode() != 204) return@withContext false
102158

103-
val userRes = nexus("$API_URL/security/users/$repoName") { DELETE() }
159+
val userRes = nexus("$API_URL/security/users/$id") { DELETE() }
104160
return@withContext userRes.statusCode() == 204
105161
}
106162

@@ -110,13 +166,23 @@ internal suspend fun getRepositories(): List<JsonObject> {
110166
return json.decodeFromString(text)
111167
}
112168

169+
/**
170+
* Gets a Nexus Repository by its case-sensitive name.
171+
* @param name The name of the nexus repository
172+
* @return The repository data in JSON format, or `null` if not found
173+
*/
113174
suspend fun getNexusRepository(name: String): JsonObject? {
114175
val res = nexus("$API_URL/repositories/$name")
115176
if (res.statusCode() == 404) return null
116177

117178
return json.decodeFromString(res.body())
118179
}
119180

181+
/**
182+
* Gets a Nexus User by its case-sensitive name.
183+
* @param name The name of the nexus user.
184+
* @return The User data in JSON format, or `null` if not found
185+
*/
120186
suspend fun getNexusUser(name: String): JsonObject? {
121187
val res = nexus("$API_URL/security/users?userId=$name")
122188
if (res.statusCode() == 404) return null

src/main/resources/templates/jenkins/credentials.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl>
2-
<id>nexus-repository</id>
3-
<description>Your Nexus Login Details</description>
2+
<id>{ID}</id>
3+
<description>{DESCRIPTION}</description>
44
<username>{USERNAME}</username>
55
<password>{PASSWORD}</password>
66
<usernameSecret>true</usernameSecret>

src/test/kotlin/io/codemc/api/TestOverall.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class TestOverall {
2323
val jenkins = JenkinsConfig(
2424
url = "http://localhost:8080",
2525
username = "admin",
26-
password = "00000000000000000000000000000000"
26+
token = "00000000000000000000000000000000"
2727
)
2828

2929
val nexus = NexusConfig(

src/test/kotlin/io/codemc/api/jenkins/TestJenkins.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class TestJenkins {
1414
jenkinsConfig = JenkinsConfig(
1515
url = "http://localhost:8080",
1616
username = "admin",
17-
password = "00000000000000000000000000000000"
17+
token = "00000000000000000000000000000000"
1818
)
1919
}
2020

@@ -119,4 +119,20 @@ class TestJenkins {
119119
assertFalse(isFreestyle(u4))
120120
}
121121

122+
@Test
123+
fun testChangePassword() = runBlocking(Dispatchers.IO) {
124+
val name = "OldUser788"
125+
126+
val p1 = "OldPassword123"
127+
assertTrue(createJenkinsUser(name, p1))
128+
assertTrue(getJenkinsUser(name).isNotEmpty())
129+
130+
val p2 = "NewPassword456"
131+
assertTrue(changeJenkinsPassword(name, p2))
132+
assertTrue(getJenkinsUser(name).isNotEmpty())
133+
134+
assertTrue(deleteUser(name))
135+
assertTrue(getJenkinsUser(name).isEmpty())
136+
}
137+
122138
}

0 commit comments

Comments
 (0)