Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Consul Health checks #16

Merged
merged 5 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public struct ApplicationHealthChecks: ApplicationHealthChecksProtocol {
/// Get uptime of the system.
/// - Returns: A `HealthCheckItem` representing the application's uptime.
public func uptime() -> HealthCheckItem {
let uptime = Date().timeIntervalSinceReferenceDate - app.launchTime
let uptime = Date().timeIntervalSinceReferenceDate - app.launchTime
return HealthCheckItem(
componentType: .system,
observedValue: uptime,
Expand All @@ -58,6 +58,7 @@ public struct ApplicationHealthChecks: ApplicationHealthChecksProtocol {
break
}
}
result[""] = nil
return result
}
}
11 changes: 7 additions & 4 deletions Sources/HealthChecks/ChecksProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@

import Vapor

/// Groups func for get health check
/// The protocol defines a basic interface for performing health checks against different systems or services.
public protocol ChecksProtocol {
/// Check with setup options
/// - Parameter options: array of `MeasurementType`
/// - Returns: dictionary `[String: HealthCheckItem]`
/// Performs health checks against Consul, returning a dictionary
/// of `HealthCheckItem`s for the specified components.
///
/// - Parameters:
/// - options: An array of `MeasurementType`s indicating which checks to perform.
/// - Returns: A dictionary of `HealthCheckItem`s, keyed by component ID and measurement type.
func check(for options: [MeasurementType]) async -> [String: HealthCheckItem]
}
44 changes: 32 additions & 12 deletions Sources/HealthChecks/ConsulHealthChecks/ConsulConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,41 @@

import Vapor

/// A generic `ConsulConfig` data that can be save in storage.
/// Represents configuration details for connecting to a Consul server.
public struct ConsulConfig {
/// Is a unique identifier of the consul, in the application scope
/// Example: `43119325-63f5-4e14-9175-84e0e296c527`
/// A unique identifier for this Consul configuration within your application.
/// This ID is not related to Consul itself and can be used for internal reference.
/// Example: "43119325-63f5-4e14-9175-84e0e296c527"
public let id: String

/// Consul url
/// Example: `http://127.0.0.1:8500`, `https://xmpl-consul.example.com`
/// The URL of the Consul server to connect to.
/// Example: "http://127.0.0.1:8500", "https://xmpl-consul.example.com"
public let url: String

/// Consul username
/// Example: `username`
/// The username for authenticating with Consul (optional).
/// Example: "username"
public let username: String?

/// Consul password
/// Example: `password`
/// The password for authenticating with Consul (optional).
/// Example: "password"
public let password: String?

/// Initializes a `ConsulConfig` with the specified details.
///
/// - Parameters:
/// - id: The unique identifier for this configuration.
/// - url: The URL of the Consul server.
/// - username: The username for authentication (optional).
/// - password: The password for authentication (optional).
public init(
id: String,
url: String,
username: String? = nil,
password: String? = nil
) {
self.id = id
self.url = url
self.username = username
self.password = password
}
}
43 changes: 22 additions & 21 deletions Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,43 +24,48 @@

import Vapor

