diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java index f241601463..5bb2e6c744 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java @@ -32,6 +32,9 @@ import com.fortify.aviator.application.ApplicationResponseMessage; import com.fortify.aviator.application.ApplicationServiceGrpc; import com.fortify.aviator.application.CreateApplicationRequest; +import com.fortify.aviator.application.GetApplicationByTokenRequest; +import com.fortify.aviator.application.GetDefaultQuotaRequest; +import com.fortify.aviator.application.GetDefaultQuotaResponse; import com.fortify.aviator.application.UpdateApplicationRequest; import com.fortify.aviator.dastentitlement.DastEntitlement; import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc; @@ -188,6 +191,24 @@ public Application getApplication(String projectId, String signature, String mes return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplication, request, Constants.OP_GET_APP); } + public Application getApplicationByToken(String token, String appName) { + GetApplicationByTokenRequest request = GetApplicationByTokenRequest.newBuilder() + .setToken(token) + .setAppName(appName) + .build(); + return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplicationByToken, request, Constants.OP_GET_APP_BY_TOKEN); + } + + public long getDefaultQuota(String token) { + GetDefaultQuotaRequest request = GetDefaultQuotaRequest.newBuilder() + .setToken(token) + .build(); + GetDefaultQuotaResponse response = GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::getDefaultQuota, + request, Constants.OP_GET_DEFAULT_QUOTA); + return response.getDefaultQuota(); + } + public List listApplication(String tenantName, String signature, String message) { ApplicationByTenantName request = ApplicationByTenantName.newBuilder().setName(tenantName).setSignature(signature).setMessage(message).build(); ApplicationList applicationList = GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::listApplications, request, Constants.OP_LIST_APPS); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java index ffcc2bd263..6f7540e2ed 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java @@ -73,6 +73,8 @@ public class Constants { public static final String OP_VALIDATE_USER_TOKEN = "validating user token"; public static final String OP_LIST_ENTITLEMENTS = "listing entitlements"; public static final String OP_LIST_DAST_ENTITLEMENTS = "listing DAST entitlements"; + public static final String OP_GET_APP_BY_TOKEN = "retrieving application by token"; + public static final String OP_GET_DEFAULT_QUOTA = "retrieving default quota"; public static final long DEFAULT_PING_INTERVAL_SECONDS = 30; public static final int DEFAULT_TIMEOUT_SECONDS = 30; diff --git a/fcli-core/fcli-aviator-common/src/main/proto/Application.proto b/fcli-core/fcli-aviator-common/src/main/proto/Application.proto index d4849f01ae..a2e332ec8e 100644 --- a/fcli-core/fcli-aviator-common/src/main/proto/Application.proto +++ b/fcli-core/fcli-aviator-common/src/main/proto/Application.proto @@ -57,6 +57,22 @@ message ApplicationResponseMessage { string responseMessage = 1; } +message GetApplicationByTokenRequest { + string token = 1; + string app_name = 2; +} + +message GetDefaultQuotaRequest { + optional string token = 1; + optional string tenant_name = 2; + optional string signature = 3; + optional string message = 4; +} + +message GetDefaultQuotaResponse { + int64 default_quota = 1; +} + service ApplicationService { rpc CreateApplication(CreateApplicationRequest) returns (Application) {} rpc GetApplication(ApplicationById) returns (Application) {} @@ -64,4 +80,6 @@ service ApplicationService { rpc DeleteApplication(ApplicationById) returns (ApplicationResponseMessage) {} rpc ListApplications(ApplicationByTenantName) returns (ApplicationList) {} rpc ListApplicationsByEntitlement(ApplicationById) returns (ApplicationList) {} + rpc GetApplicationByToken(GetApplicationByTokenRequest) returns (Application) {} + rpc GetDefaultQuota(GetDefaultQuotaRequest) returns (GetDefaultQuotaResponse) {} } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java index 08d2110e53..a8f668050b 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserSessionDescriptorSupplier; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; import com.fortify.cli.aviator.audit.AuditFPR; import com.fortify.cli.aviator.audit.model.AuditFprOptions; import com.fortify.cli.aviator.audit.model.FPRAuditResult; @@ -32,7 +33,6 @@ import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.common.output.transform.IRecordTransformer; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; @@ -58,8 +58,8 @@ @Command(name = "audit") @DefaultVariablePropertyName("artifactId") -public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand implements IRecordTransformer, IActionCommandResultSupplier { - @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; +public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; @@ -69,11 +69,15 @@ public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand imp @Option(names = {"--tag-mapping"}) private String tagMapping; @Option(names = {"--no-filterset"}) private boolean noFilterSet; @Option(names = {"--folder"}, split = ",") @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) private List folderNames; + @Option(names = {"--skip-if-exceeding-quota"}) private boolean skipIfExceedingQuota; + @Option(names = {"--test-exceeding-quota"}) private boolean testExceedingQuota; + @Option(names = {"--default-quota-fallback"}) private boolean defaultQuotaFallback; @Option(names = {"--folder-priority-order"}, split = ",", description = "Custom priority order by folder (comma-separated, highest first). Example: Critical,High,Medium,Low") @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) private List folderPriorityOrder; private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAuditCommand.class); + private Long checkedQuotaBefore; @Override @SneakyThrows @@ -84,27 +88,33 @@ public JsonNode getJsonNode(UnirestInstance unirest) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); SSCAppVersionDescriptor av = appVersionResolver.getAppVersionDescriptor(unirest); - if (refreshOptions.isRefresh() && av.isRefreshRequired()) { - logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", av.getApplicationName(), av.getVersionName()); - SSCJobDescriptor refreshJobDesc = SSCAppVersionHelper.refreshMetrics(unirest, av); - if (refreshJobDesc != null) { - SSCJobHelper.waitForJob(unirest, refreshJobDesc, refreshOptions.getRefreshTimeout()); - logger.progress("Status: Metrics refreshed successfully."); - } - } + refreshMetricsIfNeeded(unirest, av, logger); long auditableIssueCount = AviatorSSCAuditHelper.getAuditableIssueCount(unirest, av, logger, noFilterSet, filterSetOptions, folderNames); if (auditableIssueCount == 0) { logger.progress("Audit skipped - no auditable issues found matching the specified filters."); - return AviatorSSCAuditHelper.buildResultNode(av, "N/A", "SKIPPED (no auditable issues)"); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "No auditable issues found matching the specified filters"); + return result; + } + + JsonNode quotaResult = checkQuota(unirest, av, sessionDescriptor, auditableIssueCount, logger); + if (quotaResult != null) { + return quotaResult; } downloadedFprPath = downloadFpr(unirest, av, logger); if (downloadedFprPath == null) { - return AviatorSSCAuditHelper.buildResultNode(av, "N/A", "SKIPPED (no FPR available to audit)"); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "No FPR available to audit"); + return result; } - return processFpr(unirest, av, sessionDescriptor.getAviatorToken(), sessionDescriptor.getAviatorUrl(), logger, downloadedFprPath); + ObjectNode result = (ObjectNode) processFpr(unirest, av, sessionDescriptor.getAviatorToken(), sessionDescriptor.getAviatorUrl(), logger, downloadedFprPath); + if (checkedQuotaBefore != null) { + AviatorSSCAuditHelper.setAvailableQuotaBefore(result, checkedQuotaBefore); + } + return result; } finally { if (downloadedFprPath != null) { Files.deleteIfExists(downloadedFprPath); @@ -112,6 +122,117 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } } + private void refreshMetricsIfNeeded(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) { + if (refreshOptions.isRefresh() && av.isRefreshRequired()) { + logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", av.getApplicationName(), av.getVersionName()); + SSCJobDescriptor refreshJobDesc = SSCAppVersionHelper.refreshMetrics(unirest, av); + if (refreshJobDesc != null) { + SSCJobHelper.waitForJob(unirest, refreshJobDesc, refreshOptions.getRefreshTimeout()); + logger.progress("Status: Metrics refreshed successfully."); + } + } + } + + /** + * Checks quota constraints when --skip-if-exceeding-quota or --test-exceeding-quota is active. + * @return a result JsonNode if the audit should be skipped/reported, or null if the audit should proceed. + */ + private JsonNode checkQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorUserSessionDescriptor sessionDescriptor, + long auditableIssueCount, AviatorLoggerImpl logger) { + if (!skipIfExceedingQuota && !testExceedingQuota) { + return null; + } + + String effectiveAppName = appName != null ? appName : av.getApplicationName(); + long availableQuota = AviatorSSCAuditHelper.getAvailableQuota( + sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), + effectiveAppName, logger); + + // App not found — behavior depends on --default-quota-fallback + if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) { + availableQuota = handleAppNotFound(sessionDescriptor, effectiveAppName, logger); + if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "Application '" + effectiveAppName + "' not found in Aviator"); + return result; + } + } + + // If auditable issue count is unknown (-1), skip quota comparison and proceed with audit + if (auditableIssueCount < 0) { + LOG.info("Auditable issue count unknown; skipping quota evaluation for {}:{}.", + av.getApplicationName(), av.getVersionName()); + return null; + } + + return evaluateQuota(unirest, av, effectiveAppName, auditableIssueCount, availableQuota, logger); + } + + /** + * Handles the case where the application is not found in Aviator. + * @return the resolved quota (possibly from default), or QUOTA_APP_NOT_FOUND if audit should be skipped. + */ + private long handleAppNotFound(AviatorUserSessionDescriptor sessionDescriptor, + String effectiveAppName, AviatorLoggerImpl logger) { + if (defaultQuotaFallback) { + logger.progress("Application '%s' not found, using default quota for new applications.", effectiveAppName); + long defaultQuota = AviatorSSCAuditHelper.getDefaultQuota( + sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), logger); + if (defaultQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { + if (testExceedingQuota) { + // Caller will need to handle this — we return QUOTA_UNKNOWN to signal + return AviatorSSCAuditHelper.QUOTA_UNKNOWN; + } + logger.progress("Warning: Could not retrieve default quota, proceeding with audit."); + return AviatorSSCAuditHelper.QUOTA_UNKNOWN; + } + return defaultQuota; + } else { + logger.progress("Application '%s' does not exist in Aviator.", effectiveAppName); + return AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND; + } + } + + /** + * Evaluates the resolved quota against the auditable issue count and returns + * a result node if audit should be skipped, or null to proceed with the audit. + */ + private JsonNode evaluateQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, + String effectiveAppName, long auditableIssueCount, long availableQuota, + AviatorLoggerImpl logger) { + if (availableQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { + if (testExceedingQuota) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_UNKNOWN"); + AviatorSSCAuditHelper.setOperationMessage(result, "Could not retrieve quota for application '" + effectiveAppName + "'"); + return result; + } + logger.progress("Warning: Could not retrieve quota for '%s', proceeding with audit.", effectiveAppName); + } else if (availableQuota >= 0 && auditableIssueCount > availableQuota) { + checkedQuotaBefore = availableQuota; + var topCategories = AviatorSSCAuditHelper.getTopUnauditedCategories(unirest, av, logger, 10); + String detailedMessage = AviatorSSCAuditHelper.formatQuotaExceededMessage( + av, auditableIssueCount, availableQuota, topCategories); + LOG.info(detailedMessage); + logger.progress("Quota exceeded for %s:%s -- Open issues: %d, Available quota: %d. Audit skipped.", + av.getApplicationName(), av.getVersionName(), auditableIssueCount, availableQuota); + return AviatorSSCAuditHelper.buildQuotaExceededResultNode( + av, auditableIssueCount, availableQuota, topCategories); + } else if (testExceedingQuota) { + logger.progress("Quota check passed for %s:%s -- Open issues: %d, Available quota: %s", + av.getApplicationName(), av.getVersionName(), auditableIssueCount, + availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota)); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_OK"); + AviatorSSCAuditHelper.setOperationMessage(result, String.format("Quota check passed: %d issues, %s quota available", + auditableIssueCount, availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota))); + AviatorSSCAuditHelper.setAvailableQuotaBefore(result, availableQuota); + return result; + } + // Quota was checked and audit is proceeding — capture the value for the final output + checkedQuotaBefore = availableQuota >= 0 ? availableQuota : null; + return null; + } + @SneakyThrows private JsonNode processFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, String token, String url, AviatorLoggerImpl logger, Path downloadedFprPath) { FPRAuditResult auditResult; @@ -129,17 +250,29 @@ private JsonNode processFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, .folderNames(folderNames) .folderPriorityOrder(folderPriorityOrder) .build()); + } catch (Exception e) { + LOG.error("FPR audit failed for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "FAILED"); + AviatorSSCAuditHelper.setOperationMessage(result, "Audit failed: " + e.getMessage()); + return result; } - String action = AviatorSSCAuditHelper.getDetailedAction(auditResult); + String action = auditResult.getStatus(); logger.progress(AviatorSSCAuditHelper.getProgressMessage(auditResult)); - String artifactId = "UPLOAD_SKIPPED"; - if (auditResult.getUpdatedFile() != null && !"SKIPPED".equals(auditResult.getStatus()) && !"FAILED".equals(auditResult.getStatus())) { - artifactId = uploadAuditedFprToSSC(unirest, auditResult.getUpdatedFile(), av); + String artifactId = null; + if (auditResult.getUpdatedFile() != null && !"SKIPPED".equals(action) && !"FAILED".equals(action)) { + try { + artifactId = uploadAuditedFprToSSC(unirest, auditResult.getUpdatedFile(), av); + } catch (Exception e) { + LOG.error("Failed to upload audited FPR for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); + logger.progress("WARN: Upload of audited FPR to SSC failed: %s", e.getMessage()); + } } - return AviatorSSCAuditHelper.buildResultNode(av, artifactId, action); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, artifactId, action); + AviatorSSCAuditHelper.setAuditStats(result, auditResult); + return result; } private Path downloadFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) throws IOException { @@ -175,21 +308,6 @@ private String uploadAuditedFprToSSC(UnirestInstance unirest, File auditedFpr, S } } - @Override - public JsonNode transformRecord(JsonNode record) { - ObjectNode transformed = record.deepCopy(); - if (transformed.has("versionId")) { - transformed.set("Id", transformed.remove("versionId")); - } - if (transformed.has("applicationName")) { - transformed.set("Application name", transformed.remove("applicationName")); - } - if (transformed.has("versionName")) { - transformed.set("Name", transformed.remove("versionName")); - } - return SSCAppVersionHelper.renameFields(transformed); - } - @Override public String getActionCommandResult() { return "AUDITED"; @@ -199,13 +317,4 @@ public String getActionCommandResult() { public boolean isSingular() { return true; } - - private List resolvePriorityOrder() { - if (folderPriorityOrder != null && !folderPriorityOrder.isEmpty()) { - return folderPriorityOrder; - } - - - return null; - } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java index 0d91c7831a..93fe7278e2 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java @@ -12,7 +12,9 @@ */ package com.fortify.cli.aviator.ssc.helper; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -23,8 +25,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.aviator.application.Application; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator.audit.model.FPRAuditResult; import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.grpc.AviatorGrpcClient; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -32,8 +39,11 @@ import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; import com.fortify.cli.ssc.issue.cli.mixin.SSCIssueFilterSetOptionMixin; +import com.fortify.cli.ssc.issue.helper.SSCIssueFilterHelper; import com.fortify.cli.ssc.issue.helper.SSCIssueFilterSetDescriptor; import com.fortify.cli.ssc.issue.helper.SSCIssueFilterSetHelper; +import com.fortify.cli.ssc.issue.helper.SSCIssueGroupDescriptor; +import com.fortify.cli.ssc.issue.helper.SSCIssueGroupHelper; import kong.unirest.GetRequest; import kong.unirest.UnirestInstance; @@ -49,44 +59,113 @@ private AviatorSSCAuditHelper() {} /** * Builds the final JSON result node for the command output. + * All fields are always present to ensure a stable, predictable output structure. + * Fields not applicable to the current action are set to null. + * + *

