Skip to content
Open
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
108 changes: 108 additions & 0 deletions coordinator/app/src/main/kotlin/linea/coordinator/app/ConfigLogging.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package linea.coordinator.app

import com.sksamuel.hoplite.Masked
import linea.coordinator.config.v2.CoordinatorConfig
import linea.coordinator.config.v2.SignerConfig
import linea.kotlin.encodeHex
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.isAccessible
import kotlin.reflect.jvm.javaGetter
import kotlin.time.Duration
import kotlin.time.Instant

private const val INDENT_WIDTH = 2
private const val PLACEHOLDER_HINT = "enable TRACE on linea.coordinator.app for full list"

// (declaringClass.simpleName, ctorParamName) of Map-valued fields that the INFO
// render replaces with a one-line summary instead of expanding entry-by-entry.
private val noisyMapFields: Set<String> = setOf(
"CoordinatorConfig.smartContractErrors",
"DynamicGasPriceCapConfig.timeOfDayMultipliers",
"GasPriceCapCalculationConfig.timeOfTheDayMultipliers",
)

internal fun CoordinatorConfig.toPrettyLog(summarizeNoisyFields: Boolean = true): String =
renderRoot(this, summarize = summarizeNoisyFields)

private fun renderRoot(value: Any, summarize: Boolean): String {
val sb = StringBuilder()
renderObject(value, indent = -INDENT_WIDTH, sb, summarize)
return sb.toString().trimStart('\n')
}

private fun renderValue(value: Any?, indent: Int, sb: StringBuilder, summarize: Boolean) {
when (value) {
null -> sb.append(" null")
is Masked -> sb.append(" ***")
is Duration -> sb.append(' ').append(value.toString())
is Instant -> sb.append(' ').append(value.toString())
is ByteArray -> sb.append(' ').append(value.encodeHex())
is SignerConfig.Web3jConfig -> {
sb.append('\n').append(spaces(indent + INDENT_WIDTH))
.append("privateKey: ***").append(value.privateKey.size).append(" bytes***")
}
is Number, is Boolean, is Enum<*> -> sb.append(' ').append(value.toString())
is CharSequence -> sb.append(' ').append(value.toString())
is Map<*, *> -> renderMap(value, indent, sb)
is List<*> -> renderList(value, indent, sb, summarize)
else -> if (value::class.isData) {
renderObject(value, indent, sb, summarize)
} else {
sb.append(' ').append(value.toString())
}
}
}

private fun renderMap(m: Map<*, *>, indent: Int, sb: StringBuilder) {
if (m.isEmpty()) {
sb.append(" {}")
return
}
m.forEach { (k, v) ->
sb.append('\n').append(spaces(indent + INDENT_WIDTH)).append(k.toString()).append(':')
renderValue(v, indent + INDENT_WIDTH, sb, summarize = false)
}
}

private fun renderList(list: List<*>, indent: Int, sb: StringBuilder, summarize: Boolean) {
if (list.isEmpty()) {
sb.append(" []")
return
}
list.forEach { item ->
sb.append('\n').append(spaces(indent + INDENT_WIDTH)).append('-')
renderValue(item, indent + INDENT_WIDTH, sb, summarize)
}
}

private fun renderObject(value: Any, indent: Int, sb: StringBuilder, summarize: Boolean) {
val kClass = value::class
val ctor = kClass.primaryConstructor
if (ctor == null) {
sb.append(' ').append(value.toString())
return
}
val props = kClass.memberProperties.associateBy { it.name }
ctor.parameters.forEach { p ->
val name = p.name ?: return@forEach
val prop = props[name] ?: return@forEach
prop.isAccessible = true
val v = try {
// Kotlin reflection — preserves value-class boxing (e.g. Duration → Duration, not raw Long).
prop.getter.call(value)
} catch (_: Throwable) {
// Falls over on some nullable-nested-value-class shapes (e.g. BlockParameter.BlockNumber?).
// Java reflection returns the unboxed primitive in those cases; readable enough for a log.
prop.javaGetter?.also { it.isAccessible = true }?.invoke(value)
}
sb.append('\n').append(spaces(indent + INDENT_WIDTH)).append(name).append(':')
if (summarize && v is Map<*, *> && "${kClass.simpleName}.$name" in noisyMapFields) {
sb.append(" <").append(v.size).append(" entries, ").append(PLACEHOLDER_HINT).append('>')
} else {
renderValue(v, indent + INDENT_WIDTH, sb, summarize)
}
}
}

