diff --git a/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java b/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java index ea37d444..43920cc3 100644 --- a/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java +++ b/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java @@ -43,7 +43,7 @@ */ public class RundeckClient { public static final String USER_AGENT = Version.NAME + "/" + Version.VERSION; - public static final int API_VERS = 25; + public static final int API_VERS = 29; public static final Pattern API_VERS_PATTERN = Pattern.compile("^(.*)(/api/(\\d+)/?)$"); public static final String ENV_BYPASS_URL = "RD_BYPASS_URL"; public static final String ENV_INSECURE_SSL = "RD_INSECURE_SSL"; diff --git a/rd-api-client/src/main/java/org/rundeck/client/api/RundeckApi.java b/rd-api-client/src/main/java/org/rundeck/client/api/RundeckApi.java index 5d158034..a279659a 100644 --- a/rd-api-client/src/main/java/org/rundeck/client/api/RundeckApi.java +++ b/rd-api-client/src/main/java/org/rundeck/client/api/RundeckApi.java @@ -20,6 +20,7 @@ import okhttp3.RequestBody; import okhttp3.ResponseBody; import org.rundeck.client.api.model.*; +import org.rundeck.client.api.model.executions.MetricsResponse; import org.rundeck.client.api.model.metrics.EndpointListResult; import org.rundeck.client.api.model.metrics.HealthCheckStatus; import org.rundeck.client.api.model.metrics.MetricsData; @@ -1194,4 +1195,86 @@ Call bulkDisableJobSchedule( @GET("metrics/metrics") Call getMetricsData(); + /* Execution Query Metrics */ + + /** + * Get stats on a project-wide query of executions, with all query parameters available, in JSON format. + * @param project + * @param options + * @param jobIdListFilter + * @param xjobIdListFilter + * @param jobListFilter + * @param excludeJobListFilters + * @return + */ + @Headers("Accept: application/json") + @GET("project/{project}/executions/metrics") + Call executionMetrics( + @Path("project") String project, + @QueryMap Map options, + @Query("jobIdListFilter") List jobIdListFilter, + @Query("excludeJobIdListFilter") List xjobIdListFilter, + @Query("jobListFilter") List jobListFilter, + @Query("excludeJobListFilter") List excludeJobListFilters + ); + + /** + * Get stats on a system-wide query of executions, with all query parameters available, in JSON format. + * @param options + * @param jobIdListFilter + * @param xjobIdListFilter + * @param jobListFilter + * @param excludeJobListFilters + * @return + */ + @Headers("Accept: application/json") + @GET("executions/metrics") + Call executionMetrics( + @QueryMap Map options, + @Query("jobIdListFilter") List jobIdListFilter, + @Query("excludeJobIdListFilter") List xjobIdListFilter, + @Query("jobListFilter") List jobListFilter, + @Query("excludeJobListFilter") List excludeJobListFilters + ); + + /** + * Get stats on a project-wide query of executions, with all query parameters available, in XML format. + * @param project + * @param options + * @param jobIdListFilter + * @param xjobIdListFilter + * @param jobListFilter + * @param excludeJobListFilters + * @return + */ + @Headers("Accept: application/xml") + @GET("project/{project}/executions/metrics") + Call executionMetricsXML( + @Path("project") String project, + @QueryMap Map options, + @Query("jobIdListFilter") List jobIdListFilter, + @Query("excludeJobIdListFilter") List xjobIdListFilter, + @Query("jobListFilter") List jobListFilter, + @Query("excludeJobListFilter") List excludeJobListFilters + ); + + /** + * Get stats on a system-wide query of executions, with all query parameters available, in XML format. + * @param options + * @param jobIdListFilter + * @param xjobIdListFilter + * @param jobListFilter + * @param excludeJobListFilters + * @return + */ + @Headers("Accept: application/xml") + @GET("executions/metrics") + Call executionMetricsXML( + @QueryMap Map options, + @Query("jobIdListFilter") List jobIdListFilter, + @Query("excludeJobIdListFilter") List xjobIdListFilter, + @Query("jobListFilter") List jobListFilter, + @Query("excludeJobListFilter") List excludeJobListFilters + ); + } diff --git a/rd-api-client/src/main/java/org/rundeck/client/api/model/executions/MetricsResponse.java b/rd-api-client/src/main/java/org/rundeck/client/api/model/executions/MetricsResponse.java new file mode 100644 index 00000000..d6a90a84 --- /dev/null +++ b/rd-api-client/src/main/java/org/rundeck/client/api/model/executions/MetricsResponse.java @@ -0,0 +1,74 @@ +package org.rundeck.client.api.model.executions; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import org.rundeck.client.util.DataOutput; + +import java.util.HashMap; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +@Data +public class MetricsResponse + implements DataOutput +{ + private Long total; + private Status status; + private Map duration; + + @Override + public Map asMap() { + HashMap data = new HashMap<>(); + data.put("total", total); + data.put("status", status); + data.put("duration", duration); + return data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Data + public static class Status + implements DataOutput + { + private Long succeeded; + private Long failed; + @JsonProperty("failed-with-retry") + private Long failedWithRetry; + private Long aborted; + private Long running; + private Long other; + private Long timedout; + private Long scheduled; + + @Override + public Map asMap() { + HashMap data = new HashMap<>(); + if (null != succeeded) { + data.put("succeeded", succeeded); + } + if (null != failed) { + data.put("failed", failed); + } + if (null != failedWithRetry) { + data.put("failed-with-retry", failedWithRetry); + } + if (null != aborted) { + data.put("aborted", aborted); + } + if (null != running) { + data.put("running", running); + } + if (null != other) { + data.put("other", other); + } + if (null != timedout) { + data.put("timedout", timedout); + } + if (null != scheduled) { + data.put("scheduled", scheduled); + } + return data; + } + } +} diff --git a/rd-api-client/src/main/java/org/rundeck/client/util/Format.java b/rd-api-client/src/main/java/org/rundeck/client/util/Format.java index e52de81d..af066fa8 100644 --- a/rd-api-client/src/main/java/org/rundeck/client/util/Format.java +++ b/rd-api-client/src/main/java/org/rundeck/client/util/Format.java @@ -30,6 +30,10 @@ public class Format { private Format() { } + public static String format(String format, DataOutput data, final String start, final String end) { + return format(format, data.asMap(), start, end); + } + public static String format(String format, Map data, final String start, final String end) { Pattern pat = Pattern.compile(Pattern.quote(start) + "([\\w.]+)" + Pattern.quote(end)); Matcher matcher = pat.matcher(format); @@ -79,6 +83,15 @@ private static Object descend(final Map data, final String... found) { return (Map map) -> format(format, map, start, end); } + public static Function dataFormatter( + String format, + final String start, + final String end + ) + { + return formatter(format, DataOutput::asMap, start, end); + } + @SuppressWarnings("SameParameterValue") public static Function formatter( String format, diff --git a/src/main/java/org/rundeck/client/tool/commands/Adhoc.java b/src/main/java/org/rundeck/client/tool/commands/Adhoc.java index d2d97d78..aec8d652 100644 --- a/src/main/java/org/rundeck/client/tool/commands/Adhoc.java +++ b/src/main/java/org/rundeck/client/tool/commands/Adhoc.java @@ -17,9 +17,6 @@ package org.rundeck.client.tool.commands; import com.lexicalscope.jewel.cli.CommandLineInterface; -import org.rundeck.toolbelt.Command; -import org.rundeck.toolbelt.CommandOutput; -import org.rundeck.toolbelt.InputError; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.RequestBody; @@ -27,8 +24,12 @@ import org.rundeck.client.api.model.Execution; import org.rundeck.client.tool.RdApp; import org.rundeck.client.tool.options.AdhocBaseOptions; +import org.rundeck.client.tool.options.ExecutionResultOptions; import org.rundeck.client.util.Quoting; import org.rundeck.client.util.Util; +import org.rundeck.toolbelt.Command; +import org.rundeck.toolbelt.CommandOutput; +import org.rundeck.toolbelt.InputError; import java.io.ByteArrayOutputStream; import java.io.File; @@ -50,7 +51,7 @@ public Adhoc(final RdApp client) { } @CommandLineInterface(application = COMMAND) interface AdhocOptions extends AdhocBaseOptions, - Executions.ExecutionResultOptions + ExecutionResultOptions { } diff --git a/src/main/java/org/rundeck/client/tool/commands/Executions.java b/src/main/java/org/rundeck/client/tool/commands/Executions.java index 92f6385b..46aca6de 100644 --- a/src/main/java/org/rundeck/client/tool/commands/Executions.java +++ b/src/main/java/org/rundeck/client/tool/commands/Executions.java @@ -16,19 +16,23 @@ package org.rundeck.client.tool.commands; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.lexicalscope.jewel.cli.CommandLineInterface; import com.lexicalscope.jewel.cli.Option; -import org.rundeck.client.util.Util; -import org.rundeck.toolbelt.Command; -import org.rundeck.toolbelt.CommandOutput; -import org.rundeck.toolbelt.InputError; +import okhttp3.ResponseBody; import org.rundeck.client.api.RundeckApi; import org.rundeck.client.api.model.*; -import org.rundeck.client.util.RdClientConfig; +import org.rundeck.client.api.model.executions.MetricsResponse; import org.rundeck.client.tool.RdApp; import org.rundeck.client.tool.options.*; import org.rundeck.client.util.Format; +import org.rundeck.client.util.RdClientConfig; import org.rundeck.client.util.ServiceClient; +import org.rundeck.client.util.Util; +import org.rundeck.toolbelt.Command; +import org.rundeck.toolbelt.CommandOutput; +import org.rundeck.toolbelt.InputError; import java.io.IOException; import java.util.*; @@ -45,6 +49,9 @@ @Command(description = "List running executions, attach and follow their output, or kill them.") public class Executions extends AppCommand { + private static final ObjectMapper JSON = new ObjectMapper(); + + public Executions(final RdApp client) { super(client); } @@ -280,128 +287,16 @@ public void list(ListCmd options, CommandOutput out) throws IOException, InputEr } - public interface ExecutionResultOptions extends ExecutionOutputFormatOption, VerboseOption { - - } - - @CommandLineInterface(application = "query") interface QueryCmd - extends ExecutionListOptions, ProjectNameOptions, ExecutionResultOptions - { - @Option(shortName = "d", - longName = "recent", - description = "Get executions newer than specified time. e.g. \"3m\" (3 months). \n" + - "Use: h,n,s,d,w,m,y (hour,minute,second,day,week,month,year)") - String getRecentFilter(); - - boolean isRecentFilter(); - - @Option(shortName = "O", longName = "older", - description = "Get executions older than specified time. e.g. \"3m\" (3 months). \n" + - "Use: h,n,s,d,w,m,y (hour,minute,second,day,week,month,year)") - String getOlderFilter(); - - boolean isOlderFilter(); - - @Option(shortName = "s", longName = "status", - description = "Status filter, one of: running,succeeded,failed,aborted") - String getStatusFilter(); - - boolean isStatusFilter(); - - @Option(shortName = "u", longName = "user", - description = "User filter") - String getUserFilter(); - - boolean isUserFilter(); - - @Option(shortName = "A", longName = "adhoconly", - description = "Adhoc executions only") - boolean isAdhoc(); - - @Option(shortName = "J", longName = "jobonly", - description = "Job executions only") - boolean isJob(); - - @Option(shortName = "i", longName = "jobids", - description = "Job ID list to include") - List getJobIdList(); - - boolean isJobIdList(); - - @Option(shortName = "j", longName = "jobs", - description = "List of Full job group and name to include.") - List getJobList(); - - boolean isJobList(); - - @Option(shortName = "x", longName = "xjobids", - description = "Job ID list to exclude") - List getExcludeJobIdList(); - - boolean isExcludeJobIdList(); - - @Option(shortName = "X", longName = "xjobs", - description = "List of Full job group and name to exclude.") - List getExcludeJobList(); - - boolean isExcludeJobList(); - - - @Option(shortName = "g", longName = "group", - description = "Group or partial group path to include, \"-\" means top-level jobs only") - String getGroupPath(); - - boolean isGroupPath(); - - @Option(longName = "xgroup", - description = "Group or partial group path to exclude, \"-\" means top-level jobs only") - String getExcludeGroupPath(); - - boolean isExcludeGroupPath(); - - @Option(shortName = "G", longName = "groupexact", - description = "Exact group path to include, \"-\" means top-level jobs only") - String getGroupPathExact(); - - boolean isGroupPathExact(); - - @Option(longName = "xgroupexact", - description = "Exact group path to exclude, \"-\" means top-level jobs only") - String getExcludeGroupPathExact(); - - boolean isExcludeGroupPathExact(); - - @Option(shortName = "n", longName = "name", - description = "Job Name Filter, include any name that matches this value") - String getJobFilter(); - - boolean isJobFilter(); - - @Option(longName = "xname", - description = "Exclude Job Name Filter, exclude any name that matches this value") - String getExcludeJobFilter(); - - boolean isExcludeJobFilter(); - - @Option(shortName = "N", longName = "nameexact", - description = "Exact Job Name Filter, include any name that is equal to this value") - String getJobExactFilter(); - - boolean isJobExactFilter(); - - @Option(longName = "xnameexact", - description = "Exclude Exact Job Name Filter, exclude any name that is equal to this value") - String getExcludeJobExactFilter(); - - boolean isExcludeJobExactFilter(); + @CommandLineInterface(application = "query") + interface QueryCmd extends QueryOptions, ExecutionResultOptions, ExecutionListOptions { @Option(longName = "noninteractive", - description = "Don't use interactive prompts to load more pages if there are more paged results (query command only)") + description = "Don't use interactive prompts to load more pages if there are more paged results (query command only)") boolean isNonInteractive(); @Option(longName = "autopage", - description = "Automatically load more results in non-interactive mode if there are more paged " - + "results. (query command only)") + description = "Automatically load more results in non-interactive mode if there are more paged " + + "results. (query command only)") boolean isAutoLoadPages(); } @@ -520,14 +415,18 @@ public ExecutionList query(boolean disableInteractive, QueryCmd options, Command } private Map createQueryParams( - final QueryCmd options, - final int max, - final int offset + final QueryOptions options, + final Integer max, + final Integer offset ) { final Map query = new HashMap<>(); - query.put("max", Integer.toString(max)); - query.put("offset", Integer.toString(offset)); + if(max != null) { + query.put("max", Integer.toString(max)); + } + if(offset != null) { + query.put("offset", Integer.toString(offset)); + } if (options.isRecentFilter()) { query.put("recentFilter", options.getRecentFilter()); } @@ -743,4 +642,110 @@ private static BooleanSupplier waitUnlessInterrupt(final int millis) { } }; } + + @CommandLineInterface(application = "metrics") + interface MetricsCmd + extends QueryOptions + { + + @Option( + longName = "xml", + description = "Get the result in raw xml. Note: cannot be combined with RD_FORMAT env variable.") + boolean isRawXML(); + + + @Option(shortName = "%", + longName = "outformat", + description = + "Output format specifier for execution metrics data. You can use \"%key\" where key is one " + + "of: total,failed-with-retry,failed,succeeded,duration-avg,duration-min,duration-max. E.g. " + + "\"%total %failed %succeeded\"") + String getOutputFormat(); + + boolean isOutputFormat(); + } + + + @Command(description = "Obtain metrics over the result set of an execution query.") + public void metrics(MetricsCmd options, CommandOutput out) throws IOException, InputError { + requireApiVersion("metrics", 29); + + // Check parameters. + if (!"xml".equalsIgnoreCase(getAppConfig().getString("RD_FORMAT", "xml")) && options.isRawXML()) { + throw new InputError("You cannot use RD_FORMAT env var with --xml"); + } + + Map query = createQueryParams(options, null, null); + + MetricsResponse result; + + // Case project wire. + if (options.isProject()) { + + // Raw XML + if ("XML".equalsIgnoreCase(getAppConfig().getString("RD_FORMAT", null)) || options.isRawXML()) { + ResponseBody response = apiCall(api -> api.executionMetricsXML( + options.getProject(), + query, + options.getJobIdList(), + options.getExcludeJobIdList(), + options.getJobList(), + options.getExcludeJobList() + )); + out.output(response.string()); + return; + } + + // Get response. + result = apiCall(api -> api.executionMetrics( + options.getProject(), + query, + options.getJobIdList(), + options.getExcludeJobIdList(), + options.getJobList(), + options.getExcludeJobList() + )); + + } + + // Case system-wide + else { + + // Raw XML + if ("XML".equalsIgnoreCase(getAppConfig().getString("RD_FORMAT", null)) || options.isRawXML()) { + ResponseBody response = apiCall(api -> api.executionMetricsXML( + query, + options.getJobIdList(), + options.getExcludeJobIdList(), + options.getJobList(), + options.getExcludeJobList() + )); + out.output(response.string()); + return; + } + + // Get raw Json. + result = apiCall(api -> api.executionMetrics( + query, + options.getJobIdList(), + options.getExcludeJobIdList(), + options.getJobList(), + options.getExcludeJobList() + )); + + } + + if (!options.isOutputFormat()) { + if (result.getTotal() == null || result.getTotal() < 1) { + out.info("No results."); + return; + } + out.info(String.format("Showing stats for %d matching executions.", result.getTotal())); + out.output(result); + return; + } + out.output(Format.format(options.getOutputFormat(), result, "%", "")); + } + + } diff --git a/src/main/java/org/rundeck/client/tool/options/ExecutionResultOptions.java b/src/main/java/org/rundeck/client/tool/options/ExecutionResultOptions.java new file mode 100644 index 00000000..e772790b --- /dev/null +++ b/src/main/java/org/rundeck/client/tool/options/ExecutionResultOptions.java @@ -0,0 +1,5 @@ +package org.rundeck.client.tool.options; + +public interface ExecutionResultOptions extends ExecutionOutputFormatOption, VerboseOption { + +} \ No newline at end of file diff --git a/src/main/java/org/rundeck/client/tool/options/QueryOptions.java b/src/main/java/org/rundeck/client/tool/options/QueryOptions.java new file mode 100644 index 00000000..86146005 --- /dev/null +++ b/src/main/java/org/rundeck/client/tool/options/QueryOptions.java @@ -0,0 +1,119 @@ +package org.rundeck.client.tool.options; + +import com.lexicalscope.jewel.cli.Option; + +import java.util.List; + +public interface QueryOptions + extends ProjectNameOptions{ + @Option(shortName = "d", + longName = "recent", + description = "Get executions newer than specified time. e.g. \"3m\" (3 months). \n" + + "Use: h,n,s,d,w,m,y (hour,minute,second,day,week,month,year)") + String getRecentFilter(); + + boolean isRecentFilter(); + + @Option(shortName = "O", longName = "older", + description = "Get executions older than specified time. e.g. \"3m\" (3 months). \n" + + "Use: h,n,s,d,w,m,y (hour,minute,second,day,week,month,year)") + String getOlderFilter(); + + boolean isOlderFilter(); + + @Option(shortName = "s", longName = "status", + description = "Status filter, one of: running,succeeded,failed,aborted") + String getStatusFilter(); + + boolean isStatusFilter(); + + @Option(shortName = "u", longName = "user", + description = "User filter") + String getUserFilter(); + + boolean isUserFilter(); + + @Option(shortName = "A", longName = "adhoconly", + description = "Adhoc executions only") + boolean isAdhoc(); + + @Option(shortName = "J", longName = "jobonly", + description = "Job executions only") + boolean isJob(); + + @Option(shortName = "i", longName = "jobids", + description = "Job ID list to include") + List getJobIdList(); + + boolean isJobIdList(); + + @Option(shortName = "j", longName = "jobs", + description = "List of Full job group and name to include.") + List getJobList(); + + boolean isJobList(); + + @Option(shortName = "x", longName = "xjobids", + description = "Job ID list to exclude") + List getExcludeJobIdList(); + + boolean isExcludeJobIdList(); + + @Option(shortName = "X", longName = "xjobs", + description = "List of Full job group and name to exclude.") + List getExcludeJobList(); + + boolean isExcludeJobList(); + + + @Option(shortName = "g", longName = "group", + description = "Group or partial group path to include, \"-\" means top-level jobs only") + String getGroupPath(); + + boolean isGroupPath(); + + @Option(longName = "xgroup", + description = "Group or partial group path to exclude, \"-\" means top-level jobs only") + String getExcludeGroupPath(); + + boolean isExcludeGroupPath(); + + @Option(shortName = "G", longName = "groupexact", + description = "Exact group path to include, \"-\" means top-level jobs only") + String getGroupPathExact(); + + boolean isGroupPathExact(); + + @Option(longName = "xgroupexact", + description = "Exact group path to exclude, \"-\" means top-level jobs only") + String getExcludeGroupPathExact(); + + boolean isExcludeGroupPathExact(); + + @Option(shortName = "n", longName = "name", + description = "Job Name Filter, include any name that matches this value") + String getJobFilter(); + + boolean isJobFilter(); + + @Option(longName = "xname", + description = "Exclude Job Name Filter, exclude any name that matches this value") + String getExcludeJobFilter(); + + boolean isExcludeJobFilter(); + + @Option(shortName = "N", longName = "nameexact", + description = "Exact Job Name Filter, include any name that is equal to this value") + String getJobExactFilter(); + + boolean isJobExactFilter(); + + @Option(longName = "xnameexact", + description = "Exclude Exact Job Name Filter, exclude any name that is equal to this value") + String getExcludeJobExactFilter(); + + boolean isExcludeJobExactFilter(); + + +} +