Structure: + *

+     * id, applicationName, versionName, artifactId, __action__
+     * operation:
+     *   audit: { message, submitted, succeeded, skipped }
+     * state:
+     *   aviator: { availableQuotaBefore, availableQuotaAfter }
+     *   ssc: { issuesByCategory }
+     * 
+ * * @param av The SSCAppVersionDescriptor. - * @param artifactId The ID of the uploaded artifact, or a status string. + * @param artifactId The ID of the uploaded artifact, or null if not applicable. * @param action The final action string for the output. * @return An ObjectNode representing the result. */ public static ObjectNode buildResultNode(SSCAppVersionDescriptor av, String artifactId, String action) { - ObjectNode result = av.asObjectNode(); + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("id", av.getVersionId()); result.put("applicationName", av.getApplicationName()); - result.put("name", av.getVersionName()); - result.put("artifactId", artifactId); + result.put("versionName", av.getVersionName()); + if (artifactId != null) { + result.put("artifactId", artifactId); + } else { + result.putNull("artifactId"); + } result.put(IActionCommandResultSupplier.actionFieldName, action); + + // operation.audit — null by default, populated by setAuditStats or setOperationMessage + ObjectNode operation = JsonHelper.getObjectMapper().createObjectNode(); + operation.putNull("audit"); + result.set("operation", operation); + + // state.aviator and state.ssc — null sub-objects by default + ObjectNode state = JsonHelper.getObjectMapper().createObjectNode(); + ObjectNode aviatorState = JsonHelper.getObjectMapper().createObjectNode(); + aviatorState.putNull("availableQuotaBefore"); + aviatorState.putNull("availableQuotaAfter"); + state.set("aviator", aviatorState); + ObjectNode sscState = JsonHelper.getObjectMapper().createObjectNode(); + sscState.putNull("issuesByCategory"); + state.set("ssc", sscState); + result.set("state", state); + return result; } /** - * Generates a detailed action string based on the FPRAuditResult. - * This is used for the 'action' column in the output. + * Populates the {@code operation.audit} object on the result node with stats from the FPR audit. + * Sets message, submitted, succeeded, and skipped fields. + * + * @param result The result node to update. * @param auditResult The result from the Aviator audit. - * @return A descriptive string of the outcome. */ - public static String getDetailedAction(FPRAuditResult auditResult) { + public static void setAuditStats(ObjectNode result, FPRAuditResult auditResult) { + ObjectNode audit = JsonHelper.getObjectMapper().createObjectNode(); + String message; switch (auditResult.getStatus()) { + case "AUDITED": + message = "Audit completed successfully"; + break; + case "PARTIALLY_AUDITED": + message = auditResult.getMessage() != null ? auditResult.getMessage() : "Audit partially completed"; + break; case "SKIPPED": - String reason = auditResult.getMessage() != null ? auditResult.getMessage() : "Unknown reason"; - return "SKIPPED (" + reason + ")"; + message = auditResult.getMessage() != null ? auditResult.getMessage() : "No issues to audit"; + break; case "FAILED": - String message = auditResult.getMessage() != null ? auditResult.getMessage() : "Unknown error"; - return "FAILED (" + message + ")"; - case "PARTIALLY_AUDITED": - return String.format("PARTIALLY_AUDITED (%d/%d audited)", - auditResult.getIssuesSuccessfullyAudited(), - auditResult.getTotalIssuesToAudit()); - case "AUDITED": - return "AUDITED"; + message = auditResult.getMessage() != null ? auditResult.getMessage() : "Audit failed"; + break; default: - return "UNKNOWN"; + message = auditResult.getMessage() != null ? auditResult.getMessage() : "Unknown audit status"; + break; } + audit.put("message", message); + audit.put("submitted", auditResult.getTotalIssuesToAudit()); + audit.put("succeeded", auditResult.getIssuesSuccessfullyAudited()); + audit.put("skipped", Math.max(0, auditResult.getTotalIssuesToAudit() - auditResult.getIssuesSuccessfullyAudited())); + ((ObjectNode) result.get("operation")).set("audit", audit); + } + + /** + * Sets only the {@code operation.audit.message} field without audit stats. + * Used for code paths that don't perform an actual audit (SKIPPED, FAILED, QUOTA_EXCEEDED, etc.). + * + * @param result The result node to update. + * @param message The descriptive message. + */ + public static void setOperationMessage(ObjectNode result, String message) { + ObjectNode audit = JsonHelper.getObjectMapper().createObjectNode(); + audit.put("message", message); + audit.putNull("submitted"); + audit.putNull("succeeded"); + audit.putNull("skipped"); + ((ObjectNode) result.get("operation")).set("audit", audit); + } + + /** + * Sets the {@code state.aviator.availableQuotaBefore} field. + * + * @param result The result node to update. + * @param quota The quota value. + */ + public static void setAvailableQuotaBefore(ObjectNode result, long quota) { + ((ObjectNode) result.path("state").path("aviator")).put("availableQuotaBefore", quota); } /** @@ -228,4 +307,200 @@ private static String getFolderFilter(boolean noFilterSet, SSCIssueFilterSetDesc }) .collect(Collectors.joining(" OR ")); } + + /** + * Sentinel value indicating quota retrieval failed. + */ + public static final long QUOTA_UNKNOWN = Long.MIN_VALUE; + + /** + * Sentinel value indicating the application was not found on the server. + */ + public static final long QUOTA_APP_NOT_FOUND = Long.MIN_VALUE + 1; + + /** + * Retrieves the available quota for the given Aviator application using the developer token. + * Returns the quota value from the server, {@link #QUOTA_APP_NOT_FOUND} if the app doesn't exist, + * or {@link #QUOTA_UNKNOWN} if retrieval fails for other reasons. + * A server-returned value of -1 means unlimited quota. + */ + public static long getAvailableQuota(String aviatorUrl, String aviatorToken, String appName, + AviatorLoggerImpl logger) { + try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger, + Constants.DEFAULT_PING_INTERVAL_SECONDS)) { + Application app = client.getApplicationByToken(aviatorToken, appName); + long quota = app.getQuota(); + logger.progress("Status: Available Aviator quota for app '%s': %s", appName, + quota < 0 ? "unlimited" : String.valueOf(quota)); + return quota; + } catch (AviatorSimpleException e) { + if (e.getMessage() != null && e.getMessage().contains("not found")) { + LOG.info("Application '{}' not found on Aviator server.", appName); + return QUOTA_APP_NOT_FOUND; + } + LOG.warn("Failed to retrieve quota for application '{}': {}", appName, e.getMessage()); + logger.progress("WARN: Could not retrieve Aviator quota for app '%s'. Proceeding without quota check.", appName); + return QUOTA_UNKNOWN; + } catch (Exception e) { + LOG.warn("Failed to retrieve quota for application '{}': {}", appName, e.getMessage()); + logger.progress("WARN: Could not retrieve Aviator quota for app '%s'. Proceeding without quota check.", appName); + return QUOTA_UNKNOWN; + } + } + + /** + * Retrieves the default initial quota for new applications from the tenant. + * Returns the default quota, or {@link #QUOTA_UNKNOWN} if retrieval fails. + */ + public static long getDefaultQuota(String aviatorUrl, String aviatorToken, + AviatorLoggerImpl logger) { + try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger, + Constants.DEFAULT_PING_INTERVAL_SECONDS)) { + long quota = client.getDefaultQuota(aviatorToken); + logger.progress("Status: Default Aviator quota for new applications: %s", + quota < 0 ? "unlimited" : String.valueOf(quota)); + return quota; + } catch (Exception e) { + LOG.warn("Failed to retrieve default quota: {}", e.getMessage()); + logger.progress("WARN: Could not retrieve default Aviator quota."); + return QUOTA_UNKNOWN; + } + } + + /** + * Returns the top N SAST categories ordered by truly-unaudited issue count (descending). + * Uses the SSC issueGroups API with: + * - groupingtype = dynamically resolved "Category" GUID + * - filter = dynamically resolved "Aviator status:Not Set" technical filter + * Each entry contains "categoryName" and "unauditedCount". + */ + public static List> getTopUnauditedCategories( + UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorLoggerImpl logger, int topN) { + + try { + return getTopUnauditedCategoriesInternal(unirest, av, logger, topN); + } catch (Exception e) { + LOG.warn("Failed to retrieve top unaudited categories for {}:{}: {}", + av.getApplicationName(), av.getVersionName(), e.getMessage()); + logger.progress("WARN: Could not retrieve category breakdown from SSC."); + return List.of(); + } + } + + private static List> getTopUnauditedCategoriesInternal( + UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorLoggerImpl logger, int topN) { + + String versionId = av.getVersionId(); + + // Resolve "Category" grouping GUID dynamically + SSCIssueGroupHelper groupHelper = new SSCIssueGroupHelper(unirest, versionId); + SSCIssueGroupDescriptor categoryDescriptor = groupHelper.getDescriptorByDisplayNameOrId("Category", true); + String categoryGroupGuid = categoryDescriptor.getGuid(); + + // Resolve "Aviator status:Not Set" filter dynamically + String aviatorStatusFilter = null; + try { + SSCIssueFilterHelper filterHelper = new SSCIssueFilterHelper(unirest, versionId); + aviatorStatusFilter = filterHelper.getFilter("Aviator status:Not Set"); + } catch (FcliSimpleException e) { + // Tag doesn't exist on this version — all issues are unprocessed + LOG.debug("Aviator status tag not found for version {}. All issues considered unprocessed.", versionId); + } + + // Call issueGroups API + GetRequest request = unirest.get(SSCUrls.PROJECT_VERSION_ISSUE_GROUPS(versionId)) + .queryString("limit", "-1") + .queryString("qm", "issues") + .queryString("groupingtype", categoryGroupGuid); + + if (aviatorStatusFilter != null) { + request.queryString("filter", aviatorStatusFilter); + } + + JsonNode response = request.asObject(JsonNode.class).getBody(); + ArrayNode groups = (ArrayNode) response.get("data"); + + // Calculate truly-unaudited count per category and sort descending + List> categories = new ArrayList<>(); + if (groups != null) { + for (JsonNode group : groups) { + String name = group.path("id").asText("Unknown"); + long visibleCount = group.path("visibleCount").asLong(0); + long auditedCount = group.path("auditedCount").asLong(0); + long unaudited = visibleCount - auditedCount; + if (unaudited > 0) { + Map entry = new LinkedHashMap<>(); + entry.put("categoryName", name); + entry.put("totalIssues", visibleCount); + entry.put("auditedIssues", auditedCount); + entry.put("unauditedCount", unaudited); + categories.add(entry); + } + } + } + + // Sort descending by unauditedCount + categories.sort((a, b) -> Long.compare( + (long) b.get("unauditedCount"), + (long) a.get("unauditedCount") + )); + + return categories.subList(0, Math.min(topN, categories.size())); + } + + /** + * Builds the JSON result node when a version is skipped due to exceeding quota. + * Sets {@code state.aviator.availableQuotaBefore} and populates {@code state.ssc.issuesByCategory} + * with total, audited, and unaudited counts per category. + */ + public static ObjectNode buildQuotaExceededResultNode( + SSCAppVersionDescriptor av, long openIssueCount, long availableQuota, + List> topCategories) { + ObjectNode result = buildResultNode(av, null, "QUOTA_EXCEEDED"); + setOperationMessage(result, String.format("Open issues (%d) exceed available quota (%d)", openIssueCount, availableQuota)); + setAvailableQuotaBefore(result, availableQuota); + ((ObjectNode) result.path("state").path("ssc")).set("issuesByCategory", buildIssuesByCategoryArray(topCategories)); + return result; + } + + /** + * Converts the category list into a JSON array for the {@code issuesByCategory} field. + */ + private static ArrayNode buildIssuesByCategoryArray(List> categories) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (categories != null) { + for (Map cat : categories) { + ObjectNode catNode = JsonHelper.getObjectMapper().createObjectNode(); + catNode.put("category", (String) cat.get("categoryName")); + catNode.put("total", (long) cat.get("totalIssues")); + catNode.put("audited", (long) cat.get("auditedIssues")); + catNode.put("unaudited", (long) cat.get("unauditedCount")); + array.add(catNode); + } + } + return array; + } + + /** + * Formats a human-readable message for quota exceeded scenarios. + */ + public static String formatQuotaExceededMessage( + SSCAppVersionDescriptor av, long openIssueCount, long availableQuota, + List> topCategories) { + StringBuilder sb = new StringBuilder(); + sb.append(String.format("Quota exceeded for %s:%s -- Open issues: %d, Available quota: %d%n", + av.getApplicationName(), av.getVersionName(), openIssueCount, availableQuota)); + sb.append("Top SAST categories by unaudited issue count:\n"); + if (topCategories != null) { + int rank = 1; + for (Map cat : topCategories) { + sb.append(String.format(" %d. %s (total: %d, audited: %d, unaudited: %d)%n", + rank++, cat.get("categoryName"), cat.get("totalIssues"), + cat.get("auditedIssues"), cat.get("unauditedCount"))); + } + } + return sb.toString(); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties index d300d5f419..71959d7774 100644 --- a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties +++ b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties @@ -121,6 +121,9 @@ fcli.aviator.ssc.audit.tag-mapping = Custom tag mapping for audit results. fcli.aviator.ssc.audit.filterset = Name or ID of the FilterSet to apply. fcli.aviator.ssc.audit.no-filterset = Do not apply any filter sets, including the default enabled filter set from the FPR. fcli.aviator.ssc.audit.folder = Filter issues by a comma-separated list of specific folder names from the selected FilterSet (e.g., 'Hot,Critical'). This option requires a FilterSet to be active. +fcli.aviator.ssc.audit.skip-if-exceeding-quota = Skip audit if the number of open issues exceeds the available Aviator quota. When skipped, a summary with top unaudited categories is shown. +fcli.aviator.ssc.audit.test-exceeding-quota = Check whether the number of open issues exceeds the available Aviator quota and report the result without performing an audit. +fcli.aviator.ssc.audit.default-quota-fallback = (Internal) When the Aviator application does not exist, use the tenant default quota instead of reporting app not found. Used by bulk audit. fcli.aviator.ssc.audit.folder-priority-order = Custom priority order for folder-based filtering when quota is exceeded (comma-separated, highest priority first). Example: Critical,High,Medium,Low. If not specified, uses default priority order. fcli.aviator.ssc.audit.refresh = By default, this command will refresh the source application version's metrics when copying from it. \ Note that for large applications this can lead to an error if the timeout expires. diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml index 4b9cf0eb18..e3513f5458 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml @@ -85,6 +85,18 @@ cli.options: description: "Timeout period for metric refresh, e.g., 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default: 60s" required: false default: "60s" + skip-if-exceeding-quota: + names: --skip-if-exceeding-quota + description: "Skip audit if the number of open issues exceeds the available Aviator quota. When skipped, a summary with top unaudited categories is shown. Default: false" + required: false + default: false + type: boolean + test-exceeding-quota: + names: --test-exceeding-quota + description: "Check whether the number of open issues exceeds the available Aviator quota and report the result without performing an audit. Default: false" + required: false + default: false + type: boolean folder-priority-order: names: --folder-priority-order description: "Custom priority order for folder-based filtering when quota is exceeded (comma-separated, highest priority first). Example: Critical,High,Medium,Low. Applied to all audited versions. Default: uses server default (Critical > High > Medium > Low)" @@ -102,6 +114,18 @@ steps: - log.progress: "ERROR: Either --tag-mapping or --add-aviator-tags must be specified." - throw: "Either --tag-mapping or --add-aviator-tags must be specified." + # Validate that --dry-run and --test-exceeding-quota are not used together + - if: ${cli['dry-run'] && cli['test-exceeding-quota']} + do: + - log.progress: "ERROR: --dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." + - throw: "--dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." + + # Validate that --dry-run and --skip-if-exceeding-quota are not used together + - if: ${cli['dry-run'] && cli['skip-if-exceeding-quota']} + do: + - log.progress: "ERROR: --dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." + - throw: "--dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." + - log.progress: "Using Aviator app mapping: ${cli['aviator-app-mapping']}" # Get existing Aviator applications @@ -244,6 +268,7 @@ steps: stats.create_failures: 0 stats.audit_attempts: 0 stats.audit_failures: 0 + stats.quota_skipped: 0 stats.entitlement_exhausted: false stats.create_skipped_due_to_entitlement: 0 stats.would_create_count: 0 @@ -279,82 +304,176 @@ steps: do: - var.set: app_ready: ${app_known_in_aviator} + skip_this_version: false - # Create app if needed - - if: ${!app_known_in_aviator && !stats.entitlement_exhausted} + # --- TEST-EXCEEDING-QUOTA MODE --- + # In test mode we only check quota and report; no app creation, no audit. + # For non-existing apps, --default-quota-fallback tells the audit command + # to use the tenant's default quota instead of reporting "app not found". + - if: ${cli['test-exceeding-quota']} do: - var.set: - stats.create_attempts: ${stats.create_attempts + 1} - - run.fcli: - create_app: - cmd: aviator app create "${project.aviator_app_name}" - status.check: false - - if: ${create_app.exitCode == 0} + stats.audit_attempts: ${stats.audit_attempts + 1} + quota_flags: " --test-exceeding-quota" + - if: ${!app_known_in_aviator} var.set: - app_ready: true - stats.create_successes: ${stats.create_successes + 1} - known_aviator_app_names..: ${project.aviator_app_name} - - if: ${create_app.exitCode != 0} - do: - - var.set: - create_app_error_text: "${(create_app.stderr == null ? '' : create_app.stderr) + ' ' + (create_app.stdout == null ? '' : create_app.stdout)}" - create_app_already_exists: ${create_app_error_text.toLowerCase().contains('already exists')} - create_app_entitlement_or_quota_error: ${create_app_error_text.toLowerCase().contains('entitlement') || create_app_error_text.toLowerCase().contains('quota')} - - if: ${create_app_already_exists} - var.set: - app_ready: true - known_aviator_app_names..: ${project.aviator_app_name} - - if: ${!create_app_already_exists} - do: - - var.set: - stats.create_failures: ${stats.create_failures + 1} - - if: ${create_app_entitlement_or_quota_error && !stats.entitlement_exhausted} - do: - - log.warn: App creation failed due to entitlement/quota - suppressing further create attempts - - var.set: - stats.entitlement_exhausted: true - - if: ${!create_app_entitlement_or_quota_error} - log.warn: App creation failed for ${project.aviator_app_name}; continuing with remaining candidates + quota_flags: " --test-exceeding-quota --default-quota-fallback" - - if: ${!app_known_in_aviator && stats.entitlement_exhausted} - var.set: - stats.create_skipped_due_to_entitlement: ${stats.create_skipped_due_to_entitlement + 1} + - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} + run.fcli: + run_audit: + cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --tag-mapping=\"${cli['tag-mapping']}\" --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" + status.check: false + records.collect: true + stdout: show - # Prepare Aviator tags if requested - - if: ${cli['add-aviator-tags']} - do: - - log.progress: Preparing Aviator tags for ${project.project_name}:${project.version_name} - - run.fcli: - prepare_tags: - cmd: aviator ssc prepare --av "${project.id}" + - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} + run.fcli: + run_audit: + cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" status.check: false - - if: ${prepare_tags.exitCode != 0} + records.collect: true + stdout: show + + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} + var.set: + stats.quota_skipped: ${stats.quota_skipped + 1} + + - if: ${run_audit.exitCode != 0} do: - - log.warn: Aviator tag preparation failed for ${project.project_name}:${project.version_name} + - var.set: + stats.audit_failures: ${stats.audit_failures + 1} + - log.warn: Quota test failed for ${project.aviator_app_name}:${project.version_name} - # Run audit if app is ready - - if: ${app_ready} - do: - var.set: - stats.audit_attempts: ${stats.audit_attempts + 1} + skip_this_version: true + + # --- SKIP-IF-EXCEEDING-QUOTA + NON-EXISTING APP --- + # Pre-check default quota before creating the app. If the default quota + # would be exceeded, skip the version entirely (don't create, don't audit). + - if: ${!skip_this_version && cli['skip-if-exceeding-quota'] && !app_known_in_aviator} + do: + - log.progress: Pre-checking default quota for new app ${project.aviator_app_name}... - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} run.fcli: - run_audit: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --tag-mapping=\"${cli['tag-mapping']}\" --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" + quota_precheck: + cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --tag-mapping=\"${cli['tag-mapping']}\" --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\" --test-exceeding-quota --default-quota-fallback${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" status.check: false + records.collect: true + stdout: show - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} run.fcli: - run_audit: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" + quota_precheck: + cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\" --test-exceeding-quota --default-quota-fallback${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" status.check: false + records.collect: true + stdout: show - - if: ${run_audit.exitCode != 0} + # If pre-check shows quota exceeded, skip this version entirely + - if: ${quota_precheck.exitCode == 0 && quota_precheck.records != null && quota_precheck.records.size() > 0 && quota_precheck.records[0].__action__ != null && quota_precheck.records[0].__action__ == 'QUOTA_EXCEEDED'} do: + - log.progress: Default quota exceeded for ${project.aviator_app_name} - skipping app creation and audit - var.set: - stats.audit_failures: ${stats.audit_failures + 1} - - log.warn: Audit failed for ${project.aviator_app_name}:${project.version_name} + skip_this_version: true + stats.quota_skipped: ${stats.quota_skipped + 1} + + # --- NORMAL FLOW: create app, prepare tags, run audit --- + - if: ${!skip_this_version} + do: + # Create app if needed + - if: ${!app_known_in_aviator && !stats.entitlement_exhausted} + do: + - var.set: + stats.create_attempts: ${stats.create_attempts + 1} + - run.fcli: + create_app: + cmd: aviator app create "${project.aviator_app_name}" + status.check: false + - if: ${create_app.exitCode == 0} + var.set: + app_ready: true + stats.create_successes: ${stats.create_successes + 1} + known_aviator_app_names..: ${project.aviator_app_name} + - if: ${create_app.exitCode != 0} + do: + - var.set: + create_app_error_text: "${(create_app.stderr == null ? '' : create_app.stderr) + ' ' + (create_app.stdout == null ? '' : create_app.stdout)}" + create_app_already_exists: ${create_app_error_text.toLowerCase().contains('already exists')} + create_app_entitlement_or_quota_error: ${create_app_error_text.toLowerCase().contains('entitlement') || create_app_error_text.toLowerCase().contains('quota')} + - if: ${create_app_already_exists} + var.set: + app_ready: true + known_aviator_app_names..: ${project.aviator_app_name} + - if: ${!create_app_already_exists} + do: + - var.set: + stats.create_failures: ${stats.create_failures + 1} + - if: ${create_app_entitlement_or_quota_error && !stats.entitlement_exhausted} + do: + - log.warn: App creation failed due to entitlement/quota - suppressing further create attempts + - var.set: + stats.entitlement_exhausted: true + - if: ${!create_app_entitlement_or_quota_error} + log.warn: App creation failed for ${project.aviator_app_name}; continuing with remaining candidates + + - if: ${!app_known_in_aviator && stats.entitlement_exhausted} + var.set: + stats.create_skipped_due_to_entitlement: ${stats.create_skipped_due_to_entitlement + 1} + + # Prepare Aviator tags if requested + - if: ${cli['add-aviator-tags']} + do: + - log.progress: Preparing Aviator tags for ${project.project_name}:${project.version_name} + - run.fcli: + prepare_tags: + cmd: aviator ssc prepare --av "${project.id}" + status.check: false + - if: ${prepare_tags.exitCode != 0} + do: + - log.warn: Aviator tag preparation failed for ${project.project_name}:${project.version_name} + + # Run audit if app is ready + - if: ${app_ready} + do: + - var.set: + stats.audit_attempts: ${stats.audit_attempts + 1} + + # Build quota flags string (only --skip-if-exceeding-quota here; + # --test-exceeding-quota is handled in its own block above) + - var.set: + quota_flags: "" + - if: ${cli['skip-if-exceeding-quota']} + var.set: + quota_flags: " --skip-if-exceeding-quota" + + - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} + run.fcli: + run_audit: + cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --tag-mapping=\"${cli['tag-mapping']}\" --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" + status.check: false + records.collect: true + stdout: show + + - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} + run.fcli: + run_audit: + cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" + status.check: false + records.collect: true + stdout: show + + # Track quota-skipped results + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} + var.set: + stats.quota_skipped: ${stats.quota_skipped + 1} + + - if: ${run_audit.exitCode != 0} + do: + - var.set: + stats.audit_failures: ${stats.audit_failures + 1} + - log.warn: Audit failed for ${project.aviator_app_name}:${project.version_name} # Summary - if: ${cli['dry-run']} @@ -363,7 +482,10 @@ steps: - if: ${!cli['dry-run']} do: - - log.info: "Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}" + - if: ${stats.quota_skipped > 0} + log.info: "Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}, Quota-skipped ${stats.quota_skipped}" + - if: ${stats.quota_skipped == 0} + log.info: "Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}" - if: ${stats.entitlement_exhausted} log.info: Note - Entitlement exhausted, some app creations were skipped