Skip to content
This repository was archived by the owner on Oct 14, 2024. It is now read-only.

Commit d6927ba

Browse files
authored
Split out ShellSentry program from CLI (#39)
* 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
1 parent 5bb8e44 commit d6927ba

File tree

6 files changed

+308
-146
lines changed

6 files changed

+308
-146
lines changed

api/kotlin-cli-util.api

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,75 @@ public final class slack/cli/Toml {
3737
public final fun parseVersion (Ljava/io/File;)Ljava/util/Map;
3838
}
3939

40+
public final class slack/cli/shellsentry/Issue {
41+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;)V
42+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;)V
43+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lslack/cli/shellsentry/RetrySignal;Ljava/lang/String;Ljava/util/List;)V
44+
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
45+
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
46+
public final fun component1 ()Ljava/lang/String;
47+
public final fun component2 ()Ljava/lang/String;
48+
public final fun component3 ()Ljava/lang/String;
49+
public final fun component4 ()Lslack/cli/shellsentry/RetrySignal;
50+
public final fun component5 ()Ljava/lang/String;
51+
public final fun component6 ()Ljava/util/List;
52+
public final fun component7 ()Ljava/util/List;
53+
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;
54+
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;
55+
public fun equals (Ljava/lang/Object;)Z
56+
public final fun getDescription ()Ljava/lang/String;
57+
public final fun getGroupingHash ()Ljava/lang/String;
58+
public final fun getLogMessage ()Ljava/lang/String;
59+
public final fun getMatchingPatterns ()Ljava/util/List;
60+
public final fun getMatchingText ()Ljava/util/List;
61+
public final fun getMessage ()Ljava/lang/String;
62+
public final fun getRetrySignal ()Lslack/cli/shellsentry/RetrySignal;
63+
public fun hashCode ()I
64+
public fun toString ()Ljava/lang/String;
65+
}
66+
4067
public final class slack/cli/shellsentry/IssueJsonAdapter : com/squareup/moshi/JsonAdapter {
4168
public fun <init> (Lcom/squareup/moshi/Moshi;)V
4269
public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object;
4370
public fun toJson (Lcom/squareup/moshi/JsonWriter;Ljava/lang/Object;)V
4471
public fun toString ()Ljava/lang/String;
4572
}
4673

74+
public abstract interface class slack/cli/shellsentry/RetrySignal {
75+
}
76+
77+
public final class slack/cli/shellsentry/RetrySignal$Ack : slack/cli/shellsentry/RetrySignal {
78+
public static final field INSTANCE Lslack/cli/shellsentry/RetrySignal$Ack;
79+
public fun equals (Ljava/lang/Object;)Z
80+
public fun hashCode ()I
81+
public fun toString ()Ljava/lang/String;
82+
}
83+
84+
public final class slack/cli/shellsentry/RetrySignal$RetryDelayed : slack/cli/shellsentry/RetrySignal {
85+
public synthetic fun <init> (JLkotlin/jvm/internal/DefaultConstructorMarker;)V
86+
public final fun component1-UwyO8pc ()J
87+
public final fun copy-LRDsOJo (J)Lslack/cli/shellsentry/RetrySignal$RetryDelayed;
88+
public static synthetic fun copy-LRDsOJo$default (Lslack/cli/shellsentry/RetrySignal$RetryDelayed;JILjava/lang/Object;)Lslack/cli/shellsentry/RetrySignal$RetryDelayed;
89+
public fun equals (Ljava/lang/Object;)Z
90+
public final fun getDelay-UwyO8pc ()J
91+
public fun hashCode ()I
92+
public fun toString ()Ljava/lang/String;
93+
}
94+
95+
public final class slack/cli/shellsentry/RetrySignal$RetryImmediately : slack/cli/shellsentry/RetrySignal {
96+
public static final field INSTANCE Lslack/cli/shellsentry/RetrySignal$RetryImmediately;
97+
public fun equals (Ljava/lang/Object;)Z
98+
public fun hashCode ()I
99+
public fun toString ()Ljava/lang/String;
100+
}
101+
102+
public final class slack/cli/shellsentry/RetrySignal$Unknown : slack/cli/shellsentry/RetrySignal {
103+
public static final field INSTANCE Lslack/cli/shellsentry/RetrySignal$Unknown;
104+
public fun equals (Ljava/lang/Object;)Z
105+
public fun hashCode ()I
106+
public fun toString ()Ljava/lang/String;
107+
}
108+
47109
public final class slack/cli/shellsentry/RetrySignalJsonAdapter : com/squareup/moshi/JsonAdapter {
48110
public fun <init> (Lcom/squareup/moshi/Moshi;)V
49111
public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object;
@@ -58,11 +120,44 @@ public final class slack/cli/shellsentry/RetrySignal_RetryDelayedJsonAdapter : c
58120
public fun toString ()Ljava/lang/String;
59121
}
60122

