Skip to content

Commit

Permalink
Split out ShellSentry program from CLI (#39)
Browse files Browse the repository at this point in the history
* Split out ShellSentry program from CLI

This makes it easier to use and wrap in other programs or CLIs

* Consolidate CLI -> ShellSentry logic + make it a data class for easy copying
  • Loading branch information
ZacSweers committed Aug 22, 2023
1 parent 5bb8e44 commit d6927ba
Show file tree
Hide file tree
Showing 6 changed files with 308 additions and 146 deletions.
95 changes: 95 additions & 0 deletions api/kotlin-cli-util.api
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,75 @@ 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 <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;Ljava/util/List;)V
public fun <init> (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 <init> (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 <init> (Lcom/squareup/moshi/Moshi;)V
public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object;
public fun toJson (Lcom/squareup/moshi/JsonWriter;Ljava/lang/Object;)V
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 <init> (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 <init> (Lcom/squareup/moshi/Moshi;)V
public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object;
Expand All @@ -58,11 +120,44 @@ public final class slack/cli/shellsentry/RetrySignal_RetryDelayedJsonAdapter : c
public fun toString ()Ljava/lang/String;
}

public final class slack/cli/shellsentry/ShellSentry {
public static final field Companion Lslack/cli/shellsentry/ShellSentry$Companion;
public fun <init> (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 <init> (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 {
public fun <init> ()V
public fun run ()V
}

public final class slack/cli/shellsentry/ShellSentryConfig {
public fun <init> ()V
public fun <init> (ILjava/lang/String;Ljava/util/List;)V
public synthetic fun <init> (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 <init> (Lcom/squareup/moshi/Moshi;)V
public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object;
Expand Down
5 changes: 2 additions & 3 deletions src/main/kotlin/slack/cli/shellsentry/Issue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String>, log: (String) -> Unit): RetrySignal {
internal fun check(lines: List<String>, log: (String) -> Unit): RetrySignal {
if (matchingText.isNotEmpty()) {
for (matchingText in matchingText) {
if (lines.checkMatches { it.contains(matchingText, ignoreCase = true) }) {
Expand Down Expand Up @@ -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
Expand Down
22 changes: 5 additions & 17 deletions src/main/kotlin/slack/cli/shellsentry/RetrySignal.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
196 changes: 196 additions & 0 deletions src/main/kotlin/slack/cli/shellsentry/ShellSentry.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* 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 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.
*
* @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 logger a function to log output to. Defaults to [println].
*/
@Suppress("LongParameterList")
public data 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 logger: (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++
logger(
"Command failed with exit code $exitCode. Running processor script (attempt $attempts)..."
)

logger("Processing CI failure")
val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, logger)

when (val retrySignal = resultProcessor.process(logFile, false)) {
is RetrySignal.Ack,
RetrySignal.Unknown -> {
logger("Processor exited with 0, exiting with original exit code...")
break
}
is RetrySignal.RetryDelayed -> {
logger(
"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 -> {
logger("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()
}

logger("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, logger)

public companion object {
/** Creates a new instance with the given [argv] command line args as input. */
public fun create(argv: Array<String>, 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<ShellSentryConfig>().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)

// 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)
}
Loading

0 comments on commit d6927ba

Please sign in to comment.