Skip to content

Commit

Permalink
feat(ios): dynamic provider
Browse files Browse the repository at this point in the history
  • Loading branch information
Malinskiy committed Oct 10, 2024
1 parent 4e765c7 commit db39487
Show file tree
Hide file tree
Showing 20 changed files with 628 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.malinskiy.marathon.config.vendor.android.TestAccessConfiguration
import com.malinskiy.marathon.config.vendor.android.TestParserConfiguration
import com.malinskiy.marathon.config.vendor.android.ThreadingConfiguration
import com.malinskiy.marathon.config.vendor.apple.AppleTestBundleConfiguration
import com.malinskiy.marathon.config.vendor.apple.DeviceProvider
import com.malinskiy.marathon.config.vendor.apple.ios.LifecycleConfiguration
import com.malinskiy.marathon.config.vendor.apple.ios.PermissionsConfiguration
import com.malinskiy.marathon.config.vendor.apple.RsyncConfiguration
Expand Down Expand Up @@ -155,6 +156,7 @@ sealed class VendorConfiguration {
*/
data class IOSConfiguration(
@JsonProperty("bundle") val bundle: AppleTestBundleConfiguration? = null,
@JsonProperty("deviceProvider") val deviceProvider: DeviceProvider = DeviceProvider.Static(),
@JsonProperty("devices") val devicesFile: File? = null,
@JsonProperty("ssh") val ssh: SshConfiguration = SshConfiguration(),

Expand Down Expand Up @@ -186,6 +188,7 @@ sealed class VendorConfiguration {

data class MacosConfiguration(
@JsonProperty("bundle") val bundle: AppleTestBundleConfiguration? = null,
@JsonProperty("deviceProvider") val deviceProvider: DeviceProvider = DeviceProvider.Static(),
@JsonProperty("devices") val devicesFile: File? = null,
@JsonProperty("ssh") val ssh: SshConfiguration = SshConfiguration(),

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.malinskiy.marathon.config.vendor.apple

import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.io.File

@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes(
JsonSubTypes.Type(value = DeviceProvider.Static::class, names = arrayOf("static", "marathondevices")),
JsonSubTypes.Type(value = DeviceProvider.Dynamic::class, name = "dynamic"),
)
sealed class DeviceProvider {
data class Static(
@JsonProperty("path") val path: File? = null
) : DeviceProvider()

data class Dynamic(
@JsonProperty("host") val host: String = "127.0.0.1",
@JsonProperty("port") val port: Int = 5037,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ sealed interface Transport {
@JsonProperty("authentication") val authentication: SshAuthentication? = null,
@JsonProperty("checkReachability") val checkReachability: Boolean = true,
) : Transport

fun id(): String {
return when (this) {
Local -> "local"
is Ssh -> "${addr}:${port}"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,13 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator
import com.fasterxml.jackson.module.kotlin.KotlinFeature
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import com.malinskiy.marathon.apple.configuration.AppleTarget
import com.malinskiy.marathon.apple.configuration.Marathondevices
import com.malinskiy.marathon.apple.configuration.Transport
import com.malinskiy.marathon.apple.configuration.Worker
import com.malinskiy.marathon.config.vendor.apple.SshAuthentication
import org.amshove.kluent.shouldBeEqualTo
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File

class MarathondevicesTest {
class StaticTest {
lateinit var mapper: ObjectMapper

@BeforeEach
Expand All @@ -36,7 +32,7 @@ class MarathondevicesTest {

@Test
fun testSample1() {
val src = File(MarathondevicesTest::class.java.getResource("/fixtures/marathondevices/sample_1.yaml").file)
val src = File(StaticTest::class.java.getResource("/fixtures/marathondevices/sample_1.yaml").file)
val actual = mapper.readValue<Marathondevices>(src)

actual shouldBeEqualTo Marathondevices(
Expand Down
7 changes: 7 additions & 0 deletions vendor/vendor-apple/ios/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ plugins {

dependencies {
implementation(project(":vendor:vendor-apple:base"))

testImplementation(TestLibraries.kluent)
testImplementation(TestLibraries.assertk)
testImplementation(TestLibraries.mockitoKotlin)
testImplementation(TestLibraries.junit5)
testImplementation(TestLibraries.coroutinesTest)
testRuntimeOnly(TestLibraries.jupiterEngine)
}

setupDeployment()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import com.malinskiy.marathon.apple.cmd.FileBridge
import com.malinskiy.marathon.apple.configuration.AppleTarget
import com.malinskiy.marathon.apple.configuration.Marathondevices
import com.malinskiy.marathon.apple.configuration.Transport
import com.malinskiy.marathon.apple.configuration.Worker
import com.malinskiy.marathon.apple.device.ConnectionFactory
import com.malinskiy.marathon.config.Configuration
import com.malinskiy.marathon.config.vendor.VendorConfiguration
import com.malinskiy.marathon.config.vendor.apple.DeviceProvider.Static
import com.malinskiy.marathon.device.Device
import com.malinskiy.marathon.device.DeviceProvider
import com.malinskiy.marathon.exceptions.NoDevicesException
Expand All @@ -33,20 +33,27 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.onSuccess
import kotlinx.coroutines.channels.produce
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.newFixedThreadPoolContext
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.apache.commons.text.StringSubstitutor
import org.apache.commons.text.lookup.StringLookupFactory
import java.io.File
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.CoroutineContext

//Should not use udid as key to support multiple devices with the same udid across transports
typealias AppleDeviceId = String

class AppleSimulatorProvider(
private val configuration: Configuration,
private val vendorConfiguration: VendorConfiguration.IOSConfiguration,
Expand All @@ -66,7 +73,7 @@ class AppleSimulatorProvider(

private var monitoringJob: Job? = null

private val devices = ConcurrentHashMap<String, AppleSimulatorDevice>()
private val devices = ConcurrentHashMap<AppleDeviceId, AppleSimulatorDevice>()
private val channel: Channel<DeviceProvider.DeviceEvent> = unboundedChannel()
private val connectionFactory = ConnectionFactory(
configuration,
Expand All @@ -76,58 +83,125 @@ class AppleSimulatorProvider(
)
private val environmentVariableSubstitutor = StringSubstitutor(StringLookupFactory.INSTANCE.environmentVariableStringLookup())
private val simulatorFactory = SimulatorFactory(configuration, vendorConfiguration, testBundleIdentifier, gson, track, timer)
private val deviceTracker = DeviceTracker()

override fun subscribe() = channel

private val sourceMutex = Mutex()
private lateinit var sourceChannel: ReceiveChannel<Marathondevices>

override suspend fun initialize() {
logger.debug("Initializing AppleSimulatorProvider")

//Fail fast if we use static provider with no devices available
val file = vendorConfiguration.devicesFile ?: File(System.getProperty("user.dir"), "Marathondevices")
val devicesWithEnvironmentVariablesReplaced = environmentVariableSubstitutor.replace(file.readText())
val workers: List<Worker> = try {
objectMapper.readValue<Marathondevices>(devicesWithEnvironmentVariablesReplaced).workers
} catch (e: JsonMappingException) {
throw NoDevicesException("Invalid Marathondevices file ${file.absolutePath} format", e)
}
if (workers.isEmpty()) {
throw NoDevicesException("No workers found in the ${file.absolutePath}")
}
val hosts: Map<Transport, List<AppleTarget>> = mutableMapOf<Transport, List<AppleTarget>>().apply {
workers.map {
put(it.transport, it.devices)
var initialMarathonfile: Marathondevices? = null
if (vendorConfiguration.deviceProvider is Static || file.exists()) {
val devicesWithEnvironmentVariablesReplaced = environmentVariableSubstitutor.replace(file.readText())
val marathonfile = try {
objectMapper.readValue<Marathondevices>(devicesWithEnvironmentVariablesReplaced)
} catch (e: JsonMappingException) {
throw NoDevicesException("Invalid Marathondevices file ${file.absolutePath} format", e)
}
if (marathonfile.workers.isEmpty()) {
throw NoDevicesException("No workers found in the ${file.absolutePath}")
}
initialMarathonfile = marathonfile
}

logger.debug { "Establishing communication with [${hosts.keys.joinToString()}]" }
val deferred = hosts.map { (transport, targets) ->
async {
initializeForTransport(targets, transport)
}
monitoringJob = if (initialMarathonfile != null) {
startStaticProvider(initialMarathonfile)
} else {
startDynamicProvider()
}
awaitAll(*deferred.toTypedArray())
}

monitoringJob = launch {
private fun startDynamicProvider(): Job {
return launch {
while (isActive) {
var recreate = mutableSetOf<AppleDevice>()
devices.values.forEach { device ->
if (!device.commandExecutor.connected) {
channel.send(DeviceProvider.DeviceEvent.DeviceDisconnected(device))
device.dispose()
recreate.add(device)
sourceMutex.withLock {
sourceChannel = produce {
//TODO: dynamic provider
}
}
val byTransport = recreate.groupBy { it.transport }
byTransport.forEach { (transport, devices) ->
val plan = ProvisioningPlan(existingSimulators = devices.map {
logger.warn { "Re-provisioning ${it.serialNumber}" }
it.udid
}.toSet(), emptyList(), emptySet())
createExisting(plan, transport)

while(true) {
val channelResult = sourceChannel.tryReceive()
channelResult.onSuccess { processUpdate(it) }
reconnect()
delay(16)
}
recreate.clear()
}
}
}

private fun startStaticProvider(marathondevices: Marathondevices): Job {
return launch {
processUpdate(marathondevices)
while (isActive) {
reconnect()
delay(16)
}
}
Unit
}

private suspend fun reconnect() {
var recreate = mutableSetOf<AppleDevice>()
devices.values.forEach { device ->
if (!device.commandExecutor.connected) {
channel.send(DeviceProvider.DeviceEvent.DeviceDisconnected(device))
device.dispose()
recreate.add(device)
}
}
val byTransport = recreate.groupBy { it.transport }
byTransport.forEach { (transport, devices) ->
val plan = ProvisioningPlan(existingSimulators = devices.map {
logger.warn { "Re-provisioning ${it.serialNumber}" }
it.udid
}.toSet(), emptyList(), emptySet())
createExisting(plan, transport)
}
recreate.clear()
}

private suspend fun processUpdate(initialMarathonfile: Marathondevices) {
val update: Map<Transport, List<TrackingUpdate>> = deviceTracker.update(initialMarathonfile)
val deferred = update.mapNotNull { (transport, updates) ->
logger.debug { "Processing updates from $transport:\n${updates.joinToString(separator = "\n", prefix = "- ")}" }

val connected = updates.filterIsInstance<TrackingUpdate.Connected>()
val disconnected = updates.filterIsInstance<TrackingUpdate.Disconnected>()

disconnected.mapNotNull { it ->
val appleId = when (it.target) {
is AppleTarget.Physical -> toAppleId(it.target.udid, transport)
is AppleTarget.Simulator -> toAppleId(it.target.udid, transport)
AppleTarget.Host -> {
logger.warn { "host devices are not support by apple simulator provider" }
null
}

is AppleTarget.SimulatorProfile -> {
logger.warn { "simulator profile devices do not support disconnect" }
null
}
}
appleId?.let { devices[it] }
}.forEach {
notifyDisconnected(it)
dispose(it)
}

if (connected.isNotEmpty()) {
//If we already connected to this host then reuse existing
async {
initializeForTransport(connected.map { it.target }, transport)
}
} else null
}

awaitAll(*deferred.toTypedArray())
}

override suspend fun borrow(): Device {
Expand Down Expand Up @@ -160,7 +234,8 @@ class AppleSimulatorProvider(
return@async
}
val simulator = createSimulator(udid, transport, commandExecutor, fileBridge)
connect(transport, simulator)
val id: AppleDeviceId = toAppleId(udid, transport)
connect(id, simulator)
}
}
}
Expand Down Expand Up @@ -201,7 +276,8 @@ class AppleSimulatorProvider(
return@async
}
val simulator = createSimulator(udid, transport, commandExecutor, fileBridge)
connect(transport, simulator)
val id: AppleDeviceId = toAppleId(udid, transport)
connect(id, simulator)
} else {
logger.error { "Failed to create simulator for profile $profile" }
}
Expand All @@ -210,6 +286,8 @@ class AppleSimulatorProvider(
}
}

private fun toAppleId(udid: String?, transport: Transport) = "$udid@${transport.id()}"

private fun verifySimulatorCanBeProvisioned(
simctlListDevicesOutput: SimctlListDevicesOutput,
profile: AppleTarget.SimulatorProfile,
Expand Down Expand Up @@ -251,14 +329,18 @@ class AppleSimulatorProvider(
}
}
val availableUdids = simulatorDevices.keys
val usedUdids = mutableSetOf<String>()
val usedUdids = devices.values
.filter { it.transport == transport }
.map { it.udid }
.toMutableSet()
simulators.forEach {
if (!availableUdids.contains(it.udid)) {
logger.error { "udid ${it.udid} is not available at $transport" }
} else {
usedUdids.add(it.udid)
}
}

val unusedDevices = simulatorDevices.filterKeys { !usedUdids.contains(it) }.toMutableMap()
val reuseUdid = mutableSetOf<String>()
val createProfiles = mutableListOf<AppleTarget.SimulatorProfile>()
Expand Down Expand Up @@ -352,9 +434,8 @@ class AppleSimulatorProvider(
device.dispose()
}

private fun connect(transport: Transport, device: AppleSimulatorDevice) {
//Should not use udid as key here to support multiple devices with the same udid across transports
devices.put(device.serialNumber, device)
private fun connect(id: AppleDeviceId, device: AppleSimulatorDevice) {
devices.put(id, device)
?.let {
logger.error("replaced existing device $it with new $device.")
dispose(it)
Expand All @@ -369,14 +450,4 @@ class AppleSimulatorProvider(
private fun notifyDisconnected(device: AppleSimulatorDevice) = launch(context = coroutineContext) {
channel.send(element = DeviceProvider.DeviceEvent.DeviceDisconnected(device))
}

// private fun printFailingSimulatorSummary() {
// simulators
// .map { "${it.udid}@${it.transport}" to (RemoteSimulatorConnectionCounter.get(it.udid) - 1) }
// .filter { it.second > 0 }
// .sortedByDescending { it.second }
// .forEach {
// logger.debug(String.format("%3d %s", it.second, it.first))
// }
// }
}
Loading

0 comments on commit db39487

Please sign in to comment.