Skip to content

Commit

Permalink
CORE-20012, CORE-20014: Create new plugin for profile commands (#6118)
Browse files Browse the repository at this point in the history
Creates a new plugin for the CLI which allows management of a profile.yaml file. This is used to store various user profiles which consist of key-value pairs of configuration which will be used by the other CLI plugins.
  • Loading branch information
Tom-Fitzpatrick authored May 29, 2024
1 parent 6471a0b commit 5742828
Show file tree
Hide file tree
Showing 9 changed files with 626 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package net.corda.sdk.profile

import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import java.io.File
import java.io.IOException

data class CliProfile(val properties: Map<String, String>)

enum class ProfileKey(val description: String) {
REST_USERNAME("Username for REST API"),
REST_PASSWORD("Password for REST API"),
REST_ENDPOINT("Endpoint for the REST API"),
JDBC_USERNAME("Username for JDBC connection"),
JDBC_PASSWORD("Password for JDBC connection"),
DATABASE_URL("URL for the database");

companion object {
private val validKeys: List<String> by lazy { values().map { it.name.lowercase() } }
private val cachedDescriptions: String by lazy {
values().joinToString("\n") { key ->
"${key.name.lowercase()}: ${key.description},"
}
}

fun isValidKey(key: String): Boolean {
return validKeys.contains(key.lowercase())
}

fun getKeysWithDescriptions(): String {
return cachedDescriptions
}
}
}

object ProfileUtils {
private val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule()
private var profileFile: File

init {
profileFile = File(System.getProperty("user.home"), ".corda/cli/profile.yaml")
}

fun initialize(file: File) {
profileFile = file
}

fun loadProfiles(): Map<String, CliProfile> {
return try {
if (profileFile.exists()) {
val profilesMap = objectMapper.readValue(profileFile, jacksonTypeRef<Map<String, Map<String, String>>>())
profilesMap.mapValues { (_, properties) ->
CliProfile(properties)
}
} else {
emptyMap()
}
} catch (e: JsonProcessingException) {
throw IllegalArgumentException("Invalid profile.yaml file format", e)
}
}

fun saveProfiles(profiles: Map<String, CliProfile>) {
try {
val profilesMap = profiles.mapValues { (_, profile) ->
profile.properties
}
objectMapper.writeValue(profileFile, profilesMap)
} catch (e: IOException) {
throw IOException("Failed to save profiles to file", e)
}
}
}
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ include 'tools:plugins:initial-config'
include 'tools:plugins:initial-rbac'
include 'tools:plugins:network'
include 'tools:plugins:package'
include 'tools:plugins:profile'
include 'tools:plugins:preinstall'
include 'tools:plugins:secret-config'
include 'tools:plugins:topic-config'
Expand Down
67 changes: 67 additions & 0 deletions tools/plugins/profile/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
plugins {
id 'java'
id 'distribution'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.kapt'
id 'corda.cli-plugin-packager'
id 'corda.common-publishing'
}

ext {
releasable = false
}

ext.cordaEnableFormatting = true

group 'net.corda.cli.deployment'

dependencies {
constraints {
implementation(libs.slf4j.v2.api)
}

compileOnly "net.corda.cli.host:api:$pluginHostVersion"

implementation libs.jackson.module.kotlin
implementation libs.jackson.dataformat.yaml
implementation libs.pf4j
implementation project(":libs:configuration:configuration-core")
implementation project(':libs:corda-sdk')
kapt libs.pf4j

testImplementation "net.corda.cli.host:api:$pluginHostVersion"
testImplementation 'org.jetbrains.kotlin:kotlin-stdlib'
testImplementation libs.pf4j
testImplementation project(":testing:test-utilities")
testImplementation project(":testing:packaging-test-utilities")

testImplementation libs.bundles.test
testImplementation 'org.jetbrains.kotlin:kotlin-stdlib'
testRuntimeOnly libs.log4j.slf4j2
}

test {
useJUnitPlatform()
}

cliPlugin {
cliPluginClass = 'net.corda.cli.plugins.profile.ProfilePluginWrapper'
cliPluginDescription = 'Plugin for user-defined profile management.'
}

tasks.named("installDist") {
dependsOn cliPlugin
def homePath = System.properties['user.home']
from cliPlugin
into "$homePath/.corda/cli/plugins"
}

publishing {
publications {
maven(MavenPublication) {
artifactId "${cliPlugin.cliPluginId.get()}-cli-plugin"
groupId project.group
artifact cliPluginTask
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package net.corda.cli.plugins.profile

import net.corda.cli.api.AbstractCordaCliVersionProvider
import net.corda.cli.api.CordaCliPlugin
import net.corda.cli.plugins.profile.commands.CreateProfile
import net.corda.cli.plugins.profile.commands.DeleteProfile
import net.corda.cli.plugins.profile.commands.ListProfile
import net.corda.cli.plugins.profile.commands.UpdateProfile
import org.pf4j.Extension
import org.pf4j.Plugin
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import picocli.CommandLine

class VersionProvider : AbstractCordaCliVersionProvider()

@Suppress("unused")
class ProfilePluginWrapper : Plugin() {

private companion object {
val logger: Logger = LoggerFactory.getLogger(this::class.java.enclosingClass)
}

override fun start() {
logger.debug("starting profile plugin")
}

override fun stop() {
logger.debug("stopping profile plugin")
}

@Extension
@CommandLine.Command(
name = "profile",
subcommands = [
CreateProfile::class,
ListProfile::class,
DeleteProfile::class,
UpdateProfile::class,
],
mixinStandardHelpOptions = true,
description = ["Plugin for profile operations."],
versionProvider = VersionProvider::class
)
class PluginEntryPoint : CordaCliPlugin
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package net.corda.cli.plugins.profile.commands

import net.corda.libs.configuration.secret.SecretEncryptionUtil
import net.corda.sdk.profile.CliProfile
import net.corda.sdk.profile.ProfileKey
import net.corda.sdk.profile.ProfileUtils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import picocli.CommandLine
import picocli.CommandLine.Option
import java.util.UUID

@CommandLine.Command(
name = "create",
description = ["Create a new profile."],
mixinStandardHelpOptions = true
)
class CreateProfile : Runnable {

private companion object {
val logger: Logger = LoggerFactory.getLogger(this::class.java.enclosingClass)
val sysOut: Logger = LoggerFactory.getLogger("SystemOut")
val sysErr: Logger = LoggerFactory.getLogger("SystemErr")
}

@Option(names = ["-n", "--name"], description = ["Profile name"], required = true)
lateinit var profileName: String

@Option(names = ["-p", "--property"], description = ["Profile property (key=value)"], required = true)
lateinit var properties: Array<String>

private val secretEncryptionUtil = SecretEncryptionUtil()
private val salt = UUID.randomUUID().toString()

override fun run() {
logger.debug("Creating profile: $profileName")
val profiles = ProfileUtils.loadProfiles().toMutableMap()

if (profiles.containsKey(profileName)) {
sysOut.info("Profile '$profileName' already exists. Overwrite? (y/n)")
val confirmation = readlnOrNull()
if (confirmation?.lowercase() != "y") {
sysOut.info("Profile creation aborted.")
return
}
}

val profileProperties = mutableMapOf<String, String>()
properties.forEach { property ->
val (key, value) = property.split("=")
if (!ProfileKey.isValidKey(key)) {
val error = "Invalid key '$key'. Allowed keys are:\n ${ProfileKey.getKeysWithDescriptions()}"
sysErr.error(error)
throw IllegalArgumentException(error)
}
if (key.lowercase().contains("password")) {
val encryptedPassword = secretEncryptionUtil.encrypt(value, salt, salt)
profileProperties[key] = encryptedPassword
profileProperties["${key}_salt"] = salt
} else {
profileProperties[key] = value
}
}

profiles[profileName] = CliProfile(profileProperties)

ProfileUtils.saveProfiles(profiles)
sysOut.info("Profile '$profileName' created successfully.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package net.corda.cli.plugins.profile.commands

import net.corda.sdk.profile.ProfileUtils.loadProfiles
import net.corda.sdk.profile.ProfileUtils.saveProfiles
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import picocli.CommandLine
import picocli.CommandLine.Option

@CommandLine.Command(
name = "delete",
description = ["Delete a profile."],
mixinStandardHelpOptions = true
)
class DeleteProfile : Runnable {

private companion object {
val logger: Logger = LoggerFactory.getLogger(this::class.java.enclosingClass)
val sysOut: Logger = LoggerFactory.getLogger("SystemOut")
}

@Option(names = ["-n", "--name"], description = ["Profile name"], required = true)
lateinit var profileName: String

override fun run() {
logger.debug("Deleting profile: $profileName")
val profiles = loadProfiles().toMutableMap()

sysOut.info("Are you sure you want to delete profile '$profileName'? (y/n)")
val confirmation = readlnOrNull()
if (confirmation?.lowercase() != "y") {
sysOut.info("Profile deletion aborted.")
return
}

profiles.remove(profileName)

saveProfiles(profiles)
sysOut.info("Profile '$profileName' deleted.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package net.corda.cli.plugins.profile.commands

import net.corda.libs.configuration.secret.SecretEncryptionUtil
import net.corda.sdk.profile.ProfileUtils.loadProfiles
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import picocli.CommandLine

@CommandLine.Command(
name = "list",
description = ["List all profiles and profile properties."],
mixinStandardHelpOptions = true
)
class ListProfile : Runnable {

private companion object {
val sysOut: Logger = LoggerFactory.getLogger("SystemOut")
}

@CommandLine.Option(names = ["-e", "--encrypted"], description = ["Show passwords in encrypted form"])
var showEncrypted: Boolean = false

private val secretEncryptionUtil = SecretEncryptionUtil()

@Suppress("NestedBlockDepth")
private fun printProfile(profile: Map<String, String>) {
profile.forEach { (key, value) ->
if (key.lowercase().endsWith("_salt")) {
// Skip printing the salt
return@forEach
}

if (key.lowercase().contains("password")) {
val salt = profile["${key}_salt"]
if (salt != null) {
if (showEncrypted) {
sysOut.info(" $key: $value")
} else {
val decryptedPassword = secretEncryptionUtil.decrypt(value, salt, salt)
sysOut.info(" $key: $decryptedPassword")
}
} else {
sysOut.info(" $key: Unable to decrypt password")
}
} else {
sysOut.info(" $key: $value")
}
}
}

override fun run() {
val profiles = loadProfiles().toMutableMap()

if (profiles.isEmpty()) {
sysOut.info("No profiles found.")
} else {
sysOut.info("Available profiles:")
profiles.keys.forEach { profileName ->
sysOut.info("- $profileName")
profiles[profileName]?.let {
printProfile(it.properties)
}
}
}
}
}
Loading

0 comments on commit 5742828

Please sign in to comment.