private fun spaces(n: Int): String = if (n <= 0) "" else " ".repeat(n)
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,19 @@ class CoordinatorApp(
log.trace("System properties: {}", System.getProperties())
val vertxConfig = loadVertxConfig()
log.debug("Vertx full configs: {}", vertxConfig)
log.info("App configs: {}", configs)
log.info("App configs:\n{}", configs.toPrettyLog())
log.trace(
"Full smartContractErrors ({} entries): {}",
configs.smartContractErrors.size,
configs.smartContractErrors,
)
configs.l1Submission?.dynamicGasPriceCap?.let { dgc ->
log.trace("dynamicGasPriceCap.timeOfDayMultipliers: {}", dgc.timeOfDayMultipliers)
log.trace(
"dynamicGasPriceCap.gasPriceCapCalculation.timeOfTheDayMultipliers: {}",
dgc.gasPriceCapCalculation.timeOfTheDayMultipliers,
)
}

Vertx.vertx(vertxConfig)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ internal constructor(private val errorWriter: PrintWriter, private val startActi
)

if (checkConfigsOnly) {
logger.info("All configs are valid. Final configs: {}", configs)
logger.info("All configs are valid. Final configs:\n{}", configs.toPrettyLog())
} else {
startAction.start(configs)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package linea.coordinator.app

import linea.coordinator.config.v2.CoordinatorConfig
import linea.coordinator.config.v2.toml.loadConfigs
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.nio.file.Path

/**
* Guards the one failure mode of [CoordinatorConfig.toPrettyLog] that is silent: a regression
* in the reflection walker's handling of [com.sksamuel.hoplite.Masked] or
* [linea.coordinator.config.v2.SignerConfig.Web3jConfig] would land secrets in `kubectl logs`
* without crashing or otherwise surfacing in normal operation.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ConfigLoggingTest {
private lateinit var configs: CoordinatorConfig

@BeforeAll
fun loadFixture() {
configs = loadConfigs(
coordinatorConfigFiles = listOf(
Path.of("../../docker/config/coordinator/coordinator-config-v2.toml"),
Path.of("../../docker/config/coordinator/coordinator-config-v2-override-local-dev.toml"),
),
tracesLimitsFileV4 = Path.of("../../docker/config/common/traces-limits-v4.4.toml"),
tracesLimitsFileV5 = Path.of("../../docker/config/common/traces-limits-v5.toml"),
gasPriceCapTimeOfDayMultipliersFile = Path.of(
"../../docker/config/common/gas-price-cap-time-of-day-multipliers.toml",
),
smartContractErrorsFile = Path.of("../../docker/config/common/smart-contract-errors.toml"),
enforceStrict = true,
)
}

@Test
fun `secrets do not leak in pretty-printed config`() {
val outputs = listOf(configs.toPrettyLog(), configs.toPrettyLog(summarizeNoisyFields = false))

// database.password = "postgres" in the fixture; must render as ***, never plaintext.
// Anchored on `password:` so it does not false-positive on host/username.
outputs.forEach { yaml ->
assertThat(yaml).doesNotContain("password: postgres")
assertThat(yaml).contains("password: ***")
}

// web3j.privateKey hexes from coordinator-config-v2.toml (lines 174, 207, 241). Keep in sync if
// those fixture keys are ever rotated — otherwise this regression check silently tests nothing.
val privateKeyHexes = listOf(
"5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
"59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
"4d01ae6487860981699236a58b68f807ee5f17b12df5740b85cf4c4653be0f55",
)
outputs.forEach { yaml ->
privateKeyHexes.forEach { hex ->
assertThat(yaml).doesNotContain(hex)
assertThat(yaml).doesNotContain(hex.uppercase())
}
assertThat(yaml).contains("***32 bytes***")
}
}
}