Skip to content
Snippets Groups Projects
Commit cf37f2bc authored by Lorenz Boguhn's avatar Lorenz Boguhn
Browse files

Merge branch 'theodolite-kotlin' of git.se.informatik.uni-kiel.de:she/spesb...

Merge branch 'theodolite-kotlin' of git.se.informatik.uni-kiel.de:she/spesb into 144-document-image-build
parents 3d932eca fc153c62
No related branches found
No related tags found
4 merge requests!159Re-implementation of Theodolite with Kotlin/Quarkus,!157Update Graal Image in CI pipeline,!116Add image build documentation,!83WIP: Re-implementation of Theodolite with Kotlin/Quarkus
Showing
with 420 additions and 59 deletions
package theodolite.execution
import com.google.gson.GsonBuilder
import mu.KotlinLogging
import theodolite.benchmark.BenchmarkExecution
import theodolite.benchmark.KubernetesBenchmark
import theodolite.patcher.PatcherDefinitionFactory
......@@ -10,9 +11,17 @@ import theodolite.util.Config
import theodolite.util.LoadDimension
import theodolite.util.Resource
import theodolite.util.Results
import java.io.File
import java.io.PrintWriter
import java.lang.IllegalArgumentException
import java.lang.Thread.sleep
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
private val logger = KotlinLogging.logger {}
/**
* The Theodolite executor runs all the experiments defined with the given execution and benchmark configuration.
*
......@@ -92,13 +101,34 @@ class TheodoliteExecutor(
return this.kubernetesBenchmark
}
private fun getResultFolderString(): String {
var resultsFolder: String = System.getenv("RESULTS_FOLDER") ?: ""
val createResultsFolder = System.getenv("CREATE_RESULTS_FOLDER") ?: "false"
if (resultsFolder != ""){
logger.info { "RESULT_FOLDER: $resultsFolder" }
val directory = File(resultsFolder)
if (!directory.exists()) {
logger.error { "Folder $resultsFolder does not exist" }
if (createResultsFolder.toBoolean()) {
directory.mkdirs()
} else {
throw IllegalArgumentException("Result folder not found")
}
}
resultsFolder += "/"
}
return resultsFolder
}
/**
* Run all experiments which are specified in the corresponding
* execution and benchmark objects.
*/
fun run() {
storeAsFile(this.config, "${this.config.executionId}-execution-configuration")
storeAsFile(kubernetesBenchmark, "${this.config.executionId}-benchmark-configuration")
val resultsFolder = getResultFolderString()
storeAsFile(this.config, "$resultsFolder${this.config.executionId}-execution-configuration")
storeAsFile(kubernetesBenchmark, "$resultsFolder/${this.config.executionId}-benchmark-configuration")
val config = buildConfig()
// execute benchmarks for each load
......@@ -107,7 +137,7 @@ class TheodoliteExecutor(
config.compositeStrategy.findSuitableResource(load, config.resources)
}
}
storeAsFile(config.compositeStrategy.benchmarkExecutor.results, "${this.config.executionId}-result")
storeAsFile(config.compositeStrategy.benchmarkExecutor.results, "$resultsFolder${this.config.executionId}-result")
}
private fun <T> storeAsFile(saveObject: T, filename: String) {
......
......@@ -6,6 +6,9 @@ import io.fabric8.kubernetes.api.model.Service
import io.fabric8.kubernetes.api.model.apps.Deployment
import io.fabric8.kubernetes.api.model.apps.StatefulSet
import io.fabric8.kubernetes.client.NamespacedKubernetesClient
import mu.KotlinLogging
private val logger = KotlinLogging.logger {}
/**
* This class is used to deploy or remove different Kubernetes resources.
......@@ -39,16 +42,32 @@ class K8sManager(private val client: NamespacedKubernetesClient) {
*/
fun remove(resource: KubernetesResource) {
when (resource) {
is Deployment ->
is Deployment -> {
val label = resource.spec.selector.matchLabels["app"]!!
this.client.apps().deployments().delete(resource)
blockUntilPodsDeleted(label)
logger.info { "Deployment '${resource.metadata.name}' deleted." }
}
is Service ->
this.client.services().delete(resource)
is ConfigMap ->
this.client.configMaps().delete(resource)
is StatefulSet ->
is StatefulSet -> {
val label = resource.spec.selector.matchLabels["app"]!!
this.client.apps().statefulSets().delete(resource)
blockUntilPodsDeleted(label)
logger.info { "StatefulSet '$resource.metadata.name' deleted." }
}
is ServiceMonitorWrapper -> resource.delete(client)
else -> throw IllegalArgumentException("Unknown Kubernetes resource.")
}
}
private fun blockUntilPodsDeleted(podLabel: String) {
while (!this.client.pods().withLabel(podLabel).list().items.isNullOrEmpty()) {
logger.info { "Wait for pods with label '$podLabel' to be deleted." }
Thread.sleep(1000)
}
}
}
......@@ -2,28 +2,50 @@ package theodolite.k8s
import mu.KotlinLogging
import org.apache.kafka.clients.admin.AdminClient
import org.apache.kafka.clients.admin.CreateTopicsResult
import org.apache.kafka.clients.admin.NewTopic
import org.apache.kafka.common.errors.TopicExistsException
import java.lang.Thread.sleep
private val logger = KotlinLogging.logger {}
private const val RETRY_TIME = 2000L
/**
* Manages the topics related tasks
* @param kafkaConfig Kafka Configuration as HashMap
* @param kafkaConfig Kafka configuration as a Map
* @constructor Creates a KafkaAdminClient
*/
class TopicManager(private val kafkaConfig: HashMap<String, Any>) {
class TopicManager(private val kafkaConfig: Map<String, Any>) {
/**
* Creates topics.
* @param newTopics List of all Topic that should be created
* Create topics.
* @param newTopics Collection of all topic that should be created
*/
fun createTopics(newTopics: Collection<NewTopic>) {
var kafkaAdmin: AdminClient = AdminClient.create(this.kafkaConfig)
val result = kafkaAdmin.createTopics(newTopics)
result.all().get()// wait for the future object
val kafkaAdmin: AdminClient = AdminClient.create(this.kafkaConfig)
lateinit var result: CreateTopicsResult
do {
var retryCreation = false
try {
result = kafkaAdmin.createTopics(newTopics)
result.all().get() // wait for the future to be completed
} catch (e: Exception) { // TopicExistsException
logger.warn(e) { "Error during topic creation." }
logger.debug { e } // TODO remove due to attached exception to warn log?
logger.info { "Remove existing topics." }
delete(newTopics.map { topic -> topic.name() }, kafkaAdmin)
logger.info { "Will retry the topic creation in ${RETRY_TIME/1000} seconds." }
sleep(RETRY_TIME)
retryCreation = true
}
} while (retryCreation)
logger.info {
"Topics created finished with result: ${
result.values().map { it -> it.key + ": " + it.value.isDone }
"Topics creation finished with result: ${
result
.values()
.map { it -> it.key + ": " + it.value.isDone }
.joinToString(separator = ",")
} "
}
......@@ -31,24 +53,60 @@ class TopicManager(private val kafkaConfig: HashMap<String, Any>) {
}
/**
* Removes topics.
* @param topics List of names with the topics to remove.
* Remove topics.
* @param topics Collection of names for the topics to remove.
*/
fun removeTopics(topics: List<String>) {
var kafkaAdmin: AdminClient = AdminClient.create(this.kafkaConfig)
try {
val result = kafkaAdmin.deleteTopics(topics)
result.all().get() // wait for the future object
logger.info {
"\"Topics deletion finished with result: ${
result.values().map { it -> it.key + ": " + it.value.isDone }
.joinToString(separator = ",")
} "
val kafkaAdmin: AdminClient = AdminClient.create(this.kafkaConfig)
val currentTopics = kafkaAdmin.listTopics().names().get()
delete(currentTopics.filter{ matchRegex(it, topics) }, kafkaAdmin)
kafkaAdmin.close()
}
/**
* This function checks whether one string in `topics` can be used as prefix of a regular expression to create the string `existingTopic`
*
* @param existingTopic string for which should be checked if it could be created
* @param topics list of string which are used as possible prefixes to create `existingTopic`
* @return true, `existingTopics` matches a created regex, else false
*/
private fun matchRegex(existingTopic: String, topics: List<String>): Boolean {
for (t in topics) {
val regex = t.toRegex()
if (regex.matches(existingTopic)) {
return true
}
} catch (e: Exception) {
logger.error { "Error while removing topics: $e" }
logger.debug { "Existing topics are: ${kafkaAdmin.listTopics()}." }
}
kafkaAdmin.close()
return false
}
private fun delete(topics: List<String>, kafkaAdmin: AdminClient) {
var deleted = false
while (!deleted) {
try {
val result = kafkaAdmin.deleteTopics(topics)
result.all().get() // wait for the future to be completed
logger.info {
"Topics deletion finished with result: ${
result.values().map { it -> it.key + ": " + it.value.isDone }
.joinToString(separator = ",")
}"
}
} catch (e: Exception) {
logger.error(e) { "Error while removing topics: $e" }
logger.info { "Existing topics are: ${kafkaAdmin.listTopics().names().get()}." }
}
val toDelete = topics.filter { kafkaAdmin.listTopics().names().get().contains(it) }
if (toDelete.isNullOrEmpty()) {
deleted = true
} else {
logger.info { "Deletion of Kafka topics failed, will retry in ${RETRY_TIME/1000} seconds." }
sleep(RETRY_TIME)
}
}
}
}
......@@ -12,7 +12,7 @@ import io.fabric8.kubernetes.api.model.KubernetesResource
* @param variableName *(optional)* The variable name to be patched
*
*
* **For example** to patch the load dimension of a workload generator, the Patcher should be created as follow:
* **For example** to patch the load dimension of a load generator, the patcher should be created as follow:
*
* k8sResource: `uc-1-workload-generator.yaml`
* container: `workload`
......
......@@ -2,7 +2,6 @@ package theodolite.patcher
import io.fabric8.kubernetes.api.model.Container
import io.fabric8.kubernetes.api.model.EnvVar
import io.fabric8.kubernetes.api.model.EnvVarSource
import io.fabric8.kubernetes.api.model.KubernetesResource
import io.fabric8.kubernetes.api.model.apps.Deployment
......@@ -39,7 +38,9 @@ class EnvVarPatcher(
val x = container.env.filter { envVar -> envVar.name == k }
if (x.isEmpty()) {
val newVar = EnvVar(k, v, EnvVarSource())
val newVar = EnvVar()
newVar.name = k
newVar.value = v
container.env.add(newVar)
} else {
x.forEach {
......
......@@ -4,12 +4,13 @@ import theodolite.execution.BenchmarkExecutor
import theodolite.strategies.restriction.LowerBoundRestriction
import theodolite.strategies.restriction.RestrictionStrategy
import theodolite.strategies.searchstrategy.BinarySearch
import theodolite.strategies.searchstrategy.FullSearch
import theodolite.strategies.searchstrategy.LinearSearch
import theodolite.strategies.searchstrategy.SearchStrategy
import theodolite.util.Results
/**
* Factory for creating [SearchStrategy] and [RestrictionStrategy] Strategies.
* Factory for creating [SearchStrategy] and [RestrictionStrategy] strategies.
*/
class StrategyFactory {
......@@ -24,6 +25,7 @@ class StrategyFactory {
*/
fun createSearchStrategy(executor: BenchmarkExecutor, searchStrategyString: String): SearchStrategy {
return when (searchStrategyString) {
"FullSearch" -> FullSearch(executor)
"LinearSearch" -> LinearSearch(executor)
"BinarySearch" -> BinarySearch(executor)
else -> throw IllegalArgumentException("Search Strategy $searchStrategyString not found")
......
......@@ -5,12 +5,13 @@ import theodolite.util.Resource
import theodolite.util.Results
/**
* The Lower Bound Restriction sets the lower bound of the resources to be examined to the value
* The [LowerBoundRestriction] sets the lower bound of the resources to be examined to the value
* needed to successfully execute the next smaller load.
*
* @param results [Result] object used as a basis to restrict the resources.
*/
class LowerBoundRestriction(results: Results) : RestrictionStrategy(results) {
override fun apply(load: LoadDimension, resources: List<Resource>): List<Resource> {
val maxLoad: LoadDimension? = this.results.getMaxBenchmarkedLoad(load)
var lowerBound: Resource? = this.results.getMinRequiredInstances(maxLoad)
......@@ -19,4 +20,5 @@ class LowerBoundRestriction(results: Results) : RestrictionStrategy(results) {
}
return resources.filter { x -> x.get() >= lowerBound.get() }
}
}
package theodolite.strategies.searchstrategy
import mu.KotlinLogging
import theodolite.execution.BenchmarkExecutor
import theodolite.util.LoadDimension
import theodolite.util.Resource
private val logger = KotlinLogging.logger {}
/**
* Binary-search-like implementation for determining the smallest suitable number of instances.
*
......@@ -32,6 +35,8 @@ class BinarySearch(benchmarkExecutor: BenchmarkExecutor) : SearchStrategy(benchm
}
// special case: length == 1 or 2
if (lower == upper) {
val res = resources[lower]
logger.info { "Running experiment with load '${load.get()}' and resources '${res.get()}'" }
if (this.benchmarkExecutor.runExperiment(load, resources[lower])) return lower
else {
if (lower + 1 == resources.size) return -1
......@@ -41,6 +46,8 @@ class BinarySearch(benchmarkExecutor: BenchmarkExecutor) : SearchStrategy(benchm
// apply binary search for a list with
// length > 2 and adjust upper and lower depending on the result for `resources[mid]`
val mid = (upper + lower) / 2
val res = resources[mid]
logger.info { "Running experiment with load '${load.get()}' and resources '${res.get()}'" }
if (this.benchmarkExecutor.runExperiment(load, resources[mid])) {
if (mid == lower) {
return lower
......
package theodolite.strategies.searchstrategy
import mu.KotlinLogging
import theodolite.execution.BenchmarkExecutor
import theodolite.util.LoadDimension
import theodolite.util.Resource
private val logger = KotlinLogging.logger {}
/**
* [SearchStrategy] that executes experiment for provides resources in a linear-search-like fashion, but **without
* stopping** once a suitable resource amount is found.
*
* @see LinearSearch for a SearchStrategy that stops once a suitable resource amount is found.
*
* @param benchmarkExecutor Benchmark executor which runs the individual benchmarks.
*/
class FullSearch(benchmarkExecutor: BenchmarkExecutor) : SearchStrategy(benchmarkExecutor) {
override fun findSuitableResource(load: LoadDimension, resources: List<Resource>): Resource? {
var minimalSuitableResources: Resource? = null;
for (res in resources) {
logger.info { "Running experiment with load '${load.get()}' and resources '${res.get()}'" }
val result = this.benchmarkExecutor.runExperiment(load, res)
if (result && minimalSuitableResources != null) {
minimalSuitableResources = res
}
}
return minimalSuitableResources
}
}
package theodolite.strategies.searchstrategy
import mu.KotlinLogging
import theodolite.execution.BenchmarkExecutor
import theodolite.util.LoadDimension
import theodolite.util.Resource
private val logger = KotlinLogging.logger {}
/**
* Linear-search-like implementation for determining the smallest suitable number of instances.
*
......@@ -13,6 +16,8 @@ class LinearSearch(benchmarkExecutor: BenchmarkExecutor) : SearchStrategy(benchm
override fun findSuitableResource(load: LoadDimension, resources: List<Resource>): Resource? {
for (res in resources) {
logger.info { "Running experiment with load '${load.get()}' and resources '${res.get()}'" }
if (this.benchmarkExecutor.runExperiment(load, res)) return res
}
return null
......
......@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import io.quarkus.runtime.annotations.RegisterForReflection
import org.apache.kafka.clients.admin.NewTopic
import kotlin.properties.Delegates
import kotlin.reflect.KProperty
/**
* Configuration of Kafka connection.
......@@ -23,15 +24,6 @@ class KafkaConfig {
*/
lateinit var topics: List<TopicWrapper>
/**
* Get all current Kafka topics.
*
* @return the list of topics.
*/
fun getKafkaTopics(): List<NewTopic> {
return topics.map { topic -> NewTopic(topic.name, topic.numPartitions, topic.replicationFactor) }
}
/**
* Wrapper for a topic definition.
*/
......@@ -51,5 +43,25 @@ class KafkaConfig {
* The replication factor of this topic
*/
var replicationFactor by Delegates.notNull<Short>()
/**
* If remove only, this topic would only used to delete all topics, which has the name of the topic as a prefix.
*/
var removeOnly by DelegatesFalse()
}
}
/**
* Delegates to initialize a lateinit boolean to false
*/
@RegisterForReflection
class DelegatesFalse {
private var state = false
operator fun getValue(thisRef: Any?, property: KProperty<*>): Boolean {
return state
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) {
state = value
}
}
......@@ -40,23 +40,26 @@ class Results {
* @param load the [LoadDimension]
*
* @return the smallest suitable number of resources. If the experiment was not executed yet,
* a @see Resource with the constant Int.MAX_VALUE as value is returned. If no experiments have been marked as either successful or unsuccessful
* a @see Resource with the constant Int.MAX_VALUE as value is returned.
* If no experiments have been marked as either successful or unsuccessful
* yet, a Resource with the constant value Int.MIN_VALUE is returned.
*/
fun getMinRequiredInstances(load: LoadDimension?): Resource? {
if (this.results.isEmpty()) return Resource(Int.MIN_VALUE, emptyList())
if (this.results.isEmpty()) {
return Resource(Int.MIN_VALUE, emptyList())
}
var requiredInstances: Resource? = Resource(Int.MAX_VALUE, emptyList())
var minRequiredInstances: Resource? = Resource(Int.MAX_VALUE, emptyList())
for (experiment in results) {
// Get all successful experiments for requested load
if (experiment.key.first == load && experiment.value) {
if (requiredInstances == null) {
requiredInstances = experiment.key.second
} else if (experiment.key.second.get() < requiredInstances.get()) {
requiredInstances = experiment.key.second
if (minRequiredInstances == null || experiment.key.second.get() < minRequiredInstances.get()) {
// Found new smallest resources
minRequiredInstances = experiment.key.second
}
}
}
return requiredInstances
return minRequiredInstances
}
/**
......@@ -70,13 +73,11 @@ class Results {
fun getMaxBenchmarkedLoad(load: LoadDimension): LoadDimension? {
var maxBenchmarkedLoad: LoadDimension? = null
for (experiment in results) {
if (experiment.value) {
if (experiment.key.first.get() <= load.get()) {
if (maxBenchmarkedLoad == null) {
maxBenchmarkedLoad = experiment.key.first
} else if (maxBenchmarkedLoad.get() < experiment.key.first.get()) {
maxBenchmarkedLoad = experiment.key.first
}
if (experiment.key.first.get() <= load.get()) {
if (maxBenchmarkedLoad == null) {
maxBenchmarkedLoad = experiment.key.first
} else if (maxBenchmarkedLoad.get() < experiment.key.first.get()) {
maxBenchmarkedLoad = experiment.key.first
}
}
}
......
......@@ -26,4 +26,6 @@ kafkaConfig:
topics:
- name: "input"
numPartitions: 40
replicationFactor: 1
\ No newline at end of file
replicationFactor: 1
- name: "theodolite-.*"
removeOnly: True
\ No newline at end of file
package theodolite.strategies.restriction
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import theodolite.util.LoadDimension
import theodolite.util.Resource
import theodolite.util.Results
internal class LowerBoundRestrictionTest {
@Test
fun testNoPreviousResults() {
val results = Results()
val strategy = LowerBoundRestriction(results)
val load = buildLoadDimension(10000)
val resources = listOf(
buildResourcesDimension(1),
buildResourcesDimension(2),
buildResourcesDimension(3)
)
val restriction = strategy.apply(load, resources)
assertEquals(3, restriction.size)
assertEquals(resources, restriction)
}
@Test
fun testWithSuccessfulPreviousResults() {
val results = Results()
results.setResult(10000, 1, true)
results.setResult(20000, 1, false)
results.setResult(20000, 2, true)
val strategy = LowerBoundRestriction(results)
val load = buildLoadDimension(30000)
val resources = listOf(
buildResourcesDimension(1),
buildResourcesDimension(2),
buildResourcesDimension(3)
)
val restriction = strategy.apply(load, resources)
assertEquals(2, restriction.size)
assertEquals(resources.subList(1,3), restriction)
}
@Test
@Disabled
fun testWithNoSuccessfulPreviousResults() {
// This test is currently not implemented this way, but might later be the desired behavior.
val results = Results()
results.setResult(10000, 1, true)
results.setResult(20000, 1, false)
results.setResult(20000, 2, false)
results.setResult(20000, 3, false)
val strategy = LowerBoundRestriction(results)
val load = buildLoadDimension(30000)
val resources = listOf(
buildResourcesDimension(1),
buildResourcesDimension(2),
buildResourcesDimension(3)
)
val restriction = strategy.apply(load, resources)
assertEquals(0, restriction.size)
assertEquals(emptyList<Resource>(), restriction)
}
@Test
fun testNoPreviousResults2() {
val results = Results()
results.setResult(10000, 1, true)
results.setResult(20000, 2, true)
results.setResult(10000, 1, false)
results.setResult(20000, 2, true)
val minRequiredInstances = results.getMinRequiredInstances(LoadDimension(20000, emptyList()))
assertNotNull(minRequiredInstances)
assertEquals(2, minRequiredInstances!!.get())
}
@Test
@Disabled
fun testMinRequiredInstancesWhenNotSuccessful() {
// This test is currently not implemented this way, but might later be the desired behavior.
val results = Results()
results.setResult(10000, 1, true)
results.setResult(20000, 2, true)
results.setResult(10000, 1, false)
results.setResult(20000, 2, false)
val minRequiredInstances = results.getMinRequiredInstances(LoadDimension(20000, emptyList()))
assertNotNull(minRequiredInstances)
assertEquals(2, minRequiredInstances!!.get())
}
private fun buildLoadDimension(load: Int): LoadDimension {
return LoadDimension(load, emptyList())
}
private fun buildResourcesDimension(resources: Int): Resource {
return Resource(resources, emptyList())
}
private fun Results.setResult(load: Int, resources: Int, successful: Boolean) {
this.setResult(
Pair(
buildLoadDimension(load),
buildResourcesDimension(resources)
),
successful)
}
}
\ No newline at end of file
package theodolite.util
import io.quarkus.test.junit.QuarkusTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
@QuarkusTest
internal class ResultsTest {
@Test
fun testMinRequiredInstancesWhenSuccessful() {
val results = Results()
results.setResult(10000, 1, true)
results.setResult(10000, 2, true)
results.setResult(20000, 1, false)
results.setResult(20000, 2, true)
val minRequiredInstances = results.getMinRequiredInstances(LoadDimension(20000, emptyList()))
assertNotNull(minRequiredInstances)
assertEquals(2, minRequiredInstances!!.get())
}
@Test
@Disabled
fun testMinRequiredInstancesWhenNotSuccessful() {
// This test is currently not implemented this way, but might later be the desired behavior.
val results = Results()
results.setResult(10000, 1, true)
results.setResult(10000, 2, true)
results.setResult(20000, 1, false)
results.setResult(20000, 2, false)
val minRequiredInstances = results.getMinRequiredInstances(LoadDimension(20000, emptyList()))
assertNotNull(minRequiredInstances)
assertEquals(2, minRequiredInstances!!.get())
}
private fun Results.setResult(load: Int, resources: Int, successful: Boolean) {
this.setResult(
Pair(
LoadDimension(load, emptyList()),
Resource(resources, emptyList())
),
successful
)
}
@Test
fun testGetMaxBenchmarkedLoadWhenAllSuccessful() {
val results = Results()
results.setResult(10000, 1, true)
results.setResult(10000, 2, true)
val test1 = results.getMaxBenchmarkedLoad(LoadDimension(100000, emptyList()))!!.get()
assertEquals(10000, test1)
}
@Test
fun testGetMaxBenchmarkedLoadWhenLargestNotSuccessful() {
val results = Results()
results.setResult(10000, 1, true)
results.setResult(10000, 2, true)
results.setResult(20000, 1, false)
val test2 = results.getMaxBenchmarkedLoad(LoadDimension(100000, emptyList()))!!.get()
assertEquals(20000, test2)
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment