refactor-git-trust (#4)

Co-authored-by: Michael Jerger <michael.jerger@meissa-gmbh.de>
Co-authored-by: ansgarz <ansgar.zwick@meissa.de>
Reviewed-on: #4
This commit is contained in:
zwa 2023-08-15 19:40:53 +00:00
parent c1267ac17e
commit 3f4d5bb4d6
13 changed files with 275 additions and 77 deletions

View file

@ -0,0 +1,38 @@
# ADR: We implement domain services static
Domain services can be implemented either as object (and composed like done in spring / example1 ) or with extension
function and composed static (see example2).
## example1
```kotlin
class DesktopServie(val aptApi: AptApi, val prov: Prov) {
fun provisionIdeDesktop(onlyModules: List<String>? = null) {
prov.task {
if (onlyModules == null) {
aptApi.aptInstall(OPEN_VPM)
}
}
}
}
```
## example2
```kotlin
fun Prov.provisionIdeDesktop(onlyModules: List<String>? = null) {
if (onlyModules == null) {
aptInstall(OPEN_VPM)
}
}
```
## Decission
We use extension function and composed static.
## Reason
1. Similar to composed objects we can easily mock `aptInstall` in tests. Both solutions are equivalent.
2. Inheritance in case of composed objects we can solve by static composition.
3. Object composition we can solve by static composition.
There is no reason left to change the current implementd pattern.

77
doc/dev/architecture.md Normal file
View file

@ -0,0 +1,77 @@
## Initialization
```mermaid
sequenceDiagram
actor user
participant app as Application
participant ds as DesktopService
participant gtr as KnownHost
participant pa as CliArgumentsParser
participant cr as DesktopConfigRepository
participant ut as CliUtils
participant su as ProvsWithSudo
user ->> app: main
activate app
app ->> pa: parseCommands
app ->> cr: getConfig(configFileName)
app ->> ut: createProvInstance(cmd.target)
app ->> su: ensureSudoWithoutPassword(cmd.target.remoteTarget()?.password)
app ->> ds: provisionDesktopCommand(cmd, config)
activate ds
ds ->> gtr: values()
gtr -->> ds: List(KnownHost)
deactivate ds
deactivate app
```
## Domain
```mermaid
classDiagram
namespace configuration {
class TargetCliCommand {
val target: String,
val passwordInteractive: Boolean = false
}
class ConfigFileName {
fileName: String
}
}
namespace desktop {
class DesktopCliCommand {
}
class DesktopConfig {
val ssh: SshKeyPairSource? = null,
val gpg: KeyPairSource? = null,
val gitUserName: String? = null,
val gitEmail: String? = null,
}
class DesktopType {
val name: String
}
class DesktopOnlyModule {
<<enum>>
FIREFOX, VERIFY
}
class KnownHost {
hostName: String,
hostKeys: List<HostKey>
}
}
DesktopCliCommand "1" *-- "1" DesktopType: type
DesktopCliCommand "1" *-- "1" TargetCliCommand: target
DesktopCliCommand "1" *-- "1" ConfigFileName: configFile
DesktopCliCommand "1" *-- "..n" DesktopOnlyModule: onlyModules
```

View file

@ -12,6 +12,7 @@ import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.provisionKeys
import org.domaindrivenarchitecture.provs.framework.ubuntu.user.base.currentUserCanSudoWithoutPassword import org.domaindrivenarchitecture.provs.framework.ubuntu.user.base.currentUserCanSudoWithoutPassword
import org.domaindrivenarchitecture.provs.framework.ubuntu.user.base.whoami import org.domaindrivenarchitecture.provs.framework.ubuntu.user.base.whoami
internal fun Prov.provisionDesktopCommand(cmd: DesktopCliCommand, conf: DesktopConfig) = task { internal fun Prov.provisionDesktopCommand(cmd: DesktopCliCommand, conf: DesktopConfig) = task {
provisionDesktop( provisionDesktop(
cmd.type, cmd.type,

View file

@ -0,0 +1,35 @@
package org.domaindrivenarchitecture.provs.desktop.domain
/**
* A HostKey should contain space-separated: keytype, key and (optionally) a comment
*
* See: https://man7.org/linux/man-pages/man8/sshd.8.html#SSH_KNOWN_HOSTS_FILE_FORMAT
*/
typealias HostKey = String
open class KnownHost protected constructor(val hostName: String, val hostKeys: List<HostKey>) {
companion object {
val GITHUB = KnownHost(
"github.com", listOf(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl",
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=",
)
)
val GITLAB = KnownHost(
"gitlab.com", listOf(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9",
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=",
)
)
@JvmStatic
protected val values = listOf(GITHUB, GITLAB)
fun values(): List<KnownHost> {
return values
}
}
}

View file

@ -0,0 +1,13 @@
package org.domaindrivenarchitecture.provs.desktop.domain
import org.domaindrivenarchitecture.provs.framework.core.Prov
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.addKnownHost
fun Prov.addKnownHosts(knownHosts: List<KnownHost> = KnownHost.values()) = task {
for (knownHost in knownHosts) {
with(knownHost) {
addKnownHost(hostName, hostKeys, verifyKeys = true)
}
}
}

View file

@ -15,7 +15,9 @@ fun Prov.installDevOps() = task {
installTerraform() installTerraform()
installKubectlAndTools() installKubectlAndTools()
installYq() installYq()
// TODO: the can be removed
installAwsCredentials() installAwsCredentials()
// TODO: the can be removed
installDevOpsFolder() installDevOpsFolder()
} }

View file

@ -3,9 +3,6 @@ package org.domaindrivenarchitecture.provs.framework.ubuntu.git.base
import org.domaindrivenarchitecture.provs.framework.core.Prov import org.domaindrivenarchitecture.provs.framework.core.Prov
import org.domaindrivenarchitecture.provs.framework.core.ProvResult import org.domaindrivenarchitecture.provs.framework.core.ProvResult
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.* import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.*
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.KNOWN_HOSTS_FILE
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.trustHost
import java.io.File
/** /**
@ -41,25 +38,3 @@ fun Prov.gitClone(
} }
} }
fun Prov.trustGithub() = task {
// current fingerprints from https://docs.github.com/en/github/authenticating-to-github/githubs-ssh-key-fingerprints
val fingerprints = setOf(
"SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s github.com", // (RSA)
"SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM github.com", // (ECDSA)
"SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU github.com" // (Ed25519)
)
trustHost("github.com", fingerprints)
}
fun Prov.trustGitlab() = task {
// entries for known_hosts from https://docs.gitlab.com/ee/user/gitlab_com/
val gitlabFingerprints = """
gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf
gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9
gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=
""".trimIndent()
addTextToFile("\n" + gitlabFingerprints + "\n", File(KNOWN_HOSTS_FILE))
}

View file

@ -2,12 +2,9 @@ package org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base
import org.domaindrivenarchitecture.provs.framework.core.Prov import org.domaindrivenarchitecture.provs.framework.core.Prov
import org.domaindrivenarchitecture.provs.framework.core.ProvResult import org.domaindrivenarchitecture.provs.framework.core.ProvResult
import org.domaindrivenarchitecture.provs.framework.core.echoCommandForText import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.*
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.checkFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.createDir
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.createFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.createSecretFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.SshKeyPair import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.SshKeyPair
import java.io.File
const val KNOWN_HOSTS_FILE = "~/.ssh/known_hosts" const val KNOWN_HOSTS_FILE = "~/.ssh/known_hosts"
@ -34,61 +31,39 @@ fun Prov.isHostKnown(hostOrIp: String) : Boolean {
/** /**
* Adds ssh keys for specified host (which also can be an ip-address) to ssh-file "known_hosts" * Adds ssh keys for specified host (which also can be an ip-address) to ssh-file "known_hosts"
* Either add the specified rsaFingerprints or - if null - add automatically retrieved keys. * Either add the specified keys or - if null - add keys automatically retrieved.
* Note: adding keys automatically is vulnerable to a man-in-the-middle attack, thus considered insecure and not recommended. * Note: adding keys automatically is vulnerable to a man-in-the-middle attack, thus considered insecure and not recommended.
*/ */
fun Prov.trustHost(host: String, fingerprintsOfKeysToBeAdded: Set<String>?) = taskWithResult { fun Prov.addKnownHost(host: String, keysToBeAdded: List<String>?, verifyKeys: Boolean = false) = task {
if (isHostKnown(host)) {
return@taskWithResult ProvResult(true, out = "Host already known")
}
if (!checkFile(KNOWN_HOSTS_FILE)) { if (!checkFile(KNOWN_HOSTS_FILE)) {
createDir(".ssh") createDir(".ssh")
createFile(KNOWN_HOSTS_FILE, null) createFile(KNOWN_HOSTS_FILE, null)
} }
if (fingerprintsOfKeysToBeAdded == null) { if (keysToBeAdded == null) {
// auto add keys // auto add keys
cmd("ssh-keyscan $host >> $KNOWN_HOSTS_FILE") cmd("ssh-keyscan $host >> $KNOWN_HOSTS_FILE")
} else { } else {
// logic based on https://serverfault.com/questions/447028/non-interactive-git-clone-ssh-fingerprint-prompt for (key in keysToBeAdded) {
val actualKeys = findSshKeys(host) if (!verifyKeys) {
if (actualKeys == null || actualKeys.size == 0) { addTextToFile("\n$host $key\n", File(KNOWN_HOSTS_FILE))
return@taskWithResult ProvResult(false, out = "No valid keys found for host: $host") } else {
} val validKeys = getSshKeys(host)
val actualFingerprints = getFingerprintsForKeys(actualKeys) if (validKeys?.contains(key) == true) {
for (fingerprintToBeAdded in fingerprintsOfKeysToBeAdded) { addTextToFile("\n$host $key\n", File(KNOWN_HOSTS_FILE))
var indexOfKeyFound = -1 } else {
addResultToEval(ProvResult(false, err = "The following key of host [$host] could not be verified successfully: " + key))
// search for fingerprint in actual fingerprints
for ((i, actualFingerprint) in actualFingerprints.withIndex()) {
if (actualFingerprint.contains(fingerprintToBeAdded)) {
indexOfKeyFound = i
break
} }
} }
if (indexOfKeyFound == -1) {
return@taskWithResult ProvResult(
false,
err = "Fingerprint ($fingerprintToBeAdded) could not be found in actual fingerprints: $actualFingerprints"
)
}
cmd(echoCommandForText(actualKeys.get(indexOfKeyFound) + "\n") + " >> $KNOWN_HOSTS_FILE")
} }
ProvResult(true)
} }
} }
/** /**
* Returns a list of valid ssh keys for the given host (host can also be an ip address) * Returns a list of valid ssh keys for the given host (host can also be an ip address), keys are returned as keytype and key BUT WITHOUT the host name
*/ */
private fun Prov.findSshKeys(host: String): List<String>? { private fun Prov.getSshKeys(host: String, keytype: String? = null): List<String>? {
return cmd("ssh-keyscan $host 2>/dev/null").out?.split("\n")?.filter { x -> "" != x } val keytypeOption = keytype?.let { " -t $keytype " } ?: ""
} val output = cmd("ssh-keyscan $keytypeOption $host 2>/dev/null").out?.trim()
return output?.split("\n")?.filter { x -> "" != x }?.map { x -> x.substringAfter(" ") }
/**
* Returns a list of fingerprints of the given sshKeys; the returning list has same size and order as the specified list of sshKeys
*/
private fun Prov.getFingerprintsForKeys(sshKeys: List<String>): List<String> {
return sshKeys.map { x -> cmd("echo \"$x\" | ssh-keygen -lf -").out ?: "" }
} }

View file

@ -13,7 +13,7 @@ import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
internal class CliTargetCommandKtTest { internal class TargetCliCommandKtTest {
companion object { companion object {
@BeforeAll @BeforeAll

View file

@ -0,0 +1,54 @@
package org.domaindrivenarchitecture.provs.desktop.domain
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.deleteFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.aptInstall
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.KNOWN_HOSTS_FILE
import org.domaindrivenarchitecture.provs.test.defaultTestContainer
import org.domaindrivenarchitecture.provs.test.tags.ContainerTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class KnownHostTest {
@ContainerTest
fun defaultKnownHosts() {
// given
val prov = defaultTestContainer()
prov.task {
aptInstall("ssh")
deleteFile(KNOWN_HOSTS_FILE)
}
// when
val res = prov.addKnownHosts()
// then
assertTrue(res.success)
}
// Subclass of KnownHost for test knownHostSubclass_includes_additional_host
class KnownHostsSubclass(hostName: String, hostKeys: List<HostKey>): KnownHost(hostName, hostKeys) {
companion object {
val ANOTHER_HOST = KnownHostsSubclass("anotherhost.com", listOf("key1"))
fun values(): List<KnownHost> {
return values + ANOTHER_HOST
}
}
}
@Test
fun knownHostSubclass_includes_additional_host() {
// when
val hosts = KnownHostsSubclass.values()
// then
assertTrue(hosts.size > 1)
assertEquals("key1", hosts.last().hostKeys[0])
}
}

View file

@ -1,5 +1,6 @@
package org.domaindrivenarchitecture.provs.framework.ubuntu.git.base package org.domaindrivenarchitecture.provs.framework.ubuntu.git.base
import org.domaindrivenarchitecture.provs.desktop.domain.addKnownHosts
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.checkDir import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.checkDir
import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.aptInstall import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.aptInstall
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.isHostKnown import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.isHostKnown
@ -18,12 +19,10 @@ internal class GitKtTest {
a.aptInstall("openssh-client") a.aptInstall("openssh-client")
// when // when
val res = a.trustGithub() val res = a.addKnownHosts()
val res2 = a.trustGitlab()
// then // then
assertTrue(res.success) assertTrue(res.success)
assertTrue(res2.success)
assertTrue(a.isHostKnown("github.com"), "github.com does not seem to be a known host") assertTrue(a.isHostKnown("github.com"), "github.com does not seem to be a known host")
assertTrue(a.isHostKnown("gitlab.com"), "gitlab.com does not seem to be a known host") assertTrue(a.isHostKnown("gitlab.com"), "gitlab.com does not seem to be a known host")
@ -37,7 +36,7 @@ internal class GitKtTest {
prov.aptInstall("git") prov.aptInstall("git")
// when // when
prov.trustGithub() prov.addKnownHosts()
val res1 = prov.gitClone("https://gitlab.com/domaindrivenarchitecture/not a valid basename.git", "~/") val res1 = prov.gitClone("https://gitlab.com/domaindrivenarchitecture/not a valid basename.git", "~/")
val res2 = prov.gitClone(repo) val res2 = prov.gitClone(repo)
val res3 = prov.gitClone(repo, pullIfExisting = false) val res3 = prov.gitClone(repo, pullIfExisting = false)

View file

@ -1,15 +1,16 @@
package org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base package org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base
import org.domaindrivenarchitecture.provs.framework.core.Secret import org.domaindrivenarchitecture.provs.framework.core.Secret
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.deleteFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.fileContainsText
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.fileContent import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.fileContent
import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.aptInstall
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.* import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.*
import org.domaindrivenarchitecture.provs.test.defaultTestContainer import org.domaindrivenarchitecture.provs.test.defaultTestContainer
import org.domaindrivenarchitecture.provs.test.tags.ContainerTest import org.domaindrivenarchitecture.provs.test.tags.ContainerTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Assertions.assertTrue
internal class SshKtTest { internal class SshKtTest {
@ContainerTest @ContainerTest
fun configureSshKeys_for_ssh_type_rsa() { fun configureSshKeys_for_ssh_type_rsa() {
// given // given
@ -45,4 +46,32 @@ internal class SshKtTest {
val privateSshKeyFileContent = prov.fileContent("~/.ssh/id_ed25519") val privateSshKeyFileContent = prov.fileContent("~/.ssh/id_ed25519")
assertEquals(privateED25519SnakeOilKey() + "\n", privateSshKeyFileContent) assertEquals(privateED25519SnakeOilKey() + "\n", privateSshKeyFileContent)
} }
@ContainerTest
fun addKnownHost() {
// given
val prov = defaultTestContainer()
prov.task {
aptInstall("ssh")
deleteFile(KNOWN_HOSTS_FILE)
}
// when
val res = prov.addKnownHost("github.com", listOf("dummyProtocol dummyKey", "dummyProtocol2 dummyKey2", ))
val res2 = prov.addKnownHost("github.com", listOf("dummyProtocol dummyKey", "dummyProtocol2 dummyKey2", ))
val res3 = prov.addKnownHost("github.com", listOf("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl", ), verifyKeys = true)
val res4 = prov.addKnownHost("github.com", listOf("ssh-ed25519 AAAAC3Nzalwrongkey!!!", ), verifyKeys = true)
// then
assertTrue(res.success)
assertTrue(prov.fileContainsText(KNOWN_HOSTS_FILE, "github.com dummyProtocol dummyKey"))
assertTrue(prov.fileContainsText(KNOWN_HOSTS_FILE, "github.com dummyProtocol2 dummyKey2"))
assertTrue(res2.success)
val keyCount = prov.cmd("grep -o -i dummyKey2 $KNOWN_HOSTS_FILE | wc -l").out?.trim()
assertEquals("1", keyCount)
assertTrue(res3.success)
assertFalse(res4.success)
}
} }