Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Redis: Sentinel connection mode. Pick "Sentinel" in the connection form, list the Sentinel nodes, and set the master name; TablePro resolves the current master through the Sentinel quorum and re-resolves automatically on failover (#1021)
- File > Backup Dump… and Restore Dump… for PostgreSQL and Redshift connections, running `pg_dump -Fc` and `pg_restore --no-owner --no-acl` with live progress, cancel, SSH tunnel reuse, and custom binary paths under Settings > Terminal > CLI Paths (#1211).

### Changed
Expand Down
69 changes: 69 additions & 0 deletions Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// HiredisSentinelTransport.swift
// RedisDriverPlugin
//
// Production SentinelTransport backed by short-lived hiredis connections.
//

import Foundation
import OSLog

private let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "HiredisSentinelTransport")

struct HiredisSentinelTransport: SentinelTransport {
let sslConfig: RedisSSLConfig

init(sslConfig: RedisSSLConfig = RedisSSLConfig()) {
self.sslConfig = sslConfig
}

func queryMasterAddress(
masterName: String,
at sentinel: SentinelHostPort,
sentinelUsername: String?,
sentinelPassword: String?
) async throws -> SentinelMasterReply {
let connection = RedisPluginConnection(
host: sentinel.host,
port: sentinel.port,
username: sentinelUsername?.nonEmptyOrNil,
password: sentinelPassword?.nonEmptyOrNil,
database: 0,
sslConfig: sslConfig
)

try await connection.connect()
defer { connection.disconnect() }

let reply = try await connection.executeCommand([
"SENTINEL", "get-master-addr-by-name", masterName,
])

if case .error(let message) = reply {
logger.debug("Sentinel \(sentinel.host):\(sentinel.port) replied with error: \(message)")
throw RedisPluginError(code: 0, message: message)
}

let tokens = Self.extractTokens(from: reply)
return try RedisSentinelResolver.parseMasterReplyTokens(tokens, from: sentinel)
}

static func extractTokens(from reply: RedisReply) -> [String?]? {
switch reply {
case .null:
return nil
case .array(let items):
if items.isEmpty { return nil }
return items.map { item -> String? in
if case .null = item { return nil }
return item.stringValue
}
default:
return [reply.stringValue]
}
}
}

private extension String {
var nonEmptyOrNil: String? { isEmpty ? nil : self }
}
42 changes: 41 additions & 1 deletion Plugins/RedisDriverPlugin/RedisPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import TableProPluginKit

final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
static let pluginName = "Redis Driver"
static let pluginVersion = "1.0.0"
static let pluginVersion = "1.1.0"
static let pluginDescription = "Redis support via hiredis"
static let capabilities: [PluginCapability] = [.databaseDriver]

Expand All @@ -22,6 +22,46 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
static let iconName = "redis-icon"
static let defaultPort = 6379
static let additionalConnectionFields: [ConnectionField] = [
ConnectionField(
id: "redisMode",
label: String(localized: "Connection Mode"),
defaultValue: "single",
fieldType: .dropdown(options: [
.init(value: "single", label: String(localized: "Single Node")),
.init(value: "sentinel", label: String(localized: "Sentinel")),
]),
section: .connection
),
ConnectionField(
id: "redisSentinelHosts",
label: String(localized: "Sentinel Nodes"),
placeholder: "127.0.0.1:26379",
required: true,
fieldType: .hostList,
section: .connection,
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
),
ConnectionField(
id: "redisSentinelMasterName",
label: String(localized: "Master Group Name"),
placeholder: "mymaster",
defaultValue: "mymaster",
section: .connection,
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
),
ConnectionField(
id: "redisSentinelUsername",
label: String(localized: "Sentinel User"),
section: .connection,
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
),
ConnectionField(
id: "redisSentinelPassword",
label: String(localized: "Sentinel Password"),
fieldType: .secure,
section: .connection,
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
),
ConnectionField(
id: "redisDatabase",
label: String(localized: "Database Index"),
Expand Down
67 changes: 65 additions & 2 deletions Plugins/RedisDriverPlugin/RedisPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,11 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func connect() async throws {
let sslConfig = config.ssl
let redisDb = Int(config.additionalFields["redisDatabase"] ?? "") ?? Int(config.database) ?? 0
let (host, port) = try await resolveDataPlaneAddress(sslConfig: sslConfig)

let conn = RedisPluginConnection(
host: config.host,
port: config.port,
host: host,
port: port,
username: config.username.isEmpty ? nil : config.username,
password: config.password.isEmpty ? nil : config.password,
database: redisDb,
Expand All @@ -79,6 +80,68 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
redisConnection = conn
}

private func resolveDataPlaneAddress(sslConfig: RedisSSLConfig) async throws -> (String, Int) {
let mode = config.additionalFields["redisMode"] ?? "single"
switch mode {
case "sentinel":
return try await resolveSentinelMaster(sslConfig: sslConfig)
default:
return (config.host, config.port)
}
}

private func resolveSentinelMaster(sslConfig: RedisSSLConfig) async throws -> (String, Int) {
let hostsRaw = config.additionalFields["redisSentinelHosts"] ?? ""
let sentinels = RedisSentinelResolver.parseSentinelHostList(hostsRaw, defaultPort: 26_379)
let masterName = (config.additionalFields["redisSentinelMasterName"] ?? "")
.trimmingCharacters(in: .whitespaces)
let sentinelUsername = config.additionalFields["redisSentinelUsername"]
.map { $0.trimmingCharacters(in: .whitespaces) }
.flatMap { $0.isEmpty ? nil : $0 }
let sentinelPassword = config.additionalFields["redisSentinelPassword"]
.flatMap { $0.isEmpty ? nil : $0 }

let resolver = RedisSentinelResolver(
sentinels: sentinels,
masterName: masterName,
sentinelUsername: sentinelUsername,
sentinelPassword: sentinelPassword,
transport: HiredisSentinelTransport(sslConfig: sslConfig)
)

do {
let address = try await resolver.resolveMaster()
Self.logger.info("Sentinel resolved master \(masterName) to \(address.host):\(address.port)")
return (address.host, address.port)
} catch let error as RedisSentinelResolutionError {
throw Self.makeSentinelError(error)
}
}

private static func makeSentinelError(_ error: RedisSentinelResolutionError) -> RedisPluginError {
switch error {
case .noSentinelsConfigured:
return sentinelError(String(localized: "Sentinel mode requires at least one Sentinel node."))
case .emptyMasterName:
return sentinelError(String(localized: "Sentinel mode requires a master name."))
case .masterUnknown(let name, let tried):
let triedList = tried.map { "\($0.host):\($0.port)" }.joined(separator: ", ")
let template = String(localized: "None of the configured Sentinels know master \"%@\". Tried: %@")
return sentinelError(String(format: template, name, triedList))
case .allSentinelsUnreachable(let attempts):
let attemptsList = attempts.map { "\($0.host):\($0.port)" }.joined(separator: ", ")
let template = String(localized: "All Sentinels were unreachable. Tried: %@")
return sentinelError(String(format: template, attemptsList))
case .malformedReply(let sentinel, let detail):
let template = String(localized: "Malformed Sentinel reply from %@:%d (%@)")
return sentinelError(String(format: template, sentinel.host, sentinel.port, detail))
}
}

private static func sentinelError(_ message: String) -> RedisPluginError {
RedisPluginError(code: 0, message: message)
}

func disconnect() {
redisConnection?.disconnect()
redisConnection = nil
Expand Down
159 changes: 159 additions & 0 deletions Plugins/RedisDriverPlugin/RedisSentinelResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//
// RedisSentinelResolver.swift
// RedisDriverPlugin
//
// Resolves the current Redis master address by querying a list of Sentinel nodes.
// Pure Swift; transport I/O is abstracted behind SentinelTransport so the resolution
// algorithm is unit-testable without hiredis.
//

import Foundation

struct SentinelHostPort: Equatable, Sendable, Hashable {
let host: String
let port: Int
}

enum SentinelMasterReply: Equatable, Sendable {
case masterUnknown
case address(SentinelHostPort)
}

enum RedisSentinelResolutionError: Error, Equatable {
case noSentinelsConfigured
case emptyMasterName
case masterUnknown(masterName: String, triedSentinels: [SentinelHostPort])
case allSentinelsUnreachable(attempts: [SentinelHostPort])
case malformedReply(SentinelHostPort, detail: String)
}

protocol SentinelTransport: Sendable {
func queryMasterAddress(
masterName: String,
at sentinel: SentinelHostPort,
sentinelUsername: String?,
sentinelPassword: String?
) async throws -> SentinelMasterReply
}

final class RedisSentinelResolver: @unchecked Sendable {
private let sentinels: [SentinelHostPort]
private let masterName: String
private let sentinelUsername: String?
private let sentinelPassword: String?
private let transport: SentinelTransport

init(
sentinels: [SentinelHostPort],
masterName: String,
sentinelUsername: String?,
sentinelPassword: String?,
transport: SentinelTransport
) {
self.sentinels = sentinels
self.masterName = masterName
self.sentinelUsername = sentinelUsername
self.sentinelPassword = sentinelPassword
self.transport = transport
}

func resolveMaster() async throws -> SentinelHostPort {
guard !sentinels.isEmpty else {
throw RedisSentinelResolutionError.noSentinelsConfigured
}
guard !masterName.isEmpty else {
throw RedisSentinelResolutionError.emptyMasterName
}

var unreachable: [SentinelHostPort] = []
var saidUnknown: [SentinelHostPort] = []

for sentinel in sentinels {
do {
let reply = try await transport.queryMasterAddress(
masterName: masterName,
at: sentinel,
sentinelUsername: sentinelUsername,
sentinelPassword: sentinelPassword
)
switch reply {
case .address(let address):
return address
case .masterUnknown:
saidUnknown.append(sentinel)
}
} catch {
unreachable.append(sentinel)
}
}

if !saidUnknown.isEmpty {
throw RedisSentinelResolutionError.masterUnknown(
masterName: masterName,
triedSentinels: saidUnknown
)
}
throw RedisSentinelResolutionError.allSentinelsUnreachable(attempts: unreachable)
}

static func parseMasterReplyTokens(
_ tokens: [String?]?,
from sentinel: SentinelHostPort
) throws -> SentinelMasterReply {
guard let tokens else {
return .masterUnknown
}
guard tokens.count == 2 else {
throw RedisSentinelResolutionError.malformedReply(
sentinel,
detail: "expected 2-element array, got \(tokens.count)"
)
}
guard let host = tokens[0], !host.isEmpty else {
throw RedisSentinelResolutionError.malformedReply(sentinel, detail: "missing host")
}
guard let portString = tokens[1], let port = parsePort(portString) else {
throw RedisSentinelResolutionError.malformedReply(
sentinel,
detail: "invalid port \(tokens[1] ?? "nil")"
)
}
return .address(SentinelHostPort(host: host, port: port))
}

static func parseSentinelHostList(_ raw: String, defaultPort: Int) -> [SentinelHostPort] {
raw.split(separator: ",").compactMap { part in
let trimmed = part.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
return parseSingleHost(trimmed, defaultPort: defaultPort)
}
}

private static func parsePort(_ string: String) -> Int? {
guard let port = Int(string), (1...65_535).contains(port) else { return nil }
return port
}

private static func parseSingleHost(_ entry: String, defaultPort: Int) -> SentinelHostPort? {
if entry.hasPrefix("[") {
guard let closing = entry.firstIndex(of: "]") else { return nil }
let host = String(entry[entry.index(after: entry.startIndex)..<closing])
guard !host.isEmpty else { return nil }
let afterBracket = entry.index(after: closing)
if afterBracket == entry.endIndex {
return SentinelHostPort(host: host, port: defaultPort)
}
guard entry[afterBracket] == ":" else { return nil }
let portString = String(entry[entry.index(after: afterBracket)...])
guard let port = parsePort(portString) else { return nil }
return SentinelHostPort(host: host, port: port)
}
if let lastColon = entry.lastIndex(of: ":"), !entry[..<lastColon].contains(":") {
let host = String(entry[..<lastColon])
let portString = String(entry[entry.index(after: lastColon)...])
guard !host.isEmpty, let port = parsePort(portString) else { return nil }
return SentinelHostPort(host: host, port: port)
}
return SentinelHostPort(host: entry, port: defaultPort)
}
}
Loading
Loading