Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split out ShellSentry program from CLI #39

Merged
merged 2 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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