Skip to content

Commit e85fb6a

Browse files
authored
feat(connections): add native Cloud SQL Auth Proxy integration (#1844)
1 parent 15b1fee commit e85fb6a

45 files changed

Lines changed: 2037 additions & 192 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Connect to Google Cloud SQL through the Cloud SQL Auth Proxy without starting it yourself. Enable it on a MySQL, PostgreSQL, or SQL Server connection, set the instance connection name, and TablePro runs and stops the proxy with the connection. Supports Application Default Credentials, a service account key, and IAM database authentication, and can download the proxy or use one already on your Mac. (#1728)
1213
- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables through user-provided `rledger` or Python Beancount, and BQL runs with a `BQL:` prefix when `rledger` is available. (#1474)
1314
- The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab.
1415

16+
### Fixed
17+
18+
- A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background.
19+
1520
## [0.56.2] - 2026-07-10
1621

1722
### Added

TablePro/AppDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6868
DatabaseManager.shared.startObservingSystemEvents()
6969

7070
Task { await CloudflareTunnelManager.shared.sweepStalePidsIfNeeded() }
71+
Task { await CloudSQLProxyManager.shared.sweepStalePidsIfNeeded() }
7172

7273
MemoryPressureAdvisor.startMonitoring()
7374
UNUserNotificationCenter.current().delegate = self
@@ -150,6 +151,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
150151
SQLFolderWatcher.shared.stop()
151152
SSHTunnelManager.shared.terminateAllProcessesSync()
152153
CloudflareTunnelManager.shared.terminateAllProcessesSync()
154+
CloudSQLProxyManager.shared.terminateAllProcessesSync()
153155
}
154156

155157
private func persistOpenConnectionsForRecovery() {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//
2+
// CloudSQLProxyBinaryManager.swift
3+
// TablePro
4+
//
5+
6+
import CryptoKit
7+
import Darwin
8+
import Foundation
9+
import os
10+
11+
actor CloudSQLProxyBinaryManager {
12+
static let shared = CloudSQLProxyBinaryManager()
13+
private static let logger = Logger(subsystem: "com.TablePro", category: "CloudSQLProxyBinary")
14+
15+
static let pinnedVersion = "2.23.0"
16+
17+
static let defaultExpectedSHA256: [String: String] = [
18+
"arm64": "d5233967a8b5141bd1e95edcad2fb9930357d3ffbd9f433b82fc4a538d3fd68b",
19+
"amd64": "8089f6bab724a68c5e47b74759671db091df44b36e84cd273c1b899068f7a173"
20+
]
21+
22+
private let baseDirectory: URL
23+
private let expectedSHA256: [String: String]
24+
private let fetch: @Sendable (URL) async throws -> Data
25+
private var downloadTask: Task<Void, Error>?
26+
27+
init(
28+
baseDirectory: URL? = nil,
29+
expectedSHA256: [String: String] = CloudSQLProxyBinaryManager.defaultExpectedSHA256,
30+
fetch: @escaping @Sendable (URL) async throws -> Data = { try await URLSession.shared.data(from: $0).0 }
31+
) {
32+
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
33+
?? FileManager.default.temporaryDirectory
34+
self.baseDirectory = baseDirectory
35+
?? appSupport.appendingPathComponent("TablePro/cloud-sql-proxy", isDirectory: true)
36+
self.expectedSHA256 = expectedSHA256
37+
self.fetch = fetch
38+
}
39+
40+
var binaryExecutablePath: String {
41+
baseDirectory.appendingPathComponent("cloud-sql-proxy").path
42+
}
43+
44+
var cachedBinaryPath: String? {
45+
FileManager.default.isExecutableFile(atPath: binaryExecutablePath) ? binaryExecutablePath : nil
46+
}
47+
48+
func installedVersion() -> String? {
49+
let versionFile = baseDirectory.appendingPathComponent("version.txt")
50+
return try? String(contentsOf: versionFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
51+
}
52+
53+
func ensureBinary() async throws -> String {
54+
if FileManager.default.isExecutableFile(atPath: binaryExecutablePath) {
55+
return binaryExecutablePath
56+
}
57+
58+
if let existing = downloadTask {
59+
try await existing.value
60+
downloadTask = nil
61+
} else {
62+
let task = Task { try await downloadBinary() }
63+
downloadTask = task
64+
do {
65+
try await task.value
66+
downloadTask = nil
67+
} catch {
68+
downloadTask = nil
69+
throw error
70+
}
71+
}
72+
73+
guard FileManager.default.isExecutableFile(atPath: binaryExecutablePath) else {
74+
throw CloudSQLProxyError.binaryNotFound
75+
}
76+
return binaryExecutablePath
77+
}
78+
79+
private func downloadBinary() async throws {
80+
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true)
81+
82+
let arch = Self.arch
83+
guard let expected = expectedSHA256[arch],
84+
let url = URL(
85+
string: "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v\(Self.pinnedVersion)/cloud-sql-proxy.darwin.\(arch)"
86+
) else {
87+
throw CloudSQLProxyError.binaryNotFound
88+
}
89+
90+
let data = try await fetch(url)
91+
guard data.sha256HexString() == expected else {
92+
Self.logger.error("cloud-sql-proxy binary checksum mismatch, refusing to install")
93+
throw CloudSQLProxyError.binaryNotFound
94+
}
95+
96+
let tempPath = baseDirectory.appendingPathComponent("cloud-sql-proxy.download")
97+
try data.write(to: tempPath, options: .atomic)
98+
if FileManager.default.fileExists(atPath: binaryExecutablePath) {
99+
try FileManager.default.removeItem(atPath: binaryExecutablePath)
100+
}
101+
try FileManager.default.moveItem(atPath: tempPath.path, toPath: binaryExecutablePath)
102+
103+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryExecutablePath)
104+
stripQuarantineAttribute(at: binaryExecutablePath)
105+
106+
let versionFile = baseDirectory.appendingPathComponent("version.txt")
107+
try? Self.pinnedVersion.write(to: versionFile, atomically: true, encoding: .utf8)
108+
Self.logger.info("Downloaded cloud-sql-proxy \(Self.pinnedVersion, privacy: .public)")
109+
}
110+
111+
private func stripQuarantineAttribute(at path: String) {
112+
let removed = path.withCString { removexattr($0, "com.apple.quarantine", 0) }
113+
guard removed != 0 else { return }
114+
let err = errno
115+
if err != ENOATTR {
116+
Self.logger.warning("Failed to remove quarantine xattr: errno=\(err)")
117+
}
118+
}
119+
120+
private static var arch: String {
121+
#if arch(arm64)
122+
return "arm64"
123+
#else
124+
return "amd64"
125+
#endif
126+
}
127+
}
128+
129+
private extension Data {
130+
func sha256HexString() -> String {
131+
SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined()
132+
}
133+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// CloudSQLProxyError.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
enum CloudSQLProxyError: Error, LocalizedError, Equatable {
9+
case binaryNotFound
10+
case noAvailablePort
11+
case invalidInstanceConnectionName
12+
case credentialsWriteFailed
13+
case startupFailed(stderrTail: String)
14+
case readinessTimeout(stderrTail: String)
15+
16+
var errorDescription: String? {
17+
switch self {
18+
case .binaryNotFound:
19+
return String(localized: "cloud-sql-proxy was not found. Install it with `brew install cloud-sql-proxy`, or download it in the Cloud SQL Auth Proxy settings.")
20+
case .noAvailablePort:
21+
return String(localized: "No available local port for the Cloud SQL Auth Proxy.")
22+
case .invalidInstanceConnectionName:
23+
return String(localized: "The instance connection name must be in the form project:region:instance.")
24+
case .credentialsWriteFailed:
25+
return String(localized: "Could not write the Google Cloud service account key to a temporary file.")
26+
case .startupFailed(let stderrTail):
27+
return stderrTail.isEmpty
28+
? String(localized: "The Cloud SQL Auth Proxy failed to start.")
29+
: String(format: String(localized: "The Cloud SQL Auth Proxy failed to start: %@"), stderrTail)
30+
case .readinessTimeout(let stderrTail):
31+
return stderrTail.isEmpty
32+
? String(localized: "The Cloud SQL Auth Proxy did not become ready in time.")
33+
: String(format: String(localized: "The Cloud SQL Auth Proxy did not become ready in time: %@"), stderrTail)
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)