Skip to content

Commit 2fb523f

Browse files
authored
[JENKINS-75378] Adding a CLI command listener (#10382)
2 parents a1f9d3e + 737c08d commit 2fb523f

File tree

6 files changed

+485
-95
lines changed

6 files changed

+485
-95
lines changed

core/src/main/java/hudson/cli/CLICommand.java

Lines changed: 47 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@
4848
import java.nio.charset.Charset;
4949
import java.util.List;
5050
import java.util.Locale;
51-
import java.util.UUID;
52-
import java.util.logging.Level;
53-
import java.util.logging.Logger;
51+
import jenkins.cli.listeners.CLIContext;
52+
import jenkins.cli.listeners.CLIListener;
5453
import jenkins.model.Jenkins;
54+
import jenkins.util.Listeners;
5555
import jenkins.util.SystemProperties;
5656
import org.jvnet.hudson.annotation_indexer.Index;
5757
import org.jvnet.tiger_types.Types;
@@ -242,70 +242,73 @@ public int main(List<String> args, Locale locale, InputStream stdin, PrintStream
242242
this.locale = locale;
243243
CmdLineParser p = getCmdLineParser();
244244

245+
Authentication auth = getTransportAuthentication2();
246+
CLIContext context = new CLIContext(getName(), args, auth);
247+
245248
// add options from the authenticator
246249
SecurityContext sc = null;
247250
Authentication old = null;
248-
Authentication auth;
249251
try {
250252
// TODO as in CLIRegisterer this may be doing too much work
251253
sc = SecurityContextHolder.getContext();
252254
old = sc.getAuthentication();
253255

254-
sc.setAuthentication(auth = getTransportAuthentication2());
256+
sc.setAuthentication(auth);
255257

256258
if (!(this instanceof HelpCommand || this instanceof WhoAmICommand))
257259
Jenkins.get().checkPermission(Jenkins.READ);
258260
p.parseArgument(args.toArray(new String[0]));
259-
LOGGER.log(Level.FINE, "Invoking CLI command {0}, with {1} arguments, as user {2}.",
260-
new Object[] {getName(), args.size(), auth.getName()});
261+
262+
Listeners.notify(CLIListener.class, true, listener -> listener.onExecution(context));
261263
int res = run();
262-
LOGGER.log(Level.FINE, "Executed CLI command {0}, with {1} arguments, as user {2}, return code {3}",
263-
new Object[] {getName(), args.size(), auth.getName(), res});
264+
Listeners.notify(CLIListener.class, true, listener -> listener.onCompleted(context, res));
265+
264266
return res;
265-
} catch (CmdLineException e) {
266-
logFailedCommandAndPrintExceptionErrorMessage(args, e);
267-
printUsage(stderr, p);
268-
return 2;
269-
} catch (IllegalStateException e) {
270-
logFailedCommandAndPrintExceptionErrorMessage(args, e);
271-
return 4;
272-
} catch (IllegalArgumentException e) {
273-
logFailedCommandAndPrintExceptionErrorMessage(args, e);
274-
return 3;
275-
} catch (AbortException e) {
276-
logFailedCommandAndPrintExceptionErrorMessage(args, e);
277-
return 5;
278-
} catch (AccessDeniedException e) {
279-
logFailedCommandAndPrintExceptionErrorMessage(args, e);
280-
return 6;
281-
} catch (BadCredentialsException e) {
282-
// to the caller, we can't reveal whether the user didn't exist or the password didn't match.
283-
// do that to the server log instead
284-
String id = UUID.randomUUID().toString();
285-
logAndPrintError(e, "Bad Credentials. Search the server log for " + id + " for more details.",
286-
"CLI login attempt failed: " + id, Level.INFO);
287-
return 7;
288267
} catch (Throwable e) {
289-
String errorMsg = "Unexpected exception occurred while performing " + getName() + " command.";
290-
logAndPrintError(e, errorMsg, errorMsg, Level.WARNING);
291-
Functions.printStackTrace(e, stderr);
292-
return 1;
268+
int exitCode = handleException(e, context, p);
269+
Listeners.notify(CLIListener.class, true, listener -> listener.onThrowable(context, e));
270+
return exitCode;
293271
} finally {
294272
if (sc != null)
295273
sc.setAuthentication(old); // restore
296274
}
297275
}
298276

299-
private void logFailedCommandAndPrintExceptionErrorMessage(List<String> args, Throwable e) {
300-
Authentication auth = getTransportAuthentication2();
301-
String logMessage = String.format("Failed call to CLI command %s, with %d arguments, as user %s.",
302-
getName(), args.size(), auth != null ? auth.getName() : "<unknown>");
303-
304-
logAndPrintError(e, e.getMessage(), logMessage, Level.FINE);
277+
/**
278+
* Determines command stderr output and return the exit code as described on {@link #main(List, Locale, InputStream, PrintStream, PrintStream)}
279+
* */
280+
protected int handleException(Throwable e, CLIContext context, CmdLineParser p) {
281+
int exitCode;
282+
if (e instanceof CmdLineException) {
283+
exitCode = 2;
284+
printError(e.getMessage());
285+
printUsage(stderr, p);
286+
} else if (e instanceof IllegalArgumentException) {
287+
exitCode = 3;
288+
printError(e.getMessage());
289+
} else if (e instanceof IllegalStateException) {
290+
exitCode = 4;
291+
printError(e.getMessage());
292+
} else if (e instanceof AbortException) {
293+
exitCode = 5;
294+
printError(e.getMessage());
295+
} else if (e instanceof AccessDeniedException) {
296+
exitCode = 6;
297+
printError(e.getMessage());
298+
} else if (e instanceof BadCredentialsException) {
299+
exitCode = 7;
300+
printError(
301+
"Bad Credentials. Search the server log for " + context.getCorrelationId() + " for more details.");
302+
} else {
303+
exitCode = 1;
304+
printError("Unexpected exception occurred while performing " + getName() + " command.");
305+
Functions.printStackTrace(e, stderr);
306+
}
307+
return exitCode;
305308
}
306309

307-
private void logAndPrintError(Throwable e, String errorMessage, String logMessage, Level logLevel) {
308-
LOGGER.log(logLevel, logMessage, e);
310+
311+
private void printError(String errorMessage) {
309312
this.stderr.println();
310313
this.stderr.println("ERROR: " + errorMessage);
311314
}
@@ -541,8 +544,6 @@ public static CLICommand clone(String name) {
541544
return null;
542545
}
543546

544-
private static final Logger LOGGER = Logger.getLogger(CLICommand.class.getName());
545-
546547
private static final ThreadLocal<CLICommand> CURRENT_COMMAND = new ThreadLocal<>();
547548

548549
/*package*/ static CLICommand setCurrent(CLICommand cmd) {

core/src/main/java/hudson/cli/declarative/CLIRegisterer.java

Lines changed: 14 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,9 @@
2727
import static java.util.logging.Level.SEVERE;
2828

2929
import edu.umd.cs.findbugs.annotations.NonNull;
30-
import hudson.AbortException;
3130
import hudson.Extension;
3231
import hudson.ExtensionComponent;
3332
import hudson.ExtensionFinder;
34-
import hudson.Functions;
3533
import hudson.Util;
3634
import hudson.cli.CLICommand;
3735
import hudson.cli.CloneableCLICommand;
@@ -49,19 +47,17 @@
4947
import java.util.Locale;
5048
import java.util.MissingResourceException;
5149
import java.util.Stack;
52-
import java.util.UUID;
53-
import java.util.logging.Level;
5450
import java.util.logging.Logger;
5551
import jenkins.ExtensionComponentSet;
5652
import jenkins.ExtensionRefreshException;
53+
import jenkins.cli.listeners.CLIContext;
54+
import jenkins.cli.listeners.CLIListener;
5755
import jenkins.model.Jenkins;
56+
import jenkins.util.Listeners;
5857
import org.jvnet.hudson.annotation_indexer.Index;
5958
import org.jvnet.localizer.ResourceBundleHolder;
60-
import org.kohsuke.args4j.CmdLineException;
6159
import org.kohsuke.args4j.CmdLineParser;
6260
import org.kohsuke.args4j.ParserProperties;
63-
import org.springframework.security.access.AccessDeniedException;
64-
import org.springframework.security.authentication.BadCredentialsException;
6561
import org.springframework.security.core.Authentication;
6662
import org.springframework.security.core.context.SecurityContext;
6763
import org.springframework.security.core.context.SecurityContextHolder;
@@ -202,6 +198,9 @@ public int main(List<String> args, Locale locale, InputStream stdin, PrintStream
202198

203199
List<MethodBinder> binders = new ArrayList<>();
204200

201+
Authentication auth = getTransportAuthentication2();
202+
CLIContext context = new CLIContext(getName(), args, auth);
203+
205204
CmdLineParser parser = bindMethod(binders);
206205
try {
207206
// TODO this could probably use ACL.as; why is it calling SecurityContext.setAuthentication rather than SecurityContextHolder.setContext?
@@ -211,19 +210,19 @@ public int main(List<String> args, Locale locale, InputStream stdin, PrintStream
211210
// fill up all the binders
212211
parser.parseArgument(args);
213212

214-
Authentication auth = getTransportAuthentication2();
215213
sc.setAuthentication(auth); // run the CLI with the right credential
216214
jenkins.checkPermission(Jenkins.READ);
217215

216+
Listeners.notify(CLIListener.class, true, listener -> listener.onExecution(context));
217+
218218
// resolve them
219219
Object instance = null;
220220
for (MethodBinder binder : binders)
221221
instance = binder.call(instance);
222222

223-
if (instance instanceof Integer)
224-
return (Integer) instance;
225-
else
226-
return 0;
223+
Integer exitCode = (instance instanceof Integer) ? (Integer) instance : 0;
224+
Listeners.notify(CLIListener.class, true, listener -> listener.onCompleted(context, exitCode));
225+
return exitCode;
227226
} catch (InvocationTargetException e) {
228227
Throwable t = e.getTargetException();
229228
if (t instanceof Exception)
@@ -232,47 +231,13 @@ public int main(List<String> args, Locale locale, InputStream stdin, PrintStream
232231
} finally {
233232
sc.setAuthentication(old); // restore
234233
}
235-
} catch (CmdLineException e) {
236-
printError(e.getMessage());
237-
printUsage(stderr, parser);
238-
return 2;
239-
} catch (IllegalStateException e) {
240-
printError(e.getMessage());
241-
return 4;
242-
} catch (IllegalArgumentException e) {
243-
printError(e.getMessage());
244-
return 3;
245-
} catch (AbortException e) {
246-
printError(e.getMessage());
247-
return 5;
248-
} catch (AccessDeniedException e) {
249-
printError(e.getMessage());
250-
return 6;
251-
} catch (BadCredentialsException e) {
252-
// to the caller, we can't reveal whether the user didn't exist or the password didn't match.
253-
// do that to the server log instead
254-
String id = UUID.randomUUID().toString();
255-
logAndPrintError(e, "Bad Credentials. Search the server log for " + id + " for more details.",
256-
"CLI login attempt failed: " + id, Level.INFO);
257-
return 7;
258234
} catch (Throwable e) {
259-
final String errorMsg = "Unexpected exception occurred while performing " + getName() + " command.";
260-
logAndPrintError(e, errorMsg, errorMsg, Level.WARNING);
261-
Functions.printStackTrace(e, stderr);
262-
return 1;
235+
int exitCode = handleException(e, context, parser);
236+
Listeners.notify(CLIListener.class, true, listener -> listener.onThrowable(context, e));
237+
return exitCode;
263238
}
264239
}
265240

266-
private void printError(String errorMessage) {
267-
this.stderr.println();
268-
this.stderr.println("ERROR: " + errorMessage);
269-
}
270-
271-
private void logAndPrintError(Throwable e, String errorMessage, String logMessage, Level logLevel) {
272-
LOGGER.log(logLevel, logMessage, e);
273-
printError(errorMessage);
274-
}
275-
276241
@Override
277242
protected int run() throws Exception {
278243
throw new UnsupportedOperationException();
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright (c) 2025, CloudBees, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package jenkins.cli.listeners;
26+
27+
import edu.umd.cs.findbugs.annotations.CheckForNull;
28+
import edu.umd.cs.findbugs.annotations.NonNull;
29+
import edu.umd.cs.findbugs.annotations.Nullable;
30+
import java.util.List;
31+
import java.util.UUID;
32+
import org.springframework.security.core.Authentication;
33+
34+
/**
35+
* Holds information of a command execution. Same instance is used to all {@link CLIListener} invocations.
36+
* Use {@code correlationId} in order to group related events to the same command.
37+
*
38+
* @since TODO
39+
*/
40+
public class CLIContext {
41+
private final String correlationId = UUID.randomUUID().toString();
42+
private final String command;
43+
private final List<String> args;
44+
private final Authentication auth;
45+
46+
/**
47+
* @param command The command being executed.
48+
* @param args Arguments passed to the command.
49+
* @param auth Authenticated user performing the execution.
50+
*/
51+
public CLIContext(@NonNull String command, @CheckForNull List<String> args, @Nullable Authentication auth) {
52+
this.command = command;
53+
this.args = args != null ? args : List.of();
54+
this.auth = auth;
55+
}
56+
57+
/**
58+
* @return Correlate this command event to other, related command events.
59+
*/
60+
@NonNull
61+
public String getCorrelationId() {
62+
return correlationId;
63+
}
64+
65+
/**
66+
* @return Command being executed.
67+
*/
68+
@NonNull
69+
public String getCommand() {
70+
return command;
71+
}
72+
73+
/**
74+
* @return Arguments passed to the command.
75+
*/
76+
@NonNull
77+
public List<String> getArgs() {
78+
return args;
79+
}
80+
81+
/**
82+
* @return Authenticated user performing the execution.
83+
*/
84+
@CheckForNull
85+
public Authentication getAuth() {
86+
return auth;
87+
}
88+
}

0 commit comments

Comments
 (0)