diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6d698f6..ba0e8af85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift b/Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift new file mode 100644 index 000000000..d5177c18e --- /dev/null +++ b/Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift @@ -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 } +} diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index e4ffa17ea..4df236fc5 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -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] @@ -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"), diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index cf65cd4ff..3b55260f5 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -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, @@ -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 diff --git a/Plugins/RedisDriverPlugin/RedisSentinelResolver.swift b/Plugins/RedisDriverPlugin/RedisSentinelResolver.swift new file mode 100644 index 000000000..bdf27eaea --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisSentinelResolver.swift @@ -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).. PluginMetadataSnapshot { + PluginMetadataSnapshot( + displayName: displayName, iconName: iconName, defaultPort: defaultPort, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "brandtest", parameterStyle: .questionMark, + navigationModel: .inPlace, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: ["brandtest"], postConnectActions: [], + brandColorHex: brandColorHex, queryLanguageName: "Q", editorLanguage: .bash, + connectionMode: .network, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: .defaults, schema: .defaults, editor: .defaults, + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: fields, + category: .other, + tagline: "" + ) + ) + } + + @Test("withBranding takes branding from source but keeps self's connection config") + func withBrandingKeepsSelfConnection() { + let plugin = Self.snapshot( + displayName: "PluginName", + iconName: "plugin-icon", + brandColorHex: "#111111", + defaultPort: 7_000, + fields: [Self.pluginField] + ) + let existing = Self.snapshot( + displayName: "ExistingName", + iconName: "existing-icon", + brandColorHex: "#999999", + defaultPort: 8_000, + fields: [Self.existingField] + ) + + let merged = plugin.withBranding(from: existing) + + #expect(merged.displayName == "ExistingName") + #expect(merged.iconName == "existing-icon") + #expect(merged.brandColorHex == "#999999") + + let mergedIds = merged.connection.additionalConnectionFields.map(\.id) + #expect(mergedIds == ["newPluginField"]) + + #expect(merged.defaultPort == 7_000) + } + + @Test("register with preserveIcon keeps the plugin's new fields") + func registerPreserveIconKeepsPluginFields() { + let registry = PluginMetadataRegistry.shared + let typeId = "BrandTestPluginType" + + guard registry.snapshot(forTypeId: typeId) == nil else { + Issue.record("Test type \(typeId) unexpectedly in registry defaults") + return + } + + let existing = Self.snapshot( + displayName: "BrandTest", + iconName: "existing-icon", + brandColorHex: "#111111", + defaultPort: 7_000, + fields: [Self.existingField] + ) + registry.register(snapshot: existing, forTypeId: typeId) + + let pluginSnapshot = Self.snapshot( + displayName: "WrongName", + iconName: "wrong-icon", + brandColorHex: "#222222", + defaultPort: 7_000, + fields: [Self.pluginField] + ) + registry.register(snapshot: pluginSnapshot, forTypeId: typeId, preserveIcon: true) + + let resolved = registry.snapshot(forTypeId: typeId) + #expect(resolved?.iconName == "existing-icon") + #expect(resolved?.displayName == "BrandTest") + #expect(resolved?.connection.additionalConnectionFields.map(\.id) == ["newPluginField"]) + + registry.unregister(typeId: typeId) + } +} diff --git a/TableProTests/PluginTestSources/RedisSentinelResolver.swift b/TableProTests/PluginTestSources/RedisSentinelResolver.swift new file mode 120000 index 000000000..7828b98b1 --- /dev/null +++ b/TableProTests/PluginTestSources/RedisSentinelResolver.swift @@ -0,0 +1 @@ +../../Plugins/RedisDriverPlugin/RedisSentinelResolver.swift \ No newline at end of file diff --git a/TableProTests/Plugins/RedisSentinelResolverTests.swift b/TableProTests/Plugins/RedisSentinelResolverTests.swift new file mode 100644 index 000000000..8e4432a26 --- /dev/null +++ b/TableProTests/Plugins/RedisSentinelResolverTests.swift @@ -0,0 +1,367 @@ +// +// RedisSentinelResolverTests.swift +// TableProTests +// +// Tests for RedisSentinelResolver (compiled via symlink from RedisDriverPlugin). +// + +import Foundation +import Testing + +private actor FakeSentinelTransport: SentinelTransport { + typealias ReplyFactory = @Sendable (SentinelHostPort) async throws -> SentinelMasterReply + + private let factory: ReplyFactory + private(set) var calls: [SentinelHostPort] = [] + + init(factory: @escaping ReplyFactory) { + self.factory = factory + } + + func queryMasterAddress( + masterName: String, + at sentinel: SentinelHostPort, + sentinelUsername: String?, + sentinelPassword: String? + ) async throws -> SentinelMasterReply { + calls.append(sentinel) + return try await factory(sentinel) + } + + func recordedCalls() -> [SentinelHostPort] { calls } +} + +private struct StubError: Error, Equatable { + let label: String +} + +private let sentinelA = SentinelHostPort(host: "10.0.0.1", port: 26_379) +private let sentinelB = SentinelHostPort(host: "10.0.0.2", port: 26_379) +private let sentinelC = SentinelHostPort(host: "10.0.0.3", port: 26_379) + +@Suite("Redis Sentinel Resolver - iteration") +struct RedisSentinelResolverIterationTests { + @Test("Returns the first sentinel's reply when it has an address") + func returnsFirstSentinelReply() async throws { + let master = SentinelHostPort(host: "10.0.0.5", port: 6_379) + let transport = FakeSentinelTransport { _ in .address(master) } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + let resolved = try await resolver.resolveMaster() + + #expect(resolved == master) + let calls = await transport.recordedCalls() + #expect(calls == [sentinelA]) + } + + @Test("Falls over to the next sentinel when earlier ones throw") + func failsOverToNextSentinel() async throws { + let master = SentinelHostPort(host: "10.0.0.5", port: 6_379) + let transport = FakeSentinelTransport { sentinel in + if sentinel == sentinelA { throw StubError(label: "down") } + return .address(master) + } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB, sentinelC], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + let resolved = try await resolver.resolveMaster() + + #expect(resolved == master) + let calls = await transport.recordedCalls() + #expect(calls == [sentinelA, sentinelB]) + } + + @Test("All sentinels throwing produces allSentinelsUnreachable with full list") + func allSentinelsUnreachable() async throws { + let transport = FakeSentinelTransport { _ in throw StubError(label: "down") } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB, sentinelC], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.allSentinelsUnreachable( + attempts: [sentinelA, sentinelB, sentinelC] + )) { + _ = try await resolver.resolveMaster() + } + } + + @Test("All sentinels saying masterUnknown produces masterUnknown, not unreachable") + func masterUnknownTakesPrecedenceOverUnreachable() async throws { + let transport = FakeSentinelTransport { _ in .masterUnknown } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.masterUnknown( + masterName: "mymaster", + triedSentinels: [sentinelA, sentinelB] + )) { + _ = try await resolver.resolveMaster() + } + } + + @Test("Mixed unknown and unreachable still surfaces as masterUnknown") + func mixedUnknownAndUnreachableSurfacesAsMasterUnknown() async throws { + let transport = FakeSentinelTransport { sentinel in + if sentinel == sentinelA { throw StubError(label: "down") } + return .masterUnknown + } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.masterUnknown( + masterName: "mymaster", + triedSentinels: [sentinelB] + )) { + _ = try await resolver.resolveMaster() + } + } + + @Test("IPv6 master address passes through unchanged") + func ipv6MasterPassesThrough() async throws { + let master = SentinelHostPort(host: "fd00::1", port: 6_379) + let transport = FakeSentinelTransport { _ in .address(master) } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + let resolved = try await resolver.resolveMaster() + + #expect(resolved == master) + } + + @Test("Empty sentinel list short-circuits without calling transport") + func emptySentinelList() async throws { + let transport = FakeSentinelTransport { _ in + Issue.record("Transport should not be invoked") + return .masterUnknown + } + let resolver = RedisSentinelResolver( + sentinels: [], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.noSentinelsConfigured) { + _ = try await resolver.resolveMaster() + } + let calls = await transport.recordedCalls() + #expect(calls.isEmpty) + } + + @Test("Empty master name short-circuits without calling transport") + func emptyMasterName() async throws { + let transport = FakeSentinelTransport { _ in + Issue.record("Transport should not be invoked") + return .masterUnknown + } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA], + masterName: "", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.emptyMasterName) { + _ = try await resolver.resolveMaster() + } + let calls = await transport.recordedCalls() + #expect(calls.isEmpty) + } + + @Test("Sentinel credentials are forwarded to the transport") + func credentialsAreForwarded() async throws { + actor Capture { + var seenUsername: String? + var seenPassword: String? + func set(_ user: String?, _ pass: String?) { + seenUsername = user + seenPassword = pass + } + } + let capture = Capture() + + final class CapturingTransport: SentinelTransport, @unchecked Sendable { + let capture: Capture + init(capture: Capture) { self.capture = capture } + func queryMasterAddress( + masterName: String, + at sentinel: SentinelHostPort, + sentinelUsername: String?, + sentinelPassword: String? + ) async throws -> SentinelMasterReply { + await capture.set(sentinelUsername, sentinelPassword) + return .address(SentinelHostPort(host: "10.0.0.5", port: 6_379)) + } + } + + let resolver = RedisSentinelResolver( + sentinels: [sentinelA], + masterName: "mymaster", + sentinelUsername: "sentineluser", + sentinelPassword: "s3cret", + transport: CapturingTransport(capture: capture) + ) + + _ = try await resolver.resolveMaster() + + let user = await capture.seenUsername + let pass = await capture.seenPassword + #expect(user == "sentineluser") + #expect(pass == "s3cret") + } +} + +@Suite("Redis Sentinel Resolver - reply parsing") +struct RedisSentinelReplyParsingTests { + private let origin = SentinelHostPort(host: "10.0.0.1", port: 26_379) + + @Test("Two-element string array becomes an address") + func twoElementArray() throws { + let reply = try RedisSentinelResolver.parseMasterReplyTokens( + ["10.0.0.5", "6379"], + from: origin + ) + #expect(reply == .address(SentinelHostPort(host: "10.0.0.5", port: 6_379))) + } + + @Test("Nil tokens means master unknown") + func nilTokensMeansUnknown() throws { + let reply = try RedisSentinelResolver.parseMasterReplyTokens(nil, from: origin) + #expect(reply == .masterUnknown) + } + + @Test("Wrong arity throws malformedReply") + func wrongArityThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply( + origin, + detail: "expected 2-element array, got 1" + )) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["10.0.0.5"], from: origin) + } + } + + @Test("Non-numeric port throws malformedReply") + func nonNumericPortThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply( + origin, + detail: "invalid port banana" + )) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["10.0.0.5", "banana"], from: origin) + } + } + + @Test("Port out of range throws malformedReply") + func portOutOfRangeThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply( + origin, + detail: "invalid port 70000" + )) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["10.0.0.5", "70000"], from: origin) + } + } + + @Test("Empty host throws malformedReply") + func emptyHostThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply(origin, detail: "missing host")) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["", "6379"], from: origin) + } + } +} + +@Suite("Redis Sentinel Resolver - hostList parsing") +struct RedisSentinelHostListParsingTests { + @Test("Comma-separated host:port entries parse in order") + func basicCommaSeparated() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "10.0.0.1:26379,10.0.0.2:26379", + defaultPort: 26_379 + ) + #expect(parsed == [ + SentinelHostPort(host: "10.0.0.1", port: 26_379), + SentinelHostPort(host: "10.0.0.2", port: 26_379), + ]) + } + + @Test("Entries without an explicit port get the default") + func defaultPortApplied() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "sentinel-a,sentinel-b:26380", + defaultPort: 26_379 + ) + #expect(parsed == [ + SentinelHostPort(host: "sentinel-a", port: 26_379), + SentinelHostPort(host: "sentinel-b", port: 26_380), + ]) + } + + @Test("Whitespace around entries is trimmed; empty segments are skipped") + func whitespaceAndEmpties() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + " 10.0.0.1:26379 , ,10.0.0.2 ", + defaultPort: 26_379 + ) + #expect(parsed == [ + SentinelHostPort(host: "10.0.0.1", port: 26_379), + SentinelHostPort(host: "10.0.0.2", port: 26_379), + ]) + } + + @Test("IPv6 address in brackets with port") + func ipv6Bracketed() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "[fd00::1]:26379", + defaultPort: 26_379 + ) + #expect(parsed == [SentinelHostPort(host: "fd00::1", port: 26_379)]) + } + + @Test("IPv6 address in brackets without port uses default") + func ipv6BracketedDefaultPort() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "[fd00::1]", + defaultPort: 26_379 + ) + #expect(parsed == [SentinelHostPort(host: "fd00::1", port: 26_379)]) + } + + @Test("Bare IPv6 address with multiple colons falls through to host-only") + func bareIpv6FallsThroughAsHostOnly() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "fd00::1", + defaultPort: 26_379 + ) + #expect(parsed == [SentinelHostPort(host: "fd00::1", port: 26_379)]) + } +} diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index d8bc1aeca..f00d6ea92 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -22,8 +22,9 @@ TablePro supports Redis 6.0 and later. Keys are grouped by colon-separated names | Field | Default | Notes | |-------|---------|-------| -| **Host** | `localhost` | | -| **Port** | `6379` | | +| **Connection Mode** | `Single Node` | Choose `Sentinel` for HA deployments | +| **Host** | `localhost` | Single Node only | +| **Port** | `6379` | Single Node only | | **Password** | - | Leave empty for local dev | | **Database** | `0` | 0-15 | | **Key Separator** | `:` | Groups keys by prefix in sidebar | @@ -32,6 +33,27 @@ TablePro supports Redis 6.0 and later. Keys are grouped by colon-separated names Open URLs like `redis://:password@host:6379/0` or `rediss://` (TLS) from your browser. See [Connection URL Reference](/databases/connection-urls#redis). +## Connection Modes + +### Single Node + +Default. Connects directly to one Redis instance using the Host and Port fields. + +### Sentinel + +For HA deployments fronted by [Redis Sentinel](https://redis.io/docs/management/sentinel/). TablePro queries the Sentinel quorum for the current master address, then opens the data connection to it. On a dropped connection, the health monitor re-queries Sentinel and reconnects to whichever node is the new master. + +| Field | Notes | +|-------|-------| +| **Sentinel Nodes** | One or more `host:port` entries. Default port `26379`. | +| **Master Group Name** | The name configured in `sentinel.conf` (defaults to `mymaster`). | +| **Sentinel User** | Optional. Only set if Sentinel has its own ACL user (Redis 6.2+). | +| **Sentinel Password** | Optional. Only set if Sentinel has its own AUTH. | + +The Host and Port fields are ignored in Sentinel mode. The Username and Password under the Authentication section are used for the data plane (master and replicas), which is assumed to share one credential across the master set. + +If every Sentinel is unreachable, or none of them know the master name, you get a connection error naming which Sentinels were tried. + ## Example Configurations **Local**: host `localhost:6379`, no password @@ -109,6 +131,6 @@ DBSIZE **Timeout**: Verify host/port, check network and firewall, whitelist IP for cloud-hosted Redis. -**Limitations**: Cluster mode unsupported, Pub/Sub and Streams limited in grid (work in CLI), large keys paginated. +**Limitations**: Cluster mode not yet supported (planned, see [issue #1021](https://github.com/TableProApp/TablePro/issues/1021)), Pub/Sub and Streams limited in grid (work in CLI), large keys paginated. **Performance**: Use namespace browsing for filtering, use `SCAN` instead of `KEYS` in CLI, check memory with `INFO memory`.