/// Service that provides consul health check functionality
/// Service that provides Consul health check functionality
public struct ConsulHealthChecks: ConsulHealthChecksProtocol {
/// Instance of app as `Application`
/// Instance of the application as `Application`
public let app: Application

/// Check with setup options
/// - Parameters:
/// - options: array of `MeasurementType`
/// - Returns: dictionary `[String: HealthCheckItem]`
public func check(for options: [MeasurementType]) async -> [String: HealthCheckItem] {
var dict = ["": HealthCheckItem()]
var result = ["": HealthCheckItem()]
let measurementTypes = Array(Set(options))
let dateNow = Date().timeIntervalSinceReferenceDate
let response = await getStatus()
for type in measurementTypes {
switch type {
case .responseTime:
let result = responseTime(from: response, dateNow)
dict["\(ComponentName.consul):\(MeasurementType.responseTime)"] = result
result["\(ComponentName.consul):\(MeasurementType.responseTime)"] = responseTime(from: response, dateNow)
case .connections:
let result = status(response)
dict["\(ComponentName.consul):\(MeasurementType.connections)"] = result
result["\(ComponentName.consul):\(MeasurementType.connections)"] = status(response)
default:
break
}
}
return dict
result[""] = nil
return result
}

/// Get response for consul
/// - Returns: `ClientResponse`
func getStatus() async -> ClientResponse {
let url = app.consulConfig?.url ?? Constants.consulUrl
guard let url = app.consulConfig?.url else {
app.logger.error("ERROR: Consul URL is not configured.")
return ClientResponse()
}
let path = Constants.consulStatusPath
let uri = URI(string: url + path)
var headers = HTTPHeaders()
if let username = app.consulConfig?.username, !username.isEmpty, let password = app.consulConfig?.password, !password.isEmpty {
if let username = app.consulConfig?.username,
!username.isEmpty,
let password = app.consulConfig?.password,
!password.isEmpty {
headers.basicAuthorization = BasicAuthorization(username: username, password: password)
}
do {
Expand All @@ -75,14 +80,12 @@ public struct ConsulHealthChecks: ConsulHealthChecksProtocol {
/// - Parameter response: `ClientResponse`
/// - Returns: `HealthCheckItem`
func status(_ response: ClientResponse) -> HealthCheckItem {
let url = app.consulConfig?.url ?? Constants.consulUrl
let path = Constants.consulStatusPath
return HealthCheckItem(
componentId: app.consulConfig?.id,
componentType: .component,
status: response.status == .ok ? .pass : .fail,
time: app.dateTimeISOFormat.string(from: Date()),
output: response.status != .ok ? "Error response from uri - \(url + path), with http status - \(response.status)" : nil,
time: response.status == .ok ? app.dateTimeISOFormat.string(from: Date()) : nil,
output: response.status != .ok ? "Error response from consul, with http status - \(response.status)" : nil,
links: nil,
node: nil
)
Expand All @@ -94,16 +97,14 @@ public struct ConsulHealthChecks: ConsulHealthChecksProtocol {
/// - start: `TimeInterval`
/// - Returns: `HealthCheckItem`
func responseTime(from response: ClientResponse, _ start: TimeInterval) -> HealthCheckItem {
let url = app.consulConfig?.url ?? Constants.consulUrl
let path = Constants.consulStatusPath
return HealthCheckItem(
componentId: app.consulConfig?.id,
componentType: .component,
observedValue: Date().timeIntervalSinceReferenceDate - start,
observedValue: response.status == .ok ? Date().timeIntervalSinceReferenceDate - start : 0,
observedUnit: "s",
status: response.status == .ok ? .pass : .fail,
time: app.dateTimeISOFormat.string(from: Date()),
output: response.status != .ok ? "Error response from uri - \(url + path), with http status - \(response.status)" : nil,
time: response.status == .ok ? app.dateTimeISOFormat.string(from: Date()) : nil,
output: response.status != .ok ? "Error response from consul, with http status - \(response.status)" : nil,
links: nil,
node: nil
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@

import Vapor

/// Groups func for get consul health check
/// Protocol defining an interface for performing Consul health checks.
public protocol ConsulHealthChecksProtocol: ChecksProtocol {}
18 changes: 2 additions & 16 deletions Tests/HealthChecksTests/ApplicationHealthChecksTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ final class ApplicationHealthChecksTests: XCTestCase {

let healthChecks = ApplicationHealthChecks(app: app)
let checks = await healthChecks.check(for: [.uptime])

XCTAssertEqual(checks.count, 1)
XCTAssertNotNil(checks)
XCTAssertTrue(checks.keys.contains("uptime"))
guard let uptimeItem = checks["uptime"] else {
Expand All @@ -93,20 +93,6 @@ final class ApplicationHealthChecksTests: XCTestCase {

let healthChecks = ApplicationHealthChecks(app: app)
let checks = await healthChecks.check(for: [.connections])
let expectedResult = [
"": HealthCheckItem(
componentId: nil,
componentType: nil,
observedValue: nil,
observedUnit: nil,
status: nil,
affectedEndpoints: nil,
time: nil,
output: nil,
links: nil,
node: nil
)
]
XCTAssertEqual(checks, expectedResult) // Expect empty result, as .memory is not supported
XCTAssertEqual(checks.count, 0) // Expect empty result, as .memory is not supported
}
}
138 changes: 135 additions & 3 deletions Tests/HealthChecksTests/ConsulHealthChecksTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@
app.consulHealthChecks = ConsulHealthChecksMock()
let consulConfig = ConsulConfig(
id: UUID().uuidString,
url: Constants.consulUrl,
username: "username",
password: "password"
url: Constants.consulUrl
)
app.consulConfig = consulConfig
let result = await app.consulHealthChecks?.check(for: [MeasurementType.responseTime, MeasurementType.connections])
Expand All @@ -46,4 +44,138 @@
XCTAssertEqual(app.consulConfig?.username, consulConfig.username)
XCTAssertEqual(app.consulConfig?.password, consulConfig.password)
}

func testCheckForBothSuccess() async {
let app = Application(.testing)
defer { app.shutdown() }
app.consulConfig = ConsulConfig(
id: String(UUID()),
url: "consul-url"
)
let clientResponse = ClientResponse(status: .ok)
app.clients.use { app in
MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse)
}
let healthChecks = ConsulHealthChecks(app: app)
let check = await healthChecks.check(for: [.responseTime, .connections])
XCTAssertEqual(check.count, 2)

// Response time check
guard let responseTimeCheck = check["\(ComponentName.consul):\(MeasurementType.responseTime)"] else {
return XCTFail("no have key for response time")

Check warning on line 65 in Tests/HealthChecksTests/ConsulHealthChecksTests.swift

View check run for this annotation

Codecov / codecov/patch

Tests/HealthChecksTests/ConsulHealthChecksTests.swift#L65

Added line #L65 was not covered by tests
}
XCTAssertEqual(responseTimeCheck.status, .pass)
guard let observedValue = responseTimeCheck.observedValue else {
return XCTFail("no have observed value")

Check warning on line 69 in Tests/HealthChecksTests/ConsulHealthChecksTests.swift

View check run for this annotation

Codecov / codecov/patch

Tests/HealthChecksTests/ConsulHealthChecksTests.swift#L69

Added line #L69 was not covered by tests
}
XCTAssertGreaterThan(observedValue, 0)
XCTAssertNil(responseTimeCheck.output)

// Connections check
guard let connectionsCheck = check["\(ComponentName.consul):\(MeasurementType.connections)"] else {
return XCTFail("no have key for connections")

Check warning on line 76 in Tests/HealthChecksTests/ConsulHealthChecksTests.swift

View check run for this annotation

Codecov / codecov/patch

Tests/HealthChecksTests/ConsulHealthChecksTests.swift#L76

Added line #L76 was not covered by tests
}
XCTAssertEqual(connectionsCheck.status, .pass)
XCTAssertNil(connectionsCheck.observedValue)
XCTAssertNil(connectionsCheck.output)
}

func testCheckStatusSuccess() async {
let app = Application(.testing)
defer { app.shutdown() }
app.consulConfig = ConsulConfig(
id: String(UUID()),
url: "consul-url"
)
let clientResponse = ClientResponse(status: .ok)
app.clients.use { app in
MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse)
}
let healthChecks = ConsulHealthChecks(app: app)
let response = await healthChecks.getStatus()
let result = healthChecks.status(response)

XCTAssertEqual(result.status, .pass)
XCTAssertNil(result.observedValue)
XCTAssertNil(result.output)
}

func testCheckStatusFail() async {
let app = Application(.testing)
defer { app.shutdown() }
let clientResponse = ClientResponse(status: .badRequest)
let healthChecks = ConsulHealthChecks(app: app)
let response = await healthChecks.getStatus()
let result = healthChecks.status(clientResponse)

XCTAssertEqual(result.status, .fail)
XCTAssertNil(result.observedValue)
XCTAssertNotNil(result.output)
}

func testCheckResponseTimeSuccess() async {
let app = Application(.testing)
defer { app.shutdown() }
app.consulConfig = ConsulConfig(
id: String(UUID()),
url: "consul-url"
)
let clientResponse = ClientResponse(status: .ok)
app.clients.use { app in
MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse)
}
let healthChecks = ConsulHealthChecks(app: app)
let response = await healthChecks.getStatus()

let result = healthChecks.responseTime(from: response, Date().timeIntervalSinceReferenceDate)
XCTAssertEqual(result.status, .pass)
guard let observedValue = result.observedValue else {
return XCTFail("no have observed value")

Check warning on line 133 in Tests/HealthChecksTests/ConsulHealthChecksTests.swift

View check run for this annotation

Codecov / codecov/patch

Tests/HealthChecksTests/ConsulHealthChecksTests.swift#L133

Added line #L133 was not covered by tests
}
XCTAssertGreaterThan(observedValue, 0)
XCTAssertNil(result.output)
}

func testCheckResponseTimeFail() async {
let app = Application(.testing)
defer { app.shutdown() }
let clientResponse = ClientResponse(status: .badRequest)
let healthChecks = ConsulHealthChecks(app: app)

let result = healthChecks.responseTime(from: clientResponse, Date().timeIntervalSinceReferenceDate)
XCTAssertEqual(result.status, .fail)
guard let observedValue = result.observedValue else {
return XCTFail("no have observed value")

Check warning on line 148 in Tests/HealthChecksTests/ConsulHealthChecksTests.swift

View check run for this annotation

Codecov / codecov/patch

Tests/HealthChecksTests/ConsulHealthChecksTests.swift#L148

Added line #L148 was not covered by tests
}
XCTAssertEqual(observedValue, 0)
XCTAssertNotNil(result.output)
}

func testGetStatusSuccessWithAuth() async {
let app = Application(.testing)
defer { app.shutdown() }
app.consulConfig = ConsulConfig(
id: String(UUID()),
url: "https://example.com/status",
username: "user",
password: "password"
)
let clientResponse = ClientResponse(status: .ok)
app.clients.use { app in
MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse)
}
let healthChecks = ConsulHealthChecks(app: app)
let response = await healthChecks.getStatus()

XCTAssertEqual(response.status, .ok)
}

func testCheckHandlesUnsupportedTypes() async {
let app = Application(.testing)
defer { app.shutdown() }

let healthChecks = ConsulHealthChecks(app: app)
let checks = await healthChecks.check(for: [.uptime])
XCTAssertEqual(checks.count, 0) // Expect empty result, as .memory is not supported
}
}
23 changes: 23 additions & 0 deletions Tests/HealthChecksTests/Mocks/MockClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// MockClient.swift
//
//
// Created by Mykhailo Bondarenko on 23.02.2024.
//

import Vapor

struct MockClient: Client {
var eventLoop: EventLoop
var clientResponse: ClientResponse

func send(_ request: ClientRequest) -> EventLoopFuture<ClientResponse> {
self.eventLoop.makeSucceededFuture(self.clientResponse)
}

func delegating(to eventLoop: EventLoop) -> Client {
self
}

Check warning on line 20 in Tests/HealthChecksTests/Mocks/MockClient.swift

View check run for this annotation

Codecov / codecov/patch

Tests/HealthChecksTests/Mocks/MockClient.swift#L18-L20

Added lines #L18 - L20 were not covered by tests
}

extension ClientResponse: @unchecked Sendable {}
Loading