mirror of https://github.com/jenkinsci/jenkins.git
[JENKINS-75378] Adding a CLI command listener (#10382)
This commit is contained in:
commit
2fb523ffe3
|
@ -48,10 +48,10 @@ import java.lang.reflect.Type;
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.UUID;
|
import jenkins.cli.listeners.CLIContext;
|
||||||
import java.util.logging.Level;
|
import jenkins.cli.listeners.CLIListener;
|
||||||
import java.util.logging.Logger;
|
|
||||||
import jenkins.model.Jenkins;
|
import jenkins.model.Jenkins;
|
||||||
|
import jenkins.util.Listeners;
|
||||||
import jenkins.util.SystemProperties;
|
import jenkins.util.SystemProperties;
|
||||||
import org.jvnet.hudson.annotation_indexer.Index;
|
import org.jvnet.hudson.annotation_indexer.Index;
|
||||||
import org.jvnet.tiger_types.Types;
|
import org.jvnet.tiger_types.Types;
|
||||||
|
@ -242,70 +242,73 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable {
|
||||||
this.locale = locale;
|
this.locale = locale;
|
||||||
CmdLineParser p = getCmdLineParser();
|
CmdLineParser p = getCmdLineParser();
|
||||||
|
|
||||||
|
Authentication auth = getTransportAuthentication2();
|
||||||
|
CLIContext context = new CLIContext(getName(), args, auth);
|
||||||
|
|
||||||
// add options from the authenticator
|
// add options from the authenticator
|
||||||
SecurityContext sc = null;
|
SecurityContext sc = null;
|
||||||
Authentication old = null;
|
Authentication old = null;
|
||||||
Authentication auth;
|
|
||||||
try {
|
try {
|
||||||
// TODO as in CLIRegisterer this may be doing too much work
|
// TODO as in CLIRegisterer this may be doing too much work
|
||||||
sc = SecurityContextHolder.getContext();
|
sc = SecurityContextHolder.getContext();
|
||||||
old = sc.getAuthentication();
|
old = sc.getAuthentication();
|
||||||
|
|
||||||
sc.setAuthentication(auth = getTransportAuthentication2());
|
sc.setAuthentication(auth);
|
||||||
|
|
||||||
if (!(this instanceof HelpCommand || this instanceof WhoAmICommand))
|
if (!(this instanceof HelpCommand || this instanceof WhoAmICommand))
|
||||||
Jenkins.get().checkPermission(Jenkins.READ);
|
Jenkins.get().checkPermission(Jenkins.READ);
|
||||||
p.parseArgument(args.toArray(new String[0]));
|
p.parseArgument(args.toArray(new String[0]));
|
||||||
LOGGER.log(Level.FINE, "Invoking CLI command {0}, with {1} arguments, as user {2}.",
|
|
||||||
new Object[] {getName(), args.size(), auth.getName()});
|
Listeners.notify(CLIListener.class, true, listener -> listener.onExecution(context));
|
||||||
int res = run();
|
int res = run();
|
||||||
LOGGER.log(Level.FINE, "Executed CLI command {0}, with {1} arguments, as user {2}, return code {3}",
|
Listeners.notify(CLIListener.class, true, listener -> listener.onCompleted(context, res));
|
||||||
new Object[] {getName(), args.size(), auth.getName(), res});
|
|
||||||
return res;
|
return res;
|
||||||
} catch (CmdLineException e) {
|
|
||||||
logFailedCommandAndPrintExceptionErrorMessage(args, e);
|
|
||||||
printUsage(stderr, p);
|
|
||||||
return 2;
|
|
||||||
} catch (IllegalStateException e) {
|
|
||||||
logFailedCommandAndPrintExceptionErrorMessage(args, e);
|
|
||||||
return 4;
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
logFailedCommandAndPrintExceptionErrorMessage(args, e);
|
|
||||||
return 3;
|
|
||||||
} catch (AbortException e) {
|
|
||||||
logFailedCommandAndPrintExceptionErrorMessage(args, e);
|
|
||||||
return 5;
|
|
||||||
} catch (AccessDeniedException e) {
|
|
||||||
logFailedCommandAndPrintExceptionErrorMessage(args, e);
|
|
||||||
return 6;
|
|
||||||
} catch (BadCredentialsException e) {
|
|
||||||
// to the caller, we can't reveal whether the user didn't exist or the password didn't match.
|
|
||||||
// do that to the server log instead
|
|
||||||
String id = UUID.randomUUID().toString();
|
|
||||||
logAndPrintError(e, "Bad Credentials. Search the server log for " + id + " for more details.",
|
|
||||||
"CLI login attempt failed: " + id, Level.INFO);
|
|
||||||
return 7;
|
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
String errorMsg = "Unexpected exception occurred while performing " + getName() + " command.";
|
int exitCode = handleException(e, context, p);
|
||||||
logAndPrintError(e, errorMsg, errorMsg, Level.WARNING);
|
Listeners.notify(CLIListener.class, true, listener -> listener.onThrowable(context, e));
|
||||||
Functions.printStackTrace(e, stderr);
|
return exitCode;
|
||||||
return 1;
|
|
||||||
} finally {
|
} finally {
|
||||||
if (sc != null)
|
if (sc != null)
|
||||||
sc.setAuthentication(old); // restore
|
sc.setAuthentication(old); // restore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void logFailedCommandAndPrintExceptionErrorMessage(List<String> args, Throwable e) {
|
/**
|
||||||
Authentication auth = getTransportAuthentication2();
|
* Determines command stderr output and return the exit code as described on {@link #main(List, Locale, InputStream, PrintStream, PrintStream)}
|
||||||
String logMessage = String.format("Failed call to CLI command %s, with %d arguments, as user %s.",
|
* */
|
||||||
getName(), args.size(), auth != null ? auth.getName() : "<unknown>");
|
protected int handleException(Throwable e, CLIContext context, CmdLineParser p) {
|
||||||
|
int exitCode;
|
||||||
logAndPrintError(e, e.getMessage(), logMessage, Level.FINE);
|
if (e instanceof CmdLineException) {
|
||||||
|
exitCode = 2;
|
||||||
|
printError(e.getMessage());
|
||||||
|
printUsage(stderr, p);
|
||||||
|
} else if (e instanceof IllegalArgumentException) {
|
||||||
|
exitCode = 3;
|
||||||
|
printError(e.getMessage());
|
||||||
|
} else if (e instanceof IllegalStateException) {
|
||||||
|
exitCode = 4;
|
||||||
|
printError(e.getMessage());
|
||||||
|
} else if (e instanceof AbortException) {
|
||||||
|
exitCode = 5;
|
||||||
|
printError(e.getMessage());
|
||||||
|
} else if (e instanceof AccessDeniedException) {
|
||||||
|
exitCode = 6;
|
||||||
|
printError(e.getMessage());
|
||||||
|
} else if (e instanceof BadCredentialsException) {
|
||||||
|
exitCode = 7;
|
||||||
|
printError(
|
||||||
|
"Bad Credentials. Search the server log for " + context.getCorrelationId() + " for more details.");
|
||||||
|
} else {
|
||||||
|
exitCode = 1;
|
||||||
|
printError("Unexpected exception occurred while performing " + getName() + " command.");
|
||||||
|
Functions.printStackTrace(e, stderr);
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void logAndPrintError(Throwable e, String errorMessage, String logMessage, Level logLevel) {
|
|
||||||
LOGGER.log(logLevel, logMessage, e);
|
private void printError(String errorMessage) {
|
||||||
this.stderr.println();
|
this.stderr.println();
|
||||||
this.stderr.println("ERROR: " + errorMessage);
|
this.stderr.println("ERROR: " + errorMessage);
|
||||||
}
|
}
|
||||||
|
@ -541,8 +544,6 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final Logger LOGGER = Logger.getLogger(CLICommand.class.getName());
|
|
||||||
|
|
||||||
private static final ThreadLocal<CLICommand> CURRENT_COMMAND = new ThreadLocal<>();
|
private static final ThreadLocal<CLICommand> CURRENT_COMMAND = new ThreadLocal<>();
|
||||||
|
|
||||||
/*package*/ static CLICommand setCurrent(CLICommand cmd) {
|
/*package*/ static CLICommand setCurrent(CLICommand cmd) {
|
||||||
|
|
|
@ -27,11 +27,9 @@ package hudson.cli.declarative;
|
||||||
import static java.util.logging.Level.SEVERE;
|
import static java.util.logging.Level.SEVERE;
|
||||||
|
|
||||||
import edu.umd.cs.findbugs.annotations.NonNull;
|
import edu.umd.cs.findbugs.annotations.NonNull;
|
||||||
import hudson.AbortException;
|
|
||||||
import hudson.Extension;
|
import hudson.Extension;
|
||||||
import hudson.ExtensionComponent;
|
import hudson.ExtensionComponent;
|
||||||
import hudson.ExtensionFinder;
|
import hudson.ExtensionFinder;
|
||||||
import hudson.Functions;
|
|
||||||
import hudson.Util;
|
import hudson.Util;
|
||||||
import hudson.cli.CLICommand;
|
import hudson.cli.CLICommand;
|
||||||
import hudson.cli.CloneableCLICommand;
|
import hudson.cli.CloneableCLICommand;
|
||||||
|
@ -49,19 +47,17 @@ import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.MissingResourceException;
|
import java.util.MissingResourceException;
|
||||||
import java.util.Stack;
|
import java.util.Stack;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.logging.Level;
|
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
import jenkins.ExtensionComponentSet;
|
import jenkins.ExtensionComponentSet;
|
||||||
import jenkins.ExtensionRefreshException;
|
import jenkins.ExtensionRefreshException;
|
||||||
|
import jenkins.cli.listeners.CLIContext;
|
||||||
|
import jenkins.cli.listeners.CLIListener;
|
||||||
import jenkins.model.Jenkins;
|
import jenkins.model.Jenkins;
|
||||||
|
import jenkins.util.Listeners;
|
||||||
import org.jvnet.hudson.annotation_indexer.Index;
|
import org.jvnet.hudson.annotation_indexer.Index;
|
||||||
import org.jvnet.localizer.ResourceBundleHolder;
|
import org.jvnet.localizer.ResourceBundleHolder;
|
||||||
import org.kohsuke.args4j.CmdLineException;
|
|
||||||
import org.kohsuke.args4j.CmdLineParser;
|
import org.kohsuke.args4j.CmdLineParser;
|
||||||
import org.kohsuke.args4j.ParserProperties;
|
import org.kohsuke.args4j.ParserProperties;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContext;
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
@ -202,6 +198,9 @@ public class CLIRegisterer extends ExtensionFinder {
|
||||||
|
|
||||||
List<MethodBinder> binders = new ArrayList<>();
|
List<MethodBinder> binders = new ArrayList<>();
|
||||||
|
|
||||||
|
Authentication auth = getTransportAuthentication2();
|
||||||
|
CLIContext context = new CLIContext(getName(), args, auth);
|
||||||
|
|
||||||
CmdLineParser parser = bindMethod(binders);
|
CmdLineParser parser = bindMethod(binders);
|
||||||
try {
|
try {
|
||||||
// TODO this could probably use ACL.as; why is it calling SecurityContext.setAuthentication rather than SecurityContextHolder.setContext?
|
// TODO this could probably use ACL.as; why is it calling SecurityContext.setAuthentication rather than SecurityContextHolder.setContext?
|
||||||
|
@ -211,19 +210,19 @@ public class CLIRegisterer extends ExtensionFinder {
|
||||||
// fill up all the binders
|
// fill up all the binders
|
||||||
parser.parseArgument(args);
|
parser.parseArgument(args);
|
||||||
|
|
||||||
Authentication auth = getTransportAuthentication2();
|
|
||||||
sc.setAuthentication(auth); // run the CLI with the right credential
|
sc.setAuthentication(auth); // run the CLI with the right credential
|
||||||
jenkins.checkPermission(Jenkins.READ);
|
jenkins.checkPermission(Jenkins.READ);
|
||||||
|
|
||||||
|
Listeners.notify(CLIListener.class, true, listener -> listener.onExecution(context));
|
||||||
|
|
||||||
// resolve them
|
// resolve them
|
||||||
Object instance = null;
|
Object instance = null;
|
||||||
for (MethodBinder binder : binders)
|
for (MethodBinder binder : binders)
|
||||||
instance = binder.call(instance);
|
instance = binder.call(instance);
|
||||||
|
|
||||||
if (instance instanceof Integer)
|
Integer exitCode = (instance instanceof Integer) ? (Integer) instance : 0;
|
||||||
return (Integer) instance;
|
Listeners.notify(CLIListener.class, true, listener -> listener.onCompleted(context, exitCode));
|
||||||
else
|
return exitCode;
|
||||||
return 0;
|
|
||||||
} catch (InvocationTargetException e) {
|
} catch (InvocationTargetException e) {
|
||||||
Throwable t = e.getTargetException();
|
Throwable t = e.getTargetException();
|
||||||
if (t instanceof Exception)
|
if (t instanceof Exception)
|
||||||
|
@ -232,47 +231,13 @@ public class CLIRegisterer extends ExtensionFinder {
|
||||||
} finally {
|
} finally {
|
||||||
sc.setAuthentication(old); // restore
|
sc.setAuthentication(old); // restore
|
||||||
}
|
}
|
||||||
} catch (CmdLineException e) {
|
|
||||||
printError(e.getMessage());
|
|
||||||
printUsage(stderr, parser);
|
|
||||||
return 2;
|
|
||||||
} catch (IllegalStateException e) {
|
|
||||||
printError(e.getMessage());
|
|
||||||
return 4;
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
printError(e.getMessage());
|
|
||||||
return 3;
|
|
||||||
} catch (AbortException e) {
|
|
||||||
printError(e.getMessage());
|
|
||||||
return 5;
|
|
||||||
} catch (AccessDeniedException e) {
|
|
||||||
printError(e.getMessage());
|
|
||||||
return 6;
|
|
||||||
} catch (BadCredentialsException e) {
|
|
||||||
// to the caller, we can't reveal whether the user didn't exist or the password didn't match.
|
|
||||||
// do that to the server log instead
|
|
||||||
String id = UUID.randomUUID().toString();
|
|
||||||
logAndPrintError(e, "Bad Credentials. Search the server log for " + id + " for more details.",
|
|
||||||
"CLI login attempt failed: " + id, Level.INFO);
|
|
||||||
return 7;
|
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
final String errorMsg = "Unexpected exception occurred while performing " + getName() + " command.";
|
int exitCode = handleException(e, context, parser);
|
||||||
logAndPrintError(e, errorMsg, errorMsg, Level.WARNING);
|
Listeners.notify(CLIListener.class, true, listener -> listener.onThrowable(context, e));
|
||||||
Functions.printStackTrace(e, stderr);
|
return exitCode;
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void printError(String errorMessage) {
|
|
||||||
this.stderr.println();
|
|
||||||
this.stderr.println("ERROR: " + errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logAndPrintError(Throwable e, String errorMessage, String logMessage, Level logLevel) {
|
|
||||||
LOGGER.log(logLevel, logMessage, e);
|
|
||||||
printError(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected int run() throws Exception {
|
protected int run() throws Exception {
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
/*
|
||||||
|
* The MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025, CloudBees, Inc.
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package jenkins.cli.listeners;
|
||||||
|
|
||||||
|
import edu.umd.cs.findbugs.annotations.CheckForNull;
|
||||||
|
import edu.umd.cs.findbugs.annotations.NonNull;
|
||||||
|
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds information of a command execution. Same instance is used to all {@link CLIListener} invocations.
|
||||||
|
* Use {@code correlationId} in order to group related events to the same command.
|
||||||
|
*
|
||||||
|
* @since TODO
|
||||||
|
*/
|
||||||
|
public class CLIContext {
|
||||||
|
private final String correlationId = UUID.randomUUID().toString();
|
||||||
|
private final String command;
|
||||||
|
private final List<String> args;
|
||||||
|
private final Authentication auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param command The command being executed.
|
||||||
|
* @param args Arguments passed to the command.
|
||||||
|
* @param auth Authenticated user performing the execution.
|
||||||
|
*/
|
||||||
|
public CLIContext(@NonNull String command, @CheckForNull List<String> args, @Nullable Authentication auth) {
|
||||||
|
this.command = command;
|
||||||
|
this.args = args != null ? args : List.of();
|
||||||
|
this.auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Correlate this command event to other, related command events.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public String getCorrelationId() {
|
||||||
|
return correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Command being executed.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public String getCommand() {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Arguments passed to the command.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public List<String> getArgs() {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Authenticated user performing the execution.
|
||||||
|
*/
|
||||||
|
@CheckForNull
|
||||||
|
public Authentication getAuth() {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* The MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025, CloudBees, Inc.
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package jenkins.cli.listeners;
|
||||||
|
|
||||||
|
import edu.umd.cs.findbugs.annotations.NonNull;
|
||||||
|
import hudson.ExtensionPoint;
|
||||||
|
import hudson.cli.CLICommand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows implementations to listen to {@link CLICommand#run()} execution events.
|
||||||
|
*
|
||||||
|
* @since TODO
|
||||||
|
*/
|
||||||
|
public interface CLIListener extends ExtensionPoint {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked before command execution.
|
||||||
|
*
|
||||||
|
* @param context Information about the command being executed.
|
||||||
|
* */
|
||||||
|
default void onExecution(@NonNull CLIContext context) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked after command execution.
|
||||||
|
*
|
||||||
|
* @param context Information about the command being executed.
|
||||||
|
* @param exitCode Exit code returned by the implementation of {@link CLICommand#run()}.
|
||||||
|
* */
|
||||||
|
default void onCompleted(@NonNull CLIContext context, int exitCode) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked when an exception or error occurs during command execution.
|
||||||
|
*
|
||||||
|
* @param context Information about the command being executed.
|
||||||
|
* @param t Any error during the execution of the command.
|
||||||
|
* */
|
||||||
|
default void onThrowable(@NonNull CLIContext context, @NonNull Throwable t) {}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
/*
|
||||||
|
* The MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025, CloudBees, Inc.
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package jenkins.cli.listeners;
|
||||||
|
|
||||||
|
import hudson.AbortException;
|
||||||
|
import hudson.Extension;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.kohsuke.accmod.Restricted;
|
||||||
|
import org.kohsuke.accmod.restrictions.NoExternalUse;
|
||||||
|
import org.kohsuke.args4j.CmdLineException;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic default implementation of {@link CLIListener} that just logs.
|
||||||
|
*/
|
||||||
|
@Extension
|
||||||
|
@Restricted(NoExternalUse.class)
|
||||||
|
public class DefaultCLIListener implements CLIListener {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(DefaultCLIListener.class.getName());
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onExecution(CLIContext context) {
|
||||||
|
LOGGER.log(Level.FINE, "Invoking CLI command {0}, with {1} arguments, as user {2}.", new Object[] {
|
||||||
|
context.getCommand(), context.getArgs().size(), authName(context.getAuth()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted(CLIContext context, int exitCode) {
|
||||||
|
LOGGER.log(
|
||||||
|
Level.FINE, "Executed CLI command {0}, with {1} arguments, as user {2}, return code {3}", new Object[] {
|
||||||
|
context.getCommand(), context.getArgs().size(), authName(context.getAuth()), exitCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onThrowable(CLIContext context, Throwable t) {
|
||||||
|
if (t instanceof BadCredentialsException) {
|
||||||
|
// to the caller (stderr), we can't reveal whether the user didn't exist or the password didn't match.
|
||||||
|
// do that to the server log instead
|
||||||
|
LOGGER.log(Level.INFO, "CLI login attempt failed: " + context.getCorrelationId(), t);
|
||||||
|
} else if (t instanceof CmdLineException
|
||||||
|
|| t instanceof IllegalArgumentException
|
||||||
|
|| t instanceof IllegalStateException
|
||||||
|
|| t instanceof AbortException
|
||||||
|
|| t instanceof AccessDeniedException) {
|
||||||
|
// covered cases on CLICommand#handleException
|
||||||
|
LOGGER.log(
|
||||||
|
Level.FINE,
|
||||||
|
String.format(
|
||||||
|
"Failed call to CLI command %s, with %d arguments, as user %s.",
|
||||||
|
context.getCommand(), context.getArgs().size(), authName(context.getAuth())),
|
||||||
|
t);
|
||||||
|
} else {
|
||||||
|
LOGGER.log(
|
||||||
|
Level.WARNING,
|
||||||
|
"Unexpected exception occurred while performing " + context.getCommand() + " command.",
|
||||||
|
t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String authName(Authentication auth) {
|
||||||
|
return auth != null ? auth.getName() : "<unknown>";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,186 @@
|
||||||
|
/*
|
||||||
|
* The MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025, CloudBees, Inc.
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package jenkins.cli;
|
||||||
|
|
||||||
|
import static org.hamcrest.MatcherAssert.assertThat;
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.hamcrest.Matchers.notNullValue;
|
||||||
|
|
||||||
|
import hudson.cli.CLICommand;
|
||||||
|
import hudson.cli.CLICommandInvoker;
|
||||||
|
import hudson.cli.ListJobsCommand;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import jenkins.cli.listeners.DefaultCLIListener;
|
||||||
|
import jenkins.model.Jenkins;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.jvnet.hudson.test.JenkinsRule;
|
||||||
|
import org.jvnet.hudson.test.LoggerRule;
|
||||||
|
import org.jvnet.hudson.test.MockAuthorizationStrategy;
|
||||||
|
import org.jvnet.hudson.test.TestExtension;
|
||||||
|
|
||||||
|
public class DefaultCLIListenerTest {
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public JenkinsRule j = new JenkinsRule();
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public LoggerRule logging = new LoggerRule();
|
||||||
|
|
||||||
|
private static final String USER = "cli-user";
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp() throws IOException {
|
||||||
|
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||||
|
j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy()
|
||||||
|
.grant(Jenkins.ADMINISTER)
|
||||||
|
.everywhere()
|
||||||
|
.to(USER));
|
||||||
|
j.createFreeStyleProject("p");
|
||||||
|
logging.record(DefaultCLIListener.class, Level.FINE).capture(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void commandOnCompletedIsLogged() throws Exception {
|
||||||
|
CLICommandInvoker command = new CLICommandInvoker(j, new ListJobsCommand());
|
||||||
|
command.asUser(USER).invoke();
|
||||||
|
|
||||||
|
List<String> messages = logging.getMessages();
|
||||||
|
assertThat(messages, hasSize(2));
|
||||||
|
assertThat(
|
||||||
|
messages.get(0),
|
||||||
|
containsString("Invoking CLI command list-jobs, with 0 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(
|
||||||
|
messages.get(1),
|
||||||
|
containsString(
|
||||||
|
"Executed CLI command list-jobs, with 0 arguments, as user %s, return code 0".formatted(USER)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void commandOnThrowableIsLogged() throws Exception {
|
||||||
|
CLICommandInvoker command = new CLICommandInvoker(j, new ListJobsCommand());
|
||||||
|
command.asUser(USER).invokeWithArgs("view-not-found");
|
||||||
|
|
||||||
|
List<String> messages = logging.getMessages();
|
||||||
|
assertThat(messages, hasSize(2));
|
||||||
|
assertThat(
|
||||||
|
messages.get(0),
|
||||||
|
containsString("Invoking CLI command list-jobs, with 1 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(
|
||||||
|
messages.get(1),
|
||||||
|
containsString("Failed call to CLI command list-jobs, with 1 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(
|
||||||
|
logging.getRecords().get(0).getThrown().getMessage(),
|
||||||
|
containsString("No view or item group with the given name 'view-not-found' found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void commandOnThrowableUnexpectedIsLogged() throws Exception {
|
||||||
|
CLICommandInvoker command = new CLICommandInvoker(j, new ThrowsTestCommand());
|
||||||
|
command.asUser(USER).invoke();
|
||||||
|
|
||||||
|
List<String> messages = logging.getMessages();
|
||||||
|
assertThat(messages, hasSize(2));
|
||||||
|
assertThat(
|
||||||
|
messages.get(0),
|
||||||
|
containsString(
|
||||||
|
"Invoking CLI command throws-test-command, with 0 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(
|
||||||
|
messages.get(1),
|
||||||
|
containsString("Unexpected exception occurred while performing throws-test-command command."));
|
||||||
|
assertThat(logging.getRecords().get(0).getThrown().getMessage(), containsString("unexpected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void methodOnCompletedIsLogged() throws Exception {
|
||||||
|
CLICommandInvoker command = new CLICommandInvoker(j, "disable-job");
|
||||||
|
command.asUser(USER).invokeWithArgs("p");
|
||||||
|
|
||||||
|
List<String> messages = logging.getMessages();
|
||||||
|
assertThat(messages, hasSize(2));
|
||||||
|
assertThat(
|
||||||
|
messages.get(0),
|
||||||
|
containsString("Invoking CLI command disable-job, with 1 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(
|
||||||
|
messages.get(1),
|
||||||
|
containsString("Executed CLI command disable-job, with 1 arguments, as user %s, return code 0"
|
||||||
|
.formatted(USER)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void methodOnThrowableIsLogged() throws Exception {
|
||||||
|
CLICommandInvoker command = new CLICommandInvoker(j, "disable-job");
|
||||||
|
command.asUser(USER).invokeWithArgs("job-not-found");
|
||||||
|
|
||||||
|
List<String> messages = logging.getMessages();
|
||||||
|
assertThat(messages, hasSize(2));
|
||||||
|
assertThat(
|
||||||
|
messages.get(0),
|
||||||
|
containsString("Invoking CLI command disable-job, with 1 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(
|
||||||
|
messages.get(1),
|
||||||
|
containsString(
|
||||||
|
"Failed call to CLI command disable-job, with 1 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(
|
||||||
|
logging.getRecords().get(0).getThrown().getMessage(),
|
||||||
|
containsString("No such job ‘job-not-found’ exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void methodOnThrowableUnexpectedIsLogged() throws Exception {
|
||||||
|
CLICommandInvoker command = new CLICommandInvoker(j, "restart");
|
||||||
|
command.asUser(USER).invoke();
|
||||||
|
|
||||||
|
List<String> messages = logging.getMessages();
|
||||||
|
assertThat(messages, hasSize(2));
|
||||||
|
assertThat(
|
||||||
|
messages.get(0),
|
||||||
|
containsString("Invoking CLI command restart, with 0 arguments, as user %s.".formatted(USER)));
|
||||||
|
assertThat(messages.get(1), containsString("Unexpected exception occurred while performing restart command."));
|
||||||
|
assertThat(logging.getRecords().get(0).getThrown(), notNullValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestExtension
|
||||||
|
public static class ThrowsTestCommand extends CLICommand {
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "throws-test-command";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getShortDescription() {
|
||||||
|
return "throws test command";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int run() {
|
||||||
|
throw new RuntimeException("unexpected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue