Skip to content

Commit f5eff23

Browse files
committed
fix: auto-detect etcd API prefix for v3.4.x compatibility
1 parent 6846b61 commit f5eff23

2 files changed

Lines changed: 87 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- etcd connection failing with 404 on etcd 3.4.x due to hardcoded `/v3/` API prefix
13+
1014
## [0.21.0] - 2026-03-19
1115

1216
### Added

Plugins/EtcdDriverPlugin/EtcdHttpClient.swift

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
313313
private var currentTask: URLSessionDataTask?
314314
private var authToken: String?
315315
private var _isAuthenticating = false
316+
private var apiPrefix = "v3"
316317

317318
private static let logger = Logger(subsystem: "com.TablePro", category: "EtcdHttpClient")
318319

@@ -332,6 +333,13 @@ internal final class EtcdHttpClient: @unchecked Sendable {
332333
return "\(scheme)://\(config.host):\(config.port)"
333334
}
334335

336+
private func apiPath(_ suffix: String) -> String {
337+
lock.lock()
338+
let prefix = apiPrefix
339+
lock.unlock()
340+
return "\(prefix)/\(suffix)"
341+
}
342+
335343
// MARK: - Connection Lifecycle
336344

337345
func connect() async throws {
@@ -366,7 +374,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
366374
lock.unlock()
367375

368376
do {
369-
try await ping()
377+
try await detectApiPrefix()
370378
} catch {
371379
lock.lock()
372380
session?.invalidateAndCancel()
@@ -399,56 +407,104 @@ internal final class EtcdHttpClient: @unchecked Sendable {
399407
session = nil
400408
authToken = nil
401409
_isAuthenticating = false
410+
apiPrefix = "v3"
402411
lock.unlock()
403412
}
404413

405414
func ping() async throws {
406-
let _: EtcdStatusResponse = try await post(path: "v3/maintenance/status", body: EmptyBody())
415+
let _: EtcdStatusResponse = try await post(path: apiPath("maintenance/status"), body: EmptyBody())
416+
}
417+
418+
private func detectApiPrefix() async throws {
419+
let candidates = ["v3", "v3beta"]
420+
421+
lock.lock()
422+
guard let session else {
423+
lock.unlock()
424+
throw EtcdError.notConnected
425+
}
426+
lock.unlock()
427+
428+
for candidate in candidates {
429+
guard let url = URL(string: "\(baseUrl)/\(candidate)/maintenance/status") else {
430+
continue
431+
}
432+
433+
var request = URLRequest(url: url)
434+
request.httpMethod = "POST"
435+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
436+
request.httpBody = try JSONEncoder().encode(EmptyBody())
437+
438+
let response: URLResponse
439+
do {
440+
(_, response) = try await session.data(for: request)
441+
} catch {
442+
throw EtcdError.connectionFailed(error.localizedDescription)
443+
}
444+
445+
guard let httpResponse = response as? HTTPURLResponse else {
446+
throw EtcdError.serverError("Invalid response type")
447+
}
448+
449+
if httpResponse.statusCode == 404 {
450+
continue
451+
}
452+
453+
lock.lock()
454+
apiPrefix = candidate
455+
lock.unlock()
456+
Self.logger.debug("Detected etcd API prefix: \(candidate)")
457+
return
458+
}
459+
460+
lock.lock()
461+
apiPrefix = "v3"
462+
lock.unlock()
407463
}
408464

409465
// MARK: - KV Operations
410466

411467
func rangeRequest(_ req: EtcdRangeRequest) async throws -> EtcdRangeResponse {
412-
try await post(path: "v3/kv/range", body: req)
468+
try await post(path: apiPath("kv/range"), body: req)
413469
}
414470

415471
func putRequest(_ req: EtcdPutRequest) async throws -> EtcdPutResponse {
416-
try await post(path: "v3/kv/put", body: req)
472+
try await post(path: apiPath("kv/put"), body: req)
417473
}
418474

419475
func deleteRequest(_ req: EtcdDeleteRequest) async throws -> EtcdDeleteResponse {
420-
try await post(path: "v3/kv/deleterange", body: req)
476+
try await post(path: apiPath("kv/deleterange"), body: req)
421477
}
422478

423479
// MARK: - Lease Operations
424480

425481
func leaseGrant(ttl: Int64) async throws -> EtcdLeaseGrantResponse {
426482
let req = EtcdLeaseGrantRequest(TTL: String(ttl))
427-
return try await post(path: "v3/lease/grant", body: req)
483+
return try await post(path: apiPath("lease/grant"), body: req)
428484
}
429485

430486
func leaseRevoke(leaseId: Int64) async throws {
431487
let req = EtcdLeaseRevokeRequest(ID: String(leaseId))
432-
try await postVoid(path: "v3/lease/revoke", body: req)
488+
try await postVoid(path: apiPath("lease/revoke"), body: req)
433489
}
434490

435491
func leaseTimeToLive(leaseId: Int64, keys: Bool) async throws -> EtcdLeaseTimeToLiveResponse {
436492
let req = EtcdLeaseTimeToLiveRequest(ID: String(leaseId), keys: keys)
437-
return try await post(path: "v3/lease/timetolive", body: req)
493+
return try await post(path: apiPath("lease/timetolive"), body: req)
438494
}
439495

440496
func leaseList() async throws -> EtcdLeaseListResponse {
441-
try await post(path: "v3/lease/leases", body: EmptyBody())
497+
try await post(path: apiPath("lease/leases"), body: EmptyBody())
442498
}
443499

444500
// MARK: - Cluster Operations
445501

446502
func memberList() async throws -> EtcdMemberListResponse {
447-
try await post(path: "v3/cluster/member/list", body: EmptyBody())
503+
try await post(path: apiPath("cluster/member/list"), body: EmptyBody())
448504
}
449505

450506
func endpointStatus() async throws -> EtcdStatusResponse {
451-
try await post(path: "v3/maintenance/status", body: EmptyBody())
507+
try await post(path: apiPath("maintenance/status"), body: EmptyBody())
452508
}
453509

454510
// MARK: - Watch
@@ -469,8 +525,9 @@ internal final class EtcdHttpClient: @unchecked Sendable {
469525
}
470526
let watchReq = EtcdWatchRequest(createRequest: createReq)
471527

472-
guard let url = URL(string: "\(baseUrl)/v3/watch") else {
473-
throw EtcdError.serverError("Invalid URL: \(baseUrl)/v3/watch")
528+
let watchPath = apiPath("watch")
529+
guard let url = URL(string: "\(baseUrl)/\(watchPath)") else {
530+
throw EtcdError.serverError("Invalid URL: \(baseUrl)/\(watchPath)")
474531
}
475532

476533
var request = URLRequest(url: url)
@@ -525,58 +582,58 @@ internal final class EtcdHttpClient: @unchecked Sendable {
525582
// MARK: - Auth Management
526583

527584
func authEnable() async throws {
528-
try await postVoid(path: "v3/auth/enable", body: EmptyBody())
585+
try await postVoid(path: apiPath("auth/enable"), body: EmptyBody())
529586
}
530587

531588
func authDisable() async throws {
532-
try await postVoid(path: "v3/auth/disable", body: EmptyBody())
589+
try await postVoid(path: apiPath("auth/disable"), body: EmptyBody())
533590
}
534591

535592
func userAdd(name: String, password: String) async throws {
536593
let req = EtcdUserAddRequest(name: name, password: password)
537-
try await postVoid(path: "v3/auth/user/add", body: req)
594+
try await postVoid(path: apiPath("auth/user/add"), body: req)
538595
}
539596

540597
func userDelete(name: String) async throws {
541598
let req = EtcdUserDeleteRequest(name: name)
542-
try await postVoid(path: "v3/auth/user/delete", body: req)
599+
try await postVoid(path: apiPath("auth/user/delete"), body: req)
543600
}
544601

545602
func userList() async throws -> [String] {
546-
let resp: EtcdUserListResponse = try await post(path: "v3/auth/user/list", body: EmptyBody())
603+
let resp: EtcdUserListResponse = try await post(path: apiPath("auth/user/list"), body: EmptyBody())
547604
return resp.users ?? []
548605
}
549606

550607
func roleAdd(name: String) async throws {
551608
let req = EtcdRoleAddRequest(name: name)
552-
try await postVoid(path: "v3/auth/role/add", body: req)
609+
try await postVoid(path: apiPath("auth/role/add"), body: req)
553610
}
554611

555612
func roleDelete(name: String) async throws {
556613
let req = EtcdRoleDeleteRequest(name: name)
557-
try await postVoid(path: "v3/auth/role/delete", body: req)
614+
try await postVoid(path: apiPath("auth/role/delete"), body: req)
558615
}
559616

560617
func roleList() async throws -> [String] {
561-
let resp: EtcdRoleListResponse = try await post(path: "v3/auth/role/list", body: EmptyBody())
618+
let resp: EtcdRoleListResponse = try await post(path: apiPath("auth/role/list"), body: EmptyBody())
562619
return resp.roles ?? []
563620
}
564621

565622
func userGrantRole(user: String, role: String) async throws {
566623
let req = EtcdUserGrantRoleRequest(user: user, role: role)
567-
try await postVoid(path: "v3/auth/user/grant", body: req)
624+
try await postVoid(path: apiPath("auth/user/grant"), body: req)
568625
}
569626

570627
func userRevokeRole(user: String, role: String) async throws {
571628
let req = EtcdUserRevokeRoleRequest(user: user, role: role)
572-
try await postVoid(path: "v3/auth/user/revoke", body: req)
629+
try await postVoid(path: apiPath("auth/user/revoke"), body: req)
573630
}
574631

575632
// MARK: - Maintenance
576633

577634
func compaction(revision: Int64, physical: Bool) async throws {
578635
let req = EtcdCompactionRequest(revision: String(revision), physical: physical)
579-
try await postVoid(path: "v3/kv/compaction", body: req)
636+
try await postVoid(path: apiPath("kv/compaction"), body: req)
580637
}
581638

582639
// MARK: - Cancellation
@@ -710,7 +767,8 @@ internal final class EtcdHttpClient: @unchecked Sendable {
710767
}
711768

712769
let authReq = EtcdAuthRequest(name: config.username, password: config.password)
713-
guard let url = URL(string: "\(baseUrl)/v3/auth/authenticate") else {
770+
let authPath = apiPath("auth/authenticate")
771+
guard let url = URL(string: "\(baseUrl)/\(authPath)") else {
714772
throw EtcdError.serverError("Invalid auth URL")
715773
}
716774

0 commit comments

Comments
 (0)