diff --git a/SeleniumGridNodeMachineAutoscaler/Package.resolved b/SeleniumGridNodeMachineAutoscaler/Package.resolved index bdf8d8b..506abf5 100644 --- a/SeleniumGridNodeMachineAutoscaler/Package.resolved +++ b/SeleniumGridNodeMachineAutoscaler/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "9802e2b29a308fe5162b9b309bddbd9258f741cce1a70edc40ccec72b1597a4c", + "originHash" : "364aa31ddb591204d017baeb5b7ea1fe5c6ab793835d018b2b9582ff8392a7ff", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "60235983163d040f343a489f7e2e77c1918a8bd9", - "version" : "1.26.1" + "revision" : "7dc119c7edf3c23f52638faadb89182861dee853", + "version" : "1.28.0" } }, { @@ -15,8 +15,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/async-kit.git", "state" : { - "revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31", - "version" : "1.20.0" + "revision" : "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", + "version" : "1.21.0" + } + }, + { + "identity" : "automautilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GetAutomaApp/AutomaUtilities", + "state" : { + "branch" : "main", + "revision" : "bc7212b7994ee496b0d3684dd77be92f23961c8f" } }, { @@ -87,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "c059d9c9d08d6654b9a92dda93d9049a278964c6", - "version" : "1.12.0" + "revision" : "20c451f1ad8e344e61ddbb34ef196653d4b73ea6", + "version" : "1.13.0" } }, { @@ -105,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "334e682869394ee239a57dbe9262bff3cd9495bd", - "version" : "3.14.0" + "revision" : "d1c6b70f7c5f19fb0b8750cb8dcdf2ea6e2d8c34", + "version" : "3.15.0" } }, { @@ -114,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "b78796709d243d5438b36e74ce3c5ec2d2ece4d8", - "version" : "1.2.1" + "revision" : "6600888f4cb5bbf1bcac51000f60b2cbd224c91b", + "version" : "1.3.0" } }, { @@ -186,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "385f5bd783ffbfff46b246a7db7be8e4f04c53bd", - "version" : "2.33.0" + "revision" : "737e550e607d82bf15bdfddf158ec61652ce836f", + "version" : "2.34.0" } }, { @@ -204,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "bbadd4b853a33fd78c4ae977d17bb2af15eb3f2a", + "version" : "1.1.0" } }, { @@ -240,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/vapor.git", "state" : { - "revision" : "3636f443474769147828a5863e81a31f6f30e92c", - "version" : "4.115.1" + "revision" : "773ea6a63595ae4f6bc46a366d78769d4cb8b08c", + "version" : "4.117.0" } }, { diff --git a/SeleniumGridNodeMachineAutoscaler/Package.swift b/SeleniumGridNodeMachineAutoscaler/Package.swift index 62c22d9..6765967 100644 --- a/SeleniumGridNodeMachineAutoscaler/Package.swift +++ b/SeleniumGridNodeMachineAutoscaler/Package.swift @@ -4,13 +4,14 @@ import PackageDescription let package = Package( name: "SeleniumGridNodeMachineAutoscaler", platforms: [ - .macOS(.v13) + .macOS(.v15), ], dependencies: [ // 💧 A server-side Swift web framework. .package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"), // 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/GetAutomaApp/AutomaUtilities", branch: "main"), ], targets: [ .executableTarget( @@ -19,6 +20,7 @@ let package = Package( .product(name: "Vapor", package: "vapor"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "AutomaUtilities", package: "AutomaUtilities"), ], swiftSettings: swiftSettings ), diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/AnyEncodable.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/AnyEncodable.swift deleted file mode 100644 index 65fe5b1..0000000 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/AnyEncodable.swift +++ /dev/null @@ -1,61 +0,0 @@ -// AnyEncodable.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Foundation - -struct AnyEncodable: Encodable { - private let value: Any - - init(_ value: Any) { - self.value = value - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - - switch value { - case let v as Bool: - try container.encode(v) - case let v as Int: - try container.encode(v) - case let v as Int8: - try container.encode(v) - case let v as Int16: - try container.encode(v) - case let v as Int32: - try container.encode(v) - case let v as Int64: - try container.encode(v) - case let v as UInt: - try container.encode(v) - case let v as UInt8: - try container.encode(v) - case let v as UInt16: - try container.encode(v) - case let v as UInt32: - try container.encode(v) - case let v as UInt64: - try container.encode(v) - case let v as Double: - try container.encode(v) - case let v as Float: - try container.encode(v) - case let v as String: - try container.encode(v) - case let v as [Any]: - try container.encode(v.map { AnyEncodable($0) }) - case let v as [String: Any]: - try container.encode(v.mapValues { AnyEncodable($0) }) - case Optional.none: - try container.encodeNil() - default: - let context = EncodingError.Context( - codingPath: container.codingPath, - debugDescription: "Unsupported type: \(type(of: value))" - ) - throw EncodingError.invalidValue(value, context) - } - } -} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoCreatorCommand.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoCreatorCommand.swift new file mode 100644 index 0000000..1d45a24 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoCreatorCommand.swift @@ -0,0 +1,23 @@ +// SeleniumGridNodeAutoCreatorCommand.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal struct SeleniumGridNodeAutoCreatorCommand: AsyncCommand { + internal struct Signature: CommandSignature {} + + internal var help: String { + "Auto-creates fly.io SeleniumGrid Node App machines" + } + + internal func run(using context: CommandContext, signature _: Signature) async throws { + let autoCreator = try SeleniumGridNodeAutoCreator( + client: context.application.client, + logger: context.application.logger, + cyclePauseDurationSeconds: 10 + ) + try await autoCreator.autoCreateNodeMachines() + } +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoDestroyerCommand.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoDestroyerCommand.swift index 7d33790..4f8d748 100644 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoDestroyerCommand.swift +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoDestroyerCommand.swift @@ -5,31 +5,34 @@ import Vapor -enum SeleniumGridNodeAutoDestroyerType: String, Codable { +internal enum SeleniumGridNodeAutoDestroyerType: String, Codable { case offMachines case oldMachines } -struct SeleniumGridNodeAutoDestroyerCommand: AsyncCommand { - struct Signature: CommandSignature { +internal struct SeleniumGridNodeAutoDestroyerCommand: AsyncCommand { + internal struct Signature: CommandSignature { @Argument(name: "type") - var type: SeleniumGridNodeAutoDestroyerType.RawValue + internal var type: SeleniumGridNodeAutoDestroyerType.RawValue } - var help: String { + internal var help: String { "Auto destroys fly.io SeleniumGrid Node App machines" } - func run(using context: CommandContext, signature: Signature) async throws { + /// Run auto-destroyer command + /// - Throws: An eror if there was a problem destroying machines + public func run(using context: CommandContext, signature: Signature) async throws { let destroyer = try SeleniumGridNodeAutoDestroyer( logger: context.application.logger, - client: context.application.client + client: context.application.client, + cyclePauseDurationSeconds: 10 ) switch signature.type { case "offMachines": - try await destroyer.autoDestroyAllOffNodeMachines(cyclePauseDuration: 10) + try await destroyer.autoDestroyAllOffNodeMachines() case "oldMachines": - try await destroyer.autoDestroyAllOldNodeMachines(cyclePauseDuration: 10) + try await destroyer.autoDestroyAllOldNodeMachines() default: throw Abort( .internalServerError, diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoScalerCommand.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoScalerCommand.swift deleted file mode 100644 index 2ed95e2..0000000 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Commands/SeleniumGridNodeAutoScalerCommand.swift +++ /dev/null @@ -1,22 +0,0 @@ -// SeleniumGridNodeAutoScalerCommand.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Vapor - -struct SeleniumGridNodeAutoScalerCommand: AsyncCommand { - struct Signature: CommandSignature {} - - var help: String { - "Autoscales fly.io SeleniumGrid Node App machines" - } - - func run(using context: CommandContext, signature _: Signature) async throws { - let autoscaler = try SeleniumGridNodeAutoscaler( - client: context.application.client, - logger: context.application.logger - ) - try await autoscaler.autoscale(cyclePauseDuration: 10) - } -} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Controllers/.gitkeep b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/Controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/CycleSleeper.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/CycleSleeper.swift new file mode 100644 index 0000000..7b67651 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/CycleSleeper.swift @@ -0,0 +1,52 @@ +// CycleSleeper.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal struct CycleSleeper { + internal let config: CycleSleeperConfig + internal let logger: Logger + + internal init(_ config: CycleSleeperConfig, logger: Logger) { + self.config = config + self.logger = logger + } + + internal struct CycleSleeperConfig { + internal let duration: Int + internal let startMessage: String? = nil + internal let completionMessage: String? = nil + } + + /// Sleep for a cycle + /// - Throws: An error if `Task.sleep()` failed for some reason + public func sleep() async throws { + logSleepBetweenCycleStarted() + try await Task.sleep(for: .seconds(config.duration)) + logSleepBetweenCycleCompleted() + } + + private func logSleepBetweenCycleStarted() { + logger.info( + Logger + .Message(stringLiteral: config + .startMessage ?? "Pausing for \(config.duration) before next cycle starts."), + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func logSleepBetweenCycleCompleted() { + logger.info( + Logger + .Message(stringLiteral: config + .completionMessage ?? "Pausing for \(config.duration) before completed."), + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/EnvironmentExtensions.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/EnvironmentExtensions.swift deleted file mode 100644 index 466805a..0000000 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/EnvironmentExtensions.swift +++ /dev/null @@ -1,17 +0,0 @@ -// EnvironmentExtensions.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Vapor - -public extension Environment { - static func getOrThrow(_ key: String) throws -> String { - guard - let value = get(key) - else { - throw Abort(.internalServerError, reason: "Value for key \(key) not found") - } - return value - } -} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/FlyMachinesAPIErrorHandler.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/FlyMachinesAPIErrorHandler.swift new file mode 100644 index 0000000..b0f1591 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/FlyMachinesAPIErrorHandler.swift @@ -0,0 +1,52 @@ +// FlyMachinesAPIErrorHandler.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal struct FlyMachinesAPIErrorHandler { + internal let payload: FlyMachinesAPIErrorHandlerPayload + internal let logger: Logger + + /// Generic handler for handling fly.io machines API errors + /// - Throws: `SeleniumGridNodeMachineAutoscalerError.flyMachinesAPIError` + public func handle() throws { + logFlyAPIError() + try throwFlyMachinesAPIError() + } + + private func logFlyAPIError() { + let finalMetadata = getFlyAPIErrorLogFinalMetadata() + logFlyAPIErrorWithFinalMetadata(finalMetadata: finalMetadata) + } + + private func getFlyAPIErrorLogFinalMetadata() -> Logger.Metadata { + let metadataBase = getFlyAPIErrorLogFinalMetadataBase() + return mergeFlyAPIErrorLogMetadataBaseWithMetadata(base: metadataBase) + } + + private func getFlyAPIErrorLogFinalMetadataBase() -> Logger.Metadata { + [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "error": .string("\(payload.error)") + ] + } + + private func mergeFlyAPIErrorLogMetadataBaseWithMetadata(base: Logger.Metadata) -> Logger + .Metadata + { + payload.metadata.merging(base) { first, _ in first } + } + + private func logFlyAPIErrorWithFinalMetadata(finalMetadata: Logger.Metadata) { + logger.error( + "\(payload.message).", + metadata: finalMetadata + ) + } + + private func throwFlyMachinesAPIError() throws { + throw SeleniumGridNodeMachineAutoscalerError.flyMachinesAPIError(error: payload.error) + } +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/MaxNodeMachinesReachedHandler.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/MaxNodeMachinesReachedHandler.swift new file mode 100644 index 0000000..0e98ffd --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/MaxNodeMachinesReachedHandler.swift @@ -0,0 +1,49 @@ +// MaxNodeMachinesReachedHandler.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal class MaxNodeMachinesReachedHandler: SeleniumGridNodeMachineAutoscaler { + internal let maxNodeMachinesAllowed: Int = 10 + + internal init(logger: Logger, client: any Client) throws { + try super.init(logger: logger, client: client, cyclePauseDurationSeconds: 0) + } + + /// Returns whether maximum allowed machines existing at once reached + /// - Returns: Boolean, whether max node machines were reached or not + /// - Throws: An error if there was a problem getting total node machines + public func reached() async throws -> Bool { + let totalMachines = try await getTotalNodeMachines() + let reached = reachedMaxNodeMachines(totalMachines: totalMachines) + if reached { + logReachedMaxNodeMachines(totalMachines: totalMachines) + } + return reached + } + + private func getTotalNodeMachines() async throws -> Int { + try await getListOfAllNodeMachines().count + } + + private func reachedMaxNodeMachines(totalMachines: Int) -> Bool { + return totalMachines >= maxNodeMachinesAllowed + } + + private func logReachedMaxNodeMachines(totalMachines: Int) { + logger.info( + """ + The threshold of \(maxNodeMachinesAllowed) running node machines reached. + No additional node machines will be created. + """, + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "total_node_machines": .string(String(totalMachines)) + ] + ) + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineCreator.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineCreator.swift new file mode 100644 index 0000000..b55c56e --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineCreator.swift @@ -0,0 +1,159 @@ +// NodeMachineCreator.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import AutomaUtilities +import Vapor + +internal class NodeMachineCreationBase: SeleniumGridNodeAppInteractor, SeleniumGridInteractor { + internal let machineConfiguration: MachinePropertyConfiguration + internal let seleniumGridNodeBase: String + internal let seleniumGridHubBase: String + + internal struct MachinePropertyConfiguration: Content { + internal let region: String + internal var config: MachineConfiguration + } + + internal struct MachineConfiguration: Content { + internal let image: String + internal var skipLaunch: Bool + internal var env: [String: String] + internal let autoDestroy: Bool + internal let restart: [String: String] + internal let guest: MachineGuessConfiguration + + /// Coding keys for `NodeMachineCreationBase` + public enum CodingKeys: String, CodingKey { + case image + case skipLaunch = "skip_launch" + case env + case autoDestroy = "auto_destroy" + case restart + case guest + } + } + + internal struct MachineGuessConfiguration: Content { + internal let cpuKind: String + internal let cpus: Int + internal let memoryMb: Int + + /// Coding keys for `MachineGuessConfiguration` + public enum CodingKeys: String, CodingKey { + case cpuKind = "cpu_kind" + case cpus + case memoryMb = "memory_mb" + } + } + + internal typealias MachineIdentifier = String + + internal init( + logger: Logger, + client: any Client, + seleniumGridHubBase: String + ) throws { + seleniumGridNodeBase = try Environment.getOrThrow("SELENIUM_GRID_NODE_BASE") + self.seleniumGridHubBase = seleniumGridHubBase + machineConfiguration = Self.getCreateNodeMachineConfiguration() + try super.init(logger: logger, client: client) + } + + private static func getCreateNodeMachineConfiguration() -> MachinePropertyConfiguration { + MachinePropertyConfiguration( + region: "jnb", + config: .init( + image: "selenium/node-chrome:latest", + skipLaunch: true, + env: ["SE_OPTS": "--drain-after-session-count 1"], + autoDestroy: false, + restart: [ + "policy": "always" + ], + guest: .init(cpuKind: "shared", cpus: 1, memoryMb: 2_048) + ) + ) + } + + deinit {} +} + +internal class NodeMachineCreator: NodeMachineCreationBase { + /// Create new node machine for Selenium Grid Node app on fly.io + /// - Throws: An error if creating machine fails + public func create() async throws { + try await createImpl() + } + + private func createImpl() async throws { + logCreateNodeMachineStarted() + let machineID = try await createNodeMachine() + try await updateAndStartMachine(machineID: machineID) + } + + private func logCreateNodeMachineStarted() { + logger.info( + "Creating new node machine.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func createNodeMachine() async throws -> MachineIdentifier { + let response = try await getCreateNodeMachineResponse() + try handleInvalidCreateNodeMachineResponse(response: response) + let machineID = try getMachineIDFromCreateMachineResponse(response) + logNodeMachineCreationSuccess(machineID: machineID) + return machineID + } + + private func getCreateNodeMachineResponse() async throws + -> ClientResponse + { + return try await client.post(.init(stringLiteral: payload.nodesAppMachineAPIURL)) { req in + req.headers = .init(flyAPIHTTPRequestAuthenticationHeader) + try req.content.encode(machineConfiguration) + } + } + + private func handleInvalidCreateNodeMachineResponse(response: ClientResponse) throws { + if isInvalidHTTPResponseStatus(status: response.status) { + let error = try decodeErrorFromResponse(response) + try handleFlyMachinesAPIError(payload: .init(message: "Node machine creation failed", error: error)) + } + } + + private func getMachineIDFromCreateMachineResponse(_ response: ClientResponse) throws -> MachineIdentifier { + struct CreateMachineResponseContent: Content { + let id: String + } + return try response.content.decode(CreateMachineResponseContent.self).id + } + + private func logNodeMachineCreationSuccess(machineID: MachineIdentifier) { + logger.info( + "Node machine creation success.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "machine_id": .string(machineID), + ] + ) + } + + private func updateAndStartMachine(machineID: MachineIdentifier) async throws { + try await NodeMachineUpdater( + logger: logger, + client: client, + seleniumGridHubBase: seleniumGridHubBase, + machineID: machineID + ) + .updateNodeHostURLEnvironmentVariable() + + try await sleepBetweenCycle(config: .init(duration: 20)) + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineDeleter.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineDeleter.swift new file mode 100644 index 0000000..025e00a --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineDeleter.swift @@ -0,0 +1,68 @@ +// NodeMachineDeleter.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal class NodeMachineDeleter: SeleniumGridNodeAppInteractor { + internal let machineID: String + + internal init(logger: Logger, client: any Client, machineID: String) throws { + self.machineID = machineID + try super.init(logger: logger, client: client) + } + + /// Delete node machine using fly.io machines API + /// - Throws: An error if deletion of machine failed + public func delete() async throws { + logDeleteNodeMachineStarted() + try await getAndValidateDeleteNodeMachineResponse() + logDeleteNodeMachineSuccess() + } + + private func logDeleteNodeMachineStarted() { + logger.info( + "Deleting node machine started.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "node_machine_id_to_delete": .string(machineID) + ] + ) + } + + private func getAndValidateDeleteNodeMachineResponse() async throws { + let response = try await getDeleteNodeMachineResponse() + try validateDeleteNodeMachineResponseStatus(response: response) + } + + private func getDeleteNodeMachineResponse() async throws -> ClientResponse { + try await client.delete( + .init(stringLiteral: "\(payload.nodesAppMachineAPIURL)/\(machineID)?force=true"), + headers: .init(flyAPIHTTPRequestAuthenticationHeader) + ) + } + + private func validateDeleteNodeMachineResponseStatus(response: ClientResponse) throws { + if isInvalidHTTPResponseStatus(status: response.status) { + try handleInvalidDeleteNodeMachineResponse(response: response) + } + } + + private func handleInvalidDeleteNodeMachineResponse(response: ClientResponse) throws { + let error = try decodeErrorFromResponse(response) + try handleFlyMachinesAPIError(payload: .init(message: "Failed to delete machine", error: error)) + } + + private func logDeleteNodeMachineSuccess() { + logger.info( + "Successfully deleted node machine.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "deletd_node_machine_id": .string(machineID) + ] + ) + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineUpdater.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineUpdater.swift new file mode 100644 index 0000000..7297d4e --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/NodeMachineUpdater.swift @@ -0,0 +1,102 @@ +// NodeMachineUpdater.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import AutomaUtilities +import Vapor + +internal class NodeMachineUpdater: NodeMachineCreationBase { + internal let machineID: MachineIdentifier + + internal init( + logger: Logger, + client: any Client, + seleniumGridHubBase: String, + machineID: MachineIdentifier + ) throws { + self.machineID = machineID + try super.init(logger: logger, client: client, seleniumGridHubBase: seleniumGridHubBase) + } + + /// Update node machnine `SE_NODE_HOST` environment variable to node machine url + /// - Throws: An error if updating machine response failed + public func updateNodeHostURLEnvironmentVariable() async throws { + logUpdateMachineStarted() + let updatedConfig = updateMachineConfiguation() + + let response = try await getUpdateNodeMachineResponse(updatedConfig: updatedConfig) + try handleInvalidUpdateMachineResponse(response: response) + + let updateResponseBody = try getResponseBodyFromUpdateNodeMachineResponse(response) + + logUpdateMachineSuccess(updateResponseBody: updateResponseBody) + } + + private func logUpdateMachineStarted() { + logger.info( + "Updating node machine SE_NODE_HOST url.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "machine_id": .string(machineID) + ] + ) + } + + private func updateMachineConfiguation() -> MachinePropertyConfiguration { + var updatedConfiguration = machineConfiguration + updatedConfiguration.config.env = [ + "SE_OPTS": "--drain-after-session-count 1", + "SE_EVENT_BUS_HOST": seleniumGridHubBase, + "SE_NODE_HOST": "\(machineID).vm.\(seleniumGridNodeBase)" + ] + updatedConfiguration.config.skipLaunch = false + + return updatedConfiguration + } + + private func getUpdateNodeMachineResponse( + updatedConfig: MachinePropertyConfiguration + ) async throws -> ClientResponse { + let uri = getUpdateMachineURI() + + return try await client + .post(uri) { req in + req.headers = .init(flyAPIHTTPRequestAuthenticationHeader) + try req.content.encode(["config": updatedConfig.config]) + } + } + + private func getUpdateMachineURI() -> URI { + URI(stringLiteral: "\(payload.nodesAppMachineAPIURL)/\(machineID)") + } + + private func handleInvalidUpdateMachineResponse(response: ClientResponse) throws { + if isInvalidHTTPResponseStatus(status: response.status) { + try handleFlyMachinesAPIError(payload: .init( + message: "Failed to updated machine node 'SE_NODE_HOST' environment variable to URL of the machine", + error: decodeErrorFromResponse(response) + )) + } + } + + private func logUpdateMachineSuccess(updateResponseBody: ByteBuffer) { + logger.info( + "Updating node 'SE_NODE_HOST' environment variable success. Machine will start automatically.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "machine_identifier": .string(machineID), + "update_machine_response": .string(String(buffer: updateResponseBody)) + ] + ) + } + + private func getResponseBodyFromUpdateNodeMachineResponse(_ response: ClientResponse) throws -> ByteBuffer { + try response + .unwrapBodyOrThrow( + errorMessage: "Failed to get update node machine response for machine with ID '\(machineID)'" + ) + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAppInteractor.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAppInteractor.swift index 6afd896..58ea97e 100644 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAppInteractor.swift +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAppInteractor.swift @@ -3,81 +3,92 @@ // All source code and related assets are the property of GetAutomaApp. // All rights reserved. +import AutomaUtilities import Vapor internal protocol SeleniumGridNodeAppInteractorBase { - var nodesAppMachineAPIURL: String { get } - var flyAPIToken: String { get } - var authHeader: [(String, String)] { get } + var client: any Client { get } + var logger: Logger { get } + var payload: SeleniumGridNodeAppInteractorPayload { get } + var flyAPIHTTPRequestAuthenticationHeader: [(String, String)] { get } +} + +internal struct SeleniumGridNodeAppInteractorPayload: Content { + internal let nodesAppMachineAPIURL: String + internal let flyAPIToken: String +} + +internal extension SeleniumGridNodeAppInteractorBase { + typealias FlyAPIError = [String: String] + + func decodeErrorFromResponse(_ response: ClientResponse) throws -> FlyAPIError { + return try response.content.decode(FlyAPIError.self) + } + + func isInvalidHTTPResponseStatus(status: HTTPStatus) -> Bool { + return status != .ok + } + + func handleFlyMachinesAPIError(payload: FlyMachinesAPIErrorHandlerPayload) throws { + try FlyMachinesAPIErrorHandler(payload: payload, logger: logger).handle() + } +} + +internal struct FlyMachinesAPIErrorHandlerPayload { + internal let message: String + internal let metadata: Logger.Metadata = [:] + internal let error: SeleniumGridNodeAppInteractorBase.FlyAPIError } internal class SeleniumGridNodeAppInteractor: SeleniumGridNodeAppInteractorBase { - let nodesAppMachineAPIURL: String - let flyAPIToken: String - let authHeader: [(String, String)] - let logger: Logger - let client: any Client - - init(logger: Logger, client: any Client) throws { - guard - let flyAPIURL = try URL(string: Environment.getOrThrow("FLY_API_URL")) - else { - throw Abort(.internalServerError) - } + internal let payload: SeleniumGridNodeAppInteractorPayload + internal let flyAPIHTTPRequestAuthenticationHeader: [(String, String)] + internal let logger: Logger + internal let client: any Client + internal init(logger: Logger, client: any Client) throws { self.logger = logger self.client = client - nodesAppMachineAPIURL = "\(flyAPIURL.absoluteString)/v1/apps/automa-web-core-seleniumgrid-node/machines" - flyAPIToken = try Environment.getOrThrow("SELENIUM_GRID_NODE_FLY_APP_API_TOKEN") - authHeader = [("Authorization", "Bearer \(flyAPIToken)")] - } - internal struct NodeMachine: Content { - let id: String - let state: String - let createdAt: Date + let flyAPIURL = try URL.fromString(payload: .init(string: Environment.getOrThrow("FLY_API_URL"))) + let flyAPIToken = try Environment.getOrThrow("SELENIUM_GRID_NODE_FLY_APP_API_TOKEN") - public enum CodingKeys: String, CodingKey { - case id - case state - case createdAt = "created_at" - } - } + flyAPIHTTPRequestAuthenticationHeader = [("Authorization", "Bearer \(flyAPIToken)")] - internal func getListOfAllNodeMachines() async throws -> [NodeMachine] { - logger.info( - "Getting a list of all machines.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - let res = try await client.get( - .init(stringLiteral: nodesAppMachineAPIURL), - headers: .init(authHeader) + payload = .init( + nodesAppMachineAPIURL: "\(flyAPIURL.absoluteString)/v1/apps/automa-web-core-seleniumgrid-node/machines", + flyAPIToken: flyAPIToken ) + } - if res.status != .ok { - let responseContent = try res.content.decode([String: String].self) - logger.info( - "Failed to get a list of all machines in nodes app", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "response_content": .string("\(responseContent)"), - ] - ) - throw Abort(.internalServerError) - } - - let allMachines = try res.content.decode([NodeMachine].self) - - logger.info( - "Got list of all machines.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "total_machines": .string(String(allMachines.count)) - ] + internal func getListOfAllNodeMachines() async throws -> NodeMachines { + try await SeleniumGridNodeAppNodeMachinesFinder( + logger: logger, + client: client, + payload: payload, + flyAPIHTTPRequestAuthenticationHeader: flyAPIHTTPRequestAuthenticationHeader, ) + .getListOfAllNodeMachines() + } - return allMachines + internal func sleepBetweenCycle(config: CycleSleeper.CycleSleeperConfig) async throws { + try await CycleSleeper(config, logger: logger).sleep() } + + deinit {} } + +internal struct NodeMachine: Content { + internal let id: String + internal let state: String + internal let createdAt: Date + + /// Coding keys for `NodeMachine` + public enum CodingKeys: String, CodingKey { + case id + case state + case createdAt = "created_at" + } +} + +internal typealias NodeMachines = [NodeMachine] diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAppNodeMachinesFinder.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAppNodeMachinesFinder.swift new file mode 100644 index 0000000..e8758d5 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAppNodeMachinesFinder.swift @@ -0,0 +1,78 @@ +// SeleniumGridNodeAppNodeMachinesFinder.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal struct SeleniumGridNodeAppNodeMachinesFinder: SeleniumGridNodeAppInteractorBase { + internal let logger: Logger + internal let client: any Client + internal var payload: SeleniumGridNodeAppInteractorPayload + internal var flyAPIHTTPRequestAuthenticationHeader: [(String, String)] + + /// Get a list of all node machines + /// - Throws: An error if there was a problem getting list of all machines from fly.io machines API + /// - Returns: NodeMachines + public func getListOfAllNodeMachines() async throws -> NodeMachines { + return try await getAllNodeMachinesList() + } + + private func getAllNodeMachinesList() async throws -> NodeMachines { + logGetListOfAllMachinesStarted() + let allMachines = try await validateAndGetAllMachines() + logGetListOfAllMachinesSuccess(totalMachines: allMachines.count) + return allMachines + } + + private func logGetListOfAllMachinesStarted() { + logger.info( + "Getting a list of all machines.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func validateAndGetAllMachines() async throws -> NodeMachines { + let response = try await getAllNodeMachinesResponse() + try validateFindAllNodeMachinesResponseStatus(response: response) + + return try findNodeMachineListFromResponse(response) + } + + private func getAllNodeMachinesResponse() async throws -> ClientResponse { + return try await client.get( + .init(stringLiteral: payload.nodesAppMachineAPIURL), + headers: .init(flyAPIHTTPRequestAuthenticationHeader) + ) + } + + private func validateFindAllNodeMachinesResponseStatus(response: ClientResponse) throws { + if isInvalidHTTPResponseStatus(status: response.status) { + try handleInvalidFindAllNodeMachinesResponse(response: response) + } + } + + private func handleInvalidFindAllNodeMachinesResponse(response: ClientResponse) throws { + let error = try decodeErrorFromResponse(response) + try handleFlyMachinesAPIError(payload: .init( + message: "Failed to get a list of all machines in nodes app", + error: error + )) + } + + private func findNodeMachineListFromResponse(_ response: ClientResponse) throws -> NodeMachines { + try response.content.decode(NodeMachines.self) + } + + private func logGetListOfAllMachinesSuccess(totalMachines: Int) { + logger.info( + "Got list of all machines.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "total_machines": .string(String(totalMachines)) + ] + ) + } +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoCreator.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoCreator.swift new file mode 100644 index 0000000..7bde83a --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoCreator.swift @@ -0,0 +1,100 @@ +// SeleniumGridNodeAutoCreator.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import AutomaUtilities +import Vapor + +internal protocol SeleniumGridInteractor { + var client: any Client { get } + var logger: Logger { get } + var seleniumGridHubBase: String { get } +} + +internal class SeleniumGridNodeAutoCreator: SeleniumGridNodeMachineAutoscaler, SeleniumGridInteractor { + internal let seleniumGridHubBase: String + + internal init(client: any Client, logger: Logger, cyclePauseDurationSeconds: Int) throws { + seleniumGridHubBase = try Environment.getOrThrow("SELENIUM_GRID_HUB_BASE") + try super.init(logger: logger, client: client, cyclePauseDurationSeconds: cyclePauseDurationSeconds) + } + + /// Auto-create node machines + /// - Throws: An error if there was a problem auto-creating node machines + public func autoCreateNodeMachines() async throws { + try await autoCreateNodeMachinesImpl() + } + + private func autoCreateNodeMachinesImpl() async throws { + try await autoCreateNodeMachinesBasedOnSessionsInQueue() + try await recursivelyAutoCreateNodeMachines() + } + + private func autoCreateNodeMachinesBasedOnSessionsInQueue() async throws { + logAutoCreateNodeMachinesStarted() + try await handleMaxNodeMachinesReached() + + let totalSessionQueueRequests = try await getTotalSessionQueueRequests() + try await createNodeMachines(totalSessionsInQueue: totalSessionQueueRequests) + } + + private func logAutoCreateNodeMachinesStarted() { + logger.info( + "Node auto-creator cycle started (cycle: \(cycleCount)).", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)") + ] + ) + } + + private func handleMaxNodeMachinesReached() async throws { + if try await MaxNodeMachinesReachedHandler(logger: logger, client: client) + .reached() + { + try await recursivelyAutoCreateNodeMachines() + } + } + + private func recursivelyAutoCreateNodeMachines() async throws { + try await sleepBetweenCycle(config: .init(duration: cyclePauseDurationSeconds)) + cycleCount += 1 + try await autoCreateNodeMachinesImpl() + } + + private func getTotalSessionQueueRequests() async throws -> Int { + try await SeleniumGridSessionQueueRequestsHandler( + client: client, + logger: logger, + seleniumGridHubBase: seleniumGridHubBase + ).getTotalRequests() + } + + private func createNodeMachines(totalSessionsInQueue: Int) async throws { + if totalSessionsInQueue > 0 { + logFoundPendingSessionsInQueue(totalSessions: totalSessionsInQueue) + try await createNodeMachines(amount: totalSessionsInQueue) + } + } + + private func logFoundPendingSessionsInQueue(totalSessions: Int) { + logger.info( + "Found a total of \(totalSessions) pending sessions in queue.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func createNodeMachines(amount: Int) async throws { + for _ in 1 ... amount { + try await createNodeMachine() + } + } + + private func createNodeMachine() async throws { + try await NodeMachineCreator(logger: logger, client: client, seleniumGridHubBase: seleniumGridHubBase).create() + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoDestroyer.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoDestroyer.swift index 6a86af4..208e72c 100644 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoDestroyer.swift +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoDestroyer.swift @@ -5,149 +5,26 @@ import Vapor -internal class SeleniumGridNodeAutoDestroyer: SeleniumGridNodeAppInteractor { - public func autoDestroyAllOldNodeMachines(cyclePauseDuration: Int) async throws { - try await autoDestroyAllOldNodeMachines(cycleCount: 1, cyclePauseDuration: cyclePauseDuration) +internal class SeleniumGridNodeAutoDestroyer: SeleniumGridNodeMachineAutoscalerBase { + /// Auto-destroy all old node machines in Selenium Grid Node app hosted on fly.io + /// - Throws: An error if auto-destroying all old node machines failed + public func autoDestroyAllOldNodeMachines() async throws { + try await SeleniumGridNodeAutoOldMachineDestroyer( + logger: logger, + client: client, + cyclePauseDurationSeconds: cyclePauseDurationSeconds + ).autoDestroyAllOldNodeMachines() } - public func autoDestroyAllOffNodeMachines(cyclePauseDuration: Int) async throws { - try await autoDestroyAllOffNodeMachines(cycleCount: 1, cyclePauseDuration: cyclePauseDuration) + /// Auto-destroy all off node machines in Selenium Grid Node app hosted on fly.io + /// - Throws: An error if auto-destroying all off node machines failed + public func autoDestroyAllOffNodeMachines() async throws { + try await SeleniumGridNodeAutoOffMachineDestroyer( + logger: logger, + client: client, + cyclePauseDurationSeconds: cyclePauseDurationSeconds + ).autoDestroyAllOffNodeMachines() } - private func autoDestroyAllOldNodeMachines( - cycleCount: Int, - cyclePauseDuration: Int - ) async throws { - logger.info( - "Auto destroy all old node machines cycle started (cycle: \(cycleCount))", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - - let allMachines = try await getListOfAllNodeMachines() - try await destroyAllOldNodeMachines(allMachines) - - try await Task.sleep(for: .seconds(cyclePauseDuration)) - try await autoDestroyAllOldNodeMachines( - cycleCount: cycleCount + 1, - cyclePauseDuration: cyclePauseDuration - ) - } - - private func autoDestroyAllOffNodeMachines(cycleCount: Int, cyclePauseDuration: Int) async throws { - logger.info( - "Auto destroy all off node machines cycle started (cycle: \(cycleCount))", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - - let allMachines = try await getListOfAllNodeMachines() - try await destroyAllOffNodeMachines(allMachines) - - try await Task.sleep(for: .seconds(cyclePauseDuration)) - try await autoDestroyAllOffNodeMachines(cycleCount: cycleCount + 1, cyclePauseDuration: cyclePauseDuration) - } - - private func destroyAllOldNodeMachines(_ allMachines: [NodeMachine]) async throws { - if allMachines.count == 0 { - return - } - - let machinesToStop = allMachines.filter { machine in - let identifyAsOldAt = machine.createdAt.addingTimeInterval(60 * 60) - return Date() > identifyAsOldAt - } - - if machinesToStop.count == 0 { - logger.info( - "None of the \(allMachines.count) machines in a considered old. No machines will be destroyed.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - return - } - - logger.info( - "Destroying all old node machines started.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "total_machines_to_destroy": .string(String(machinesToStop.count)) - ] - ) - - for machine in machinesToStop { - try await deleteNodeMachine(id: machine.id) - } - } - - private func destroyAllOffNodeMachines(_ allMachines: [NodeMachine]) async throws { - if allMachines.count == 0 { - return - } - - let machinesToStop = allMachines.filter { machine in - ["stopped", "suspended"].contains(machine.state) - } - - if machinesToStop.count == 0 { - logger.info( - "None of the \(allMachines.count) machines in a stopped or suspended state. No machines will be destroyed.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - return - } - - logger.info( - "Destroying all off node machines started.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "total_machines_to_destroy": .string(String(machinesToStop.count)) - ] - ) - - for machine in machinesToStop { - try await deleteNodeMachine(id: machine.id) - } - } - - private func deleteNodeMachine(id: String) async throws { - logger.info( - "Deleting node machine started.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "node_machine_id_to_delete": .string(id) - ] - ) - - let res = try await client.delete( - .init(stringLiteral: "\(nodesAppMachineAPIURL)/\(id)?force=true"), - headers: .init(authHeader) - ) - - if res.status != .ok { - let responseContent = try res.content.decode([String: String].self) - logger.info( - "Failed to delete machine.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "response_content": .string("\(responseContent)"), - "node_machine_id_to_delete": .string(id) - ] - ) - throw Abort(.internalServerError) - } - - logger.info( - "Successfully deleted node machine.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "deletd_node_machine_id": .string(id) - ] - ) - } + deinit {} } diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoOffMachineDestroyer.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoOffMachineDestroyer.swift new file mode 100644 index 0000000..af59b95 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoOffMachineDestroyer.swift @@ -0,0 +1,86 @@ +// SeleniumGridNodeAutoOffMachineDestroyer.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +internal class SeleniumGridNodeAutoOffMachineDestroyer: SeleniumGridNodeMachineAutoscaler { + /// Auto-destroy off machines + /// - Throws: An error if there was a problem auto-destroying off machines + public func autoDestroyAllOffNodeMachines() async throws { + try await autoDestroyAllOffNodeMachinesImpl() + } + + private func autoDestroyAllOffNodeMachinesImpl() async throws { + try await destroyAllCurrentlyOffNodeMachines() + + try await recursivelyAutoDestroyAllOffNodeMachines() + } + + private func destroyAllCurrentlyOffNodeMachines() async throws { + logAutoDestroyAllOffNodeMachinesStarted() + let allMachines = try await getListOfAllNodeMachines() + try await destroyAllOffNodeMachines(allMachines) + } + + private func logAutoDestroyAllOffNodeMachinesStarted() { + logger.info( + "Auto destroy all off node machines cycle started (cycle: \(cycleCount))", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func destroyAllOffNodeMachines(_ allMachines: NodeMachines) async throws { + if allMachines.isEmpty { + return + } + + let machinesToStop = getAllOffNodeMachines(allMachines) + let totalMachines = allMachines.count + + if machinesToStop.isEmpty { + logNoOffMachines(totalMachines: totalMachines) + return + } + + logDestroyAllOffMachinesStarted(totalMachines: totalMachines) + + for machine in machinesToStop { + try await deleteNodeMachine(id: machine.id) + } + } + + private func getAllOffNodeMachines(_ machines: NodeMachines) -> NodeMachines { + machines.filter { machine in + ["stopped", "suspended"].contains(machine.state) + } + } + + private func logNoOffMachines(totalMachines: Int) { + logger.info( + "None of the \(totalMachines) machines in a stopped or suspended state. No machines will be destroyed.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func logDestroyAllOffMachinesStarted(totalMachines: Int) { + logger.info( + "Destroying all off node machines started.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "total_machines_to_destroy": .string(String(totalMachines)) + ] + ) + } + + private func recursivelyAutoDestroyAllOffNodeMachines() async throws { + try await sleepBetweenCycle(config: .init(duration: cyclePauseDurationSeconds)) + cycleCount += 1 + try await autoDestroyAllOffNodeMachines() + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoOldMachineDestroyer.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoOldMachineDestroyer.swift new file mode 100644 index 0000000..cdb6126 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoOldMachineDestroyer.swift @@ -0,0 +1,76 @@ +// SeleniumGridNodeAutoOldMachineDestroyer.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal class SeleniumGridNodeAutoOldMachineDestroyer: SeleniumGridNodeMachineAutoscaler { + /// Auto-destroy all old node machines on fly.io + /// - Throws: An error if there was a problem auto-destroying all old node machines + public func autoDestroyAllOldNodeMachines() async throws { + try await autoDestroyAllOldNodeMachinesImpl() + } + + private func autoDestroyAllOldNodeMachinesImpl() async throws { + try await destroyAllCurrentlyOldNodeMachines() + + try await recursivelyAutoDestroyAllOldNodeMachines() + } + + private func recursivelyAutoDestroyAllOldNodeMachines() async throws { + try await sleepBetweenCycle(config: .init(duration: cyclePauseDurationSeconds)) + cycleCount += 1 + try await autoDestroyAllOldNodeMachinesImpl() + } + + private func destroyAllCurrentlyOldNodeMachines() async throws { + logAutoDestroyAllOldNodeMachinesStarted() + let allMachines = try await getListOfAllNodeMachines() + try await destroyAllOldNodeMachines(allMachines) + } + + private func logAutoDestroyAllOldNodeMachinesStarted() { + logger.info( + "Auto destroy all old node machines cycle started (cycle: \(cycleCount))", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func destroyAllOldNodeMachines(_ allMachines: [NodeMachine]) async throws { + if allMachines.isEmpty { + return + } + + let machinesToStop = allMachines.filter { machine in + let identifyAsOldAt = machine.createdAt.addingTimeInterval(60 * 60) + return Date() > identifyAsOldAt + } + + if machinesToStop.isEmpty { + logger.info( + "None of the \(allMachines.count) machines in a considered old. No machines will be destroyed.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + return + } + + logger.info( + "Destroying all old node machines started.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "total_machines_to_destroy": .string(String(machinesToStop.count)) + ] + ) + + for machine in machinesToStop { + try await deleteNodeMachine(id: machine.id) + } + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoscaler.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoscaler.swift deleted file mode 100644 index 5273434..0000000 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeAutoscaler.swift +++ /dev/null @@ -1,252 +0,0 @@ -// SeleniumGridNodeAutoscaler.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Vapor - -internal class SeleniumGridNodeAutoscaler: SeleniumGridNodeAppInteractor { - let seleniumGridHubBase: String - let seleniumGridNodeBase: String - - let maxNodeMachinesAllowed: Int = 10 - - init(client: any Client, logger: Logger) throws { - seleniumGridHubBase = try Environment.getOrThrow("SELENIUM_GRID_HUB_BASE") - seleniumGridNodeBase = try Environment.getOrThrow("SELENIUM_GRID_NODE_BASE") - try super.init(logger: logger, client: client) - } - - public func autoscale(cyclePauseDuration: Int) async throws { - try await autoscaleNodes(cyclePauseDuration: cyclePauseDuration) - } - - private func autoscaleNodes(cyclePauseDuration: Int, cycleCount: Int = 1) async throws { - logger.info( - "Node autoscaler cycle started (cycle: \(cycleCount)).", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)") - ] - ) - - if try await maxNodeMachinesReached() { - return - } - - let response = try await getGridSessionQueueReponse() - let totalSessionsInQueue = response.data.sessionsInfo.sessionQueueRequests.count - if totalSessionsInQueue > 0 { - logger.info( - "Found a total of \(totalSessionsInQueue) pending sessions in queue.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - // create a selenium node fly machine for every session - for _ in 1 ... totalSessionsInQueue { - try await createNewSeleniumGridNodeFlyMachine() - } - } - try await Task.sleep(for: .seconds(cyclePauseDuration)) - try await autoscaleNodes(cyclePauseDuration: cyclePauseDuration, cycleCount: cycleCount + 1) - } - - private func maxNodeMachinesReached() async throws -> Bool { - let totalNodeMachines = try await getListOfAllNodeMachines().count - - if totalNodeMachines < maxNodeMachinesAllowed { - return false - } - - logger.info( - "The threshold of \(maxNodeMachinesAllowed) running node machines reached. No additional node machines will be created.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "total_node_machines": .string(String(totalNodeMachines)) - ] - ) - return true - } - - private func getGridSessionQueueReponse() async throws -> SessionQueueResponse { - logger.info( - "Looking for sessions in queue.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - let url = "http://\(seleniumGridHubBase):4444/graphql" - let res = try await client.post(.init(stringLiteral: url)) { req in - try req.content - .encode(SeleniumGridGraphQLQuery(query: "query SessionsInfo { sessionsInfo { sessionQueueRequests }}")) - } - return try res.content.decode(SessionQueueResponse.self) - } - - private func createNewSeleniumGridNodeFlyMachine() async throws { - logger.info( - "Creating new node machine.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - - let machineConfiguration = MachinePropertyConfiguration( - region: "jnb", - config: .init( - image: "selenium/node-chrome:latest", - skipLaunch: true, - env: ["SE_OPTS": "--drain-after-session-count 1"], - autoDestroy: false, - restart: [ - "policy": "always" - ], - guest: .init(cpuKind: "shared", cpus: 1, memoryMb: 2_048) - ) - ) - - let res = try await client.post(.init(stringLiteral: nodesAppMachineAPIURL)) { req in - req.headers = .init(authHeader) - try req.content.encode(machineConfiguration) - } - - if res.status != .ok { - let responseContent = try res.content.decode([String: String].self) - logger.info( - "Node machine creation failed.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "response_content": .string("\(responseContent)") - ] - ) - throw Abort(.internalServerError) - } - - logger.info( - "Node machine creation success.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - ] - ) - - struct CreateMachineResponseContent: Content { - let id: String - } - - let machineIdentifier = try res.content.decode(CreateMachineResponseContent.self).id - try await updateMachineNodeHostURLEnvironmentVariable( - machineIdentifier: machineIdentifier, - machineConfiguration: machineConfiguration, - flyAPIToken: flyAPIToken - ) - - try await Task.sleep(for: .seconds(20)) - } - - private func updateMachineNodeHostURLEnvironmentVariable( - machineIdentifier: String, - machineConfiguration: MachinePropertyConfiguration, - flyAPIToken _: String - ) async throws { - var updatedConfiguration = machineConfiguration - - updatedConfiguration.config.env = [ - "SE_OPTS": "--drain-after-session-count 1", - "SE_EVENT_BUS_HOST": seleniumGridHubBase, - "SE_NODE_HOST": "\(machineIdentifier).vm.\(seleniumGridNodeBase)" - ] - - updatedConfiguration.config.skipLaunch = false - logger.info( - "Machine updated environment.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "updated_configuration": .string("\(updatedConfiguration.config)") - ] - ) - - let res = try await client.post(.init(stringLiteral: "\(nodesAppMachineAPIURL)/\(machineIdentifier)")) { req in - req.headers = .init(authHeader) - try req.content.encode(["config": updatedConfiguration.config]) - } - - if res.status != .ok { - let responseContent = try res.content.decode([String: String].self) - logger.info( - "Failed to updated machine node 'SE_NODE_HOST' environment variable to URL of the machine", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "response_content": .string("\(responseContent)"), - "machine_identifier": .string(machineIdentifier) - ] - ) - throw Abort(.internalServerError) - } - - guard - let body = res.body - else { - throw Abort(.internalServerError) - } - - logger.info( - "Updating node 'SE_NODE_HOST' environment variable success. Machine will start automatically.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "machine_identifier": .string(machineIdentifier), - "update_machine_response": .string(String(buffer: body)) - ] - ) - } - - internal struct SeleniumGridGraphQLQuery: Content { - let query: String - } - - internal struct SessionQueueResponse: Content { - let data: SessionQueueResponseData - } - - internal struct SessionQueueResponseData: Content { - let sessionsInfo: SessionsInfo - } - - internal struct SessionsInfo: Content { - let sessionQueueRequests: [String] - } - - internal struct MachinePropertyConfiguration: Content { - let region: String - var config: MachineConfiguration - } - - internal struct MachineConfiguration: Content { - let image: String - var skipLaunch: Bool - var env: [String: String] - let autoDestroy: Bool - let restart: [String: String] - let guest: MachineGuessConfiguration - - public enum CodingKeys: String, CodingKey { - case image - case skipLaunch = "skip_launch" - case env - case autoDestroy = "auto_destroy" - case restart - case guest - } - } - - internal struct MachineGuessConfiguration: Content { - let cpuKind: String - let cpus: Int - let memoryMb: Int - - public enum CodingKeys: String, CodingKey { - case cpuKind = "cpu_kind" - case cpus - case memoryMb = "memory_mb" - } - } -} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeMachineAutoscaler.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeMachineAutoscaler.swift new file mode 100644 index 0000000..a499170 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeMachineAutoscaler.swift @@ -0,0 +1,26 @@ +// SeleniumGridNodeMachineAutoscaler.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal class SeleniumGridNodeMachineAutoscalerBase: SeleniumGridNodeAppInteractor { + internal let cyclePauseDurationSeconds: Int + internal var cycleCount: Int = 1 + + internal init(logger: Logger, client: any Client, cyclePauseDurationSeconds: Int) throws { + self.cyclePauseDurationSeconds = cyclePauseDurationSeconds + try super.init(logger: logger, client: client) + } + + deinit {} +} + +internal class SeleniumGridNodeMachineAutoscaler: SeleniumGridNodeMachineAutoscalerBase { + internal func deleteNodeMachine(id: String) async throws { + try await NodeMachineDeleter(logger: logger, client: client, machineID: id).delete() + } + + deinit {} +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeMachineAutoscalerError.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeMachineAutoscalerError.swift new file mode 100644 index 0000000..eadc7a5 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridNodeMachineAutoscalerError.swift @@ -0,0 +1,8 @@ +// SeleniumGridNodeMachineAutoscalerError.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +internal enum SeleniumGridNodeMachineAutoscalerError: Error { + case flyMachinesAPIError(error: SeleniumGridNodeAppInteractorBase.FlyAPIError) +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridSessionQueueRequestsHandler.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridSessionQueueRequestsHandler.swift new file mode 100644 index 0000000..b2f9524 --- /dev/null +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/SeleniumGridSessionQueueRequestsHandler.swift @@ -0,0 +1,79 @@ +// SeleniumGridSessionQueueRequestsHandler.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Vapor + +internal struct SeleniumGridSessionQueueRequestsHandler: SeleniumGridInteractor { + internal let client: any Client + internal let logger: Logger + internal let seleniumGridHubBase: String + + /// Get total session requests from selenium grid hub + /// - Throws: An error if there was a problem making a request to Selenium hub + /// - Returns: Total session requests + public func getTotalRequests() async throws -> Int { + let response = try await getGridSessionQueueReponse() + return getTotalSessionQueueRequestsFromResponse(response) + } + + private func getGridSessionQueueReponse() async throws -> SessionQueueResponse { + logGetGridSessionQueueResponseStarted() + let response = try await querySeleniumGridHubForSessionQueueRequests() + return try decodeSessionQueueResponseFromClientResponse(response) + } + + internal struct SessionQueueResponse: Content { + internal let data: SessionQueueResponseData + } + + internal struct SessionQueueResponseData: Content { + internal let sessionsInfo: SessionsInfo + } + + internal struct SessionsInfo: Content { + internal let sessionQueueRequests: [String] + } + + private func logGetGridSessionQueueResponseStarted() { + logger.info( + "Looking for sessions in queue.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func querySeleniumGridHubForSessionQueueRequests() async throws -> ClientResponse { + let uri = getSeleniumGridGraphQLURI() + let query = getSessionQueueRequestsGraphQLQuery() + return try await client.post(uri) { req in + try req.content.encode(query) + } + } + + private func getSeleniumGridGraphQLURI() -> URI { + URI(stringLiteral: "http://\(seleniumGridHubBase):4444/graphql") + } + + private func getSessionQueueRequestsGraphQLQuery() -> SeleniumGridGraphQLQuery { + SeleniumGridGraphQLQuery(query: "query SessionsInfo { sessionsInfo { sessionQueueRequests }}") + } + + internal struct SeleniumGridGraphQLQuery: Content { + internal let query: String + } + + private func decodeSessionQueueResponseFromClientResponse(_ response: ClientResponse) throws + -> SessionQueueResponse + { + return try response.content.decode(SessionQueueResponse.self) + } + + private func getTotalSessionQueueRequestsFromResponse(_ response: SessionQueueResponse) + -> Int + { + response.data.sessionsInfo.sessionQueueRequests.count + } +} diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/configure.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/configure.swift index adf8c70..ea1df19 100644 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/configure.swift +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/configure.swift @@ -6,10 +6,10 @@ import Vapor // configures your application -public func configure(_ app: Application) async throws { +internal func configure(_ app: Application) throws { // uncomment to serve files from /Public folder // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) - app.asyncCommands.use(SeleniumGridNodeAutoScalerCommand(), as: "autoscaler") + app.asyncCommands.use(SeleniumGridNodeAutoCreatorCommand(), as: "autocreator") app.asyncCommands.use(SeleniumGridNodeAutoDestroyerCommand(), as: "autodestroyer") } diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/entrypoint.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/entrypoint.swift index 04a3806..cad2faa 100644 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/entrypoint.swift +++ b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/entrypoint.swift @@ -9,8 +9,10 @@ import NIOPosix import Vapor @main -enum Entrypoint { - static func main() async throws { +internal enum Entrypoint { + /// The main function that initializes and runs the application. + /// - Throws: Throws an error if the application fails to start or execute. + public static func main() async throws { var env = try Environment.detect() try LoggingSystem.bootstrap(from: &env) @@ -27,7 +29,7 @@ enum Entrypoint { // metadata: ["success": .stringConvertible(executorTakeoverSuccess)]) do { - try await configure(app) + try configure(app) try await app.execute() } catch { app.logger.report(error: error) diff --git a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/routes.swift b/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/routes.swift deleted file mode 100644 index 2edcc8f..0000000 --- a/SeleniumGridNodeMachineAutoscaler/Sources/SeleniumGridNodeMachineAutoscaler/routes.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Vapor - -func routes(_ app: Application) throws { - app.get { req async in - "It works!" - } - - app.get("hello") { req async -> String in - "Hello, world!" - } -} diff --git a/SeleniumGridNodeMachineAutoscaler/Tests/SeleniumGridNodeMachineAutoscalerTests/SeleniumGridNodeMachineAutoscalerTests.swift b/SeleniumGridNodeMachineAutoscaler/Tests/SeleniumGridNodeMachineAutoscalerTests/SeleniumGridNodeMachineAutoscalerTests.swift index eb4f1ce..2d35b85 100644 --- a/SeleniumGridNodeMachineAutoscaler/Tests/SeleniumGridNodeMachineAutoscalerTests/SeleniumGridNodeMachineAutoscalerTests.swift +++ b/SeleniumGridNodeMachineAutoscaler/Tests/SeleniumGridNodeMachineAutoscalerTests/SeleniumGridNodeMachineAutoscalerTests.swift @@ -1,16 +1,22 @@ +// SeleniumGridNodeMachineAutoscalerTests.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + @testable import SeleniumGridNodeMachineAutoscaler -import VaporTesting import Testing +import VaporTesting @Suite("App Tests") -struct SeleniumGridNodeMachineAutoscalerTests { +internal struct SeleniumGridNodeMachineAutoscalerTests { + /// Example Test @Test("Test Hello World Route") - func helloWorld() async throws { + public func helloWorld() async throws { try await withApp(configure: configure) { app in - try await app.testing().test(.GET, "hello", afterResponse: { res async in + try await app.testing().test(.GET, "hello") { res async in #expect(res.status == .ok) #expect(res.body.string == "Hello, world!") - }) + } } } } diff --git a/SeleniumGridNodeMachineAutoscaler/infra/autoscaler.toml b/SeleniumGridNodeMachineAutoscaler/infra/autoscaler.toml index 408f977..493e6da 100644 --- a/SeleniumGridNodeMachineAutoscaler/infra/autoscaler.toml +++ b/SeleniumGridNodeMachineAutoscaler/infra/autoscaler.toml @@ -1,8 +1,3 @@ -# fly.toml app configuration file generated for automa-web-core-seleniumgrid-node-autoscaler on 2025-09-01T18:06:11+02:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - app = 'automa-web-core-seleniumgrid-node-autoscaler' primary_region = 'jnb' @@ -15,13 +10,13 @@ auto_stop_machines = 'off' auto_start_machines = false min_machines_running = 0 processes = [ - 'app', + 'machines_auto_creator', 'off_machines_auto_destroyer', 'old_machines_auto_destroyer', ] [processes] -app = "./SeleniumGridNodeMachineAutoscaler autoscaler" +machines_auto_creator = "./SeleniumGridNodeMachineAutoscaler autocreator" off_machines_auto_destroyer = "./SeleniumGridNodeMachineAutoscaler autodestroyer offMachines" old_machines_auto_destroyer = "./SeleniumGridNodeMachineAutoscaler autodestroyer oldMachines"