python-jcli 1.0.0__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jcli/__init__.py +1 -1
- jcli/cli.py +30 -10
- jcli/cli_helpers.py +5 -42
- jcli/skills/jcli/build/SKILL.md +123 -0
- jcli/specs/_auth.yaml +15 -0
- jcli/{plugins/skills.py → specs/plugins/jcli_commands.py} +64 -46
- jcli/specs/plugins/jenkins_auth.py +75 -0
- jcli/specs/plugins/jenkins_methods.py +332 -0
- jcli/specs/plugins/jenkins_pipeline_validate.py +51 -0
- jcli/specs/plugins/jenkins_plugin.py +77 -0
- jcli/specs/plugins/jenkins_system.py +137 -0
- jcli/specs/resources/build.yaml +160 -0
- jcli/specs/resources/credential.yaml +97 -0
- jcli/specs/resources/job.yaml +184 -0
- jcli/specs/resources/node.yaml +69 -0
- jcli/specs/resources/pipeline.yaml +62 -0
- jcli/specs/resources/plugin.yaml +65 -0
- jcli/specs/resources/system.yaml +72 -0
- jcli/specs/resources/view.yaml +75 -0
- {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/METADATA +10 -9
- python_jcli-1.0.1.dist-info/RECORD +40 -0
- jcli/plugins/__init__.py +0 -28
- jcli/plugins/build.py +0 -197
- jcli/plugins/config.py +0 -268
- jcli/plugins/credential.py +0 -210
- jcli/plugins/job.py +0 -425
- jcli/plugins/node.py +0 -134
- jcli/plugins/pipeline.py +0 -108
- jcli/plugins/plugin.py +0 -173
- jcli/plugins/system.py +0 -173
- jcli/plugins/view.py +0 -148
- jcli/sdk/build.py +0 -182
- jcli/sdk/credential.py +0 -143
- jcli/sdk/job.py +0 -191
- jcli/sdk/job_templates.py +0 -128
- jcli/sdk/node.py +0 -139
- jcli/sdk/pipeline.py +0 -121
- jcli/sdk/plugin.py +0 -155
- jcli/sdk/system.py +0 -202
- jcli/sdk/view.py +0 -94
- python_jcli-1.0.0.dist-info/RECORD +0 -44
- {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/WHEEL +0 -0
- {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/entry_points.txt +0 -0
- {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Jenkins method plugins: build trigger, replay, node create."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cliyard.plugin import register_method
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _job_path(job_name: str) -> str:
|
|
12
|
+
"""Convert job name to Jenkins URL path (folder/sub → /job/folder/job/sub)."""
|
|
13
|
+
segments = job_name.split("/")
|
|
14
|
+
return "/job/" + "/job/".join(segments)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_key_values(items: Any) -> dict[str, str]:
|
|
18
|
+
"""Parse a sequence of ``KEY=VALUE`` strings into a dict."""
|
|
19
|
+
result: dict[str, str] = {}
|
|
20
|
+
for item in items or ():
|
|
21
|
+
if "=" in item:
|
|
22
|
+
k, v = item.split("=", 1)
|
|
23
|
+
result[k.strip()] = v.strip()
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _queue_item_from_location(http_client: Any, location: str) -> dict[str, Any] | None:
|
|
28
|
+
"""Follow a Jenkins Location header to the queue-item JSON.
|
|
29
|
+
|
|
30
|
+
Handles both absolute URLs (``http://host/queue/item/12/``) and
|
|
31
|
+
relative paths (``/queue/item/12/``). Returns ``None`` when the
|
|
32
|
+
queue item cannot be fetched.
|
|
33
|
+
"""
|
|
34
|
+
if not location:
|
|
35
|
+
return None
|
|
36
|
+
queue_path = urllib.parse.urlparse(location).path
|
|
37
|
+
if queue_path.startswith(http_client.base_url):
|
|
38
|
+
queue_path = queue_path[len(http_client.base_url):]
|
|
39
|
+
if not queue_path.startswith("/"):
|
|
40
|
+
queue_path = "/" + queue_path
|
|
41
|
+
try:
|
|
42
|
+
resp = http_client.request("GET", queue_path.rstrip("/") + "/api/json")
|
|
43
|
+
return resp.json()
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@register_method("jenkins_build_trigger")
|
|
49
|
+
def jenkins_build_trigger(
|
|
50
|
+
params: dict[str, Any], http_client: Any, config: dict[str, Any]
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""Trigger a build with Location header following.
|
|
53
|
+
|
|
54
|
+
POST /job/{job}/build or /buildWithParameters, follow Location to queue item.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
params: Body params — ``job_name`` (str), ``params`` (list of
|
|
58
|
+
``KEY=VALUE`` strings).
|
|
59
|
+
http_client: Configured HttpClient instance.
|
|
60
|
+
config: Plugin configuration (unused).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dict with ``status``, ``url`` (queue item URL) and optionally
|
|
64
|
+
``queue_item`` (parsed queue-item JSON).
|
|
65
|
+
"""
|
|
66
|
+
job_name = params.get("job_name", "")
|
|
67
|
+
build_params = _parse_key_values(params.get("params"))
|
|
68
|
+
|
|
69
|
+
path = _job_path(job_name)
|
|
70
|
+
if build_params:
|
|
71
|
+
url = f"{path}/buildWithParameters"
|
|
72
|
+
resp = http_client.request("POST", url, query_params=build_params)
|
|
73
|
+
else:
|
|
74
|
+
url = f"{path}/build"
|
|
75
|
+
resp = http_client.request("POST", url)
|
|
76
|
+
|
|
77
|
+
location = resp.headers.get("Location", "")
|
|
78
|
+
queue_item = _queue_item_from_location(http_client, location)
|
|
79
|
+
if queue_item is not None:
|
|
80
|
+
return {"status": "queued", "url": location, "queue_item": queue_item}
|
|
81
|
+
return {"status": "queued", "url": location}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@register_method("jenkins_build_rebuild")
|
|
85
|
+
def jenkins_build_rebuild(
|
|
86
|
+
params: dict[str, Any], http_client: Any, config: dict[str, Any]
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""Rebuild a build: auto-fetch previous build parameters + ``--set`` override.
|
|
89
|
+
|
|
90
|
+
Flow: GET previous parameters → merge ``--set`` overrides →
|
|
91
|
+
POST buildWithParameters or /build.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
params: Body params — ``job_name`` (str), ``build_number`` (int),
|
|
95
|
+
``set`` (list of ``KEY=VALUE`` overrides).
|
|
96
|
+
http_client: Configured HttpClient instance.
|
|
97
|
+
config: Plugin configuration (unused).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Dict with ``status``, ``url`` (queue item URL) and ``parameters``
|
|
101
|
+
(the merged parameter dict actually sent).
|
|
102
|
+
"""
|
|
103
|
+
job_name = params.get("job_name", "")
|
|
104
|
+
build_number = params.get("build_number", 0)
|
|
105
|
+
set_overrides = params.get("set") or ()
|
|
106
|
+
|
|
107
|
+
path = _job_path(job_name)
|
|
108
|
+
|
|
109
|
+
# Step 1: Fetch previous build parameters
|
|
110
|
+
build_params: dict[str, str] = {}
|
|
111
|
+
try:
|
|
112
|
+
resp = http_client.request(
|
|
113
|
+
"GET",
|
|
114
|
+
f"{path}/{build_number}/api/json",
|
|
115
|
+
query_params={"tree": "actions[parameters[name,value]]"},
|
|
116
|
+
)
|
|
117
|
+
for action in resp.json().get("actions", []):
|
|
118
|
+
for p in action.get("parameters", []):
|
|
119
|
+
build_params[str(p["name"])] = p["value"]
|
|
120
|
+
except Exception:
|
|
121
|
+
pass # Non-parameterized build
|
|
122
|
+
|
|
123
|
+
# Step 2: Apply --set overrides
|
|
124
|
+
build_params.update(_parse_key_values(set_overrides))
|
|
125
|
+
|
|
126
|
+
# Step 3: Trigger build
|
|
127
|
+
if build_params:
|
|
128
|
+
resp = http_client.request(
|
|
129
|
+
"POST", f"{path}/buildWithParameters", query_params=build_params
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
resp = http_client.request("POST", f"{path}/build")
|
|
133
|
+
|
|
134
|
+
location = resp.headers.get("Location", "")
|
|
135
|
+
return {"status": "queued", "url": location, "parameters": build_params}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@register_method("jenkins_build_replay")
|
|
139
|
+
def jenkins_build_replay(
|
|
140
|
+
params: dict[str, Any], http_client: Any, config: dict[str, Any]
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Replay a build with a provided Jenkinsfile via the Jenkins Replay API.
|
|
143
|
+
|
|
144
|
+
POST /job/{job}/{build}/replay/run with form data carrying the
|
|
145
|
+
Jenkinsfile content (``_.mainScript``), a crumb, the ``Submit``
|
|
146
|
+
button value and the serialized ``json`` form. On success Jenkins
|
|
147
|
+
responds with a 302 redirect (new build queued) or 200.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
params: Body params — ``job_name`` (str), ``build_number`` (int),
|
|
151
|
+
``jenkinsfile`` (str, file content or path to read).
|
|
152
|
+
http_client: Configured HttpClient instance.
|
|
153
|
+
config: Plugin configuration (unused).
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Dict with ``status`` (``replayed``/``failed``), ``job`` and
|
|
157
|
+
``build``; on failure also ``code`` and ``detail``.
|
|
158
|
+
"""
|
|
159
|
+
import os as _os
|
|
160
|
+
|
|
161
|
+
job_name = params.get("job_name", "")
|
|
162
|
+
build_number = params.get("build_number", 0)
|
|
163
|
+
jenkinsfile = params.get("jenkinsfile", "")
|
|
164
|
+
|
|
165
|
+
# ``type: file`` may arrive as a path string (plugin callbacks do not
|
|
166
|
+
# pre-read file contents) — read it when it points at an existing file.
|
|
167
|
+
if jenkinsfile and _os.path.isfile(jenkinsfile):
|
|
168
|
+
with open(jenkinsfile, encoding="utf-8") as _f:
|
|
169
|
+
jenkinsfile = _f.read()
|
|
170
|
+
|
|
171
|
+
path = _job_path(job_name)
|
|
172
|
+
|
|
173
|
+
crumb = http_client.default_headers.get("Jenkins-Crumb", "")
|
|
174
|
+
if not crumb:
|
|
175
|
+
try:
|
|
176
|
+
resp = http_client.request("GET", "/crumbIssuer/api/json")
|
|
177
|
+
crumb = resp.json().get("crumb", "")
|
|
178
|
+
except Exception:
|
|
179
|
+
crumb = ""
|
|
180
|
+
|
|
181
|
+
# F12-verified Replay form (4 fields; missing ones cause 400/500).
|
|
182
|
+
form_data = {
|
|
183
|
+
"_.mainScript": jenkinsfile,
|
|
184
|
+
"Submit": "运行",
|
|
185
|
+
"Jenkins-Crumb": crumb,
|
|
186
|
+
"json": json.dumps(
|
|
187
|
+
{"mainScript": jenkinsfile, "": "", "Submit": "运行", "Jenkins-Crumb": crumb},
|
|
188
|
+
ensure_ascii=False,
|
|
189
|
+
),
|
|
190
|
+
}
|
|
191
|
+
# Form-encoded POST — _session directly (HttpClient.request would JSON-encode).
|
|
192
|
+
resp = http_client._session.post(
|
|
193
|
+
f"{http_client.base_url}{path}/{build_number}/replay/run",
|
|
194
|
+
data=form_data,
|
|
195
|
+
headers={**http_client.default_headers, "Content-Type": "application/x-www-form-urlencoded"},
|
|
196
|
+
timeout=getattr(http_client, "timeout", 30),
|
|
197
|
+
allow_redirects=False,
|
|
198
|
+
)
|
|
199
|
+
if resp.status_code in (302, 200):
|
|
200
|
+
return {"status": "replayed", "job": job_name, "build": build_number}
|
|
201
|
+
return {"status": "failed", "code": resp.status_code, "detail": resp.text[:300]}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@register_method("jenkins_node_create")
|
|
205
|
+
def jenkins_node_create(
|
|
206
|
+
params: dict[str, Any], http_client: Any, config: dict[str, Any]
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
"""Create a Jenkins agent node via form-encoded POST.
|
|
209
|
+
|
|
210
|
+
POST /computer/doCreateItem with form-encoded data (not JSON).
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
params: Body params — ``name`` (str), ``num_executors`` (int),
|
|
214
|
+
``remote_fs`` (str), ``labels`` (str), ``launch_method`` (str).
|
|
215
|
+
http_client: Configured HttpClient instance.
|
|
216
|
+
config: Plugin configuration (unused).
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Dict with ``status`` and ``name``.
|
|
220
|
+
"""
|
|
221
|
+
name = params.get("name", "")
|
|
222
|
+
num_executors = params.get("num_executors", 1)
|
|
223
|
+
remote_fs = params.get("remote_fs", "/tmp")
|
|
224
|
+
labels = params.get("labels", "")
|
|
225
|
+
launch_method = params.get("launch_method", "hudson.slaves.JNLPLauncher")
|
|
226
|
+
|
|
227
|
+
node_json = json.dumps({
|
|
228
|
+
"name": name,
|
|
229
|
+
"nodeDescription": "",
|
|
230
|
+
"numExecutors": str(num_executors),
|
|
231
|
+
"remoteFS": remote_fs,
|
|
232
|
+
"labelString": labels,
|
|
233
|
+
"mode": "NORMAL",
|
|
234
|
+
"type": "hudson.slaves.DumbSlave",
|
|
235
|
+
"retentionStrategy": {"stapler-class": "hudson.slaves.RetentionStrategy$Always"},
|
|
236
|
+
"nodeProperties": {"stapler-class-bag": "true"},
|
|
237
|
+
"launcher": {"stapler-class": launch_method},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
# Form-encoded (not JSON) — use _session directly with merged default
|
|
241
|
+
# headers so the Authorization / crumb chain still applies.
|
|
242
|
+
form_data = (
|
|
243
|
+
f"name={urllib.parse.quote(name)}"
|
|
244
|
+
f"&type=hudson.slaves.DumbSlave"
|
|
245
|
+
f"&json={urllib.parse.quote(node_json)}"
|
|
246
|
+
)
|
|
247
|
+
http_client._session.post(
|
|
248
|
+
f"{http_client.base_url}/computer/doCreateItem",
|
|
249
|
+
params={"name": name, "type": "hudson.slaves.DumbSlave"},
|
|
250
|
+
data=form_data,
|
|
251
|
+
headers={**http_client.default_headers, "Content-Type": "application/x-www-form-urlencoded"},
|
|
252
|
+
timeout=http_client.timeout,
|
|
253
|
+
)
|
|
254
|
+
return {"status": "created", "name": name}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _tail_lines(text: str, n: int) -> str:
|
|
258
|
+
"""Return the last *n* lines of *text* (all of it when shorter)."""
|
|
259
|
+
if n <= 0:
|
|
260
|
+
return text
|
|
261
|
+
lines = text.splitlines()
|
|
262
|
+
if len(lines) <= n:
|
|
263
|
+
return text
|
|
264
|
+
return "\n".join(lines[-n:]) + "\n"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@register_method("jenkins_build_log")
|
|
268
|
+
def jenkins_build_log(
|
|
269
|
+
params: dict[str, Any], http_client: Any, config: dict[str, Any]
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
"""Show a build's console log (``tail`` semantics).
|
|
272
|
+
|
|
273
|
+
* no flags — print the whole log once
|
|
274
|
+
* ``-n N`` — print only the last *N* lines
|
|
275
|
+
* ``-f`` — follow live; history (all, or the last ``-n N`` lines) is
|
|
276
|
+
printed first, then newly appended lines stream until the build ends
|
|
277
|
+
|
|
278
|
+
Follow polls the full console text and slices locally by byte offset,
|
|
279
|
+
so it does not rely on Jenkins' ``?start=`` support (some servers and
|
|
280
|
+
proxies ignore it) — each refresh prints only new lines, no reprints.
|
|
281
|
+
Output is written directly by this plugin (``_formatted``), so the
|
|
282
|
+
cliyard/jcli wrappers do not re-format.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
params: Body params — ``job_name`` (str), ``number`` (int),
|
|
286
|
+
``follow`` (bool), ``lines`` (int).
|
|
287
|
+
http_client: Configured HttpClient instance.
|
|
288
|
+
config: Plugin configuration (unused).
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
``{"_formatted": True}`` — output already printed.
|
|
292
|
+
"""
|
|
293
|
+
import time as _time
|
|
294
|
+
|
|
295
|
+
job_name = params.get("job_name", "")
|
|
296
|
+
number = params.get("number", 0)
|
|
297
|
+
follow = params.get("follow", False)
|
|
298
|
+
lines = params.get("lines", 0) or 0
|
|
299
|
+
|
|
300
|
+
path = _job_path(job_name)
|
|
301
|
+
log_url = f"{path}/{number}/consoleText"
|
|
302
|
+
|
|
303
|
+
first_text = http_client.request("GET", log_url).text
|
|
304
|
+
|
|
305
|
+
if not follow:
|
|
306
|
+
print(_tail_lines(first_text, lines), end="")
|
|
307
|
+
return {"_formatted": True}
|
|
308
|
+
|
|
309
|
+
print(_tail_lines(first_text, lines), end="", flush=True)
|
|
310
|
+
offset = len(first_text)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
while True:
|
|
314
|
+
resp = http_client.request("GET", log_url)
|
|
315
|
+
text = resp.text
|
|
316
|
+
total = len(text)
|
|
317
|
+
if total > offset:
|
|
318
|
+
print(text[offset:], end="", flush=True)
|
|
319
|
+
offset = total
|
|
320
|
+
|
|
321
|
+
build = http_client.request(
|
|
322
|
+
"GET",
|
|
323
|
+
f"{path}/{number}/api/json",
|
|
324
|
+
query_params={"tree": "building"},
|
|
325
|
+
).json()
|
|
326
|
+
if not build.get("building"):
|
|
327
|
+
break
|
|
328
|
+
_time.sleep(0.25)
|
|
329
|
+
except KeyboardInterrupt:
|
|
330
|
+
pass
|
|
331
|
+
print()
|
|
332
|
+
return {"_formatted": True}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Jenkins Pipeline validate plugin: validate Jenkinsfile content."""
|
|
2
|
+
from cliyard.plugin import register_method
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@register_method("jenkins_pipeline_validate")
|
|
6
|
+
def jenkins_pipeline_validate(params, http_client, config):
|
|
7
|
+
"""Validate a Jenkinsfile against the Jenkins server.
|
|
8
|
+
|
|
9
|
+
Sends the Jenkinsfile content to the pipeline-model-converter endpoint
|
|
10
|
+
and returns the validation result.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
params: Dictionary containing 'jenkinsfile' key with file path.
|
|
14
|
+
http_client: Configured HttpClient instance.
|
|
15
|
+
config: Plugin configuration (unused).
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Parsed validation result (text or JSON).
|
|
19
|
+
"""
|
|
20
|
+
jenkinsfile_path = params.get("jenkinsfile") or params.get("body", {}).get("jenkinsfile", "")
|
|
21
|
+
if not jenkinsfile_path:
|
|
22
|
+
return {"valid": False, "errors": ["No Jenkinsfile path provided"]}
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
with open(jenkinsfile_path, "r", encoding="utf-8") as f:
|
|
26
|
+
jenkinsfile_content = f.read()
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return {"valid": False, "errors": [f"Failed to read Jenkinsfile: {e}"]}
|
|
29
|
+
|
|
30
|
+
from urllib.parse import urlencode
|
|
31
|
+
form_data = urlencode({"jenkinsfile": jenkinsfile_content})
|
|
32
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
33
|
+
resp = http_client.request(
|
|
34
|
+
"POST",
|
|
35
|
+
"/pipeline-model-converter/validate",
|
|
36
|
+
data=form_data,
|
|
37
|
+
headers=headers,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
return resp.json()
|
|
42
|
+
except ValueError:
|
|
43
|
+
text = resp.text.strip()
|
|
44
|
+
if "Errors encountered" in text:
|
|
45
|
+
lines = text.split('\n')
|
|
46
|
+
errors = [line.strip() for line in lines[1:] if line.strip()]
|
|
47
|
+
return {"valid": False, "errors": errors}
|
|
48
|
+
elif "successfully validated" in text.lower():
|
|
49
|
+
return {"valid": True, "message": text}
|
|
50
|
+
else:
|
|
51
|
+
return {"valid": None, "raw": text}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Jenkins Plugin management plugin: get plugin details and install plugins."""
|
|
2
|
+
from cliyard.plugin import register_method
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@register_method("jenkins_plugin_get")
|
|
6
|
+
def jenkins_plugin_get(params, http_client, config):
|
|
7
|
+
"""Get details for a single plugin by short name.
|
|
8
|
+
|
|
9
|
+
Fetches all plugins and filters locally to find the matching one.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
params: Dictionary containing 'short_name' key.
|
|
13
|
+
http_client: Configured HttpClient instance.
|
|
14
|
+
config: Plugin configuration (unused).
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Plugin detail dict, or error dict if not found.
|
|
18
|
+
"""
|
|
19
|
+
short_name = params.get("short_name", "")
|
|
20
|
+
if not short_name:
|
|
21
|
+
return {"error": "No plugin name provided"}
|
|
22
|
+
|
|
23
|
+
# Fetch all plugins with detailed info
|
|
24
|
+
resp = http_client.request(
|
|
25
|
+
"GET",
|
|
26
|
+
"/pluginManager/api/json",
|
|
27
|
+
query_params={"tree": "plugins[shortName,version,active,hasUpdate,longName,url,requiredCoreVersion,hasRequireRestart]"}
|
|
28
|
+
)
|
|
29
|
+
data = resp.json()
|
|
30
|
+
plugins = data.get("plugins", [])
|
|
31
|
+
|
|
32
|
+
for p in plugins:
|
|
33
|
+
if p.get("shortName") == short_name:
|
|
34
|
+
return p
|
|
35
|
+
|
|
36
|
+
return {"error": f"Plugin '{short_name}' not found"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@register_method("jenkins_plugin_install")
|
|
40
|
+
def jenkins_plugin_install(params, http_client, config):
|
|
41
|
+
"""Install one or more plugins (optionally with specific versions).
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
params: Dictionary containing 'plugins' key with list of plugin specs.
|
|
45
|
+
Each spec can be 'name' or 'name@version'.
|
|
46
|
+
http_client: Configured HttpClient instance.
|
|
47
|
+
config: Plugin configuration (unused).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Success message or error dict.
|
|
51
|
+
"""
|
|
52
|
+
plugins_list = params.get("plugins", [])
|
|
53
|
+
if not plugins_list:
|
|
54
|
+
return {"error": "No plugins specified"}
|
|
55
|
+
|
|
56
|
+
lines = ["<jenkins>"]
|
|
57
|
+
for spec in plugins_list:
|
|
58
|
+
if "@" in spec:
|
|
59
|
+
name, version = spec.split("@", 1)
|
|
60
|
+
lines.append(f' <install plugin="{name}@{version}" />')
|
|
61
|
+
else:
|
|
62
|
+
lines.append(f' <install plugin="{spec}" />')
|
|
63
|
+
lines.append("</jenkins>")
|
|
64
|
+
xml_body = "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
headers = {"Content-Type": "application/xml"}
|
|
67
|
+
resp = http_client.request(
|
|
68
|
+
"POST",
|
|
69
|
+
"/pluginManager/installNecessaryPlugins",
|
|
70
|
+
data=xml_body.encode("utf-8"),
|
|
71
|
+
headers=headers
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if resp.status_code == 200:
|
|
75
|
+
return {"status": "success", "message": "Plugins scheduled for installation"}
|
|
76
|
+
else:
|
|
77
|
+
return {"error": f"Install failed with status {resp.status_code}"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Jenkins System management plugin: info, load, script, token methods."""
|
|
2
|
+
from cliyard.plugin import register_method
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@register_method("jenkins_system_info")
|
|
6
|
+
def jenkins_system_info(params, http_client, config):
|
|
7
|
+
"""Get Jenkins system information.
|
|
8
|
+
|
|
9
|
+
Calls GET /api/json and extracts version from X-Jenkins header.
|
|
10
|
+
Aggregates executor count, mode, quieting down status, nodes, and jobs.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
params: Dictionary (unused, empty body).
|
|
14
|
+
http_client: Configured HttpClient instance.
|
|
15
|
+
config: Plugin configuration (unused).
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dictionary with version, num_executors, mode, quietingDown,
|
|
19
|
+
slaveAgentPort, num_nodes, num_jobs.
|
|
20
|
+
"""
|
|
21
|
+
resp = http_client.request("GET", "/api/json")
|
|
22
|
+
data = resp.json()
|
|
23
|
+
|
|
24
|
+
version = resp.headers.get("X-Jenkins", "unknown")
|
|
25
|
+
|
|
26
|
+
# Count nodes from computer list
|
|
27
|
+
num_nodes = 0
|
|
28
|
+
computer = data.get("computer", [])
|
|
29
|
+
if isinstance(computer, list):
|
|
30
|
+
num_nodes = len(computer)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"version": version,
|
|
34
|
+
"num_executors": data.get("numExecutors", 0),
|
|
35
|
+
"mode": data.get("mode", ""),
|
|
36
|
+
"quietingDown": data.get("quietingDown", False),
|
|
37
|
+
"slaveAgentPort": data.get("slaveAgentPort", 0),
|
|
38
|
+
"num_nodes": num_nodes,
|
|
39
|
+
"num_jobs": len(data.get("jobs", [])),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register_method("jenkins_system_load")
|
|
44
|
+
def jenkins_system_load(params, http_client, config):
|
|
45
|
+
"""Get current system load statistics.
|
|
46
|
+
|
|
47
|
+
Aggregates queue length from /queue/api/json and executor usage
|
|
48
|
+
from /computer/api/json.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
params: Dictionary (unused, empty body).
|
|
52
|
+
http_client: Configured HttpClient instance.
|
|
53
|
+
config: Plugin configuration (unused).
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Dictionary with queue_length, total_executors, busy_executors,
|
|
57
|
+
idle_executors.
|
|
58
|
+
"""
|
|
59
|
+
queue_data = http_client.request("GET", "/queue/api/json").json()
|
|
60
|
+
queue_items = queue_data.get("items", [])
|
|
61
|
+
queue_length = len(queue_items) if isinstance(queue_items, list) else 0
|
|
62
|
+
|
|
63
|
+
computer_data = http_client.request("GET", "/computer/api/json").json()
|
|
64
|
+
computers = computer_data.get("computer", [])
|
|
65
|
+
total_executors = 0
|
|
66
|
+
busy_executors = 0
|
|
67
|
+
if isinstance(computers, list):
|
|
68
|
+
for c in computers:
|
|
69
|
+
total_executors += c.get("numExecutors", 0)
|
|
70
|
+
busy = c.get("busyExecutors", 0)
|
|
71
|
+
if busy:
|
|
72
|
+
busy_executors += busy
|
|
73
|
+
|
|
74
|
+
idle_executors = total_executors - busy_executors
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"queue_length": queue_length,
|
|
78
|
+
"total_executors": total_executors,
|
|
79
|
+
"busy_executors": busy_executors,
|
|
80
|
+
"idle_executors": idle_executors,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@register_method("jenkins_system_script")
|
|
85
|
+
def jenkins_system_script(params, http_client, config):
|
|
86
|
+
"""Execute a Groovy script on the Jenkins server.
|
|
87
|
+
|
|
88
|
+
Posts form-encoded script to /scriptText.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
params: Dictionary with 'script' key containing Groovy code.
|
|
92
|
+
http_client: Configured HttpClient instance.
|
|
93
|
+
config: Plugin configuration (unused).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Script output as text.
|
|
97
|
+
"""
|
|
98
|
+
script = params.get("script", "")
|
|
99
|
+
if not script:
|
|
100
|
+
return {"error": "No script provided"}
|
|
101
|
+
|
|
102
|
+
resp = http_client.request(
|
|
103
|
+
"POST",
|
|
104
|
+
"/scriptText",
|
|
105
|
+
data={"script": script},
|
|
106
|
+
)
|
|
107
|
+
return {"output": resp.text}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@register_method("jenkins_system_token")
|
|
111
|
+
def jenkins_system_token(params, http_client, config):
|
|
112
|
+
"""Generate a new API token for a user.
|
|
113
|
+
|
|
114
|
+
Posts form-encoded newTokenName to
|
|
115
|
+
/user/{username}/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
params: Dictionary with 'username' and 'token_name' keys.
|
|
119
|
+
http_client: Configured HttpClient instance.
|
|
120
|
+
config: Plugin configuration (unused).
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Parsed JSON with token data including the token value.
|
|
124
|
+
"""
|
|
125
|
+
username = params.get("username", "")
|
|
126
|
+
token_name = params.get("token_name", "jcli-generated")
|
|
127
|
+
|
|
128
|
+
if not username:
|
|
129
|
+
return {"error": "No username provided"}
|
|
130
|
+
|
|
131
|
+
path = f"/user/{username}/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken"
|
|
132
|
+
resp = http_client.request(
|
|
133
|
+
"POST",
|
|
134
|
+
path,
|
|
135
|
+
data={"newTokenName": token_name},
|
|
136
|
+
)
|
|
137
|
+
return resp.json()
|