From f5eff230a1fd742e4684748e53a1ece609632110 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 20 Mar 2026 08:58:02 +0700 Subject: [PATCH 1/3] fix: auto-detect etcd API prefix for v3.4.x compatibility --- CHANGELOG.md | 4 + Plugins/EtcdDriverPlugin/EtcdHttpClient.swift | 108 ++++++++++++++---- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6733fb7..f1122f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- etcd connection failing with 404 on etcd 3.4.x due to hardcoded `/v3/` API prefix + ## [0.21.0] - 2026-03-19 ### Added diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index b441475b..4433df93 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -313,6 +313,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { private var currentTask: URLSessionDataTask? private var authToken: String? private var _isAuthenticating = false + private var apiPrefix = "v3" private static let logger = Logger(subsystem: "com.TablePro", category: "EtcdHttpClient") @@ -332,6 +333,13 @@ internal final class EtcdHttpClient: @unchecked Sendable { return "\(scheme)://\(config.host):\(config.port)" } + private func apiPath(_ suffix: String) -> String { + lock.lock() + let prefix = apiPrefix + lock.unlock() + return "\(prefix)/\(suffix)" + } + // MARK: - Connection Lifecycle func connect() async throws { @@ -366,7 +374,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { lock.unlock() do { - try await ping() + try await detectApiPrefix() } catch { lock.lock() session?.invalidateAndCancel() @@ -399,56 +407,104 @@ internal final class EtcdHttpClient: @unchecked Sendable { session = nil authToken = nil _isAuthenticating = false + apiPrefix = "v3" lock.unlock() } func ping() async throws { - let _: EtcdStatusResponse = try await post(path: "v3/maintenance/status", body: EmptyBody()) + let _: EtcdStatusResponse = try await post(path: apiPath("maintenance/status"), body: EmptyBody()) + } + + private func detectApiPrefix() async throws { + let candidates = ["v3", "v3beta"] + + lock.lock() + guard let session else { + lock.unlock() + throw EtcdError.notConnected + } + lock.unlock() + + for candidate in candidates { + guard let url = URL(string: "\(baseUrl)/\(candidate)/maintenance/status") else { + continue + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(EmptyBody()) + + let response: URLResponse + do { + (_, response) = try await session.data(for: request) + } catch { + throw EtcdError.connectionFailed(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw EtcdError.serverError("Invalid response type") + } + + if httpResponse.statusCode == 404 { + continue + } + + lock.lock() + apiPrefix = candidate + lock.unlock() + Self.logger.debug("Detected etcd API prefix: \(candidate)") + return + } + + lock.lock() + apiPrefix = "v3" + lock.unlock() } // MARK: - KV Operations func rangeRequest(_ req: EtcdRangeRequest) async throws -> EtcdRangeResponse { - try await post(path: "v3/kv/range", body: req) + try await post(path: apiPath("kv/range"), body: req) } func putRequest(_ req: EtcdPutRequest) async throws -> EtcdPutResponse { - try await post(path: "v3/kv/put", body: req) + try await post(path: apiPath("kv/put"), body: req) } func deleteRequest(_ req: EtcdDeleteRequest) async throws -> EtcdDeleteResponse { - try await post(path: "v3/kv/deleterange", body: req) + try await post(path: apiPath("kv/deleterange"), body: req) } // MARK: - Lease Operations func leaseGrant(ttl: Int64) async throws -> EtcdLeaseGrantResponse { let req = EtcdLeaseGrantRequest(TTL: String(ttl)) - return try await post(path: "v3/lease/grant", body: req) + return try await post(path: apiPath("lease/grant"), body: req) } func leaseRevoke(leaseId: Int64) async throws { let req = EtcdLeaseRevokeRequest(ID: String(leaseId)) - try await postVoid(path: "v3/lease/revoke", body: req) + try await postVoid(path: apiPath("lease/revoke"), body: req) } func leaseTimeToLive(leaseId: Int64, keys: Bool) async throws -> EtcdLeaseTimeToLiveResponse { let req = EtcdLeaseTimeToLiveRequest(ID: String(leaseId), keys: keys) - return try await post(path: "v3/lease/timetolive", body: req) + return try await post(path: apiPath("lease/timetolive"), body: req) } func leaseList() async throws -> EtcdLeaseListResponse { - try await post(path: "v3/lease/leases", body: EmptyBody()) + try await post(path: apiPath("lease/leases"), body: EmptyBody()) } // MARK: - Cluster Operations func memberList() async throws -> EtcdMemberListResponse { - try await post(path: "v3/cluster/member/list", body: EmptyBody()) + try await post(path: apiPath("cluster/member/list"), body: EmptyBody()) } func endpointStatus() async throws -> EtcdStatusResponse { - try await post(path: "v3/maintenance/status", body: EmptyBody()) + try await post(path: apiPath("maintenance/status"), body: EmptyBody()) } // MARK: - Watch @@ -469,8 +525,9 @@ internal final class EtcdHttpClient: @unchecked Sendable { } let watchReq = EtcdWatchRequest(createRequest: createReq) - guard let url = URL(string: "\(baseUrl)/v3/watch") else { - throw EtcdError.serverError("Invalid URL: \(baseUrl)/v3/watch") + let watchPath = apiPath("watch") + guard let url = URL(string: "\(baseUrl)/\(watchPath)") else { + throw EtcdError.serverError("Invalid URL: \(baseUrl)/\(watchPath)") } var request = URLRequest(url: url) @@ -525,58 +582,58 @@ internal final class EtcdHttpClient: @unchecked Sendable { // MARK: - Auth Management func authEnable() async throws { - try await postVoid(path: "v3/auth/enable", body: EmptyBody()) + try await postVoid(path: apiPath("auth/enable"), body: EmptyBody()) } func authDisable() async throws { - try await postVoid(path: "v3/auth/disable", body: EmptyBody()) + try await postVoid(path: apiPath("auth/disable"), body: EmptyBody()) } func userAdd(name: String, password: String) async throws { let req = EtcdUserAddRequest(name: name, password: password) - try await postVoid(path: "v3/auth/user/add", body: req) + try await postVoid(path: apiPath("auth/user/add"), body: req) } func userDelete(name: String) async throws { let req = EtcdUserDeleteRequest(name: name) - try await postVoid(path: "v3/auth/user/delete", body: req) + try await postVoid(path: apiPath("auth/user/delete"), body: req) } func userList() async throws -> [String] { - let resp: EtcdUserListResponse = try await post(path: "v3/auth/user/list", body: EmptyBody()) + let resp: EtcdUserListResponse = try await post(path: apiPath("auth/user/list"), body: EmptyBody()) return resp.users ?? [] } func roleAdd(name: String) async throws { let req = EtcdRoleAddRequest(name: name) - try await postVoid(path: "v3/auth/role/add", body: req) + try await postVoid(path: apiPath("auth/role/add"), body: req) } func roleDelete(name: String) async throws { let req = EtcdRoleDeleteRequest(name: name) - try await postVoid(path: "v3/auth/role/delete", body: req) + try await postVoid(path: apiPath("auth/role/delete"), body: req) } func roleList() async throws -> [String] { - let resp: EtcdRoleListResponse = try await post(path: "v3/auth/role/list", body: EmptyBody()) + let resp: EtcdRoleListResponse = try await post(path: apiPath("auth/role/list"), body: EmptyBody()) return resp.roles ?? [] } func userGrantRole(user: String, role: String) async throws { let req = EtcdUserGrantRoleRequest(user: user, role: role) - try await postVoid(path: "v3/auth/user/grant", body: req) + try await postVoid(path: apiPath("auth/user/grant"), body: req) } func userRevokeRole(user: String, role: String) async throws { let req = EtcdUserRevokeRoleRequest(user: user, role: role) - try await postVoid(path: "v3/auth/user/revoke", body: req) + try await postVoid(path: apiPath("auth/user/revoke"), body: req) } // MARK: - Maintenance func compaction(revision: Int64, physical: Bool) async throws { let req = EtcdCompactionRequest(revision: String(revision), physical: physical) - try await postVoid(path: "v3/kv/compaction", body: req) + try await postVoid(path: apiPath("kv/compaction"), body: req) } // MARK: - Cancellation @@ -710,7 +767,8 @@ internal final class EtcdHttpClient: @unchecked Sendable { } let authReq = EtcdAuthRequest(name: config.username, password: config.password) - guard let url = URL(string: "\(baseUrl)/v3/auth/authenticate") else { + let authPath = apiPath("auth/authenticate") + guard let url = URL(string: "\(baseUrl)/\(authPath)") else { throw EtcdError.serverError("Invalid auth URL") } From d2a16af70a9044e3a31b04dd0a54e0a6cf595080 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 20 Mar 2026 09:03:44 +0700 Subject: [PATCH 2/3] fix: improve etcd API prefix detection for all versions --- CHANGELOG.md | 2 +- Plugins/EtcdDriverPlugin/EtcdHttpClient.swift | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1122f63..121c26fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- etcd connection failing with 404 on etcd 3.4.x due to hardcoded `/v3/` API prefix +- etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`) ## [0.21.0] - 2026-03-19 diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index 4433df93..dfe18a03 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -415,8 +415,14 @@ internal final class EtcdHttpClient: @unchecked Sendable { let _: EtcdStatusResponse = try await post(path: apiPath("maintenance/status"), body: EmptyBody()) } + /// Probes etcd gateway prefixes in order and selects the first that responds + /// with a non-404 status. Covers all etcd versions: + /// 3.5+ → /v3/ only + /// 3.4 → /v3/ + /v3beta/ + /// 3.3 → /v3beta/ + /v3alpha/ + /// 3.2- → /v3alpha/ only private func detectApiPrefix() async throws { - let candidates = ["v3", "v3beta"] + let candidates = ["v3", "v3beta", "v3alpha"] lock.lock() guard let session else { @@ -439,7 +445,8 @@ internal final class EtcdHttpClient: @unchecked Sendable { do { (_, response) = try await session.data(for: request) } catch { - throw EtcdError.connectionFailed(error.localizedDescription) + // Network-level failure — server is unreachable regardless of prefix + throw error } guard let httpResponse = response as? HTTPURLResponse else { @@ -450,6 +457,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { continue } + // Any non-404 (200, 401, etc.) means this prefix exists on the server lock.lock() apiPrefix = candidate lock.unlock() @@ -457,9 +465,9 @@ internal final class EtcdHttpClient: @unchecked Sendable { return } - lock.lock() - apiPrefix = "v3" - lock.unlock() + throw EtcdError.connectionFailed( + "No supported etcd API found (tried: \(candidates.joined(separator: ", ")))" + ) } // MARK: - KV Operations From 03f86e63b1b3d96407cbb2e13c86f80c82b3751d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 20 Mar 2026 09:17:21 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?smarter=20probe=20status=20handling,=20no=20double=20error=20wr?= =?UTF-8?q?apping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/EtcdDriverPlugin/EtcdHttpClient.swift | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index dfe18a03..9b36511d 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -375,6 +375,13 @@ internal final class EtcdHttpClient: @unchecked Sendable { do { try await detectApiPrefix() + } catch let etcdError as EtcdError { + lock.lock() + session?.invalidateAndCancel() + session = nil + lock.unlock() + Self.logger.error("Connection test failed: \(etcdError.localizedDescription)") + throw etcdError } catch { lock.lock() session?.invalidateAndCancel() @@ -453,19 +460,32 @@ internal final class EtcdHttpClient: @unchecked Sendable { throw EtcdError.serverError("Invalid response type") } - if httpResponse.statusCode == 404 { + switch httpResponse.statusCode { + case 404: continue + case 200: + lock.lock() + apiPrefix = candidate + lock.unlock() + Self.logger.debug("Detected etcd API prefix: \(candidate)") + return + case 401 where !config.username.isEmpty: + // Auth required but credentials are configured — prefix is valid, + // authenticate() will run after detection + lock.lock() + apiPrefix = candidate + lock.unlock() + Self.logger.debug("Detected etcd API prefix: \(candidate) (auth required)") + return + case 401: + throw EtcdError.authFailed("Authentication required") + default: + Self.logger.warning("Prefix probe \(candidate) returned HTTP \(httpResponse.statusCode)") + throw EtcdError.serverError("Unexpected HTTP \(httpResponse.statusCode) from \(candidate)/maintenance/status") } - - // Any non-404 (200, 401, etc.) means this prefix exists on the server - lock.lock() - apiPrefix = candidate - lock.unlock() - Self.logger.debug("Detected etcd API prefix: \(candidate)") - return } - throw EtcdError.connectionFailed( + throw EtcdError.serverError( "No supported etcd API found (tried: \(candidates.joined(separator: ", ")))" ) }