From dd59dc5cc6eb70d75c38ba4105dc6d2f0e80167d Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 22 Aug 2023 00:39:03 -0400 Subject: [PATCH 1/2] Split out ShellSentry program from CLI This makes it easier to use and wrap in other programs or CLIs --- api/kotlin-cli-util.api | 85 ++++++++++ .../kotlin/slack/cli/shellsentry/Issue.kt | 5 +- .../slack/cli/shellsentry/RetrySignal.kt | 22 +-- .../slack/cli/shellsentry/ShellSentry.kt | 154 ++++++++++++++++++ .../slack/cli/shellsentry/ShellSentryCli.kt | 131 +++------------ .../cli/shellsentry/ShellSentryConfig.kt | 2 +- 6 files changed, 268 insertions(+), 131 deletions(-) create mode 100644 src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt diff --git a/api/kotlin-cli-util.api b/api/kotlin-cli-util.api index 71f86a7..5756105 100644 --- a/api/kotlin-cli-util.api +++ b/api/kotlin-cli-util.api @@ -37,6 +37,33 @@ public final class slack/cli/Toml { public final fun parseVersion (Ljava/io/File;)Ljava/util/Map; } +public final class slack/cli/shellsentry/Issue { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;Ljava/util/List;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lslack/cli/shellsentry/RetrySignal; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/util/List; + public final fun component7 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lslack/cli/shellsentry/Issue; + public static synthetic fun copy$default (Lslack/cli/shellsentry/Issue;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lslack/cli/shellsentry/Issue; + public fun equals (Ljava/lang/Object;)Z + public final fun getDescription ()Ljava/lang/String; + public final fun getGroupingHash ()Ljava/lang/String; + public final fun getLogMessage ()Ljava/lang/String; + public final fun getMatchingPatterns ()Ljava/util/List; + public final fun getMatchingText ()Ljava/util/List; + public final fun getMessage ()Ljava/lang/String; + public final fun getRetrySignal ()Lslack/cli/shellsentry/RetrySignal; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class slack/cli/shellsentry/IssueJsonAdapter : com/squareup/moshi/JsonAdapter { public fun (Lcom/squareup/moshi/Moshi;)V public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object; @@ -44,6 +71,41 @@ public final class slack/cli/shellsentry/IssueJsonAdapter : com/squareup/moshi/J public fun toString ()Ljava/lang/String; } +public abstract interface class slack/cli/shellsentry/RetrySignal { +} + +public final class slack/cli/shellsentry/RetrySignal$Ack : slack/cli/shellsentry/RetrySignal { + public static final field INSTANCE Lslack/cli/shellsentry/RetrySignal$Ack; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class slack/cli/shellsentry/RetrySignal$RetryDelayed : slack/cli/shellsentry/RetrySignal { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-UwyO8pc ()J + public final fun copy-LRDsOJo (J)Lslack/cli/shellsentry/RetrySignal$RetryDelayed; + public static synthetic fun copy-LRDsOJo$default (Lslack/cli/shellsentry/RetrySignal$RetryDelayed;JILjava/lang/Object;)Lslack/cli/shellsentry/RetrySignal$RetryDelayed; + public fun equals (Ljava/lang/Object;)Z + public final fun getDelay-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class slack/cli/shellsentry/RetrySignal$RetryImmediately : slack/cli/shellsentry/RetrySignal { + public static final field INSTANCE Lslack/cli/shellsentry/RetrySignal$RetryImmediately; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class slack/cli/shellsentry/RetrySignal$Unknown : slack/cli/shellsentry/RetrySignal { + public static final field INSTANCE Lslack/cli/shellsentry/RetrySignal$Unknown; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class slack/cli/shellsentry/RetrySignalJsonAdapter : com/squareup/moshi/JsonAdapter { public fun (Lcom/squareup/moshi/Moshi;)V public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object; @@ -58,11 +120,34 @@ public final class slack/cli/shellsentry/RetrySignal_RetryDelayedJsonAdapter : c public fun toString ()Ljava/lang/String; } +public final class slack/cli/shellsentry/ShellSentry { + public fun (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun exec ()V +} + public final class slack/cli/shellsentry/ShellSentryCli : com/github/ajalt/clikt/core/CliktCommand { public fun ()V public fun run ()V } +public final class slack/cli/shellsentry/ShellSentryConfig { + public fun ()V + public fun (ILjava/lang/String;Ljava/util/List;)V + public synthetic fun (ILjava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun copy (ILjava/lang/String;Ljava/util/List;)Lslack/cli/shellsentry/ShellSentryConfig; + public static synthetic fun copy$default (Lslack/cli/shellsentry/ShellSentryConfig;ILjava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lslack/cli/shellsentry/ShellSentryConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getGradleEnterpriseServer ()Ljava/lang/String; + public final fun getKnownIssues ()Ljava/util/List; + public final fun getVersion ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class slack/cli/shellsentry/ShellSentryConfigJsonAdapter : com/squareup/moshi/JsonAdapter { public fun (Lcom/squareup/moshi/Moshi;)V public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object; diff --git a/src/main/kotlin/slack/cli/shellsentry/Issue.kt b/src/main/kotlin/slack/cli/shellsentry/Issue.kt index 1750f72..68320d8 100644 --- a/src/main/kotlin/slack/cli/shellsentry/Issue.kt +++ b/src/main/kotlin/slack/cli/shellsentry/Issue.kt @@ -33,7 +33,7 @@ import com.squareup.moshi.JsonClass * @property retrySignal the [RetrySignal] to use when this issue is found. */ @JsonClass(generateAdapter = true) -internal data class Issue +public data class Issue @JvmOverloads constructor( val message: String, @@ -57,7 +57,7 @@ constructor( /** Checks the log for this issue and returns a [RetrySignal] if it should be retried. */ @Suppress("ReturnCount") - fun check(lines: List, log: (String) -> Unit): RetrySignal { + internal fun check(lines: List, log: (String) -> Unit): RetrySignal { if (matchingText.isNotEmpty()) { for (matchingText in matchingText) { if (lines.checkMatches { it.contains(matchingText, ignoreCase = true) }) { @@ -85,7 +85,6 @@ constructor( * purposes but doesn't fill in a stacktrace. */ internal class IssueThrowable(issue: Issue) : Throwable(issue.message) { - override fun fillInStackTrace(): Throwable { // Do nothing, the stacktrace isn't relevant for these! return this diff --git a/src/main/kotlin/slack/cli/shellsentry/RetrySignal.kt b/src/main/kotlin/slack/cli/shellsentry/RetrySignal.kt index ad71f19..ebf0198 100644 --- a/src/main/kotlin/slack/cli/shellsentry/RetrySignal.kt +++ b/src/main/kotlin/slack/cli/shellsentry/RetrySignal.kt @@ -20,33 +20,21 @@ import dev.zacsweers.moshix.sealed.annotations.TypeLabel import kotlin.time.Duration @JsonClass(generateAdapter = true, generator = "sealed:type") -internal sealed interface RetrySignal { +public sealed interface RetrySignal { /** Unknown issue. */ - @TypeLabel("unknown") - object Unknown : RetrySignal { - // TODO remove when we have data objects in Kotlin 1.9 - override fun toString() = this::class.simpleName!! - } + @TypeLabel("unknown") public data object Unknown : RetrySignal /** Indicates an issue that is recognized but cannot be retried. */ - @TypeLabel("ack") - object Ack : RetrySignal { - // TODO remove when we have data objects in Kotlin 1.9 - override fun toString() = this::class.simpleName!! - } + @TypeLabel("ack") public data object Ack : RetrySignal /** Indicates this issue should be retried immediately. */ - @TypeLabel("immediate") - object RetryImmediately : RetrySignal { - // TODO remove when we have data objects in Kotlin 1.9 - override fun toString() = this::class.simpleName!! - } + @TypeLabel("immediate") public data object RetryImmediately : RetrySignal /** Indicates this issue should be retried after a [delay]. */ @TypeLabel("delayed") @JsonClass(generateAdapter = true) - data class RetryDelayed( + public data class RetryDelayed( // Can't default to 1.minutes due to https://github.com/ZacSweers/MoshiX/issues/442 val delay: Duration ) : RetrySignal diff --git a/src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt b/src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt new file mode 100644 index 0000000..f76df89 --- /dev/null +++ b/src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2023 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.cli.shellsentry + +import eu.jrie.jetbrains.kotlinshell.shell.shell +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createTempDirectory +import kotlin.io.path.createTempFile +import kotlin.io.path.deleteRecursively +import kotlin.system.exitProcess + +/** + * Executes a command with Bugsnag tracing and retries as needed. + * + * @property command the command to execute (i.e. './gradlew build'). + * @property workingDir the working directory to execute the command in. + * @property cacheDir the directory to use for caching temporary files. Defaults to + * [createTempDirectory]. + * @property bugsnagKey optional Bugsnag API key to use for reporting. + * @property config the [ShellSentryConfig] to use. + * @property verbose whether to print verbose output. + * @property debug whether to keep the cache directory around for debugging. Otherwise, it will be + * deleted at the end. + * @property noExit whether to exit the process with the exit code. This is useful for testing. + * @property echo a function to echo output to. Defaults to [println]. + */ +@Suppress("LongParameterList") +public class ShellSentry( + private val command: String, + private val workingDir: Path, + private val cacheDir: Path = createTempDirectory("shellsentry"), + private val bugsnagKey: String? = null, + private val config: ShellSentryConfig = ShellSentryConfig(), + private val verbose: Boolean = false, + private val debug: Boolean = false, + private val noExit: Boolean = false, + private val echo: (String) -> Unit = ::println, +) { + + @Suppress("CyclomaticComplexMethod", "LongMethod") + @OptIn(ExperimentalPathApi::class) + public fun exec() { + // Initial command execution + val (initialExitCode, initialLogFile) = executeCommand(command, cacheDir) + var exitCode = initialExitCode + var logFile = initialLogFile + var attempts = 0 + while (exitCode != 0 && attempts < 1) { + attempts++ + echo( + "Command failed with exit code $exitCode. Running processor script (attempt $attempts)..." + ) + + echo("Processing CI failure") + val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, echo) + + when (val retrySignal = resultProcessor.process(logFile, false)) { + is RetrySignal.Ack, + RetrySignal.Unknown -> { + echo("Processor exited with 0, exiting with original exit code...") + break + } + is RetrySignal.RetryDelayed -> { + echo( + "Processor script exited with 2, rerunning the command after ${retrySignal.delay}..." + ) + // TODO add option to reclaim memory? + Thread.sleep(retrySignal.delay.inWholeMilliseconds) + val secondResult = executeCommand(command, cacheDir) + exitCode = secondResult.exitCode + logFile = secondResult.outputFile + if (secondResult.exitCode != 0) { + // Process the second failure, then bounce out + resultProcessor.process(secondResult.outputFile, isAfterRetry = true) + } + } + is RetrySignal.RetryImmediately -> { + echo("Processor script exited with 1, rerunning the command immediately...") + // TODO add option to reclaim memory? + val secondResult = executeCommand(command, cacheDir) + exitCode = secondResult.exitCode + logFile = secondResult.outputFile + if (secondResult.exitCode != 0) { + // Process the second failure, then bounce out + resultProcessor.process(secondResult.outputFile, isAfterRetry = true) + } + } + } + } + + // If we got here, all is well + // Delete the tmp files + if (!debug) { + cacheDir.deleteRecursively() + } + + echo("Exiting with code $exitCode") + if (!noExit) { + exitProcess(exitCode) + } + } + + // Function to execute command and capture output. Shorthand to the testable top-level function. + private fun executeCommand(command: String, tmpDir: Path) = + executeCommand(workingDir, command, tmpDir, echo) +} + +internal data class ProcessResult(val exitCode: Int, val outputFile: Path) + +// Function to execute command and capture output +internal fun executeCommand( + workingDir: Path, + command: String, + tmpDir: Path, + echo: (String) -> Unit, +): ProcessResult { + echo("Running command: '$command'") + + val tmpFile = createTempFile(tmpDir, "shellsentry", ".txt").toAbsolutePath() + + var exitCode = 0 + shell { + // Weird but the only way to set the working dir + shell(dir = workingDir.toFile()) { + // Read the output of the process and write to both stdout and file + // This makes it behave a bit like tee. + val echoHandler = stringLambda { line -> + // The line always includes a trailing newline, but we don't need that + echo(line.removeSuffix("\n")) + // Pass the line through unmodified + line to "" + } + val process = command.process() forkErr { it pipe echoHandler pipe tmpFile.toFile() } + pipeline { process pipe echoHandler pipe tmpFile.toFile() }.join() + exitCode = process.process.pcb.exitCode + } + } + + return ProcessResult(exitCode, tmpFile) +} diff --git a/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt b/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt index e626669..b27c12d 100644 --- a/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt +++ b/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt @@ -22,20 +22,15 @@ import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.path import com.squareup.moshi.adapter -import eu.jrie.jetbrains.kotlinshell.shell.shell -import java.nio.file.Path -import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.createDirectories -import kotlin.io.path.createTempFile -import kotlin.io.path.deleteRecursively -import kotlin.system.exitProcess import okio.buffer import okio.source import org.jetbrains.annotations.TestOnly import slack.cli.projectDirOption /** - * Executes a command with Bugsnag tracing and retries as needed. + * Executes a command with Bugsnag tracing and retries as needed. This CLI is a shim over + * [ShellSentry]. * * Example: * ``` @@ -46,8 +41,11 @@ public class ShellSentryCli : CliktCommand("Executes a command with Bugsnag tracing and retries as needed.") { internal val projectDir by projectDirOption() + internal val verbose by option("--verbose", "-v").flag() + internal val bugsnagKey by option("--bugsnag-key", envvar = "PE_BUGSNAG_KEY") + internal val configurationFile by option("--config", envvar = "PE_CONFIGURATION_FILE") .path(mustExist = true, canBeFile = true, canBeDir = false) @@ -61,12 +59,11 @@ public class ShellSentryCli : help = "Instructs this CLI to not exit the process with the status code. Test only!" ) .flag() + @get:TestOnly internal val parseOnly by option("--parse-only").flag(default = false) internal val args by argument().multiple() - @Suppress("CyclomaticComplexMethod", "LongMethod") - @OptIn(ExperimentalStdlibApi::class, ExperimentalPathApi::class) override fun run() { if (parseOnly) return @@ -77,108 +74,22 @@ public class ShellSentryCli : it.source().buffer().use { source -> moshi.adapter().fromJson(source) } } ?: ShellSentryConfig() - // The command to be executed - val cmd = args.joinToString(" ") - - // Temporary file for command output - val tmpDir = projectDir.resolve("tmp/shellsentry") - tmpDir.createDirectories() - // Initial command execution - val (initialExitCode, initialLogFile) = executeCommand(cmd, tmpDir) - var exitCode = initialExitCode - var logFile = initialLogFile - var attempts = 0 - while (exitCode != 0 && attempts < 1) { - attempts++ - echo( - "Command failed with exit code $exitCode. Running processor script (attempt $attempts)..." + // Temporary dir for command output + val cacheDir = projectDir.resolve("tmp/shellsentry") + cacheDir.createDirectories() + + ShellSentry( + command = args.joinToString(" "), + workingDir = projectDir, + cacheDir = cacheDir, + config = config, + verbose = verbose, + bugsnagKey = bugsnagKey, + debug = debug, + noExit = noExit, + echo = ::echo ) - - echo("Processing CI failure") - val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, ::echo) - - when (val retrySignal = resultProcessor.process(logFile, false)) { - is RetrySignal.Ack, - RetrySignal.Unknown -> { - echo("Processor exited with 0, exiting with original exit code...") - break - } - is RetrySignal.RetryDelayed -> { - echo( - "Processor script exited with 2, rerunning the command after ${retrySignal.delay}..." - ) - // TODO add option to reclaim memory? - Thread.sleep(retrySignal.delay.inWholeMilliseconds) - val secondResult = executeCommand(cmd, tmpDir) - exitCode = secondResult.exitCode - logFile = secondResult.outputFile - if (secondResult.exitCode != 0) { - // Process the second failure, then bounce out - resultProcessor.process(secondResult.outputFile, isAfterRetry = true) - } - } - is RetrySignal.RetryImmediately -> { - echo("Processor script exited with 1, rerunning the command immediately...") - // TODO add option to reclaim memory? - val secondResult = executeCommand(cmd, tmpDir) - exitCode = secondResult.exitCode - logFile = secondResult.outputFile - if (secondResult.exitCode != 0) { - // Process the second failure, then bounce out - resultProcessor.process(secondResult.outputFile, isAfterRetry = true) - } - } - } - } - - // If we got here, all is well - // Delete the tmp files - if (!debug) { - tmpDir.deleteRecursively() - } - - echo("Exiting with code $exitCode") - if (!noExit) { - exitProcess(exitCode) - } + .exec() } - - // Function to execute command and capture output. Shorthand to the testable top-level function. - private fun executeCommand(command: String, tmpDir: Path) = - executeCommand(projectDir, command, tmpDir, ::echo) -} - -internal data class ProcessResult(val exitCode: Int, val outputFile: Path) - -// Function to execute command and capture output -internal fun executeCommand( - workingDir: Path, - command: String, - tmpDir: Path, - echo: (String) -> Unit, -): ProcessResult { - echo("Running command: '$command'") - - val tmpFile = createTempFile(tmpDir, "shellsentry", ".txt").toAbsolutePath() - - var exitCode = 0 - shell { - // Weird but the only way to set the working dir - shell(dir = workingDir.toFile()) { - // Read the output of the process and write to both stdout and file - // This makes it behave a bit like tee. - val echoHandler = stringLambda { line -> - // The line always includes a trailing newline, but we don't need that - echo(line.removeSuffix("\n")) - // Pass the line through unmodified - line to "" - } - val process = command.process() forkErr { it pipe echoHandler pipe tmpFile.toFile() } - pipeline { process pipe echoHandler pipe tmpFile.toFile() }.join() - exitCode = process.process.pcb.exitCode - } - } - - return ProcessResult(exitCode, tmpFile) } diff --git a/src/main/kotlin/slack/cli/shellsentry/ShellSentryConfig.kt b/src/main/kotlin/slack/cli/shellsentry/ShellSentryConfig.kt index 4de7b67..534877c 100644 --- a/src/main/kotlin/slack/cli/shellsentry/ShellSentryConfig.kt +++ b/src/main/kotlin/slack/cli/shellsentry/ShellSentryConfig.kt @@ -23,7 +23,7 @@ internal const val CURRENT_VERSION = 2 /** Represents a configuration for [ShellSentryCli]. */ @JsonClass(generateAdapter = true) -internal data class ShellSentryConfig( +public data class ShellSentryConfig( val version: Int = CURRENT_VERSION, @Json(name = "gradle_enterprise_server") val gradleEnterpriseServer: String? = null, @Json(name = "known_issues") From f26910fdd3dbe753e70d5149764379724df9f4bb Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Tue, 22 Aug 2023 11:06:16 -0400 Subject: [PATCH 2/2] Consolidate CLI -> ShellSentry logic + make it a data class for easy copying --- api/kotlin-cli-util.api | 10 +++ .../slack/cli/shellsentry/ShellSentry.kt | 64 +++++++++++++++---- .../slack/cli/shellsentry/ShellSentryCli.kt | 33 +--------- 3 files changed, 66 insertions(+), 41 deletions(-) diff --git a/api/kotlin-cli-util.api b/api/kotlin-cli-util.api index 5756105..fce093f 100644 --- a/api/kotlin-cli-util.api +++ b/api/kotlin-cli-util.api @@ -121,9 +121,19 @@ public final class slack/cli/shellsentry/RetrySignal_RetryDelayedJsonAdapter : c } public final class slack/cli/shellsentry/ShellSentry { + public static final field Companion Lslack/cli/shellsentry/ShellSentry$Companion; public fun (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun copy (Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;)Lslack/cli/shellsentry/ShellSentry; + public static synthetic fun copy$default (Lslack/cli/shellsentry/ShellSentry;Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/lang/String;Lslack/cli/shellsentry/ShellSentryConfig;ZZZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lslack/cli/shellsentry/ShellSentry; + public fun equals (Ljava/lang/Object;)Z public final fun exec ()V + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class slack/cli/shellsentry/ShellSentry$Companion { + public final fun create ([Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lslack/cli/shellsentry/ShellSentry; } public final class slack/cli/shellsentry/ShellSentryCli : com/github/ajalt/clikt/core/CliktCommand { diff --git a/src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt b/src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt index f76df89..20981e0 100644 --- a/src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt +++ b/src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt @@ -15,13 +15,17 @@ */ package slack.cli.shellsentry +import com.squareup.moshi.adapter import eu.jrie.jetbrains.kotlinshell.shell.shell import java.nio.file.Path import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createDirectories import kotlin.io.path.createTempDirectory import kotlin.io.path.createTempFile import kotlin.io.path.deleteRecursively import kotlin.system.exitProcess +import okio.buffer +import okio.source /** * Executes a command with Bugsnag tracing and retries as needed. @@ -36,10 +40,10 @@ import kotlin.system.exitProcess * @property debug whether to keep the cache directory around for debugging. Otherwise, it will be * deleted at the end. * @property noExit whether to exit the process with the exit code. This is useful for testing. - * @property echo a function to echo output to. Defaults to [println]. + * @property logger a function to log output to. Defaults to [println]. */ @Suppress("LongParameterList") -public class ShellSentry( +public data class ShellSentry( private val command: String, private val workingDir: Path, private val cacheDir: Path = createTempDirectory("shellsentry"), @@ -48,7 +52,7 @@ public class ShellSentry( private val verbose: Boolean = false, private val debug: Boolean = false, private val noExit: Boolean = false, - private val echo: (String) -> Unit = ::println, + private val logger: (String) -> Unit = ::println, ) { @Suppress("CyclomaticComplexMethod", "LongMethod") @@ -61,21 +65,21 @@ public class ShellSentry( var attempts = 0 while (exitCode != 0 && attempts < 1) { attempts++ - echo( + logger( "Command failed with exit code $exitCode. Running processor script (attempt $attempts)..." ) - echo("Processing CI failure") - val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, echo) + logger("Processing CI failure") + val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, logger) when (val retrySignal = resultProcessor.process(logFile, false)) { is RetrySignal.Ack, RetrySignal.Unknown -> { - echo("Processor exited with 0, exiting with original exit code...") + logger("Processor exited with 0, exiting with original exit code...") break } is RetrySignal.RetryDelayed -> { - echo( + logger( "Processor script exited with 2, rerunning the command after ${retrySignal.delay}..." ) // TODO add option to reclaim memory? @@ -89,7 +93,7 @@ public class ShellSentry( } } is RetrySignal.RetryImmediately -> { - echo("Processor script exited with 1, rerunning the command immediately...") + logger("Processor script exited with 1, rerunning the command immediately...") // TODO add option to reclaim memory? val secondResult = executeCommand(command, cacheDir) exitCode = secondResult.exitCode @@ -108,7 +112,7 @@ public class ShellSentry( cacheDir.deleteRecursively() } - echo("Exiting with code $exitCode") + logger("Exiting with code $exitCode") if (!noExit) { exitProcess(exitCode) } @@ -116,7 +120,45 @@ public class ShellSentry( // Function to execute command and capture output. Shorthand to the testable top-level function. private fun executeCommand(command: String, tmpDir: Path) = - executeCommand(workingDir, command, tmpDir, echo) + executeCommand(workingDir, command, tmpDir, logger) + + public companion object { + /** Creates a new instance with the given [argv] command line args as input. */ + public fun create(argv: Array, echo: (String) -> Unit): ShellSentry { + val cli = ShellSentryCli().apply { main(argv + "--parse-only") } + return create(cli, echo) + } + + /** Internal function to consolidate CLI args -> [ShellSentry] creation logic. */ + internal fun create( + cli: ShellSentryCli, + logger: (String) -> Unit = { cli.echo(it) } + ): ShellSentry { + val moshi = ProcessingUtil.newMoshi() + val config = + cli.configurationFile?.let { + logger("Parsing config file '$it'") + it.source().buffer().use { source -> moshi.adapter().fromJson(source) } + } + ?: ShellSentryConfig() + + // Temporary dir for command output + val cacheDir = cli.projectDir.resolve("tmp/shellsentry") + cacheDir.createDirectories() + + return ShellSentry( + command = cli.args.joinToString(" "), + workingDir = cli.projectDir, + cacheDir = cacheDir, + config = config, + verbose = cli.verbose, + bugsnagKey = cli.bugsnagKey, + debug = cli.debug, + noExit = cli.noExit, + logger = logger + ) + } + } } internal data class ProcessResult(val exitCode: Int, val outputFile: Path) diff --git a/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt b/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt index b27c12d..48f8632 100644 --- a/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt +++ b/src/main/kotlin/slack/cli/shellsentry/ShellSentryCli.kt @@ -21,10 +21,6 @@ import com.github.ajalt.clikt.parameters.arguments.multiple import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.path -import com.squareup.moshi.adapter -import kotlin.io.path.createDirectories -import okio.buffer -import okio.source import org.jetbrains.annotations.TestOnly import slack.cli.projectDirOption @@ -50,10 +46,10 @@ public class ShellSentryCli : option("--config", envvar = "PE_CONFIGURATION_FILE") .path(mustExist = true, canBeFile = true, canBeDir = false) - private val debug by option("--debug", "-d").flag() + internal val debug by option("--debug", "-d").flag() @get:TestOnly - private val noExit by + internal val noExit by option( "--no-exit", help = "Instructs this CLI to not exit the process with the status code. Test only!" @@ -67,29 +63,6 @@ public class ShellSentryCli : override fun run() { if (parseOnly) return - val moshi = ProcessingUtil.newMoshi() - val config = - configurationFile?.let { - echo("Parsing config file '$it'") - it.source().buffer().use { source -> moshi.adapter().fromJson(source) } - } - ?: ShellSentryConfig() - - // Temporary dir for command output - val cacheDir = projectDir.resolve("tmp/shellsentry") - cacheDir.createDirectories() - - ShellSentry( - command = args.joinToString(" "), - workingDir = projectDir, - cacheDir = cacheDir, - config = config, - verbose = verbose, - bugsnagKey = bugsnagKey, - debug = debug, - noExit = noExit, - echo = ::echo - ) - .exec() + return ShellSentry.create(this).exec() } }