123+
public final class slack/cli/shellsentry/ShellSentry {
124+
public static final field Companion Lslack/cli/shellsentry/ShellSentry$Companion;
125+
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
126+
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
127+
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;
128+
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;
129+
public fun equals (Ljava/lang/Object;)Z
130+
public final fun exec ()V
131+
public fun hashCode ()I
132+
public fun toString ()Ljava/lang/String;
133+
}
134+
135+
public final class slack/cli/shellsentry/ShellSentry$Companion {
136+
public final fun create ([Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lslack/cli/shellsentry/ShellSentry;
137+
}
138+
61139
public final class slack/cli/shellsentry/ShellSentryCli : com/github/ajalt/clikt/core/CliktCommand {
62140
public fun <init> ()V
63141
public fun run ()V
64142
}
65143

144+
public final class slack/cli/shellsentry/ShellSentryConfig {
145+
public fun <init> ()V
146+
public fun <init> (ILjava/lang/String;Ljava/util/List;)V
147+
public synthetic fun <init> (ILjava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
148+
public final fun component1 ()I
149+
public final fun component2 ()Ljava/lang/String;
150+
public final fun component3 ()Ljava/util/List;
151+
public final fun copy (ILjava/lang/String;Ljava/util/List;)Lslack/cli/shellsentry/ShellSentryConfig;
152+
public static synthetic fun copy$default (Lslack/cli/shellsentry/ShellSentryConfig;ILjava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lslack/cli/shellsentry/ShellSentryConfig;
153+
public fun equals (Ljava/lang/Object;)Z
154+
public final fun getGradleEnterpriseServer ()Ljava/lang/String;
155+
public final fun getKnownIssues ()Ljava/util/List;
156+
public final fun getVersion ()I
157+
public fun hashCode ()I
158+
public fun toString ()Ljava/lang/String;
159+
}
160+
66161
public final class slack/cli/shellsentry/ShellSentryConfigJsonAdapter : com/squareup/moshi/JsonAdapter {
67162
public fun <init> (Lcom/squareup/moshi/Moshi;)V
68163
public fun fromJson (Lcom/squareup/moshi/JsonReader;)Ljava/lang/Object;

src/main/kotlin/slack/cli/shellsentry/Issue.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import com.squareup.moshi.JsonClass
3333
* @property retrySignal the [RetrySignal] to use when this issue is found.
3434
*/
3535
@JsonClass(generateAdapter = true)
36-
internal data class Issue
36+
public data class Issue
3737
@JvmOverloads
3838
constructor(
3939
val message: String,
@@ -57,7 +57,7 @@ constructor(
5757

5858
/** Checks the log for this issue and returns a [RetrySignal] if it should be retried. */
5959
@Suppress("ReturnCount")
60-
fun check(lines: List<String>, log: (String) -> Unit): RetrySignal {
60+
internal fun check(lines: List<String>, log: (String) -> Unit): RetrySignal {
6161
if (matchingText.isNotEmpty()) {
6262
for (matchingText in matchingText) {
6363
if (lines.checkMatches { it.contains(matchingText, ignoreCase = true) }) {
@@ -85,7 +85,6 @@ constructor(
8585
* purposes but doesn't fill in a stacktrace.
8686
*/
8787
internal class IssueThrowable(issue: Issue) : Throwable(issue.message) {
88-
8988
override fun fillInStackTrace(): Throwable {
9089
// Do nothing, the stacktrace isn't relevant for these!
9190
return this

src/main/kotlin/slack/cli/shellsentry/RetrySignal.kt

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,33 +20,21 @@ import dev.zacsweers.moshix.sealed.annotations.TypeLabel
2020
import kotlin.time.Duration
2121

2222
@JsonClass(generateAdapter = true, generator = "sealed:type")
23-
internal sealed interface RetrySignal {
23+
public sealed interface RetrySignal {
2424

2525
/** Unknown issue. */
26-
@TypeLabel("unknown")
27-
object Unknown : RetrySignal {
28-
// TODO remove when we have data objects in Kotlin 1.9
29-
override fun toString() = this::class.simpleName!!
30-
}
26+
@TypeLabel("unknown") public data object Unknown : RetrySignal
3127

3228
/** Indicates an issue that is recognized but cannot be retried. */
33-
@TypeLabel("ack")
34-
object Ack : RetrySignal {
35-
// TODO remove when we have data objects in Kotlin 1.9
36-
override fun toString() = this::class.simpleName!!
37-
}
29+
@TypeLabel("ack") public data object Ack : RetrySignal
3830

3931
/** Indicates this issue should be retried immediately. */
40-
@TypeLabel("immediate")
41-
object RetryImmediately : RetrySignal {
42-
// TODO remove when we have data objects in Kotlin 1.9
43-
override fun toString() = this::class.simpleName!!
44-
}
32+
@TypeLabel("immediate") public data object RetryImmediately : RetrySignal
4533

4634
/** Indicates this issue should be retried after a [delay]. */
4735
@TypeLabel("delayed")
4836
@JsonClass(generateAdapter = true)
49-
data class RetryDelayed(
37+
public data class RetryDelayed(
5038
// Can't default to 1.minutes due to https://github.com/ZacSweers/MoshiX/issues/442
5139
val delay: Duration
5240
) : RetrySignal
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
* Copyright (C) 2023 Slack Technologies, LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package slack.cli.shellsentry
17+
18+
import com.squareup.moshi.adapter
19+
import eu.jrie.jetbrains.kotlinshell.shell.shell
20+
import java.nio.file.Path
21+
import kotlin.io.path.ExperimentalPathApi
22+
import kotlin.io.path.createDirectories
23+
import kotlin.io.path.createTempDirectory
24+
import kotlin.io.path.createTempFile
25+
import kotlin.io.path.deleteRecursively
26+
import kotlin.system.exitProcess
27+
import okio.buffer
28+
import okio.source
29+
30+
/**
31+
* Executes a command with Bugsnag tracing and retries as needed.
32+
*
33+
* @property command the command to execute (i.e. './gradlew build').
34+
* @property workingDir the working directory to execute the command in.
35+
* @property cacheDir the directory to use for caching temporary files. Defaults to
36+
* [createTempDirectory].
37+
* @property bugsnagKey optional Bugsnag API key to use for reporting.
38+
* @property config the [ShellSentryConfig] to use.
39+
* @property verbose whether to print verbose output.
40+
* @property debug whether to keep the cache directory around for debugging. Otherwise, it will be
41+
* deleted at the end.
42+
* @property noExit whether to exit the process with the exit code. This is useful for testing.
43+
* @property logger a function to log output to. Defaults to [println].
44+
*/
45+
@Suppress("LongParameterList")
46+
public data class ShellSentry(
47+
private val command: String,
48+
private val workingDir: Path,
49+
private val cacheDir: Path = createTempDirectory("shellsentry"),
50+
private val bugsnagKey: String? = null,
51+
private val config: ShellSentryConfig = ShellSentryConfig(),
52+
private val verbose: Boolean = false,
53+
private val debug: Boolean = false,
54+
private val noExit: Boolean = false,
55+
private val logger: (String) -> Unit = ::println,
56+
) {
57+
58+
@Suppress("CyclomaticComplexMethod", "LongMethod")
59+
@OptIn(ExperimentalPathApi::class)
60+
public fun exec() {
61+
// Initial command execution
62+
val (initialExitCode, initialLogFile) = executeCommand(command, cacheDir)
63+
var exitCode = initialExitCode
64+
var logFile = initialLogFile
65+
var attempts = 0
66+
while (exitCode != 0 && attempts < 1) {
67+
attempts++
68+
logger(
69+
"Command failed with exit code $exitCode. Running processor script (attempt $attempts)..."
70+
)
71+
72+
logger("Processing CI failure")
73+
val resultProcessor = ResultProcessor(verbose, bugsnagKey, config, logger)
74+
75+
when (val retrySignal = resultProcessor.process(logFile, false)) {
76+
is RetrySignal.Ack,
77+
RetrySignal.Unknown -> {
78+
logger("Processor exited with 0, exiting with original exit code...")
79+
break
80+
}
81+
is RetrySignal.RetryDelayed -> {
82+
logger(
83+
"Processor script exited with 2, rerunning the command after ${retrySignal.delay}..."
84+
)
85+
// TODO add option to reclaim memory?
86+
Thread.sleep(retrySignal.delay.inWholeMilliseconds)
87+
val secondResult = executeCommand(command, cacheDir)
88+
exitCode = secondResult.exitCode
89+
logFile = secondResult.outputFile
90+
if (secondResult.exitCode != 0) {
91+
// Process the second failure, then bounce out
92+
resultProcessor.process(secondResult.outputFile, isAfterRetry = true)
93+
}
94+
}
95+
is RetrySignal.RetryImmediately -> {
96+
logger("Processor script exited with 1, rerunning the command immediately...")
97+
// TODO add option to reclaim memory?
98+
val secondResult = executeCommand(command, cacheDir)
99+
exitCode = secondResult.exitCode
100+
logFile = secondResult.outputFile
101+
if (secondResult.exitCode != 0) {
102+
// Process the second failure, then bounce out
103+
resultProcessor.process(secondResult.outputFile, isAfterRetry = true)
104+
}
105+
}
106+
}
107+
}
108+
109+
// If we got here, all is well
110+
// Delete the tmp files
111+
if (!debug) {
112+
cacheDir.deleteRecursively()
113+
}
114+
115+
logger("Exiting with code $exitCode")
116+
if (!noExit) {
117+
exitProcess(exitCode)
118+
}
119+
}
120+
121+
// Function to execute command and capture output. Shorthand to the testable top-level function.
122+
private fun executeCommand(command: String, tmpDir: Path) =
123+
executeCommand(workingDir, command, tmpDir, logger)
124+
125+
public companion object {
126+
/** Creates a new instance with the given [argv] command line args as input. */
127+
public fun create(argv: Array<String>, echo: (String) -> Unit): ShellSentry {
128+
val cli = ShellSentryCli().apply { main(argv + "--parse-only") }
129+
return create(cli, echo)
130+
}
131+
132+
/** Internal function to consolidate CLI args -> [ShellSentry] creation logic. */
133+
internal fun create(
134+
cli: ShellSentryCli,
135+
logger: (String) -> Unit = { cli.echo(it) }
136+
): ShellSentry {
137+
val moshi = ProcessingUtil.newMoshi()
138+
val config =
139+
cli.configurationFile?.let {
140+
logger("Parsing config file '$it'")
141+
it.source().buffer().use { source -> moshi.adapter<ShellSentryConfig>().fromJson(source) }
142+
}
143+
?: ShellSentryConfig()
144+
145+
// Temporary dir for command output
146+
val cacheDir = cli.projectDir.resolve("tmp/shellsentry")
147+
cacheDir.createDirectories()
148+
149+
return ShellSentry(
150+
command = cli.args.joinToString(" "),
151+
workingDir = cli.projectDir,
152+
cacheDir = cacheDir,
153+
config = config,
154+
verbose = cli.verbose,
155+
bugsnagKey = cli.bugsnagKey,
156+
debug = cli.debug,
157+
noExit = cli.noExit,
158+
logger = logger
159+
)
160+
}
161+
}
162+
}
163+
164+
internal data class ProcessResult(val exitCode: Int, val outputFile: Path)
165+
166+
// Function to execute command and capture output
167+
internal fun executeCommand(
168+
workingDir: Path,
169+
command: String,
170+
tmpDir: Path,
171+
echo: (String) -> Unit,
172+
): ProcessResult {
173+
echo("Running command: '$command'")
174+
175+
val tmpFile = createTempFile(tmpDir, "shellsentry", ".txt").toAbsolutePath()
176+
177+
var exitCode = 0
178+
shell {
179+
// Weird but the only way to set the working dir
180+
shell(dir = workingDir.toFile()) {
181+
// Read the output of the process and write to both stdout and file
182+
// This makes it behave a bit like tee.
183+
val echoHandler = stringLambda { line ->
184+
// The line always includes a trailing newline, but we don't need that
185+
echo(line.removeSuffix("\n"))
186+
// Pass the line through unmodified
187+
line to ""
188+
}
189+
val process = command.process() forkErr { it pipe echoHandler pipe tmpFile.toFile() }
190+
pipeline { process pipe echoHandler pipe tmpFile.toFile() }.join()
191+
exitCode = process.process.pcb.exitCode
192+
}
193+
}
194+
195+
return ProcessResult(exitCode, tmpFile)
196+
}

0 commit comments

Comments
 (0)