Compare commits
342 Commits
packages-v
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
91ebf9bd8e | |
|
|
11c47d1250 | |
|
|
89ca914f0e | |
|
|
d08189ba7d | |
|
|
fff07ada83 | |
|
|
a3cf5fe256 | |
|
|
a8495b2454 | |
|
|
4311ace960 | |
|
|
6c2ca2eadd | |
|
|
0c9b6c7e1d | |
|
|
a1918ec8c2 | |
|
|
c20ad57f9d | |
|
|
b2e450308e | |
|
|
318e1dfc5d | |
|
|
a41b3bf417 | |
|
|
868f7961e3 | |
|
|
0402fea37b | |
|
|
681c6f35c5 | |
|
|
56edbd3656 | |
|
|
f66a18d88d | |
|
|
789482eceb | |
|
|
f1c998c9cc | |
|
|
5966aea43b | |
|
|
295c260a72 | |
|
|
99888aaeff | |
|
|
fdad1873bc | |
|
|
35362c8bf4 | |
|
|
31587bcbfe | |
|
|
09edbfbef3 | |
|
|
cdfa993642 | |
|
|
a4129c02fa | |
|
|
7c2528d32f | |
|
|
5d5b4f6154 | |
|
|
1daaac03aa | |
|
|
8f597a0516 | |
|
|
9e3013adf9 | |
|
|
e7f496bf0b | |
|
|
71ccb3d97d | |
|
|
328a2aa107 | |
|
|
b1fcb23625 | |
|
|
72ff141f02 | |
|
|
5c343ab416 | |
|
|
d72d4beed3 | |
|
|
2b3bf1367d | |
|
|
ca5a5dc9bf | |
|
|
7445f647c8 | |
|
|
cedd2e5097 | |
|
|
f10642e29a | |
|
|
d38e58b136 | |
|
|
ad9ca8400b | |
|
|
be7b9ac021 | |
|
|
3d55100717 | |
|
|
d74e007150 | |
|
|
28b3491843 | |
|
|
5b85f9eae8 | |
|
|
bad7f6b472 | |
|
|
e49d86b101 | |
|
|
0107d5fafb | |
|
|
c4289e6f21 | |
|
|
fc1f89ddb6 | |
|
|
062daf3093 | |
|
|
2c036e2860 | |
|
|
50c42d8935 | |
|
|
36e5fa4f4c | |
|
|
25fd5677c7 | |
|
|
63498da121 | |
|
|
efd0c381ad | |
|
|
9057be0e78 | |
|
|
e00a6a8b52 | |
|
|
fa2365153a | |
|
|
1405f00060 | |
|
|
159e813c9c | |
|
|
bdf87ac255 | |
|
|
9a255aa135 | |
|
|
e134acbccb | |
|
|
29fe571436 | |
|
|
42bd1d4d16 | |
|
|
996ce42e80 | |
|
|
bac8f4166f | |
|
|
43234ed47d | |
|
|
309e3aef5e | |
|
|
f711855ddf | |
|
|
3affcbfa85 | |
|
|
2df9a1dd48 | |
|
|
15b524404b | |
|
|
b10d276d96 | |
|
|
19401112d2 | |
|
|
9fab90b617 | |
|
|
9893ac8734 | |
|
|
8fb7559310 | |
|
|
a5e46fd659 | |
|
|
4c7ddd266b | |
|
|
904ab5456e | |
|
|
4ae3e33103 | |
|
|
9bb1b7aa91 | |
|
|
143fd96c3f | |
|
|
b8144da5f0 | |
|
|
96d16b173b | |
|
|
7d2a5d3355 | |
|
|
f9e93b356f | |
|
|
13878a1a30 | |
|
|
fd3f9b169c | |
|
|
e8885f7798 | |
|
|
6d2659ac97 | |
|
|
9a00c10da4 | |
|
|
d7a3a03357 | |
|
|
4e02102610 | |
|
|
ae13ca9c1a | |
|
|
dd5d74fa4f | |
|
|
1ac1b75f6d | |
|
|
518f60fc11 | |
|
|
7f3d07bc29 | |
|
|
d959045889 | |
|
|
f30d59b989 | |
|
|
352403d79e | |
|
|
c3bb16f93e | |
|
|
dfb9971849 | |
|
|
cc5a4068d2 | |
|
|
b437c470e1 | |
|
|
c32f92a902 | |
|
|
4efe579d90 | |
|
|
6a82a87e26 | |
|
|
d6fa7fd866 | |
|
|
87fd52de87 | |
|
|
59187f2e41 | |
|
|
f8b2924f42 | |
|
|
ef4d7664ac | |
|
|
4a0de8c586 | |
|
|
4402faac1c | |
|
|
cda6fd2600 | |
|
|
1648c0b602 | |
|
|
ba68892759 | |
|
|
a1dfc81d4c | |
|
|
69f4450aee | |
|
|
c835deb3f9 | |
|
|
00bbfad055 | |
|
|
e452ce26ce | |
|
|
ab586d121a | |
|
|
670ddcfcb3 | |
|
|
48e5ef331a | |
|
|
142ccb175f | |
|
|
89a35d6e02 | |
|
|
575d87c022 | |
|
|
62b0c5d880 | |
|
|
3e6b3d5321 | |
|
|
a71c767174 | |
|
|
fa15eb5707 | |
|
|
ebb51cde16 | |
|
|
835864e8a8 | |
|
|
25dc3ec2f2 | |
|
|
7bb5767bf7 | |
|
|
6f1bf65726 | |
|
|
cd6a3c8b5c | |
|
|
939c8d493d | |
|
|
b7606ceee3 | |
|
|
9d348f8ef7 | |
|
|
71a3b383d3 | |
|
|
15cdbcb8a8 | |
|
|
f36c280d6d | |
|
|
b55d944514 | |
|
|
2daf8cb4be | |
|
|
c0b2cfc2ca | |
|
|
74f7ce31b6 | |
|
|
87752a227b | |
|
|
122b606bd7 | |
|
|
c398a07573 | |
|
|
0862e63389 | |
|
|
61e5073d25 | |
|
|
c754b85295 | |
|
|
b5ba730404 | |
|
|
0f319edca9 | |
|
|
3bad08a56b | |
|
|
5547923b84 | |
|
|
ebb9b35c10 | |
|
|
9dbbfb4a06 | |
|
|
67e6b3f32d | |
|
|
2f9ca313cb | |
|
|
5c281b1653 | |
|
|
fa6540b739 | |
|
|
b58c221e16 | |
|
|
445b6473f4 | |
|
|
580b70e1d4 | |
|
|
2ede64f1cb | |
|
|
930aa48c20 | |
|
|
e749bdd42c | |
|
|
44e0eff76e | |
|
|
51dedaa5b7 | |
|
|
e1822cb704 | |
|
|
a6d335fff9 | |
|
|
1b11098ddb | |
|
|
f0c0065b17 | |
|
|
ecec6381ed | |
|
|
c8c40ba839 | |
|
|
f1617bbd87 | |
|
|
1ebff8e7a5 | |
|
|
ad81f02db4 | |
|
|
5d89e01247 | |
|
|
62624786dc | |
|
|
7243f5eb06 | |
|
|
c972dab3d0 | |
|
|
605d20dd4f | |
|
|
7fd4aef060 | |
|
|
12360e9793 | |
|
|
7a499ce9ff | |
|
|
bff03ed78a | |
|
|
f67f02e107 | |
|
|
d65d712089 | |
|
|
1a818563b7 | |
|
|
c4683b026a | |
|
|
5bd9f41abe | |
|
|
336fbe094b | |
|
|
c0a6d6f2f9 | |
|
|
4cd88384ba | |
|
|
200d6f55ab | |
|
|
8825a98ca2 | |
|
|
76501d6ae3 | |
|
|
509a1530f6 | |
|
|
ffaede31b9 | |
|
|
2c91cdaba3 | |
|
|
9440835c94 | |
|
|
0dfc6b5cd8 | |
|
|
39fcaa15cb | |
|
|
c3547fcb61 | |
|
|
16369acbda | |
|
|
eed0a26b6f | |
|
|
fb0aafa04b | |
|
|
e61e3935c8 | |
|
|
680d24b686 | |
|
|
f700627675 | |
|
|
eafa98b771 | |
|
|
be2590347f | |
|
|
27d72f8776 | |
|
|
a2e1128670 | |
|
|
e82c1aedf0 | |
|
|
4aa36cb893 | |
|
|
bc60339806 | |
|
|
f9e49bed1c | |
|
|
8d03d284d9 | |
|
|
8cfe380784 | |
|
|
dff33662fc | |
|
|
018c94cba1 | |
|
|
d38fe5b173 | |
|
|
ec8f308c53 | |
|
|
11b120a55c | |
|
|
cd5c222f95 | |
|
|
947cc16d92 | |
|
|
8228c2af17 | |
|
|
ae87353a28 | |
|
|
2e57ac8bc0 | |
|
|
343ef6fa84 | |
|
|
44784d664c | |
|
|
70db5be257 | |
|
|
f8c71e61dc | |
|
|
b6e1d6d9c4 | |
|
|
7e6923d53b | |
|
|
7d5a9459e4 | |
|
|
6f906e7cbd | |
|
|
497c88aa1e | |
|
|
33de1834c4 | |
|
|
66cec0f5d3 | |
|
|
a7c4ab6714 | |
|
|
6f0b4666f8 | |
|
|
6193055453 | |
|
|
ce7b8c7b74 | |
|
|
0ece86c478 | |
|
|
44ef416d2f | |
|
|
8c584590ef | |
|
|
92991bcc7c | |
|
|
77be504394 | |
|
|
5c2dfde334 | |
|
|
a6f1a9a871 | |
|
|
c9764be9a0 | |
|
|
1a42246909 | |
|
|
8481061a03 | |
|
|
dae150ea74 | |
|
|
05841277d1 | |
|
|
feaf914dcf | |
|
|
1dd005db0f | |
|
|
ec44e09ec8 | |
|
|
06838c422b | |
|
|
35535be419 | |
|
|
e4d22904ed | |
|
|
892a40274e | |
|
|
51560c49ae | |
|
|
6e4363be36 | |
|
|
5104d48346 | |
|
|
7fbee89144 | |
|
|
db791b5aed | |
|
|
50cd9a8f95 | |
|
|
658e3d16ad | |
|
|
246dfad018 | |
|
|
212cf1da7e | |
|
|
9fda1d690a | |
|
|
ed26fc10f5 | |
|
|
02bad5d754 | |
|
|
147e92ff39 | |
|
|
a49e795680 | |
|
|
a537565875 | |
|
|
5325c42637 | |
|
|
3e3e6d04b8 | |
|
|
043ecfeafa | |
|
|
16920188ee | |
|
|
fa17c01c3b | |
|
|
3dac9d715a | |
|
|
12323e406c | |
|
|
4858fed870 | |
|
|
876e1b38b9 | |
|
|
003c3c682d | |
|
|
8af3459577 | |
|
|
43b24c43dc | |
|
|
8673bb4717 | |
|
|
3fb938c733 | |
|
|
acdf78967f | |
|
|
dfd38a9559 | |
|
|
ed1e36912c | |
|
|
cc7b2b17c5 | |
|
|
1f0e20e150 | |
|
|
8559aec8bc | |
|
|
5c3b045979 | |
|
|
8e7b74d0ee | |
|
|
22055e081f | |
|
|
2aeafa136c | |
|
|
b75e7094c2 | |
|
|
a299d7a294 | |
|
|
918c2fdf7a | |
|
|
2ac8799cb0 | |
|
|
b2811788a7 | |
|
|
20334dc928 | |
|
|
8efe83aaa1 | |
|
|
bcafc4a474 | |
|
|
2e9dcdc26b | |
|
|
473ef910b6 | |
|
|
fb7b2878fd | |
|
|
86c53479a1 | |
|
|
3af3dda9b5 | |
|
|
b053adf175 | |
|
|
2220eed285 | |
|
|
7206900313 | |
|
|
5611449eaf | |
|
|
916ac4399f | |
|
|
e6c9a3932e | |
|
|
98b1cb8219 |
|
|
@ -1,6 +1,15 @@
|
|||
# Keep formatter-sensitive source files consistent across platforms.
|
||||
# Keep formatter-sensitive source files consistent across platforms. The three
|
||||
# apps/desktop rules below the .vue line exist for
|
||||
# docs_export_bundle_is_current, which hashes raw bytes: every extension it
|
||||
# manifests (.css, tsconfig.json, and the one .ts outside src/ —
|
||||
# vite.docs-export.config.ts) needs an explicit eol=lf, or a CRLF checkout
|
||||
# fails the guard for files the contributor never touched.
|
||||
apps/desktop/src/**/*.ts text eol=lf
|
||||
apps/desktop/src/**/*.vue text eol=lf
|
||||
apps/desktop/**/*.css text eol=lf
|
||||
apps/desktop/tsconfig.json text eol=lf
|
||||
apps/desktop/*.ts text eol=lf
|
||||
src-tauri/windows/nsis/**/*.nsi text eol=lf
|
||||
|
||||
# Keep tests available for review and CI without counting them as shipped code
|
||||
# in GitHub's repository language breakdown.
|
||||
|
|
@ -11,3 +20,13 @@ apps/desktop/src/**/*.test.ts linguist-vendored
|
|||
packages/**/tests/** linguist-vendored
|
||||
packages/**/*.spec.ts linguist-vendored
|
||||
packages/**/*.test.ts linguist-vendored
|
||||
|
||||
# Build output, committed because the Rust crate embeds it. Minified and large,
|
||||
# so it would drown every diff and review it appears in — `-diff` makes git
|
||||
# report "Binary files differ" instead. The manifest stays diffable on purpose:
|
||||
# it is how a reviewer sees which sources moved. `-text` disables any
|
||||
# eol conversion on checkout: docs_export_bundle_is_current hashes these two
|
||||
# files' raw bytes, and an autocrlf checkout would otherwise change those
|
||||
# bytes and fail the guard for a contributor who touched neither file.
|
||||
crates/dbx-core/assets/docs-export.js -text -diff linguist-generated
|
||||
crates/dbx-core/assets/docs-export.css -text -diff linguist-generated
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RuntimeDirectory = (Join-Path $PSScriptRoot "..\..\src-tauri\webview2-fixed-runtime"),
|
||||
[string]$LoaderPath = (Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-webview2-loader-probe\WebView2Loader.dll"),
|
||||
[string]$ExpectedVersion = "109.0.1518.78"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$runtimeDirectory = (Resolve-Path -LiteralPath $RuntimeDirectory).Path
|
||||
$runtimeExecutable = Join-Path $runtimeDirectory "msedgewebview2.exe"
|
||||
if (!(Test-Path -LiteralPath $runtimeExecutable -PathType Leaf)) {
|
||||
throw "WebView2 fixed runtime executable does not exist: $runtimeExecutable"
|
||||
}
|
||||
|
||||
$loaderPath = (Resolve-Path -LiteralPath $LoaderPath).Path
|
||||
$escapedLoaderPath = $loaderPath.Replace('"', '""')
|
||||
$source = @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class DbxWebView2LoaderProbe
|
||||
{
|
||||
[DllImport(@"$escapedLoaderPath", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
||||
public static extern int GetAvailableCoreWebView2BrowserVersionString(
|
||||
string browserExecutableFolder,
|
||||
out IntPtr versionInfo);
|
||||
}
|
||||
"@
|
||||
|
||||
Add-Type -TypeDefinition $source -Language CSharp
|
||||
$versionPointer = [IntPtr]::Zero
|
||||
$result = [DbxWebView2LoaderProbe]::GetAvailableCoreWebView2BrowserVersionString(
|
||||
$runtimeDirectory,
|
||||
[ref]$versionPointer
|
||||
)
|
||||
if ($result -ne 0) {
|
||||
throw "WebView2 loader failed to recognize fixed runtime at $runtimeDirectory (HRESULT 0x$($result.ToString('X8')))."
|
||||
}
|
||||
if ($versionPointer -eq [IntPtr]::Zero) {
|
||||
throw "WebView2 loader returned an empty version pointer for $runtimeDirectory."
|
||||
}
|
||||
|
||||
try {
|
||||
$version = [Runtime.InteropServices.Marshal]::PtrToStringUni($versionPointer)
|
||||
}
|
||||
finally {
|
||||
[Runtime.InteropServices.Marshal]::FreeCoTaskMem($versionPointer)
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($version) -or !$version.StartsWith($ExpectedVersion)) {
|
||||
throw "Expected WebView2 fixed runtime $ExpectedVersion, detected '$version'."
|
||||
}
|
||||
|
||||
Write-Host "WebView2 fixed runtime probe passed: loader=$loaderPath runtime=$runtimeDirectory version=$version"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InstallerPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$installerPath = (Resolve-Path -LiteralPath $InstallerPath).Path
|
||||
$installDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-installer-audit"
|
||||
if (Test-Path -LiteralPath $installDirectory) {
|
||||
Remove-Item -LiteralPath $installDirectory -Recurse -Force
|
||||
}
|
||||
|
||||
$installer = Start-Process -FilePath $installerPath -ArgumentList @("/S", "/D=$installDirectory") -Wait -PassThru
|
||||
if ($installer.ExitCode -ne 0) {
|
||||
throw "Windows 7 test installer failed with exit code $($installer.ExitCode)."
|
||||
}
|
||||
|
||||
$expectedFiles = @(
|
||||
(Join-Path $installDirectory "dbx.exe"),
|
||||
(Join-Path $installDirectory "webview2-fixed-runtime\msedgewebview2.exe"),
|
||||
(Join-Path $installDirectory "uninstall.exe")
|
||||
)
|
||||
foreach ($path in $expectedFiles) {
|
||||
if (!(Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "Windows 7 test installer omitted required file: $path"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Windows 7 installer content audit passed: $installerPath"
|
||||
|
||||
$uninstallerPath = Join-Path $installDirectory "uninstall.exe"
|
||||
$uninstaller = Start-Process -FilePath $uninstallerPath -ArgumentList @("/S", "_?=$installDirectory") -Wait -PassThru
|
||||
if ($uninstaller.ExitCode -ne 0) {
|
||||
Write-Warning "Windows 7 test uninstaller returned exit code $($uninstaller.ExitCode)."
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BinaryPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (!(Test-Path -LiteralPath $BinaryPath -PathType Leaf)) {
|
||||
throw "Windows 7 PE audit target does not exist: $BinaryPath"
|
||||
}
|
||||
|
||||
$dumpbinCommand = Get-Command dumpbin.exe -ErrorAction SilentlyContinue
|
||||
$dumpbinPath = if ($null -ne $dumpbinCommand) { $dumpbinCommand.Source } else { $null }
|
||||
if ($null -eq $dumpbinPath) {
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (Test-Path -LiteralPath $vswhere) {
|
||||
$visualStudio = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
if ($visualStudio) {
|
||||
$dumpbin = Get-ChildItem (Join-Path $visualStudio "VC\Tools\MSVC") -Filter dumpbin.exe -Recurse |
|
||||
Where-Object { $_.FullName -match '\\bin\\Hostx64\\x64\\dumpbin\.exe$' } |
|
||||
Sort-Object FullName -Descending |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $dumpbin) {
|
||||
$dumpbinPath = $dumpbin.FullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $dumpbinPath) {
|
||||
throw "Unable to find dumpbin.exe for the Windows 7 PE compatibility audit."
|
||||
}
|
||||
|
||||
$imports = (& $dumpbinPath /nologo /imports $BinaryPath 2>&1 | Out-String)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dumpbin failed while auditing ${BinaryPath}:`n$imports"
|
||||
}
|
||||
|
||||
$forbiddenImports = [ordered]@{
|
||||
"combase.dll" = "COMBASE is only available starting with Windows 8; use OLE32 imports."
|
||||
"api-ms-win-core-winrt-" = "WinRT API sets are unavailable on Windows 7."
|
||||
"CoIncrementMTAUsage" = "CoIncrementMTAUsage is unavailable on Windows 7."
|
||||
"EventSetInformation" = "EventSetInformation is unavailable on Windows 7. Use the legacy WebView2 loader."
|
||||
"GetSystemTimePreciseAsFileTime" = "GetSystemTimePreciseAsFileTime is unavailable on Windows 7."
|
||||
"GetDpiForWindow" = "GetDpiForWindow is unavailable on Windows 7."
|
||||
"GetSystemMetricsForDpi" = "GetSystemMetricsForDpi is unavailable on Windows 7."
|
||||
"SetThreadDpiAwarenessContext" = "SetThreadDpiAwarenessContext is unavailable on Windows 7."
|
||||
"VCRUNTIME140.dll" = "The Windows 7 package must not require a separately installed VC++ Runtime."
|
||||
"VCRUNTIME140_1.dll" = "The Windows 7 package must not require a separately installed VC++ Runtime."
|
||||
"MSVCP140.dll" = "The Windows 7 package must not require a separately installed VC++ Runtime."
|
||||
"ucrtbase.dll" = "The Windows 7 package must link the Universal CRT statically."
|
||||
"api-ms-win-crt-" = "The Windows 7 package must not require separately installed Universal CRT API sets."
|
||||
}
|
||||
|
||||
$violations = @()
|
||||
foreach ($entry in $forbiddenImports.GetEnumerator()) {
|
||||
if ($imports -match [regex]::Escape($entry.Key)) {
|
||||
$violations += "$($entry.Key): $($entry.Value)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations.Count -gt 0) {
|
||||
$summary = $violations -join "`n"
|
||||
Write-Host "Full PE import table for diagnosis:"
|
||||
Write-Host $imports
|
||||
throw "Windows 7 incompatible PE imports detected in ${BinaryPath}:`n$summary"
|
||||
}
|
||||
|
||||
Write-Host "Windows 7 PE import audit passed: $BinaryPath"
|
||||
|
|
@ -3,6 +3,12 @@ import { execFileSync } from "node:child_process";
|
|||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const VERSIONS_PATH = "agents/versions.json";
|
||||
const VERSION_SYNC_SUBJECT = "chore: bump module versions [skip ci]";
|
||||
const JRE_BUILD_PATHS = new Set([".github/workflows/agents-release.yml"]);
|
||||
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([
|
||||
".github/scripts/reuse-agent-release-assets.mjs",
|
||||
"agents/scripts/version_agent_artifacts.py",
|
||||
]);
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version);
|
||||
|
|
@ -43,11 +49,16 @@ function fileContainsCommonDependency(path, moduleExists, readModuleFile) {
|
|||
}
|
||||
|
||||
const nativeDriverDirectories = {
|
||||
cassandra: "cassandra-go",
|
||||
duckdb: "duckdb",
|
||||
oracle: "oracle-go",
|
||||
kingbase: "kingbase-go",
|
||||
neo4j: "neo4j-go",
|
||||
vastbase: "vastbase-go",
|
||||
rabbitmq: "rabbitmq",
|
||||
tdengine: "tdengine",
|
||||
};
|
||||
const nativeDriverModules = new Set(["cassandra", "duckdb", "oracle", "xugu", "kingbase", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
|
||||
|
||||
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
|
||||
let checkDir = null;
|
||||
|
|
@ -70,10 +81,18 @@ function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists,
|
|||
return {
|
||||
checkDir,
|
||||
modulePath,
|
||||
javaBuild: hasBuildGradle,
|
||||
nativeBuild: nativeDriverModules.has(moduleName),
|
||||
commonDependent: hasBuildGradle && (explicitlyDependsOnCommon || !legacyStandaloneModules.has(moduleName)),
|
||||
};
|
||||
}
|
||||
|
||||
function classifyModules(versions, options) {
|
||||
return Object.keys(versions)
|
||||
.map((moduleName) => ({ moduleName, module: resolveAgentModule(moduleName, options) }))
|
||||
.filter(({ module }) => module);
|
||||
}
|
||||
|
||||
export function evaluateAgentVersionBump({
|
||||
versions,
|
||||
prevVersions = versions,
|
||||
|
|
@ -87,6 +106,11 @@ export function evaluateAgentVersionBump({
|
|||
const nextVersions = { ...versions };
|
||||
const logs = [];
|
||||
let changed = false;
|
||||
const changedModules = [];
|
||||
const javaModules = [];
|
||||
const nativeModules = [];
|
||||
const reusedModules = [];
|
||||
const resolvedModules = classifyModules(versions, { legacyStandaloneModules, moduleExists, readModuleFile });
|
||||
|
||||
if (manualVersionsChanged && !skipBump) {
|
||||
logs.push("Manual agents/versions.json changes detected; preserving manually changed module versions and auto-bumping the rest.");
|
||||
|
|
@ -94,35 +118,50 @@ export function evaluateAgentVersionBump({
|
|||
|
||||
if (skipBump) {
|
||||
logs.push("Skipping automatic module version bump for migrated first release; versions.json was carried over from dbx-agents.");
|
||||
return { changed, versions: nextVersions, prevVersions, logs };
|
||||
for (const { moduleName, module } of resolvedModules) {
|
||||
changedModules.push(moduleName);
|
||||
if (module.javaBuild) javaModules.push(moduleName);
|
||||
if (module.nativeBuild) nativeModules.push(moduleName);
|
||||
}
|
||||
return { changed, versions: nextVersions, prevVersions, logs, changedModules, javaModules, nativeModules, reusedModules };
|
||||
}
|
||||
|
||||
const commonChanged = changedFiles.some(isCommonRuntimeChange);
|
||||
if (commonChanged) {
|
||||
logs.push("Common agent runtime changes detected; common-triggered bumps are limited to modules that package agents/common.");
|
||||
}
|
||||
const nativeReleasePackagingChanged = changedFiles.some((file) => NATIVE_RELEASE_PACKAGING_PATHS.has(file));
|
||||
if (nativeReleasePackagingChanged) {
|
||||
logs.push("Shared native release packaging changes detected; all native modules will be rebuilt.");
|
||||
}
|
||||
|
||||
for (const moduleName of Object.keys(versions)) {
|
||||
const module = resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile });
|
||||
if (!module) continue;
|
||||
|
||||
for (const { moduleName, module } of resolvedModules) {
|
||||
const moduleChanged = pathChanged(changedFiles, module.modulePath);
|
||||
// Only modules that package agents/common need installer-visible updates
|
||||
// for shared Java runtime changes; native and standalone agents do not.
|
||||
const commonAffectsModule = commonChanged && module.commonDependent;
|
||||
const nativePackagingAffectsModule = nativeReleasePackagingChanged && module.nativeBuild;
|
||||
const oldVersion = nextVersions[moduleName] ?? "0.1.0";
|
||||
const prevVersion = prevVersions[moduleName] ?? "";
|
||||
const manuallyVersioned = manualVersionsChanged && (!prevVersion || prevVersion !== oldVersion);
|
||||
const moduleNeedsBuild = moduleChanged || commonAffectsModule || nativePackagingAffectsModule || manuallyVersioned;
|
||||
|
||||
if (!moduleChanged && !commonAffectsModule) {
|
||||
if (!moduleNeedsBuild) {
|
||||
logs.push(` ${moduleName}: no changes`);
|
||||
reusedModules.push(moduleName);
|
||||
} else if (manuallyVersioned) {
|
||||
changedModules.push(moduleName);
|
||||
if (module.javaBuild) javaModules.push(moduleName);
|
||||
if (module.nativeBuild) nativeModules.push(moduleName);
|
||||
if (!prevVersion) {
|
||||
logs.push(` ${moduleName}: CHANGED, new module version kept at ${oldVersion}`);
|
||||
} else {
|
||||
logs.push(` ${moduleName}: CHANGED, manual version ${prevVersion} -> ${oldVersion}`);
|
||||
}
|
||||
} else {
|
||||
changedModules.push(moduleName);
|
||||
if (module.javaBuild) javaModules.push(moduleName);
|
||||
if (module.nativeBuild) nativeModules.push(moduleName);
|
||||
const newVersion = bumpPatchVersion(oldVersion);
|
||||
nextVersions[moduleName] = newVersion;
|
||||
changed = true;
|
||||
|
|
@ -131,7 +170,7 @@ export function evaluateAgentVersionBump({
|
|||
}
|
||||
}
|
||||
|
||||
return { changed, versions: nextVersions, prevVersions, logs };
|
||||
return { changed, versions: nextVersions, prevVersions, logs, changedModules, javaModules, nativeModules, reusedModules };
|
||||
}
|
||||
|
||||
export function getAgentVersionChanges(previousVersions, nextVersions) {
|
||||
|
|
@ -148,6 +187,62 @@ function git(args) {
|
|||
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function lines(value) {
|
||||
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutput = git }) {
|
||||
const allChangedFiles = lines(gitOutput(["diff", "--name-only", `${prevTag}..${headRef}`]));
|
||||
const versionCommits = lines(
|
||||
gitOutput([
|
||||
"log",
|
||||
"--reverse",
|
||||
"--ancestry-path",
|
||||
"--format=%H%x09%s",
|
||||
`${prevTag}..${headRef}`,
|
||||
"--",
|
||||
VERSIONS_PATH,
|
||||
]),
|
||||
);
|
||||
|
||||
let syncCommit = "";
|
||||
for (const entry of versionCommits) {
|
||||
const separator = entry.indexOf("\t");
|
||||
if (separator < 0 || entry.slice(separator + 1) !== VERSION_SYNC_SUBJECT) continue;
|
||||
|
||||
const commit = entry.slice(0, separator);
|
||||
const changedPaths = lines(gitOutput(["diff-tree", "--no-commit-id", "--name-only", "-r", commit]));
|
||||
if (changedPaths.length !== 1 || changedPaths[0] !== VERSIONS_PATH) continue;
|
||||
|
||||
JSON.parse(gitOutput(["show", `${commit}:${VERSIONS_PATH}`]));
|
||||
syncCommit = commit;
|
||||
break;
|
||||
}
|
||||
|
||||
const versionsRef = syncCommit || prevTag;
|
||||
const versions = JSON.parse(gitOutput(["show", `${versionsRef}:${VERSIONS_PATH}`]));
|
||||
const versionsChangedAfterSync = syncCommit
|
||||
? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0
|
||||
: false;
|
||||
const changedFiles = syncCommit && !versionsChangedAfterSync
|
||||
? allChangedFiles.filter((file) => file !== VERSIONS_PATH)
|
||||
: allChangedFiles;
|
||||
|
||||
return {
|
||||
prevTag,
|
||||
versionsRef,
|
||||
syncCommit,
|
||||
versions,
|
||||
changedFiles,
|
||||
allChangedFiles,
|
||||
versionsChangedAfterSync,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldBuildAgentJre(changedFiles, migratedFirstRelease = false) {
|
||||
return migratedFirstRelease || changedFiles.some((file) => JRE_BUILD_PATHS.has(file));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
migratedFirstRelease: false,
|
||||
|
|
@ -180,7 +275,7 @@ function parseArgs(argv) {
|
|||
return options;
|
||||
}
|
||||
|
||||
function outputStepValues(result, prevTag, migratedFirstRelease) {
|
||||
function outputStepValues(result, baseline, migratedFirstRelease, buildJre) {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (!outputPath) return;
|
||||
|
||||
|
|
@ -189,7 +284,14 @@ function outputStepValues(result, prevTag, migratedFirstRelease) {
|
|||
[
|
||||
`versions=${JSON.stringify(result.versions)}`,
|
||||
`prev_versions=${JSON.stringify(result.prevVersions)}`,
|
||||
`prev_tag=${prevTag}`,
|
||||
`prev_tag=${baseline.prevTag}`,
|
||||
`effective_prev_ref=${baseline.versionsRef}`,
|
||||
`changed_modules=${JSON.stringify(result.changedModules)}`,
|
||||
`java_modules=${JSON.stringify(result.javaModules)}`,
|
||||
`native_modules=${JSON.stringify(result.nativeModules)}`,
|
||||
`reuse_modules=${JSON.stringify(migratedFirstRelease ? [] : result.reusedModules)}`,
|
||||
`build_jre=${buildJre}`,
|
||||
`reuse_jre=${!migratedFirstRelease && !buildJre}`,
|
||||
`migrated_first_release=${migratedFirstRelease}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
|
|
@ -200,13 +302,18 @@ function main() {
|
|||
const options = parseArgs(process.argv.slice(2));
|
||||
const versions = JSON.parse(readFileSync(VERSIONS_PATH, "utf8"));
|
||||
const legacyStandaloneModules = parseLegacyStandaloneProjects(readFileSync("agents/build.gradle", "utf8"));
|
||||
const changedFiles = options.skipBump ? [] : git(["diff", "--name-only", `${options.prevTag}..HEAD`]).split("\n").filter(Boolean);
|
||||
const baseline = options.prevVersionsFile
|
||||
? {
|
||||
prevTag: options.prevTag,
|
||||
versionsRef: options.prevTag,
|
||||
syncCommit: "",
|
||||
versions: JSON.parse(readFileSync(options.prevVersionsFile, "utf8")),
|
||||
changedFiles: lines(git(["diff", "--name-only", `${options.prevTag}..HEAD`])),
|
||||
}
|
||||
: resolveAgentReleaseBaseline({ prevTag: options.prevTag });
|
||||
const changedFiles = options.skipBump ? [] : baseline.changedFiles;
|
||||
const manualVersionsChanged = changedFiles.includes(VERSIONS_PATH);
|
||||
const prevVersions = options.prevVersionsFile
|
||||
? JSON.parse(readFileSync(options.prevVersionsFile, "utf8"))
|
||||
: manualVersionsChanged
|
||||
? JSON.parse(git(["show", `${options.prevTag}:${VERSIONS_PATH}`]))
|
||||
: versions;
|
||||
const prevVersions = baseline.versions;
|
||||
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions,
|
||||
|
|
@ -226,7 +333,8 @@ function main() {
|
|||
writeFileSync(VERSIONS_PATH, versionsJson);
|
||||
}
|
||||
console.log(versionsJson);
|
||||
outputStepValues(result, options.prevTag, options.migratedFirstRelease);
|
||||
const buildJre = shouldBuildAgentJre(baseline.changedFiles, options.migratedFirstRelease);
|
||||
outputStepValues(result, baseline, options.migratedFirstRelease, buildJre);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { evaluateAgentVersionBump } from "./bump-agent-versions.mjs";
|
||||
import { evaluateAgentVersionBump, resolveAgentReleaseBaseline, shouldBuildAgentJre } from "./bump-agent-versions.mjs";
|
||||
|
||||
const moduleExists = (path) => path === "agents/drivers/duckdb";
|
||||
|
||||
|
|
@ -30,6 +34,20 @@ test("bumps DuckDB after its initial release", () => {
|
|||
assert.equal(result.versions.duckdb, "0.1.1");
|
||||
});
|
||||
|
||||
test("classifies TDengine Rust changes as native-only", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { tdengine: "0.1.39" },
|
||||
changedFiles: ["agents/drivers/tdengine/src/driver.rs"],
|
||||
moduleExists: (path) => path === "agents/drivers/tdengine",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.tdengine, "0.1.40");
|
||||
assert.deepEqual(result.changedModules, ["tdengine"]);
|
||||
assert.deepEqual(result.javaModules, []);
|
||||
assert.deepEqual(result.nativeModules, ["tdengine"]);
|
||||
});
|
||||
|
||||
test("bumps the native RabbitMQ agent from its Go directory", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { rabbitmq: "0.1.0" },
|
||||
|
|
@ -40,3 +58,194 @@ test("bumps the native RabbitMQ agent from its Go directory", () => {
|
|||
|
||||
assert.equal(result.versions.rabbitmq, "0.1.1");
|
||||
});
|
||||
|
||||
test("bumps the native Vastbase agent from its independent Go directory", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { vastbase: "0.1.37" },
|
||||
changedFiles: ["agents/drivers/vastbase-go/main.go"],
|
||||
moduleExists: (path) => path === "agents/drivers/vastbase-go",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.vastbase, "0.1.38");
|
||||
assert.deepEqual(result.nativeModules, ["vastbase"]);
|
||||
});
|
||||
|
||||
test("bumps Cassandra from its native Go source directory", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { cassandra: "0.1.37" },
|
||||
changedFiles: ["agents/drivers/cassandra-go/main.go"],
|
||||
moduleExists: (path) => path === "agents/drivers/cassandra-go",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.cassandra, "0.1.38");
|
||||
assert.deepEqual(result.nativeModules, ["cassandra"]);
|
||||
});
|
||||
|
||||
test("bumps Neo4j from its native Go source directory", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { neo4j: "0.1.39" },
|
||||
changedFiles: ["agents/drivers/neo4j-go/main.go"],
|
||||
moduleExists: (path) => path === "agents/drivers/neo4j-go",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.neo4j, "0.1.40");
|
||||
assert.deepEqual(result.javaModules, []);
|
||||
assert.deepEqual(result.nativeModules, ["neo4j"]);
|
||||
});
|
||||
|
||||
test("rebuilds native modules when shared native packaging changes", () => {
|
||||
const existing = new Set([
|
||||
"agents/drivers/access",
|
||||
"agents/drivers/access/build.gradle",
|
||||
"agents/drivers/duckdb",
|
||||
"agents/drivers/neo4j-go",
|
||||
]);
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { access: "0.1.37", duckdb: "0.1.3", neo4j: "0.1.40" },
|
||||
changedFiles: ["agents/scripts/version_agent_artifacts.py"],
|
||||
moduleExists: (path) => existing.has(path),
|
||||
readModuleFile: () => "implementation project(':common')",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.access, "0.1.37");
|
||||
assert.equal(result.versions.duckdb, "0.1.4");
|
||||
assert.equal(result.versions.neo4j, "0.1.41");
|
||||
assert.deepEqual(result.nativeModules, ["duckdb", "neo4j"]);
|
||||
assert.deepEqual(result.reusedModules, ["access"]);
|
||||
});
|
||||
|
||||
test("builds a manually versioned module even without runtime file changes", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { duckdb: "0.1.1" },
|
||||
prevVersions: { duckdb: "0.1.0" },
|
||||
changedFiles: ["agents/versions.json"],
|
||||
moduleExists,
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.deepEqual(result.changedModules, ["duckdb"]);
|
||||
assert.deepEqual(result.nativeModules, ["duckdb"]);
|
||||
assert.deepEqual(result.reusedModules, []);
|
||||
assert.equal(result.versions.duckdb, "0.1.1");
|
||||
});
|
||||
|
||||
test("bumps DuckDB when its Cargo target configuration changes", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { duckdb: "0.1.2" },
|
||||
changedFiles: ["agents/drivers/duckdb/.cargo/config.toml"],
|
||||
moduleExists: (path) => path === "agents/drivers/duckdb",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.duckdb, "0.1.3");
|
||||
assert.deepEqual(result.nativeModules, ["duckdb"]);
|
||||
});
|
||||
|
||||
test("builds only common-dependent Java modules for a shared runtime change", () => {
|
||||
const existing = new Set([
|
||||
"agents/drivers/access",
|
||||
"agents/drivers/access/build.gradle",
|
||||
"agents/drivers/mongodb",
|
||||
"agents/drivers/mongodb/build.gradle",
|
||||
]);
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { access: "0.1.0", mongodb: "0.1.0" },
|
||||
changedFiles: ["agents/common/src/main/java/com/dbx/Agent.java"],
|
||||
legacyStandaloneModules: new Set(["mongodb"]),
|
||||
moduleExists: (path) => existing.has(path),
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.deepEqual(result.changedModules, ["access"]);
|
||||
assert.deepEqual(result.javaModules, ["access"]);
|
||||
assert.deepEqual(result.reusedModules, ["mongodb"]);
|
||||
assert.equal(result.versions.access, "0.1.1");
|
||||
assert.equal(result.versions.mongodb, "0.1.0");
|
||||
});
|
||||
|
||||
test("rebuilds JREs only for the first migration or release recipe changes", () => {
|
||||
assert.equal(shouldBuildAgentJre(["agents/drivers/access/src/main/java/Agent.java"]), false);
|
||||
assert.equal(shouldBuildAgentJre([".github/workflows/agents-release.yml"]), true);
|
||||
assert.equal(shouldBuildAgentJre([], true), true);
|
||||
});
|
||||
|
||||
test("uses the first post-tag version sync as the effective release baseline", () => {
|
||||
const repository = createRepository({ kingbase: "0.1.0" });
|
||||
git(repository, ["tag", "agents-v0.2.72"]);
|
||||
|
||||
writeVersions(repository, { kingbase: "0.1.1" });
|
||||
commitAll(repository, "chore: bump module versions [skip ci]");
|
||||
const syncCommit = git(repository, ["rev-parse", "HEAD"]);
|
||||
|
||||
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n\nconst fixed = true\n");
|
||||
commitAll(repository, "fix(kingbase): export primary key columns");
|
||||
|
||||
const baseline = resolveAgentReleaseBaseline({
|
||||
prevTag: "agents-v0.2.72",
|
||||
gitOutput: (args) => git(repository, args),
|
||||
});
|
||||
|
||||
assert.equal(baseline.versionsRef, syncCommit);
|
||||
assert.deepEqual(baseline.versions, { kingbase: "0.1.1" });
|
||||
assert.deepEqual(baseline.changedFiles, ["agents/drivers/kingbase-go/kingbase_metadata.go"]);
|
||||
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { kingbase: "0.1.1" },
|
||||
prevVersions: baseline.versions,
|
||||
changedFiles: baseline.changedFiles,
|
||||
moduleExists: (path) => path === "agents/drivers/kingbase-go",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
assert.equal(result.versions.kingbase, "0.1.2");
|
||||
assert.deepEqual(result.nativeModules, ["kingbase"]);
|
||||
});
|
||||
|
||||
test("keeps versions.json publish-relevant when it changes after the sync commit", () => {
|
||||
const repository = createRepository({ duckdb: "0.1.0" });
|
||||
git(repository, ["tag", "agents-v0.2.72"]);
|
||||
|
||||
writeVersions(repository, { duckdb: "0.1.1" });
|
||||
commitAll(repository, "chore: bump module versions [skip ci]");
|
||||
writeVersions(repository, { duckdb: "0.1.2" });
|
||||
commitAll(repository, "chore: adjust DuckDB agent version");
|
||||
|
||||
const baseline = resolveAgentReleaseBaseline({
|
||||
prevTag: "agents-v0.2.72",
|
||||
gitOutput: (args) => git(repository, args),
|
||||
});
|
||||
|
||||
assert.equal(baseline.versionsChangedAfterSync, true);
|
||||
assert.deepEqual(baseline.versions, { duckdb: "0.1.1" });
|
||||
assert.deepEqual(baseline.changedFiles, ["agents/versions.json"]);
|
||||
});
|
||||
|
||||
function createRepository(versions) {
|
||||
const repository = mkdtempSync(join(tmpdir(), "dbx-agent-release-"));
|
||||
git(repository, ["init", "--initial-branch=main"]);
|
||||
git(repository, ["config", "user.name", "DBX Test"]);
|
||||
git(repository, ["config", "user.email", "dbx-test@example.com"]);
|
||||
mkdirSync(join(repository, "agents/drivers/kingbase-go"), { recursive: true });
|
||||
mkdirSync(join(repository, "agents/drivers/duckdb"), { recursive: true });
|
||||
writeVersions(repository, versions);
|
||||
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n");
|
||||
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), "[package]\nname = \"duckdb-test\"\n");
|
||||
commitAll(repository, "feat(agents): initial release state");
|
||||
return repository;
|
||||
}
|
||||
|
||||
function writeVersions(repository, versions) {
|
||||
mkdirSync(join(repository, "agents"), { recursive: true });
|
||||
writeFileSync(join(repository, "agents/versions.json"), `${JSON.stringify(versions, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function commitAll(repository, message) {
|
||||
git(repository, ["add", "."]);
|
||||
git(repository, ["commit", "-m", message]);
|
||||
}
|
||||
|
||||
function git(repository, args) {
|
||||
return execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const POM_PATH = "plugins/jdbc/pom.xml";
|
||||
const BUILD_GRADLE_PATH = "plugins/jdbc/build.gradle";
|
||||
const MANIFEST_PATH = "plugins/jdbc/manifest.json";
|
||||
|
||||
function firstProjectVersion(pomXml) {
|
||||
const match = pomXml.match(/<project[\s\S]*?<version>([^<]+)<\/version>/);
|
||||
function gradleVersion(buildGradle) {
|
||||
const match = buildGradle.match(/^version\s*=\s*['"]([^'"]+)['"]/m);
|
||||
return match?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
|
|
@ -27,16 +27,16 @@ function isReleaseBumpRelevantJdbcPluginChange(file) {
|
|||
if (!file.startsWith("plugins/jdbc/")) return false;
|
||||
if (file.startsWith("plugins/jdbc/dist/") || file.startsWith("plugins/jdbc/target/")) return false;
|
||||
if (file === "plugins/jdbc/README.md" || file === "plugins/jdbc/package.sh") return false;
|
||||
if (file === POM_PATH || file === MANIFEST_PATH) return false;
|
||||
if (file === BUILD_GRADLE_PATH || file === MANIFEST_PATH) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasJdbcPluginVersionChange(file) {
|
||||
return file === POM_PATH || file === MANIFEST_PATH;
|
||||
return file === MANIFEST_PATH;
|
||||
}
|
||||
|
||||
function updatePomVersion(pomXml, version) {
|
||||
return pomXml.replace(/(<project[\s\S]*?<version>)([^<]+)(<\/version>)/, `$1${version}$3`);
|
||||
function updateGradleVersion(buildGradle, version) {
|
||||
return buildGradle.replace(/^(version\s*=\s*)(['"])[^'"]+\2/m, (_, prefix, quote) => `${prefix}${quote}${version}${quote}`);
|
||||
}
|
||||
|
||||
function updateManifestVersion(manifestJson, version) {
|
||||
|
|
@ -45,20 +45,20 @@ function updateManifestVersion(manifestJson, version) {
|
|||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function evaluateJdbcPluginReleaseBump({ changedFiles, pomXml, manifestJson }) {
|
||||
const pomVersion = firstProjectVersion(pomXml);
|
||||
export function evaluateJdbcPluginReleaseBump({ changedFiles, buildGradle, manifestJson }) {
|
||||
const currentGradleVersion = gradleVersion(buildGradle);
|
||||
const currentManifestVersion = manifestVersion(manifestJson);
|
||||
if (pomVersion !== currentManifestVersion) {
|
||||
throw new Error(`JDBC plugin version mismatch: pom.xml is ${pomVersion} but manifest.json is ${currentManifestVersion}.`);
|
||||
if (currentGradleVersion !== currentManifestVersion) {
|
||||
throw new Error(`JDBC plugin version mismatch: build.gradle is ${currentGradleVersion} but manifest.json is ${currentManifestVersion}.`);
|
||||
}
|
||||
|
||||
const shouldBump = changedFiles.some(isReleaseBumpRelevantJdbcPluginChange) && !changedFiles.some(hasJdbcPluginVersionChange);
|
||||
const newVersion = shouldBump ? bumpPatchVersion(pomVersion) : pomVersion;
|
||||
const newVersion = shouldBump ? bumpPatchVersion(currentGradleVersion) : currentGradleVersion;
|
||||
return {
|
||||
changed: shouldBump,
|
||||
oldVersion: pomVersion,
|
||||
oldVersion: currentGradleVersion,
|
||||
newVersion,
|
||||
pomXml: shouldBump ? updatePomVersion(pomXml, newVersion) : pomXml,
|
||||
buildGradle: shouldBump ? updateGradleVersion(buildGradle, newVersion) : buildGradle,
|
||||
manifestJson: shouldBump ? updateManifestVersion(manifestJson, newVersion) : manifestJson,
|
||||
};
|
||||
}
|
||||
|
|
@ -73,12 +73,12 @@ function main() {
|
|||
const changedFiles = git(["diff", "--name-only", baseRef, headRef]).split("\n").filter(Boolean);
|
||||
const result = evaluateJdbcPluginReleaseBump({
|
||||
changedFiles,
|
||||
pomXml: readFileSync(POM_PATH, "utf8"),
|
||||
buildGradle: readFileSync(BUILD_GRADLE_PATH, "utf8"),
|
||||
manifestJson: readFileSync(MANIFEST_PATH, "utf8"),
|
||||
});
|
||||
|
||||
if (write && result.changed) {
|
||||
writeFileSync(POM_PATH, result.pomXml);
|
||||
writeFileSync(BUILD_GRADLE_PATH, result.buildGradle);
|
||||
writeFileSync(MANIFEST_PATH, result.manifestJson);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const POM_PATH = "plugins/jdbc/pom.xml";
|
||||
const BUILD_GRADLE_PATH = "plugins/jdbc/build.gradle";
|
||||
const MANIFEST_PATH = "plugins/jdbc/manifest.json";
|
||||
|
||||
function firstProjectVersion(pomXml) {
|
||||
const match = pomXml.match(/<project[\s\S]*?<version>([^<]+)<\/version>/);
|
||||
export function jdbcGradleVersion(buildGradle) {
|
||||
const match = buildGradle.match(/^version\s*=\s*['"]([^'"]+)['"]/m);
|
||||
return match?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
|
|
@ -13,10 +13,10 @@ function manifestVersion(manifestJson) {
|
|||
return JSON.parse(manifestJson).version ?? "";
|
||||
}
|
||||
|
||||
export function evaluateJdbcPluginVersionChange({ headPomVersion, headManifestVersion }) {
|
||||
export function evaluateJdbcPluginVersionChange({ headGradleVersion, headManifestVersion }) {
|
||||
const errors = [];
|
||||
if (headPomVersion !== headManifestVersion) {
|
||||
errors.push(`JDBC plugin version mismatch: pom.xml is ${headPomVersion} but manifest.json is ${headManifestVersion}.`);
|
||||
if (headGradleVersion !== headManifestVersion) {
|
||||
errors.push(`JDBC plugin version mismatch: build.gradle is ${headGradleVersion} but manifest.json is ${headManifestVersion}.`);
|
||||
return errors;
|
||||
}
|
||||
return errors;
|
||||
|
|
@ -32,10 +32,10 @@ function readFileAt(ref, path) {
|
|||
|
||||
function main() {
|
||||
const [, headRef = "HEAD"] = process.argv.slice(2);
|
||||
const headPomVersion = firstProjectVersion(readFileAt(headRef, POM_PATH));
|
||||
const headGradleVersion = jdbcGradleVersion(readFileAt(headRef, BUILD_GRADLE_PATH));
|
||||
const headManifestVersion = manifestVersion(readFileAt(headRef, MANIFEST_PATH));
|
||||
const errors = evaluateJdbcPluginVersionChange({
|
||||
headPomVersion,
|
||||
headGradleVersion,
|
||||
headManifestVersion,
|
||||
});
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ function main() {
|
|||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`JDBC plugin version check passed (${headPomVersion}).`);
|
||||
console.log(`JDBC plugin version check passed (${headGradleVersion}).`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ const supplementalDrivers = [
|
|||
{ dbType: "kafka", label: "Apache Kafka" },
|
||||
{ dbType: "rocketmq", label: "Apache RocketMQ" },
|
||||
{ dbType: "rabbitmq", label: "RabbitMQ" },
|
||||
{ dbType: "mqtt", label: "MQTT", aliases: ["emqx", "hivemq", "mosquitto"] },
|
||||
];
|
||||
|
||||
const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url);
|
||||
|
|
|
|||
|
|
@ -201,9 +201,7 @@ function assertSamePlaceholders(key, source, translated, locale) {
|
|||
const sourcePlaceholders = placeholders(source);
|
||||
const translatedPlaceholders = placeholders(translated);
|
||||
if (sourcePlaceholders.join("\0") !== translatedPlaceholders.join("\0")) {
|
||||
throw new Error(
|
||||
`${locale}:${key} placeholder mismatch: expected [${sourcePlaceholders.join(", ")}], got [${translatedPlaceholders.join(", ")}]`,
|
||||
);
|
||||
throw new Error(`${locale}:${key} placeholder mismatch: expected [${sourcePlaceholders.join(", ")}], got [${translatedPlaceholders.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +360,7 @@ function flattenNode(node, path, result) {
|
|||
const nextPath = [...path, property.key];
|
||||
if (property.value.type === "object") {
|
||||
flattenNode(property.value, nextPath, result);
|
||||
} else {
|
||||
} else if (property.value.type === "string") {
|
||||
result.set(nextPath.join("."), property.value.value);
|
||||
}
|
||||
}
|
||||
|
|
@ -416,6 +414,12 @@ class Parser {
|
|||
const start = this.index;
|
||||
const key = this.parseKey();
|
||||
this.skipSpace();
|
||||
if (this.peek() === "," || this.peek() === "}") {
|
||||
const hasComma = this.peek() === ",";
|
||||
if (hasComma) this.index += 1;
|
||||
properties.push({ key, start, end: this.index, hasComma, value: { type: "external" } });
|
||||
continue;
|
||||
}
|
||||
this.expect(":");
|
||||
this.skipSpace();
|
||||
const value = this.parseValue();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { evaluateJdbcPluginReleaseBump } from "./bump-jdbc-plugin-version.mjs";
|
||||
import { evaluateJdbcPluginVersionChange, jdbcGradleVersion } from "./check-jdbc-plugin-version.mjs";
|
||||
|
||||
const buildGradle = `plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
version = '0.1.26'
|
||||
`;
|
||||
const manifestJson = `${JSON.stringify({ version: "0.1.26" }, null, 2)}\n`;
|
||||
|
||||
test("reads the JDBC plugin version from Gradle", () => {
|
||||
assert.equal(jdbcGradleVersion(buildGradle), "0.1.26");
|
||||
});
|
||||
|
||||
test("checks Gradle and manifest versions together", () => {
|
||||
assert.deepEqual(
|
||||
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }),
|
||||
[],
|
||||
);
|
||||
assert.match(
|
||||
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0],
|
||||
/build\.gradle is 0\.1\.27/,
|
||||
);
|
||||
});
|
||||
|
||||
test("bumps Gradle and manifest versions for JDBC source changes", () => {
|
||||
const result = evaluateJdbcPluginReleaseBump({
|
||||
changedFiles: ["plugins/jdbc/build.gradle", "plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java"],
|
||||
buildGradle,
|
||||
manifestJson,
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.oldVersion, "0.1.26");
|
||||
assert.equal(result.newVersion, "0.1.27");
|
||||
assert.match(result.buildGradle, /version = '0\.1\.27'/);
|
||||
assert.equal(JSON.parse(result.manifestJson).version, "0.1.27");
|
||||
});
|
||||
|
||||
test("keeps an explicit Gradle version change", () => {
|
||||
const result = evaluateJdbcPluginReleaseBump({
|
||||
changedFiles: ["plugins/jdbc/build.gradle", "plugins/jdbc/manifest.json"],
|
||||
buildGradle,
|
||||
manifestJson,
|
||||
});
|
||||
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.newVersion, "0.1.26");
|
||||
});
|
||||
|
|
@ -57,6 +57,8 @@ test("labels native and compatibility database products with their families", ()
|
|||
["Dremio", ["db/dremio", "db/jdbc"]],
|
||||
["Apache Kafka", ["db/kafka", "db/mq"]],
|
||||
["RabbitMQ", ["db/mq", "db/rabbitmq"]],
|
||||
["MQTT 5.0", ["db/mqtt"]],
|
||||
["EMQX 5.8", ["db/mqtt"]],
|
||||
];
|
||||
|
||||
for (const [database, expected] of cases) {
|
||||
|
|
|
|||
|
|
@ -47,17 +47,20 @@ const LABEL_PALETTE = [
|
|||
];
|
||||
|
||||
const DRIVER_DATABASE_ALIASES = {
|
||||
"cassandra-go": "cassandra",
|
||||
gbase8a: "gbase",
|
||||
gbase8s: "gbase",
|
||||
"h2-legacy": "h2",
|
||||
kafka: "mq",
|
||||
"kingbase-go": "kingbase",
|
||||
"neo4j-go": "neo4j",
|
||||
"oracle-10g": "oracle",
|
||||
"oracle-go": "oracle",
|
||||
"oracle-legacy": "oracle",
|
||||
rabbitmq: "mq",
|
||||
rocketmq: "mq",
|
||||
"sqlserver-legacy": "sqlserver",
|
||||
"vastbase-go": "vastbase",
|
||||
};
|
||||
|
||||
const DIALECT_DATABASE_ALIASES = {
|
||||
|
|
|
|||
|
|
@ -14,17 +14,20 @@ import {
|
|||
|
||||
const knownDatabaseTypes = new Set([
|
||||
"access",
|
||||
"cassandra",
|
||||
"doris",
|
||||
"jdbc",
|
||||
"mongodb",
|
||||
"mq",
|
||||
"mysql",
|
||||
"neo4j",
|
||||
"oceanbase-oracle",
|
||||
"oracle",
|
||||
"postgres",
|
||||
"redis",
|
||||
"sqlite",
|
||||
"sqlserver",
|
||||
"vastbase",
|
||||
]);
|
||||
|
||||
test("labels a desktop MySQL UI fix", () => {
|
||||
|
|
@ -54,11 +57,14 @@ test("maps agent and dialect paths to existing database types", () => {
|
|||
assert.deepEqual(
|
||||
inferDatabaseTypes([
|
||||
"agents/drivers/oracle-go/go.mod",
|
||||
"agents/drivers/cassandra-go/go.mod",
|
||||
"agents/drivers/neo4j-go/go.mod",
|
||||
"agents/drivers/vastbase-go/go.mod",
|
||||
"agents/drivers/kafka/build.gradle",
|
||||
"plugins/dialects/postgresql.yaml",
|
||||
"plugins/dialects/oceanbase.yaml",
|
||||
], knownDatabaseTypes),
|
||||
["mq", "oceanbase-oracle", "oracle", "postgres"],
|
||||
["cassandra", "mq", "neo4j", "oceanbase-oracle", "oracle", "postgres", "vastbase"],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Newer WebView2 static loaders import EventSetInformation, which does not exist on Windows 7.
|
||||
# The loader entry points are stable, so the Win7 bundle uses the last verified compatible SDK loader.
|
||||
$sdkVersion = "1.0.1054.31"
|
||||
$sdkPackageSha256 = "0afe683aa3d143a5f6330db1ce833c69278b38fe5e1eadec52f26910ad26e22f"
|
||||
$loaderSha256 = "76314119685bbf4c2b2423a44e81b57beadc914c943d0e772fd6bc78c8e6b0e8"
|
||||
$webView2ComSysVersion = "0.38.2"
|
||||
$upstreamLoaderSha256 = "0659b741bde6348d4c4a6ec4ceb9af50e3d0048ed9cd3c8659bccbb61fde55ee"
|
||||
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path
|
||||
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-webview2-loader-$([Guid]::NewGuid())"
|
||||
$packagePath = Join-Path $temporaryRoot "Microsoft.Web.WebView2.$sdkVersion.nupkg"
|
||||
$extractedPath = Join-Path $temporaryRoot "extracted"
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $extractedPath -Force | Out-Null
|
||||
|
||||
$packageUrl = "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/$sdkVersion"
|
||||
Write-Host "Downloading WebView2 SDK $sdkVersion for the Windows 7 loader..."
|
||||
Invoke-WebRequest -Uri $packageUrl -OutFile $packagePath -UseBasicParsing
|
||||
|
||||
$actualPackageSha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualPackageSha256 -ne $sdkPackageSha256) {
|
||||
throw "Unexpected WebView2 SDK package SHA256: $actualPackageSha256"
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $extractedPath)
|
||||
|
||||
$legacyLoader = Join-Path $extractedPath "build/native/x64/WebView2LoaderStatic.lib"
|
||||
if (!(Test-Path -LiteralPath $legacyLoader -PathType Leaf)) {
|
||||
throw "WebView2 SDK $sdkVersion does not contain the x64 static loader."
|
||||
}
|
||||
|
||||
$legacyLoaderDll = Join-Path $extractedPath "build/native/x64/WebView2Loader.dll"
|
||||
if (!(Test-Path -LiteralPath $legacyLoaderDll -PathType Leaf)) {
|
||||
throw "WebView2 SDK $sdkVersion does not contain the x64 loader DLL."
|
||||
}
|
||||
|
||||
$actualLoaderSha256 = (Get-FileHash -LiteralPath $legacyLoader -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualLoaderSha256 -ne $loaderSha256) {
|
||||
throw "Unexpected Windows 7 WebView2 loader SHA256: $actualLoaderSha256"
|
||||
}
|
||||
|
||||
Push-Location $repositoryRoot
|
||||
try {
|
||||
& cargo fetch --locked --target x86_64-win7-windows-msvc
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "cargo fetch failed while preparing the Windows 7 WebView2 loader."
|
||||
}
|
||||
|
||||
$metadataJson = & cargo metadata --locked --format-version 1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "cargo metadata failed while locating webview2-com-sys."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$metadata = $metadataJson | ConvertFrom-Json
|
||||
$webView2Packages = @($metadata.packages | Where-Object {
|
||||
$_.name -eq "webview2-com-sys" -and $_.version -eq $webView2ComSysVersion
|
||||
})
|
||||
if ($webView2Packages.Count -ne 1) {
|
||||
throw "Expected exactly one webview2-com-sys $webView2ComSysVersion package, found $($webView2Packages.Count)."
|
||||
}
|
||||
|
||||
$crateRoot = Split-Path -Parent $webView2Packages[0].manifest_path
|
||||
$loaderDestination = Join-Path $crateRoot "x64/WebView2LoaderStatic.lib"
|
||||
if (!(Test-Path -LiteralPath $loaderDestination -PathType Leaf)) {
|
||||
throw "webview2-com-sys static loader does not exist: $loaderDestination"
|
||||
}
|
||||
|
||||
$existingLoaderSha256 = (Get-FileHash -LiteralPath $loaderDestination -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$knownLoaderHashes = @($upstreamLoaderSha256, $loaderSha256)
|
||||
if ($existingLoaderSha256 -notin $knownLoaderHashes) {
|
||||
throw "Refusing to replace an unknown webview2-com-sys loader SHA256: $existingLoaderSha256"
|
||||
}
|
||||
|
||||
Set-ItemProperty -LiteralPath $loaderDestination -Name IsReadOnly -Value $false
|
||||
Copy-Item -LiteralPath $legacyLoader -Destination $loaderDestination -Force
|
||||
|
||||
$installedLoaderSha256 = (Get-FileHash -LiteralPath $loaderDestination -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($installedLoaderSha256 -ne $loaderSha256) {
|
||||
throw "Windows 7 WebView2 loader replacement failed: $installedLoaderSha256"
|
||||
}
|
||||
|
||||
$probeDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-webview2-loader-probe"
|
||||
New-Item -ItemType Directory -Path $probeDirectory -Force | Out-Null
|
||||
$probeLoader = Join-Path $probeDirectory "WebView2Loader.dll"
|
||||
Copy-Item -LiteralPath $legacyLoaderDll -Destination $probeLoader -Force
|
||||
|
||||
Write-Host "Prepared WebView2 SDK $sdkVersion static loader for Windows 7: $loaderDestination"
|
||||
Write-Host "Prepared WebView2 SDK $sdkVersion loader probe DLL: $probeLoader"
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $temporaryRoot) {
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +1,76 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$CacheRoot = (Join-Path $env:LOCALAPPDATA "tauri"),
|
||||
[string]$RuntimeDirectory = (Join-Path $PSScriptRoot "..\..\src-tauri\webview2-fixed-runtime"),
|
||||
[string]$DownloadDirectory = $env:RUNNER_TEMP
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$runtimeVersion = "109.0.1518.140"
|
||||
$runtimeUrl = "https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/updt/2023/09/microsoftedgestandaloneinstallerx64_1c890b4b8dd6b7c93da98ebdc08ecdc5e30e50cb.exe"
|
||||
$runtimeSha256 = "eac95c8095ec5f9971eade9827d8fb67fd251f5c16e702b5312d31067e39119b"
|
||||
$evergreenUrl = "https://go.microsoft.com/fwlink/?linkid=2124701"
|
||||
$runtimeVersion = "109.0.1518.78"
|
||||
$runtimeFolderName = "Microsoft.WebView2.FixedVersionRuntime.$runtimeVersion.x64"
|
||||
$archiveName = "$runtimeFolderName.cab"
|
||||
$runtimeUrl = "https://github.com/westinyang/WebView2RuntimeArchive/releases/download/$runtimeVersion/$archiveName"
|
||||
$runtimeSha256 = "7622281cf83de1a35e3a471f432f7a897d65f0a7d3975df08512b7b253dd45c7"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($CacheRoot)) {
|
||||
throw "A Tauri cache root is required."
|
||||
if ([string]::IsNullOrWhiteSpace($RuntimeDirectory)) {
|
||||
throw "A WebView2 fixed runtime directory is required."
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($DownloadDirectory)) {
|
||||
$DownloadDirectory = [System.IO.Path]::GetTempPath()
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $DownloadDirectory | Out-Null
|
||||
$downloadPath = Join-Path $DownloadDirectory "MicrosoftEdgeWebView2Runtime-$runtimeVersion-x64.exe"
|
||||
$archivePath = Join-Path $DownloadDirectory $archiveName
|
||||
|
||||
if (Test-Path $downloadPath) {
|
||||
$downloadHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if (Test-Path $archivePath) {
|
||||
$downloadHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($downloadHash -ne $runtimeSha256) {
|
||||
Remove-Item -LiteralPath $downloadPath -Force
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (!(Test-Path $downloadPath)) {
|
||||
Write-Host "Downloading WebView2 Runtime $runtimeVersion for Windows 7..."
|
||||
Invoke-WebRequest -Uri $runtimeUrl -OutFile $downloadPath
|
||||
if (!(Test-Path $archivePath)) {
|
||||
Write-Host "Downloading WebView2 fixed runtime $runtimeVersion for Windows 7..."
|
||||
Invoke-WebRequest -Uri $runtimeUrl -OutFile $archivePath
|
||||
}
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $runtimeSha256) {
|
||||
throw "WebView2 Runtime SHA-256 mismatch. Expected $runtimeSha256, got $actualHash."
|
||||
throw "WebView2 fixed runtime SHA-256 mismatch. Expected $runtimeSha256, got $actualHash."
|
||||
}
|
||||
|
||||
# Tauri 2.11 does not expose an offline-installer path override. It resolves the
|
||||
# Evergreen URL and reuses a matching cache entry, so place the verified 109
|
||||
# installer at that exact location before bundling.
|
||||
$response = Invoke-WebRequest -Uri $evergreenUrl -Method Head
|
||||
$resolvedUrl = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
|
||||
$match = [regex]::Match(
|
||||
$resolvedUrl,
|
||||
"/filestreamingservice/files/(?<guid>[^/]+)/(?<filename>[^/?]+)"
|
||||
)
|
||||
if (!$match.Success) {
|
||||
throw "Unexpected Evergreen WebView2 URL: $resolvedUrl"
|
||||
# Microsoft no longer publishes old Fixed Version downloads. The archive is
|
||||
# accepted only when both its pinned hash and original Microsoft signature match.
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $archivePath
|
||||
if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
|
||||
$null -eq $signature.SignerCertificate -or
|
||||
$signature.SignerCertificate.Subject -notmatch "Microsoft Corporation") {
|
||||
throw "WebView2 fixed runtime does not have a valid Microsoft signature."
|
||||
}
|
||||
|
||||
$cacheDirectory = Join-Path $CacheRoot (Join-Path "x64" $match.Groups["guid"].Value)
|
||||
$cachePath = Join-Path $cacheDirectory $match.Groups["filename"].Value
|
||||
New-Item -ItemType Directory -Force -Path $cacheDirectory | Out-Null
|
||||
Copy-Item -LiteralPath $downloadPath -Destination $cachePath -Force
|
||||
$extractDirectory = Join-Path $DownloadDirectory "dbx-webview2-fixed-runtime-$runtimeVersion"
|
||||
if (Test-Path $extractDirectory) {
|
||||
Remove-Item -LiteralPath $extractDirectory -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $extractDirectory | Out-Null
|
||||
|
||||
$cacheHash = (Get-FileHash -LiteralPath $cachePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($cacheHash -ne $runtimeSha256) {
|
||||
throw "Cached WebView2 Runtime SHA-256 mismatch. Expected $runtimeSha256, got $cacheHash."
|
||||
$expand = Join-Path $env:SystemRoot "System32\expand.exe"
|
||||
& $expand $archivePath "-F:*" $extractDirectory
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to extract WebView2 fixed runtime archive (exit code $LASTEXITCODE)."
|
||||
}
|
||||
|
||||
Write-Host "Prepared WebView2 Runtime $runtimeVersion at $cachePath"
|
||||
$extractedRuntime = Join-Path $extractDirectory $runtimeFolderName
|
||||
$runtimeExecutable = Join-Path $extractedRuntime "msedgewebview2.exe"
|
||||
if (!(Test-Path $runtimeExecutable)) {
|
||||
throw "Extracted WebView2 runtime is missing msedgewebview2.exe."
|
||||
}
|
||||
|
||||
if (Test-Path $RuntimeDirectory) {
|
||||
Remove-Item -LiteralPath $RuntimeDirectory -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $RuntimeDirectory) | Out-Null
|
||||
Move-Item -LiteralPath $extractedRuntime -Destination $RuntimeDirectory
|
||||
|
||||
Write-Host "Prepared WebView2 fixed runtime $runtimeVersion at $RuntimeDirectory"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,372 @@
|
|||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const REGISTRY_ASSET = "agent-registry.json";
|
||||
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
|
||||
const PLATFORMS = [
|
||||
"macos-aarch64",
|
||||
"macos-x64",
|
||||
"linux-aarch64",
|
||||
"linux-x64",
|
||||
"windows-aarch64",
|
||||
"windows-x64",
|
||||
];
|
||||
|
||||
function artifactFilename(url) {
|
||||
return basename(url.split(/[?#]/, 1)[0]);
|
||||
}
|
||||
|
||||
function sha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
|
||||
function releaseAssetMap(release) {
|
||||
return new Map((release.assets ?? []).map((asset) => [asset.name, asset]));
|
||||
}
|
||||
|
||||
function requireReleaseAsset(assets, artifact, context) {
|
||||
const name = artifactFilename(artifact.url);
|
||||
const releaseAsset = assets.get(name);
|
||||
if (!releaseAsset) {
|
||||
throw new Error(`${context} is missing from the previous GitHub release: ${name}`);
|
||||
}
|
||||
if (!artifact.sha256) {
|
||||
throw new Error(`${context} is missing sha256 in the previous agent registry: ${name}`);
|
||||
}
|
||||
if (releaseAsset.digest !== `sha256:${artifact.sha256}`) {
|
||||
throw new Error(`${context} digest mismatch between the registry and GitHub release: ${name}`);
|
||||
}
|
||||
return { name, sha256: artifact.sha256, size: artifact.size, releaseAsset };
|
||||
}
|
||||
|
||||
export function collectReusableAssetPlan({ registry, release, versions, modules, reuseJre }) {
|
||||
const assets = releaseAssetMap(release);
|
||||
const driverAssets = [];
|
||||
const jreAssets = [];
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const driver = registry.drivers?.[moduleName];
|
||||
if (!driver) {
|
||||
throw new Error(`Previous agent registry is missing reusable module: ${moduleName}`);
|
||||
}
|
||||
if (driver.version !== versions[moduleName]) {
|
||||
throw new Error(`Previous agent version mismatch for ${moduleName}: registry=${driver.version}, expected=${versions[moduleName]}`);
|
||||
}
|
||||
|
||||
const reusableJar = driver.jar && driver.jar.size > 0;
|
||||
if (reusableJar) {
|
||||
driverAssets.push({
|
||||
...requireReleaseAsset(assets, driver.jar, `${moduleName} Java package`),
|
||||
moduleName,
|
||||
kind: "jar",
|
||||
platform: "",
|
||||
});
|
||||
}
|
||||
|
||||
const nativePlatforms = Object.keys(driver.native ?? {}).sort();
|
||||
if (NATIVE_MODULES.has(moduleName)) {
|
||||
const missingPlatforms = PLATFORMS.filter((platform) => !nativePlatforms.includes(platform));
|
||||
const extraPlatforms = nativePlatforms.filter((platform) => !PLATFORMS.includes(platform));
|
||||
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
|
||||
throw new Error(
|
||||
`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const platform of nativePlatforms) {
|
||||
driverAssets.push({
|
||||
...requireReleaseAsset(assets, driver.native[platform], `${moduleName}/${platform} native package`),
|
||||
moduleName,
|
||||
kind: "native",
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
if (!reusableJar && nativePlatforms.length === 0) {
|
||||
throw new Error(`Previous agent registry has no reusable artifacts for module: ${moduleName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (reuseJre) {
|
||||
for (const [jreKey, jre] of Object.entries(registry.jres ?? {})) {
|
||||
const platforms = Object.keys(jre.platforms ?? {}).sort();
|
||||
const missingPlatforms = PLATFORMS.filter((platform) => !platforms.includes(platform));
|
||||
const extraPlatforms = platforms.filter((platform) => !PLATFORMS.includes(platform));
|
||||
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
|
||||
throw new Error(
|
||||
`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
|
||||
);
|
||||
}
|
||||
for (const platform of platforms) {
|
||||
jreAssets.push({
|
||||
...requireReleaseAsset(assets, jre.platforms[platform], `JRE ${jreKey}/${platform} package`),
|
||||
jreKey,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (jreAssets.length === 0) {
|
||||
throw new Error("Previous agent registry has no reusable JRE artifacts.");
|
||||
}
|
||||
}
|
||||
|
||||
return { driverAssets, jreAssets };
|
||||
}
|
||||
|
||||
function verifyDownloadedAsset(path, asset) {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Downloaded release asset is missing: ${asset.name}`);
|
||||
}
|
||||
const size = statSync(path).size;
|
||||
if (asset.size != null && size !== asset.size) {
|
||||
throw new Error(`Downloaded release asset size mismatch for ${asset.name}: got=${size}, expected=${asset.size}`);
|
||||
}
|
||||
const digest = sha256(path);
|
||||
if (digest !== asset.sha256) {
|
||||
throw new Error(`Downloaded release asset SHA-256 mismatch for ${asset.name}: got=${digest}, expected=${asset.sha256}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyWithoutConflict(source, target) {
|
||||
if (existsSync(target)) {
|
||||
if (sha256(source) !== sha256(target)) {
|
||||
throw new Error(`Reused raw artifact conflicts with an existing file: ${basename(target)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
copyFileSync(source, target);
|
||||
}
|
||||
|
||||
function extractRawDriver(packagePath, asset, outputDir = "") {
|
||||
const extractDir = mkdtempSync(join(tmpdir(), "dbx-agent-package-"));
|
||||
try {
|
||||
execFileSync("tar", ["--use-compress-program=unzstd", "-xf", packagePath, "-C", extractDir], { stdio: "inherit" });
|
||||
const embeddedRegistry = JSON.parse(readFileSync(join(extractDir, REGISTRY_ASSET), "utf8"));
|
||||
const driver = embeddedRegistry.drivers?.[asset.moduleName];
|
||||
if (!driver || driver.version !== asset.releaseVersion) {
|
||||
throw new Error(`Embedded registry mismatch in ${asset.name}`);
|
||||
}
|
||||
|
||||
const embeddedArtifact = asset.kind === "jar" ? driver.jar : driver.native?.[asset.platform];
|
||||
if (!embeddedArtifact) {
|
||||
throw new Error(`Embedded registry artifact is missing in ${asset.name}`);
|
||||
}
|
||||
const rawName = artifactFilename(embeddedArtifact.url);
|
||||
const rawPath = join(extractDir, "drivers", rawName);
|
||||
if (!existsSync(rawPath)) {
|
||||
throw new Error(`Embedded raw driver is missing in ${asset.name}: ${rawName}`);
|
||||
}
|
||||
const rawSize = statSync(rawPath).size;
|
||||
if (embeddedArtifact.size != null && rawSize !== embeddedArtifact.size) {
|
||||
throw new Error(`Embedded raw driver size mismatch in ${asset.name}: ${rawName}`);
|
||||
}
|
||||
if (embeddedArtifact.sha256 && sha256(rawPath) !== embeddedArtifact.sha256) {
|
||||
throw new Error(`Embedded raw driver SHA-256 mismatch in ${asset.name}: ${rawName}`);
|
||||
}
|
||||
if (outputDir) {
|
||||
copyWithoutConflict(rawPath, join(outputDir, rawName));
|
||||
}
|
||||
} finally {
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function extractReusableDriverPackages({ packagesDir, outputDir, versions, modules }) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const filenames = new Set(readdirSync(packagesDir));
|
||||
let extracted = 0;
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const releaseVersion = versions[moduleName];
|
||||
if (!releaseVersion) {
|
||||
throw new Error(`Missing effective previous version for reusable module: ${moduleName}`);
|
||||
}
|
||||
|
||||
const javaName = `dbx-agent-${moduleName}-${releaseVersion}.tar.zst`;
|
||||
if (filenames.has(javaName)) {
|
||||
extractRawDriver(join(packagesDir, javaName), {
|
||||
name: javaName,
|
||||
moduleName,
|
||||
kind: "jar",
|
||||
platform: "",
|
||||
releaseVersion,
|
||||
}, outputDir);
|
||||
extracted += 1;
|
||||
}
|
||||
|
||||
const nativePlatforms = [];
|
||||
for (const platform of PLATFORMS) {
|
||||
const nativeName = `dbx-agent-${moduleName}-${releaseVersion}-${platform}.tar.zst`;
|
||||
if (!filenames.has(nativeName)) continue;
|
||||
nativePlatforms.push(platform);
|
||||
extractRawDriver(join(packagesDir, nativeName), {
|
||||
name: nativeName,
|
||||
moduleName,
|
||||
kind: "native",
|
||||
platform,
|
||||
releaseVersion,
|
||||
}, outputDir);
|
||||
extracted += 1;
|
||||
}
|
||||
|
||||
if (NATIVE_MODULES.has(moduleName) && nativePlatforms.length !== PLATFORMS.length) {
|
||||
throw new Error(`Reusable native package set is incomplete for ${moduleName}.`);
|
||||
}
|
||||
if (!filenames.has(javaName) && nativePlatforms.length === 0) {
|
||||
throw new Error(`Reusable package is missing for module: ${moduleName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return extracted;
|
||||
}
|
||||
|
||||
function gh(args, options = {}) {
|
||||
const result = execFileSync("gh", args, { encoding: "utf8", ...options });
|
||||
return typeof result === "string" ? result.trim() : "";
|
||||
}
|
||||
|
||||
function downloadReleaseAssets(assets, downloadDir) {
|
||||
if (assets.length === 0) return;
|
||||
const args = [
|
||||
"--fail",
|
||||
"--location",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"5",
|
||||
"--retry-all-errors",
|
||||
"--retry-delay",
|
||||
"2",
|
||||
"--connect-timeout",
|
||||
"30",
|
||||
"--parallel",
|
||||
"--parallel-immediate",
|
||||
"--parallel-max",
|
||||
"6",
|
||||
];
|
||||
for (const asset of assets) {
|
||||
if (!asset.browser_download_url) {
|
||||
throw new Error(`GitHub release asset is missing browser_download_url: ${asset.name}`);
|
||||
}
|
||||
args.push("--output", join(downloadDir, asset.name), asset.browser_download_url);
|
||||
}
|
||||
execFileSync("curl", args, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
repo: "",
|
||||
tag: "",
|
||||
versions: {},
|
||||
modules: [],
|
||||
reuseJre: false,
|
||||
outputDir: "",
|
||||
extractPackagesDir: "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const value = argv[++index];
|
||||
if (value == null) throw new Error(`Missing value for ${arg}`);
|
||||
if (arg === "--repo") options.repo = value;
|
||||
else if (arg === "--tag") options.tag = value;
|
||||
else if (arg === "--versions") options.versions = JSON.parse(value);
|
||||
else if (arg === "--modules") options.modules = JSON.parse(value);
|
||||
else if (arg === "--reuse-jre") options.reuseJre = value === "true";
|
||||
else if (arg === "--output") options.outputDir = value;
|
||||
else if (arg === "--extract-packages") options.extractPackagesDir = value;
|
||||
else throw new Error(`Unexpected argument: ${arg}`);
|
||||
}
|
||||
|
||||
const requiredKeys = options.extractPackagesDir ? ["outputDir"] : ["repo", "tag", "outputDir"];
|
||||
for (const key of requiredKeys) {
|
||||
if (!options[key]) throw new Error(`--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is required.`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.extractPackagesDir) {
|
||||
const count = extractReusableDriverPackages({
|
||||
packagesDir: options.extractPackagesDir,
|
||||
outputDir: options.outputDir,
|
||||
versions: options.versions,
|
||||
modules: options.modules,
|
||||
});
|
||||
console.log(`Extracted ${count} reusable driver artifacts.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const workDir = mkdtempSync(join(tmpdir(), "dbx-agent-reuse-"));
|
||||
const downloadDir = join(workDir, "downloads");
|
||||
mkdirSync(downloadDir);
|
||||
mkdirSync(options.outputDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const release = JSON.parse(gh(["api", `repos/${options.repo}/releases/tags/${options.tag}`]));
|
||||
const assets = releaseAssetMap(release);
|
||||
const registryReleaseAsset = assets.get(REGISTRY_ASSET);
|
||||
if (!registryReleaseAsset?.digest?.startsWith("sha256:")) {
|
||||
throw new Error(`Previous GitHub release ${options.tag} is missing a SHA-256 digest for ${REGISTRY_ASSET}.`);
|
||||
}
|
||||
|
||||
downloadReleaseAssets([registryReleaseAsset], downloadDir);
|
||||
const registryPath = join(downloadDir, REGISTRY_ASSET);
|
||||
const registryDigest = registryReleaseAsset.digest.slice("sha256:".length);
|
||||
verifyDownloadedAsset(registryPath, {
|
||||
name: REGISTRY_ASSET,
|
||||
sha256: registryDigest,
|
||||
size: registryReleaseAsset.size,
|
||||
});
|
||||
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
||||
const plan = collectReusableAssetPlan({
|
||||
registry,
|
||||
release,
|
||||
versions: options.versions,
|
||||
modules: options.modules,
|
||||
reuseJre: options.reuseJre,
|
||||
});
|
||||
const plannedAssets = [...plan.driverAssets, ...plan.jreAssets].map((asset) => ({
|
||||
...asset,
|
||||
releaseVersion: options.versions[asset.moduleName],
|
||||
}));
|
||||
|
||||
if (plannedAssets.length > 0) {
|
||||
downloadReleaseAssets(plannedAssets.map((asset) => asset.releaseAsset), downloadDir);
|
||||
}
|
||||
|
||||
for (const asset of plannedAssets) {
|
||||
const source = join(downloadDir, asset.name);
|
||||
verifyDownloadedAsset(source, asset);
|
||||
copyFileSync(source, join(options.outputDir, asset.name));
|
||||
if (asset.moduleName) {
|
||||
extractRawDriver(source, asset);
|
||||
}
|
||||
}
|
||||
|
||||
const outputNames = readdirSync(options.outputDir).sort();
|
||||
console.log(`Reused ${plan.driverAssets.length} driver packages and ${plan.jreAssets.length} JRE packages from ${options.tag}.`);
|
||||
console.log(outputNames.join("\n"));
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { collectReusableAssetPlan } from "./reuse-agent-release-assets.mjs";
|
||||
|
||||
const platforms = [
|
||||
"macos-aarch64",
|
||||
"macos-x64",
|
||||
"linux-aarch64",
|
||||
"linux-x64",
|
||||
"windows-aarch64",
|
||||
"windows-x64",
|
||||
];
|
||||
|
||||
test("collects complete reusable Java, native, and JRE assets", () => {
|
||||
const access = artifact("dbx-agent-access-0.1.34.tar.zst", "a");
|
||||
const kingbase = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const jre = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]),
|
||||
);
|
||||
const registry = {
|
||||
drivers: {
|
||||
access: { version: "0.1.34", jar: access },
|
||||
kingbase: { version: "0.1.40", native: kingbase },
|
||||
},
|
||||
jres: { 21: { version: "21.0.12", platforms: jre } },
|
||||
};
|
||||
const release = releaseFor([access, ...Object.values(kingbase), ...Object.values(jre)]);
|
||||
|
||||
const plan = collectReusableAssetPlan({
|
||||
registry,
|
||||
release,
|
||||
versions: { access: "0.1.34", kingbase: "0.1.40" },
|
||||
modules: ["access", "kingbase"],
|
||||
reuseJre: true,
|
||||
});
|
||||
|
||||
assert.equal(plan.driverAssets.length, 7);
|
||||
assert.equal(plan.jreAssets.length, 6);
|
||||
assert.deepEqual(plan.driverAssets.map((asset) => asset.moduleName), ["access", ...Array(6).fill("kingbase")]);
|
||||
});
|
||||
|
||||
test("rejects an incomplete reusable native platform set", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const registry = { drivers: { vastbase: { version: "0.1.38", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { vastbase: "0.1.38" },
|
||||
modules: ["vastbase"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=macos-aarch64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("requires all TDengine native platforms when reusing a release", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const registry = { drivers: { tdengine: { version: "0.1.40", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { tdengine: "0.1.40" },
|
||||
modules: ["tdengine"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("requires all Neo4j native platforms when reusing a release", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const registry = { drivers: { neo4j: { version: "0.1.40", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { neo4j: "0.1.40" },
|
||||
modules: ["neo4j"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const registry = {
|
||||
drivers: {
|
||||
duckdb: {
|
||||
version: "0.1.2",
|
||||
jar: {
|
||||
url: "https://example.invalid/dbx-agent-duckdb-legacy-placeholder.jar",
|
||||
size: 0,
|
||||
sha256: "",
|
||||
},
|
||||
native,
|
||||
},
|
||||
},
|
||||
jres: {},
|
||||
};
|
||||
|
||||
const plan = collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { duckdb: "0.1.2" },
|
||||
modules: ["duckdb"],
|
||||
reuseJre: false,
|
||||
});
|
||||
|
||||
assert.equal(plan.driverAssets.length, 6);
|
||||
assert.equal(plan.driverAssets.some((asset) => asset.kind === "jar"), false);
|
||||
});
|
||||
|
||||
test("rejects a registry version that differs from the effective baseline", () => {
|
||||
const access = artifact("dbx-agent-access-0.1.33.tar.zst", "a");
|
||||
const registry = { drivers: { access: { version: "0.1.33", jar: access } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor([access]),
|
||||
versions: { access: "0.1.34" },
|
||||
modules: ["access"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/registry=0\.1\.33, expected=0\.1\.34/,
|
||||
);
|
||||
});
|
||||
|
||||
function artifact(name, seed) {
|
||||
return { url: `https://example.invalid/${name}`, size: 100, sha256: seed.repeat(64).slice(0, 64) };
|
||||
}
|
||||
|
||||
function releaseFor(artifacts) {
|
||||
return {
|
||||
assets: artifacts.map((entry) => ({
|
||||
name: entry.url.split("/").at(-1),
|
||||
size: entry.size,
|
||||
digest: `sha256:${entry.sha256}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,13 @@ jobs:
|
|||
versions: ${{ steps.bump.outputs.versions }}
|
||||
prev_versions: ${{ steps.bump.outputs.prev_versions }}
|
||||
prev_tag: ${{ steps.bump.outputs.prev_tag }}
|
||||
effective_prev_ref: ${{ steps.bump.outputs.effective_prev_ref }}
|
||||
changed_modules: ${{ steps.bump.outputs.changed_modules }}
|
||||
java_modules: ${{ steps.bump.outputs.java_modules }}
|
||||
native_modules: ${{ steps.bump.outputs.native_modules }}
|
||||
reuse_modules: ${{ steps.bump.outputs.reuse_modules }}
|
||||
build_jre: ${{ steps.bump.outputs.build_jre }}
|
||||
reuse_jre: ${{ steps.bump.outputs.reuse_jre }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
@ -121,6 +128,7 @@ jobs:
|
|||
|
||||
build-agents:
|
||||
needs: [bump-versions]
|
||||
if: ${{ needs.bump-versions.outputs.java_modules != '[]' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -129,7 +137,16 @@ jobs:
|
|||
distribution: temurin
|
||||
java-version: "21"
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- run: ./gradlew shadowJar --parallel
|
||||
- name: Build changed Java agents
|
||||
env:
|
||||
JAVA_MODULES: ${{ needs.bump-versions.outputs.java_modules }}
|
||||
run: |
|
||||
mapfile -t MODULES < <(echo "$JAVA_MODULES" | python3 -c 'import json,sys; print("\n".join(json.load(sys.stdin)))')
|
||||
TASKS=()
|
||||
for module in "${MODULES[@]}"; do
|
||||
TASKS+=(":${module}:shadowJar")
|
||||
done
|
||||
./gradlew "${TASKS[@]}" --parallel
|
||||
working-directory: agents
|
||||
- run: python3 scripts/validate_agent_jars.py
|
||||
working-directory: agents
|
||||
|
|
@ -141,6 +158,7 @@ jobs:
|
|||
|
||||
build-oracle-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'oracle') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -161,7 +179,6 @@ jobs:
|
|||
["linux-aarch64"]="linux/arm64"
|
||||
["linux-x64"]="linux/amd64"
|
||||
["windows-aarch64"]="windows/arm64"
|
||||
["windows-x64"]="windows/amd64"
|
||||
)
|
||||
for platform in "${!TARGETS[@]}"; do
|
||||
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
|
||||
|
|
@ -173,6 +190,20 @@ jobs:
|
|||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
ls -lh ../../../release-native
|
||||
# Go 1.20 is the last release that supports Windows 7; keep other targets on Go 1.22.
|
||||
- name: Set up Go for Windows 7-compatible Oracle agent
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.20.14"
|
||||
cache-dependency-path: agents/drivers/oracle-go/go.sum
|
||||
- name: Build Windows 7-compatible Oracle agent
|
||||
shell: bash
|
||||
working-directory: agents/drivers/oracle-go
|
||||
run: |
|
||||
output="../../../release-native/dbx-agent-oracle-windows-x64.exe"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
go version -m "$output" | tee /tmp/oracle-windows-x64-build-info.txt
|
||||
grep -q ': go1\.20\.14$' /tmp/oracle-windows-x64-build-info.txt
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oracle-native
|
||||
|
|
@ -180,6 +211,7 @@ jobs:
|
|||
|
||||
build-xugu-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'xugu') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -219,6 +251,7 @@ jobs:
|
|||
|
||||
build-rabbitmq-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'rabbitmq') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -256,8 +289,49 @@ jobs:
|
|||
name: rabbitmq-native
|
||||
path: "release-native/dbx-agent-rabbitmq-*"
|
||||
|
||||
build-cassandra-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'cassandra') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
- name: Test Cassandra native agent
|
||||
working-directory: agents/drivers/cassandra-go
|
||||
run: go test ./...
|
||||
- name: Cross-compile Cassandra native agent
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-native
|
||||
cd agents/drivers/cassandra-go
|
||||
declare -A TARGETS=(
|
||||
["macos-aarch64"]="darwin/arm64"
|
||||
["macos-x64"]="darwin/amd64"
|
||||
["linux-aarch64"]="linux/arm64"
|
||||
["linux-x64"]="linux/amd64"
|
||||
["windows-aarch64"]="windows/arm64"
|
||||
["windows-x64"]="windows/amd64"
|
||||
)
|
||||
for platform in "${!TARGETS[@]}"; do
|
||||
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
|
||||
output="../../../release-native/dbx-agent-cassandra-${platform}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
output="${output}.exe"
|
||||
fi
|
||||
echo "Building $platform ($goos/$goarch)"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
ls -lh ../../../release-native
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cassandra-native
|
||||
path: "release-native/dbx-agent-cassandra-*"
|
||||
|
||||
build-kingbase-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'kingbase') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -295,8 +369,89 @@ jobs:
|
|||
name: kingbase-native
|
||||
path: "release-native/dbx-agent-kingbase-*"
|
||||
|
||||
build-vastbase-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'vastbase') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
- name: Test Vastbase native agent
|
||||
working-directory: agents/drivers/vastbase-go
|
||||
run: go test ./...
|
||||
- name: Cross-compile Vastbase native agent
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-native
|
||||
cd agents/drivers/vastbase-go
|
||||
declare -A TARGETS=(
|
||||
["macos-aarch64"]="darwin/arm64"
|
||||
["macos-x64"]="darwin/amd64"
|
||||
["linux-aarch64"]="linux/arm64"
|
||||
["linux-x64"]="linux/amd64"
|
||||
["windows-aarch64"]="windows/arm64"
|
||||
["windows-x64"]="windows/amd64"
|
||||
)
|
||||
for platform in "${!TARGETS[@]}"; do
|
||||
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
|
||||
output="../../../release-native/dbx-agent-vastbase-${platform}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
output="${output}.exe"
|
||||
fi
|
||||
echo "Building $platform ($goos/$goarch)"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
ls -lh ../../../release-native
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vastbase-native
|
||||
path: "release-native/dbx-agent-vastbase-*"
|
||||
|
||||
build-neo4j-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'neo4j') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
- name: Test Neo4j native agent
|
||||
working-directory: agents/drivers/neo4j-go
|
||||
run: go test ./...
|
||||
- name: Cross-compile Neo4j native agent
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-native
|
||||
cd agents/drivers/neo4j-go
|
||||
declare -A TARGETS=(
|
||||
["macos-aarch64"]="darwin/arm64"
|
||||
["macos-x64"]="darwin/amd64"
|
||||
["linux-aarch64"]="linux/arm64"
|
||||
["linux-x64"]="linux/amd64"
|
||||
["windows-aarch64"]="windows/arm64"
|
||||
["windows-x64"]="windows/amd64"
|
||||
)
|
||||
for platform in "${!TARGETS[@]}"; do
|
||||
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
|
||||
output="../../../release-native/dbx-agent-neo4j-${platform}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
output="${output}.exe"
|
||||
fi
|
||||
echo "Building $platform ($goos/$goarch)"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
ls -lh ../../../release-native
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: neo4j-native
|
||||
path: "release-native/dbx-agent-neo4j-*"
|
||||
|
||||
build-duckdb-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'duckdb') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -345,12 +500,19 @@ jobs:
|
|||
with:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
- uses: actions/setup-python@v5
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
with:
|
||||
version: "v0.10.0"
|
||||
- name: Build DuckDB native driver
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-C target-feature=+crt-static"
|
||||
fi
|
||||
if [ -n "${{ matrix.manylinux_image }}" ]; then
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
|
|
@ -358,14 +520,14 @@ jobs:
|
|||
-e CARGO_INCREMENTAL=0 \
|
||||
-e CARGO_TARGET_DIR=/workspace/target/duckdb-driver \
|
||||
-v "${{ github.workspace }}:/workspace" \
|
||||
-w /workspace \
|
||||
-w /workspace/agents/drivers/duckdb \
|
||||
"${{ matrix.manylinux_image }}" \
|
||||
bash -lc '
|
||||
mkdir -p "$HOME"
|
||||
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.97.1
|
||||
source "$HOME/.cargo/env"
|
||||
cargo build \
|
||||
--manifest-path agents/drivers/duckdb/Cargo.toml \
|
||||
--manifest-path Cargo.toml \
|
||||
--locked \
|
||||
--release \
|
||||
--bin dbx-duckdb-driver \
|
||||
|
|
@ -376,18 +538,24 @@ jobs:
|
|||
if [ "${{ matrix.win7 }}" = "true" ]; then
|
||||
BUILD_STD=(-Z build-std=std,panic_abort)
|
||||
fi
|
||||
pushd agents/drivers/duckdb
|
||||
cargo build \
|
||||
--manifest-path agents/drivers/duckdb/Cargo.toml \
|
||||
--manifest-path Cargo.toml \
|
||||
--locked \
|
||||
--release \
|
||||
--bin dbx-duckdb-driver \
|
||||
--target "${{ matrix.target }}" \
|
||||
"${BUILD_STD[@]}"
|
||||
popd
|
||||
fi
|
||||
mkdir -p release-native
|
||||
cp \
|
||||
"target/duckdb-driver/${{ matrix.target }}/release/dbx-duckdb-driver${{ matrix.extension }}" \
|
||||
"release-native/dbx-agent-duckdb-${{ matrix.platform }}${{ matrix.extension }}"
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
python agents/scripts/validate_windows_pe_dependencies.py \
|
||||
"release-native/dbx-agent-duckdb-${{ matrix.platform }}${{ matrix.extension }}"
|
||||
fi
|
||||
if [ -n "${{ matrix.manylinux_image }}" ]; then
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
|
|
@ -400,7 +568,120 @@ jobs:
|
|||
name: duckdb-native-${{ matrix.platform }}
|
||||
path: "release-native/dbx-agent-duckdb-*"
|
||||
|
||||
build-tdengine-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'tdengine') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos-aarch64
|
||||
extension: ""
|
||||
smoke: true
|
||||
- runner: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
platform: macos-x64
|
||||
extension: ""
|
||||
smoke: true
|
||||
- runner: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
platform: linux-aarch64
|
||||
extension: ""
|
||||
manylinux_image: quay.io/pypa/manylinux_2_28_aarch64
|
||||
- runner: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform: linux-x64
|
||||
extension: ""
|
||||
manylinux_image: quay.io/pypa/manylinux_2_28_x86_64
|
||||
- runner: windows-2022
|
||||
target: aarch64-pc-windows-msvc
|
||||
platform: windows-aarch64
|
||||
extension: .exe
|
||||
- runner: windows-2022
|
||||
target: x86_64-win7-windows-msvc
|
||||
platform: windows-x64
|
||||
extension: .exe
|
||||
smoke: true
|
||||
win7: true
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target/tdengine-driver
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.1
|
||||
if: runner.os != 'Linux' && matrix.win7 != true
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Setup Rust for Windows 7
|
||||
if: matrix.win7 == true
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
with:
|
||||
version: "v0.10.0"
|
||||
- name: Build TDengine native driver
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${{ matrix.manylinux_image }}" ]; then
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp/dbx-rust-home \
|
||||
-e CARGO_INCREMENTAL=0 \
|
||||
-e CARGO_TARGET_DIR=/workspace/target/tdengine-driver \
|
||||
-v "${{ github.workspace }}:/workspace" \
|
||||
-w /workspace \
|
||||
"${{ matrix.manylinux_image }}" \
|
||||
bash -lc '
|
||||
mkdir -p "$HOME"
|
||||
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.97.1
|
||||
source "$HOME/.cargo/env"
|
||||
cargo build \
|
||||
--manifest-path agents/drivers/tdengine/Cargo.toml \
|
||||
--locked \
|
||||
--release \
|
||||
--bin dbx-tdengine-driver \
|
||||
--target "${{ matrix.target }}"
|
||||
'
|
||||
else
|
||||
BUILD_STD=()
|
||||
if [ "${{ matrix.win7 }}" = "true" ]; then
|
||||
BUILD_STD=(-Z build-std=std,panic_abort)
|
||||
fi
|
||||
cargo build \
|
||||
--manifest-path agents/drivers/tdengine/Cargo.toml \
|
||||
--locked \
|
||||
--release \
|
||||
--bin dbx-tdengine-driver \
|
||||
--target "${{ matrix.target }}" \
|
||||
"${BUILD_STD[@]}"
|
||||
fi
|
||||
mkdir -p release-native
|
||||
cp \
|
||||
"target/tdengine-driver/${{ matrix.target }}/release/dbx-tdengine-driver${{ matrix.extension }}" \
|
||||
"release-native/dbx-agent-tdengine-${{ matrix.platform }}${{ matrix.extension }}"
|
||||
if [ -n "${{ matrix.manylinux_image }}" ]; then
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "${{ github.workspace }}/release-native:/driver:ro" \
|
||||
"${{ matrix.manylinux_image }}" \
|
||||
"/driver/dbx-agent-tdengine-${{ matrix.platform }}" < /dev/null
|
||||
elif [ "${{ matrix.smoke }}" = "true" ]; then
|
||||
"release-native/dbx-agent-tdengine-${{ matrix.platform }}${{ matrix.extension }}" < /dev/null
|
||||
fi
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tdengine-native-${{ matrix.platform }}
|
||||
path: "release-native/dbx-agent-tdengine-*"
|
||||
|
||||
build-jre:
|
||||
needs: [bump-versions]
|
||||
if: ${{ needs.bump-versions.outputs.build_jre == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -474,8 +755,41 @@ jobs:
|
|||
name: jre-${{ matrix.jre-key }}
|
||||
path: "dbx-jre-*.tar.zst"
|
||||
|
||||
reuse-previous-assets:
|
||||
name: Reuse unchanged agent artifacts
|
||||
needs: [bump-versions]
|
||||
if: ${{ needs.bump-versions.outputs.reuse_modules != '[]' || needs.bump-versions.outputs.reuse_jre == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install artifact tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
- name: Download and verify previous release artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PREV_TAG: ${{ needs.bump-versions.outputs.prev_tag }}
|
||||
PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }}
|
||||
REUSE_MODULES: ${{ needs.bump-versions.outputs.reuse_modules }}
|
||||
REUSE_JRE: ${{ needs.bump-versions.outputs.reuse_jre }}
|
||||
run: |
|
||||
node .github/scripts/reuse-agent-release-assets.mjs \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--tag "$PREV_TAG" \
|
||||
--versions "$PREV_VERSIONS" \
|
||||
--modules "$REUSE_MODULES" \
|
||||
--reuse-jre "$REUSE_JRE" \
|
||||
--output reused-release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: reused-agent-artifacts
|
||||
path: reused-release/*
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-kingbase-native, build-duckdb-native, build-jre]
|
||||
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-cassandra-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
|
||||
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create DBX bot release token
|
||||
|
|
@ -499,18 +813,32 @@ jobs:
|
|||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Install artifact tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
|
||||
- name: Flatten artifacts
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts/agent-jars -name '*.jar' -exec cp {} release/ \;
|
||||
find artifacts/oracle-native -type f -name 'dbx-agent-oracle-*' -exec cp {} release/ \;
|
||||
find artifacts/xugu-native -type f -name 'dbx-agent-xugu-*' -exec cp {} release/ \;
|
||||
find artifacts/rabbitmq-native -type f -name 'dbx-agent-rabbitmq-*' -exec cp {} release/ \;
|
||||
find artifacts/kingbase-native -type f -name 'dbx-agent-kingbase-*' -exec cp {} release/ \;
|
||||
find artifacts/duckdb-native-* -type f -name 'dbx-agent-duckdb-*' -exec cp {} release/ \;
|
||||
find artifacts -type f -name 'dbx-agent-*.jar' -exec cp {} release/ \;
|
||||
find artifacts -type f -name 'dbx-agent-*' ! -name '*.jar' ! -name '*.tar.zst' -exec cp {} release/ \;
|
||||
find artifacts -type f -name 'dbx-agent-*.tar.zst' -exec cp {} release/ \;
|
||||
find artifacts -name 'dbx-jre-*.tar.zst' -exec cp {} release/ \;
|
||||
ls -lh release/
|
||||
|
||||
- name: Extract reused raw agent artifacts
|
||||
if: ${{ needs.bump-versions.outputs.reuse_modules != '[]' }}
|
||||
env:
|
||||
PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }}
|
||||
REUSE_MODULES: ${{ needs.bump-versions.outputs.reuse_modules }}
|
||||
run: |
|
||||
node .github/scripts/reuse-agent-release-assets.mjs \
|
||||
--extract-packages release \
|
||||
--versions "$PREV_VERSIONS" \
|
||||
--modules "$REUSE_MODULES" \
|
||||
--output release
|
||||
|
||||
- name: Add versions to agent artifact filenames
|
||||
env:
|
||||
MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }}
|
||||
|
|
@ -578,9 +906,13 @@ jobs:
|
|||
local name="$1"
|
||||
case "$name" in
|
||||
kingbase) echo "人大金仓 KingbaseES" ;;
|
||||
vastbase) echo "Vastbase" ;;
|
||||
duckdb) echo "DuckDB" ;;
|
||||
xugu) echo "虚谷 XuguDB" ;;
|
||||
rabbitmq) echo "RabbitMQ" ;;
|
||||
cassandra) echo "Apache Cassandra" ;;
|
||||
neo4j) echo "Neo4j" ;;
|
||||
tdengine) echo "TDengine" ;;
|
||||
*) echo "$name" ;;
|
||||
esac
|
||||
}
|
||||
|
|
@ -644,7 +976,7 @@ jobs:
|
|||
[ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n'
|
||||
DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")"
|
||||
done
|
||||
for name in oracle xugu kingbase duckdb rabbitmq; do
|
||||
for name in oracle xugu kingbase vastbase neo4j duckdb rabbitmq cassandra tdengine; do
|
||||
version=$(get_module_version "$name")
|
||||
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
|
||||
native_json=$(generate_native_platforms "$name" "$version")
|
||||
|
|
@ -681,8 +1013,6 @@ jobs:
|
|||
|
||||
- name: Build single-driver packages
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
|
||||
python3 -m json.tool release/agent-registry.json > /dev/null
|
||||
echo "=== final agent-registry.json ==="
|
||||
|
|
@ -711,10 +1041,14 @@ jobs:
|
|||
local name="$1"
|
||||
case "$name" in
|
||||
kingbase) echo "人大金仓 KingbaseES" ;;
|
||||
vastbase) echo "Vastbase" ;;
|
||||
duckdb) echo "DuckDB" ;;
|
||||
oracle) echo "Oracle" ;;
|
||||
xugu) echo "虚谷 XuguDB" ;;
|
||||
rabbitmq) echo "RabbitMQ" ;;
|
||||
cassandra) echo "Apache Cassandra" ;;
|
||||
neo4j) echo "Neo4j" ;;
|
||||
tdengine) echo "TDengine" ;;
|
||||
*) echo "$name" ;;
|
||||
esac
|
||||
}
|
||||
|
|
@ -737,6 +1071,12 @@ jobs:
|
|||
LOG_PATH="agents/drivers/oracle-go/"
|
||||
elif [ "$name" = "kingbase" ]; then
|
||||
LOG_PATH="agents/drivers/kingbase-go/"
|
||||
elif [ "$name" = "vastbase" ]; then
|
||||
LOG_PATH="agents/drivers/vastbase-go/"
|
||||
elif [ "$name" = "cassandra" ]; then
|
||||
LOG_PATH="agents/drivers/cassandra-go/"
|
||||
elif [ "$name" = "neo4j" ]; then
|
||||
LOG_PATH="agents/drivers/neo4j-go/"
|
||||
elif [ -d "agents/drivers/$name" ]; then
|
||||
LOG_PATH="agents/drivers/$name/"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ jobs:
|
|||
env:
|
||||
# The workspace intentionally contains platform-specific CLI/MCP packages for every release target.
|
||||
NPM_CONFIG_LOGLEVEL: error
|
||||
# Same two-layer cache as rust-test/rust-fmt-clippy: sccache cannot
|
||||
# reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
|
|
@ -38,9 +44,98 @@ jobs:
|
|||
- name: Install frontend dependencies
|
||||
run: pnpm --filter dbx... install --frozen-lockfile
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
with:
|
||||
version: "v0.10.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./ -> target"
|
||||
# Own key: this job builds one crate with --no-default-features,
|
||||
# a different fingerprint than either Rust job's feature set, so
|
||||
# sharing their key would only ever miss and waste cache space.
|
||||
shared-key: ci-frontend-docs-export-smoke-x86_64-unknown-linux-gnu
|
||||
# Preserve completed dependency builds when a later step fails.
|
||||
cache-on-failure: true
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
# exportSmoke.spec.ts shells out to this example to build a real
|
||||
# to_standalone_html fixture. Built here, before `pnpm check`, so the
|
||||
# test's own `cargo run` (same flags — see the spec's comment) is a
|
||||
# cache hit rather than a fresh compile of dbx-core inside a vitest
|
||||
# hook. `--no-default-features`: the docs module needs none of
|
||||
# duckdb-sidecar, mq-admin, sqlite-sqlcipher or system-fonts, so this
|
||||
# avoids requiring any system dev packages this job doesn't already
|
||||
# install.
|
||||
- name: Build the docs export smoke fixture example
|
||||
run: cargo build -p dbx-core --locked --no-default-features --example docs_export_smoke
|
||||
|
||||
# Guards that the committed bundle (crates/dbx-core/assets/docs-export.*)
|
||||
# matches the sources under apps/desktop/ it was built from. This job is
|
||||
# what runs on a frontend-only change, so the guard has to run here too —
|
||||
# rust-test is gated on crates/**/src-tauri/**/Cargo.*, none of which a
|
||||
# frontend-only PR touches. Same `--no-default-features` fingerprint as
|
||||
# the prebuild step above, so this is a cache hit, not a fresh compile.
|
||||
- name: Guard the committed docs export bundle
|
||||
run: cargo test -p dbx-core --locked --no-default-features --lib docs::export
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
|
||||
- name: Frontend check
|
||||
run: pnpm check
|
||||
|
||||
# Advisory, not a guard: the manifest guard above hashes INPUTS, so it
|
||||
# cannot see a hand-edited artefact (no source hash moves) or a
|
||||
# toolchain bump (vite, tailwindcss and @vitejs/plugin-vue are
|
||||
# plugins, not modules — they never enter the graph). Rebuilding and
|
||||
# diffing checks the relationship between inputs and output instead,
|
||||
# closing both gaps. Must run last: it overwrites the committed
|
||||
# bundle, and both the guard above and `pnpm check` above (whose
|
||||
# exportSmoke.spec.ts executes the *committed* bundle) need it
|
||||
# untouched to be testing what is actually checked in.
|
||||
- name: Rebuild the docs export bundle
|
||||
run: pnpm build:docs-export
|
||||
|
||||
# continue-on-error for one cycle: every reproducibility observation
|
||||
# so far was same-platform, and this job is ubuntu-22.04 x86_64 like
|
||||
# every contributor's toolchain is lockfile-pinned to expect. If a
|
||||
# byte ever differs cross-platform, surface it in the job summary
|
||||
# rather than reddening every PR at once on a repo we contribute to,
|
||||
# not maintain.
|
||||
- name: Report any docs export bundle drift
|
||||
run: git diff --exit-code -- crates/dbx-core/assets/
|
||||
continue-on-error: true
|
||||
|
||||
github-scripts:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.github_scripts == 'true'
|
||||
|
|
@ -101,6 +196,7 @@ jobs:
|
|||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
|
|
@ -118,36 +214,119 @@ jobs:
|
|||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup Rust for standard Windows
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
- name: Check standard Windows dependency path
|
||||
run: cargo check --locked --package dbx --no-default-features --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Setup Rust for Windows 7
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
|
||||
- name: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
|
||||
|
||||
- name: Prepare WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Probe WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Build DBX for Windows 7
|
||||
run: cargo build --locked --package dbx --release --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
|
||||
|
||||
- name: Prepare WebView2 109 offline runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
|
||||
run: |
|
||||
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
|
||||
cargo build --locked --package dbx --release --features custom-protocol --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
|
||||
|
||||
- name: Bundle Windows 7 offline installer
|
||||
- name: Audit Windows 7 PE imports
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
|
||||
|
||||
- name: Bundle Windows 7 fixed-runtime installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-offline.conf.json
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if (!$installer) {
|
||||
Write-Error "Missing Windows 7 WebView2 offline installer in ${bundleDir}"
|
||||
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
|
||||
exit 1
|
||||
}
|
||||
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
|
||||
|
||||
- name: Audit Windows 7 installer contents
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = Get-ChildItem "target/x86_64-win7-windows-msvc/release/bundle/nsis" -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
|
||||
|
||||
- name: Upload Windows 7 test installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DBX-win7-fixed-runtime-test
|
||||
path: target/x86_64-win7-windows-msvc/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
duckdb-windows-driver:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.duckdb_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target/duckdb-driver
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Rust for Windows 7
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
with:
|
||||
version: "v0.10.0"
|
||||
|
||||
- name: Build DuckDB Windows driver
|
||||
shell: bash
|
||||
run: |
|
||||
pushd agents/drivers/duckdb
|
||||
cargo build \
|
||||
--locked \
|
||||
--release \
|
||||
--bin dbx-duckdb-driver \
|
||||
--target x86_64-win7-windows-msvc \
|
||||
-Z build-std=std,panic_abort
|
||||
popd
|
||||
|
||||
- name: Validate DuckDB Windows driver
|
||||
shell: bash
|
||||
run: |
|
||||
DRIVER="target/duckdb-driver/x86_64-win7-windows-msvc/release/dbx-duckdb-driver.exe"
|
||||
python agents/scripts/validate_windows_pe_dependencies.py "$DRIVER"
|
||||
"$DRIVER" < /dev/null
|
||||
|
||||
rust-fmt-clippy:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
|
|
@ -332,8 +511,8 @@ jobs:
|
|||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
cache: maven
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
- name: JDBC plugin version guard
|
||||
env:
|
||||
|
|
@ -370,6 +549,7 @@ jobs:
|
|||
rust_full: ${{ steps.rust-mode.outputs.full }}
|
||||
jdbc: ${{ steps.filter.outputs.jdbc }}
|
||||
agents: ${{ steps.filter.outputs.agents }}
|
||||
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows }}
|
||||
nix: ${{ steps.filter.outputs.nix }}
|
||||
windows_win7_bundle: ${{ steps.filter.outputs.windows_win7_bundle }}
|
||||
github_scripts: ${{ steps.filter.outputs.github_scripts }}
|
||||
|
|
@ -391,6 +571,8 @@ jobs:
|
|||
- 'package.json'
|
||||
- '.oxfmtrc.json'
|
||||
- 'scripts/run-check.mjs'
|
||||
- 'crates/dbx-core/src/docs/**'
|
||||
- 'crates/dbx-core/assets/docs-export.*'
|
||||
- '.github/workflows/ci.yml'
|
||||
packages:
|
||||
- 'packages/cli/**'
|
||||
|
|
@ -406,6 +588,7 @@ jobs:
|
|||
rust:
|
||||
- 'crates/**'
|
||||
- 'src-tauri/**'
|
||||
- 'vendor/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain*'
|
||||
|
|
@ -421,6 +604,11 @@ jobs:
|
|||
- '.github/scripts/bump-agent-versions.test.mjs'
|
||||
- '.github/workflows/agents-release.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
duckdb_windows:
|
||||
- 'agents/drivers/duckdb/**'
|
||||
- 'agents/scripts/validate_windows_pe_dependencies.py'
|
||||
- '.github/workflows/agents-release.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
nix:
|
||||
# These advisory checks validate the pnpm and Cargo dependency closures.
|
||||
- 'package.json'
|
||||
|
|
@ -436,16 +624,27 @@ jobs:
|
|||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/update-nix-pnpm-hash.yml'
|
||||
windows_win7_bundle:
|
||||
- '.github/scripts/assert-win7-pe-compat.ps1'
|
||||
- '.github/scripts/assert-win7-installer-content.ps1'
|
||||
- '.github/scripts/assert-webview2-win7-runtime.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-loader.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-runtime.ps1'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/release.yml'
|
||||
- 'src-tauri/tauri.webview2-win7-offline.conf.json'
|
||||
- 'src-tauri/tauri.webview2-win7-fixed.conf.json'
|
||||
- 'src-tauri/build.rs'
|
||||
- 'src-tauri/Cargo.toml'
|
||||
- 'src-tauri/windows/nsis/**'
|
||||
- 'src-tauri/src/commands/update.rs'
|
||||
- 'crates/dbx-core/Cargo.toml'
|
||||
- 'crates/dbx-core/src/db/postgres.rs'
|
||||
- 'crates/dbx-core/src/update.rs'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'vendor/ctor/**'
|
||||
- 'vendor/dirs-sys/**'
|
||||
- 'vendor/pageant/**'
|
||||
- 'vendor/wry/**'
|
||||
github_scripts:
|
||||
- '.github/scripts/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
|
@ -470,13 +669,13 @@ jobs:
|
|||
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
Cargo.toml|Cargo.lock|rust-toolchain*|.github/workflows/ci.yml|*/Cargo.toml)
|
||||
Cargo.toml|Cargo.lock|rust-toolchain*|.github/workflows/ci.yml|*/Cargo.toml|vendor/*)
|
||||
full=true
|
||||
break
|
||||
;;
|
||||
esac
|
||||
|
||||
done < <(git diff --name-only "$BASE_SHA" HEAD -- Cargo.toml Cargo.lock 'rust-toolchain*' crates src-tauri .github/workflows/ci.yml)
|
||||
done < <(git diff --name-only "$BASE_SHA" HEAD -- Cargo.toml Cargo.lock 'rust-toolchain*' crates src-tauri vendor .github/workflows/ci.yml)
|
||||
fi
|
||||
|
||||
echo "full=$full" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -502,7 +701,7 @@ jobs:
|
|||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.24.x"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
|
@ -523,6 +722,9 @@ jobs:
|
|||
- name: DuckDB driver tests
|
||||
run: cargo test --manifest-path drivers/duckdb/Cargo.toml --locked
|
||||
|
||||
- name: TDengine driver tests
|
||||
run: cargo test --manifest-path drivers/tdengine/Cargo.toml --locked
|
||||
|
||||
- name: Agent validation
|
||||
run: python3 scripts/validate_agents.py
|
||||
|
||||
|
|
@ -538,6 +740,18 @@ jobs:
|
|||
run: go test ./...
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
|
||||
- name: Cassandra native agent tests
|
||||
run: go test ./...
|
||||
working-directory: agents/drivers/cassandra-go
|
||||
|
||||
- name: Vastbase native agent tests
|
||||
run: go test ./...
|
||||
working-directory: agents/drivers/vastbase-go
|
||||
|
||||
- name: Neo4j native agent tests
|
||||
run: go test ./...
|
||||
working-directory: agents/drivers/neo4j-go
|
||||
|
||||
- name: Oracle native agent build
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-oracle-linux-x64 .
|
||||
working-directory: agents/drivers/oracle-go
|
||||
|
|
@ -550,6 +764,108 @@ jobs:
|
|||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rabbitmq-linux-x64 .
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
|
||||
- name: Cassandra native agent build
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-cassandra-linux-x64 .
|
||||
working-directory: agents/drivers/cassandra-go
|
||||
|
||||
- name: Vastbase native agent build
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-vastbase-linux-x64 .
|
||||
working-directory: agents/drivers/vastbase-go
|
||||
|
||||
- name: Neo4j native agent build
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-neo4j-linux-x64 .
|
||||
working-directory: agents/drivers/neo4j-go
|
||||
|
||||
- name: TDengine native agent build
|
||||
run: cargo build --manifest-path drivers/tdengine/Cargo.toml --locked --release --bin dbx-tdengine-driver
|
||||
|
||||
- name: TDengine native agent integration tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
while IFS='|' read -r version image; do
|
||||
name="dbx-tdengine-${version//./-}"
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$name" \
|
||||
--memory 4g \
|
||||
--ulimit nofile=65535:65535 \
|
||||
-p 6030:6030 \
|
||||
-p 6041:6041 \
|
||||
"$image"
|
||||
cleanup() {
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
ready=false
|
||||
dnodes=""
|
||||
for _ in $(seq 1 90); do
|
||||
dnodes="$(docker exec "$name" taos -s 'SHOW DNODES' 2>/dev/null || true)"
|
||||
if grep -Eq '\|[[:space:]]*ready[[:space:]]*\|' <<<"$dnodes"; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$ready" != "true" ]; then
|
||||
printf '%s\n' "$dnodes"
|
||||
docker logs "$name"
|
||||
exit 1
|
||||
fi
|
||||
TDENGINE_INTEGRATION=1 \
|
||||
TDENGINE_TEST_HOST=127.0.0.1 \
|
||||
TDENGINE_TEST_PORT=6041 \
|
||||
cargo test --manifest-path drivers/tdengine/Cargo.toml --locked --test live -- --nocapture
|
||||
cleanup
|
||||
trap - EXIT
|
||||
done <<'EOF'
|
||||
2.4.0.14|tdengine/tdengine:2.4.0.14
|
||||
2.6.0.34|tdengine/tdengine:2.6.0.34
|
||||
3.0.7.1|tdengine/tdengine:3.0.7.1
|
||||
3.3.6.13|tdengine/tdengine:3.3.6.13
|
||||
3.4.2.2|tdengine/tsdb:3.4.2.2
|
||||
EOF
|
||||
|
||||
- name: Cassandra native agent integration tests
|
||||
shell: bash
|
||||
working-directory: agents/drivers/cassandra-go
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for version in 3.11.19 5.0.6; do
|
||||
name="dbx-cassandra-${version//./-}"
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$name" \
|
||||
-e CASSANDRA_CLUSTER_NAME="DBX Cassandra CI $version" \
|
||||
-e CASSANDRA_DC=dc1 \
|
||||
-e CASSANDRA_RACK=rack1 \
|
||||
-e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch \
|
||||
-e CASSANDRA_NUM_TOKENS=16 \
|
||||
-e MAX_HEAP_SIZE=512M \
|
||||
-e HEAP_NEWSIZE=100M \
|
||||
-p 9042:9042 \
|
||||
"cassandra:$version"
|
||||
cleanup() {
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
ready=false
|
||||
for _ in $(seq 1 100); do
|
||||
if docker exec "$name" cqlsh -e 'SELECT release_version FROM system.local' >/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ "$ready" != "true" ]; then
|
||||
docker logs "$name"
|
||||
exit 1
|
||||
fi
|
||||
CASSANDRA_TEST_HOST=127.0.0.1 \
|
||||
CASSANDRA_TEST_PORT=9042 \
|
||||
go test -run '^TestCassandraIntegration$' -count=1 ./...
|
||||
cleanup
|
||||
trap - EXIT
|
||||
done
|
||||
|
||||
- name: RabbitMQ native agent integration tests
|
||||
shell: bash
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
|
|
|
|||
|
|
@ -87,16 +87,16 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cp plugins/jdbc/pom.xml "$TMP_DIR/pom.xml"
|
||||
cp plugins/jdbc/build.gradle "$TMP_DIR/build.gradle"
|
||||
cp plugins/jdbc/manifest.json "$TMP_DIR/manifest.json"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main
|
||||
git switch -C jdbc-plugin-version-bump origin/main
|
||||
cp "$TMP_DIR/pom.xml" plugins/jdbc/pom.xml
|
||||
cp "$TMP_DIR/build.gradle" plugins/jdbc/build.gradle
|
||||
cp "$TMP_DIR/manifest.json" plugins/jdbc/manifest.json
|
||||
git add plugins/jdbc/pom.xml plugins/jdbc/manifest.json
|
||||
git add plugins/jdbc/build.gradle plugins/jdbc/manifest.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "JDBC plugin version ${{ steps.bump.outputs.new_version }} is already on main."
|
||||
exit 0
|
||||
|
|
@ -357,6 +357,7 @@ jobs:
|
|||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
|
|
@ -377,6 +378,9 @@ jobs:
|
|||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
|
||||
# sccache must be set up before the WebView2 loader/runtime scripts: they
|
||||
# run `cargo fetch` under the job-wide RUSTC_WRAPPER=sccache, so installing
|
||||
# sccache only after them made cargo abort with "program not found".
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
with:
|
||||
|
|
@ -403,6 +407,18 @@ jobs:
|
|||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
|
||||
|
||||
- name: Prepare WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Probe WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
|
|
@ -416,13 +432,16 @@ jobs:
|
|||
run: pnpm build
|
||||
|
||||
- name: Build DBX for Windows 7
|
||||
run: cargo build --locked --package dbx --release --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
|
||||
|
||||
- name: Prepare WebView2 109 offline runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
|
||||
run: |
|
||||
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
|
||||
cargo build --locked --package dbx --release --features custom-protocol --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
|
||||
|
||||
- name: Bundle and upload Windows 7 offline installer
|
||||
- name: Audit Windows 7 PE imports
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
|
||||
|
||||
- name: Bundle and upload Windows 7 fixed-runtime installer
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -438,16 +457,18 @@ jobs:
|
|||
exit 1
|
||||
}
|
||||
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-offline.conf.json
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
|
||||
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if (!$installer) {
|
||||
Write-Error "Missing Windows 7 WebView2 offline installer in ${bundleDir}"
|
||||
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
|
||||
|
||||
Copy-Item $installer.FullName $offlineName -Force
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
|
|
@ -599,8 +620,8 @@ jobs:
|
|||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
cache: maven
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
- name: Apply automatic JDBC plugin version bump
|
||||
shell: bash
|
||||
|
|
@ -610,7 +631,7 @@ jobs:
|
|||
id: jdbc-plugin
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="$(grep -m1 '<version>' plugins/jdbc/pom.xml | sed -E 's/.*<version>([^<]+)<.*/\1/')"
|
||||
VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Package JDBC plugin
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
name: Windows Diagnostic Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_ref:
|
||||
description: "Branch, tag, or SHA to build"
|
||||
required: true
|
||||
default: "codex/windows-startup-diagnostics"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows-portable:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Build Windows executable
|
||||
env:
|
||||
CXXFLAGS: /std:c++17 /EHsc
|
||||
run: pnpm tauri build --target x86_64-pc-windows-msvc --no-bundle --ci
|
||||
|
||||
- name: Package diagnostic portable ZIP
|
||||
shell: pwsh
|
||||
run: |
|
||||
$portableDir = "diagnostic-portable\DBX-diagnostic-x64"
|
||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||
Copy-Item "target\x86_64-pc-windows-msvc\release\dbx.exe" (Join-Path $portableDir "DBX.exe") -Force
|
||||
Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force
|
||||
Copy-Item "README.md" (Join-Path $portableDir "README.md") -Force
|
||||
Set-Content -Path (Join-Path $portableDir "portable.dbx") -Value "" -NoNewline
|
||||
Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath "DBX-diagnostic-x64-portable.zip" -Force
|
||||
|
||||
- name: Upload diagnostic artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DBX-diagnostic-x64-portable
|
||||
path: DBX-diagnostic-x64-portable.zip
|
||||
|
|
@ -15,14 +15,18 @@ CLAUDE.md
|
|||
dist/
|
||||
coverage/
|
||||
/target/
|
||||
src-tauri/webview2-fixed-runtime/
|
||||
plugins/jdbc/target/
|
||||
plugins/jdbc/dependency-reduced-pom.xml
|
||||
plugins/jdbc/lib/*.jar
|
||||
plugins/jdbc/.gradle/
|
||||
plugins/jdbc/build/
|
||||
plugins/jdbc/dist/
|
||||
docs/.next/
|
||||
docs/out/
|
||||
docs/.source/
|
||||
docs/superpowers/
|
||||
/docs/aegis/
|
||||
src-tauri/*.dylib
|
||||
|
||||
# OS files
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
|
||||
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
|
|
@ -71,7 +71,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972"
|
||||
dependencies = [
|
||||
"aead 0.6.0-rc.10",
|
||||
"aes 0.9.1",
|
||||
"aes 0.9.2",
|
||||
"cipher 0.5.2",
|
||||
"ctr 0.10.1",
|
||||
"ghash 0.6.0",
|
||||
|
|
@ -256,9 +256,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
version = "0.4.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
|
|
@ -395,6 +395,35 @@ dependencies = [
|
|||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-tungstenite"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cca750b12e02c389c1694d35c16539f88b8bbaa5945934fdc1b41a776688589"
|
||||
dependencies = [
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rustls-native-certs 0.7.3",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.25.0",
|
||||
"tungstenite 0.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async_io_stream"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"pharos",
|
||||
"rustc_version",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asynchronous-codec"
|
||||
version = "0.6.2"
|
||||
|
|
@ -897,9 +926,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
version = "1.2.4"
|
||||
version = "1.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
|
||||
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
|
@ -1621,7 +1650,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.5.73"
|
||||
version = "0.5.79"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -1646,7 +1675,7 @@ dependencies = [
|
|||
"reqwest 0.12.28",
|
||||
"russh",
|
||||
"rust_decimal",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1669,12 +1698,13 @@ dependencies = [
|
|||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.51"
|
||||
version = "0.4.57"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
|
|
@ -1725,10 +1755,11 @@ dependencies = [
|
|||
"redis",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rumqttc",
|
||||
"rusqlite",
|
||||
"russh",
|
||||
"rust_decimal",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1759,14 +1790,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.51"
|
||||
version = "0.4.57"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
"dirs",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
|
@ -1777,7 +1808,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.5.73"
|
||||
version = "0.5.79"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
|
|
@ -1791,7 +1822,7 @@ dependencies = [
|
|||
"pbkdf2 0.12.2",
|
||||
"redis",
|
||||
"reqwest 0.12.28",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
|
@ -1997,8 +2028,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
|
|
@ -2020,13 +2049,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2233,7 +2262,7 @@ dependencies = [
|
|||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
|
|
@ -2343,11 +2372,10 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
|||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
|
@ -2483,6 +2511,17 @@ version = "0.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
|
|
@ -3279,9 +3318,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
|
|
@ -3371,7 +3410,7 @@ dependencies = [
|
|||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
|
|
@ -4441,7 +4480,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"rand 0.9.5",
|
||||
"rustc_version_runtime",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_with",
|
||||
|
|
@ -4554,7 +4593,7 @@ dependencies = [
|
|||
"mysql_common",
|
||||
"percent-encoding",
|
||||
"rand 0.10.2",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"socket2",
|
||||
"thiserror 2.0.19",
|
||||
|
|
@ -5223,8 +5262,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pageant"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"byteorder",
|
||||
|
|
@ -5236,8 +5273,8 @@ dependencies = [
|
|||
"sha2 0.11.0",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"windows 0.61.3",
|
||||
"windows 0.62.2",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5437,6 +5474,16 @@ dependencies = [
|
|||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pharos"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
|
|
@ -5523,7 +5570,7 @@ version = "0.8.0-rc.13"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5a777c6e26664bc9504b3ce3f6133f8f20d9071f130a4f9fcbd3186959d8dd6"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes 0.9.2",
|
||||
"aes-gcm 0.11.0-rc.3",
|
||||
"cbc 0.2.1",
|
||||
"der 0.8.1",
|
||||
|
|
@ -5921,7 +5968,7 @@ dependencies = [
|
|||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"socket2",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
|
|
@ -5942,7 +5989,7 @@ dependencies = [
|
|||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.19",
|
||||
|
|
@ -6141,7 +6188,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"rustls-native-certs 0.8.4",
|
||||
"ryu",
|
||||
"sha1_smol",
|
||||
|
|
@ -6256,7 +6303,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -6297,7 +6344,7 @@ dependencies = [
|
|||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
|
|
@ -6412,7 +6459,7 @@ dependencies = [
|
|||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.1",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
|
|
@ -6454,6 +6501,25 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rumqttc"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"bytes",
|
||||
"flume",
|
||||
"futures-util",
|
||||
"http",
|
||||
"log",
|
||||
"rustls-native-certs 0.7.3",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-webpki 0.102.8",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-rustls 0.25.0",
|
||||
"ws_stream_tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
|
|
@ -6476,7 +6542,7 @@ checksum = "324b92f459d3e42da294e14e8eb150d2215fcfb7c966838bc1127cd68bc05a0d"
|
|||
dependencies = [
|
||||
"aead 0.6.0-rc.10",
|
||||
"aes 0.8.4",
|
||||
"aes 0.9.1",
|
||||
"aes 0.9.2",
|
||||
"aes-gcm 0.11.0-rc.3",
|
||||
"aws-lc-rs",
|
||||
"bitflags 2.13.1",
|
||||
|
|
@ -6675,9 +6741,23 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
|
|
@ -6701,6 +6781,19 @@ dependencies = [
|
|||
"security-framework 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
|
||||
dependencies = [
|
||||
"openssl-probe 0.1.6",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
|
|
@ -6752,7 +6845,7 @@ dependencies = [
|
|||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"rustls-native-certs 0.8.4",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki 0.103.13",
|
||||
|
|
@ -6778,6 +6871,17 @@ dependencies = [
|
|||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
|
|
@ -6865,14 +6969,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive 1.2.1",
|
||||
"schemars_derive 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
@ -6885,20 +6989,20 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"serde_derive_internals 0.29.1",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.119",
|
||||
"serde_derive_internals 0.30.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7077,6 +7181,17 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
|
|
@ -7156,7 +7271,7 @@ dependencies = [
|
|||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"schemars 1.2.2",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
|
|
@ -7490,6 +7605,9 @@ name = "spin"
|
|||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
|
|
@ -7813,9 +7931,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tao-macros"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
|
||||
checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -8048,7 +8166,7 @@ dependencies = [
|
|||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.19",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
|
@ -8137,7 +8255,7 @@ dependencies = [
|
|||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.4",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -8250,7 +8368,7 @@ dependencies = [
|
|||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.19",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
|
|
@ -8265,7 +8383,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
|
|||
dependencies = [
|
||||
"dunce",
|
||||
"embed-resource",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8498,13 +8616,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.1"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8550,7 +8668,7 @@ checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144"
|
|||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"ring",
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-rustls 0.26.4",
|
||||
|
|
@ -8567,13 +8685,24 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
|
||||
dependencies = [
|
||||
"rustls 0.22.4",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.42",
|
||||
"rustls 0.23.43",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
|
@ -8597,7 +8726,7 @@ dependencies = [
|
|||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
"tungstenite 0.29.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8645,9 +8774,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.1.4+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
|
||||
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
|
|
@ -8723,9 +8852,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
|
@ -8857,9 +8986,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.24.1"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
||||
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
|
|
@ -8894,6 +9023,27 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.7",
|
||||
"rustls 0.22.4",
|
||||
"rustls-pki-types",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
|
|
@ -9120,6 +9270,12 @@ dependencies = [
|
|||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
@ -10210,8 +10366,6 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
|||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.55.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
|
|
@ -10251,6 +10405,26 @@ dependencies = [
|
|||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ws_stream_tungstenite"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a198f414f083fb19fcc1bffcb0fa0cf46d33ccfa229adf248cac12c180e91609"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"async_io_stream",
|
||||
"bitflags 2.13.1",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"pharos",
|
||||
"rustc_version",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tungstenite 0.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
|
|
|
|||
14
Cargo.toml
14
Cargo.toml
|
|
@ -1,12 +1,24 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli"]
|
||||
exclude = ["vendor/ctor"]
|
||||
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/wry"]
|
||||
|
||||
[patch.crates-io]
|
||||
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
|
||||
# upstream rust-ctor#443 until tauri-utils moves to ctor 1.x.
|
||||
ctor = { path = "vendor/ctor" }
|
||||
# rumqttc 0.24 hardcodes MQTT 3.1.1; this patch adds MQTT 3.1 CONNECT encoding.
|
||||
rumqttc = { path = "vendor/rumqttc" }
|
||||
# Keep two small upstream crates on Win7-compatible API calls. Their current
|
||||
# Windows bindings otherwise import COMBASE or WinRT APIs unavailable on Win7.
|
||||
dirs-sys = { path = "vendor/dirs-sys" }
|
||||
pageant = { path = "vendor/pageant" }
|
||||
# Wry 0.55 probes and creates WebView2 with a null browser folder. Pass the
|
||||
# bundled Fixed Runtime path explicitly so Windows 7 does not fall back to an
|
||||
# unavailable system Runtime. The vendored copy also intercepts the WebView2
|
||||
# F6 "Focus Next Pane" accelerator to prevent a black screen on frameless
|
||||
# Overlay-titlebar windows (see vendor/wry/src/webview2/mod.rs).
|
||||
wry = { path = "vendor/wry" }
|
||||
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
|
|
|
|||
6
Makefile
6
Makefile
|
|
@ -16,8 +16,8 @@ export CONFIRM
|
|||
node_modules/.modules.yaml: package.json pnpm-lock.yaml
|
||||
$(PNPM) install --frozen-lockfile
|
||||
|
||||
docs/node_modules/.modules.yaml: docs/package.json docs/pnpm-lock.yaml
|
||||
cd docs && $(PNPM) install --frozen-lockfile --ignore-workspace
|
||||
docs/node_modules/.modules.yaml: docs/package.json docs/pnpm-lock.yaml docs/pnpm-workspace.yaml $(wildcard docs/patches/*.patch)
|
||||
cd docs && $(PNPM) install --frozen-lockfile
|
||||
|
||||
help:
|
||||
@printf '%s\n' 'DBX development targets:'
|
||||
|
|
@ -59,7 +59,7 @@ install:
|
|||
$(PNPM) install --frozen-lockfile
|
||||
|
||||
docs-install:
|
||||
cd docs && $(PNPM) install --frozen-lockfile --ignore-workspace
|
||||
cd docs && $(PNPM) install --frozen-lockfile
|
||||
|
||||
check-tauri-dev-port:
|
||||
@if lsof -nP -iTCP:$(TAURI_DEV_PORT) -sTCP:LISTEN >/dev/null 2>&1; then \
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -1,15 +1,12 @@
|
|||
[](https://mcptoplist.com/server/io.github.t8y2%2Fdbx)
|
||||
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>70+ databases in 20 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260707.png" alt="DBX screenshot" width="820" />
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX screenshot" width="820" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
|
||||
<a href="https://github.com/t8y2/dbx/graphs/contributors"><img src="https://img.shields.io/github/contributors/t8y2/dbx?style=for-the-badge" /></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ1LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="Join QQ Group" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="Join WeChat Group" /></a>
|
||||
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="Join Discord" /></a>
|
||||
|
|
@ -48,6 +45,7 @@
|
|||
<img src="https://img.shields.io/badge/JDBC-4B5563?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/and%20more...-555555?logoColor=white" />
|
||||
<a href="https://cnb.cool/dbxio.com/dbx"><img src="https://img.shields.io/badge/CNB-dbx-F76945?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAppJREFUOE9tk01rE1EUhs+5H5lJ0iQT3FRX2Yi4avoLkv4CWxBcNt11oZiuFKGmXQhFN6kuRESSgitXKf6A5g+I6cqFLiKCiptOkraZ5H4cuZOkH9oLw+UO8z7nvGfei3DFohoEQLAKCGWyGJCSbQZsD3ei7r+f45WATV6xmjdISyAlYLaDliveux+ti5ozwLAaFPhIlk6G6f18sxuO7l9bBiWbVoncDGCMWMl++PI/gJ7IohkmD0zkBxT5oRn5LTpN7ULqBByEtFywWvRyHz8Fx3dvN0UwuMPyR0uJFycddH7tKPHZRn7BjnwwkQcUud0HO/K7oOENcTYPRCEAFERmsCpyfZDZfpdnBosOUKGx13BC60QTIdjpmSzrEWDR+UagjsgMciLXAwfhc4MNpKfQtOPE6mVh0gEOaeTXtWXXEdADpJAR/GaZ/rrM9Us82wORPd5zHbRp7JVcyxR5PRP5LTPy6uo0HXIlG9awMgLuEUABkBYI7VLyxs+yyPW3+Nyg4zqoG+UV7TDRlMBbWA/D43u3qlbLGmgRWM2BjFxjQjWQGQBuu4bYYvrm14JMnQSXcjBcny9A5DXIiDJp9/8FWCXAAOaFUEfILTgIcttOvf+2NJnLdI0fBFUyskZKBGTEJEAmBuzr9LCSUDwGQAwwgMzu+m9/VWOAeZSsk+YP48C4li/sRok1nlBd5PogBjgxNw70PfHyqBADyEVXicasZXCVpxYiLfO+HxVZDHCVYwvu2ebPT7fOLNAm65AWLnEAzvsEcOi9/lPU1aACXDfOLdhD9kxNszGdweQGYstqXgItJwDFNxKvwrp57G8h0zVwldHuA0IFt8El83yIs2FSDcpgxDJpUUTwK7gTduN3AK5iG7ehc/E2/gUPD3q3eY4awwAAAABJRU5ErkJggg==" alt="CNB" /></a>
|
||||
<a href="https://mcptoplist.com/server/io.github.t8y2%2Fdbx"><img src="https://mcptoplist.com/badge/io.github.t8y2%2Fdbx.svg" alt="MCP Toplist" /></a>
|
||||
</p>
|
||||
<p>
|
||||
English | <a href="README.zh-CN.md">前往中文版本</a>
|
||||
|
|
@ -92,7 +90,7 @@
|
|||
|
||||
### 70+ Databases, One Tool
|
||||
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
|
|
@ -443,6 +441,19 @@ DBX is free and open source, but ongoing maintenance, database compatibility tes
|
|||
<a href="https://easysearch.cn" target="_blank">Visit Easysearch</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Atlas Cloud gives developers one unified API for 400+ AI models across chat, image, video, and audio.
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">Visit Atlas Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- [Support DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) — voluntary donations via WeChat or Alipay
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 70+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260707.png" alt="DBX 截图" width="820" />
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX 截图" width="820" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
|
||||
<a href="https://github.com/t8y2/dbx/graphs/contributors"><img src="https://img.shields.io/github/contributors/t8y2/dbx?style=for-the-badge" /></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ0LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="加入 QQ 群" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信交流群-点击加入-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="加入微信交流群" /></a>
|
||||
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="加入 Discord" /></a>
|
||||
|
|
@ -46,6 +45,7 @@
|
|||
<img src="https://img.shields.io/badge/JDBC-4B5563?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/and%20more...-555555?logoColor=white" />
|
||||
<a href="https://cnb.cool/dbxio.com/dbx"><img src="https://img.shields.io/badge/CNB-dbx-F76945?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAppJREFUOE9tk01rE1EUhs+5H5lJ0iQT3FRX2Yi4avoLkv4CWxBcNt11oZiuFKGmXQhFN6kuRESSgitXKf6A5g+I6cqFLiKCiptOkraZ5H4cuZOkH9oLw+UO8z7nvGfei3DFohoEQLAKCGWyGJCSbQZsD3ei7r+f45WATV6xmjdISyAlYLaDliveux+ti5ozwLAaFPhIlk6G6f18sxuO7l9bBiWbVoncDGCMWMl++PI/gJ7IohkmD0zkBxT5oRn5LTpN7ULqBByEtFywWvRyHz8Fx3dvN0UwuMPyR0uJFycddH7tKPHZRn7BjnwwkQcUud0HO/K7oOENcTYPRCEAFERmsCpyfZDZfpdnBosOUKGx13BC60QTIdjpmSzrEWDR+UagjsgMciLXAwfhc4MNpKfQtOPE6mVh0gEOaeTXtWXXEdADpJAR/GaZ/rrM9Us82wORPd5zHbRp7JVcyxR5PRP5LTPy6uo0HXIlG9awMgLuEUABkBYI7VLyxs+yyPW3+Nyg4zqoG+UV7TDRlMBbWA/D43u3qlbLGmgRWM2BjFxjQjWQGQBuu4bYYvrm14JMnQSXcjBcny9A5DXIiDJp9/8FWCXAAOaFUEfILTgIcttOvf+2NJnLdI0fBFUyskZKBGTEJEAmBuzr9LCSUDwGQAwwgMzu+m9/VWOAeZSsk+YP48C4li/sRok1nlBd5PogBjgxNw70PfHyqBADyEVXicasZXCVpxYiLfO+HxVZDHCVYwvu2ebPT7fOLNAm65AWLnEAzvsEcOi9/lPU1aACXDfOLdhD9kxNszGdweQGYstqXgItJwDFNxKvwrp57G8h0zVwldHuA0IFt8El83yIs2FSDcpgxDJpUUTwK7gTduN3AK5iG7ehc/E2/gUPD3q3eY4awwAAAABJRU5ErkJggg==" alt="CNB" /></a>
|
||||
<a href="https://mcptoplist.com/server/io.github.t8y2%2Fdbx"><img src="https://mcptoplist.com/badge/io.github.t8y2%2Fdbx.svg" alt="MCP Toplist" /></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="README.md">English</a> | 简体中文
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
|
||||
### 70+ 种数据库,一个工具搞定
|
||||
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
|
|
@ -225,11 +225,15 @@ flatpak install flatpark com.dbxio.dbx
|
|||
|
||||
之后通过常规的 `flatpak update` 即可获取更新。详见 [FlatPark 上的 DBX 页面](https://flatpark.org/apps/com.dbxio.dbx/)。
|
||||
|
||||
**星火应用商店 / AmberPM (Linux):**
|
||||
**Spark Store 星火应用商店(Linux):**
|
||||
|
||||
通过[星火应用商店](https://spk-resolv.spark-app.store/?spk=spk://store/development/dbx)一键安装,后续可直接在商店客户端中获取更新。
|
||||
|
||||
银河麒麟 V10、统信 UOS 等系统推荐选择 **APM 版本**,以减少发行版依赖差异导致的安装或启动问题。APM 在兼容环境中运行 DBX;如果为 Agent/JDBC 驱动选择宿主机 Java,需要在路径前添加 `/host`,例如将 `/usr/bin/java` 填写为 `/host/usr/bin/java`。
|
||||
<a href="https://spk-resolv.spark-app.store/?spk=spk://store/development/dbx" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://spk-json.spark-app.store/install-from-spark-store.png" width="200"/>
|
||||
</a>
|
||||
|
||||
银河麒麟 V10、统信 UOS 等系统推荐选择 **APM(AmberPM)版本**,以减少发行版依赖差异导致的安装或启动问题。APM 在兼容环境中运行 DBX;如果为 Agent/JDBC 驱动选择宿主机 Java,需要在路径前添加 `/host`,例如将 `/usr/bin/java` 填写为 `/host/usr/bin/java`。
|
||||
|
||||
## 自托管 (Docker)
|
||||
|
||||
|
|
@ -439,6 +443,19 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
|
|||
<a href="https://easysearch.cn" target="_blank">访问 Easysearch</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Atlas Cloud 为开发者提供统一的多模态 AI API,可通过一个接口访问聊天、图像、视频和音频等 400+ 模型。
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">访问 Atlas Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- [支持 DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) —— 通过微信或支付宝自愿捐助
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
|||
| access | Microsoft Access | UCanAccess |
|
||||
| dameng | 达梦 DM8 | DM JDBC |
|
||||
| kingbase | 人大金仓 KingbaseES | gokb Go native agent |
|
||||
| vastbase | Vastbase | Vastbase JDBC |
|
||||
| vastbase | Vastbase | openGauss Go native agent |
|
||||
| uxdb | UXDB | UXDB JDBC |
|
||||
| goldendb | GoldenDB | MySQL Connector/J |
|
||||
| databend | Databend | Databend JDBC |
|
||||
|
|
@ -33,12 +33,12 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
|||
| hive | Apache Hive | Hive JDBC |
|
||||
| db2 | IBM DB2 | DB2 JDBC |
|
||||
| informix | IBM Informix | Informix JDBC |
|
||||
| neo4j | Neo4j | Neo4j JDBC |
|
||||
| cassandra | Apache Cassandra | Cassandra JDBC |
|
||||
| neo4j | Neo4j | Official Neo4j Go Driver native agent |
|
||||
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver native agent |
|
||||
| bigquery | Google BigQuery | BigQuery JDBC |
|
||||
| kylin | Apache Kylin | Kylin JDBC |
|
||||
| sundb | SunDB | SunDB JDBC |
|
||||
| tdengine | TDengine | taos-jdbcdriver (WebSocket, REST fallback) |
|
||||
| tdengine | TDengine 2.4+ | taos-connector-rust native WebSocket agent |
|
||||
| yashandb | 崖山 YashanDB | YashanDB JDBC |
|
||||
| xugu | 虚谷 XuguDB | XuguDB Go native agent |
|
||||
| iotdb | Apache IoTDB | IoTDB JDBC |
|
||||
|
|
@ -49,7 +49,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
|||
|
||||
## Multi-JRE Support
|
||||
|
||||
Most Java agents target JRE 21. Native agents, such as `duckdb`, `oracle`, `kingbase`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
|
||||
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
|
||||
|
||||
## JDBC Connection Pooling
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ Set `DBX_AGENT_JDBC_POOL_ENABLED=false` for a runtime-level compatibility fallba
|
|||
|
||||
For new agents, prefer a **native (Go or Rust) driver** over a Java/JDBC agent whenever a mature, license-compatible native driver is available. Native agents ship as a single self-contained executable with no JRE, which significantly reduces memory footprint and startup time — the JVM baseline that every Java agent pays even when idle is avoided entirely.
|
||||
|
||||
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/duckdb`, `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
|
||||
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
|
||||
- **Java/JDBC** — the default fallback when only a JDBC driver exists for the database, or when the native driver is immature or unmaintained. Most agents still fall in this category.
|
||||
|
||||
Native agents implement the same JSON-RPC contract and `versions.json` registration as Java agents; they ship an `agent` executable instead of `agent.jar`. If both native and Java source implementations exist for the same database, publish only the native artifact unless the Java variant has a separately registered compatibility profile, such as `oracle-legacy` / `oracle-10g`.
|
||||
|
|
@ -87,12 +87,15 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
|
|||
```bash
|
||||
./gradlew shadowJar
|
||||
(cd drivers/oracle-go && go build -o agent .)
|
||||
(cd drivers/cassandra-go && go build -o agent .)
|
||||
(cd drivers/kingbase-go && go build -o agent .)
|
||||
(cd drivers/vastbase-go && go build -o agent .)
|
||||
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
|
||||
(cd drivers/xugu && go build -o agent .)
|
||||
(cd drivers/rabbitmq && go build -o agent .)
|
||||
```
|
||||
|
||||
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/xugu`, and `drivers/rabbitmq`.
|
||||
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, and `drivers/rabbitmq`.
|
||||
|
||||
### Local DBX Runtime Test
|
||||
|
||||
|
|
@ -106,14 +109,14 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
|
|||
|
||||
Restart DBX or disconnect and reconnect the database so the new agent process loads the replacement JAR.
|
||||
|
||||
Native agents such as `oracle`, `kingbase`, `xugu`, and `rabbitmq` use the `agent` executable in the driver directory instead of `agent.jar`.
|
||||
Native agents such as `cassandra`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
|
||||
|
||||
## Versioning
|
||||
|
||||
Agent module versions are tracked in [`versions.json`](versions.json).
|
||||
|
||||
- **Changing an existing driver** — do not edit `versions.json` manually. The release CI diffs each `drivers/<module>/` directory against the previous tag and auto-bumps the patch version for every changed module (see [`bump-agent-versions.mjs`](../.github/scripts/bump-agent-versions.mjs)). A change to the shared `agents/common` runtime bumps every module that packages it.
|
||||
- **Adding a new driver** — add an entry to `versions.json`, e.g. `"rabbitmq": "0.1.0"`. The CI only bumps keys already present in the file, so a new module is invisible to versioning until it is registered here. In the same change, also add the module to `settings.gradle` and the support table above — `versions.json` keys must match the agent modules declared in `settings.gradle`, excluding the infrastructure modules `common` and `test-support`.
|
||||
- **Adding a new driver** — add an entry to `versions.json`, e.g. `"rabbitmq": "0.1.0"`. The CI only bumps keys already present in the file, so a new module is invisible to versioning until it is registered here. Java modules must also be added to `settings.gradle`; native modules must be registered in the release version script and workflow. Update the support table in the same change.
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
|||
| access | Microsoft Access | UCanAccess |
|
||||
| dameng | 达梦 DM8 | DM JDBC |
|
||||
| kingbase | 人大金仓 KingbaseES | gokb Go 原生 agent |
|
||||
| vastbase | Vastbase | Vastbase JDBC |
|
||||
| vastbase | Vastbase | openGauss Go 原生 agent |
|
||||
| uxdb | 优炫 UXDB | UXDB JDBC |
|
||||
| goldendb | GoldenDB | MySQL Connector/J |
|
||||
| databend | Databend | Databend JDBC |
|
||||
|
|
@ -33,12 +33,12 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
|||
| hive | Apache Hive | Hive JDBC |
|
||||
| db2 | IBM DB2 | DB2 JDBC |
|
||||
| informix | IBM Informix | Informix JDBC |
|
||||
| neo4j | Neo4j | Neo4j JDBC |
|
||||
| cassandra | Apache Cassandra | Cassandra JDBC |
|
||||
| neo4j | Neo4j | 官方 Neo4j Go Driver 原生 Agent |
|
||||
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver 原生 Agent |
|
||||
| bigquery | Google BigQuery | BigQuery JDBC |
|
||||
| kylin | Apache Kylin | Kylin JDBC |
|
||||
| sundb | SunDB | SunDB JDBC |
|
||||
| tdengine | TDengine | taos-jdbcdriver(WebSocket,REST 兜底) |
|
||||
| tdengine | TDengine 2.4+ | taos-connector-rust 原生 WebSocket agent |
|
||||
| yashandb | 崖山 YashanDB | YashanDB JDBC |
|
||||
| xugu | 虚谷 XuguDB | XuguDB Go 原生 agent |
|
||||
| iotdb | Apache IoTDB | IoTDB JDBC |
|
||||
|
|
@ -49,7 +49,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
|||
|
||||
## 多 JRE 支持
|
||||
|
||||
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `oracle`、`kingbase`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
|
||||
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
|
||||
|
||||
## JDBC 连接池
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
|
|||
|
||||
对于新 agent,只要存在成熟、许可证兼容的原生驱动,优先选择**原生(Go 或 Rust)驱动**而非 Java/JDBC agent。原生 agent 以单一自包含可执行文件发布,无需 JRE,可显著降低内存占用和启动时间 —— 完全避开 Java agent 即便空闲也要付出的 JVM 基线开销。
|
||||
|
||||
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/xugu` 和 `drivers/rabbitmq`(amqp091-go)。无需 JRE 下载与管理。
|
||||
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu` 和 `drivers/rabbitmq`(amqp091-go)。无需 JRE 下载与管理。
|
||||
- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。
|
||||
|
||||
原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时保留原生和 Java 源码实现,默认只发布原生产物;只有 Java 变体以独立兼容配置登记时才同时发布,例如 `oracle-legacy` / `oracle-10g`。
|
||||
|
|
@ -87,12 +87,15 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
|
|||
```bash
|
||||
./gradlew shadowJar
|
||||
(cd drivers/oracle-go && go build -o agent .)
|
||||
(cd drivers/cassandra-go && go build -o agent .)
|
||||
(cd drivers/kingbase-go && go build -o agent .)
|
||||
(cd drivers/vastbase-go && go build -o agent .)
|
||||
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
|
||||
(cd drivers/xugu && go build -o agent .)
|
||||
(cd drivers/rabbitmq && go build -o agent .)
|
||||
```
|
||||
|
||||
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/oracle-go`、`drivers/kingbase-go`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
|
||||
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
|
||||
|
||||
### 本地 DBX 运行时测试
|
||||
|
||||
|
|
@ -106,14 +109,14 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
|
|||
|
||||
重启 DBX 或断开重连数据库,使新 agent 进程加载替换后的 JAR。
|
||||
|
||||
`oracle`、`kingbase`、`xugu` 和 `rabbitmq` 等原生 agent 使用驱动目录下的 `agent` 可执行文件而非 `agent.jar`。
|
||||
`cassandra`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
|
||||
|
||||
## 版本管理
|
||||
|
||||
Agent 模块的版本记录在 [`versions.json`](versions.json) 中,遵循以下规则:
|
||||
|
||||
- **修改现有驱动**:无需手动编辑 `versions.json`。发版 CI 会把每个 `drivers/<module>/` 目录与上一个 tag 做对比,对有变更的模块自动 bump patch 版本号(见 [`bump-agent-versions.mjs`](../.github/scripts/bump-agent-versions.mjs))。若改动的是共享运行时 `agents/common`,所有依赖它的模块会一并 bump。
|
||||
- **新增驱动**:在 `versions.json` 中新增一行,例如 `"rabbitmq": "0.1.0"`。CI 只 bump 文件里已存在的 key,所以新模块在登记到这里之前对版本管理完全不可见。同一次改动中,还要把模块加进 `settings.gradle` 并更新上方的支持表 —— `versions.json` 的 key 必须与 `settings.gradle` 声明的 agent 模块一致(不含 `common`、`test-support` 这类基础设施模块)。
|
||||
- **新增驱动**:在 `versions.json` 中新增一行,例如 `"rabbitmq": "0.1.0"`。CI 只 bump 文件里已存在的 key,所以新模块在登记到这里之前对版本管理完全不可见。Java 模块还要加入 `settings.gradle`;原生模块要在发版版本脚本与 workflow 中登记,并同步更新上方支持表。
|
||||
|
||||
## 开发
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ plugins {
|
|||
def infrastructureProjects = ['common', 'test-support'] as Set
|
||||
def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set
|
||||
def pooledJdbcProjects = [
|
||||
'access', 'bigquery', 'cassandra', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
|
||||
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
|
||||
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo', 'hive',
|
||||
'informix', 'iotdb', 'iris', 'kylin', 'neo4j', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'tdengine', 'teradata', 'trino', 'uxdb',
|
||||
'vastbase', 'vertica', 'yashandb'
|
||||
'informix', 'iotdb', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
|
||||
'vertica', 'yashandb'
|
||||
] as Set
|
||||
def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) }
|
||||
def jdbcAgentProjects = agentProjects.findAll { !legacyStandaloneProjects.contains(it.name) }
|
||||
|
|
|
|||
|
|
@ -270,10 +270,18 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
|||
poolRegistry = registry;
|
||||
}
|
||||
|
||||
public boolean supportsConnectionPooling() {
|
||||
return true;
|
||||
}
|
||||
|
||||
final synchronized boolean usesConnectionPool() {
|
||||
return poolRegistry != null;
|
||||
}
|
||||
|
||||
final synchronized boolean hasActivePooledLeases() {
|
||||
return poolRegistry != null && poolIdentity != null && poolRegistry.hasActiveLeases(poolIdentity);
|
||||
}
|
||||
|
||||
final synchronized boolean quarantinePooledConnection() {
|
||||
pooledConnectionPoisoned = true;
|
||||
return requestActive && pooledLease != null && pooledLease.quarantine();
|
||||
|
|
@ -531,7 +539,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
|||
return this::resultValue;
|
||||
}
|
||||
|
||||
private Connection openInitializedConnection(ConnectParams params) throws Exception {
|
||||
protected final Connection openInitializedConnection(ConnectParams params) throws Exception {
|
||||
Connection opened = openConnection(params);
|
||||
try {
|
||||
afterPhysicalConnect(params, opened);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ public final class AgentProtocol {
|
|||
public static final String MONGO_METHOD_LIST_DATABASES = "list_databases";
|
||||
public static final String MONGO_METHOD_LIST_COLLECTIONS = "list_collections";
|
||||
public static final String MONGO_METHOD_FIND_DOCUMENTS = "find_documents";
|
||||
public static final String MONGO_METHOD_FIND_ONE = "find_one";
|
||||
public static final String MONGO_METHOD_EXPLAIN_FIND = "explain_find";
|
||||
public static final String MONGO_METHOD_AGGREGATE_DOCUMENTS = "aggregate_documents";
|
||||
/**
|
||||
* MongoDB read path that returns documents as relaxed Extended JSON for transfer.
|
||||
*/
|
||||
|
|
@ -55,8 +58,10 @@ public final class AgentProtocol {
|
|||
public static final String MONGO_METHOD_COUNT_DOCUMENTS = "count_documents";
|
||||
public static final String MONGO_METHOD_SERVER_VERSION = "server_version";
|
||||
public static final String MONGO_METHOD_CREATE_INDEX = "create_index";
|
||||
public static final String MONGO_METHOD_CREATE_USER = "create_user";
|
||||
public static final String MONGO_METHOD_DROP_INDEXES = "drop_indexes";
|
||||
public static final String MONGO_METHOD_DROP_COLLECTION = "drop_collection";
|
||||
public static final String MONGO_METHOD_DROP_DATABASE = "drop_database";
|
||||
public static final String MONGO_METHOD_INSERT_DOCUMENT = "insert_document";
|
||||
public static final String MONGO_METHOD_UPDATE_DOCUMENT = "update_document";
|
||||
public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents";
|
||||
|
|
@ -112,7 +117,9 @@ public final class AgentProtocol {
|
|||
public static final String CAPABILITY_ETCD_WATCH = "etcd_watch";
|
||||
public static final String CAPABILITY_ETCD_LEASE = "etcd_lease";
|
||||
public static final String CAPABILITY_ETCD_AUTH = "etcd_auth";
|
||||
public static final String CAPABILITY_MONGO_DROP_DATABASE = "mongo_drop_database";
|
||||
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
|
||||
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
|
||||
|
||||
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
|
||||
CAPABILITY_CONNECT,
|
||||
|
|
@ -142,11 +149,16 @@ public final class AgentProtocol {
|
|||
CAPABILITY_ETCD_DEFRAG,
|
||||
CAPABILITY_ETCD_WATCH,
|
||||
CAPABILITY_ETCD_LEASE,
|
||||
CAPABILITY_ETCD_AUTH
|
||||
CAPABILITY_ETCD_AUTH,
|
||||
CAPABILITY_MONGO_DROP_DATABASE
|
||||
));
|
||||
|
||||
public static final List<String> MULTI_SESSION_CAPABILITIES;
|
||||
public static final List<String> MULTI_SESSION_ALL_CAPABILITIES;
|
||||
public static final List<String> MONGO_LEGACY_CAPABILITIES;
|
||||
public static final List<String> MONGO_LEGACY_MULTI_SESSION_CAPABILITIES;
|
||||
public static final List<String> MULTI_SESSION_JDBC_CAPABILITIES;
|
||||
public static final List<String> MULTI_SESSION_JDBC_ALL_CAPABILITIES;
|
||||
|
||||
public static final List<String> COMMON_METHODS = Collections.unmodifiableList(Arrays.asList(
|
||||
METHOD_HANDSHAKE,
|
||||
|
|
@ -194,6 +206,22 @@ public final class AgentProtocol {
|
|||
allCapabilities.add(CAPABILITY_MULTI_SESSION);
|
||||
MULTI_SESSION_ALL_CAPABILITIES = Collections.unmodifiableList(allCapabilities);
|
||||
|
||||
List<String> mongoCapabilities = new java.util.ArrayList<>(CAPABILITIES);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
|
||||
MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities);
|
||||
|
||||
List<String> mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
|
||||
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities);
|
||||
|
||||
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
|
||||
jdbcCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
|
||||
MULTI_SESSION_JDBC_CAPABILITIES = Collections.unmodifiableList(jdbcCapabilities);
|
||||
|
||||
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
|
||||
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
|
||||
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
|
||||
|
||||
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
|
||||
int insertAt = methods.indexOf(METHOD_CONNECT) + 1;
|
||||
methods.addAll(insertAt, Arrays.asList(
|
||||
|
|
@ -209,12 +237,17 @@ public final class AgentProtocol {
|
|||
MONGO_METHOD_LIST_DATABASES,
|
||||
MONGO_METHOD_LIST_COLLECTIONS,
|
||||
MONGO_METHOD_FIND_DOCUMENTS,
|
||||
MONGO_METHOD_FIND_ONE,
|
||||
MONGO_METHOD_EXPLAIN_FIND,
|
||||
MONGO_METHOD_AGGREGATE_DOCUMENTS,
|
||||
MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON,
|
||||
MONGO_METHOD_COUNT_DOCUMENTS,
|
||||
MONGO_METHOD_SERVER_VERSION,
|
||||
MONGO_METHOD_CREATE_INDEX,
|
||||
MONGO_METHOD_CREATE_USER,
|
||||
MONGO_METHOD_DROP_INDEXES,
|
||||
MONGO_METHOD_DROP_COLLECTION,
|
||||
MONGO_METHOD_DROP_DATABASE,
|
||||
MONGO_METHOD_INSERT_DOCUMENT,
|
||||
MONGO_METHOD_UPDATE_DOCUMENT,
|
||||
MONGO_METHOD_UPDATE_DOCUMENTS,
|
||||
|
|
@ -270,6 +303,30 @@ public final class AgentProtocol {
|
|||
);
|
||||
}
|
||||
|
||||
public static HandshakeResult mongoLegacyHandshakeResult() {
|
||||
return new HandshakeResult(PROTOCOL_VERSION, PROTOCOL_VERSION, MONGO_LEGACY_CAPABILITIES);
|
||||
}
|
||||
|
||||
public static HandshakeResult mongoLegacyMultiSessionHandshakeResult() {
|
||||
return new HandshakeResult(
|
||||
MULTI_SESSION_PROTOCOL_VERSION,
|
||||
MULTI_SESSION_PROTOCOL_VERSION,
|
||||
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handshake for pooled JDBC Agents that emit the v1 structured error contract.
|
||||
* Generic/custom v2 handlers must continue using multiSessionHandshakeResult().
|
||||
*/
|
||||
public static HandshakeResult multiSessionJdbcHandshakeResult() {
|
||||
return new HandshakeResult(
|
||||
MULTI_SESSION_PROTOCOL_VERSION,
|
||||
MULTI_SESSION_PROTOCOL_VERSION,
|
||||
MULTI_SESSION_JDBC_CAPABILITIES
|
||||
);
|
||||
}
|
||||
|
||||
public static final class HandshakeResult {
|
||||
private final int protocolVersion;
|
||||
private final int agentProtocolVersion;
|
||||
|
|
|
|||
|
|
@ -3,15 +3,21 @@ package com.dbx.agent;
|
|||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.sql.SQLRecoverableException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
final class AgentRpcError extends RuntimeException {
|
||||
private final String category;
|
||||
private final boolean retryable;
|
||||
private final String disposition;
|
||||
private final String stage;
|
||||
private final String operationOutcome;
|
||||
private final String sqlState;
|
||||
private final Integer vendorCode;
|
||||
private final String exceptionClass;
|
||||
|
||||
private AgentRpcError(
|
||||
String message,
|
||||
|
|
@ -19,6 +25,10 @@ final class AgentRpcError extends RuntimeException {
|
|||
boolean retryable,
|
||||
String disposition,
|
||||
String stage,
|
||||
String operationOutcome,
|
||||
String sqlState,
|
||||
Integer vendorCode,
|
||||
String exceptionClass,
|
||||
Throwable cause
|
||||
) {
|
||||
super(message, cause);
|
||||
|
|
@ -26,6 +36,10 @@ final class AgentRpcError extends RuntimeException {
|
|||
this.retryable = retryable;
|
||||
this.disposition = disposition;
|
||||
this.stage = stage;
|
||||
this.operationOutcome = operationOutcome;
|
||||
this.sqlState = sqlState;
|
||||
this.vendorCode = vendorCode;
|
||||
this.exceptionClass = exceptionClass;
|
||||
}
|
||||
|
||||
static AgentRpcError resource(String stage, Throwable cause) {
|
||||
|
|
@ -35,6 +49,10 @@ final class AgentRpcError extends RuntimeException {
|
|||
false,
|
||||
"replace_runtime",
|
||||
stage,
|
||||
operationOutcome(stage),
|
||||
null,
|
||||
null,
|
||||
cause == null ? null : cause.getClass().getName(),
|
||||
cause
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +64,10 @@ final class AgentRpcError extends RuntimeException {
|
|||
true,
|
||||
"keep",
|
||||
stage,
|
||||
operationOutcome(stage),
|
||||
null,
|
||||
null,
|
||||
cause == null ? null : cause.getClass().getName(),
|
||||
cause
|
||||
);
|
||||
}
|
||||
|
|
@ -60,6 +82,13 @@ final class AgentRpcError extends RuntimeException {
|
|||
data.addProperty("retryable", classified.retryable);
|
||||
data.addProperty("sessionDisposition", classified.disposition);
|
||||
data.addProperty("stage", classified.stage);
|
||||
data.addProperty("contractVersion", 1);
|
||||
data.addProperty("operationOutcome", classified.operationOutcome);
|
||||
addDiagnostic(data, "sqlState", classified.sqlState);
|
||||
if (classified.vendorCode != null) {
|
||||
data.addProperty("vendorCode", classified.vendorCode);
|
||||
}
|
||||
addDiagnostic(data, "exceptionClass", classified.exceptionClass);
|
||||
if (agentSessionId != null && !agentSessionId.trim().isEmpty()) {
|
||||
data.addProperty("agentSessionId", agentSessionId);
|
||||
}
|
||||
|
|
@ -72,34 +101,69 @@ final class AgentRpcError extends RuntimeException {
|
|||
if (explicit != null) {
|
||||
return explicit;
|
||||
}
|
||||
String stage = stage(method);
|
||||
if (find(error, CancellationException.class) != null || find(error, InterruptedException.class) != null) {
|
||||
return new AgentRpcError(
|
||||
message(error),
|
||||
"canceled",
|
||||
false,
|
||||
"quarantine",
|
||||
stage,
|
||||
operationOutcome(stage),
|
||||
null,
|
||||
null,
|
||||
safeClassName(error),
|
||||
error
|
||||
);
|
||||
}
|
||||
SQLException sqlError = find(error, SQLException.class);
|
||||
if (sqlError != null) {
|
||||
String sqlState = sqlError.getSQLState();
|
||||
String stage = stage(method);
|
||||
String category = sqlError instanceof SQLTimeoutException ? "timeout" : null;
|
||||
boolean connectionError = "connect".equals(stage)
|
||||
|| "validate".equals(stage)
|
||||
|| sqlError instanceof SQLRecoverableException
|
||||
|| sqlError instanceof SQLTransientConnectionException
|
||||
|| (sqlState != null && sqlState.toUpperCase(Locale.ROOT).startsWith("08"));
|
||||
boolean operationRetryable = connectionError && ("connect".equals(stage) || "validate".equals(stage));
|
||||
String disposition = connectionError && !"connect".equals(stage) ? "quarantine" : "keep";
|
||||
String disposition = category != null || (connectionError && !"connect".equals(stage)) ? "quarantine" : "keep";
|
||||
return new AgentRpcError(
|
||||
message(error),
|
||||
connectionError ? "connection" : "sql",
|
||||
category == null ? (connectionError ? "connection" : "sql") : category,
|
||||
operationRetryable,
|
||||
disposition,
|
||||
stage,
|
||||
operationOutcome(stage),
|
||||
safeSqlState(sqlState),
|
||||
sqlError.getErrorCode(),
|
||||
safeClassName(sqlError),
|
||||
error
|
||||
);
|
||||
}
|
||||
return new AgentRpcError(message(error), "protocol", false, "keep", stage(method), error);
|
||||
return new AgentRpcError(
|
||||
message(error),
|
||||
"protocol",
|
||||
false,
|
||||
"keep",
|
||||
stage,
|
||||
operationOutcome(stage),
|
||||
null,
|
||||
null,
|
||||
safeClassName(error),
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
private static String stage(String method) {
|
||||
if (method == null) {
|
||||
return "request";
|
||||
}
|
||||
if (AgentProtocol.METHOD_CONNECT.equals(method) || AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
|
||||
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
|
||||
return "request";
|
||||
}
|
||||
if (AgentProtocol.METHOD_CONNECT.equals(method)
|
||||
|| AgentProtocol.METHOD_OPEN_SESSION.equals(method)
|
||||
|| AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
|
||||
return "connect";
|
||||
}
|
||||
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method) || AgentProtocol.METHOD_VALIDATE_SESSION.equals(method)) {
|
||||
|
|
@ -108,7 +172,11 @@ final class AgentRpcError extends RuntimeException {
|
|||
if (AgentProtocol.METHOD_CANCEL_SESSION.equals(method)) {
|
||||
return "cancel";
|
||||
}
|
||||
if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method) || AgentProtocol.METHOD_DISCONNECT.equals(method)) {
|
||||
if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)
|
||||
|| AgentProtocol.METHOD_DISCONNECT.equals(method)
|
||||
|| AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)
|
||||
|| AgentProtocol.METHOD_CLOSE_TABLE_READ_SESSION.equals(method)
|
||||
|| AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
|
||||
return "close";
|
||||
}
|
||||
if (AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method)
|
||||
|
|
@ -122,6 +190,41 @@ final class AgentRpcError extends RuntimeException {
|
|||
return error.getMessage() == null ? error.toString() : error.getMessage();
|
||||
}
|
||||
|
||||
private static String operationOutcome(String stage) {
|
||||
return switch (stage) {
|
||||
case "request", "checkout", "connect", "validate" -> "not_started";
|
||||
default -> "unknown";
|
||||
};
|
||||
}
|
||||
|
||||
private static String safeSqlState(String sqlState) {
|
||||
return safeDiagnostic(sqlState, 16);
|
||||
}
|
||||
|
||||
private static String safeClassName(Throwable error) {
|
||||
return error == null ? null : safeDiagnostic(error.getClass().getName(), 160);
|
||||
}
|
||||
|
||||
private static String safeDiagnostic(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder safe = new StringBuilder(Math.min(value.length(), maxLength));
|
||||
for (int index = 0; index < value.length() && safe.length() < maxLength; index++) {
|
||||
char character = value.charAt(index);
|
||||
if (character >= 0x21 && character <= 0x7e) {
|
||||
safe.append(character);
|
||||
}
|
||||
}
|
||||
return safe.isEmpty() ? null : safe.toString();
|
||||
}
|
||||
|
||||
private static void addDiagnostic(JsonObject data, String name, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
data.addProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends Throwable> T find(Throwable error, Class<T> type) {
|
||||
Throwable current = error;
|
||||
while (current != null) {
|
||||
|
|
|
|||
|
|
@ -32,25 +32,8 @@ public final class BatchExecutor {
|
|||
return unchecked(() -> {
|
||||
long start = System.currentTimeMillis();
|
||||
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
long totalAffected = 0;
|
||||
int statementCount = 0;
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
for (String statement : statements) {
|
||||
String trimmed = JdbcExecutor.trimSql(statement);
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
stmt.addBatch(trimmed);
|
||||
statementCount++;
|
||||
}
|
||||
if (statementCount > 0) {
|
||||
totalAffected = affectedRows(executeBatch(stmt));
|
||||
}
|
||||
} catch (BatchUpdateException e) {
|
||||
long[] counts = e.getLargeUpdateCounts();
|
||||
int failedIndex = counts == null ? 1 : counts.length + 1;
|
||||
throw new RuntimeException("Statement " + failedIndex + " failed: " + e.getMessage(), e);
|
||||
}
|
||||
Long batchAffected = tryExecuteBatch(conn, statements);
|
||||
long totalAffected = batchAffected == null ? executeIndividually(conn, statements) : batchAffected;
|
||||
return new QueryResult(
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
|
|
@ -61,6 +44,54 @@ public final class BatchExecutor {
|
|||
});
|
||||
}
|
||||
|
||||
private static Long tryExecuteBatch(Connection conn, List<String> statements) throws Exception {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
try {
|
||||
int statementCount = 0;
|
||||
for (String statement : statements) {
|
||||
String trimmed = JdbcExecutor.trimSql(statement);
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
stmt.addBatch(trimmed);
|
||||
statementCount++;
|
||||
}
|
||||
return statementCount == 0 ? 0L : affectedRows(executeBatch(stmt));
|
||||
} catch (BatchUpdateException e) {
|
||||
long[] counts = e.getLargeUpdateCounts();
|
||||
int failedIndex = counts == null ? 1 : counts.length + 1;
|
||||
throw new RuntimeException("Statement " + failedIndex + " failed: " + e.getMessage(), e);
|
||||
} catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long executeIndividually(Connection conn, List<String> statements) throws Exception {
|
||||
long totalAffected = 0;
|
||||
int statementIndex = 0;
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
for (String statement : statements) {
|
||||
String trimmed = JdbcExecutor.trimSql(statement);
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
statementIndex++;
|
||||
try {
|
||||
if (!stmt.execute(trimmed)) {
|
||||
long updateCount = updateCount(stmt);
|
||||
if (updateCount >= 0) {
|
||||
totalAffected += updateCount;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Statement " + statementIndex + " failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalAffected;
|
||||
}
|
||||
|
||||
private static long affectedRows(long[] updateCounts) {
|
||||
long total = 0;
|
||||
if (updateCounts == null) {
|
||||
|
|
@ -89,6 +120,14 @@ public final class BatchExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
private static long updateCount(Statement stmt) throws Exception {
|
||||
try {
|
||||
return stmt.getLargeUpdateCount();
|
||||
} catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
|
||||
return stmt.getUpdateCount();
|
||||
}
|
||||
}
|
||||
|
||||
private static void applySchema(
|
||||
Connection conn,
|
||||
String schema,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,11 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
|||
return physicalConnectionBudget.activeCount();
|
||||
}
|
||||
|
||||
boolean hasActiveLeases(String identity) {
|
||||
PoolEntry entry = pools.get(digest(identity));
|
||||
return entry != null && entry.hasActiveLeases();
|
||||
}
|
||||
|
||||
private PoolEntry createPoolEntry(String key, ConnectionFactory connectionFactory) {
|
||||
try {
|
||||
ConnectionFactoryDataSource factoryDataSource = new ConnectionFactoryDataSource(
|
||||
|
|
@ -695,6 +700,10 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
|||
return retired;
|
||||
}
|
||||
|
||||
private synchronized boolean hasActiveLeases() {
|
||||
return activeLeases > 0;
|
||||
}
|
||||
|
||||
private void retireAfterCheckoutFailure(OperationDeadline deadline) {
|
||||
synchronized (this) {
|
||||
retired = true;
|
||||
|
|
@ -1475,7 +1484,7 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
|||
call("physical_set_network_timeout", () -> {
|
||||
connection.setNetworkTimeout(networkTimeoutExecutor, networkTimeoutMillis);
|
||||
return null;
|
||||
}, factoryDataSource, timeoutMillis);
|
||||
}, factoryDataSource, timeoutMillis, true);
|
||||
}
|
||||
|
||||
private <T> T call(
|
||||
|
|
@ -1483,6 +1492,16 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
|||
PhysicalConnectionCall<T> call,
|
||||
ConnectionFactoryDataSource factoryDataSource,
|
||||
long timeoutMillis
|
||||
) throws SQLException {
|
||||
return call(operation, call, factoryDataSource, timeoutMillis, false);
|
||||
}
|
||||
|
||||
private <T> T call(
|
||||
String operation,
|
||||
PhysicalConnectionCall<T> call,
|
||||
ConnectionFactoryDataSource factoryDataSource,
|
||||
long timeoutMillis,
|
||||
boolean preserveCompletedFailure
|
||||
) throws SQLException {
|
||||
CompletableFuture<T> outcome = new CompletableFuture<>();
|
||||
try {
|
||||
|
|
@ -1511,6 +1530,13 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
|||
factoryDataSource.poison(failure);
|
||||
throw failure;
|
||||
} catch (ExecutionException error) {
|
||||
if (preserveCompletedFailure) {
|
||||
Throwable cause = error.getCause();
|
||||
if (cause instanceof SQLException sqlError) {
|
||||
throw sqlError;
|
||||
}
|
||||
throw new SQLException("JDBC physical operation failed: " + operation, cause);
|
||||
}
|
||||
SQLException failure = new PhysicalConnectionStateUnknownException(error.getCause());
|
||||
factoryDataSource.poison(failure);
|
||||
throw failure;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,21 @@ public final class JdbcExecutor {
|
|||
Integer fetchSize,
|
||||
int timeoutSecs,
|
||||
ResultValueReader valueReader
|
||||
) {
|
||||
return execute(conn, sql, schema, setSchemaSql, resetSchemaSql, maxRows, fetchSize, timeoutSecs, valueReader, StatementMessageReader.NONE);
|
||||
}
|
||||
|
||||
public QueryResult execute(
|
||||
Connection conn,
|
||||
String sql,
|
||||
String schema,
|
||||
Function<String, String> setSchemaSql,
|
||||
Supplier<String> resetSchemaSql,
|
||||
int maxRows,
|
||||
Integer fetchSize,
|
||||
int timeoutSecs,
|
||||
ResultValueReader valueReader,
|
||||
StatementMessageReader statementMessageReader
|
||||
) {
|
||||
return unchecked(() -> {
|
||||
String trimmedSql = trimSql(sql);
|
||||
|
|
@ -122,7 +137,7 @@ public final class JdbcExecutor {
|
|||
false
|
||||
);
|
||||
}
|
||||
return withStatementWarnings(result, stmt);
|
||||
return withStatementMessages(result, stmt, effectiveMaxRows, statementMessageReader);
|
||||
} finally {
|
||||
activeStatements.remove(stmt);
|
||||
}
|
||||
|
|
@ -198,7 +213,19 @@ public final class JdbcExecutor {
|
|||
QueryPageOptions options,
|
||||
ResultValueReader valueReader
|
||||
) {
|
||||
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, sessions);
|
||||
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, StatementMessageReader.NONE, sessions);
|
||||
}
|
||||
|
||||
public QueryPageResult executePage(
|
||||
Connection conn,
|
||||
String sql,
|
||||
String schema,
|
||||
Function<String, String> setSchemaSql,
|
||||
QueryPageOptions options,
|
||||
ResultValueReader valueReader,
|
||||
StatementMessageReader statementMessageReader
|
||||
) {
|
||||
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, statementMessageReader, sessions);
|
||||
}
|
||||
|
||||
public QueryPageResult executePage(
|
||||
|
|
@ -210,7 +237,7 @@ public final class JdbcExecutor {
|
|||
QueryPageOptions options,
|
||||
ResultValueReader valueReader
|
||||
) {
|
||||
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, sessions);
|
||||
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, StatementMessageReader.NONE, sessions);
|
||||
}
|
||||
|
||||
public QueryPageResult startTableRead(
|
||||
|
|
@ -221,7 +248,7 @@ public final class JdbcExecutor {
|
|||
QueryPageOptions options,
|
||||
ResultValueReader valueReader
|
||||
) {
|
||||
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, tableReadSessions);
|
||||
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, StatementMessageReader.NONE, tableReadSessions);
|
||||
}
|
||||
|
||||
public QueryPageResult startTableRead(
|
||||
|
|
@ -233,7 +260,7 @@ public final class JdbcExecutor {
|
|||
QueryPageOptions options,
|
||||
ResultValueReader valueReader
|
||||
) {
|
||||
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, tableReadSessions);
|
||||
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, StatementMessageReader.NONE, tableReadSessions);
|
||||
}
|
||||
|
||||
private QueryPageResult executePage(
|
||||
|
|
@ -244,6 +271,7 @@ public final class JdbcExecutor {
|
|||
Supplier<String> resetSchemaSql,
|
||||
QueryPageOptions options,
|
||||
ResultValueReader valueReader,
|
||||
StatementMessageReader statementMessageReader,
|
||||
ConcurrentHashMap<String, QuerySession> targetSessions
|
||||
) {
|
||||
return unchecked(() -> {
|
||||
|
|
@ -267,13 +295,32 @@ public final class JdbcExecutor {
|
|||
long elapsed = System.currentTimeMillis() - start;
|
||||
if (!hasResultSet) {
|
||||
int updateCount = stmt.getUpdateCount();
|
||||
activeStatements.remove(stmt);
|
||||
stmt.close();
|
||||
return new QueryPageResult(
|
||||
QueryResult result = new QueryResult(
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
updateCount >= 0 ? updateCount : 0,
|
||||
elapsed
|
||||
elapsed,
|
||||
false
|
||||
);
|
||||
if (statementMessageReader != StatementMessageReader.NONE) {
|
||||
result = withStatementMessages(
|
||||
result,
|
||||
stmt,
|
||||
Math.max(options.getMaxRows(), 1),
|
||||
statementMessageReader
|
||||
);
|
||||
}
|
||||
activeStatements.remove(stmt);
|
||||
stmt.close();
|
||||
return new QueryPageResult(
|
||||
result.getColumns(),
|
||||
result.getColumn_types(),
|
||||
result.getRows(),
|
||||
result.getAffected_rows(),
|
||||
result.getExecution_time_ms(),
|
||||
result.getTruncated(),
|
||||
null,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -700,17 +747,28 @@ public final class JdbcExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
private static QueryResult withStatementWarnings(QueryResult result, Statement stmt) {
|
||||
private static QueryResult withStatementMessages(
|
||||
QueryResult result,
|
||||
Statement stmt,
|
||||
int maxRows,
|
||||
StatementMessageReader statementMessageReader
|
||||
) {
|
||||
if (!result.getColumns().isEmpty() || !result.getRows().isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
int effectiveMaxRows = Math.max(maxRows, 1);
|
||||
boolean truncated = result.getTruncated();
|
||||
try {
|
||||
Set<SQLWarning> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (SQLWarning warning = stmt.getWarnings(); warning != null && seen.add(warning); warning = warning.getNextWarning()) {
|
||||
String message = warning.getMessage();
|
||||
if (message != null && !message.trim().isEmpty()) {
|
||||
if (rows.size() >= effectiveMaxRows) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
rows.add(Collections.singletonList(message));
|
||||
}
|
||||
}
|
||||
|
|
@ -720,6 +778,24 @@ public final class JdbcExecutor {
|
|||
// successfully executed statement into a query failure.
|
||||
}
|
||||
|
||||
try {
|
||||
List<String> messages = statementMessageReader.read(stmt);
|
||||
if (messages != null) {
|
||||
for (String message : messages) {
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
if (rows.size() >= effectiveMaxRows) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
rows.add(Collections.singletonList(message));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Driver-specific informational output is advisory, like SQLWarning.
|
||||
}
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
|
@ -729,7 +805,7 @@ public final class JdbcExecutor {
|
|||
rows,
|
||||
result.getAffected_rows(),
|
||||
result.getExecution_time_ms(),
|
||||
result.getTruncated()
|
||||
truncated
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -800,6 +876,14 @@ public final class JdbcExecutor {
|
|||
Object read(ResultSet rs, int index, int sqlType) throws SQLException;
|
||||
}
|
||||
|
||||
/** Reads driver-specific informational output that is not exposed as {@link SQLWarning}. */
|
||||
@FunctionalInterface
|
||||
public interface StatementMessageReader {
|
||||
StatementMessageReader NONE = statement -> Collections.emptyList();
|
||||
|
||||
List<String> read(Statement statement) throws SQLException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional extension of {@link ResultValueReader} that exposes the JDBC
|
||||
* {@code getColumnTypeName} alongside the SQL type code, allowing per-driver
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ public final class JsonRpcServer {
|
|||
Object dispatchForRuntime(String method, JsonObject params) throws Exception {
|
||||
return AgentExecutionContext.withJdbcExecutor(jdbcExecutor, () -> {
|
||||
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
|
||||
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method)
|
||||
&& jdbcAgent != null
|
||||
&& jdbcAgent.hasActivePooledLeases()) {
|
||||
return Collections.singletonMap("ok", true);
|
||||
}
|
||||
boolean manageConnection = jdbcAgent != null && requiresConnectedConnection(method);
|
||||
if (manageConnection) {
|
||||
jdbcAgent.beginPooledRequest();
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import java.util.function.Supplier;
|
|||
|
||||
public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
private static final String LEGACY_SESSION_ID = "__legacy__";
|
||||
private static final int MAX_SESSIONS = 256;
|
||||
static final int MAX_SESSIONS = 256;
|
||||
private static final int MAX_REQUEST_THREADS = 64;
|
||||
private static final int MAX_CLEANUP_THREADS = 16;
|
||||
private static final long MAINTENANCE_INTERVAL_MILLIS = 60_000L;
|
||||
|
|
@ -137,7 +137,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
|||
try {
|
||||
Object result;
|
||||
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
|
||||
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionHandshakeResult() : customHandshake();
|
||||
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake();
|
||||
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
|
||||
result = openSession(requiredSessionId(params), params);
|
||||
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
|
||||
|
|
@ -171,14 +171,19 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
|||
|
||||
private Object openSession(String sessionId, JsonObject params) throws Exception {
|
||||
if (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(sessionId)) {
|
||||
throw new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS);
|
||||
throw AgentRpcError.backpressure(
|
||||
"connect",
|
||||
new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS)
|
||||
);
|
||||
}
|
||||
Session session;
|
||||
if (sessionHandlerFactory != null) {
|
||||
session = new Session(sessionHandlerFactory.get());
|
||||
} else {
|
||||
DatabaseAgent agent = agentFactory.get();
|
||||
if (poolRegistry.isEnabled() && agent instanceof AbstractJdbcAgent jdbcAgent) {
|
||||
if (poolRegistry.isEnabled()
|
||||
&& agent instanceof AbstractJdbcAgent jdbcAgent
|
||||
&& jdbcAgent.supportsConnectionPooling()) {
|
||||
jdbcAgent.attachConnectionPoolRegistry(poolRegistry);
|
||||
ensureMaintenanceStarted();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"protocolVersion": 1,
|
||||
"handshakeMethod": "handshake",
|
||||
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database"],
|
||||
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
|
||||
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
|
||||
"commonMethods": [
|
||||
|
|
@ -39,6 +39,6 @@
|
|||
"disconnect",
|
||||
"shutdown"
|
||||
],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
|
||||
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
"protocolVersion": 2,
|
||||
"handshakeMethod": "handshake",
|
||||
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "multi_session", "structured_error_v1"],
|
||||
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
|
||||
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
|
||||
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
|
||||
"commonMethods": [
|
||||
"handshake",
|
||||
"connect",
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
"disconnect",
|
||||
"shutdown"
|
||||
],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
|
||||
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"],
|
||||
"sessionField": "agentSessionId",
|
||||
"cursorSessionField": "sessionId"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.dbx.agent;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class AgentRpcErrorTest {
|
||||
@Test
|
||||
void serializesVersionedSqlDiagnosticsAndUnknownExecuteOutcome() {
|
||||
SQLException cause = new SQLException("statement failed", "42000", 1064);
|
||||
|
||||
JsonObject data = errorData(cause, AgentProtocol.METHOD_EXECUTE_QUERY, "session-1");
|
||||
|
||||
assertEquals(1, data.get("contractVersion").getAsInt());
|
||||
assertEquals("sql", data.get("category").getAsString());
|
||||
assertEquals("execute", data.get("stage").getAsString());
|
||||
assertEquals("unknown", data.get("operationOutcome").getAsString());
|
||||
assertEquals("keep", data.get("sessionDisposition").getAsString());
|
||||
assertEquals("42000", data.get("sqlState").getAsString());
|
||||
assertEquals(1064, data.get("vendorCode").getAsInt());
|
||||
assertEquals(SQLException.class.getName(), data.get("exceptionClass").getAsString());
|
||||
assertEquals("session-1", data.get("agentSessionId").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesTimeoutAndCanceledErrorsWithoutClaimingSafeReplay() {
|
||||
JsonObject timeout = errorData(
|
||||
new SQLTimeoutException("timed out", "HYT00", 0),
|
||||
AgentProtocol.METHOD_EXECUTE_QUERY,
|
||||
"session-1"
|
||||
);
|
||||
JsonObject canceled = errorData(
|
||||
new CancellationException("canceled"),
|
||||
AgentProtocol.METHOD_CANCEL_SESSION,
|
||||
"session-1"
|
||||
);
|
||||
|
||||
assertEquals("timeout", timeout.get("category").getAsString());
|
||||
assertEquals("unknown", timeout.get("operationOutcome").getAsString());
|
||||
assertFalse(timeout.get("retryable").getAsBoolean());
|
||||
assertEquals("quarantine", timeout.get("sessionDisposition").getAsString());
|
||||
assertEquals("canceled", canceled.get("category").getAsString());
|
||||
assertEquals("cancel", canceled.get("stage").getAsString());
|
||||
assertEquals("unknown", canceled.get("operationOutcome").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksConnectionSetupFailureAsNotStartedAndBoundsSqlState() {
|
||||
SQLException cause = new SQLException("connect failed", "12345678901234567890", -7);
|
||||
|
||||
JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null);
|
||||
|
||||
assertEquals("connection", data.get("category").getAsString());
|
||||
assertEquals("connect", data.get("stage").getAsString());
|
||||
assertEquals("not_started", data.get("operationOutcome").getAsString());
|
||||
assertEquals(16, data.get("sqlState").getAsString().length());
|
||||
assertFalse(data.has("agentSessionId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesNonGraphicCharactersFromStrictDiagnostics() {
|
||||
SQLException cause = new SQLException("connect failed", "08\n006\u00e9", -7);
|
||||
|
||||
JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null);
|
||||
|
||||
assertEquals("08006", data.get("sqlState").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsConnectionAndCloseMethodsToTheSameStagesAsTheRustDecoder() {
|
||||
JsonObject testConnection = errorData(
|
||||
new SQLException("connect failed", "08001", 0),
|
||||
AgentProtocol.METHOD_TEST_CONNECTION,
|
||||
null
|
||||
);
|
||||
JsonObject closeQuery = errorData(
|
||||
new SQLException("close failed", "42000", 0),
|
||||
AgentProtocol.METHOD_CLOSE_QUERY_SESSION,
|
||||
"session-1"
|
||||
);
|
||||
|
||||
assertEquals("connect", testConnection.get("stage").getAsString());
|
||||
assertEquals("not_started", testConnection.get("operationOutcome").getAsString());
|
||||
assertEquals("close", closeQuery.get("stage").getAsString());
|
||||
assertEquals("unknown", closeQuery.get("operationOutcome").getAsString());
|
||||
}
|
||||
|
||||
private static JsonObject errorData(Throwable error, String method, String agentSessionId) {
|
||||
return AgentRpcError.toJson(error, method, agentSessionId).getAsJsonObject("data");
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import java.lang.reflect.InvocationHandler;
|
|||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
|
@ -37,6 +38,31 @@ class BatchExecutorTest {
|
|||
assertEquals(2L, result.getAffected_rows());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeBatchStatementsFallsBackWhenStatementBatchIsUnsupported() {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
AtomicInteger addBatchCalls = new AtomicInteger();
|
||||
AtomicInteger executeLargeBatchCalls = new AtomicInteger();
|
||||
|
||||
Statement statement = unsupportedBatchStatementProxy(executedSql, addBatchCalls, executeLargeBatchCalls);
|
||||
Connection connection = connectionProxy(statement);
|
||||
|
||||
QueryResult result = BatchExecutor.executeBatchStatements(
|
||||
connection,
|
||||
Arrays.asList(" UPDATE items SET name = 'Ada' WHERE id = 1; ", " DELETE FROM items WHERE id = 2; "),
|
||||
null,
|
||||
schema -> null
|
||||
);
|
||||
|
||||
assertEquals(1, addBatchCalls.get());
|
||||
assertEquals(0, executeLargeBatchCalls.get());
|
||||
assertEquals(
|
||||
Arrays.asList("UPDATE items SET name = 'Ada' WHERE id = 1", "DELETE FROM items WHERE id = 2"),
|
||||
executedSql
|
||||
);
|
||||
assertEquals(0L, result.getAffected_rows());
|
||||
}
|
||||
|
||||
private static Statement statementProxy(
|
||||
List<String> batchedSql,
|
||||
AtomicInteger executeLargeBatchCalls,
|
||||
|
|
@ -60,6 +86,31 @@ class BatchExecutorTest {
|
|||
return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, handler);
|
||||
}
|
||||
|
||||
private static Statement unsupportedBatchStatementProxy(
|
||||
List<String> executedSql,
|
||||
AtomicInteger addBatchCalls,
|
||||
AtomicInteger executeLargeBatchCalls
|
||||
) {
|
||||
InvocationHandler handler = (Object unused, Method method, Object[] args) -> {
|
||||
switch (method.getName()) {
|
||||
case "addBatch":
|
||||
addBatchCalls.incrementAndGet();
|
||||
throw new SQLFeatureNotSupportedException("Batches not supported");
|
||||
case "executeLargeBatch":
|
||||
executeLargeBatchCalls.incrementAndGet();
|
||||
throw new AssertionError("Unsupported JDBC batches must not be executed");
|
||||
case "execute":
|
||||
executedSql.add((String) args[0]);
|
||||
return false;
|
||||
case "getLargeUpdateCount":
|
||||
return -1L;
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
};
|
||||
return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, handler);
|
||||
}
|
||||
|
||||
private static Connection connectionProxy(Statement statement) {
|
||||
InvocationHandler handler = (Object unused, Method method, Object[] args) -> {
|
||||
if ("createStatement".equals(method.getName())) {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ class CommonJavaCompatibilityTest {
|
|||
strings(contract.getAsJsonArray("handshakeResponseFields"))
|
||||
);
|
||||
assertEquals(
|
||||
AgentProtocol.MULTI_SESSION_ALL_CAPABILITIES,
|
||||
AgentProtocol.MULTI_SESSION_JDBC_ALL_CAPABILITIES,
|
||||
strings(contract.getAsJsonArray("allCapabilities"))
|
||||
);
|
||||
assertEquals(
|
||||
|
|
@ -80,7 +80,7 @@ class CommonJavaCompatibilityTest {
|
|||
strings(contract.getAsJsonArray("capabilities"))
|
||||
);
|
||||
assertEquals(
|
||||
AgentProtocol.MULTI_SESSION_CAPABILITIES,
|
||||
AgentProtocol.MULTI_SESSION_JDBC_CAPABILITIES,
|
||||
strings(contract.getAsJsonArray("defaultSqlCapabilities"))
|
||||
);
|
||||
assertEquals(AgentProtocol.MULTI_SESSION_METHODS, strings(contract.getAsJsonArray("commonMethods")));
|
||||
|
|
@ -164,6 +164,7 @@ class CommonJavaCompatibilityTest {
|
|||
)).getAsJsonObject().getAsJsonObject("result");
|
||||
assertEquals(2, handshake.get("protocolVersion").getAsInt());
|
||||
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session"));
|
||||
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1"));
|
||||
|
||||
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"a\"}}");
|
||||
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"b\"}}");
|
||||
|
|
@ -177,6 +178,68 @@ class CommonJavaCompatibilityTest {
|
|||
assertEquals(0, created.get(1).disconnectCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customSessionHandlersDoNotImplicitlyAdvertiseStructuredErrors() {
|
||||
MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() {
|
||||
@Override
|
||||
public Object connect(JsonObject params) {
|
||||
return Collections.singletonMap("ok", true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(String method, JsonObject params) {
|
||||
return Collections.singletonMap("ok", true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
});
|
||||
|
||||
JsonObject handshake = JsonParser.parseString(server.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}"
|
||||
)).getAsJsonObject().getAsJsonObject("result");
|
||||
|
||||
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session"));
|
||||
assertFalse(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSessionLimitReturnsStructuredBackpressureBeforeConnectStarts() {
|
||||
try (MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() {
|
||||
@Override
|
||||
public Object connect(JsonObject params) {
|
||||
return Collections.singletonMap("ok", true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(String method, JsonObject params) {
|
||||
return Collections.singletonMap("ok", true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
})) {
|
||||
for (int index = 0; index < MultiSessionJsonRpcServer.MAX_SESSIONS; index++) {
|
||||
JsonObject response = JsonParser.parseString(server.handleRequest(openSessionRequest(index, "session-" + index)))
|
||||
.getAsJsonObject();
|
||||
assertTrue(response.has("result"), response::toString);
|
||||
}
|
||||
|
||||
JsonObject response = JsonParser.parseString(server.handleRequest(
|
||||
openSessionRequest(MultiSessionJsonRpcServer.MAX_SESSIONS, "overflow")
|
||||
)).getAsJsonObject();
|
||||
JsonObject data = response.getAsJsonObject("error").getAsJsonObject("data");
|
||||
|
||||
assertEquals("resource", data.get("category").getAsString());
|
||||
assertTrue(data.get("retryable").getAsBoolean());
|
||||
assertEquals("keep", data.get("sessionDisposition").getAsString());
|
||||
assertEquals("connect", data.get("stage").getAsString());
|
||||
assertEquals("not_started", data.get("operationOutcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSessionServerKeepsProtocolOutputWhenGlobalStdoutChanges() {
|
||||
synchronized (System.class) {
|
||||
|
|
@ -1113,6 +1176,17 @@ class CommonJavaCompatibilityTest {
|
|||
return false;
|
||||
}
|
||||
|
||||
private static String openSessionRequest(int requestId, String sessionId) {
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("agentSessionId", sessionId);
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("jsonrpc", "2.0");
|
||||
request.addProperty("id", requestId);
|
||||
request.addProperty("method", AgentProtocol.METHOD_OPEN_SESSION);
|
||||
request.add("params", params);
|
||||
return request.toString();
|
||||
}
|
||||
|
||||
private static void awaitCondition(java.util.function.BooleanSupplier condition) throws InterruptedException {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
|
||||
while (!condition.getAsBoolean() && System.nanoTime() < deadline) {
|
||||
|
|
|
|||
|
|
@ -850,6 +850,27 @@ class JdbcConnectionPoolingTest {
|
|||
}
|
||||
}
|
||||
|
||||
@RepeatedTest(5)
|
||||
void unsupportedNetworkTimeoutDoesNotPoisonIdentity() throws Exception {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
String url = h2Url("unsupported_network_timeout");
|
||||
try (Connection ignored = openH2(url, physicalOpens)) {
|
||||
// Keep H2 bootstrap outside the setup classification watchdog.
|
||||
}
|
||||
physicalOpens.set(0);
|
||||
try (JdbcConnectionPoolRegistry registry = new JdbcConnectionPoolRegistry(shortTimeoutPoolSettings(1, 32))) {
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
try (JdbcConnectionPoolRegistry.Lease lease = registry.borrow(
|
||||
"unsupported-network-timeout",
|
||||
() -> unsupportedNetworkTimeoutConnection(openH2(url, physicalOpens))
|
||||
)) {
|
||||
assertTrue(lease.connection().isValid(1));
|
||||
}
|
||||
}
|
||||
assertEquals(1, physicalOpens.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedSetupAfterKnownFailurePoisonsCurrentAttemptGeneration() throws Exception {
|
||||
AtomicInteger connectionAttempts = new AtomicInteger();
|
||||
|
|
@ -1120,6 +1141,55 @@ class JdbcConnectionPoolingTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationSkipsBusySharedPoolWithoutWaiting() throws Exception {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
AtomicInteger requestIds = new AtomicInteger();
|
||||
String url = h2Url("busy_validation");
|
||||
try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 1)) {
|
||||
openSession(server, requestIds, "cursor-owner");
|
||||
openSession(server, requestIds, "validation-session");
|
||||
|
||||
JsonObject pageParams = sessionParams("cursor-owner");
|
||||
pageParams.addProperty("sql", "SELECT X FROM SYSTEM_RANGE(1, 3)");
|
||||
pageParams.addProperty("pageSize", 1);
|
||||
JsonObject firstPage = result(request(
|
||||
server,
|
||||
requestIds,
|
||||
AgentProtocol.METHOD_EXECUTE_QUERY_PAGE,
|
||||
pageParams
|
||||
));
|
||||
assertTrue(firstPage.get("has_more").getAsBoolean());
|
||||
String querySessionId = firstPage.get("session_id").getAsString();
|
||||
|
||||
long startedAtNanos = System.nanoTime();
|
||||
JsonObject validation = result(request(
|
||||
server,
|
||||
requestIds,
|
||||
AgentProtocol.METHOD_VALIDATE_SESSION,
|
||||
sessionParams("validation-session")
|
||||
));
|
||||
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos);
|
||||
assertTrue(elapsedMillis < 200L, () -> "busy validation took " + elapsedMillis + "ms");
|
||||
assertTrue(validation.get("ok").getAsBoolean());
|
||||
assertEquals(1, physicalOpens.get());
|
||||
|
||||
JsonObject closeParams = sessionParams("cursor-owner");
|
||||
closeParams.addProperty("sessionId", querySessionId);
|
||||
assertTrue(request(
|
||||
server,
|
||||
requestIds,
|
||||
AgentProtocol.METHOD_CLOSE_QUERY_SESSION,
|
||||
closeParams
|
||||
).get("result").getAsBoolean());
|
||||
assertEquals(
|
||||
2,
|
||||
query(server, requestIds, "validation-session", "SELECT 2", null)
|
||||
.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void maintenanceExpiresAbandonedCursorAndReturnsItsConnection() throws Exception {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
|
|
@ -2136,6 +2206,23 @@ class JdbcConnectionPoolingTest {
|
|||
);
|
||||
}
|
||||
|
||||
private static Connection unsupportedNetworkTimeoutConnection(Connection delegate) {
|
||||
return (Connection) Proxy.newProxyInstance(
|
||||
Connection.class.getClassLoader(),
|
||||
new Class<?>[] {Connection.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("setNetworkTimeout".equals(method.getName())) {
|
||||
throw new SQLException("Does not support setNetworkTimeout");
|
||||
}
|
||||
try {
|
||||
return method.invoke(delegate, args);
|
||||
} catch (InvocationTargetException error) {
|
||||
throw error.getCause();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection asynchronousAbortConnection(
|
||||
Connection delegate,
|
||||
CountDownLatch abortScheduled,
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||
import javax.sql.rowset.serial.SerialBlob;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcExecutorTest {
|
||||
@Test
|
||||
|
|
@ -145,6 +147,87 @@ class JdbcExecutorTest {
|
|||
assertEquals(3L, result.getAffected_rows());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeReturnsDriverMessagesForNoResultStatementsAndHonorsMaxRows() {
|
||||
QueryResult result = JdbcExecutor.INSTANCE.execute(
|
||||
executionConnection(false, -1, null, new AtomicInteger(), null, null),
|
||||
"CALL LOG_ONLY_PROCEDURE()",
|
||||
"",
|
||||
schema -> "",
|
||||
() -> "",
|
||||
2,
|
||||
null,
|
||||
0,
|
||||
JdbcExecutor.INSTANCE::defaultResultValue,
|
||||
statement -> Arrays.asList("first", "second", "third")
|
||||
);
|
||||
|
||||
assertEquals(Arrays.asList("Message"), result.getColumns());
|
||||
assertEquals(Arrays.asList(Arrays.asList("first"), Arrays.asList("second")), result.getRows());
|
||||
assertTrue(result.getTruncated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeLimitsCombinedWarningsAndDriverMessages() {
|
||||
SQLWarning first = new SQLWarning("first warning");
|
||||
first.setNextWarning(new SQLWarning("second warning"));
|
||||
|
||||
QueryResult result = JdbcExecutor.INSTANCE.execute(
|
||||
executionConnection(false, -1, first, new AtomicInteger(), null, null),
|
||||
"CALL LOG_ONLY_PROCEDURE()",
|
||||
"",
|
||||
schema -> "",
|
||||
() -> "",
|
||||
1,
|
||||
null,
|
||||
0,
|
||||
JdbcExecutor.INSTANCE::defaultResultValue,
|
||||
statement -> Arrays.asList("driver message")
|
||||
);
|
||||
|
||||
assertEquals(Arrays.asList("Message"), result.getColumns());
|
||||
assertEquals(Arrays.asList(Arrays.asList("first warning")), result.getRows());
|
||||
assertTrue(result.getTruncated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executePageReturnsDriverMessagesForNoResultStatements() {
|
||||
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
|
||||
executionConnection(false, -1, null, new AtomicInteger(), null, null),
|
||||
"CALL LOG_ONLY_PROCEDURE()",
|
||||
"",
|
||||
schema -> "",
|
||||
new QueryPageOptions(100, null, 100),
|
||||
JdbcExecutor.INSTANCE::defaultResultValue,
|
||||
statement -> Arrays.asList("first", "second")
|
||||
);
|
||||
|
||||
assertEquals(Arrays.asList("Message"), result.getColumns());
|
||||
assertEquals(Arrays.asList(Arrays.asList("first"), Arrays.asList("second")), result.getRows());
|
||||
assertFalse(result.getHas_more());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executePageKeepsWarningsHiddenWithoutADriverMessageReader() {
|
||||
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
|
||||
executionConnection(
|
||||
false,
|
||||
-1,
|
||||
new SQLWarning("existing paged warning"),
|
||||
new AtomicInteger(),
|
||||
null,
|
||||
null
|
||||
),
|
||||
"CALL EXISTING_PROCEDURE()",
|
||||
"",
|
||||
schema -> "",
|
||||
new QueryPageOptions()
|
||||
);
|
||||
|
||||
assertEquals(Collections.emptyList(), result.getColumns());
|
||||
assertEquals(Collections.emptyList(), result.getRows());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeDoesNotReplaceOrdinaryResultSetsWithWarnings() {
|
||||
CountingResultSetFixture fixture = countingResultSet(new Object[][]{{1, "Ada"}});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ class MetadataConstraintCoverageTest {
|
|||
result.add(driversRoot.relativize(file).getName(0).toString());
|
||||
} else if ("main.go".equals(fileName) && hasGoMetadataDispatcher(file)) {
|
||||
result.add(driversRoot.relativize(file).getName(0).toString());
|
||||
} else if (fileName.endsWith(".rs") && hasRustMetadataDispatcher(file)) {
|
||||
result.add(driversRoot.relativize(file).getName(0).toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -85,6 +87,15 @@ class MetadataConstraintCoverageTest {
|
|||
}
|
||||
}
|
||||
|
||||
private static boolean hasRustMetadataDispatcher(Path file) {
|
||||
try {
|
||||
String source = readUtf8(file);
|
||||
return source.contains("\"list_tables\"") || source.contains("\"list_objects\"");
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> readCoverageMatrix(Path matrix) throws IOException {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String line : Files.readAllLines(matrix, StandardCharsets.UTF_8)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Agent Protocol v2: Multi-session runtimes
|
||||
|
||||
Protocol v2 allows one Agent process to serve multiple isolated database sessions. The handshake advertises `protocolVersion: 2` and the `multi_session` capability. DBX falls back to the v1 one-process-per-pool lifecycle when that capability is absent.
|
||||
Protocol v2 allows one Agent process to serve multiple isolated database sessions. Pooled JDBC Agents that use the common structured error producer advertise `protocolVersion: 2`, `multi_session`, and `structured_error_v1`. Generic/custom v2 handlers may advertise only `multi_session`. DBX falls back to the v1 one-process-per-pool lifecycle when `multi_session` is absent.
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
|
|
@ -37,14 +37,21 @@ JSON-RPC failures may include structured recovery data:
|
|||
|
||||
```json
|
||||
{
|
||||
"contractVersion": 1,
|
||||
"category": "timeout|canceled|connection|protocol|resource|sql",
|
||||
"retryable": false,
|
||||
"sessionDisposition": "keep|quarantine|replace_runtime",
|
||||
"agentSessionId": "optional-session-id",
|
||||
"stage": "checkout|connect|validate|execute|fetch|cancel|close"
|
||||
"stage": "request|checkout|connect|validate|execute|fetch|cancel|close",
|
||||
"operationOutcome": "not_started|unknown",
|
||||
"sqlState": "optional-jdbc-sql-state",
|
||||
"vendorCode": 0,
|
||||
"exceptionClass": "optional-java-exception-class"
|
||||
}
|
||||
```
|
||||
|
||||
`contractVersion: 1` is guaranteed only when the handshake advertises `structured_error_v1`. Unknown extra fields are allowed, but unknown enum values, missing required fields, invalid types, or an `agentSessionId` that does not match the current request are contract violations. `operationOutcome` describes whether the user operation may have reached the database; `retryable` is an internal hint and never authorizes automatic SQL replay.
|
||||
|
||||
`keep` preserves the logical session, `quarantine` removes only that session from routing, and `replace_runtime` requires DBX to atomically remove every pool sharing the runtime before terminating it. Agent code reports the disposition but must not independently terminate a shared runtime because it does not own DBX routing state. Temporary workload checkout backpressure uses `category=resource`, `retryable=true`, and `sessionDisposition=keep`; only unrecoverable runtime or cleanup saturation requests `replace_runtime`.
|
||||
|
||||
The complete JDBC pool checkout runs under a bounded runtime executor, including HikariCP idle-connection validation, physical connection creation, and driver setup. Workload admission, the runtime-wide physical connection budget, physical creation, and checkout consume one absolute deadline rather than restarting the timeout at each stage. Connection return, eviction, and physical close use separate bounded executors so they cannot deadlock checkout or creation. If a driver call outlives its boundary, or cleanup cannot confirm the physical connection state, the connection identity is poisoned and returns `category=resource` with `sessionDisposition=replace_runtime` on the current or next checkout. A late connection must be evicted and closed instead of published, and DBX must not replay the timed-out user operation automatically.
|
||||
|
|
|
|||
|
|
@ -166,9 +166,9 @@ git push origin "$RELEASE_TAG"
|
|||
|
||||
The release workflow will:
|
||||
|
||||
- Bump changed module versions in `versions.json`.
|
||||
- Build all agent shadow jars.
|
||||
- Build/download JRE `.tar.zst` artifacts.
|
||||
- Resolve the effective previous module versions from the post-release version-sync commit after the previous `agents-v*` tag.
|
||||
- Bump and build only changed Java or native agent modules.
|
||||
- Download unchanged single-driver packages and JRE archives from the previous immutable release, then verify filenames, versions, platform coverage, sizes, and SHA-256 digests before reuse.
|
||||
- Generate `agent-registry.json`.
|
||||
- Create full offline platform ZIPs from raw staging files.
|
||||
- Create one `.tar.zst` package per Java or native driver.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
# Cassandra native Agent
|
||||
|
||||
The Cassandra Agent uses Apache `cassandra-gocql-driver` and implements the DBX
|
||||
multi-session JSON-RPC protocol without a JVM.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Native protocol versions: v3-v5
|
||||
- Declared server range: Apache Cassandra 2.1+
|
||||
- Live validation: 2.2.19, 3.11.19, 4.1.10, and 5.0.6
|
||||
- Kerberos live validation: Cassandra 4.1.10 with password, keytab, FILE ccache,
|
||||
JAAS discovery, and HOCON `configfile`
|
||||
- Astra validation: secure-connect bundle parsing and transport configuration;
|
||||
live Astra credentials were not available
|
||||
- Authentication: username/password and Kerberos/GSSAPI
|
||||
- TLS: CA verification, optional client certificate/key, hostname verification
|
||||
- Cloud: DataStax Astra secure connect bundles
|
||||
- Configuration: Java Driver 4 HOCON `configfile` mapping plus native extensions
|
||||
- Metadata: keyspaces, tables, columns, indexes, CQL table DDL, completion search
|
||||
- Queries: legacy string result values, paging, cancellation, logged and unlogged batches
|
||||
|
||||
The Agent accepts both normal DBX connection fields and Cassandra JDBC-style
|
||||
connection strings, including the wrapper's `host1--host2:9042` contact-point
|
||||
syntax.
|
||||
|
||||
## JDBC URL parameter mapping
|
||||
|
||||
| JDBC parameter | Native behavior |
|
||||
| --- | --- |
|
||||
| `consistency` | GoCQL consistency |
|
||||
| `fetchsize` | default page size |
|
||||
| `retries` | retry/reconnection attempt count |
|
||||
| `loadbalancing` | default, round-robin, DC-aware, or token-aware built-in policy |
|
||||
| `localdatacenter` | DC-aware host selection |
|
||||
| `retry` | default/simple, fallthrough, downgrading, or exponential built-in policy |
|
||||
| `reconnection` | constant or exponential reconnection policy |
|
||||
| `debug` | GoCQL debug logging to stderr |
|
||||
| `enablessl` | TLS enablement |
|
||||
| `sslenginefactory` | the standard `DefaultSslEngineFactory` maps to native TLS |
|
||||
| `hostnameverification` | TLS hostname verification; enabled by default |
|
||||
| `user`, `password` | password authentication |
|
||||
| `configfile` | Java Driver 4 HOCON configuration; overrides URL options except contact points and keyspace |
|
||||
| `usekrb5` | Kerberos/GSSAPI authentication using password, keytab, or FILE credential cache |
|
||||
| `secureconnectbundle` | DataStax Astra secure connect bundle; contact points and manual TLS options are ignored |
|
||||
| `requesttimeout`, `connecttimeout` | request and connection deadlines |
|
||||
| `tcpnodelay`, `keepalive` | native TCP socket options |
|
||||
| `compliancemode` | accepted; JDBC-only `java.sql` behavior is not applicable to JSON-RPC |
|
||||
|
||||
The Agent rejects custom Java implementation classes because they cannot be
|
||||
loaded by a native binary. This includes custom authentication, SSL, retry,
|
||||
reconnection, and load-balancing classes. Java JKS/PKCS12 truststores and
|
||||
keystores are not read directly; use the native PEM paths described below.
|
||||
|
||||
## Java Driver HOCON configuration
|
||||
|
||||
`configfile` reads Java Driver 4 HOCON files and preserves the JDBC wrapper's
|
||||
precedence: the file overrides URL options except contact points and keyspace.
|
||||
A missing file is ignored for compatibility with the JDBC wrapper.
|
||||
|
||||
Mapped Java Driver paths include:
|
||||
|
||||
- `basic.request.timeout`, `consistency`, `serial-consistency`, and `page-size`
|
||||
- `basic.load-balancing-policy.class` and `local-datacenter`
|
||||
- `basic.cloud.secure-connect-bundle`
|
||||
- `advanced.connection.connect-timeout` and `pool.local.size`
|
||||
- `advanced.socket.tcp-no-delay` and `keep-alive`
|
||||
- `advanced.protocol.version`, retry policy, and reconnection policy
|
||||
- `advanced.auth-provider` plaintext and Instaclustr Kerberos options
|
||||
- `advanced.ssl-engine-factory` default TLS and hostname validation
|
||||
|
||||
Native-only settings can be placed under `dbx.cassandra`:
|
||||
|
||||
```hocon
|
||||
dbx.cassandra {
|
||||
tls {
|
||||
enabled = true
|
||||
ca-cert-path = "/path/to/ca.pem"
|
||||
client-cert-path = "/path/to/client.pem"
|
||||
client-key-path = "/path/to/client-key.pem"
|
||||
hostname-verification = true
|
||||
}
|
||||
kerberos {
|
||||
enabled = true
|
||||
config = "/etc/krb5.conf"
|
||||
jaas-config = "/path/to/jaas.conf"
|
||||
principal = "alice@EXAMPLE.COM"
|
||||
keytab = "/path/to/alice.keytab"
|
||||
service-name = "cassandra"
|
||||
server-name = "node1.example.com"
|
||||
authorization-id = "assumed_role"
|
||||
qop = "auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kerberos
|
||||
|
||||
`usekrb5=true` implements the same GSSAPI flow used by the former Instaclustr
|
||||
Java auth provider. The service principal defaults to
|
||||
`cassandra/<canonical-node-hostname>`. Set `kerberosservername` when reverse DNS
|
||||
does not resolve to the service-principal hostname.
|
||||
|
||||
Credential discovery order is:
|
||||
|
||||
1. Explicit JAAS `CassandraJavaClient` cache/keytab selection
|
||||
2. Explicit `kerberosccache` or `kerberoskeytab`
|
||||
3. Explicit principal and password
|
||||
4. `KRB5CCNAME`, then `KRB5_CLIENT_KTNAME`/`KRB5_KTNAME`
|
||||
|
||||
The Agent also reads `java.security.auth.login.config` and
|
||||
`java.security.krb5.conf` from `JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, or
|
||||
`JDK_JAVA_OPTIONS`. Only FILE credential caches are supported. SASL QOP `auth`
|
||||
is supported; `auth-int` and `auth-conf` are rejected because they require
|
||||
wrapping Cassandra traffic after authentication.
|
||||
|
||||
## Astra secure connect bundles
|
||||
|
||||
Set `secureconnectbundle` to a local Astra bundle ZIP and provide its database
|
||||
credentials with `user` and `password`. A normal Cassandra host is not required.
|
||||
Kerberos cannot be combined with a secure connect bundle. Manual TLS settings
|
||||
are ignored because the bundle supplies its own CA, client certificate, key,
|
||||
SNI endpoint, and metadata service.
|
||||
|
||||
## Integration test
|
||||
|
||||
```bash
|
||||
CASSANDRA_TEST_HOST=127.0.0.1 \
|
||||
CASSANDRA_TEST_PORT=9042 \
|
||||
CASSANDRA_TEST_USERNAME=cassandra \
|
||||
CASSANDRA_TEST_PASSWORD=cassandra \
|
||||
go test -run TestCassandraIntegration -v
|
||||
```
|
||||
|
||||
Optional variables include `CASSANDRA_TEST_URL_PARAMS`, `CASSANDRA_TEST_SSL`,
|
||||
`CASSANDRA_TEST_CA_CERT_PATH`, `CASSANDRA_TEST_CLIENT_CERT_PATH`, and
|
||||
`CASSANDRA_TEST_CLIENT_KEY_PATH`.
|
||||
|
||||
See `bench/README.md` for the archived JDBC comparison workflow and measured
|
||||
Cassandra 4.1.10 results.
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
|
||||
bundlePath := writeTestSecureConnectBundle(t)
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Username: "token",
|
||||
Password: "astra-token",
|
||||
URLParams: url.Values{
|
||||
"secureconnectbundle": []string{bundlePath},
|
||||
"requesttimeout": []string{"9s"},
|
||||
"connecttimeout": []string{"7s"},
|
||||
}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.hosts) != 0 || config.secureConnectBundle != bundlePath {
|
||||
t.Fatalf("unexpected Astra config: %#v", config)
|
||||
}
|
||||
|
||||
cluster, err := config.clusterConfig("app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cluster.HostDialer == nil {
|
||||
t.Fatal("Astra cluster must use the secure-connect HostDialer")
|
||||
}
|
||||
if reflect.DeepEqual(cluster.Hosts, config.hosts) || len(cluster.Hosts) != 3 {
|
||||
t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts)
|
||||
}
|
||||
credentials, ok := cluster.Authenticator.(*gocql.PasswordAuthenticator)
|
||||
if !ok || credentials.Username != "token" || credentials.Password != "astra-token" {
|
||||
t.Fatalf("unexpected Astra authenticator: %#v", cluster.Authenticator)
|
||||
}
|
||||
if cluster.Keyspace != "app" || cluster.Timeout != 9*time.Second || cluster.ConnectTimeout != 7*time.Second {
|
||||
t.Fatalf("unexpected Astra cluster options: %#v", cluster)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureConnectBundleSupportsHOCONConfiguration(t *testing.T) {
|
||||
bundlePath := writeTestSecureConnectBundle(t)
|
||||
configPath := writeTestFile(t, "astra.conf", `
|
||||
datastax-java-driver {
|
||||
basic.cloud.secure-connect-bundle = "`+bundlePath+`"
|
||||
advanced.auth-provider {
|
||||
class = PlainTextAuthProvider
|
||||
username = token
|
||||
password = astra-token
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
URLParams: url.Values{"configfile": []string{configPath}}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.secureConnectBundle != bundlePath || config.username != "token" || config.password != "astra-token" {
|
||||
t.Fatalf("unexpected HOCON Astra config: %#v", config)
|
||||
}
|
||||
if _, err := config.clusterConfig(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureConnectBundleValidatesCredentialsAndAuthMode(t *testing.T) {
|
||||
bundlePath := writeTestSecureConnectBundle(t)
|
||||
|
||||
if _, err := parseCassandraConfig(connectParams{
|
||||
URLParams: url.Values{"secureconnectbundle": []string{bundlePath}}.Encode(),
|
||||
}); err == nil {
|
||||
t.Fatal("expected Astra credential validation error")
|
||||
}
|
||||
|
||||
if _, err := parseCassandraConfig(connectParams{
|
||||
Username: "token",
|
||||
Password: "astra-token",
|
||||
URLParams: url.Values{
|
||||
"secureconnectbundle": []string{bundlePath},
|
||||
"usekrb5": []string{"true"},
|
||||
}.Encode(),
|
||||
}); err == nil {
|
||||
t.Fatal("expected Astra and Kerberos conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSecureConnectBundle(t *testing.T) string {
|
||||
t.Helper()
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "dbx-astra-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKeyDER, err := x509.MarshalECPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER})
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyDER})
|
||||
configJSON, err := json.Marshal(map[string]any{"host": "astra.example.com", "port": 29042})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(t.TempDir(), "secure-connect.zip")
|
||||
bundle, err := os.Create(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(bundle)
|
||||
for name, contents := range map[string][]byte{
|
||||
"config.json": configJSON,
|
||||
"ca.crt": certificatePEM,
|
||||
"cert": certificatePEM,
|
||||
"key": privateKeyPEM,
|
||||
} {
|
||||
entry, createErr := archive.Create(name)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
if _, writeErr := entry.Write(contents); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bundle.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bundlePath
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Cassandra Agent benchmark
|
||||
|
||||
This benchmark compares the same DBX JSON-RPC operations through the native
|
||||
Apache `cassandra-gocql-driver` Agent and the archived Cassandra JDBC Agent.
|
||||
It measures process startup, connection creation, RSS, latency, throughput,
|
||||
artifact size, and shutdown behavior.
|
||||
|
||||
Each connection sample uses a fresh Agent process so JDBC runtime pooling cannot
|
||||
turn later samples into warm reconnects. Query workloads use one persistent,
|
||||
already-connected process per candidate.
|
||||
|
||||
## Prepare the fixture
|
||||
|
||||
The default workload expects `dbx_native_test.all_types` with at least 100 rows
|
||||
and an integer primary key named `id`. Override the SQL variables below when
|
||||
using another schema.
|
||||
|
||||
## Build the native Agent
|
||||
|
||||
From `agents/`:
|
||||
|
||||
```bash
|
||||
go build -o /tmp/dbx-cassandra-bench/cassandra-go ./drivers/cassandra-go
|
||||
```
|
||||
|
||||
Keep an archived JDBC Agent JAR as the baseline. The production Cassandra
|
||||
module publishes only the native executable.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
GO_AGENT=/tmp/dbx-cassandra-bench/cassandra-go \
|
||||
JDBC_AGENT_JAR=/tmp/dbx-cassandra-bench/dbx-agent-cassandra.jar \
|
||||
CASSANDRA_HOST=127.0.0.1 \
|
||||
CASSANDRA_PORT=9042 \
|
||||
CASSANDRA_KEYSPACE=dbx_native_test \
|
||||
python3 drivers/cassandra-go/bench/agent_compare.py \
|
||||
> /tmp/dbx-cassandra-bench/result.json
|
||||
```
|
||||
|
||||
If Java is only available in a container, provide the full interactive command:
|
||||
|
||||
```bash
|
||||
JDBC_AGENT_COMMAND='docker run --rm -i --name dbx-cassandra-jdbc-bench -v /tmp/dbx-cassandra-bench:/bench:ro eclipse-temurin:21-jre java -jar /bench/dbx-agent-cassandra.jar'
|
||||
JDBC_RSS_COMMAND="docker inspect --format '{{.State.Pid}}' dbx-cassandra-jdbc-bench | xargs -I{} awk '/VmRSS/ {print \$2}' /proc/{}/status"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `BENCH_CANDIDATES`: `go,jdbc` by default
|
||||
- `BENCH_STARTUPS`: startup samples, default `10`
|
||||
- `BENCH_CONNECTS`: connection samples, default `10`
|
||||
- `BENCH_WARMUPS`: warmups before each workload, default `20`
|
||||
- `CASSANDRA_USERNAME`, `CASSANDRA_PASSWORD`, `CASSANDRA_URL_PARAMS`
|
||||
- `CASSANDRA_SSL`, `CASSANDRA_CA_CERT_PATH`, `CASSANDRA_CLIENT_CERT_PATH`, `CASSANDRA_CLIENT_KEY_PATH`
|
||||
- `BENCH_SELECT_ONE_SQL`, `BENCH_DECODE_SQL`, `BENCH_PAGE_SQL`
|
||||
- `BENCH_SELECT_ONE_COUNT`, `BENCH_DECODE_COUNT`, `BENCH_LIST_TABLES_COUNT`, `BENCH_PAGE_COUNT`
|
||||
|
||||
Run both candidates on the same host against the same Cassandra instance. Do
|
||||
not compare a local native Agent with a remote JDBC Agent or change the query
|
||||
shape between candidates.
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
name: str
|
||||
command: list[str]
|
||||
artifact: Path
|
||||
rss_command: str = ""
|
||||
|
||||
|
||||
class AgentProcess:
|
||||
def __init__(self, candidate: Candidate):
|
||||
self.candidate = candidate
|
||||
self.process = subprocess.Popen(
|
||||
candidate.command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
self.request_id = 0
|
||||
self.stderr_lines: list[str] = []
|
||||
threading.Thread(target=self._drain_stderr, daemon=True).start()
|
||||
self._wait_ready()
|
||||
|
||||
def _drain_stderr(self) -> None:
|
||||
assert self.process.stderr is not None
|
||||
for line in self.process.stderr:
|
||||
self.stderr_lines.append(line.rstrip())
|
||||
|
||||
def _wait_ready(self) -> None:
|
||||
assert self.process.stdout is not None
|
||||
deadline = time.monotonic() + env_float("BENCH_READY_TIMEOUT", 30.0)
|
||||
while time.monotonic() < deadline:
|
||||
line = self.process.stdout.readline()
|
||||
if line == "" and self.process.poll() is not None:
|
||||
raise RuntimeError(self._failure("agent exited before ready"))
|
||||
try:
|
||||
if json.loads(line).get("ready") is True:
|
||||
return
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
raise TimeoutError(self._failure("timed out waiting for agent readiness"))
|
||||
|
||||
def call(self, method: str, params: dict | None = None) -> dict:
|
||||
self.request_id += 1
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": self.request_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
assert self.process.stdin is not None
|
||||
assert self.process.stdout is not None
|
||||
self.process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n")
|
||||
self.process.stdin.flush()
|
||||
while True:
|
||||
line = self.process.stdout.readline()
|
||||
if line == "" and self.process.poll() is not None:
|
||||
raise RuntimeError(self._failure(f"agent exited during {method}"))
|
||||
try:
|
||||
response = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if response.get("id") != self.request_id:
|
||||
continue
|
||||
if response.get("error") is not None:
|
||||
raise RuntimeError(f"{self.candidate.name} {method}: {json.dumps(response['error'], ensure_ascii=False)}")
|
||||
return response.get("result")
|
||||
|
||||
def rss_kib(self) -> int:
|
||||
if self.candidate.rss_command:
|
||||
output = subprocess.check_output(self.candidate.rss_command, shell=True, text=True).strip()
|
||||
return int(output)
|
||||
output = subprocess.check_output(
|
||||
["ps", "-o", "rss=", "-p", str(self.process.pid)],
|
||||
text=True,
|
||||
).strip()
|
||||
return int(output or "0")
|
||||
|
||||
def close(self) -> bool:
|
||||
if self.process.poll() is not None:
|
||||
return True
|
||||
try:
|
||||
self.call("shutdown")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.process.wait(timeout=3)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
return False
|
||||
|
||||
def _failure(self, message: str) -> str:
|
||||
stderr = "\n".join(self.stderr_lines[-20:])
|
||||
return f"{self.candidate.name}: {message}\n{stderr}".rstrip()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
candidates = configured_candidates()
|
||||
connection = connection_params()
|
||||
startup_iterations = env_int("BENCH_STARTUPS", 10)
|
||||
connect_iterations = env_int("BENCH_CONNECTS", 10)
|
||||
warmups = env_int("BENCH_WARMUPS", 20)
|
||||
workloads = configured_workloads(connection["database"])
|
||||
results = []
|
||||
|
||||
for candidate in candidates:
|
||||
startup_samples = benchmark_startup(candidate, startup_iterations)
|
||||
connect_samples = benchmark_connect(candidate, connection, connect_iterations)
|
||||
process = AgentProcess(candidate)
|
||||
shutdown_clean = False
|
||||
try:
|
||||
process.call("connect", connection)
|
||||
rss_kib = process.rss_kib()
|
||||
workload_results = [benchmark_workload(process, workload, warmups) for workload in workloads]
|
||||
process.call("disconnect")
|
||||
finally:
|
||||
shutdown_clean = process.close()
|
||||
results.append(
|
||||
{
|
||||
"candidate": candidate.name,
|
||||
"command": candidate.command,
|
||||
"artifact_bytes": candidate.artifact.stat().st_size,
|
||||
"startup_ms": statistics.median(startup_samples),
|
||||
"startup_samples_ms": startup_samples,
|
||||
"connect_ms": statistics.median(connect_samples),
|
||||
"connect_samples_ms": connect_samples,
|
||||
"rss_kib": rss_kib,
|
||||
"shutdown_exited_within_3s": shutdown_clean,
|
||||
"workloads": workload_results,
|
||||
}
|
||||
)
|
||||
|
||||
output = {
|
||||
"host": os.uname().nodename,
|
||||
"server": env_default("CASSANDRA_SERVER", f"{connection['host']}:{connection['port']}"),
|
||||
"keyspace": connection["database"],
|
||||
"startup_iterations": startup_iterations,
|
||||
"connect_iterations": connect_iterations,
|
||||
"warmups": warmups,
|
||||
"results": results,
|
||||
}
|
||||
json.dump(output, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
def configured_candidates() -> list[Candidate]:
|
||||
selected = {item.strip() for item in env_default("BENCH_CANDIDATES", "go,jdbc").split(",") if item.strip()}
|
||||
candidates = []
|
||||
if "go" in selected:
|
||||
artifact = required_path("GO_AGENT")
|
||||
candidates.append(Candidate("go-native", [str(artifact)], artifact, os.getenv("GO_RSS_COMMAND", "")))
|
||||
if "jdbc" in selected:
|
||||
artifact = required_path("JDBC_AGENT_JAR")
|
||||
raw_command = os.getenv("JDBC_AGENT_COMMAND", "")
|
||||
command = shlex.split(raw_command) if raw_command else [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
|
||||
candidates.append(Candidate("jdbc-java", command, artifact, os.getenv("JDBC_RSS_COMMAND", "")))
|
||||
if not candidates:
|
||||
raise ValueError("BENCH_CANDIDATES selected no candidates")
|
||||
return candidates
|
||||
|
||||
|
||||
def connection_params() -> dict:
|
||||
return {
|
||||
"host": env_default("CASSANDRA_HOST", "127.0.0.1"),
|
||||
"port": env_int("CASSANDRA_PORT", 9042),
|
||||
"database": env_default("CASSANDRA_KEYSPACE", "dbx_native_test"),
|
||||
"username": os.getenv("CASSANDRA_USERNAME", ""),
|
||||
"password": os.getenv("CASSANDRA_PASSWORD", ""),
|
||||
"url_params": os.getenv("CASSANDRA_URL_PARAMS", ""),
|
||||
"connection_string": os.getenv("CASSANDRA_CONNECTION_STRING", ""),
|
||||
"ssl": env_bool("CASSANDRA_SSL", False),
|
||||
"ca_cert_path": os.getenv("CASSANDRA_CA_CERT_PATH", ""),
|
||||
"client_cert_path": os.getenv("CASSANDRA_CLIENT_CERT_PATH", ""),
|
||||
"client_key_path": os.getenv("CASSANDRA_CLIENT_KEY_PATH", ""),
|
||||
}
|
||||
|
||||
|
||||
def configured_workloads(keyspace: str) -> list[dict]:
|
||||
table = env_default("CASSANDRA_BENCH_TABLE", "all_types")
|
||||
qualified = f'"{keyspace}"."{table}"'
|
||||
return [
|
||||
{
|
||||
"name": "select_one",
|
||||
"method": "execute_query",
|
||||
"params": {"sql": env_default("BENCH_SELECT_ONE_SQL", f"SELECT id, txt FROM {qualified} WHERE id = 1"), "schema": keyspace, "maxRows": 1},
|
||||
"count": env_int("BENCH_SELECT_ONE_COUNT", 1000),
|
||||
},
|
||||
{
|
||||
"name": "decode_all_types",
|
||||
"method": "execute_query",
|
||||
"params": {"sql": env_default("BENCH_DECODE_SQL", f"SELECT * FROM {qualified} WHERE id = 1"), "schema": keyspace, "maxRows": 1},
|
||||
"count": env_int("BENCH_DECODE_COUNT", 500),
|
||||
},
|
||||
{
|
||||
"name": "list_tables",
|
||||
"method": "list_tables",
|
||||
"params": {"schema": keyspace},
|
||||
"count": env_int("BENCH_LIST_TABLES_COUNT", 500),
|
||||
},
|
||||
{
|
||||
"name": "page_100",
|
||||
"method": "execute_query_page",
|
||||
"params": {
|
||||
"sql": env_default("BENCH_PAGE_SQL", f"SELECT id, txt FROM {qualified}"),
|
||||
"schema": keyspace,
|
||||
"maxRows": 100,
|
||||
"pageSize": 100,
|
||||
},
|
||||
"count": env_int("BENCH_PAGE_COUNT", 200),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def benchmark_startup(candidate: Candidate, iterations: int) -> list[float]:
|
||||
samples = []
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
process = AgentProcess(candidate)
|
||||
samples.append((time.perf_counter() - start) * 1000)
|
||||
process.close()
|
||||
return samples
|
||||
|
||||
|
||||
def benchmark_connect(candidate: Candidate, connection: dict, iterations: int) -> list[float]:
|
||||
samples = []
|
||||
for _ in range(iterations):
|
||||
process = AgentProcess(candidate)
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
process.call("connect", connection)
|
||||
samples.append((time.perf_counter() - start) * 1000)
|
||||
finally:
|
||||
process.close()
|
||||
return samples
|
||||
|
||||
|
||||
def benchmark_workload(process: AgentProcess, workload: dict, warmups: int) -> dict:
|
||||
for _ in range(warmups):
|
||||
process.call(workload["method"], workload["params"])
|
||||
samples = []
|
||||
start = time.perf_counter()
|
||||
for _ in range(workload["count"]):
|
||||
operation_start = time.perf_counter()
|
||||
process.call(workload["method"], workload["params"])
|
||||
samples.append((time.perf_counter() - operation_start) * 1000)
|
||||
elapsed = time.perf_counter() - start
|
||||
ordered = sorted(samples)
|
||||
return {
|
||||
"name": workload["name"],
|
||||
"count": workload["count"],
|
||||
"elapsed_ms": elapsed * 1000,
|
||||
"ops_per_sec": workload["count"] / elapsed,
|
||||
"mean_ms": statistics.mean(samples),
|
||||
"p50_ms": percentile(ordered, 0.50),
|
||||
"p95_ms": percentile(ordered, 0.95),
|
||||
"p99_ms": percentile(ordered, 0.99),
|
||||
}
|
||||
|
||||
|
||||
def percentile(values: list[float], fraction: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
index = min(len(values) - 1, max(0, round((len(values) - 1) * fraction)))
|
||||
return values[index]
|
||||
|
||||
|
||||
def required_path(name: str) -> Path:
|
||||
value = os.getenv(name, "")
|
||||
if not value:
|
||||
raise ValueError(f"{name} is required")
|
||||
path = Path(value).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(path)
|
||||
return path
|
||||
|
||||
|
||||
def env_default(name: str, fallback: str) -> str:
|
||||
return os.getenv(name, "") or fallback
|
||||
|
||||
|
||||
def env_int(name: str, fallback: int) -> int:
|
||||
value = int(env_default(name, str(fallback)))
|
||||
if value < 1:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
return value
|
||||
|
||||
|
||||
def env_float(name: str, fallback: float) -> float:
|
||||
value = float(env_default(name, str(fallback)))
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
return value
|
||||
|
||||
|
||||
def env_bool(name: str, fallback: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return fallback
|
||||
normalized = raw.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ValueError(f"{name} must be a boolean")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"date": "2026-08-03",
|
||||
"host": "oss-rainyun-01",
|
||||
"cassandra": "4.1.10",
|
||||
"method": "five cold-process startup and connection samples; persistent process for query workloads",
|
||||
"results": [
|
||||
{
|
||||
"candidate": "go-native",
|
||||
"startup_ms": 8.994690957479179,
|
||||
"connect_ms": 33.21323194541037,
|
||||
"rss_kib": 10352,
|
||||
"artifact_bytes": 6750370,
|
||||
"shutdown_exited_within_3s": true,
|
||||
"workloads": [
|
||||
{"name": "select_one", "count": 1000, "elapsed_ms": 1940.8286979887635, "ops_per_sec": 515.2438239584345, "mean_ms": 1.9388069859705865, "p50_ms": 1.9103229278698564, "p95_ms": 2.221024944446981, "p99_ms": 2.3676720447838306},
|
||||
{"name": "decode_all_types", "count": 500, "elapsed_ms": 1079.476205050014, "ops_per_sec": 463.18760678641746, "mean_ms": 2.156704908935353, "p50_ms": 2.141958102583885, "p95_ms": 2.4329390143975616, "p99_ms": 2.7680869679898024},
|
||||
{"name": "list_tables", "count": 500, "elapsed_ms": 57.42691201157868, "ops_per_sec": 8706.71924513698, "mean_ms": 0.11419291398487985, "p50_ms": 0.10622991248965263, "p95_ms": 0.1487070694565773, "p99_ms": 0.19879091996699572},
|
||||
{"name": "page_100", "count": 200, "elapsed_ms": 984.7569830017164, "ops_per_sec": 203.09579261917395, "mean_ms": 4.921203925041482, "p50_ms": 4.922428051941097, "p95_ms": 6.079918937757611, "p99_ms": 7.303814985789359}
|
||||
]
|
||||
},
|
||||
{
|
||||
"candidate": "jdbc-java",
|
||||
"startup_ms": 657.9867920372635,
|
||||
"connect_ms": 1778.3896300243214,
|
||||
"rss_kib": 166232,
|
||||
"artifact_bytes": 22984056,
|
||||
"shutdown_exited_within_3s": false,
|
||||
"workloads": [
|
||||
{"name": "select_one", "count": 1000, "elapsed_ms": 3868.2536740088835, "ops_per_sec": 258.51458675502147, "mean_ms": 3.865854301955551, "p50_ms": 3.6270120181143284, "p95_ms": 5.733568919822574, "p99_ms": 7.344924029894173},
|
||||
{"name": "decode_all_types", "count": 500, "elapsed_ms": 2014.9188039358705, "ops_per_sec": 248.14895718046694, "mean_ms": 4.0274618696421385, "p50_ms": 3.9998559514060616, "p95_ms": 4.94410190731287, "p99_ms": 5.3489640122279525},
|
||||
{"name": "list_tables", "count": 500, "elapsed_ms": 763.6687039630488, "ops_per_sec": 654.7341764894339, "mean_ms": 1.5251681823283434, "p50_ms": 1.494601950980723, "p95_ms": 1.9315499812364578, "p99_ms": 2.339883940294385},
|
||||
{"name": "page_100", "count": 200, "elapsed_ms": 8309.039836982265, "ops_per_sec": 24.070169829952025, "mean_ms": 41.53980694070924, "p50_ms": 42.0777719700709, "p95_ms": 51.87095201108605, "p99_ms": 57.71494994405657}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,653 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
gocqlastra "github.com/datastax/gocql-astra/v2"
|
||||
)
|
||||
|
||||
type cassandraConfig struct {
|
||||
hosts []string
|
||||
port int
|
||||
keyspace string
|
||||
username string
|
||||
password string
|
||||
localDatacenter string
|
||||
requestTimeout time.Duration
|
||||
connectTimeout time.Duration
|
||||
protocolVersion int
|
||||
consistency string
|
||||
serialConsistency string
|
||||
numConnections int
|
||||
pageSize int
|
||||
cqlVersion string
|
||||
ssl bool
|
||||
caCertPath string
|
||||
clientCertPath string
|
||||
clientKeyPath string
|
||||
hostVerification bool
|
||||
tcpNoDelay bool
|
||||
keepAlive bool
|
||||
debug bool
|
||||
retryPolicy string
|
||||
retryCount int
|
||||
reconnectionPolicy string
|
||||
reconnectionBaseDelay time.Duration
|
||||
reconnectionMaxDelay time.Duration
|
||||
loadBalancingPolicy string
|
||||
disableInitialHostLookup bool
|
||||
configFile string
|
||||
secureConnectBundle string
|
||||
kerberos kerberosConfig
|
||||
}
|
||||
|
||||
func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
|
||||
config := cassandraConfig{
|
||||
port: 9042,
|
||||
keyspace: strings.TrimSpace(cp.Database),
|
||||
username: cp.Username,
|
||||
password: cp.Password,
|
||||
requestTimeout: 11 * time.Second,
|
||||
connectTimeout: defaultConnectTimeout,
|
||||
numConnections: 2,
|
||||
pageSize: 5000,
|
||||
ssl: cp.SSL,
|
||||
caCertPath: cp.CACertPath,
|
||||
clientCertPath: cp.ClientCertPath,
|
||||
clientKeyPath: cp.ClientKeyPath,
|
||||
hostVerification: true,
|
||||
tcpNoDelay: true,
|
||||
retryCount: 3,
|
||||
reconnectionBaseDelay: time.Second,
|
||||
reconnectionMaxDelay: 60 * time.Second,
|
||||
kerberos: defaultKerberosConfig(),
|
||||
}
|
||||
if cp.Port > 0 {
|
||||
config.port = cp.Port
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
if strings.TrimSpace(cp.ConnectionString) != "" {
|
||||
if err := applyConnectionString(&config, params, cp.ConnectionString); err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
}
|
||||
if len(config.hosts) == 0 {
|
||||
config.hosts = splitHosts(cp.Host)
|
||||
}
|
||||
urlParams, err := parseURLParams(cp.URLParams)
|
||||
if err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
for key, values := range urlParams {
|
||||
params[key] = values
|
||||
}
|
||||
if err := applyCassandraURLParams(&config, params); err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
if config.configFile != "" {
|
||||
if err := applyCassandraConfigFile(&config, config.configFile); err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
}
|
||||
if err := config.finalize(); err != nil {
|
||||
return cassandraConfig{}, err
|
||||
}
|
||||
if len(config.hosts) == 0 && config.secureConnectBundle == "" {
|
||||
return cassandraConfig{}, fmt.Errorf("Cassandra host is required")
|
||||
}
|
||||
if len(config.hosts) > 0 && !config.disableInitialHostLookup && allLoopbackHosts(config.hosts) {
|
||||
config.disableInitialHostLookup = true
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func applyConnectionString(config *cassandraConfig, params url.Values, raw string) error {
|
||||
value := strings.TrimSpace(raw)
|
||||
value = strings.TrimPrefix(value, "jdbc:")
|
||||
if !strings.Contains(value, "://") {
|
||||
return fmt.Errorf("unsupported Cassandra connection string: %s", raw)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra connection string: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "cassandra" {
|
||||
return fmt.Errorf("unsupported Cassandra connection scheme: %s", parsed.Scheme)
|
||||
}
|
||||
if parsed.User != nil {
|
||||
config.username = parsed.User.Username()
|
||||
if password, ok := parsed.User.Password(); ok {
|
||||
config.password = password
|
||||
}
|
||||
}
|
||||
config.hosts = splitHosts(parsed.Host)
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsedPort, parseErr := strconv.Atoi(port)
|
||||
if parseErr != nil || parsedPort < 1 || parsedPort > 65535 {
|
||||
return fmt.Errorf("invalid Cassandra port: %s", port)
|
||||
}
|
||||
config.port = parsedPort
|
||||
}
|
||||
if keyspace := strings.Trim(strings.TrimSpace(parsed.Path), "/"); keyspace != "" {
|
||||
config.keyspace = keyspace
|
||||
}
|
||||
for key, values := range parsed.Query() {
|
||||
params[key] = values
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURLParams(raw string) (url.Values, error) {
|
||||
raw = strings.TrimPrefix(strings.TrimSpace(raw), "?")
|
||||
if raw == "" {
|
||||
return url.Values{}, nil
|
||||
}
|
||||
values, err := url.ParseQuery(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Cassandra URL parameters: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func applyCassandraURLParams(config *cassandraConfig, params url.Values) error {
|
||||
for rawKey, values := range params {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
key := normalizeOptionName(rawKey)
|
||||
value := strings.TrimSpace(values[len(values)-1])
|
||||
switch key {
|
||||
case "localdatacenter", "datacenter", "dc":
|
||||
config.localDatacenter = value
|
||||
case "requesttimeout", "timeout":
|
||||
duration, err := parseDurationOption(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid requesttimeout: %w", err)
|
||||
}
|
||||
config.requestTimeout = duration
|
||||
case "connecttimeout", "logintimeout":
|
||||
duration, err := parseDurationOption(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid connecttimeout: %w", err)
|
||||
}
|
||||
config.connectTimeout = duration
|
||||
case "protocolversion", "protoversion":
|
||||
version, err := strconv.Atoi(value)
|
||||
if err != nil || version < 3 || version > 5 {
|
||||
return fmt.Errorf("protocolversion must be between 3 and 5")
|
||||
}
|
||||
config.protocolVersion = version
|
||||
case "consistency":
|
||||
if _, err := gocql.ParseConsistencyWrapper(value); err != nil {
|
||||
return err
|
||||
}
|
||||
config.consistency = value
|
||||
case "serialconsistency":
|
||||
consistency, err := gocql.ParseConsistencyWrapper(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consistency != gocql.Serial && consistency != gocql.LocalSerial {
|
||||
return fmt.Errorf("serialconsistency must be SERIAL or LOCAL_SERIAL")
|
||||
}
|
||||
config.serialConsistency = value
|
||||
case "numconns", "connectionsperhost":
|
||||
count, err := strconv.Atoi(value)
|
||||
if err != nil || count < 1 || count > 32 {
|
||||
return fmt.Errorf("numconns must be between 1 and 32")
|
||||
}
|
||||
config.numConnections = count
|
||||
case "pagesize", "fetchsize":
|
||||
size, err := strconv.Atoi(value)
|
||||
if err != nil || size < 1 {
|
||||
return fmt.Errorf("pagesize must be positive")
|
||||
}
|
||||
config.pageSize = size
|
||||
case "cqlversion":
|
||||
config.cqlVersion = value
|
||||
case "ssl", "enablessl":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ssl option: %w", err)
|
||||
}
|
||||
config.ssl = enabled
|
||||
case "hostverification", "verifyhostname", "sslhostnameverification", "hostnameverification":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid host verification option: %w", err)
|
||||
}
|
||||
config.hostVerification = enabled
|
||||
case "tcpnodelay":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tcpnodelay option: %w", err)
|
||||
}
|
||||
config.tcpNoDelay = enabled
|
||||
case "keepalive":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid keepalive option: %w", err)
|
||||
}
|
||||
config.keepAlive = enabled
|
||||
case "user":
|
||||
config.username = value
|
||||
case "password":
|
||||
config.password = value
|
||||
case "debug":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid debug option: %w", err)
|
||||
}
|
||||
config.debug = enabled
|
||||
case "retries":
|
||||
count, err := strconv.Atoi(value)
|
||||
if err != nil || count < 0 || count > 1000 {
|
||||
return fmt.Errorf("retries must be between 0 and 1000")
|
||||
}
|
||||
config.retryCount = count
|
||||
case "retry":
|
||||
policy, err := normalizeRetryPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.retryPolicy = policy
|
||||
case "reconnection":
|
||||
policy, baseDelay, maxDelay, err := parseReconnectionPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.reconnectionPolicy = policy
|
||||
config.reconnectionBaseDelay = baseDelay
|
||||
config.reconnectionMaxDelay = maxDelay
|
||||
case "disableinitialhostlookup":
|
||||
disabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid disableinitialhostlookup option: %w", err)
|
||||
}
|
||||
config.disableInitialHostLookup = disabled
|
||||
case "loadbalancing":
|
||||
policy, err := normalizeLoadBalancingPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.loadBalancingPolicy = policy
|
||||
case "sslenginefactory":
|
||||
if value != "" && !strings.EqualFold(simpleClassName(value), "DefaultSslEngineFactory") {
|
||||
return fmt.Errorf("custom Cassandra sslenginefactory is not supported by the native agent: %s", value)
|
||||
}
|
||||
config.ssl = true
|
||||
case "usekrb5":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid usekrb5 option: %w", err)
|
||||
}
|
||||
config.kerberos.enabled = enabled
|
||||
case "secureconnectbundle":
|
||||
config.secureConnectBundle = value
|
||||
case "configfile":
|
||||
config.configFile = value
|
||||
case "kerberosconfig", "kerberosconfigpath", "krb5config", "krb5conf":
|
||||
config.kerberos.configPath = value
|
||||
case "jaasconfig", "jaasconfigpath":
|
||||
config.kerberos.jaasConfigPath = value
|
||||
case "kerberosprincipal", "krb5principal":
|
||||
config.kerberos.principal = value
|
||||
case "kerberosrealm", "krb5realm":
|
||||
config.kerberos.realm = value
|
||||
case "kerberoskeytab", "keytab":
|
||||
config.kerberos.keytabPath = value
|
||||
case "kerberosccache", "kerberosticketcache", "ccache", "ticketcache":
|
||||
config.kerberos.ccachePath = value
|
||||
case "kerberospassword":
|
||||
config.kerberos.password = value
|
||||
case "kerberosservice", "kerberosservicename", "saslprotocol":
|
||||
config.kerberos.serviceName = value
|
||||
case "kerberosservername", "saslservername":
|
||||
config.kerberos.serverName = value
|
||||
case "kerberosauthorizationid", "authorizationid":
|
||||
config.kerberos.authorizationID = value
|
||||
case "kerberosqop", "saslqop":
|
||||
config.kerberos.qop = value
|
||||
case "kerberosdisablepafxfast", "disablepafxfast":
|
||||
disabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid disablepafxfast option: %w", err)
|
||||
}
|
||||
config.kerberos.disablePAFXFAST = disabled
|
||||
case "kerberosusekeytab", "usekeytab":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid usekeytab option: %w", err)
|
||||
}
|
||||
config.kerberos.useKeytab = enabled
|
||||
config.kerberos.useKeytabSet = true
|
||||
case "kerberosuseticketcache", "useticketcache":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid useticketcache option: %w", err)
|
||||
}
|
||||
config.kerberos.useTicketCache = enabled
|
||||
config.kerberos.useTicketCacheSet = true
|
||||
case "compliancemode":
|
||||
// JDBC compliance modes only alter java.sql behavior. The native DBX
|
||||
// JSON-RPC contract already defines statement and transaction behavior.
|
||||
default:
|
||||
return fmt.Errorf("unsupported Cassandra URL parameter: %s", rawKey)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConfig, error) {
|
||||
var cluster *gocql.ClusterConfig
|
||||
var err error
|
||||
if config.secureConnectBundle != "" {
|
||||
cluster, err = gocqlastra.NewClusterFromBundle(
|
||||
config.secureConnectBundle,
|
||||
config.username,
|
||||
config.password,
|
||||
config.connectTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Cassandra secure connect bundle: %w", err)
|
||||
}
|
||||
} else {
|
||||
cluster = gocql.NewCluster(config.hosts...)
|
||||
cluster.Port = config.port
|
||||
cluster.Dialer = cassandraDialer{
|
||||
timeout: config.connectTimeout,
|
||||
keepAlive: config.keepAlive,
|
||||
tcpNoDelay: config.tcpNoDelay,
|
||||
}
|
||||
cluster.DisableInitialHostLookup = config.disableInitialHostLookup
|
||||
cluster.IgnorePeerAddr = config.disableInitialHostLookup
|
||||
}
|
||||
cluster.Keyspace = strings.TrimSpace(keyspace)
|
||||
cluster.Timeout = config.requestTimeout
|
||||
cluster.ConnectTimeout = config.connectTimeout
|
||||
cluster.WriteTimeout = config.requestTimeout
|
||||
cluster.NumConns = config.numConnections
|
||||
cluster.PageSize = config.pageSize
|
||||
if config.protocolVersion != 0 {
|
||||
cluster.ProtoVersion = config.protocolVersion
|
||||
}
|
||||
if config.cqlVersion != "" {
|
||||
cluster.CQLVersion = config.cqlVersion
|
||||
}
|
||||
if config.consistency != "" {
|
||||
consistency, err := gocql.ParseConsistencyWrapper(config.consistency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cluster.Consistency = consistency
|
||||
}
|
||||
if config.serialConsistency != "" {
|
||||
consistency, err := gocql.ParseConsistencyWrapper(config.serialConsistency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cluster.SerialConsistency = consistency
|
||||
}
|
||||
if config.kerberos.enabled {
|
||||
authProvider, err := newKerberosAuthProvider(config.kerberos, config.username, config.password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cluster.Authenticator = nil
|
||||
cluster.AuthProvider = authProvider
|
||||
} else if config.secureConnectBundle == "" && config.username != "" {
|
||||
cluster.Authenticator = gocql.PasswordAuthenticator{Username: config.username, Password: config.password}
|
||||
}
|
||||
if config.secureConnectBundle == "" && config.ssl {
|
||||
cluster.SslOpts = &gocql.SslOptions{
|
||||
CaPath: config.caCertPath,
|
||||
CertPath: config.clientCertPath,
|
||||
KeyPath: config.clientKeyPath,
|
||||
EnableHostVerification: config.hostVerification,
|
||||
}
|
||||
}
|
||||
if config.debug {
|
||||
cluster.Logger = gocql.NewLogger(gocql.LogLevelDebug)
|
||||
}
|
||||
if err := applyRetryPolicies(cluster, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyLoadBalancingPolicy(cluster, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func (config *cassandraConfig) finalize() error {
|
||||
var err error
|
||||
config.configFile, err = normalizeLocalFilePath(config.configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra configfile: %w", err)
|
||||
}
|
||||
config.secureConnectBundle, err = normalizeLocalFilePath(config.secureConnectBundle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra secureconnectbundle: %w", err)
|
||||
}
|
||||
if config.secureConnectBundle != "" && config.kerberos.enabled {
|
||||
return fmt.Errorf("Cassandra secure connect bundles cannot be combined with Kerberos authentication")
|
||||
}
|
||||
if config.secureConnectBundle != "" && (config.username == "" || config.password == "") {
|
||||
return fmt.Errorf("Cassandra secure connect bundles require username and password credentials")
|
||||
}
|
||||
if config.kerberos.enabled {
|
||||
if err := config.kerberos.finalize(config.username, config.password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitHosts(raw string) []string {
|
||||
raw = strings.ReplaceAll(raw, "--", ",")
|
||||
parts := strings.FieldsFunc(raw, func(char rune) bool { return char == ',' || char == ';' })
|
||||
hosts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
host := strings.TrimSpace(part)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func allLoopbackHosts(hosts []string) bool {
|
||||
for _, host := range hosts {
|
||||
host = hostNameOnly(host)
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(hosts) > 0
|
||||
}
|
||||
|
||||
func hostNameOnly(host string) string {
|
||||
host = strings.TrimSpace(host)
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
return parsedHost
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
func parseDurationOption(value string) (time.Duration, error) {
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return duration, nil
|
||||
}
|
||||
milliseconds, err := strconv.Atoi(value)
|
||||
if err != nil || milliseconds < 1 {
|
||||
return 0, fmt.Errorf("expected duration or positive milliseconds")
|
||||
}
|
||||
return time.Duration(milliseconds) * time.Millisecond, nil
|
||||
}
|
||||
|
||||
func normalizeRetryPolicy(value string) (string, error) {
|
||||
name := strings.ToLower(simpleClassName(value))
|
||||
switch name {
|
||||
case "", "defaultretrypolicy", "simpleretrypolicy":
|
||||
return "simple", nil
|
||||
case "fallthroughretrypolicy":
|
||||
return "fallthrough", nil
|
||||
case "downgradingconsistencyretrypolicy":
|
||||
return "downgrading", nil
|
||||
case "exponentialbackoffretrypolicy":
|
||||
return "exponential", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Cassandra retry policy: %s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLoadBalancingPolicy(value string) (string, error) {
|
||||
name := strings.ToLower(simpleClassName(value))
|
||||
switch name {
|
||||
case "", "basicloadbalancingpolicy", "dcinferringloadbalancingpolicy", "defaultloadbalancingpolicy":
|
||||
return "default", nil
|
||||
case "roundrobinpolicy":
|
||||
return "round_robin", nil
|
||||
case "dcawareroundrobinpolicy":
|
||||
return "dc_aware", nil
|
||||
case "tokenawarepolicy":
|
||||
return "token_aware", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Cassandra loadbalancing policy: %s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseReconnectionPolicy(value string) (string, time.Duration, time.Duration, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
name := simpleClassName(trimmed)
|
||||
parameters := ""
|
||||
if open := strings.IndexByte(name, '('); open >= 0 {
|
||||
parameters = strings.TrimSuffix(name[open+1:], ")")
|
||||
name = name[:open]
|
||||
}
|
||||
policy := strings.ToLower(strings.TrimSpace(name))
|
||||
baseDelay := time.Second
|
||||
maxDelay := 60 * time.Second
|
||||
if parameters != "" {
|
||||
parts := strings.Split(parameters, ",")
|
||||
for index, part := range parts {
|
||||
part = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(part), "(long)", ""))
|
||||
seconds, err := strconv.Atoi(part)
|
||||
if err != nil || seconds < 0 {
|
||||
return "", 0, 0, fmt.Errorf("invalid Cassandra reconnection policy delay: %s", part)
|
||||
}
|
||||
if index == 0 {
|
||||
baseDelay = time.Duration(seconds) * time.Second
|
||||
} else if index == 1 {
|
||||
maxDelay = time.Duration(seconds) * time.Second
|
||||
} else {
|
||||
return "", 0, 0, fmt.Errorf("too many Cassandra reconnection policy parameters")
|
||||
}
|
||||
}
|
||||
}
|
||||
switch policy {
|
||||
case "", "constantreconnectionpolicy":
|
||||
return "constant", baseDelay, baseDelay, nil
|
||||
case "exponentialreconnectionpolicy":
|
||||
return "exponential", baseDelay, maxDelay, nil
|
||||
default:
|
||||
return "", 0, 0, fmt.Errorf("unsupported Cassandra reconnection policy: %s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleClassName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
prefix := value
|
||||
if open := strings.IndexByte(prefix, '('); open >= 0 {
|
||||
prefix = prefix[:open]
|
||||
}
|
||||
if dot := strings.LastIndexByte(prefix, '.'); dot >= 0 {
|
||||
return value[dot+1:]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func applyRetryPolicies(cluster *gocql.ClusterConfig, config cassandraConfig) error {
|
||||
switch config.retryPolicy {
|
||||
case "":
|
||||
case "simple":
|
||||
cluster.RetryPolicy = &gocql.SimpleRetryPolicy{NumRetries: config.retryCount}
|
||||
case "fallthrough":
|
||||
cluster.RetryPolicy = &gocql.SimpleRetryPolicy{NumRetries: 0}
|
||||
case "downgrading":
|
||||
cluster.RetryPolicy = &gocql.DowngradingConsistencyRetryPolicy{}
|
||||
case "exponential":
|
||||
cluster.RetryPolicy = &gocql.ExponentialBackoffRetryPolicy{
|
||||
NumRetries: config.retryCount,
|
||||
Min: config.reconnectionBaseDelay,
|
||||
Max: config.reconnectionMaxDelay,
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported Cassandra retry policy: %s", config.retryPolicy)
|
||||
}
|
||||
if config.reconnectionPolicy != "" || config.retryCount != 3 {
|
||||
switch config.reconnectionPolicy {
|
||||
case "", "constant":
|
||||
cluster.ReconnectionPolicy = &gocql.ConstantReconnectionPolicy{
|
||||
MaxRetries: config.retryCount,
|
||||
Interval: config.reconnectionBaseDelay,
|
||||
}
|
||||
case "exponential":
|
||||
cluster.ReconnectionPolicy = &gocql.ExponentialReconnectionPolicy{
|
||||
MaxRetries: config.retryCount,
|
||||
InitialInterval: config.reconnectionBaseDelay,
|
||||
MaxInterval: config.reconnectionMaxDelay,
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported Cassandra reconnection policy: %s", config.reconnectionPolicy)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyLoadBalancingPolicy(cluster *gocql.ClusterConfig, config cassandraConfig) error {
|
||||
policy := config.loadBalancingPolicy
|
||||
if policy == "" {
|
||||
policy = "default"
|
||||
}
|
||||
switch policy {
|
||||
case "default":
|
||||
if config.localDatacenter == "" {
|
||||
return nil
|
||||
}
|
||||
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(
|
||||
gocql.DCAwareRoundRobinPolicy(config.localDatacenter),
|
||||
)
|
||||
case "round_robin":
|
||||
cluster.PoolConfig.HostSelectionPolicy = gocql.RoundRobinHostPolicy()
|
||||
case "dc_aware":
|
||||
if config.localDatacenter == "" {
|
||||
return fmt.Errorf("DCAwareRoundRobinPolicy requires localdatacenter")
|
||||
}
|
||||
cluster.PoolConfig.HostSelectionPolicy = gocql.DCAwareRoundRobinPolicy(config.localDatacenter)
|
||||
case "token_aware":
|
||||
fallback := gocql.RoundRobinHostPolicy()
|
||||
if config.localDatacenter != "" {
|
||||
fallback = gocql.DCAwareRoundRobinPolicy(config.localDatacenter)
|
||||
}
|
||||
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(fallback)
|
||||
default:
|
||||
return fmt.Errorf("unsupported Cassandra loadbalancing policy: %s", policy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOptionName(value string) string {
|
||||
return strings.NewReplacer("_", "", "-", "", ".", "").Replace(strings.ToLower(strings.TrimSpace(value)))
|
||||
}
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/gurkankaymak/hocon"
|
||||
)
|
||||
|
||||
const javaDriverConfigPrefix = "datastax-java-driver."
|
||||
|
||||
func applyCassandraConfigFile(config *cassandraConfig, rawPath string) error {
|
||||
path, err := normalizeLocalFilePath(rawPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra configfile: %w", err)
|
||||
}
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read Cassandra configfile %s: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("Cassandra configfile is not a regular file: %s", path)
|
||||
}
|
||||
parsed, err := hocon.ParseResource(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Cassandra configfile %s: %w", path, err)
|
||||
}
|
||||
config.configFile = path
|
||||
if err := applyJavaDriverHOCON(config, parsed); err != nil {
|
||||
return fmt.Errorf("apply Cassandra configfile %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyJavaDriverHOCON(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"basic.request.timeout"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.requestTimeout = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.request.consistency"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if _, err := gocql.ParseConsistencyWrapper(value); err != nil {
|
||||
return err
|
||||
}
|
||||
config.consistency = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.request.serial-consistency"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
consistency, err := gocql.ParseConsistencyWrapper(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consistency != gocql.Serial && consistency != gocql.LocalSerial {
|
||||
return fmt.Errorf("serial consistency must be SERIAL or LOCAL_SERIAL")
|
||||
}
|
||||
config.serialConsistency = value
|
||||
}
|
||||
if value, ok, err := hoconInt(parsed, javaDriverConfigPrefix+"basic.request.page-size"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if value < 1 {
|
||||
return fmt.Errorf("page size must be positive")
|
||||
}
|
||||
config.pageSize = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.load-balancing-policy.local-datacenter"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.localDatacenter = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.load-balancing-policy.class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
policy, err := normalizeLoadBalancingPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.loadBalancingPolicy = policy
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.cloud.secure-connect-bundle"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.secureConnectBundle = value
|
||||
}
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.connection.connect-timeout"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.connectTimeout = value
|
||||
}
|
||||
if value, ok, err := hoconInt(parsed, javaDriverConfigPrefix+"advanced.connection.pool.local.size"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if value < 1 || value > 32 {
|
||||
return fmt.Errorf("connection pool local size must be between 1 and 32")
|
||||
}
|
||||
config.numConnections = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, javaDriverConfigPrefix+"advanced.socket.tcp-no-delay"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.tcpNoDelay = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, javaDriverConfigPrefix+"advanced.socket.keep-alive"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.keepAlive = value
|
||||
}
|
||||
if value, ok, err := hoconProtocolVersion(parsed, javaDriverConfigPrefix+"advanced.protocol.version"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.protocolVersion = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"advanced.retry-policy.class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
policy, err := normalizeRetryPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.retryPolicy = policy
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
policy, baseDelay, maxDelay, err := parseReconnectionPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.reconnectionPolicy = policy
|
||||
config.reconnectionBaseDelay = baseDelay
|
||||
config.reconnectionMaxDelay = maxDelay
|
||||
}
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.base-delay"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.reconnectionBaseDelay = value
|
||||
}
|
||||
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.max-delay"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.reconnectionMaxDelay = value
|
||||
}
|
||||
if err := applyHOCONAuthentication(config, parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyHOCONSSL(config, parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyNativeHOCON(config, parsed)
|
||||
}
|
||||
|
||||
func applyHOCONAuthentication(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
prefix := javaDriverConfigPrefix + "advanced.auth-provider."
|
||||
if value, ok, err := hoconString(parsed, prefix+"class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
switch strings.ToLower(simpleClassName(value)) {
|
||||
case "plaintextauthprovider", "dseplaintextauthprovider":
|
||||
config.kerberos.enabled = false
|
||||
case "kerberosauthprovider", "programmatickerberosauthprovider", "dsegssapiauthprovider":
|
||||
config.kerberos.enabled = true
|
||||
default:
|
||||
return fmt.Errorf("unsupported Cassandra auth provider class: %s", value)
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"username"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.username = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"password"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.password = value
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"authorization-id"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.authorizationID = value
|
||||
}
|
||||
for _, path := range []string{prefix + "sasl-protocol", prefix + "service"} {
|
||||
if value, ok, err := hoconString(parsed, path); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.serviceName = value
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconStringMap(parsed, prefix+"sasl-properties"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
for key, property := range value {
|
||||
if strings.EqualFold(key, "javax.security.sasl.qop") {
|
||||
config.kerberos.qop = property
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconString(parsed, prefix+"server-name-resolver"); err != nil {
|
||||
return err
|
||||
} else if ok && value != "" {
|
||||
return fmt.Errorf("custom Java Kerberos server-name-resolver is not supported; use dbx.cassandra.kerberos.server-name")
|
||||
}
|
||||
loginPrefix := prefix + "login-configuration."
|
||||
if value, ok, err := hoconString(parsed, loginPrefix+"principal"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.principal = value
|
||||
}
|
||||
if value, ok, err := firstHOCONString(parsed, loginPrefix+"keyTab", loginPrefix+"keytab"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.keytabPath = value
|
||||
}
|
||||
if value, ok, err := firstHOCONString(parsed, loginPrefix+"ticketCache", loginPrefix+"ticket-cache"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.ccachePath = value
|
||||
}
|
||||
if value, ok, err := firstHOCONBool(parsed, loginPrefix+"useKeyTab", loginPrefix+"use-keytab"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useKeytab = value
|
||||
config.kerberos.useKeytabSet = true
|
||||
}
|
||||
if value, ok, err := firstHOCONBool(parsed, loginPrefix+"useTicketCache", loginPrefix+"use-ticket-cache"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useTicketCache = value
|
||||
config.kerberos.useTicketCacheSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyHOCONSSL(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
prefix := javaDriverConfigPrefix + "advanced.ssl-engine-factory."
|
||||
if value, ok, err := hoconString(parsed, prefix+"class"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if !strings.EqualFold(simpleClassName(value), "DefaultSslEngineFactory") {
|
||||
return fmt.Errorf("unsupported Cassandra SSL engine factory class: %s", value)
|
||||
}
|
||||
config.ssl = true
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"hostname-validation"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.hostVerification = value
|
||||
config.ssl = true
|
||||
}
|
||||
for _, path := range []string{prefix + "truststore-path", prefix + "keystore-path"} {
|
||||
if value, ok, err := hoconString(parsed, path); err != nil {
|
||||
return err
|
||||
} else if ok && value != "" {
|
||||
return fmt.Errorf("Java truststore and keystore files are not supported; use dbx.cassandra.tls PEM paths")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyNativeHOCON(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
prefix := "dbx.cassandra."
|
||||
stringMappings := []struct {
|
||||
path string
|
||||
target *string
|
||||
}{
|
||||
{"tls.ca-cert-path", &config.caCertPath},
|
||||
{"tls.client-cert-path", &config.clientCertPath},
|
||||
{"tls.client-key-path", &config.clientKeyPath},
|
||||
{"kerberos.config", &config.kerberos.configPath},
|
||||
{"kerberos.jaas-config", &config.kerberos.jaasConfigPath},
|
||||
{"kerberos.principal", &config.kerberos.principal},
|
||||
{"kerberos.realm", &config.kerberos.realm},
|
||||
{"kerberos.keytab", &config.kerberos.keytabPath},
|
||||
{"kerberos.ccache", &config.kerberos.ccachePath},
|
||||
{"kerberos.password", &config.kerberos.password},
|
||||
{"kerberos.service-name", &config.kerberos.serviceName},
|
||||
{"kerberos.server-name", &config.kerberos.serverName},
|
||||
{"kerberos.authorization-id", &config.kerberos.authorizationID},
|
||||
{"kerberos.qop", &config.kerberos.qop},
|
||||
}
|
||||
for _, mapping := range stringMappings {
|
||||
if value, ok, err := hoconString(parsed, prefix+mapping.path); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
*mapping.target = value
|
||||
}
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"tls.enabled"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.ssl = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"tls.hostname-verification"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.hostVerification = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.enabled"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.enabled = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.disable-pafxfast"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.disablePAFXFAST = value
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.use-keytab"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useKeytab = value
|
||||
config.kerberos.useKeytabSet = true
|
||||
}
|
||||
if value, ok, err := hoconBool(parsed, prefix+"kerberos.use-ticket-cache"); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
config.kerberos.useTicketCache = value
|
||||
config.kerberos.useTicketCacheSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLocalFilePath(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(value, "://") || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Scheme != "file" {
|
||||
return "", fmt.Errorf("unsupported file URI scheme: %s", parsed.Scheme)
|
||||
}
|
||||
if parsed.Host != "" && !strings.EqualFold(parsed.Host, "localhost") {
|
||||
return "", fmt.Errorf("remote file URI hosts are not supported: %s", parsed.Host)
|
||||
}
|
||||
value, err = url.PathUnescape(parsed.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtime.GOOS == "windows" && len(value) >= 3 && value[0] == '/' && value[2] == ':' {
|
||||
value = value[1:]
|
||||
}
|
||||
}
|
||||
return filepath.Clean(filepath.FromSlash(value)), nil
|
||||
}
|
||||
|
||||
func hoconString(config *hocon.Config, path string) (string, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
value, err := config.GetStringE(path)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return strings.TrimSpace(value), true, nil
|
||||
}
|
||||
|
||||
func firstHOCONString(config *hocon.Config, paths ...string) (string, bool, error) {
|
||||
for _, path := range paths {
|
||||
value, ok, err := hoconString(config, path)
|
||||
if err != nil || ok {
|
||||
return value, ok, err
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func hoconStringMap(config *hocon.Config, path string) (map[string]string, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
value, err := config.GetStringMapStringE(path)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func hoconDuration(config *hocon.Config, path string) (time.Duration, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
value, err := config.GetDurationE(path)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func hoconInt(config *hocon.Config, path string) (int, bool, error) {
|
||||
if config.Get(path) == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
value, err := config.GetIntE(path)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func hoconBool(config *hocon.Config, path string) (bool, bool, error) {
|
||||
value := config.Get(path)
|
||||
if value == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case hocon.Boolean:
|
||||
return bool(typed), true, nil
|
||||
case hocon.String:
|
||||
parsed, err := strconv.ParseBool(string(typed))
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
return parsed, true, nil
|
||||
default:
|
||||
return false, false, fmt.Errorf("invalid %s: expected boolean", path)
|
||||
}
|
||||
}
|
||||
|
||||
func firstHOCONBool(config *hocon.Config, paths ...string) (bool, bool, error) {
|
||||
for _, path := range paths {
|
||||
value, ok, err := hoconBool(config, path)
|
||||
if err != nil || ok {
|
||||
return value, ok, err
|
||||
}
|
||||
}
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
func hoconProtocolVersion(config *hocon.Config, path string) (int, bool, error) {
|
||||
value := config.Get(path)
|
||||
if value == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
var raw string
|
||||
switch typed := value.(type) {
|
||||
case hocon.Int:
|
||||
raw = strconv.Itoa(int(typed))
|
||||
case hocon.String:
|
||||
raw = string(typed)
|
||||
default:
|
||||
return 0, false, fmt.Errorf("invalid %s: expected V3, V4, or V5", path)
|
||||
}
|
||||
raw = strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(raw)), "V")
|
||||
version, err := strconv.Atoi(raw)
|
||||
if err != nil || version < 3 || version > 5 {
|
||||
return 0, false, fmt.Errorf("invalid %s: expected V3, V4, or V5", path)
|
||||
}
|
||||
return version, true, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gurkankaymak/hocon"
|
||||
)
|
||||
|
||||
func TestCassandraConfigFileOverridesURLExceptEndpoint(t *testing.T) {
|
||||
configPath := writeTestFile(t, "application.conf", `
|
||||
datastax-java-driver {
|
||||
basic {
|
||||
contact-points = ["ignored.example.com:9042"]
|
||||
session-keyspace = ignored_keyspace
|
||||
request {
|
||||
timeout = 2 seconds
|
||||
consistency = LOCAL_ONE
|
||||
serial-consistency = LOCAL_SERIAL
|
||||
page-size = 321
|
||||
}
|
||||
load-balancing-policy {
|
||||
class = BasicLoadBalancingPolicy
|
||||
local-datacenter = dc-config
|
||||
}
|
||||
}
|
||||
advanced {
|
||||
connection {
|
||||
connect-timeout = 3 seconds
|
||||
pool.local.size = 4
|
||||
}
|
||||
socket {
|
||||
tcp-no-delay = false
|
||||
keep-alive = true
|
||||
}
|
||||
protocol.version = V4
|
||||
retry-policy.class = FallthroughRetryPolicy
|
||||
reconnection-policy {
|
||||
class = ExponentialReconnectionPolicy
|
||||
base-delay = 4 seconds
|
||||
max-delay = 20 seconds
|
||||
}
|
||||
auth-provider {
|
||||
class = PlainTextAuthProvider
|
||||
username = file-user
|
||||
password = file-password
|
||||
}
|
||||
ssl-engine-factory {
|
||||
class = DefaultSslEngineFactory
|
||||
hostname-validation = false
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Host: "url.example.com",
|
||||
Database: "url_keyspace",
|
||||
Username: "url-user",
|
||||
Password: "url-password",
|
||||
URLParams: url.Values{
|
||||
"configfile": []string{configPath},
|
||||
"requesttimeout": []string{"30s"},
|
||||
"connecttimeout": []string{"31s"},
|
||||
"consistency": []string{"QUORUM"},
|
||||
"serialconsistency": []string{"SERIAL"},
|
||||
"fetchsize": []string{"999"},
|
||||
"localdatacenter": []string{"dc-url"},
|
||||
"protocolversion": []string{"5"},
|
||||
"numconns": []string{"2"},
|
||||
"tcpnodelay": []string{"true"},
|
||||
"keepalive": []string{"false"},
|
||||
"retry": []string{"DefaultRetryPolicy"},
|
||||
"reconnection": []string{"ConstantReconnectionPolicy((long)1)"},
|
||||
"user": []string{"query-user"},
|
||||
"password": []string{"query-password"},
|
||||
}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(config.hosts, []string{"url.example.com"}) || config.keyspace != "url_keyspace" {
|
||||
t.Fatalf("configfile must not replace endpoint or keyspace: %#v", config)
|
||||
}
|
||||
if config.username != "file-user" || config.password != "file-password" {
|
||||
t.Fatalf("configfile credentials did not override URL values: %#v", config)
|
||||
}
|
||||
if config.requestTimeout != 2*time.Second || config.connectTimeout != 3*time.Second {
|
||||
t.Fatalf("unexpected configfile timeouts: request=%s connect=%s", config.requestTimeout, config.connectTimeout)
|
||||
}
|
||||
if config.consistency != "LOCAL_ONE" || config.serialConsistency != "LOCAL_SERIAL" || config.pageSize != 321 {
|
||||
t.Fatalf("unexpected request config: %#v", config)
|
||||
}
|
||||
if config.localDatacenter != "dc-config" || config.loadBalancingPolicy != "default" || config.protocolVersion != 4 {
|
||||
t.Fatalf("unexpected topology/protocol config: %#v", config)
|
||||
}
|
||||
if config.numConnections != 4 || config.tcpNoDelay || !config.keepAlive {
|
||||
t.Fatalf("unexpected connection/socket config: %#v", config)
|
||||
}
|
||||
if config.retryPolicy != "fallthrough" || config.reconnectionPolicy != "exponential" {
|
||||
t.Fatalf("unexpected policy config: %#v", config)
|
||||
}
|
||||
if config.reconnectionBaseDelay != 4*time.Second || config.reconnectionMaxDelay != 20*time.Second {
|
||||
t.Fatalf("unexpected reconnection delays: %#v", config)
|
||||
}
|
||||
if !config.ssl || config.hostVerification {
|
||||
t.Fatalf("unexpected TLS config: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCassandraConfigFileSupportsKerberosLoginOptionCasing(t *testing.T) {
|
||||
parsed, err := hocon.ParseString(`
|
||||
datastax-java-driver.advanced.auth-provider {
|
||||
class = com.instaclustr.cassandra.driver.auth.KerberosAuthProvider
|
||||
authorization-id = assumed_role
|
||||
sasl-protocol = cassandra-custom
|
||||
sasl-properties."javax.security.sasl.qop" = auth
|
||||
login-configuration {
|
||||
principal = "alice@EXAMPLE.COM"
|
||||
keyTab = "/tmp/alice.keytab"
|
||||
ticketCache = "FILE:/tmp/alice.ccache"
|
||||
useKeyTab = true
|
||||
useTicketCache = false
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := cassandraConfig{kerberos: defaultKerberosConfig()}
|
||||
if err := applyJavaDriverHOCON(&config, parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !config.kerberos.enabled || config.kerberos.principal != "alice@EXAMPLE.COM" {
|
||||
t.Fatalf("unexpected Kerberos provider config: %#v", config.kerberos)
|
||||
}
|
||||
if config.kerberos.keytabPath != "/tmp/alice.keytab" || config.kerberos.ccachePath != "FILE:/tmp/alice.ccache" {
|
||||
t.Fatalf("unexpected Kerberos file options: %#v", config.kerberos)
|
||||
}
|
||||
if !config.kerberos.useKeytab || !config.kerberos.useKeytabSet || config.kerberos.useTicketCache || !config.kerberos.useTicketCacheSet {
|
||||
t.Fatalf("unexpected Kerberos credential switches: %#v", config.kerberos)
|
||||
}
|
||||
if config.kerberos.authorizationID != "assumed_role" || config.kerberos.serviceName != "cassandra-custom" || config.kerberos.qop != "auth" {
|
||||
t.Fatalf("unexpected Kerberos SASL options: %#v", config.kerberos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingCassandraConfigFileIsIgnoredForJDBCCompatibility(t *testing.T) {
|
||||
missingPath := filepath.Join(t.TempDir(), "missing.conf")
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Host: "localhost",
|
||||
URLParams: url.Values{"configfile": []string{missingPath}, "requesttimeout": []string{"2s"}}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.requestTimeout != 2*time.Second {
|
||||
t.Fatalf("missing configfile must leave URL options intact: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func TestParseCassandraConfigSupportsLegacyJDBCOptions(t *testing.T) {
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Host: "127.0.0.1",
|
||||
Database: "app",
|
||||
Username: "cassandra",
|
||||
Password: "secret",
|
||||
URLParams: "?localdatacenter=dc1&requesttimeout=10000&connecttimeout=5s&protocolversion=4&consistency=local_quorum&numconns=4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.hosts) != 1 || config.hosts[0] != "127.0.0.1" {
|
||||
t.Fatalf("unexpected hosts: %#v", config.hosts)
|
||||
}
|
||||
if config.port != 9042 || config.keyspace != "app" {
|
||||
t.Fatalf("unexpected endpoint: port=%d keyspace=%q", config.port, config.keyspace)
|
||||
}
|
||||
if config.localDatacenter != "dc1" || config.protocolVersion != 4 {
|
||||
t.Fatalf("unexpected topology config: %#v", config)
|
||||
}
|
||||
if config.requestTimeout != 10*time.Second || config.connectTimeout != 5*time.Second {
|
||||
t.Fatalf("unexpected timeouts: request=%s connect=%s", config.requestTimeout, config.connectTimeout)
|
||||
}
|
||||
if config.numConnections != 4 || !config.disableInitialHostLookup {
|
||||
t.Fatalf("unexpected pool/tunnel config: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigAcceptsConnectionString(t *testing.T) {
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
ConnectionString: "jdbc:cassandra://alice:secret@db.example.com:9142/catalog?protocolversion=5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.hosts) != 1 || config.hosts[0] != "db.example.com:9142" || config.port != 9142 {
|
||||
t.Fatalf("unexpected endpoint: %#v", config)
|
||||
}
|
||||
if config.keyspace != "catalog" || config.username != "alice" || config.password != "secret" {
|
||||
t.Fatalf("unexpected credentials/keyspace: %#v", config)
|
||||
}
|
||||
if config.protocolVersion != 5 {
|
||||
t.Fatalf("unexpected protocol version: %d", config.protocolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigCoversMappableJDBCWrapperOptions(t *testing.T) {
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
ConnectionString: "jdbc:cassandra://host1--host2:9142/catalog?" +
|
||||
"user=query-user&password=query-secret&enablessl=true&hostnameverification=false&" +
|
||||
"tcpnodelay=false&keepalive=true&debug=true&retries=7&retry=DefaultRetryPolicy&" +
|
||||
"reconnection=ExponentialReconnectionPolicy((long)2,(long)30)&" +
|
||||
"loadbalancing=TokenAwarePolicy&compliancemode=Liquibase",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(config.hosts, []string{"host1", "host2:9142"}) || config.port != 9142 {
|
||||
t.Fatalf("unexpected multi-host endpoint: hosts=%#v port=%d", config.hosts, config.port)
|
||||
}
|
||||
if config.username != "query-user" || config.password != "query-secret" {
|
||||
t.Fatalf("unexpected query credentials: %#v", config)
|
||||
}
|
||||
if !config.ssl || config.hostVerification || config.tcpNoDelay || !config.keepAlive || !config.debug {
|
||||
t.Fatalf("unexpected transport options: %#v", config)
|
||||
}
|
||||
if config.retryPolicy != "simple" || config.retryCount != 7 || config.reconnectionPolicy != "exponential" {
|
||||
t.Fatalf("unexpected retry options: %#v", config)
|
||||
}
|
||||
if config.reconnectionBaseDelay != 2*time.Second || config.reconnectionMaxDelay != 30*time.Second {
|
||||
t.Fatalf("unexpected reconnection delays: %#v", config)
|
||||
}
|
||||
if config.loadBalancingPolicy != "token_aware" {
|
||||
t.Fatalf("unexpected load-balancing option: %#v", config)
|
||||
}
|
||||
|
||||
cluster, err := config.clusterConfig(config.keyspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dialer, ok := cluster.Dialer.(cassandraDialer)
|
||||
if !ok || dialer.tcpNoDelay || !dialer.keepAlive {
|
||||
t.Fatalf("unexpected socket dialer: %#v", cluster.Dialer)
|
||||
}
|
||||
retryPolicy, ok := cluster.RetryPolicy.(*gocql.SimpleRetryPolicy)
|
||||
if !ok || retryPolicy.NumRetries != 7 {
|
||||
t.Fatalf("unexpected query retry policy: %#v", cluster.RetryPolicy)
|
||||
}
|
||||
reconnectionPolicy, ok := cluster.ReconnectionPolicy.(*gocql.ExponentialReconnectionPolicy)
|
||||
if !ok || reconnectionPolicy.MaxRetries != 7 || reconnectionPolicy.InitialInterval != 2*time.Second || reconnectionPolicy.MaxInterval != 30*time.Second {
|
||||
t.Fatalf("unexpected reconnection policy: %#v", cluster.ReconnectionPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigUsesSecureTransportDefaults(t *testing.T) {
|
||||
config, err := parseCassandraConfig(connectParams{Host: "127.0.0.1:9042", SSL: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !config.hostVerification || !config.tcpNoDelay || config.keepAlive {
|
||||
t.Fatalf("unexpected defaults: %#v", config)
|
||||
}
|
||||
if !config.disableInitialHostLookup {
|
||||
t.Fatal("loopback host with explicit port must disable peer discovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigAcceptsDefaultSSLEngineFactory(t *testing.T) {
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Host: "localhost",
|
||||
URLParams: "sslenginefactory=com.datastax.oss.driver.internal.core.ssl.DefaultSslEngineFactory&usekrb5=false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !config.ssl {
|
||||
t.Fatal("default SSL engine factory must enable TLS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigRejectsCustomJavaImplementationClasses(t *testing.T) {
|
||||
tests := []string{
|
||||
"sslenginefactory=example.CustomSslEngineFactory",
|
||||
"loadbalancing=example.CustomPolicy",
|
||||
"retry=example.CustomRetryPolicy",
|
||||
}
|
||||
for _, urlParams := range tests {
|
||||
if _, err := parseCassandraConfig(connectParams{Host: "localhost", URLParams: urlParams}); err == nil {
|
||||
t.Fatalf("expected custom Java implementation rejection for %q", urlParams)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReconnectionPolicySupportsFullyQualifiedClass(t *testing.T) {
|
||||
policy, baseDelay, maxDelay, err := parseReconnectionPolicy(
|
||||
"com.datastax.oss.driver.internal.core.connection.ExponentialReconnectionPolicy((long)1,(long)8)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if policy != "exponential" || baseDelay != time.Second || maxDelay != 8*time.Second {
|
||||
t.Fatalf("unexpected policy: %s %s %s", policy, baseDelay, maxDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigRejectsUnsupportedLoadBalancingClass(t *testing.T) {
|
||||
_, err := parseCassandraConfig(connectParams{
|
||||
Host: "localhost",
|
||||
URLParams: "loadbalancing=example.CustomPolicy",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported load-balancing policy error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigRejectsCassandra20Protocol(t *testing.T) {
|
||||
_, err := parseCassandraConfig(connectParams{
|
||||
Host: "localhost",
|
||||
URLParams: "protocolversion=2",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected native protocol v2 rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationOptionTreatsBareNumbersAsMilliseconds(t *testing.T) {
|
||||
duration, err := parseDurationOption("1500")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if duration != 1500*time.Millisecond {
|
||||
t.Fatalf("unexpected duration: %s", duration)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cassandraKeepAlivePeriod = 30 * time.Second
|
||||
|
||||
type cassandraDialer struct {
|
||||
timeout time.Duration
|
||||
keepAlive bool
|
||||
tcpNoDelay bool
|
||||
}
|
||||
|
||||
func (dialer cassandraDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
keepAlivePeriod := time.Duration(-1)
|
||||
if dialer.keepAlive {
|
||||
keepAlivePeriod = cassandraKeepAlivePeriod
|
||||
}
|
||||
connection, err := (&net.Dialer{
|
||||
Timeout: dialer.timeout,
|
||||
KeepAlive: keepAlivePeriod,
|
||||
}).DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tcpConnection, ok := connection.(*net.TCPConn)
|
||||
if !ok {
|
||||
return connection, nil
|
||||
}
|
||||
if err := tcpConnection.SetNoDelay(dialer.tcpNoDelay); err != nil {
|
||||
connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := tcpConnection.SetKeepAlive(dialer.keepAlive); err != nil {
|
||||
connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if dialer.keepAlive {
|
||||
if err := tcpConnection.SetKeepAlivePeriod(cassandraKeepAlivePeriod); err != nil {
|
||||
connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return connection, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
module github.com/t8y2/dbx/agents/drivers/cassandra-go
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.2
|
||||
github.com/datastax/gocql-astra/v2 v2.0.1
|
||||
github.com/gurkankaymak/hocon v1.3.0
|
||||
github.com/jcmturner/gofork v1.7.6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/datastax/astra-client-go/v2 v2.2.54 // indirect
|
||||
github.com/datastax/cql-proxy v0.1.6 // indirect
|
||||
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90 // indirect
|
||||
github.com/deepmap/oapi-codegen v1.12.4 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.6.0 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.2 h1:lu/p0Db2av18enHJvWJQoChLssI0P+AR06STq4VdvCc=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.2/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/datastax/astra-client-go/v2 v2.2.54 h1:R2k9ek9zaU15cLD96np5gsj12oZhK3Z5/tSytjQagO8=
|
||||
github.com/datastax/astra-client-go/v2 v2.2.54/go.mod h1:zxXWuqDkYia7PzFIL3T7RmjChc9LN81UnfI2yB4kE7M=
|
||||
github.com/datastax/cql-proxy v0.1.6 h1:IFJ/QV5Hk25CVaqVzPAz9o3ZsczZKKE3htpeNk3/e9o=
|
||||
github.com/datastax/cql-proxy v0.1.6/go.mod h1:AgfO39+s+6oSwMETjysTnr5dhDArYfiDoMz0J9RG72I=
|
||||
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90 h1:SiFe3gwoHPt95ly6HLjwyyItxROxCUJuxqqTnguR5ac=
|
||||
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90/go.mod h1:6FzirJfdffakAVqmHjwVfFkpru/gNbIazUOK5rIhndc=
|
||||
github.com/datastax/gocql-astra/v2 v2.0.1 h1:yZo7pN51mn/D8LlA7FXFrkYeEw2OrsbGcAZLfY9dx6M=
|
||||
github.com/datastax/gocql-astra/v2 v2.0.1/go.mod h1:dnBksaFCHOIHWqVqDploY3/J8G+0PO/9193VBphW0cA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP7g5Q2s=
|
||||
github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas=
|
||||
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gurkankaymak/hocon v1.3.0 h1:OPw9XP+c3o4JGBVIS5e4+84YNfFFFgKQ625LX5tjSyQ=
|
||||
github.com/gurkankaymak/hocon v1.3.0/go.mod h1:CM7yeDDq8AUU7I+QiLA7aVSJVbP1BVgZqqCiuzgcNS0=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4=
|
||||
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.8.0 h1:CUhrE4N1rqSE6FM9ecihEjRkLQu8cDfgDyoOs83mEY4=
|
||||
go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCassandraIntegration(t *testing.T) {
|
||||
host := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_HOST"))
|
||||
if host == "" {
|
||||
t.Skip("Cassandra integration environment is not configured")
|
||||
}
|
||||
port := 9042
|
||||
if rawPort := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_PORT")); rawPort != "" {
|
||||
parsedPort, err := strconv.Atoi(rawPort)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port = parsedPort
|
||||
}
|
||||
ssl, err := strconv.ParseBool(envDefault("CASSANDRA_TEST_SSL", "false"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection := connectParams{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: os.Getenv("CASSANDRA_TEST_USERNAME"),
|
||||
Password: os.Getenv("CASSANDRA_TEST_PASSWORD"),
|
||||
URLParams: os.Getenv("CASSANDRA_TEST_URL_PARAMS"),
|
||||
SSL: ssl,
|
||||
CACertPath: os.Getenv("CASSANDRA_TEST_CA_CERT_PATH"),
|
||||
ClientCertPath: os.Getenv("CASSANDRA_TEST_CLIENT_CERT_PATH"),
|
||||
ClientKeyPath: os.Getenv("CASSANDRA_TEST_CLIENT_KEY_PATH"),
|
||||
}
|
||||
runtime, err := newConnectionRuntime(connection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.close()
|
||||
server := newServer(runtime, connection)
|
||||
if err := server.validateConnection(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
keyspace := "dbx_native_it_" + suffix
|
||||
table := "all_types"
|
||||
pagedTable := "paged_rows"
|
||||
mustCQL(t, server, "CREATE KEYSPACE "+quoteCQLIdentifier(keyspace)+" WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", "")
|
||||
t.Cleanup(func() {
|
||||
_, _ = server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)})
|
||||
})
|
||||
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, table)+" ("+
|
||||
"id int PRIMARY KEY, txt text, flag boolean, amount decimal, payload blob, created timestamp, address inet, "+
|
||||
"tags set<text>, items list<int>, attrs map<text, int>, pair frozen<tuple<int, text>>)", keyspace)
|
||||
mustCQL(t, server, "CREATE INDEX "+quoteCQLIdentifier(table+"_txt_idx")+" ON "+qualifiedCQLName(keyspace, table)+" (txt)", keyspace)
|
||||
mustCQL(t, server, "INSERT INTO "+qualifiedCQLName(keyspace, table)+" "+
|
||||
"(id, txt, flag, amount, payload, created, address, tags, items, attrs, pair) VALUES "+
|
||||
"(1, 'hello', true, 12.34, 0x00ff, '2026-08-03T00:00:00Z', '127.0.0.1', {'a', 'b'}, [1, 2], {'a': 1}, (7, 'seven'))", keyspace)
|
||||
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, pagedTable)+" (id int PRIMARY KEY, txt text)", keyspace)
|
||||
|
||||
for start := 0; start < 250; start += 50 {
|
||||
statements := make([]string, 0, 50)
|
||||
for id := start; id < start+50; id++ {
|
||||
statements = append(statements, fmt.Sprintf("INSERT INTO %s (id, txt) VALUES (%d, 'row-%d')", qualifiedCQLName(keyspace, pagedTable), id, id))
|
||||
}
|
||||
mustStatements(t, server, keyspace, statements, false)
|
||||
}
|
||||
mustStatements(t, server, keyspace, []string{
|
||||
"INSERT INTO " + qualifiedCQLName(keyspace, pagedTable) + " (id, txt) VALUES (1001, 'unlogged')",
|
||||
}, false)
|
||||
mustStatements(t, server, keyspace, []string{
|
||||
"INSERT INTO " + qualifiedCQLName(keyspace, pagedTable) + " (id, txt) VALUES (1002, 'logged')",
|
||||
}, true)
|
||||
|
||||
connectionInfo, err := server.connectionInfo()
|
||||
if err != nil || strings.TrimSpace(fmt.Sprint(connectionInfo["database_version"])) == "" {
|
||||
t.Fatalf("connection info failed: info=%v err=%v", connectionInfo, err)
|
||||
}
|
||||
databases, err := server.listDatabases()
|
||||
if err != nil || !containsDatabase(databases, keyspace) {
|
||||
t.Fatalf("keyspace metadata missing: databases=%v err=%v", databases, err)
|
||||
}
|
||||
tables, err := server.listTables(keyspace, metadataListConstraints{})
|
||||
if err != nil || !containsTable(tables, table) || !containsTable(tables, pagedTable) {
|
||||
t.Fatalf("table metadata missing: tables=%v err=%v", tables, err)
|
||||
}
|
||||
columns, err := server.getColumns(keyspace, table)
|
||||
if err != nil || len(columns) != 11 || !containsPrimaryKeyColumn(columns, "id") {
|
||||
t.Fatalf("column metadata mismatch: columns=%v err=%v", columns, err)
|
||||
}
|
||||
indexes, err := server.listIndexes(keyspace, table)
|
||||
if err != nil || !containsIndex(indexes, table+"_txt_idx") {
|
||||
t.Fatalf("index metadata missing: indexes=%v err=%v", indexes, err)
|
||||
}
|
||||
ddl, err := server.getTableDDL(keyspace, table)
|
||||
if err != nil || !strings.Contains(ddl, "tuple<int, text>") || !strings.Contains(ddl, "PRIMARY KEY") {
|
||||
t.Fatalf("table DDL mismatch: ddl=%q err=%v", ddl, err)
|
||||
}
|
||||
result, err := server.executeQuery(queryOptions{
|
||||
SQL: "SELECT * FROM " + qualifiedCQLName(keyspace, table) + " WHERE id = 1",
|
||||
Schema: keyspace,
|
||||
})
|
||||
if err != nil || len(result.Rows) != 1 || len(result.Rows[0]) != len(result.Columns) {
|
||||
t.Fatalf("all-types query failed: result=%v err=%v", result, err)
|
||||
}
|
||||
for _, value := range result.Rows[0] {
|
||||
if value != nil {
|
||||
if _, ok := value.(string); !ok {
|
||||
t.Fatalf("legacy result contract requires strings, got %T (%v)", value, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page, err := server.executeQueryPage(queryOptions{
|
||||
SQL: "SELECT id, txt FROM " + qualifiedCQLName(keyspace, pagedTable),
|
||||
Schema: keyspace,
|
||||
MaxRows: 250,
|
||||
}, 100)
|
||||
if err != nil || len(page.Rows) != 100 || !page.HasMore || page.SessionID == nil {
|
||||
t.Fatalf("first page mismatch: page=%v err=%v", page, err)
|
||||
}
|
||||
totalRows := len(page.Rows)
|
||||
for page.HasMore {
|
||||
page, err = server.fetchQueryPage(*page.SessionID, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
totalRows += len(page.Rows)
|
||||
}
|
||||
if totalRows != 250 {
|
||||
t.Fatalf("unexpected paged row count: %d", totalRows)
|
||||
}
|
||||
}
|
||||
|
||||
func envDefault(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func qualifiedCQLName(keyspace, object string) string {
|
||||
return quoteCQLIdentifier(keyspace) + "." + quoteCQLIdentifier(object)
|
||||
}
|
||||
|
||||
func mustCQL(t *testing.T, server *server, sql, keyspace string) {
|
||||
t.Helper()
|
||||
if _, err := server.executeQuery(queryOptions{SQL: sql, Schema: keyspace}); err != nil {
|
||||
t.Fatalf("execute %q: %v", sql, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStatements(t *testing.T, server *server, keyspace string, statements []string, transactional bool) {
|
||||
t.Helper()
|
||||
rawStatements, _ := json.Marshal(statements)
|
||||
rawSchema, _ := json.Marshal(keyspace)
|
||||
if _, err := server.executeStatements(map[string]json.RawMessage{
|
||||
"schema": rawSchema,
|
||||
"statements": rawStatements,
|
||||
}, transactional); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsDatabase(databases []databaseInfo, name string) bool {
|
||||
for _, database := range databases {
|
||||
if database.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsTable(tables []tableInfo, name string) bool {
|
||||
for _, table := range tables {
|
||||
if table.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsIndex(indexes []indexInfo, name string) bool {
|
||||
for _, index := range indexes {
|
||||
if index.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsPrimaryKeyColumn(columns []columnInfo, name string) bool {
|
||||
for _, column := range columns {
|
||||
if column.Name == name && column.IsPrimaryKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,634 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/jcmturner/gofork/encoding/asn1"
|
||||
"github.com/jcmturner/gokrb5/v8/asn1tools"
|
||||
krb5client "github.com/jcmturner/gokrb5/v8/client"
|
||||
krb5config "github.com/jcmturner/gokrb5/v8/config"
|
||||
"github.com/jcmturner/gokrb5/v8/credentials"
|
||||
"github.com/jcmturner/gokrb5/v8/gssapi"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/chksumtype"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/keyusage"
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/jcmturner/gokrb5/v8/messages"
|
||||
"github.com/jcmturner/gokrb5/v8/types"
|
||||
)
|
||||
|
||||
const (
|
||||
kerberosAPRequestTokenID = 0x0100
|
||||
kerberosGSSAPITag = 0x60
|
||||
kerberosSecurityNone = 0x01
|
||||
)
|
||||
|
||||
type kerberosCredentialMode int
|
||||
|
||||
const (
|
||||
kerberosCredentialNone kerberosCredentialMode = iota
|
||||
kerberosCredentialPassword
|
||||
kerberosCredentialKeytab
|
||||
kerberosCredentialCCache
|
||||
)
|
||||
|
||||
type kerberosConfig struct {
|
||||
enabled bool
|
||||
configPath string
|
||||
jaasConfigPath string
|
||||
principal string
|
||||
realm string
|
||||
keytabPath string
|
||||
ccachePath string
|
||||
password string
|
||||
serviceName string
|
||||
serverName string
|
||||
authorizationID string
|
||||
qop string
|
||||
disablePAFXFAST bool
|
||||
useKeytab bool
|
||||
useKeytabSet bool
|
||||
useTicketCache bool
|
||||
useTicketCacheSet bool
|
||||
credentialMode kerberosCredentialMode
|
||||
credentialUser string
|
||||
credentialRealm string
|
||||
}
|
||||
|
||||
type kerberosAuthenticator struct {
|
||||
domain string
|
||||
clientName types.PrincipalName
|
||||
ticket messages.Ticket
|
||||
sessionKey types.EncryptionKey
|
||||
authorizationID string
|
||||
step int
|
||||
}
|
||||
|
||||
var (
|
||||
jaasBlockPattern = regexp.MustCompile(`(?is)\bCassandraJavaClient\s*\{(.*?)\}\s*;`)
|
||||
jaasModulePattern = regexp.MustCompile(`(?is)\bcom\.sun\.security\.auth\.module\.Krb5LoginModule\b(.*?);`)
|
||||
jaasOptionPattern = regexp.MustCompile(`(?is)([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s;]+))`)
|
||||
)
|
||||
|
||||
func defaultKerberosConfig() kerberosConfig {
|
||||
return kerberosConfig{
|
||||
serviceName: "cassandra",
|
||||
qop: "auth",
|
||||
}
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) finalize(username, password string) error {
|
||||
config.applyJavaSystemProperties()
|
||||
if config.jaasConfigPath != "" {
|
||||
path, err := normalizeLocalFilePath(config.jaasConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra JAAS config path: %w", err)
|
||||
}
|
||||
config.jaasConfigPath = path
|
||||
if err := config.applyJAASConfig(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config.applyKerberosConfigEnvironment()
|
||||
if config.configPath == "" {
|
||||
config.configPath = defaultKerberosConfigPath()
|
||||
}
|
||||
path, err := normalizeLocalFilePath(firstPathListEntry(config.configPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Kerberos config path: %w", err)
|
||||
}
|
||||
config.configPath = path
|
||||
if err := requireRegularFile("Kerberos config", config.configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
krbConfig, err := krb5config.Load(config.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Kerberos config %s: %w", config.configPath, err)
|
||||
}
|
||||
if config.serviceName == "" {
|
||||
config.serviceName = "cassandra"
|
||||
}
|
||||
if !kerberosQOPIncludesAuth(config.qop) {
|
||||
return fmt.Errorf("Cassandra Kerberos currently supports SASL QOP auth only, got %s", config.qop)
|
||||
}
|
||||
config.qop = "auth"
|
||||
if config.principal == "" {
|
||||
config.principal = strings.TrimSpace(username)
|
||||
}
|
||||
if config.password == "" {
|
||||
config.password = password
|
||||
}
|
||||
if config.useTicketCache {
|
||||
return config.selectCCacheCredential()
|
||||
}
|
||||
if config.useKeytab {
|
||||
return config.selectKeytabCredential(krbConfig)
|
||||
}
|
||||
if config.ccachePath != "" && !config.useTicketCacheSet {
|
||||
return config.selectCCacheCredential()
|
||||
}
|
||||
if config.keytabPath != "" && !config.useKeytabSet {
|
||||
return config.selectKeytabCredential(krbConfig)
|
||||
}
|
||||
if config.principal != "" && config.password != "" {
|
||||
config.credentialUser, config.credentialRealm, err = splitKerberosPrincipal(
|
||||
config.principal,
|
||||
config.realm,
|
||||
krbConfig.LibDefaults.DefaultRealm,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.credentialMode = kerberosCredentialPassword
|
||||
return nil
|
||||
}
|
||||
if !config.useTicketCacheSet {
|
||||
config.ccachePath = os.Getenv("KRB5CCNAME")
|
||||
if config.ccachePath == "" {
|
||||
defaultCache := defaultKerberosCCachePath()
|
||||
if path, normalizeErr := normalizeKerberosCachePath(defaultCache); normalizeErr == nil {
|
||||
if info, statErr := os.Stat(path); statErr == nil && info.Mode().IsRegular() {
|
||||
config.ccachePath = defaultCache
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.ccachePath != "" {
|
||||
return config.selectCCacheCredential()
|
||||
}
|
||||
}
|
||||
if !config.useKeytabSet {
|
||||
config.keytabPath = firstNonEmpty(os.Getenv("KRB5_CLIENT_KTNAME"), os.Getenv("KRB5_KTNAME"))
|
||||
if config.keytabPath != "" {
|
||||
return config.selectKeytabCredential(krbConfig)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("Kerberos authentication requires a credential cache, keytab, or principal and password")
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) selectCCacheCredential() error {
|
||||
var err error
|
||||
if config.ccachePath == "" {
|
||||
config.ccachePath = defaultKerberosCCachePath()
|
||||
}
|
||||
config.ccachePath, err = normalizeKerberosCachePath(config.ccachePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireRegularFile("Kerberos credential cache", config.ccachePath); err != nil {
|
||||
return err
|
||||
}
|
||||
config.credentialMode = kerberosCredentialCCache
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) selectKeytabCredential(krbConfig *krb5config.Config) error {
|
||||
var err error
|
||||
if config.keytabPath == "" {
|
||||
config.keytabPath = firstNonEmpty(os.Getenv("KRB5_CLIENT_KTNAME"), os.Getenv("KRB5_KTNAME"))
|
||||
if config.keytabPath == "" {
|
||||
return fmt.Errorf("Kerberos keytab authentication requires a keytab path")
|
||||
}
|
||||
}
|
||||
config.keytabPath, err = normalizeKerberosFileReference(config.keytabPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireRegularFile("Kerberos keytab", config.keytabPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.principal == "" {
|
||||
config.principal, err = principalFromKeytab(config.keytabPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config.credentialUser, config.credentialRealm, err = splitKerberosPrincipal(
|
||||
config.principal,
|
||||
config.realm,
|
||||
krbConfig.LibDefaults.DefaultRealm,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.credentialMode = kerberosCredentialKeytab
|
||||
return nil
|
||||
}
|
||||
|
||||
func newKerberosAuthProvider(
|
||||
config kerberosConfig,
|
||||
username string,
|
||||
password string,
|
||||
) (func(*gocql.HostInfo) (gocql.Authenticator, error), error) {
|
||||
if !config.enabled {
|
||||
return nil, fmt.Errorf("Kerberos authentication is not enabled")
|
||||
}
|
||||
if config.credentialMode == kerberosCredentialNone {
|
||||
if err := config.finalize(username, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
krbConfig, err := krb5config.Load(config.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Kerberos config %s: %w", config.configPath, err)
|
||||
}
|
||||
return func(host *gocql.HostInfo) (gocql.Authenticator, error) {
|
||||
return newKerberosAuthenticator(config, krbConfig, host)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newKerberosAuthenticator(
|
||||
config kerberosConfig,
|
||||
krbConfig *krb5config.Config,
|
||||
host *gocql.HostInfo,
|
||||
) (gocql.Authenticator, error) {
|
||||
client, err := newKerberosClient(config, krbConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.Login(); err != nil {
|
||||
client.Destroy()
|
||||
return nil, fmt.Errorf("Kerberos login failed: %w", err)
|
||||
}
|
||||
serverName, err := kerberosServerName(config, host)
|
||||
if err != nil {
|
||||
client.Destroy()
|
||||
return nil, err
|
||||
}
|
||||
servicePrincipal := config.serviceName + "/" + serverName
|
||||
ticket, sessionKey, err := client.GetServiceTicket(servicePrincipal)
|
||||
if err != nil {
|
||||
client.Destroy()
|
||||
return nil, fmt.Errorf("get Kerberos service ticket for %s: %w", servicePrincipal, err)
|
||||
}
|
||||
clientName := client.Credentials.CName()
|
||||
clientName.NameString = append([]string(nil), clientName.NameString...)
|
||||
authenticator := &kerberosAuthenticator{
|
||||
domain: strings.Clone(client.Credentials.Domain()),
|
||||
clientName: clientName,
|
||||
ticket: ticket,
|
||||
sessionKey: sessionKey,
|
||||
authorizationID: config.authorizationID,
|
||||
}
|
||||
client.Destroy()
|
||||
return authenticator, nil
|
||||
}
|
||||
|
||||
func newKerberosClient(config kerberosConfig, krbConfig *krb5config.Config) (*krb5client.Client, error) {
|
||||
settings := []func(*krb5client.Settings){krb5client.DisablePAFXFAST(config.disablePAFXFAST)}
|
||||
switch config.credentialMode {
|
||||
case kerberosCredentialCCache:
|
||||
cache, err := credentials.LoadCCache(config.ccachePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Kerberos credential cache %s: %w", config.ccachePath, err)
|
||||
}
|
||||
client, err := krb5client.NewFromCCache(cache, krbConfig, settings...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Kerberos client from credential cache: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
case kerberosCredentialKeytab:
|
||||
loadedKeytab, err := keytab.Load(config.keytabPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Kerberos keytab %s: %w", config.keytabPath, err)
|
||||
}
|
||||
return krb5client.NewWithKeytab(
|
||||
config.credentialUser,
|
||||
config.credentialRealm,
|
||||
loadedKeytab,
|
||||
krbConfig,
|
||||
settings...,
|
||||
), nil
|
||||
case kerberosCredentialPassword:
|
||||
return krb5client.NewWithPassword(
|
||||
config.credentialUser,
|
||||
config.credentialRealm,
|
||||
config.password,
|
||||
krbConfig,
|
||||
settings...,
|
||||
), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("Kerberos credentials are not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) Challenge(request []byte) ([]byte, gocql.Authenticator, error) {
|
||||
switch authenticator.step {
|
||||
case 0:
|
||||
token, err := authenticator.initialToken()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
authenticator.step = 1
|
||||
return token, authenticator, nil
|
||||
case 1:
|
||||
token, err := authenticator.securityLayerResponse(request)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
authenticator.step = 2
|
||||
return token, authenticator, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unexpected Cassandra Kerberos authentication challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) Success(_ []byte) error {
|
||||
if authenticator.step != 2 {
|
||||
return fmt.Errorf("Cassandra reported Kerberos success before SASL negotiation completed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) initialToken() ([]byte, error) {
|
||||
value, err := types.NewAuthenticator(authenticator.domain, authenticator.clientName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.Cksum = types.Checksum{
|
||||
CksumType: chksumtype.GSSAPI,
|
||||
Checksum: kerberosAuthenticatorChecksum(),
|
||||
}
|
||||
request, err := messages.NewAPReq(authenticator.ticket, authenticator.sessionKey, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(payload, kerberosAPRequestTokenID)
|
||||
encodedRequest, err := request.Marshal()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload = append(payload, encodedRequest...)
|
||||
encodedOID, err := asn1.Marshal(gssapi.OIDKRB5.OID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := append([]byte{kerberosGSSAPITag}, asn1tools.MarshalLengthBytes(len(encodedOID)+len(payload))...)
|
||||
header = append(header, encodedOID...)
|
||||
return append(header, payload...), nil
|
||||
}
|
||||
|
||||
func (authenticator *kerberosAuthenticator) securityLayerResponse(challenge []byte) ([]byte, error) {
|
||||
var request gssapi.WrapToken
|
||||
if err := request.Unmarshal(challenge, true); err != nil {
|
||||
return nil, fmt.Errorf("decode Kerberos SASL security-layer challenge: %w", err)
|
||||
}
|
||||
valid, err := request.Verify(authenticator.sessionKey, keyusage.GSSAPI_ACCEPTOR_SEAL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify Kerberos SASL security-layer challenge: %w", err)
|
||||
}
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("invalid Kerberos SASL security-layer challenge")
|
||||
}
|
||||
if len(request.Payload) < 4 || request.Payload[0]&kerberosSecurityNone == 0 {
|
||||
return nil, fmt.Errorf("Cassandra Kerberos server does not allow SASL QOP auth")
|
||||
}
|
||||
payload := []byte{kerberosSecurityNone, 0, 0, 0}
|
||||
payload = append(payload, authenticator.authorizationID...)
|
||||
response, err := gssapi.NewInitiatorWrapToken(payload, authenticator.sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Marshal()
|
||||
}
|
||||
|
||||
func kerberosAuthenticatorChecksum() []byte {
|
||||
checksum := make([]byte, 24)
|
||||
binary.LittleEndian.PutUint32(checksum[:4], 16)
|
||||
flags := uint32(gssapi.ContextFlagInteg | gssapi.ContextFlagConf)
|
||||
binary.LittleEndian.PutUint32(checksum[20:24], flags)
|
||||
return checksum
|
||||
}
|
||||
|
||||
func kerberosQOPIncludesAuth(value string) bool {
|
||||
for _, qop := range strings.Split(value, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(qop), "auth") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kerberosServerName(config kerberosConfig, host *gocql.HostInfo) (string, error) {
|
||||
if config.serverName != "" {
|
||||
return strings.TrimSuffix(strings.TrimSpace(config.serverName), "."), nil
|
||||
}
|
||||
if host == nil {
|
||||
return "", fmt.Errorf("resolve Kerberos server name: Cassandra host is unavailable")
|
||||
}
|
||||
address := host.ConnectAddress()
|
||||
if address != nil {
|
||||
names, err := net.LookupAddr(address.String())
|
||||
if err == nil && len(names) > 0 {
|
||||
return strings.TrimSuffix(strings.TrimSpace(names[0]), "."), nil
|
||||
}
|
||||
}
|
||||
hostname, _, err := net.SplitHostPort(host.HostnameAndPort())
|
||||
if err == nil && hostname != "" && net.ParseIP(hostname) == nil {
|
||||
return strings.TrimSuffix(hostname, "."), nil
|
||||
}
|
||||
return "", fmt.Errorf("resolve Kerberos server name for Cassandra host %s; configure kerberosservername explicitly", host.ConnectAddressAndPort())
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) applyJavaSystemProperties() {
|
||||
if config.jaasConfigPath == "" {
|
||||
config.jaasConfigPath = javaSystemProperty("java.security.auth.login.config")
|
||||
}
|
||||
if config.configPath == "" {
|
||||
config.configPath = javaSystemProperty("java.security.krb5.conf")
|
||||
}
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) applyKerberosConfigEnvironment() {
|
||||
if config.configPath == "" {
|
||||
config.configPath = os.Getenv("KRB5_CONFIG")
|
||||
}
|
||||
}
|
||||
|
||||
func (config *kerberosConfig) applyJAASConfig(path string) error {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Cassandra JAAS config %s: %w", path, err)
|
||||
}
|
||||
block := jaasBlockPattern.FindSubmatch(contents)
|
||||
if len(block) != 2 {
|
||||
return fmt.Errorf("Cassandra JAAS config %s does not contain CassandraJavaClient", path)
|
||||
}
|
||||
module := jaasModulePattern.FindSubmatch(block[1])
|
||||
if len(module) != 2 {
|
||||
return fmt.Errorf("CassandraJavaClient in %s does not configure Krb5LoginModule", path)
|
||||
}
|
||||
options := map[string]string{}
|
||||
for _, match := range jaasOptionPattern.FindAllSubmatch(module[1], -1) {
|
||||
value := firstNonEmpty(string(match[2]), string(match[3]), string(match[4]))
|
||||
options[strings.ToLower(string(match[1]))] = value
|
||||
}
|
||||
if config.principal == "" {
|
||||
config.principal = options["principal"]
|
||||
}
|
||||
if config.keytabPath == "" {
|
||||
config.keytabPath = options["keytab"]
|
||||
}
|
||||
if config.ccachePath == "" {
|
||||
config.ccachePath = options["ticketcache"]
|
||||
}
|
||||
if value, ok := options["usekeytab"]; ok {
|
||||
config.useKeytab, err = strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid useKeyTab in Cassandra JAAS config: %w", err)
|
||||
}
|
||||
config.useKeytabSet = true
|
||||
}
|
||||
if value, ok := options["useticketcache"]; ok {
|
||||
config.useTicketCache, err = strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid useTicketCache in Cassandra JAAS config: %w", err)
|
||||
}
|
||||
config.useTicketCacheSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func javaSystemProperty(name string) string {
|
||||
pattern := regexp.MustCompile(`(?:^|\s)-D` + regexp.QuoteMeta(name) + `=(?:"([^"]*)"|'([^']*)'|(\S+))`)
|
||||
for _, environmentName := range []string{"JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"} {
|
||||
match := pattern.FindStringSubmatch(os.Getenv(environmentName))
|
||||
if len(match) == 4 {
|
||||
return firstNonEmpty(match[1], match[2], match[3])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeKerberosCachePath(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("Kerberos ticket cache path is empty")
|
||||
}
|
||||
if separator := strings.IndexByte(value, ':'); separator > 0 && !isWindowsDrivePath(value) {
|
||||
cacheType := strings.ToUpper(value[:separator])
|
||||
if cacheType != "FILE" {
|
||||
return "", fmt.Errorf("Kerberos credential cache type %s is not supported; use a FILE cache or keytab", cacheType)
|
||||
}
|
||||
value = value[separator+1:]
|
||||
}
|
||||
path, err := normalizeLocalFilePath(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Kerberos credential cache path: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isWindowsDrivePath(value string) bool {
|
||||
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) &&
|
||||
value[1] == ':' && (value[2] == '\\' || value[2] == '/')
|
||||
}
|
||||
|
||||
func normalizeKerberosFileReference(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(strings.ToUpper(value), "FILE:") {
|
||||
value = value[5:]
|
||||
}
|
||||
path, err := normalizeLocalFilePath(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Kerberos file path: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func splitKerberosPrincipal(principal, configuredRealm, defaultRealm string) (string, string, error) {
|
||||
value := strings.TrimSpace(principal)
|
||||
realm := strings.TrimSpace(configuredRealm)
|
||||
if separator := strings.LastIndexByte(value, '@'); separator >= 0 {
|
||||
realm = value[separator+1:]
|
||||
value = value[:separator]
|
||||
}
|
||||
if value == "" {
|
||||
return "", "", fmt.Errorf("Kerberos principal is empty")
|
||||
}
|
||||
if realm == "" {
|
||||
realm = strings.TrimSpace(defaultRealm)
|
||||
}
|
||||
if realm == "" {
|
||||
return "", "", fmt.Errorf("Kerberos realm is required for principal %s", principal)
|
||||
}
|
||||
return value, realm, nil
|
||||
}
|
||||
|
||||
func principalFromKeytab(path string) (string, error) {
|
||||
loadedKeytab, err := keytab.Load(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load Kerberos keytab %s: %w", path, err)
|
||||
}
|
||||
principals := map[string]struct{}{}
|
||||
for _, entry := range loadedKeytab.Entries {
|
||||
principals[entry.Principal.String()] = struct{}{}
|
||||
}
|
||||
if len(principals) != 1 {
|
||||
return "", fmt.Errorf("Kerberos keytab %s contains %d principals; configure kerberosprincipal explicitly", path, len(principals))
|
||||
}
|
||||
for principal := range principals {
|
||||
return principal, nil
|
||||
}
|
||||
return "", fmt.Errorf("Kerberos keytab %s contains no principals", path)
|
||||
}
|
||||
|
||||
func defaultKerberosConfigPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if windowsDirectory := os.Getenv("WINDIR"); windowsDirectory != "" {
|
||||
return filepath.Join(windowsDirectory, "krb5.ini")
|
||||
}
|
||||
}
|
||||
return "/etc/krb5.conf"
|
||||
}
|
||||
|
||||
func defaultKerberosCCachePath() string {
|
||||
if value := os.Getenv("KRB5CCNAME"); value != "" {
|
||||
return value
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err == nil && currentUser.Uid != "" {
|
||||
return filepath.Join(os.TempDir(), "krb5cc_"+currentUser.Uid)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPathListEntry(value string) string {
|
||||
entries := filepath.SplitList(value)
|
||||
if len(entries) == 0 {
|
||||
return value
|
||||
}
|
||||
return entries[0]
|
||||
}
|
||||
|
||||
func requireRegularFile(label, path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("%s path is empty", label)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s %s: %w", label, path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s is not a regular file: %s", label, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/jcmturner/gofork/encoding/asn1"
|
||||
"github.com/jcmturner/gokrb5/v8/gssapi"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/keyusage"
|
||||
"github.com/jcmturner/gokrb5/v8/messages"
|
||||
"github.com/jcmturner/gokrb5/v8/types"
|
||||
)
|
||||
|
||||
func TestKerberosPasswordCredentialsTakePrecedenceOverEnvironmentCache(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
t.Setenv("KRB5CCNAME", filepath.Join(t.TempDir(), "missing.ccache"))
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = writeKerberosConfig(t)
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "secret"
|
||||
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialPassword || config.credentialUser != "alice" || config.credentialRealm != "EXAMPLE.COM" {
|
||||
t.Fatalf("unexpected password credential selection: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosExplicitCredentialSourcesTakePrecedence(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
krb5Path := writeKerberosConfig(t)
|
||||
cachePath := writeTestFile(t, "alice.ccache", "placeholder")
|
||||
keytabPath := writeTestFile(t, "alice.keytab", "placeholder")
|
||||
|
||||
t.Run("ccache", func(t *testing.T) {
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = krb5Path
|
||||
config.ccachePath = "FILE:" + cachePath
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "ignored"
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialCCache || config.ccachePath != cachePath {
|
||||
t.Fatalf("unexpected ccache credential selection: %#v", config)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keytab", func(t *testing.T) {
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = krb5Path
|
||||
config.keytabPath = "FILE:" + keytabPath
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "ignored"
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialKeytab || config.keytabPath != keytabPath {
|
||||
t.Fatalf("unexpected keytab credential selection: %#v", config)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKerberosDiscoversJavaJAASAndKrb5Properties(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
krb5Path := writeKerberosConfig(t)
|
||||
cachePath := writeTestFile(t, "alice.ccache", "placeholder")
|
||||
jaasPath := writeTestFile(t, "jaas.conf", `
|
||||
CassandraJavaClient {
|
||||
com.sun.security.auth.module.Krb5LoginModule required
|
||||
useTicketCache=true
|
||||
ticketCache="FILE:`+cachePath+`"
|
||||
principal="alice@EXAMPLE.COM";
|
||||
};
|
||||
`)
|
||||
t.Setenv("JAVA_TOOL_OPTIONS", `-Djava.security.krb5.conf="`+krb5Path+`" -Djava.security.auth.login.config='`+jaasPath+`'`)
|
||||
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.configPath != krb5Path || config.jaasConfigPath != jaasPath {
|
||||
t.Fatalf("Java system properties were not applied: %#v", config)
|
||||
}
|
||||
if config.credentialMode != kerberosCredentialCCache || config.ccachePath != cachePath || config.principal != "alice@EXAMPLE.COM" {
|
||||
t.Fatalf("JAAS credential cache was not applied: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosRejectsUnsupportedQOP(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = writeKerberosConfig(t)
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "secret"
|
||||
config.qop = "auth-conf"
|
||||
|
||||
err := config.finalize("", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "supports SASL QOP auth only") {
|
||||
t.Fatalf("expected QOP rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosAcceptsQOPPreferenceListContainingAuth(t *testing.T) {
|
||||
clearKerberosEnvironment(t)
|
||||
config := defaultKerberosConfig()
|
||||
config.enabled = true
|
||||
config.configPath = writeKerberosConfig(t)
|
||||
config.principal = "alice@EXAMPLE.COM"
|
||||
config.password = "secret"
|
||||
config.qop = "auth-conf, auth"
|
||||
|
||||
if err := config.finalize("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.qop != "auth" {
|
||||
t.Fatalf("unexpected negotiated QOP preference: %q", config.qop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKerberosCachePathSupportsWindowsDrivePaths(t *testing.T) {
|
||||
path, err := normalizeKerberosCachePath(`C:\Users\alice\krb5cc`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != `C:\Users\alice\krb5cc` {
|
||||
t.Fatalf("unexpected Windows cache path: %q", path)
|
||||
}
|
||||
if _, err := normalizeKerberosCachePath("DIR:/tmp/krb5cc"); err == nil {
|
||||
t.Fatal("expected non-FILE credential cache type rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosServerNameSupportsExplicitOverride(t *testing.T) {
|
||||
name, err := kerberosServerName(kerberosConfig{serverName: "node1.example.com."}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "node1.example.com" {
|
||||
t.Fatalf("unexpected explicit server name: %q", name)
|
||||
}
|
||||
if _, err := kerberosServerName(kerberosConfig{}, nil); err == nil {
|
||||
t.Fatal("expected missing host error")
|
||||
}
|
||||
|
||||
host, err := gocql.NewHostInfoFromAddrPort(net.ParseIP("127.0.0.1"), 9042)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved, err := kerberosServerName(kerberosConfig{}, host); err != nil || strings.TrimSpace(resolved) == "" {
|
||||
t.Fatalf("expected loopback canonical name, got %q, %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosInitialTokenContainsDecryptableAPRequest(t *testing.T) {
|
||||
key := testKerberosEncryptionKey()
|
||||
authenticator := kerberosAuthenticator{
|
||||
domain: "EXAMPLE.COM",
|
||||
clientName: types.NewPrincipalName(1, "alice"),
|
||||
ticket: messages.Ticket{
|
||||
TktVNO: 5,
|
||||
Realm: "EXAMPLE.COM",
|
||||
SName: types.NewPrincipalName(2, "cassandra/node1.example.com"),
|
||||
EncPart: types.EncryptedData{
|
||||
EType: key.KeyType,
|
||||
KVNO: 1,
|
||||
Cipher: []byte{1},
|
||||
},
|
||||
},
|
||||
sessionKey: key,
|
||||
}
|
||||
|
||||
token, err := authenticator.initialToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) < 2 || token[0] != kerberosGSSAPITag {
|
||||
t.Fatalf("unexpected GSSAPI token prefix: %x", token)
|
||||
}
|
||||
encodedOID, err := asn1.Marshal(gssapi.OIDKRB5.OID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oidOffset := bytes.Index(token, encodedOID)
|
||||
if oidOffset < 0 {
|
||||
t.Fatalf("Kerberos OID missing from token: %x", token)
|
||||
}
|
||||
payload := token[oidOffset+len(encodedOID):]
|
||||
if len(payload) < 3 || binary.BigEndian.Uint16(payload[:2]) != kerberosAPRequestTokenID {
|
||||
t.Fatalf("unexpected Kerberos mechanism token: %x", payload)
|
||||
}
|
||||
var request messages.APReq
|
||||
if err := request.Unmarshal(payload[2:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := request.DecryptAuthenticator(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.Authenticator.CName.PrincipalNameString() != "alice" || request.Authenticator.CRealm != "EXAMPLE.COM" {
|
||||
t.Fatalf("unexpected AP-REQ authenticator: %#v", request.Authenticator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosSecurityLayerNegotiatesAuthAndAuthorizationID(t *testing.T) {
|
||||
key := testKerberosEncryptionKey()
|
||||
challenge := marshalKerberosAcceptorToken(t, key, []byte{0x07, 0x00, 0x10, 0x00})
|
||||
authenticator := kerberosAuthenticator{sessionKey: key, authorizationID: "assumed_role"}
|
||||
|
||||
response, err := authenticator.securityLayerResponse(challenge)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded gssapi.WrapToken
|
||||
if err := decoded.Unmarshal(response, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid, err := decoded.Verify(key, keyusage.GSSAPI_INITIATOR_SEAL)
|
||||
if err != nil || !valid {
|
||||
t.Fatalf("invalid security-layer response: valid=%t err=%v", valid, err)
|
||||
}
|
||||
want := append([]byte{kerberosSecurityNone, 0, 0, 0}, []byte("assumed_role")...)
|
||||
if !bytes.Equal(decoded.Payload, want) {
|
||||
t.Fatalf("unexpected security-layer payload: %x", decoded.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosSecurityLayerRejectsUnavailableAuthQOP(t *testing.T) {
|
||||
key := testKerberosEncryptionKey()
|
||||
challenge := marshalKerberosAcceptorToken(t, key, []byte{0x02, 0, 0, 0})
|
||||
authenticator := kerberosAuthenticator{sessionKey: key}
|
||||
if _, err := authenticator.securityLayerResponse(challenge); err == nil {
|
||||
t.Fatal("expected server QOP rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosAuthenticatorChecksumRequestsIntegrityAndConfidentiality(t *testing.T) {
|
||||
checksum := kerberosAuthenticatorChecksum()
|
||||
if len(checksum) != 24 || binary.LittleEndian.Uint32(checksum[:4]) != 16 {
|
||||
t.Fatalf("unexpected channel-binding checksum: %x", checksum)
|
||||
}
|
||||
wantFlags := uint32(gssapi.ContextFlagInteg | gssapi.ContextFlagConf)
|
||||
if flags := binary.LittleEndian.Uint32(checksum[20:24]); flags != wantFlags {
|
||||
t.Fatalf("unexpected GSSAPI context flags: %x", flags)
|
||||
}
|
||||
}
|
||||
|
||||
func clearKerberosEnvironment(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"KRB5_CONFIG",
|
||||
"KRB5CCNAME",
|
||||
"KRB5_CLIENT_KTNAME",
|
||||
"KRB5_KTNAME",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func writeKerberosConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeTestFile(t, "krb5.conf", `
|
||||
[libdefaults]
|
||||
default_realm = EXAMPLE.COM
|
||||
dns_lookup_realm = false
|
||||
dns_lookup_kdc = false
|
||||
|
||||
[realms]
|
||||
EXAMPLE.COM = {
|
||||
kdc = 127.0.0.1:88
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
func testKerberosEncryptionKey() types.EncryptionKey {
|
||||
return types.EncryptionKey{KeyType: 18, KeyValue: bytes.Repeat([]byte{0x42}, 32)}
|
||||
}
|
||||
|
||||
func marshalKerberosAcceptorToken(t *testing.T, key types.EncryptionKey, payload []byte) []byte {
|
||||
t.Helper()
|
||||
token := gssapi.WrapToken{
|
||||
Flags: 0x01,
|
||||
EC: 12,
|
||||
Payload: payload,
|
||||
}
|
||||
if err := token.SetCheckSum(key, keyusage.GSSAPI_ACCEPTOR_SEAL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := token.Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
protocolVersion = 2
|
||||
defaultMaxRows = 10000
|
||||
defaultPageSize = 500
|
||||
legacyAgentSessionID = "__legacy__"
|
||||
maxAgentSessions = 256
|
||||
defaultConnectTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
type request struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
JSONRPC string `json:"jsonrpc,omitempty"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type connectParams struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
URLParams string `json:"url_params"`
|
||||
ConnectionString string `json:"connection_string"`
|
||||
SSL bool `json:"ssl"`
|
||||
CACertPath string `json:"ca_cert_path"`
|
||||
ClientCertPath string `json:"client_cert_path"`
|
||||
ClientKeyPath string `json:"client_key_path"`
|
||||
SessionRole string `json:"sessionRole"`
|
||||
}
|
||||
|
||||
type queryOptions struct {
|
||||
SQL string `json:"sql"`
|
||||
Database string `json:"database"`
|
||||
Schema string `json:"schema"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
FetchSize int `json:"fetchSize"`
|
||||
TimeoutSecs int `json:"timeoutSecs"`
|
||||
}
|
||||
|
||||
type queryResult struct {
|
||||
Columns []string `json:"columns"`
|
||||
ColumnTypes []string `json:"column_types"`
|
||||
Rows [][]any `json:"rows"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMS int64 `json:"execution_time_ms"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type queryPageResult struct {
|
||||
Columns []string `json:"columns"`
|
||||
ColumnTypes []string `json:"column_types"`
|
||||
Rows [][]any `json:"rows"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMS int64 `json:"execution_time_ms"`
|
||||
Truncated bool `json:"truncated"`
|
||||
SessionID *string `json:"session_id"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type querySession struct {
|
||||
sql string
|
||||
keyspace string
|
||||
pageState []byte
|
||||
remaining int
|
||||
}
|
||||
|
||||
type server struct {
|
||||
runtime *connectionRuntime
|
||||
params connectParams
|
||||
querySessions map[string]*querySession
|
||||
nextSessionID uint64
|
||||
activeMu sync.Mutex
|
||||
activeCancel context.CancelFunc
|
||||
}
|
||||
|
||||
type agentSession struct {
|
||||
server *server
|
||||
runtimeKey string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type runtimeServer struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*agentSession
|
||||
runtimesMu sync.Mutex
|
||||
runtimes map[string]*connectionRuntime
|
||||
}
|
||||
|
||||
func main() {
|
||||
runtime := newRuntimeServer()
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
var encoderMu sync.Mutex
|
||||
var requests sync.WaitGroup
|
||||
fmt.Fprintln(os.Stdout, `{"ready":true}`)
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 512*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var envelope request
|
||||
if json.Unmarshal([]byte(line), &envelope) == nil && envelope.Method == "shutdown" {
|
||||
requests.Wait()
|
||||
resp, _ := runtime.handleLine(line)
|
||||
encoderMu.Lock()
|
||||
_ = encoder.Encode(resp)
|
||||
encoderMu.Unlock()
|
||||
return
|
||||
}
|
||||
requests.Add(1)
|
||||
go func(line string) {
|
||||
defer requests.Done()
|
||||
resp, _ := runtime.handleLine(line)
|
||||
encoderMu.Lock()
|
||||
defer encoderMu.Unlock()
|
||||
if err := encoder.Encode(resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
|
||||
}
|
||||
}(line)
|
||||
}
|
||||
requests.Wait()
|
||||
}
|
||||
|
||||
func newRuntimeServer() *runtimeServer {
|
||||
return &runtimeServer{
|
||||
sessions: map[string]*agentSession{},
|
||||
runtimes: map[string]*connectionRuntime{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runtimeServer) handleLine(line string) (response, bool) {
|
||||
var req request
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
return errorResponse(nil, "", "", err), false
|
||||
}
|
||||
if len(req.ID) == 0 {
|
||||
req.ID = json.RawMessage("1")
|
||||
}
|
||||
result, shutdown, err := r.dispatch(req.Method, req.Params)
|
||||
if err != nil {
|
||||
return errorResponse(req.ID, req.Method, stringParam(req.Params, "agentSessionId"), err), false
|
||||
}
|
||||
return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown
|
||||
}
|
||||
|
||||
func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
|
||||
switch method {
|
||||
case "handshake":
|
||||
return handshakeResult(true), false, nil
|
||||
case "open_session":
|
||||
id := stringParam(params, "agentSessionId")
|
||||
if id == "" {
|
||||
return nil, false, errors.New("agentSessionId is required")
|
||||
}
|
||||
var cp connectParams
|
||||
if err := decodeParams(params, &cp); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return map[string]bool{"ok": true}, false, r.openSession(id, cp)
|
||||
case "close_session":
|
||||
return map[string]bool{"ok": true}, false, r.closeSession(stringParam(params, "agentSessionId"))
|
||||
case "validate_session":
|
||||
session, err := r.session(stringParam(params, "agentSessionId"))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
return map[string]bool{"ok": true}, false, session.server.validateConnection()
|
||||
case "cancel_session":
|
||||
session, err := r.session(stringParam(params, "agentSessionId"))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
session.server.cancelActiveQuery()
|
||||
return map[string]bool{"ok": true}, false, nil
|
||||
case "test_connection":
|
||||
var cp connectParams
|
||||
if err := decodeParams(params, &cp); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
result, err := testConnection(cp)
|
||||
return result, false, err
|
||||
case "connect":
|
||||
var cp connectParams
|
||||
if err := decodeParams(params, &cp); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
_ = r.closeSession(legacyAgentSessionID)
|
||||
return map[string]bool{"ok": true}, false, r.openSession(legacyAgentSessionID, cp)
|
||||
case "disconnect":
|
||||
return map[string]bool{"ok": true}, false, r.closeSession(legacyAgentSessionID)
|
||||
case "shutdown":
|
||||
return map[string]bool{"ok": true}, true, r.closeAllSessions()
|
||||
default:
|
||||
id := stringParam(params, "agentSessionId")
|
||||
if id == "" {
|
||||
id = legacyAgentSessionID
|
||||
}
|
||||
session, err := r.session(id)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
release, err := session.server.runtime.acquire(isMetadataOperation(method))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer release()
|
||||
return session.server.dispatch(method, params)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runtimeServer) openSession(id string, cp connectParams) error {
|
||||
r.mu.Lock()
|
||||
if _, exists := r.sessions[id]; exists {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("agent session already exists: %s", id)
|
||||
}
|
||||
if len(r.sessions) >= maxAgentSessions {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("agent session limit reached: %d", maxAgentSessions)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
runtime, key, err := r.acquireRuntime(cp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := newServer(runtime, cp)
|
||||
if err := s.validateConnection(); err != nil {
|
||||
r.releaseRuntime(key)
|
||||
return err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.sessions[id]; exists {
|
||||
r.releaseRuntime(key)
|
||||
return fmt.Errorf("agent session already exists: %s", id)
|
||||
}
|
||||
r.sessions[id] = &agentSession{server: s, runtimeKey: key}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeServer) session(id string) (*agentSession, error) {
|
||||
r.mu.RLock()
|
||||
session := r.sessions[id]
|
||||
r.mu.RUnlock()
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("agent session not found: %s", id)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (r *runtimeServer) closeSession(id string) error {
|
||||
r.mu.Lock()
|
||||
session := r.sessions[id]
|
||||
delete(r.sessions, id)
|
||||
r.mu.Unlock()
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
session.server.cancelActiveQuery()
|
||||
session.mu.Lock()
|
||||
session.server.disconnect()
|
||||
session.mu.Unlock()
|
||||
r.releaseRuntime(session.runtimeKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeServer) closeAllSessions() error {
|
||||
r.mu.RLock()
|
||||
ids := make([]string, 0, len(r.sessions))
|
||||
for id := range r.sessions {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
for _, id := range ids {
|
||||
_ = r.closeSession(id)
|
||||
}
|
||||
r.runtimesMu.Lock()
|
||||
runtimes := r.runtimes
|
||||
r.runtimes = map[string]*connectionRuntime{}
|
||||
r.runtimesMu.Unlock()
|
||||
for _, runtime := range runtimes {
|
||||
runtime.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newServer(runtime *connectionRuntime, cp connectParams) *server {
|
||||
return &server{runtime: runtime, params: cp, querySessions: map[string]*querySession{}}
|
||||
}
|
||||
|
||||
func (s *server) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
|
||||
switch method {
|
||||
case "handshake":
|
||||
return handshakeResult(false), false, nil
|
||||
case "validate_connection":
|
||||
return map[string]bool{"ok": true}, false, s.validateConnection()
|
||||
case "connection_info":
|
||||
result, err := s.connectionInfo()
|
||||
return result, false, err
|
||||
case "list_databases":
|
||||
result, err := s.listDatabases()
|
||||
return result, false, err
|
||||
case "list_schemas":
|
||||
result, err := s.listSchemas()
|
||||
return result, false, err
|
||||
case "list_tables":
|
||||
result, err := s.listTables(stringParam(params, "schema"), metadataListConstraintsFromParams(params))
|
||||
return result, false, err
|
||||
case "get_table_comment":
|
||||
return nil, false, nil
|
||||
case "list_objects":
|
||||
result, err := s.listObjects(stringParam(params, "schema"), metadataListConstraintsFromParams(params))
|
||||
return result, false, err
|
||||
case "list_data_types":
|
||||
return cassandraDataTypes(), false, nil
|
||||
case "completion_assistant_search_v1":
|
||||
var input completionAssistantRequest
|
||||
if err := decodeParams(params, &input); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
result, err := s.completionAssistantSearch(input)
|
||||
return result, false, err
|
||||
case "get_columns":
|
||||
result, err := s.getColumns(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
case "list_indexes":
|
||||
result, err := s.listIndexes(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
case "list_foreign_keys":
|
||||
return []foreignKeyInfo{}, false, nil
|
||||
case "list_triggers":
|
||||
return []triggerInfo{}, false, nil
|
||||
case "get_object_source":
|
||||
return nil, false, errors.New("object source is not supported by Cassandra")
|
||||
case "get_table_ddl":
|
||||
result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
case "get_explain_info":
|
||||
return nil, false, errors.New("execution plans are not supported by Cassandra")
|
||||
case "execute_query":
|
||||
result, err := s.executeQuery(queryOptionsFromParams(params))
|
||||
return result, false, err
|
||||
case "execute_query_page", "start_table_read":
|
||||
result, err := s.executeQueryPage(queryOptionsFromParams(params), intParam(params, "pageSize"))
|
||||
return result, false, err
|
||||
case "fetch_query_page", "fetch_table_read_page":
|
||||
result, err := s.fetchQueryPage(stringParam(params, "sessionId"), intParam(params, "pageSize"))
|
||||
return result, false, err
|
||||
case "close_query_session", "close_table_read_session":
|
||||
return s.closeQuerySession(stringParam(params, "sessionId")), false, nil
|
||||
case "execute_transaction":
|
||||
result, err := s.executeStatements(params, true)
|
||||
return result, false, err
|
||||
case "execute_batch":
|
||||
result, err := s.executeStatements(params, false)
|
||||
return result, false, err
|
||||
case "disconnect":
|
||||
s.disconnect()
|
||||
return map[string]bool{"ok": true}, false, nil
|
||||
case "shutdown":
|
||||
s.disconnect()
|
||||
return map[string]bool{"ok": true}, true, nil
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unknown method: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
func handshakeResult(multiSession bool) map[string]any {
|
||||
capabilities := []string{
|
||||
"connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "structured_error_v1",
|
||||
}
|
||||
if multiSession {
|
||||
capabilities = append(capabilities, "multi_session")
|
||||
}
|
||||
return map[string]any{
|
||||
"protocolVersion": protocolVersion,
|
||||
"agentProtocolVersion": protocolVersion,
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) validateConnection() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultConnectTimeout)
|
||||
defer cancel()
|
||||
session, err := s.runtime.sessionFor(s.defaultKeyspace())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var releaseVersion string
|
||||
return session.Query("SELECT release_version FROM system.local").WithContext(ctx).Scan(&releaseVersion)
|
||||
}
|
||||
|
||||
func testConnection(cp connectParams) (map[string]any, error) {
|
||||
runtime, err := newConnectionRuntime(cp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer runtime.close()
|
||||
s := newServer(runtime, cp)
|
||||
if err := s.validateConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := s.connectionInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"ok": true, "info": info}, nil
|
||||
}
|
||||
|
||||
func (s *server) disconnect() {
|
||||
s.cancelActiveQuery()
|
||||
s.querySessions = map[string]*querySession{}
|
||||
}
|
||||
|
||||
func (s *server) defaultKeyspace() string {
|
||||
if keyspace := strings.TrimSpace(s.params.Database); keyspace != "" {
|
||||
return keyspace
|
||||
}
|
||||
return strings.TrimSpace(s.runtime.config.keyspace)
|
||||
}
|
||||
|
||||
func (s *server) beginOperation(timeoutSecs int) (context.Context, context.CancelFunc) {
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
if timeoutSecs > 0 {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeoutSecs)*time.Second)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
}
|
||||
s.activeMu.Lock()
|
||||
s.activeCancel = cancel
|
||||
s.activeMu.Unlock()
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
func (s *server) endOperation(cancel context.CancelFunc) {
|
||||
cancel()
|
||||
s.activeMu.Lock()
|
||||
s.activeCancel = nil
|
||||
s.activeMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *server) cancelActiveQuery() {
|
||||
s.activeMu.Lock()
|
||||
cancel := s.activeCancel
|
||||
s.activeMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func queryOptionsFromParams(params map[string]json.RawMessage) queryOptions {
|
||||
return queryOptions{
|
||||
SQL: stringParam(params, "sql"),
|
||||
Database: stringParam(params, "database"),
|
||||
Schema: stringParam(params, "schema"),
|
||||
MaxRows: intParam(params, "maxRows"),
|
||||
FetchSize: intParam(params, "fetchSize"),
|
||||
TimeoutSecs: intParam(params, "timeoutSecs"),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeParams(params map[string]json.RawMessage, target any) error {
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
|
||||
func stringParam(params map[string]json.RawMessage, key string) string {
|
||||
if raw, ok := params[key]; ok {
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intParam(params map[string]json.RawMessage, key string) int {
|
||||
if raw, ok := params[key]; ok {
|
||||
var value int
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func boolParam(params map[string]json.RawMessage, key string) bool {
|
||||
if raw, ok := params[key]; ok {
|
||||
var value bool
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringSliceParam(params map[string]json.RawMessage, key string) []string {
|
||||
if raw, ok := params[key]; ok {
|
||||
var value []string
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func errorResponse(id json.RawMessage, method, sessionID string, err error) response {
|
||||
return response{JSONRPC: "2.0", ID: id, Error: classifyRPCError(method, sessionID, err)}
|
||||
}
|
||||
|
||||
func isMetadataOperation(method string) bool {
|
||||
switch method {
|
||||
case "connection_info", "list_databases", "list_schemas", "list_tables", "get_table_comment", "list_objects",
|
||||
"list_data_types", "completion_assistant_search_v1", "get_columns", "list_indexes", "list_foreign_keys",
|
||||
"list_triggers", "get_object_source", "get_table_ddl", "get_explain_info":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
var cassandraTypes = []string{
|
||||
"ascii", "bigint", "blob", "boolean", "counter", "date", "decimal", "double", "duration",
|
||||
"float", "inet", "int", "list", "map", "set", "smallint", "text", "time", "timestamp",
|
||||
"timeuuid", "tinyint", "tuple", "uuid", "varchar", "varint", "vector", "frozen",
|
||||
}
|
||||
|
||||
type databaseInfo struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type tableInfo struct {
|
||||
Name string `json:"name"`
|
||||
TableType string `json:"table_type"`
|
||||
Comment *string `json:"comment"`
|
||||
}
|
||||
|
||||
type objectInfo struct {
|
||||
Name string `json:"name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
Schema string `json:"schema"`
|
||||
Comment *string `json:"comment"`
|
||||
Valid *bool `json:"valid,omitempty"`
|
||||
}
|
||||
|
||||
type columnInfo struct {
|
||||
Name string `json:"name"`
|
||||
DataType string `json:"data_type"`
|
||||
IsNullable bool `json:"is_nullable"`
|
||||
ColumnDefault *string `json:"column_default"`
|
||||
IsPrimaryKey bool `json:"is_primary_key"`
|
||||
Extra *string `json:"extra"`
|
||||
Comment *string `json:"comment"`
|
||||
NumericPrecision *int `json:"numeric_precision"`
|
||||
NumericScale *int `json:"numeric_scale"`
|
||||
CharacterMaximumLength *int `json:"character_maximum_length"`
|
||||
}
|
||||
|
||||
type indexInfo struct {
|
||||
Name string `json:"name"`
|
||||
Columns []string `json:"columns"`
|
||||
IsUnique bool `json:"is_unique"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Filter *string `json:"filter"`
|
||||
IndexType *string `json:"index_type"`
|
||||
IncludedColumns []string `json:"included_columns"`
|
||||
Comment *string `json:"comment"`
|
||||
}
|
||||
|
||||
func (i indexInfo) MarshalJSON() ([]byte, error) {
|
||||
type alias indexInfo
|
||||
value := alias(i)
|
||||
if value.Columns == nil {
|
||||
value.Columns = []string{}
|
||||
}
|
||||
if value.IncludedColumns == nil {
|
||||
value.IncludedColumns = []string{}
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
type foreignKeyInfo struct {
|
||||
Name string `json:"name"`
|
||||
Column string `json:"column"`
|
||||
RefTable string `json:"ref_table"`
|
||||
RefColumn string `json:"ref_column"`
|
||||
}
|
||||
|
||||
type triggerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Event string `json:"event"`
|
||||
Timing string `json:"timing"`
|
||||
}
|
||||
|
||||
type metadataListConstraints struct {
|
||||
Filter string
|
||||
Limit int
|
||||
Offset int
|
||||
ObjectTypes []string
|
||||
}
|
||||
|
||||
type completionAssistantRequest struct {
|
||||
ConnectionID string `json:"connection_id"`
|
||||
Database string `json:"database"`
|
||||
Schema string `json:"schema"`
|
||||
ObjectKinds []string `json:"object_kinds"`
|
||||
Mask string `json:"mask"`
|
||||
CaseSensitive bool `json:"case_sensitive"`
|
||||
GlobalSearch bool `json:"global_search"`
|
||||
MaxResults int `json:"max_results"`
|
||||
ParentSchema string `json:"parent_schema"`
|
||||
ParentName string `json:"parent_name"`
|
||||
MatchMode string `json:"match_mode"`
|
||||
}
|
||||
|
||||
type completionAssistantCandidate struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Database *string `json:"database"`
|
||||
Schema *string `json:"schema"`
|
||||
ParentSchema *string `json:"parent_schema"`
|
||||
ParentName *string `json:"parent_name"`
|
||||
Comment *string `json:"comment"`
|
||||
DataType *string `json:"data_type"`
|
||||
}
|
||||
|
||||
type completionAssistantResponse struct {
|
||||
Candidates []completionAssistantCandidate `json:"candidates"`
|
||||
Incomplete bool `json:"incomplete"`
|
||||
FallbackUsed bool `json:"fallback_used"`
|
||||
}
|
||||
|
||||
func cassandraDataTypes() []string {
|
||||
return append([]string(nil), cassandraTypes...)
|
||||
}
|
||||
|
||||
func (s *server) connectionInfo() (map[string]any, error) {
|
||||
session, err := s.runtime.sessionFor("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var clusterName, version, cqlVersion, dataCenter string
|
||||
err = session.Query("SELECT cluster_name, release_version, cql_version, data_center FROM system.local").Scan(
|
||||
&clusterName, &version, &cqlVersion, &dataCenter,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"database": s.defaultKeyspace(),
|
||||
"schema": s.defaultKeyspace(),
|
||||
"username": s.params.Username,
|
||||
"version": version,
|
||||
"clusterName": clusterName,
|
||||
"cqlVersion": cqlVersion,
|
||||
"localDatacenter": dataCenter,
|
||||
"identifierQuote": `"`,
|
||||
"compatibilityMode": "cql",
|
||||
"databaseInfo": map[string]string{
|
||||
"productName": "Apache Cassandra",
|
||||
"productVersion": version,
|
||||
"unquotedIdentifierCase": "lower",
|
||||
"quotedIdentifierCase": "mixed",
|
||||
"driverName": "Apache cassandra-gocql-driver",
|
||||
"driverVersion": "2.1.2",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) allKeyspaceMetadata() (map[string]*gocql.KeyspaceMetadata, error) {
|
||||
session, err := s.runtime.sessionFor("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session.AllKeyspaceMetadata()
|
||||
}
|
||||
|
||||
func (s *server) keyspaceMetadata(schema string) (*gocql.KeyspaceMetadata, error) {
|
||||
session, err := s.runtime.sessionFor("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, err := session.KeyspaceMetadata(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metadata == nil {
|
||||
return nil, fmt.Errorf("Cassandra keyspace not found: %s", schema)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *server) tableMetadata(schema, table string) (*gocql.TableMetadata, error) {
|
||||
keyspace, err := s.keyspaceMetadata(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata := keyspace.Tables[table]
|
||||
if metadata == nil {
|
||||
return nil, fmt.Errorf("Cassandra table not found: %s.%s", schema, table)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *server) listDatabases() ([]databaseInfo, error) {
|
||||
metadata, err := s.allKeyspaceMetadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := sortedMapKeys(metadata)
|
||||
result := make([]databaseInfo, len(names))
|
||||
for index, name := range names {
|
||||
result[index] = databaseInfo{Name: name}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) listSchemas() ([]string, error) {
|
||||
databases, err := s.listDatabases()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]string, len(databases))
|
||||
for index, database := range databases {
|
||||
result[index] = database.Name
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) listTables(schema string, constraints metadataListConstraints) ([]tableInfo, error) {
|
||||
metadata, err := s.keyspaceMetadata(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := sortedMapKeys(metadata.Tables)
|
||||
result := make([]tableInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
if !metadataNameMatches(name, constraints.Filter) {
|
||||
continue
|
||||
}
|
||||
result = append(result, tableInfo{Name: name, TableType: "TABLE"})
|
||||
}
|
||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
|
||||
}
|
||||
|
||||
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
|
||||
tables, err := s.listTables(schema, metadataListConstraints{Filter: constraints.Filter})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := stringSet(constraints.ObjectTypes)
|
||||
result := make([]objectInfo, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
if len(allowed) > 0 && !allowed["table"] && !allowed["base_table"] {
|
||||
continue
|
||||
}
|
||||
result = append(result, objectInfo{Name: table.Name, ObjectType: "TABLE", Schema: schema})
|
||||
}
|
||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
|
||||
}
|
||||
|
||||
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
|
||||
metadata, err := s.tableMetadata(schema, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columnsFromMetadata(metadata), nil
|
||||
}
|
||||
|
||||
func columnsFromMetadata(metadata *gocql.TableMetadata) []columnInfo {
|
||||
names := orderedColumnNames(metadata)
|
||||
result := make([]columnInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
column := metadata.Columns[name]
|
||||
if column == nil {
|
||||
continue
|
||||
}
|
||||
primary := column.Kind == gocql.ColumnPartitionKey || column.Kind == gocql.ColumnClusteringKey
|
||||
extra := column.Kind.String()
|
||||
result = append(result, columnInfo{
|
||||
Name: column.Name,
|
||||
DataType: cqlTypeName(column.Type),
|
||||
IsNullable: !primary,
|
||||
IsPrimaryKey: primary,
|
||||
Extra: &extra,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *server) listIndexes(schema, table string) ([]indexInfo, error) {
|
||||
metadata, err := s.tableMetadata(schema, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := indexesFromMetadata(metadata)
|
||||
queried, queryErr := s.querySystemIndexes(schema, table)
|
||||
if queryErr == nil {
|
||||
result = mergeIndexes(result, queried)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) querySystemIndexes(schema, table string) ([]indexInfo, error) {
|
||||
session, err := s.runtime.sessionFor("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iter := session.Query(
|
||||
"SELECT index_name, kind, options FROM system_schema.indexes WHERE keyspace_name = ? AND table_name = ?",
|
||||
schema,
|
||||
table,
|
||||
).Iter()
|
||||
result := []indexInfo{}
|
||||
var name, kind string
|
||||
var options map[string]string
|
||||
for iter.Scan(&name, &kind, &options) {
|
||||
indexType := strings.TrimSpace(kind)
|
||||
result = append(result, indexInfo{
|
||||
Name: name,
|
||||
Columns: targetColumns(options["target"]),
|
||||
IndexType: optionalString(indexType),
|
||||
IncludedColumns: []string{},
|
||||
})
|
||||
options = nil
|
||||
}
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mergeIndexes(first, second []indexInfo) []indexInfo {
|
||||
byName := make(map[string]indexInfo, len(first)+len(second))
|
||||
for _, index := range first {
|
||||
byName[index.Name] = index
|
||||
}
|
||||
for _, index := range second {
|
||||
if existing, ok := byName[index.Name]; ok && len(index.Columns) == 0 {
|
||||
index.Columns = existing.Columns
|
||||
}
|
||||
byName[index.Name] = index
|
||||
}
|
||||
names := sortedMapKeys(byName)
|
||||
result := make([]indexInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
result = append(result, byName[name])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func targetColumns(target string) []string {
|
||||
target = strings.TrimSpace(target)
|
||||
for _, wrapper := range []string{"values", "keys", "entries", "full"} {
|
||||
prefix := wrapper + "("
|
||||
if strings.HasPrefix(strings.ToLower(target), prefix) && strings.HasSuffix(target, ")") {
|
||||
target = strings.TrimSpace(target[len(prefix) : len(target)-1])
|
||||
break
|
||||
}
|
||||
}
|
||||
target = strings.Trim(target, `"'`)
|
||||
if target == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{target}
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func indexesFromMetadata(metadata *gocql.TableMetadata) []indexInfo {
|
||||
byName := map[string]*indexInfo{}
|
||||
for _, columnName := range orderedColumnNames(metadata) {
|
||||
column := metadata.Columns[columnName]
|
||||
if column == nil || strings.TrimSpace(column.Index.Name) == "" {
|
||||
continue
|
||||
}
|
||||
index := byName[column.Index.Name]
|
||||
if index == nil {
|
||||
indexType := strings.TrimSpace(column.Index.Type)
|
||||
index = &indexInfo{Name: column.Index.Name, Columns: []string{}, IncludedColumns: []string{}}
|
||||
if indexType != "" {
|
||||
index.IndexType = &indexType
|
||||
}
|
||||
byName[index.Name] = index
|
||||
}
|
||||
index.Columns = append(index.Columns, columnName)
|
||||
}
|
||||
names := sortedMapKeys(byName)
|
||||
result := make([]indexInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
result = append(result, *byName[name])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *server) getTableDDL(schema, table string) (string, error) {
|
||||
metadata, err := s.tableMetadata(schema, table)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tableDDLFromMetadata(schema, table, metadata)
|
||||
}
|
||||
|
||||
func tableDDLFromMetadata(schema, table string, metadata *gocql.TableMetadata) (string, error) {
|
||||
definitions := make([]string, 0, len(metadata.Columns)+1)
|
||||
for _, name := range orderedColumnNames(metadata) {
|
||||
column := metadata.Columns[name]
|
||||
if column != nil {
|
||||
definitions = append(definitions, " "+quoteCQLIdentifier(column.Name)+" "+cqlTypeName(column.Type))
|
||||
}
|
||||
}
|
||||
partitionKeys := metadataColumnNames(metadata.PartitionKey)
|
||||
clusteringKeys := metadataColumnNames(metadata.ClusteringColumns)
|
||||
if len(partitionKeys) == 0 {
|
||||
return "", fmt.Errorf("Cassandra table has no partition key: %s.%s", schema, table)
|
||||
}
|
||||
primaryParts := make([]string, 0, len(clusteringKeys)+1)
|
||||
if len(partitionKeys) == 1 {
|
||||
primaryParts = append(primaryParts, quoteCQLIdentifier(partitionKeys[0]))
|
||||
} else {
|
||||
quoted := make([]string, len(partitionKeys))
|
||||
for index, name := range partitionKeys {
|
||||
quoted[index] = quoteCQLIdentifier(name)
|
||||
}
|
||||
primaryParts = append(primaryParts, "("+strings.Join(quoted, ", ")+")")
|
||||
}
|
||||
for _, name := range clusteringKeys {
|
||||
primaryParts = append(primaryParts, quoteCQLIdentifier(name))
|
||||
}
|
||||
definitions = append(definitions, " PRIMARY KEY ("+strings.Join(primaryParts, ", ")+")")
|
||||
ddl := "CREATE TABLE " + quoteCQLIdentifier(schema) + "." + quoteCQLIdentifier(table) + " (\n" + strings.Join(definitions, ",\n") + "\n)"
|
||||
orders := make([]string, 0, len(metadata.ClusteringColumns))
|
||||
for _, column := range metadata.ClusteringColumns {
|
||||
if column != nil {
|
||||
order := "ASC"
|
||||
if column.Order == gocql.DESC {
|
||||
order = "DESC"
|
||||
}
|
||||
orders = append(orders, quoteCQLIdentifier(column.Name)+" "+order)
|
||||
}
|
||||
}
|
||||
if len(orders) > 0 {
|
||||
ddl += " WITH CLUSTERING ORDER BY (" + strings.Join(orders, ", ") + ")"
|
||||
}
|
||||
return ddl + ";", nil
|
||||
}
|
||||
|
||||
func (s *server) completionAssistantSearch(input completionAssistantRequest) (completionAssistantResponse, error) {
|
||||
limit := input.MaxResults
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
candidates := make([]completionAssistantCandidate, 0, limit+1)
|
||||
kinds := stringSet(input.ObjectKinds)
|
||||
if kinds["column"] && input.ParentName != "" {
|
||||
schema := input.ParentSchema
|
||||
if schema == "" {
|
||||
schema = input.Schema
|
||||
}
|
||||
columns, err := s.getColumns(schema, input.ParentName)
|
||||
if err != nil {
|
||||
return completionAssistantResponse{}, err
|
||||
}
|
||||
for _, column := range columns {
|
||||
if !completionNameMatches(column.Name, input) {
|
||||
continue
|
||||
}
|
||||
dataType := column.DataType
|
||||
candidates = append(candidates, completionAssistantCandidate{
|
||||
Name: column.Name, Kind: "COLUMN", Schema: stringPtr(schema), ParentSchema: stringPtr(schema),
|
||||
ParentName: stringPtr(input.ParentName), DataType: &dataType,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
schemas := []string{input.Schema}
|
||||
if input.GlobalSearch || input.Schema == "" {
|
||||
var err error
|
||||
schemas, err = s.listSchemas()
|
||||
if err != nil {
|
||||
return completionAssistantResponse{}, err
|
||||
}
|
||||
}
|
||||
for _, schema := range schemas {
|
||||
objects, err := s.listObjects(schema, metadataListConstraints{ObjectTypes: input.ObjectKinds})
|
||||
if err != nil {
|
||||
return completionAssistantResponse{}, err
|
||||
}
|
||||
for _, object := range objects {
|
||||
if !completionNameMatches(object.Name, input) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, completionAssistantCandidate{
|
||||
Name: object.Name, Kind: object.ObjectType, Schema: stringPtr(schema),
|
||||
})
|
||||
if len(candidates) > limit {
|
||||
return completionAssistantResponse{Candidates: candidates[:limit], Incomplete: true}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
incomplete := len(candidates) > limit
|
||||
if incomplete {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete}, nil
|
||||
}
|
||||
|
||||
func metadataListConstraintsFromParams(params map[string]json.RawMessage) metadataListConstraints {
|
||||
return metadataListConstraints{
|
||||
Filter: stringParam(params, "filter"),
|
||||
Limit: intParam(params, "limit"),
|
||||
Offset: intParam(params, "offset"),
|
||||
ObjectTypes: stringSliceParam(params, "object_types"),
|
||||
}
|
||||
}
|
||||
|
||||
func orderedColumnNames(metadata *gocql.TableMetadata) []string {
|
||||
if len(metadata.OrderedColumns) > 0 {
|
||||
return append([]string(nil), metadata.OrderedColumns...)
|
||||
}
|
||||
return sortedMapKeys(metadata.Columns)
|
||||
}
|
||||
|
||||
func metadataColumnNames(columns []*gocql.ColumnMetadata) []string {
|
||||
result := make([]string, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
if column != nil {
|
||||
result = append(result, column.Name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func metadataNameMatches(name, filter string) bool {
|
||||
return filter == "" || strings.Contains(strings.ToLower(name), strings.ToLower(filter))
|
||||
}
|
||||
|
||||
func applyMetadataWindow[T any](values []T, offset, limit int) []T {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(values) {
|
||||
return []T{}
|
||||
}
|
||||
values = values[offset:]
|
||||
if limit > 0 && limit < len(values) {
|
||||
values = values[:limit]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func sortedMapKeys[T any](values map[string]T) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]bool {
|
||||
result := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
result[strings.ToLower(strings.TrimSpace(value))] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func completionNameMatches(name string, input completionAssistantRequest) bool {
|
||||
mask := input.Mask
|
||||
if mask == "" {
|
||||
return true
|
||||
}
|
||||
if !input.CaseSensitive {
|
||||
name = strings.ToLower(name)
|
||||
mask = strings.ToLower(mask)
|
||||
}
|
||||
if strings.EqualFold(input.MatchMode, "contains") {
|
||||
return strings.Contains(name, mask)
|
||||
}
|
||||
return strings.HasPrefix(name, mask)
|
||||
}
|
||||
|
||||
func quoteCQLIdentifier(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func TestColumnsIndexesAndDDLFromMetadata(t *testing.T) {
|
||||
textType := gocql.NewNativeType(4, gocql.TypeVarchar, "")
|
||||
intType := gocql.NewNativeType(4, gocql.TypeInt, "")
|
||||
id := &gocql.ColumnMetadata{Name: "tenant", Kind: gocql.ColumnPartitionKey, Type: textType}
|
||||
bucket := &gocql.ColumnMetadata{Name: "bucket", Kind: gocql.ColumnPartitionKey, Type: intType}
|
||||
created := &gocql.ColumnMetadata{Name: "created_at", Kind: gocql.ColumnClusteringKey, Type: textType, Order: gocql.DESC}
|
||||
email := &gocql.ColumnMetadata{
|
||||
Name: "email", Kind: gocql.ColumnRegular, Type: textType,
|
||||
Index: gocql.ColumnIndexMetadata{Name: "users_email_idx", Type: "COMPOSITES"},
|
||||
}
|
||||
metadata := &gocql.TableMetadata{
|
||||
OrderedColumns: []string{"tenant", "bucket", "created_at", "email"},
|
||||
PartitionKey: []*gocql.ColumnMetadata{id, bucket},
|
||||
ClusteringColumns: []*gocql.ColumnMetadata{created},
|
||||
Columns: map[string]*gocql.ColumnMetadata{
|
||||
"tenant": id, "bucket": bucket, "created_at": created, "email": email,
|
||||
},
|
||||
}
|
||||
|
||||
columns := columnsFromMetadata(metadata)
|
||||
if len(columns) != 4 || !columns[0].IsPrimaryKey || columns[0].IsNullable || columns[3].IsPrimaryKey || !columns[3].IsNullable {
|
||||
t.Fatalf("unexpected columns: %#v", columns)
|
||||
}
|
||||
if columns[2].Extra == nil || *columns[2].Extra != "clustering_key" {
|
||||
t.Fatalf("unexpected clustering metadata: %#v", columns[2])
|
||||
}
|
||||
|
||||
indexes := indexesFromMetadata(metadata)
|
||||
if len(indexes) != 1 || indexes[0].Name != "users_email_idx" || !reflect.DeepEqual(indexes[0].Columns, []string{"email"}) {
|
||||
t.Fatalf("unexpected indexes: %#v", indexes)
|
||||
}
|
||||
|
||||
ddl, err := tableDDLFromMetadata("app", "users", metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "CREATE TABLE \"app\".\"users\" (\n" +
|
||||
" \"tenant\" text,\n" +
|
||||
" \"bucket\" int,\n" +
|
||||
" \"created_at\" text,\n" +
|
||||
" \"email\" text,\n" +
|
||||
" PRIMARY KEY ((\"tenant\", \"bucket\"), \"created_at\")\n" +
|
||||
") WITH CLUSTERING ORDER BY (\"created_at\" DESC);"
|
||||
if ddl != want {
|
||||
t.Fatalf("unexpected DDL:\n%s\nwant:\n%s", ddl, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataWindowAndFilter(t *testing.T) {
|
||||
values := []string{"a", "b", "c", "d"}
|
||||
if got := applyMetadataWindow(values, 1, 2); !reflect.DeepEqual(got, []string{"b", "c"}) {
|
||||
t.Fatalf("unexpected window: %#v", got)
|
||||
}
|
||||
if !metadataNameMatches("CustomerEvents", "event") || metadataNameMatches("users", "event") {
|
||||
t.Fatal("metadata filter mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetColumnsHandlesCollectionIndexes(t *testing.T) {
|
||||
for input, want := range map[string]string{
|
||||
"txt": "txt",
|
||||
"values(tags)": "tags",
|
||||
"keys(attrs)": "attrs",
|
||||
`entries("attrs")`: "attrs",
|
||||
} {
|
||||
got := targetColumns(input)
|
||||
if !reflect.DeepEqual(got, []string{want}) {
|
||||
t.Fatalf("targetColumns(%q) = %#v", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteCQLIdentifierEscapesQuotes(t *testing.T) {
|
||||
if got := quoteCQLIdentifier(`a"b`); got != `"a""b"` {
|
||||
t.Fatalf("unexpected quoted identifier: %s", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data *rpcErrorData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type rpcErrorData struct {
|
||||
Category string `json:"category"`
|
||||
Retryable bool `json:"retryable"`
|
||||
SessionDisposition string `json:"sessionDisposition"`
|
||||
Stage string `json:"stage"`
|
||||
ContractVersion int `json:"contractVersion"`
|
||||
OperationOutcome string `json:"operationOutcome"`
|
||||
SQLState string `json:"sqlState,omitempty"`
|
||||
ExceptionClass string `json:"exceptionClass,omitempty"`
|
||||
AgentSessionID string `json:"agentSessionId,omitempty"`
|
||||
}
|
||||
|
||||
func classifyRPCError(method, agentSessionID string, err error) *rpcError {
|
||||
stage := rpcErrorStage(method)
|
||||
data := &rpcErrorData{
|
||||
Category: "protocol",
|
||||
Retryable: false,
|
||||
SessionDisposition: "keep",
|
||||
Stage: stage,
|
||||
ContractVersion: 1,
|
||||
OperationOutcome: rpcOperationOutcome(stage),
|
||||
ExceptionClass: safeRPCDiagnostic(fmt.Sprintf("%T", err), 160),
|
||||
AgentSessionID: strings.TrimSpace(agentSessionID),
|
||||
}
|
||||
if errors.Is(err, errOperationCapacity) {
|
||||
data.Category = "resource"
|
||||
data.Retryable = true
|
||||
return &rpcError{Code: -1, Message: err.Error(), Data: data}
|
||||
}
|
||||
|
||||
var requestError gocql.RequestError
|
||||
if errors.As(err, &requestError) {
|
||||
data.SQLState = fmt.Sprintf("0x%04x", requestError.Code())
|
||||
switch requestError.Code() {
|
||||
case gocql.ErrCodeUnavailable, gocql.ErrCodeOverloaded, gocql.ErrCodeBootstrapping:
|
||||
data.Category = "resource"
|
||||
data.Retryable = true
|
||||
case gocql.ErrCodeWriteTimeout, gocql.ErrCodeReadTimeout:
|
||||
data.Category = "timeout"
|
||||
data.Retryable = true
|
||||
case gocql.ErrCodeCredentials:
|
||||
data.Category = "connection"
|
||||
data.Retryable = stage == "connect" || stage == "validate"
|
||||
case gocql.ErrCodeSyntax, gocql.ErrCodeUnauthorized, gocql.ErrCodeInvalid,
|
||||
gocql.ErrCodeConfig, gocql.ErrCodeAlreadyExists, gocql.ErrCodeUnprepared:
|
||||
data.Category = "sql"
|
||||
default:
|
||||
data.Category = "sql"
|
||||
}
|
||||
} else if errors.Is(err, context.Canceled) {
|
||||
data.Category = "canceled"
|
||||
data.SessionDisposition = "quarantine"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, gocql.ErrTimeoutNoResponse) || isTimeoutError(err) {
|
||||
data.Category = "timeout"
|
||||
data.SessionDisposition = "quarantine"
|
||||
} else if isConnectionError(err) {
|
||||
data.Category = "connection"
|
||||
data.Retryable = stage == "connect" || stage == "validate"
|
||||
if stage != "connect" {
|
||||
data.SessionDisposition = "quarantine"
|
||||
}
|
||||
}
|
||||
|
||||
return &rpcError{Code: -1, Message: err.Error(), Data: data}
|
||||
}
|
||||
|
||||
func rpcErrorStage(method string) string {
|
||||
switch method {
|
||||
case "connect", "open_session", "test_connection":
|
||||
return "connect"
|
||||
case "validate_connection", "validate_session":
|
||||
return "validate"
|
||||
case "cancel_session":
|
||||
return "cancel"
|
||||
case "close_session", "disconnect", "close_query_session", "close_table_read_session", "shutdown":
|
||||
return "close"
|
||||
case "fetch_query_page", "fetch_table_read_page":
|
||||
return "fetch"
|
||||
case "handshake", "":
|
||||
return "request"
|
||||
default:
|
||||
return "execute"
|
||||
}
|
||||
}
|
||||
|
||||
func rpcOperationOutcome(stage string) string {
|
||||
switch stage {
|
||||
case "request", "connect", "validate":
|
||||
return "not_started"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeoutError(err error) bool {
|
||||
var timeout interface{ Timeout() bool }
|
||||
return errors.As(err, &timeout) && timeout.Timeout()
|
||||
}
|
||||
|
||||
func isConnectionError(err error) bool {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||
return true
|
||||
}
|
||||
var networkError *net.OpError
|
||||
if errors.As(err, &networkError) {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
for _, marker := range []string{
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"connection lost",
|
||||
"unexpected eof",
|
||||
"no route to host",
|
||||
"no hosts available",
|
||||
} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func safeRPCDiagnostic(value string, maxLength int) string {
|
||||
var result strings.Builder
|
||||
for _, char := range value {
|
||||
if result.Len() >= maxLength {
|
||||
break
|
||||
}
|
||||
if char >= 0x21 && char <= 0x7e {
|
||||
result.WriteRune(char)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandshakeAdvertisesMultiSessionAndStructuredErrors(t *testing.T) {
|
||||
result, shutdown, err := newRuntimeServer().dispatch("handshake", nil)
|
||||
if err != nil || shutdown {
|
||||
t.Fatalf("unexpected handshake result: shutdown=%t err=%v", shutdown, err)
|
||||
}
|
||||
capabilities := result.(map[string]any)["capabilities"].([]string)
|
||||
want := []string{"connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "structured_error_v1", "multi_session"}
|
||||
if !reflect.DeepEqual(capabilities, want) {
|
||||
t.Fatalf("unexpected capabilities: %#v", capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLineRejectsMissingSession(t *testing.T) {
|
||||
params, _ := json.Marshal(map[string]any{"agentSessionId": "missing"})
|
||||
line := `{"jsonrpc":"2.0","id":7,"method":"validate_session","params":` + string(params) + `}`
|
||||
response, _ := newRuntimeServer().handleLine(line)
|
||||
if response.Error == nil || response.Error.Data == nil || response.Error.Data.Stage != "validate" {
|
||||
t.Fatalf("unexpected error response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCanceledQuery(t *testing.T) {
|
||||
err := classifyRPCError("execute_query", "session-1", context.Canceled)
|
||||
if err.Data.Category != "canceled" || err.Data.SessionDisposition != "quarantine" {
|
||||
t.Fatalf("unexpected cancellation classification: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeIdentityIncludesCredentials(t *testing.T) {
|
||||
first := connectionRuntimeKey(connectParams{Host: "localhost", Username: "user", Password: "one"})
|
||||
second := connectionRuntimeKey(connectParams{Host: "localhost", Username: "user", Password: "two"})
|
||||
if first == second {
|
||||
t.Fatal("runtime identities must not share sessions across credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimStatementSQL(t *testing.T) {
|
||||
if got := trimStatementSQL(" SELECT * FROM t;;; \n"); got != "SELECT * FROM t" {
|
||||
t.Fatalf("unexpected trimmed SQL: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSchemaChangingCQL(t *testing.T) {
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE app.events (id int PRIMARY KEY)",
|
||||
" alter keyspace app with replication = {'class': 'SimpleStrategy'} ",
|
||||
"DROP INDEX app.events_idx;",
|
||||
} {
|
||||
if !isSchemaChangingCQL(sql) {
|
||||
t.Fatalf("expected schema-changing CQL: %q", sql)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{"SELECT * FROM app.events", "INSERT INTO app.events (id) VALUES (1)", "TRUNCATE app.events"} {
|
||||
if isSchemaChangingCQL(sql) {
|
||||
t.Fatalf("unexpected schema-changing CQL: %q", sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func (s *server) executeQuery(options queryOptions) (queryResult, error) {
|
||||
start := time.Now()
|
||||
maxRows := options.MaxRows
|
||||
if maxRows <= 0 {
|
||||
maxRows = defaultMaxRows
|
||||
}
|
||||
session, err := s.runtime.sessionFor(s.keyspaceForOptions(options))
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
ctx, cancel := s.beginOperation(options.TimeoutSecs)
|
||||
defer s.endOperation(cancel)
|
||||
query := session.Query(trimStatementSQL(options.SQL)).WithContext(ctx)
|
||||
if options.FetchSize > 0 {
|
||||
query = query.PageSize(options.FetchSize)
|
||||
}
|
||||
iter := query.Iter()
|
||||
columns := iter.Columns()
|
||||
result := queryResult{
|
||||
Columns: columnNames(columns),
|
||||
ColumnTypes: columnTypeNames(columns),
|
||||
Rows: make([][]any, 0, min(maxRows, 1024)),
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
err := iter.Close()
|
||||
if err == nil && isSchemaChangingCQL(options.SQL) {
|
||||
s.runtime.invalidateMetadataSession()
|
||||
}
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
return result, err
|
||||
}
|
||||
for len(result.Rows) < maxRows {
|
||||
row, ok, scanErr := scanCQLRow(iter, columns)
|
||||
if scanErr != nil {
|
||||
_ = iter.Close()
|
||||
return queryResult{}, scanErr
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
result.Rows = append(result.Rows, row)
|
||||
}
|
||||
if len(result.Rows) == maxRows {
|
||||
_, hasExtra, scanErr := scanCQLRow(iter, columns)
|
||||
if scanErr != nil {
|
||||
_ = iter.Close()
|
||||
return queryResult{}, scanErr
|
||||
}
|
||||
result.Truncated = hasExtra
|
||||
}
|
||||
if err := iter.Close(); err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) executeQueryPage(options queryOptions, pageSize int) (queryPageResult, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = options.FetchSize
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
remaining := options.MaxRows
|
||||
if remaining <= 0 {
|
||||
remaining = defaultMaxRows
|
||||
}
|
||||
result, nextState, err := s.fetchCQLPage(options.SQL, s.keyspaceForOptions(options), nil, pageSize, remaining, options.TimeoutSecs)
|
||||
if err != nil {
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
remaining -= len(result.Rows)
|
||||
if len(nextState) == 0 || remaining <= 0 {
|
||||
result.HasMore = false
|
||||
result.Truncated = len(nextState) > 0 && remaining <= 0
|
||||
return result, nil
|
||||
}
|
||||
s.nextSessionID++
|
||||
id := fmt.Sprintf("cassandra-query-%d", s.nextSessionID)
|
||||
s.querySessions[id] = &querySession{
|
||||
sql: trimStatementSQL(options.SQL),
|
||||
keyspace: s.keyspaceForOptions(options),
|
||||
pageState: append([]byte(nil), nextState...),
|
||||
remaining: remaining,
|
||||
}
|
||||
result.SessionID = &id
|
||||
result.HasMore = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) fetchQueryPage(id string, pageSize int) (queryPageResult, error) {
|
||||
state := s.querySessions[id]
|
||||
if state == nil {
|
||||
return queryPageResult{}, fmt.Errorf("query session not found: %s", id)
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
result, nextState, err := s.fetchCQLPage(state.sql, state.keyspace, state.pageState, pageSize, state.remaining, 0)
|
||||
if err != nil {
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
state.remaining -= len(result.Rows)
|
||||
if len(nextState) == 0 || state.remaining <= 0 {
|
||||
delete(s.querySessions, id)
|
||||
result.HasMore = false
|
||||
result.Truncated = len(nextState) > 0 && state.remaining <= 0
|
||||
return result, nil
|
||||
}
|
||||
state.pageState = append(state.pageState[:0], nextState...)
|
||||
result.SessionID = &id
|
||||
result.HasMore = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) fetchCQLPage(sql, keyspace string, pageState []byte, pageSize, remaining, timeoutSecs int) (queryPageResult, []byte, error) {
|
||||
start := time.Now()
|
||||
if remaining < pageSize {
|
||||
pageSize = remaining
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
return queryPageResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}}, nil, nil
|
||||
}
|
||||
session, err := s.runtime.sessionFor(keyspace)
|
||||
if err != nil {
|
||||
return queryPageResult{}, nil, err
|
||||
}
|
||||
ctx, cancel := s.beginOperation(timeoutSecs)
|
||||
defer s.endOperation(cancel)
|
||||
iter := session.Query(trimStatementSQL(sql)).WithContext(ctx).PageSize(pageSize).PageState(pageState).Iter()
|
||||
columns := iter.Columns()
|
||||
result := queryPageResult{
|
||||
Columns: columnNames(columns),
|
||||
ColumnTypes: columnTypeNames(columns),
|
||||
Rows: make([][]any, 0, pageSize),
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
err := iter.Close()
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
return result, nil, err
|
||||
}
|
||||
for len(result.Rows) < pageSize {
|
||||
row, ok, scanErr := scanCQLRow(iter, columns)
|
||||
if scanErr != nil {
|
||||
_ = iter.Close()
|
||||
return queryPageResult{}, nil, scanErr
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
result.Rows = append(result.Rows, row)
|
||||
}
|
||||
nextState := append([]byte(nil), iter.PageState()...)
|
||||
if err := iter.Close(); err != nil {
|
||||
return queryPageResult{}, nil, err
|
||||
}
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
return result, nextState, nil
|
||||
}
|
||||
|
||||
func (s *server) closeQuerySession(id string) bool {
|
||||
if _, exists := s.querySessions[id]; !exists {
|
||||
return false
|
||||
}
|
||||
delete(s.querySessions, id)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *server) executeStatements(params map[string]json.RawMessage, transactional bool) (queryResult, error) {
|
||||
statements := stringSliceParam(params, "statements")
|
||||
if len(statements) == 0 {
|
||||
return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}}, nil
|
||||
}
|
||||
keyspace := strings.TrimSpace(stringParam(params, "schema"))
|
||||
if keyspace == "" {
|
||||
keyspace = strings.TrimSpace(stringParam(params, "database"))
|
||||
}
|
||||
if keyspace == "" {
|
||||
keyspace = strings.TrimSpace(s.params.Database)
|
||||
}
|
||||
session, err := s.runtime.sessionFor(keyspace)
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
batchType := gocql.UnloggedBatch
|
||||
if transactional {
|
||||
batchType = gocql.LoggedBatch
|
||||
}
|
||||
batch := session.NewBatch(batchType)
|
||||
for _, statement := range statements {
|
||||
statement = trimStatementSQL(statement)
|
||||
if statement != "" {
|
||||
batch.Query(statement)
|
||||
}
|
||||
}
|
||||
ctx, cancel := s.beginOperation(intParam(params, "timeoutSecs"))
|
||||
defer s.endOperation(cancel)
|
||||
start := time.Now()
|
||||
if err := session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
return queryResult{
|
||||
Columns: []string{},
|
||||
ColumnTypes: []string{},
|
||||
Rows: [][]any{},
|
||||
AffectedRows: 0,
|
||||
ExecutionTimeMS: time.Since(start).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) keyspaceForOptions(options queryOptions) string {
|
||||
if schema := strings.TrimSpace(options.Schema); schema != "" {
|
||||
return schema
|
||||
}
|
||||
if database := strings.TrimSpace(options.Database); database != "" {
|
||||
return database
|
||||
}
|
||||
return s.defaultKeyspace()
|
||||
}
|
||||
|
||||
func scanCQLRow(iter *gocql.Iter, columns []gocql.ColumnInfo) ([]any, bool, error) {
|
||||
destinations := make([]any, 0, len(columns))
|
||||
extractors := make([]func() any, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
if tuple, ok := column.TypeInfo.(gocql.TupleTypeInfo); ok {
|
||||
tupleDestinations := make([]*cqlDestination, 0, len(tuple.Elems))
|
||||
for _, element := range tuple.Elems {
|
||||
destination := newCQLDestination(element)
|
||||
tupleDestinations = append(tupleDestinations, destination)
|
||||
destinations = append(destinations, destination.destination)
|
||||
}
|
||||
extractors = append(extractors, func() any {
|
||||
values := make([]any, len(tupleDestinations))
|
||||
allNull := true
|
||||
for index, destination := range tupleDestinations {
|
||||
value, present := destination.value()
|
||||
if present {
|
||||
allNull = false
|
||||
values[index] = value
|
||||
}
|
||||
}
|
||||
if allNull {
|
||||
return nil
|
||||
}
|
||||
return normalizeCQLValue(values)
|
||||
})
|
||||
continue
|
||||
}
|
||||
destination := newCQLDestination(column.TypeInfo)
|
||||
destinations = append(destinations, destination.destination)
|
||||
extractors = append(extractors, func() any {
|
||||
value, present := destination.value()
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
return normalizeCQLValue(value)
|
||||
})
|
||||
}
|
||||
if !iter.Scan(destinations...) {
|
||||
return nil, false, nil
|
||||
}
|
||||
row := make([]any, len(columns))
|
||||
for index, extract := range extractors {
|
||||
row[index] = extract()
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
type cqlDestination struct {
|
||||
destination any
|
||||
holder reflect.Value
|
||||
fallback *any
|
||||
}
|
||||
|
||||
func newCQLDestination(typeInfo gocql.TypeInfo) *cqlDestination {
|
||||
zero := typeInfo.Zero()
|
||||
valueType := reflect.TypeOf(zero)
|
||||
if valueType == nil {
|
||||
var fallback any
|
||||
return &cqlDestination{destination: &fallback, fallback: &fallback}
|
||||
}
|
||||
holder := reflect.New(reflect.PointerTo(valueType))
|
||||
return &cqlDestination{destination: holder.Interface(), holder: holder}
|
||||
}
|
||||
|
||||
func (destination *cqlDestination) value() (any, bool) {
|
||||
if destination.fallback != nil {
|
||||
return *destination.fallback, *destination.fallback != nil
|
||||
}
|
||||
pointer := destination.holder.Elem()
|
||||
if pointer.IsNil() {
|
||||
return nil, false
|
||||
}
|
||||
return pointer.Elem().Interface(), true
|
||||
}
|
||||
|
||||
func columnNames(columns []gocql.ColumnInfo) []string {
|
||||
result := make([]string, len(columns))
|
||||
for index, column := range columns {
|
||||
result[index] = column.Name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func columnTypeNames(columns []gocql.ColumnInfo) []string {
|
||||
result := make([]string, len(columns))
|
||||
for index, column := range columns {
|
||||
result[index] = cqlTypeName(column.TypeInfo)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func trimStatementSQL(sql string) string {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
for strings.HasSuffix(trimmed, ";") {
|
||||
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func isSchemaChangingCQL(sql string) bool {
|
||||
fields := strings.Fields(trimStatementSQL(sql))
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
switch strings.ToUpper(fields[0]) {
|
||||
case "CREATE", "ALTER", "DROP":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRuntimePoolSize = 32
|
||||
defaultRuntimeMetadataLimit = 8
|
||||
operationPermitTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var errOperationCapacity = errors.New("agent operation capacity is temporarily exhausted")
|
||||
|
||||
type connectionRuntime struct {
|
||||
mu sync.Mutex
|
||||
config cassandraConfig
|
||||
sessions map[string]*gocql.Session
|
||||
retiredSessions []*gocql.Session
|
||||
permits chan struct{}
|
||||
metadataPermits chan struct{}
|
||||
activeOperations int
|
||||
references int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newConnectionRuntime(cp connectParams) (*connectionRuntime, error) {
|
||||
config, err := parseCassandraConfig(cp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
poolSize := runtimePoolSize()
|
||||
return &connectionRuntime{
|
||||
config: config,
|
||||
sessions: map[string]*gocql.Session{},
|
||||
permits: make(chan struct{}, poolSize),
|
||||
metadataPermits: make(chan struct{}, runtimeMetadataLimit(poolSize)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *connectionRuntime) sessionFor(keyspace string) (*gocql.Session, error) {
|
||||
keyspace = strings.TrimSpace(keyspace)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
return nil, errors.New("Cassandra connection runtime is closed")
|
||||
}
|
||||
if session := r.sessions[keyspace]; session != nil && !session.Closed() {
|
||||
return session, nil
|
||||
}
|
||||
cluster, err := r.config.clusterConfig(keyspace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := cluster.CreateSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.sessions[keyspace] = session
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (r *connectionRuntime) invalidateMetadataSession() {
|
||||
var retiredSession *gocql.Session
|
||||
r.mu.Lock()
|
||||
if session := r.sessions[""]; session != nil {
|
||||
delete(r.sessions, "")
|
||||
if r.activeOperations == 0 {
|
||||
retiredSession = session
|
||||
} else {
|
||||
r.retiredSessions = append(r.retiredSessions, session)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if retiredSession != nil {
|
||||
retiredSession.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *connectionRuntime) acquire(metadata bool) (func(), error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), operationPermitTimeout)
|
||||
defer cancel()
|
||||
metadataAcquired := false
|
||||
if metadata {
|
||||
select {
|
||||
case r.metadataPermits <- struct{}{}:
|
||||
metadataAcquired = true
|
||||
case <-ctx.Done():
|
||||
return nil, errOperationCapacity
|
||||
}
|
||||
}
|
||||
select {
|
||||
case r.permits <- struct{}{}:
|
||||
r.mu.Lock()
|
||||
r.activeOperations++
|
||||
r.mu.Unlock()
|
||||
return func() {
|
||||
var retiredSessions []*gocql.Session
|
||||
r.mu.Lock()
|
||||
r.activeOperations--
|
||||
if r.activeOperations == 0 && len(r.retiredSessions) > 0 {
|
||||
retiredSessions = r.retiredSessions
|
||||
r.retiredSessions = nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
<-r.permits
|
||||
if metadataAcquired {
|
||||
<-r.metadataPermits
|
||||
}
|
||||
for _, session := range retiredSessions {
|
||||
session.Close()
|
||||
}
|
||||
}, nil
|
||||
case <-ctx.Done():
|
||||
if metadataAcquired {
|
||||
<-r.metadataPermits
|
||||
}
|
||||
return nil, errOperationCapacity
|
||||
}
|
||||
}
|
||||
|
||||
func (r *connectionRuntime) close() {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.closed = true
|
||||
sessions := r.sessions
|
||||
retiredSessions := r.retiredSessions
|
||||
r.sessions = map[string]*gocql.Session{}
|
||||
r.retiredSessions = nil
|
||||
r.mu.Unlock()
|
||||
for _, session := range sessions {
|
||||
session.Close()
|
||||
}
|
||||
for _, session := range retiredSessions {
|
||||
session.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runtimeServer) acquireRuntime(cp connectParams) (*connectionRuntime, string, error) {
|
||||
key := connectionRuntimeKey(cp)
|
||||
r.runtimesMu.Lock()
|
||||
defer r.runtimesMu.Unlock()
|
||||
runtime := r.runtimes[key]
|
||||
if runtime == nil {
|
||||
var err error
|
||||
runtime, err = newConnectionRuntime(cp)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
r.runtimes[key] = runtime
|
||||
}
|
||||
runtime.references++
|
||||
return runtime, key, nil
|
||||
}
|
||||
|
||||
func (r *runtimeServer) releaseRuntime(key string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
r.runtimesMu.Lock()
|
||||
runtime := r.runtimes[key]
|
||||
shouldClose := false
|
||||
if runtime != nil && runtime.references > 0 {
|
||||
runtime.references--
|
||||
}
|
||||
if runtime != nil && runtime.references == 0 {
|
||||
delete(r.runtimes, key)
|
||||
shouldClose = true
|
||||
}
|
||||
r.runtimesMu.Unlock()
|
||||
if shouldClose {
|
||||
runtime.close()
|
||||
}
|
||||
}
|
||||
|
||||
func connectionRuntimeKey(cp connectParams) string {
|
||||
data, _ := json.Marshal(cp)
|
||||
digest := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", digest[:])
|
||||
}
|
||||
|
||||
func runtimePoolSize() int {
|
||||
value := defaultRuntimePoolSize
|
||||
if raw := os.Getenv("DBX_AGENT_CASSANDRA_MAX_CONCURRENT_OPERATIONS"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 1 && parsed <= 128 {
|
||||
value = parsed
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func runtimeMetadataLimit(poolSize int) int {
|
||||
value := min(defaultRuntimeMetadataLimit, poolSize)
|
||||
if raw := os.Getenv("DBX_AGENT_CASSANDRA_MAX_CONCURRENT_METADATA"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 1 && parsed <= poolSize {
|
||||
value = parsed
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func TestInvalidateMetadataSessionDefersCloseUntilOperationsFinish(t *testing.T) {
|
||||
runtime := &connectionRuntime{
|
||||
sessions: map[string]*gocql.Session{"": {}},
|
||||
permits: make(chan struct{}, 2),
|
||||
metadataPermits: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
releaseFirst, err := runtime.acquire(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseSecond, err := runtime.acquire(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadataSession := runtime.sessions[""]
|
||||
|
||||
runtime.invalidateMetadataSession()
|
||||
if metadataSession.Closed() {
|
||||
t.Fatal("metadata session closed while operations were active")
|
||||
}
|
||||
if len(runtime.retiredSessions) != 1 {
|
||||
t.Fatalf("unexpected retired session count: %d", len(runtime.retiredSessions))
|
||||
}
|
||||
|
||||
releaseFirst()
|
||||
if metadataSession.Closed() {
|
||||
t.Fatal("metadata session closed before the final operation completed")
|
||||
}
|
||||
|
||||
releaseSecond()
|
||||
if !metadataSession.Closed() {
|
||||
t.Fatal("metadata session was not closed after the final operation completed")
|
||||
}
|
||||
if len(runtime.retiredSessions) != 0 {
|
||||
t.Fatalf("retired sessions were not cleared: %d", len(runtime.retiredSessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateMetadataSessionClosesImmediatelyWithoutOperations(t *testing.T) {
|
||||
metadataSession := &gocql.Session{}
|
||||
runtime := &connectionRuntime{sessions: map[string]*gocql.Session{"": metadataSession}}
|
||||
|
||||
runtime.invalidateMetadataSession()
|
||||
|
||||
if !metadataSession.Closed() {
|
||||
t.Fatal("idle metadata session was not closed immediately")
|
||||
}
|
||||
if _, exists := runtime.sessions[""]; exists {
|
||||
t.Fatal("invalidated metadata session remains cached")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func normalizeCQLValue(value any) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cqlString(value)
|
||||
}
|
||||
|
||||
func cqlString(value any) string {
|
||||
if value == nil {
|
||||
return "null"
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case []byte:
|
||||
return "0x" + hex.EncodeToString(typed)
|
||||
case time.Time:
|
||||
return typed.Format(time.RFC3339Nano)
|
||||
case time.Duration:
|
||||
return typed.String()
|
||||
case gocql.Duration:
|
||||
return fmt.Sprintf("%dmo%dd%dns", typed.Months, typed.Days, typed.Nanoseconds)
|
||||
case gocql.UUID:
|
||||
return typed.String()
|
||||
case net.IP:
|
||||
return typed.String()
|
||||
case *big.Int:
|
||||
if typed == nil {
|
||||
return ""
|
||||
}
|
||||
return typed.String()
|
||||
case big.Int:
|
||||
return typed.String()
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
}
|
||||
valueOf := reflect.ValueOf(value)
|
||||
for valueOf.Kind() == reflect.Pointer {
|
||||
if valueOf.IsNil() {
|
||||
return ""
|
||||
}
|
||||
valueOf = valueOf.Elem()
|
||||
}
|
||||
switch valueOf.Kind() {
|
||||
case reflect.Map:
|
||||
entries := make([]string, 0, valueOf.Len())
|
||||
iterator := valueOf.MapRange()
|
||||
for iterator.Next() {
|
||||
entries = append(entries, cqlString(iterator.Key().Interface())+"="+cqlString(iterator.Value().Interface()))
|
||||
}
|
||||
sort.Strings(entries)
|
||||
return "{" + strings.Join(entries, ", ") + "}"
|
||||
case reflect.Slice, reflect.Array:
|
||||
values := make([]string, valueOf.Len())
|
||||
for index := range values {
|
||||
values[index] = cqlString(valueOf.Index(index).Interface())
|
||||
}
|
||||
return "[" + strings.Join(values, ", ") + "]"
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func cqlTypeName(typeInfo gocql.TypeInfo) string {
|
||||
if typeInfo == nil {
|
||||
return "unknown"
|
||||
}
|
||||
switch typed := typeInfo.(type) {
|
||||
case gocql.CollectionType:
|
||||
switch typed.Type() {
|
||||
case gocql.TypeMap:
|
||||
return "map<" + cqlTypeName(typed.Key) + ", " + cqlTypeName(typed.Elem) + ">"
|
||||
case gocql.TypeList:
|
||||
return "list<" + cqlTypeName(typed.Elem) + ">"
|
||||
case gocql.TypeSet:
|
||||
return "set<" + cqlTypeName(typed.Elem) + ">"
|
||||
}
|
||||
case gocql.TupleTypeInfo:
|
||||
parts := make([]string, len(typed.Elems))
|
||||
for index, element := range typed.Elems {
|
||||
parts[index] = cqlTypeName(element)
|
||||
}
|
||||
return "tuple<" + strings.Join(parts, ", ") + ">"
|
||||
case gocql.UDTTypeInfo:
|
||||
return quoteCQLIdentifier(typed.Name)
|
||||
case gocql.VectorType:
|
||||
return fmt.Sprintf("vector<%s, %d>", cqlTypeName(typed.SubType), typed.Dimensions)
|
||||
}
|
||||
names := map[gocql.Type]string{
|
||||
gocql.TypeCustom: "custom", gocql.TypeAscii: "ascii", gocql.TypeBigInt: "bigint",
|
||||
gocql.TypeBlob: "blob", gocql.TypeBoolean: "boolean", gocql.TypeCounter: "counter",
|
||||
gocql.TypeDecimal: "decimal", gocql.TypeDouble: "double", gocql.TypeFloat: "float",
|
||||
gocql.TypeInt: "int", gocql.TypeText: "text", gocql.TypeTimestamp: "timestamp",
|
||||
gocql.TypeUUID: "uuid", gocql.TypeVarchar: "text", gocql.TypeVarint: "varint",
|
||||
gocql.TypeTimeUUID: "timeuuid", gocql.TypeInet: "inet", gocql.TypeDate: "date",
|
||||
gocql.TypeTime: "time", gocql.TypeSmallInt: "smallint", gocql.TypeTinyInt: "tinyint",
|
||||
gocql.TypeDuration: "duration", gocql.TypeUDT: "udt", gocql.TypeTuple: "tuple",
|
||||
gocql.TypeList: "list", gocql.TypeMap: "map", gocql.TypeSet: "set",
|
||||
}
|
||||
if name := names[typeInfo.Type()]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
func TestNormalizeCQLValuePreservesLegacyStringContract(t *testing.T) {
|
||||
uuid, err := gocql.ParseUUID("00112233-4455-6677-8899-aabbccddeeff")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
value any
|
||||
want any
|
||||
}{
|
||||
{nil, nil},
|
||||
{42, "42"},
|
||||
{true, "true"},
|
||||
{[]byte{0x00, 0xff}, "0x00ff"},
|
||||
{uuid, "00112233-4455-6677-8899-aabbccddeeff"},
|
||||
{net.ParseIP("127.0.0.1"), "127.0.0.1"},
|
||||
{time.Date(2026, 8, 3, 12, 34, 56, 7, time.UTC), "2026-08-03T12:34:56.000000007Z"},
|
||||
{gocql.Duration{Months: 1, Days: 2, Nanoseconds: 3}, "1mo2d3ns"},
|
||||
{[]int{1, 2}, "[1, 2]"},
|
||||
{[]any{1, nil, "three"}, "[1, null, three]"},
|
||||
{map[string]int{"b": 2, "a": 1}, "{a=1, b=2}"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := normalizeCQLValue(test.value); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("normalizeCQLValue(%#v) = %#v, want %#v", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCQLTypeNameUsesCQLSyntax(t *testing.T) {
|
||||
typeInfo := gocql.NewNativeType(4, gocql.TypeList, "varchar")
|
||||
if got := cqlTypeName(typeInfo); got != "list<text>" {
|
||||
t.Fatalf("unexpected collection type name: %s", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.ing.data:cassandra-jdbc-wrapper:4.12.0'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
manifest {
|
||||
attributes('Agent-Label': 'Apache Cassandra', 'Main-Class': 'com.dbx.agent.cassandra.CassandraAgent')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
package com.dbx.agent.cassandra;
|
||||
|
||||
import com.dbx.agent.AbstractJdbcAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class CassandraAgent extends AbstractJdbcAgent {
|
||||
private static final Pattern TARGET_PATTERN = Pattern.compile("target[\"']?\\s*[:=]\\s*[\"']?([\\w]+)");
|
||||
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
return "com.ing.data.cassandra.jdbc.CassandraDriver";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String setSchemaSQL(String schema) {
|
||||
return "USE " + JdbcIdentifiers.INSTANCE.doubleQuote(schema);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
return unchecked(() -> {
|
||||
List<DatabaseInfo> result = new ArrayList<>();
|
||||
String sql = "SELECT keyspace_name FROM system_schema.keyspaces";
|
||||
try (java.sql.Statement stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
result.add(new DatabaseInfo(rs.getString(1)));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(DatabaseInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (DatabaseInfo database : listDatabases()) {
|
||||
result.add(database.getName());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return unchecked(() -> {
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
String sql = "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?";
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new TableInfo(rs.getString(1), "TABLE", null));
|
||||
}
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(TableInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
String sql = "SELECT column_name, type, kind FROM system_schema.columns WHERE keyspace_name = ? AND table_name = ?";
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String kind = coalesce(rs.getString("kind"));
|
||||
boolean isPrimaryKey = "partition_key".equals(kind) || "clustering".equals(kind);
|
||||
result.add(new ColumnInfo(
|
||||
rs.getString("column_name"),
|
||||
coalesce(rs.getString("type"), "unknown"),
|
||||
!isPrimaryKey,
|
||||
null,
|
||||
isPrimaryKey,
|
||||
kind.trim().isEmpty() ? null : kind,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
String sql = "SELECT index_name, options FROM system_schema.indexes WHERE keyspace_name = ? AND table_name = ?";
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String indexName = coalesce(rs.getString("index_name"));
|
||||
String options = coalesce(rs.getString("options"));
|
||||
result.add(new IndexInfo(
|
||||
indexName,
|
||||
targetColumns(options),
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(IndexInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object resultValue(ResultSet rs, int index, int sqlType) {
|
||||
return unchecked(() -> {
|
||||
Object value = rs.getObject(index);
|
||||
return rs.wasNull() ? null : value == null ? null : value.toString();
|
||||
});
|
||||
}
|
||||
|
||||
static String buildUrl(ConnectParams params) {
|
||||
String baseUrl = "jdbc:cassandra://" + params.getHost() + ":" + params.getPort();
|
||||
String keyspace = coalesce(params.getDatabase()).trim();
|
||||
// Cassandra rejects an empty keyspace path; omit it so DBX can connect first and list keyspaces.
|
||||
String url = keyspace.isEmpty() ? baseUrl : baseUrl + "/" + keyspace;
|
||||
// Multi-DC clusters require localdatacenter=<dc>
|
||||
String extraParams = coalesce(params.getUrl_params()).trim();
|
||||
while (extraParams.startsWith("?") || extraParams.startsWith("&")) {
|
||||
extraParams = extraParams.substring(1);
|
||||
}
|
||||
return extraParams.isEmpty() ? url : url + "?" + extraParams;
|
||||
}
|
||||
|
||||
private static List<String> targetColumns(String options) {
|
||||
Matcher matcher = TARGET_PATTERN.matcher(options);
|
||||
if (!matcher.find()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Collections.singletonList(matcher.group(1));
|
||||
}
|
||||
|
||||
private static String coalesce(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String coalesce(String value, String fallback) {
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(CassandraAgent::new).run();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package com.dbx.agent.cassandra;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class CassandraAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
@Override
|
||||
protected DatabaseAgent createAgent() {
|
||||
return new CassandraAgent();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resultSetSql() {
|
||||
return "LIST ROLES";
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsServerUrlWhenKeyspaceIsEmpty() {
|
||||
ConnectParams params = new ConnectParams("127.0.0.1", 9042, "", "cassandra", "cassandra", "", "", false);
|
||||
|
||||
assertEquals("jdbc:cassandra://127.0.0.1:9042", CassandraAgent.buildUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsKeyspaceUrlWhenKeyspaceIsSet() {
|
||||
ConnectParams params = new ConnectParams("127.0.0.1", 9042, "app_keyspace", "cassandra", "cassandra", "", "", false);
|
||||
|
||||
assertEquals("jdbc:cassandra://127.0.0.1:9042/app_keyspace", CassandraAgent.buildUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsUrlParamsForMultiDcLocalDatacenter() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"127.0.0.1", 9042, "app_keyspace", "cassandra", "cassandra", "localdatacenter=dc1", "", false
|
||||
);
|
||||
|
||||
assertEquals("jdbc:cassandra://127.0.0.1:9042/app_keyspace?localdatacenter=dc1", CassandraAgent.buildUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripsLeadingQuestionMarkFromUrlParams() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"127.0.0.1", 9042, "", "cassandra", "cassandra", "?localdatacenter=dc1", "", false
|
||||
);
|
||||
|
||||
assertEquals("jdbc:cassandra://127.0.0.1:9042?localdatacenter=dc1", CassandraAgent.buildUrl(params));
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,11 @@ import java.sql.DriverManager;
|
|||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.sql.SQLNonTransientConnectionException;
|
||||
import java.sql.SQLRecoverableException;
|
||||
import java.sql.SQLSyntaxErrorException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.sql.SQLXML;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
|
|
@ -102,6 +107,77 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
connectedUsername = params.getUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterPhysicalConnect(ConnectParams params, Connection connection) throws SQLException {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;");
|
||||
} catch (SQLException error) {
|
||||
if (!isIgnorableDbmsOutputError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isIgnorableDbmsOutputError(SQLException error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
if (current instanceof SQLException sqlError) {
|
||||
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
|
||||
if (isConnectionError(candidate)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
if (current instanceof SQLException sqlError) {
|
||||
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
|
||||
if (candidate instanceof SQLFeatureNotSupportedException || candidate instanceof SQLSyntaxErrorException) {
|
||||
return true;
|
||||
}
|
||||
String sqlState = candidate.getSQLState();
|
||||
if ("0A000".equalsIgnoreCase(sqlState)
|
||||
|| "42000".equalsIgnoreCase(sqlState)
|
||||
|| "42501".equalsIgnoreCase(sqlState)) {
|
||||
return true;
|
||||
}
|
||||
String message = candidate.getMessage();
|
||||
if (message != null && isDbmsOutputUnavailableMessage(message.toLowerCase(Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isConnectionError(SQLException error) {
|
||||
String sqlState = error.getSQLState();
|
||||
return error instanceof SQLNonTransientConnectionException
|
||||
|| error instanceof SQLRecoverableException
|
||||
|| error instanceof SQLTransientConnectionException
|
||||
|| (sqlState != null && sqlState.toUpperCase(Locale.ROOT).startsWith("08"));
|
||||
}
|
||||
|
||||
private static boolean isDbmsOutputUnavailableMessage(String message) {
|
||||
if (!message.contains("dbms_output")) {
|
||||
return false;
|
||||
}
|
||||
return message.contains("权限")
|
||||
|| message.contains("privilege")
|
||||
|| message.contains("permission")
|
||||
|| message.contains("access denied")
|
||||
|| message.contains("not authorized")
|
||||
|| message.contains("不支持")
|
||||
|| message.contains("unsupported")
|
||||
|| message.contains("not supported")
|
||||
|| message.contains("不存在")
|
||||
|| message.contains("not exist")
|
||||
|| message.contains("not found")
|
||||
|| message.contains("未找到")
|
||||
|| message.contains("undefined")
|
||||
|| message.contains("未定义");
|
||||
}
|
||||
|
||||
/**
|
||||
* The DM JDBC driver writes a banner to {@code System.out} during
|
||||
* {@code Class.forName} / driver initialization. This corrupts the
|
||||
|
|
@ -140,7 +216,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
return listVisibleSchemas();
|
||||
} catch (SQLException catalogError) {
|
||||
try {
|
||||
return listVisibleUsers();
|
||||
return listJdbcSchemas();
|
||||
} catch (Exception fallbackError) {
|
||||
catalogError.addSuppressed(fallbackError);
|
||||
throw catalogError;
|
||||
|
|
@ -162,6 +238,19 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
return result;
|
||||
}
|
||||
|
||||
private List<String> listJdbcSchemas() throws Exception {
|
||||
Set<String> schemas = new LinkedHashSet<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getSchemas()) {
|
||||
while (rs.next()) {
|
||||
String schema = rs.getString("TABLE_SCHEM");
|
||||
if (schema != null && !schema.isBlank()) {
|
||||
schemas.add(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
return schemas.stream().sorted().toList();
|
||||
}
|
||||
|
||||
private List<String> listVisibleSchemas() throws Exception {
|
||||
List<String> result = new ArrayList<>();
|
||||
String sql = "SELECT NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' ORDER BY NAME";
|
||||
|
|
@ -198,6 +287,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
try {
|
||||
return executeConstrainedTables(buildConstrainedTablesQuery(schema, constraints), constraints);
|
||||
} catch (RuntimeException e) {
|
||||
if (isDamengInvalidDatetimeMetadataError(e)) {
|
||||
return executeJdbcMetadataTables(schema, constraints);
|
||||
}
|
||||
if (!isDamengMetadataPermissionError(e)) {
|
||||
throw e;
|
||||
}
|
||||
|
|
@ -210,6 +302,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
constraints
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
if (isDamengInvalidDatetimeMetadataError(e)) {
|
||||
return executeJdbcMetadataTables(schema, constraints);
|
||||
}
|
||||
if (!isDamengMetadataPermissionError(e)) {
|
||||
throw e;
|
||||
}
|
||||
|
|
@ -223,6 +318,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
constraints
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
if (isDamengInvalidDatetimeMetadataError(e)) {
|
||||
return executeJdbcMetadataTables(schema, constraints);
|
||||
}
|
||||
if (!isDamengMetadataPermissionError(e)) {
|
||||
throw e;
|
||||
}
|
||||
|
|
@ -232,6 +330,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
try {
|
||||
return executeRawConstrainedTables(schema, constraints);
|
||||
} catch (RuntimeException e) {
|
||||
if (isDamengInvalidDatetimeMetadataError(e)) {
|
||||
return executeJdbcMetadataTables(schema, constraints);
|
||||
}
|
||||
if (!isDamengMetadataPermissionError(e)) {
|
||||
throw e;
|
||||
}
|
||||
|
|
@ -283,6 +384,21 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
.replace("%", escape + "%");
|
||||
}
|
||||
|
||||
private static boolean isDamengInvalidDatetimeMetadataError(Throwable error) {
|
||||
// DM7 ALL_OBJECTS casts SYSOBJINFOS.ALTTIME text to DATETIME and can fail on legacy catalog values.
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
if (!(current instanceof SQLException sqlError)) {
|
||||
continue;
|
||||
}
|
||||
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
|
||||
if (candidate.getErrorCode() == -6118) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isDamengMetadataPermissionError(Throwable error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
if (!(current instanceof SQLException sqlError)) {
|
||||
|
|
@ -297,7 +413,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
boolean metadataObject = normalized.contains("all_objects")
|
||||
|| normalized.contains("sysobjects")
|
||||
|| normalized.contains("all_dependencies")
|
||||
|| normalized.contains("all_tab_comments");
|
||||
|| normalized.contains("all_tab_comments")
|
||||
|| normalized.contains("dbms_metadata")
|
||||
|| normalized.contains("get_ddl");
|
||||
boolean permissionDenied = normalized.contains("权限")
|
||||
|| normalized.contains("privilege")
|
||||
|| normalized.contains("permission denied")
|
||||
|
|
@ -795,25 +913,37 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
|
||||
@Override
|
||||
public String getTableDdl(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
|
||||
String ddl = null;
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, "TABLE");
|
||||
stmt.setString(2, table);
|
||||
stmt.setString(3, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
ddl = coalesce(readTextColumn(rs, 1));
|
||||
try {
|
||||
return unchecked(() -> {
|
||||
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
|
||||
String ddl = null;
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, "TABLE");
|
||||
stmt.setString(2, table);
|
||||
stmt.setString(3, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
ddl = coalesce(readTextColumn(rs, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ddl != null) {
|
||||
ddl = appendTableAndColumnComments(ddl, schema, table);
|
||||
return appendIndependentIndexDdl(ddl, schema, table);
|
||||
}
|
||||
throw new IllegalArgumentException("Table not found: " + schema + "." + table);
|
||||
});
|
||||
} catch (RuntimeException error) {
|
||||
if (!isDamengMetadataPermissionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (ddl != null) {
|
||||
ddl = appendTableAndColumnComments(ddl, schema, table);
|
||||
return appendIndependentIndexDdl(ddl, schema, table);
|
||||
try {
|
||||
return super.getTableDdl(schema, table);
|
||||
} catch (RuntimeException fallbackError) {
|
||||
fallbackError.addSuppressed(error);
|
||||
throw fallbackError;
|
||||
}
|
||||
throw new IllegalArgumentException("Table not found: " + schema + "." + table);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -1026,13 +1156,38 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
sql,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
() -> "",
|
||||
options.getMaxRows(),
|
||||
options.getFetchSize(),
|
||||
options.getTimeoutSecs(),
|
||||
this::resultValue
|
||||
this::resultValue,
|
||||
DamengAgent::statementPrintMessages
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> statementPrintMessages(Statement statement) {
|
||||
try {
|
||||
Object target = statement;
|
||||
Method method;
|
||||
try {
|
||||
method = statement.getClass().getMethod("getPrintMsg");
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
// Pooled connections expose a Hikari proxy rather than DmdbStatement directly.
|
||||
Class<?> damengStatementClass = Class.forName("dm.jdbc.driver.DmdbStatement");
|
||||
target = statement.unwrap(damengStatementClass);
|
||||
method = damengStatementClass.getMethod("getPrintMsg");
|
||||
}
|
||||
Object value = method.invoke(target);
|
||||
if (!(value instanceof String)) {
|
||||
return List.of();
|
||||
}
|
||||
String message = (String) value;
|
||||
return message.isEmpty() ? List.of() : message.lines().toList();
|
||||
} catch (Exception ignored) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private QueryResult executeExplainQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
return explainQueryResult(sql, schema, options.getTimeoutSecs(), options.getMaxRows());
|
||||
}
|
||||
|
|
@ -1125,7 +1280,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
|||
schema,
|
||||
this::setSchemaSQL,
|
||||
options,
|
||||
this::resultValue
|
||||
this::resultValue,
|
||||
DamengAgent::statementPrintMessages
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,31 @@ class DamengAgentMetadataTest {
|
|||
Assertions.assertEquals(List.of("catalog=null,schema=APP\\_DATA\\%2026,table=%,types=null"), jdbcMetadataCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackImmediatelyWhenAllObjectsContainsInvalidDatetimeMetadata() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
List<String> jdbcMetadataCalls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, restrictedTableConnection(
|
||||
sqls,
|
||||
jdbcMetadataCalls,
|
||||
List.of(
|
||||
List.of("VIEW_B", "VIEW", "view comment"),
|
||||
List.of("TABLE_A", "TABLE", "table comment"),
|
||||
List.of("MTAB$_INTERNAL", "TABLE", "internal table")
|
||||
),
|
||||
null,
|
||||
new SQLException("非法的时间日期类型数据", "22015", -6118)
|
||||
));
|
||||
MetadataListConstraints constraints = new MetadataListConstraints(null, 20, null, List.of("TABLE"));
|
||||
|
||||
List<TableInfo> tables = agent.listTables("APP", constraints);
|
||||
|
||||
Assertions.assertEquals(List.of("TABLE_A"), tables.stream().map(TableInfo::getName).toList());
|
||||
Assertions.assertEquals(1, sqls.size(), String.join("\n", sqls));
|
||||
Assertions.assertEquals(List.of("catalog=null,schema=APP,table=%,types=null"), jdbcMetadataCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyWhenRestrictedSchemaJdbcMetadataHasNoTables() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
|
@ -341,17 +366,31 @@ class DamengAgentMetadataTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void listSchemasFallsBackToAllUsersWithoutSysObjectsPrivilege() {
|
||||
void listSchemasFallsBackToJdbcMetadataWithoutSysObjectsPrivilege() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, restrictedSchemaConnection(sqls));
|
||||
List<String> jdbcMetadataCalls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, restrictedSchemaConnection(sqls, jdbcMetadataCalls, null));
|
||||
|
||||
List<String> schemas = agent.listSchemas();
|
||||
|
||||
Assertions.assertEquals(List.of("APP", "REPORTING", "SYSDBA"), schemas);
|
||||
Assertions.assertEquals(2, sqls.size(), String.join("\n", sqls));
|
||||
Assertions.assertEquals(List.of("APP", "REPORTING", "REPORTING_ARCHIVE", "SYSDBA"), schemas);
|
||||
Assertions.assertEquals(1, sqls.size(), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.get(0).contains("SYS.SYSOBJECTS"), sqls.get(0));
|
||||
Assertions.assertTrue(sqls.get(1).contains("ALL_USERS"), sqls.get(1));
|
||||
Assertions.assertEquals(List.of("getSchemas"), jdbcMetadataCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSchemasPreservesCatalogErrorWhenJdbcMetadataFails() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
SQLException metadataError = new SQLException("JDBC metadata getSchemas failed");
|
||||
TestSupport.setPrivateConnection(agent, restrictedSchemaConnection(new ArrayList<>(), new ArrayList<>(), metadataError));
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, agent::listSchemas);
|
||||
|
||||
Assertions.assertEquals("no SYS.SYSOBJECTS privilege", error.getCause().getMessage());
|
||||
Assertions.assertEquals(1, error.getCause().getSuppressed().length);
|
||||
Assertions.assertSame(metadataError, error.getCause().getSuppressed()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -610,6 +649,42 @@ class DamengAgentMetadataTest {
|
|||
Assertions.assertTrue(ddl.contains("DBX_FULL_DDL_END"), ddl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToGeneratedTableDdlWhenDbmsMetadataPermissionIsDenied() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnectionWithDbmsMetadataError(
|
||||
sqls,
|
||||
"没有[SYS.DBMS_METADATA.GET_DDL]对象的执行权限"
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
|
||||
Assertions.assertTrue(ddl.contains("\"ID\" NUMBER(10) NOT NULL"), ddl);
|
||||
Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TAB_COLUMNS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesNonPermissionDbmsMetadataErrorsWithoutFallback() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnectionWithDbmsMetadataError(
|
||||
sqls,
|
||||
"DBMS_METADATA.GET_DDL connection reset"
|
||||
));
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.getTableDdl("APP", "USERS")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("DBMS_METADATA.GET_DDL connection reset", error.getCause().getMessage());
|
||||
Assertions.assertEquals(1, sqls.size());
|
||||
Assertions.assertTrue(sqls.get(0).contains("DBMS_METADATA.GET_DDL"), sqls.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsIndependentIndexesToTableDdl() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
|
@ -685,6 +760,19 @@ class DamengAgentMetadataTest {
|
|||
);
|
||||
}
|
||||
|
||||
private static Connection metadataConnectionWithDbmsMetadataError(List<String> sqls, String message) {
|
||||
return metadataConnection(
|
||||
"id comment",
|
||||
null,
|
||||
false,
|
||||
List.of(),
|
||||
sqls,
|
||||
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
|
||||
defaultColumnMetadataRows("id comment"),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection metadataConnection(
|
||||
String allColumnComment,
|
||||
String fallbackColumnComment,
|
||||
|
|
@ -734,6 +822,28 @@ class DamengAgentMetadataTest {
|
|||
List<String> sqls,
|
||||
String dbmsMetadataDdl,
|
||||
List<List<Object>> columnRows
|
||||
) {
|
||||
return metadataConnection(
|
||||
allColumnComment,
|
||||
fallbackColumnComment,
|
||||
includeMaterializedView,
|
||||
independentIndexes,
|
||||
sqls,
|
||||
dbmsMetadataDdl,
|
||||
columnRows,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection metadataConnection(
|
||||
String allColumnComment,
|
||||
String fallbackColumnComment,
|
||||
boolean includeMaterializedView,
|
||||
List<List<Object>> independentIndexes,
|
||||
List<String> sqls,
|
||||
String dbmsMetadataDdl,
|
||||
List<List<Object>> columnRows,
|
||||
String dbmsMetadataError
|
||||
) {
|
||||
boolean[] dbmsMetadataResultOpen = {false};
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
|
|
@ -747,6 +857,9 @@ class DamengAgentMetadataTest {
|
|||
sqls.add(sql);
|
||||
}
|
||||
if (sql.contains("DBMS_METADATA.GET_DDL")) {
|
||||
if (dbmsMetadataError != null) {
|
||||
return failingMetadataStatement(dbmsMetadataError);
|
||||
}
|
||||
return dbmsMetadataStatement(dbmsMetadataDdl, dbmsMetadataResultOpen);
|
||||
}
|
||||
if (sql.startsWith("SELECT NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH'")) {
|
||||
|
|
@ -966,6 +1079,22 @@ class DamengAgentMetadataTest {
|
|||
List<List<Object>> rows,
|
||||
SQLException jdbcMetadataError,
|
||||
String catalogError
|
||||
) {
|
||||
return restrictedTableConnection(
|
||||
sqls,
|
||||
jdbcMetadataCalls,
|
||||
rows,
|
||||
jdbcMetadataError,
|
||||
new SQLException(catalogError)
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection restrictedTableConnection(
|
||||
List<String> sqls,
|
||||
List<String> jdbcMetadataCalls,
|
||||
List<List<Object>> rows,
|
||||
SQLException jdbcMetadataError,
|
||||
SQLException catalogError
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
|
|
@ -1013,7 +1142,11 @@ class DamengAgentMetadataTest {
|
|||
});
|
||||
}
|
||||
|
||||
private static Connection restrictedSchemaConnection(List<String> sqls) {
|
||||
private static Connection restrictedSchemaConnection(
|
||||
List<String> sqls,
|
||||
List<String> jdbcMetadataCalls,
|
||||
SQLException jdbcMetadataError
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("prepareStatement".equals(name)) {
|
||||
|
|
@ -1022,11 +1155,11 @@ class DamengAgentMetadataTest {
|
|||
if (sql.contains("SYS.SYSOBJECTS")) {
|
||||
return failingMetadataStatement("no SYS.SYSOBJECTS privilege");
|
||||
}
|
||||
if (sql.contains("ALL_USERS")) {
|
||||
return metadataStatement(List.of(List.of("APP"), List.of("REPORTING"), List.of("SYSDBA")));
|
||||
}
|
||||
throw new AssertionError("Unexpected SQL: " + sql);
|
||||
}
|
||||
if ("getMetaData".equals(name)) {
|
||||
return jdbcSchemaMetadata(jdbcMetadataCalls, jdbcMetadataError);
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1037,6 +1170,25 @@ class DamengAgentMetadataTest {
|
|||
});
|
||||
}
|
||||
|
||||
private static DatabaseMetaData jdbcSchemaMetadata(List<String> calls, SQLException failure) {
|
||||
return proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("getSchemas".equals(method.getName())) {
|
||||
calls.add("getSchemas");
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
return metadataResultSet(List.of(
|
||||
List.of("REPORTING_ARCHIVE"),
|
||||
List.of("APP"),
|
||||
List.of("REPORTING"),
|
||||
List.of("SYSDBA"),
|
||||
List.of("APP")
|
||||
));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection schemaConnection(List<String> params) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
|
|
@ -1054,9 +1206,13 @@ class DamengAgentMetadataTest {
|
|||
}
|
||||
|
||||
private static PreparedStatement failingMetadataStatement(String message) {
|
||||
return failingMetadataStatement(new SQLException(message));
|
||||
}
|
||||
|
||||
private static PreparedStatement failingMetadataStatement(SQLException error) {
|
||||
return proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
throw new SQLException(message);
|
||||
throw error;
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
return null;
|
||||
|
|
@ -1109,7 +1265,7 @@ class DamengAgentMetadataTest {
|
|||
return value == null ? null : value.toString();
|
||||
}
|
||||
return switch (((String) args[0]).toUpperCase()) {
|
||||
case "TABLE_NAME", "OBJECT_NAME" -> string(rows, index[0], 0);
|
||||
case "TABLE_NAME", "TABLE_SCHEM", "OBJECT_NAME" -> string(rows, index[0], 0);
|
||||
case "TABLE_TYPE", "OBJECT_TYPE" -> string(rows, index[0], 1);
|
||||
case "COLUMN_NAME" -> string(rows, index[0], 0);
|
||||
case "DATA_TYPE" -> string(rows, index[0], 1);
|
||||
|
|
|
|||
|
|
@ -12,11 +12,22 @@ import com.dbx.agent.test.JdbcAgentFake;
|
|||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
|
@ -47,6 +58,80 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
|||
assertEquals(List.of("executeQuery"), JdbcAgentFake.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsEnableDbmsOutputWithoutChangingUserSql() throws Exception {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
||||
agent.afterPhysicalConnect(null, printMessageConnection(null, executedSql));
|
||||
|
||||
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), executedSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsIgnoreUnsupportedOrRestrictedDbmsOutput() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
||||
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
|
||||
null,
|
||||
failingDbmsOutputConnection(new SQLFeatureNotSupportedException("unsupported", "0A000"))
|
||||
));
|
||||
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
|
||||
null,
|
||||
failingDbmsOutputConnection(new SQLException("permission denied", "42000"))
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsPropagateConnectionFailures() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
SQLException transientFailure = new SQLTransientConnectionException("connection closed");
|
||||
SQLException sqlStateFailure = new SQLException("connection failure", "08006");
|
||||
SQLException wrappedFailure = new SQLException("permission denied", "42000");
|
||||
wrappedFailure.initCause(new SQLTransientConnectionException("connection closed"));
|
||||
|
||||
assertSame(transientFailure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(transientFailure))
|
||||
));
|
||||
assertSame(sqlStateFailure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(sqlStateFailure))
|
||||
));
|
||||
assertSame(wrappedFailure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(wrappedFailure))
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsPropagateUnrelatedSetupFailures() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
SQLException failure = new SQLException("resource busy", "HY000");
|
||||
|
||||
assertSame(failure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(failure))
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeQueryReturnsDamengPrintMessagesForLogOnlyProcedures() {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection("first\n中文日志\n", executedSql));
|
||||
|
||||
QueryResult result = agent.executeQuery(
|
||||
"CALL LOG_ONLY_PROCEDURE('input')",
|
||||
null,
|
||||
new ExecuteQueryOptions()
|
||||
);
|
||||
|
||||
assertEquals(List.of("Message"), result.getColumns());
|
||||
assertEquals(List.of(List.of("first"), List.of("中文日志")), result.getRows());
|
||||
assertEquals(List.of("CALL LOG_ONLY_PROCEDURE('input')"), executedSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeQueryPageReturnsPlanRowsForExplainStatements() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
|
@ -274,4 +359,67 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
|||
assertTrue(query.sql().endsWith("LIMIT ? OFFSET ?"));
|
||||
assertEquals(List.of("REPORTING", "VIEW", "MATERIALIZED_VIEW", "%S%A%L%E%S%", 10, 30), query.args());
|
||||
}
|
||||
|
||||
private static Connection printMessageConnection(String printMessage, List<String> executedSql) {
|
||||
return statementConnection(printMessage, executedSql, null);
|
||||
}
|
||||
|
||||
private static Connection failingDbmsOutputConnection(SQLException failure) {
|
||||
return statementConnection(null, new ArrayList<>(), failure);
|
||||
}
|
||||
|
||||
private static Connection statementConnection(
|
||||
String printMessage,
|
||||
List<String> executedSql,
|
||||
SQLException executeFailure
|
||||
) {
|
||||
InvocationHandler statementHandler = (Object unused, Method method, Object[] args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute":
|
||||
if (executeFailure != null) {
|
||||
throw executeFailure;
|
||||
}
|
||||
executedSql.add((String) args[0]);
|
||||
return false;
|
||||
case "getPrintMsg":
|
||||
return printMessage;
|
||||
case "getUpdateCount":
|
||||
return -1;
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
};
|
||||
Statement statement = (Statement) Proxy.newProxyInstance(
|
||||
DamengAgentTest.class.getClassLoader(),
|
||||
new Class<?>[]{Statement.class, PrintMessageStatement.class},
|
||||
statementHandler
|
||||
);
|
||||
InvocationHandler connectionHandler = (Object unused, Method method, Object[] args) -> {
|
||||
if (method.getName().equals("createStatement")) {
|
||||
return statement;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
};
|
||||
return (Connection) Proxy.newProxyInstance(
|
||||
DamengAgentTest.class.getClassLoader(),
|
||||
new Class<?>[]{Connection.class},
|
||||
connectionHandler
|
||||
);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == Boolean.TYPE) return false;
|
||||
if (type == Byte.TYPE) return (byte) 0;
|
||||
if (type == Short.TYPE) return (short) 0;
|
||||
if (type == Integer.TYPE) return 0;
|
||||
if (type == Long.TYPE) return 0L;
|
||||
if (type == Float.TYPE) return 0f;
|
||||
if (type == Double.TYPE) return 0.0d;
|
||||
if (type == Character.TYPE) return '\0';
|
||||
return null;
|
||||
}
|
||||
|
||||
public interface PrintMessageStatement {
|
||||
String getPrintMsg();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
|
@ -7,7 +7,8 @@ existing newline-delimited JSON worker runtime, while keeping DuckDB and
|
|||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --manifest-path agents/drivers/duckdb/Cargo.toml --release --bin dbx-duckdb-driver
|
||||
cd agents/drivers/duckdb
|
||||
cargo build --release --bin dbx-duckdb-driver
|
||||
```
|
||||
|
||||
Point DBX at the resulting executable with:
|
||||
|
|
@ -35,6 +36,9 @@ DBX decompresses the package itself, so users do not need to install `zstd`,
|
|||
DuckDB, or a separate database driver. The existing aggregate offline `.zip`
|
||||
packages remain supported for backward compatibility.
|
||||
|
||||
Windows MSVC artifacts statically link the Visual C++ runtime so they also run
|
||||
on fresh Windows installations without a separate redistributable package.
|
||||
|
||||
## Current scope
|
||||
|
||||
The driver implements connect, execute, database/schema/table/column metadata,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,22 @@ mod tests {
|
|||
assert_eq!(result.rows[2][0], serde_json::json!(99.999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_execute_returns_dml_returning_rows() {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
con.execute_batch("CREATE TABLE users (id INTEGER, name VARCHAR)").expect("create table");
|
||||
|
||||
let inserted = duckdb_execute(&con, "INSERT INTO users VALUES (1, 'Ada') RETURNING id, name")
|
||||
.expect("insert returning");
|
||||
let updated = duckdb_execute(&con, "UPDATE users SET name = 'Ada Lovelace' RETURNING id, name")
|
||||
.expect("update returning");
|
||||
let deleted = duckdb_execute(&con, "DELETE FROM users RETURNING id, name").expect("delete returning");
|
||||
|
||||
assert_eq!(inserted.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada")]]);
|
||||
assert_eq!(updated.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada Lovelace")]]);
|
||||
assert_eq!(deleted.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada Lovelace")]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_execute_returns_rows_for_from_first_query() {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
|
|
|
|||
|
|
@ -202,6 +202,62 @@ pub fn starts_with_duckdb_result_sql_keyword(sql: &str) -> bool {
|
|||
.any(|keyword| {
|
||||
token.eq_ignore_ascii_case(keyword) || (*keyword == "DESCRIBE" && token.eq_ignore_ascii_case("DESC"))
|
||||
})
|
||||
|| matches!(token.to_ascii_uppercase().as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE")
|
||||
&& contains_unquoted_sql_keyword(sql, "RETURNING")
|
||||
}
|
||||
|
||||
fn contains_unquoted_sql_keyword(sql: &str, keyword: &str) -> bool {
|
||||
let bytes = sql.as_bytes();
|
||||
let mut index = 0;
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b'\'' if !in_double => {
|
||||
if in_single && bytes.get(index + 1) == Some(&b'\'') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
in_single = !in_single;
|
||||
index += 1;
|
||||
}
|
||||
b'"' if !in_single => {
|
||||
if in_double && bytes.get(index + 1) == Some(&b'"') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
in_double = !in_double;
|
||||
index += 1;
|
||||
}
|
||||
b'-' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'-') => {
|
||||
index += 2;
|
||||
while index < bytes.len() && bytes[index] != b'\n' {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
b'/' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'*') => {
|
||||
index += 2;
|
||||
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
|
||||
index += 1;
|
||||
}
|
||||
index = (index + 2).min(bytes.len());
|
||||
}
|
||||
byte if !in_single && !in_double && (byte.is_ascii_alphabetic() || byte == b'_') => {
|
||||
let start = index;
|
||||
index += 1;
|
||||
while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') {
|
||||
index += 1;
|
||||
}
|
||||
if sql[start..index].eq_ignore_ascii_case(keyword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => index += 1,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn first_executable_sql_token(sql: &str) -> Option<&str> {
|
||||
|
|
@ -269,6 +325,8 @@ mod tests {
|
|||
fn detects_result_statements_after_comments() {
|
||||
assert!(starts_with_duckdb_result_sql_keyword("/* note */ WITH rows AS (SELECT 1) SELECT * FROM rows"));
|
||||
assert!(starts_with_duckdb_result_sql_keyword("DESC SELECT 1"));
|
||||
assert!(starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1) RETURNING id"));
|
||||
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items(note) VALUES ('RETURNING')"));
|
||||
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1)"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,6 +227,13 @@ async fn worker_process_recovers_after_parser_error() {
|
|||
.await
|
||||
.expect("create events table");
|
||||
|
||||
let typed_err = client
|
||||
.execute_typed(None, "select * from table limit 19;".to_string(), Some(10), None, Some(Duration::from_secs(5)))
|
||||
.await
|
||||
.expect_err("reserved word query should fail");
|
||||
assert_eq!(typed_err.code, "duckdb_execute_failed");
|
||||
assert!(typed_err.message.contains("Parser Error"), "unexpected error: {}", typed_err.message);
|
||||
|
||||
let err = client
|
||||
.execute(None, "select * from table limit 19;".to_string(), Some(10), None, Some(Duration::from_secs(5)))
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ public final class EtcdAgent {
|
|||
private static final Gson GSON = new Gson();
|
||||
private static final int DEFAULT_LIMIT = 100;
|
||||
private static final int RPC_TIMEOUT_SECONDS = 30;
|
||||
static final int DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE = 32 * 1024 * 1024;
|
||||
static final int MIN_GRPC_MAX_INBOUND_MESSAGE_SIZE = 1024 * 1024;
|
||||
static final int MAX_GRPC_MAX_INBOUND_MESSAGE_SIZE = 256 * 1024 * 1024;
|
||||
private static final String GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY = "grpc_max_inbound_message_size";
|
||||
private static final int PRESERVE_LEASE_MAX_ATTEMPTS = 3;
|
||||
private static final long HISTORY_DEFAULT_REVISION_WINDOW = 10_000L;
|
||||
private static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
|
||||
|
|
@ -167,7 +171,8 @@ public final class EtcdAgent {
|
|||
List<String> endpoints = endpoints(connection);
|
||||
ClientBuilder builder = Client.builder()
|
||||
.endpoints(endpoints.toArray(String[]::new))
|
||||
.connectTimeout(Duration.ofSeconds(connectTimeoutSeconds(connection)));
|
||||
.connectTimeout(Duration.ofSeconds(connectTimeoutSeconds(connection)))
|
||||
.maxInboundMessageSize(grpcMaxInboundMessageSize(connection));
|
||||
String username = stringOrEmpty(connection, "username");
|
||||
String password = stringOrEmpty(connection, "password");
|
||||
if (!username.isBlank()) {
|
||||
|
|
@ -184,6 +189,19 @@ public final class EtcdAgent {
|
|||
return Math.min(300, Math.max(1, intOrDefault(connection, "connect_timeout_secs", RPC_TIMEOUT_SECONDS)));
|
||||
}
|
||||
|
||||
static int grpcMaxInboundMessageSize(JsonObject connection) {
|
||||
int configured = intOrDefault(
|
||||
connection,
|
||||
GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY,
|
||||
intUrlParamOrDefault(
|
||||
stringOrEmpty(connection, "url_params"),
|
||||
GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY,
|
||||
DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE
|
||||
)
|
||||
);
|
||||
return Math.min(MAX_GRPC_MAX_INBOUND_MESSAGE_SIZE, Math.max(MIN_GRPC_MAX_INBOUND_MESSAGE_SIZE, configured));
|
||||
}
|
||||
|
||||
private static Map<String, Object> validateConnectedClient() throws Exception {
|
||||
EtcdSessionState state = sessionState();
|
||||
Client active = requireClient();
|
||||
|
|
@ -1630,6 +1648,26 @@ public final class EtcdAgent {
|
|||
return element == null || element.isJsonNull() ? fallback : element.getAsInt();
|
||||
}
|
||||
|
||||
private static int intUrlParamOrDefault(String params, String key, int fallback) {
|
||||
if (params == null || params.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
for (String entry : params.replaceFirst("^\\?", "").split("&")) {
|
||||
int separator = entry.indexOf('=');
|
||||
String entryKey = separator < 0 ? entry : entry.substring(0, separator);
|
||||
if (!key.equals(entryKey)) {
|
||||
continue;
|
||||
}
|
||||
String value = separator < 0 ? "" : entry.substring(separator + 1);
|
||||
try {
|
||||
return Integer.parseInt(value);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) {
|
||||
JsonElement element = object.get(key);
|
||||
return element == null || element.isJsonNull() ? fallback : element.getAsBoolean();
|
||||
|
|
|
|||
|
|
@ -89,6 +89,41 @@ final class EtcdAgentTest {
|
|||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void grpcInboundLimitDefaultsTo32MiBAndUsesSafeBounds() {
|
||||
Assertions.assertEquals(
|
||||
32 * 1024 * 1024,
|
||||
EtcdAgent.grpcMaxInboundMessageSize(new JsonObject())
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
64 * 1024 * 1024,
|
||||
EtcdAgent.grpcMaxInboundMessageSize(
|
||||
JsonParser.parseString("{\"grpc_max_inbound_message_size\":67108864}").getAsJsonObject()
|
||||
)
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
1024 * 1024,
|
||||
EtcdAgent.grpcMaxInboundMessageSize(
|
||||
JsonParser.parseString("{\"grpc_max_inbound_message_size\":0}").getAsJsonObject()
|
||||
)
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
256 * 1024 * 1024,
|
||||
EtcdAgent.grpcMaxInboundMessageSize(
|
||||
JsonParser.parseString("{\"grpc_max_inbound_message_size\":536870912}").getAsJsonObject()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void grpcInboundLimitCanBeConfiguredThroughConnectionUrlParams() {
|
||||
JsonObject connection = JsonParser.parseString(
|
||||
"{\"url_params\":\"foo=bar&grpc_max_inbound_message_size=50331648\"}"
|
||||
).getAsJsonObject();
|
||||
|
||||
Assertions.assertEquals(48 * 1024 * 1024, EtcdAgent.grpcMaxInboundMessageSize(connection));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateConnectionRequiresAnActiveSession() {
|
||||
String response = EtcdAgent.handleRequest(
|
||||
|
|
|
|||
|
|
@ -46,11 +46,17 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
|||
private String tableCacheSchema = "";
|
||||
private long tableCacheTimeMillis;
|
||||
private List<TableInfo> tableCache = Collections.emptyList();
|
||||
private ConnectParams databaseListParams;
|
||||
|
||||
public Gbase8sAgent() {
|
||||
super(GBASE8S_PROFILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsConnectionPooling() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static String buildUrl(ConnectParams params) {
|
||||
if (!params.getConnection_string().trim().isEmpty()) {
|
||||
return params.getConnection_string();
|
||||
|
|
@ -78,6 +84,38 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
|||
return defaultGbaseServer(params.getHost());
|
||||
}
|
||||
|
||||
static String buildUrlForDatabase(ConnectParams params, String database) {
|
||||
return buildUrl(paramsForDatabase(params, database));
|
||||
}
|
||||
|
||||
private static ConnectParams paramsForDatabase(ConnectParams params, String database) {
|
||||
String connectionString = trim(params.getConnection_string());
|
||||
if (!connectionString.isEmpty()) {
|
||||
int schemeEnd = connectionString.indexOf("://");
|
||||
int databaseStart = schemeEnd < 0 ? -1 : connectionString.indexOf('/', schemeEnd + 3);
|
||||
if (databaseStart >= 0) {
|
||||
int paramsStart = connectionString.indexOf(':', databaseStart + 1);
|
||||
String suffix = paramsStart >= 0 ? connectionString.substring(paramsStart) : "";
|
||||
connectionString = connectionString.substring(0, databaseStart + 1) + database + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
ConnectParams databaseParams = new ConnectParams(
|
||||
params.getHost(),
|
||||
params.getPort(),
|
||||
database,
|
||||
params.getUsername(),
|
||||
params.getPassword(),
|
||||
params.getUrl_params(),
|
||||
connectionString,
|
||||
params.isMysql_compat_mode(),
|
||||
params.getJdbc_driver_class(),
|
||||
params.getJdbc_driver_paths()
|
||||
);
|
||||
databaseParams.setGbase_server(getGbaseServer(params));
|
||||
return databaseParams;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return buildUrl(params);
|
||||
|
|
@ -86,11 +124,13 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
|||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
databaseListParams = paramsForDatabase(params, "sysmaster");
|
||||
clearMetadataCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterDisconnect() {
|
||||
databaseListParams = null;
|
||||
clearMetadataCache();
|
||||
}
|
||||
|
||||
|
|
@ -109,13 +149,7 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
|||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
List<String> names = queryDatabaseNamesInCatalog("sysmaster", "SELECT name FROM sysdatabases ORDER BY name");
|
||||
if (names.isEmpty()) {
|
||||
names = queryDatabaseNames("SELECT name FROM sysmaster:sysdatabases ORDER BY name");
|
||||
}
|
||||
if (names.isEmpty()) {
|
||||
names = queryDatabaseNames("SELECT name FROM sysdatabases ORDER BY name");
|
||||
}
|
||||
List<String> names = queryDatabaseNamesFromSysmaster();
|
||||
if (names.isEmpty()) {
|
||||
return super.listDatabases();
|
||||
}
|
||||
|
|
@ -135,8 +169,14 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
|||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Connection connection = requireConnection();
|
||||
if (!connection.getMetaData().supportsSchemasInDataManipulation()) {
|
||||
List<String> schemas = Collections.emptyList();
|
||||
cacheSchemas(catalog, schemas);
|
||||
return schemas;
|
||||
}
|
||||
Set<String> schemas = new LinkedHashSet<>();
|
||||
try (PreparedStatement stmt = requireConnection().prepareStatement(
|
||||
try (PreparedStatement stmt = connection.prepareStatement(
|
||||
"SELECT DISTINCT owner FROM systables WHERE tabid >= 100 AND tabtype IN ('T', 'V') ORDER BY owner"
|
||||
); ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
|
|
@ -419,33 +459,13 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
|||
return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private List<String> queryDatabaseNames(String sql) {
|
||||
try {
|
||||
return queryDatabaseNames(requireConnection(), sql);
|
||||
} catch (Exception ignored) {
|
||||
private List<String> queryDatabaseNamesFromSysmaster() {
|
||||
ConnectParams params = databaseListParams;
|
||||
if (params == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> queryDatabaseNamesInCatalog(String catalog, String sql) {
|
||||
try {
|
||||
Connection connection = requireConnection();
|
||||
String previousCatalog = "";
|
||||
try {
|
||||
previousCatalog = trim(connection.getCatalog());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
connection.setCatalog(catalog);
|
||||
try {
|
||||
return queryDatabaseNames(connection, sql);
|
||||
} finally {
|
||||
if (!previousCatalog.isEmpty()) {
|
||||
try {
|
||||
connection.setCatalog(previousCatalog);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
try (Connection connection = openInitializedConnection(params)) {
|
||||
return queryDatabaseNames(connection, "SELECT name FROM sysdatabases ORDER BY name");
|
||||
} catch (Exception ignored) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import java.lang.reflect.InvocationHandler;
|
|||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -31,6 +32,7 @@ class Gbase8sAgentTest {
|
|||
Assertions.assertEquals("jdbc:gbasedbt-sqli://{host}:{port}/{database}:GBASEDBTSERVER=gbase8s", agent.getProfile().getUrlTemplate());
|
||||
Assertions.assertEquals(9088, agent.getProfile().getDefaultPort());
|
||||
Assertions.assertTrue(agent.getProfile().getSkipExecutionContext());
|
||||
Assertions.assertFalse(agent.supportsConnectionPooling());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -114,6 +116,71 @@ class Gbase8sAgentTest {
|
|||
Assertions.assertEquals("jdbc:gbasedbt-sqli://db.example.com:20013/app:GBASEDBTSERVER=gbase01", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsSysmasterUrlWithoutChangingTheConfiguredDatabase() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"db.example.com",
|
||||
20013,
|
||||
"appdb",
|
||||
"user",
|
||||
"password",
|
||||
"CLIENT_LOCALE=zh_cn.utf8",
|
||||
"",
|
||||
false
|
||||
);
|
||||
params.setGbase_server("gbase01");
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:gbasedbt-sqli://db.example.com:20013/sysmaster:GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8",
|
||||
Gbase8sAgent.buildUrlForDatabase(params, "sysmaster")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacesDatabaseInCustomConnectionStringForDatabaseListing() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
"user",
|
||||
"password",
|
||||
"",
|
||||
"jdbc:gbasedbt-sqli://db.example.com:20013/appdb:GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8",
|
||||
false
|
||||
);
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:gbasedbt-sqli://db.example.com:20013/sysmaster:GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8",
|
||||
Gbase8sAgent.buildUrlForDatabase(params, "sysmaster")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsOwnerSchemasWhenTheDatabaseCannotUseThemInDml() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
Gbase8sAgent agent = new Gbase8sAgent();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
schemaConnection(false, sql, resultSet(new String[]{"owner"}, new Object[][]{{"gbasedbt"}}))
|
||||
);
|
||||
|
||||
Assertions.assertTrue(agent.listSchemas().isEmpty());
|
||||
Assertions.assertTrue(sql.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOwnerSchemasWhenTheDatabaseSupportsThemInDml() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
Gbase8sAgent agent = new Gbase8sAgent();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
schemaConnection(true, sql, resultSet(new String[]{"owner"}, new Object[][]{{"gbasedbt"}}))
|
||||
);
|
||||
|
||||
Assertions.assertEquals(List.of("gbasedbt"), agent.listSchemas());
|
||||
Assertions.assertEquals(1, sql.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constrainedListTablesUsesGbase8sSystemTableQuery() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
|
|
@ -367,6 +434,37 @@ class Gbase8sAgentTest {
|
|||
});
|
||||
}
|
||||
|
||||
private static Connection schemaConnection(boolean supportsSchemasInDml, List<String> sql, ResultSet resultSet) {
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("supportsSchemasInDataManipulation".equals(method.getName())) {
|
||||
return supportsSchemasInDml;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
return resultSet;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("getCatalog".equals(method.getName())) {
|
||||
return "appdb";
|
||||
}
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return metadata;
|
||||
}
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
sql.add(String.valueOf(args[0]));
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static ResultSet resultSet(String[] columns, Object[][] rows) {
|
||||
int[] index = {-1};
|
||||
Object[] lastValue = {null};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ public final class HighgoAgent extends PostgresLikeAgent {
|
|||
super(HIGHGO_PROFILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String setSchemaSQL(String schema) {
|
||||
if ("public".equals(schema)) {
|
||||
return super.setSchemaSQL(schema);
|
||||
}
|
||||
return super.setSchemaSQL(schema) + ", public";
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(HighgoAgent::new).run();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ class HighgoAgentTest extends JdbcFakeExecutionBehaviorTest {
|
|||
Assertions.assertEquals("jdbc:highgo://{host}:{port}/{database}", agent.getProfile().getUrlTemplate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesPublicFunctionsWhenSwitchingSchemas() {
|
||||
HighgoAgent agent = new HighgoAgent();
|
||||
|
||||
Assertions.assertEquals("SET search_path TO \"app\", public", agent.setSchemaSQL("app"));
|
||||
Assertions.assertEquals("SET search_path TO \"public\"", agent.setSchemaSQL("public"));
|
||||
Assertions.assertEquals("SET search_path TO \"PUBLIC\", public", agent.setSchemaSQL("PUBLIC"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsViewSourceWithQuotedRegclassParameter() {
|
||||
HighgoAgent agent = new HighgoAgent();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import com.dbx.agent.QueryResult;
|
|||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
|
|
@ -86,6 +87,26 @@ public final class HiveAgent extends AbstractJdbcAgent {
|
|||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTableDdl(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
String qualifiedName = schema == null || schema.trim().isEmpty()
|
||||
? JdbcIdentifiers.INSTANCE.backtick(table)
|
||||
: JdbcIdentifiers.INSTANCE.backtick(schema) + "." + JdbcIdentifiers.INSTANCE.backtick(table);
|
||||
try (Statement stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SHOW CREATE TABLE " + qualifiedName)) {
|
||||
StringBuilder ddl = new StringBuilder();
|
||||
while (rs.next()) {
|
||||
String line = rs.getString(1);
|
||||
if (line != null) {
|
||||
ddl.append(line).append('\n');
|
||||
}
|
||||
}
|
||||
return ddl.toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
package com.dbx.agent.hive;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class HiveAgentTest {
|
||||
|
|
@ -39,4 +49,79 @@ class HiveAgentTest {
|
|||
HiveAgent.buildUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTableDdlUsesHiveShowCreateTable() {
|
||||
HiveAgent agent = new HiveAgent();
|
||||
List<String> queries = new ArrayList<>();
|
||||
String expectedDdl = "CREATE TABLE `hive_test`.`cleaned_data_table`(col string)\n"
|
||||
+ "ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'\n"
|
||||
+ "LOCATION 'hdfs://warehouse/cleaned_data_table'";
|
||||
ResultSet resultSet = proxy(ResultSet.class, new InvocationHandler() {
|
||||
private int row = -1;
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) {
|
||||
if ("next".equals(method.getName())) {
|
||||
return ++row == 0;
|
||||
}
|
||||
if ("getString".equals(method.getName())) {
|
||||
return expectedDdl;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Statement statement = proxy(Statement.class, (proxy, method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
queries.add((String) args[0]);
|
||||
return resultSet;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
Connection connection = proxy(Connection.class, (proxy, method, args) -> {
|
||||
if ("createStatement".equals(method.getName())) {
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
|
||||
assertEquals(expectedDdl + "\n", agent.getTableDdl("hive_test", "cleaned_data_table"));
|
||||
assertEquals(List.of("SHOW CREATE TABLE `hive_test`.`cleaned_data_table`"), queries);
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ import java.sql.SQLException;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class InformixAgent extends AbstractJdbcAgent {
|
||||
private String loginOwner = "";
|
||||
|
|
@ -187,28 +187,25 @@ public final class InformixAgent extends AbstractJdbcAgent {
|
|||
}
|
||||
}
|
||||
}
|
||||
return mergeSchemaOwners(catalogOwners, loginOwner);
|
||||
return normalizeSchemaOwners(catalogOwners);
|
||||
});
|
||||
}
|
||||
|
||||
static String schemaCatalogSql() {
|
||||
// Informix JDBC catalogs are databases; schemas are the object owners in the current database.
|
||||
// Informix schemas are object owners. Include routine-only owners because
|
||||
// the same sidebar node also exposes procedures and functions.
|
||||
return "SELECT owner FROM systables WHERE tabid >= 100 AND owner IS NOT NULL "
|
||||
+ "UNION SELECT owner FROM sysprocedures WHERE owner IS NOT NULL ORDER BY owner";
|
||||
}
|
||||
|
||||
static List<String> mergeSchemaOwners(List<String> catalogOwners, String loginOwner) {
|
||||
Set<String> owners = new TreeSet<>();
|
||||
static List<String> normalizeSchemaOwners(List<String> catalogOwners) {
|
||||
Set<String> owners = new LinkedHashSet<>();
|
||||
for (String owner : catalogOwners) {
|
||||
String normalized = normalizeOwner(owner);
|
||||
if (!normalized.isEmpty()) {
|
||||
owners.add(normalized);
|
||||
}
|
||||
}
|
||||
String normalizedLoginOwner = normalizeOwner(loginOwner);
|
||||
if (!normalizedLoginOwner.isEmpty()) {
|
||||
owners.add(normalizedLoginOwner);
|
||||
}
|
||||
return new ArrayList<>(owners);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ class InformixAgentTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void listsSchemasFromTableRoutineAndCurrentLoginOwners() {
|
||||
void listsSchemasFromTableAndRoutineOwnersWithoutLoginFallback() {
|
||||
InformixAgent agent = new InformixAgent();
|
||||
java.sql.Connection connection = JdbcMetadataSqlFake.connection();
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
|
|
@ -181,7 +181,7 @@ class InformixAgentTest {
|
|||
params.setUsername("current_owner");
|
||||
agent.afterConnect(params, connection);
|
||||
|
||||
Assertions.assertEquals(List.of("current_owner"), agent.listSchemas());
|
||||
Assertions.assertEquals(List.of(), agent.listSchemas());
|
||||
|
||||
Assertions.assertEquals(
|
||||
List.of("SELECT owner FROM systables WHERE tabid >= 100 AND owner IS NOT NULL "
|
||||
|
|
@ -189,12 +189,11 @@ class InformixAgentTest {
|
|||
JdbcMetadataSqlFake.statements
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
List.of("current_owner", "routine_owner", "table_owner"),
|
||||
InformixAgent.mergeSchemaOwners(
|
||||
List.of("table_owner", "routine_owner", "routine_owner", " "),
|
||||
"current_owner"
|
||||
)
|
||||
List.of("table_owner", "routine_owner"),
|
||||
InformixAgent.normalizeSchemaOwners(List.of("table_owner", "routine_owner", "routine_owner", " "))
|
||||
);
|
||||
Assertions.assertTrue(InformixAgent.schemaCatalogSql().contains("sysprocedures"));
|
||||
Assertions.assertFalse(InformixAgent.normalizeSchemaOwners(List.of("routine_owner", " ")).contains("current_owner"));
|
||||
Assertions.assertNotEquals(InformixAgent.databaseCatalogSql(), InformixAgent.schemaCatalogSql());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ public final class KafkaAgent {
|
|||
private static final PrintStream JSON_RPC_OUT = System.out;
|
||||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||
private static final int DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||
private static final int MAX_PEEK_MESSAGE_COUNT = 100;
|
||||
private static final int MAX_PEEK_SCAN_RECORDS = 1_000;
|
||||
private static final int DEFAULT_SESSION_TIMEOUT_MS = 30_000;
|
||||
private static final int DEFAULT_ZOOKEEPER_CONNECTION_TIMEOUT_MS = 10_000;
|
||||
private static final String ZOOKEEPER_PROPERTY_PREFIX = "zookeeper.";
|
||||
|
|
@ -1025,31 +1027,43 @@ public final class KafkaAgent {
|
|||
String topic = stringOrEmpty(params, "topic");
|
||||
Integer partition = integerOrNull(params, "partition");
|
||||
Long offset = longOrNull(params, "offset");
|
||||
int count = Math.max(1, intOrDefault(params, "count", 10));
|
||||
int count = validatedPeekCount(intOrDefault(params, "count", 10));
|
||||
PeekStartPosition startPosition = peekStartPosition(params);
|
||||
boolean explicitStartPosition = stringOrNull(params, "startPosition") != null;
|
||||
validatePeekRequest(startPosition, explicitStartPosition, partition, offset);
|
||||
boolean legacyOffsetRequest = !explicitStartPosition && offset != null;
|
||||
|
||||
JsonObject conn = activeConnection;
|
||||
if (conn == null) {
|
||||
throw new IllegalStateException("Kafka Agent is not connected");
|
||||
}
|
||||
Properties props = peekConsumerProperties(conn, count);
|
||||
Duration requestTimeout = Duration.ofMillis(peekRequestTimeoutMs(conn, props));
|
||||
|
||||
try (KafkaConsumer<String, byte[]> consumer = new KafkaConsumer<>(props)) {
|
||||
List<TopicPartition> candidatePartitions = resolvePeekPartitions(consumer, topic, partition);
|
||||
List<TopicPartition> candidatePartitions = resolvePeekPartitions(
|
||||
consumer, topic, partition, requestTimeout
|
||||
);
|
||||
if (candidatePartitions.isEmpty()) {
|
||||
return Collections.singletonMap("messages", Collections.emptyList());
|
||||
return peekMessagesResult(Collections.emptyList(), false);
|
||||
}
|
||||
|
||||
Map<TopicPartition, Long> beginningOffsets =
|
||||
consumer.beginningOffsets(candidatePartitions, Duration.ofSeconds(5));
|
||||
consumer.beginningOffsets(candidatePartitions, requestTimeout);
|
||||
Map<TopicPartition, Long> endOffsets =
|
||||
consumer.endOffsets(candidatePartitions, Duration.ofSeconds(5));
|
||||
consumer.endOffsets(candidatePartitions, requestTimeout);
|
||||
|
||||
List<TopicPartition> readablePartitions = new ArrayList<>();
|
||||
Map<TopicPartition, Long> seekOffsets = new LinkedHashMap<>();
|
||||
for (TopicPartition tp : candidatePartitions) {
|
||||
long beginningOffset = beginningOffsets.getOrDefault(tp, 0L);
|
||||
long endOffset = endOffsets.getOrDefault(tp, beginningOffset);
|
||||
long requestedOffset = offset != null ? offset : beginningOffset;
|
||||
Long requestedOffset = requestedPeekOffset(
|
||||
startPosition, offset, legacyOffsetRequest, beginningOffset, endOffset
|
||||
);
|
||||
if (requestedOffset == null) {
|
||||
continue;
|
||||
}
|
||||
Long seekOffset = normalizePeekOffset(requestedOffset, beginningOffset, endOffset);
|
||||
if (seekOffset == null) {
|
||||
continue;
|
||||
|
|
@ -1058,7 +1072,23 @@ public final class KafkaAgent {
|
|||
seekOffsets.put(tp, seekOffset);
|
||||
}
|
||||
if (readablePartitions.isEmpty()) {
|
||||
return Collections.singletonMap("messages", Collections.emptyList());
|
||||
return peekMessagesResult(Collections.emptyList(), false);
|
||||
}
|
||||
|
||||
int messagesPerPartition = peekMessagesPerPartition(count, readablePartitions.size());
|
||||
int scanLimit = peekScanLimit(count, readablePartitions.size());
|
||||
Map<TopicPartition, Long> snapshotEndOffsets = new LinkedHashMap<>();
|
||||
if (startPosition == PeekStartPosition.LATEST) {
|
||||
for (TopicPartition tp : readablePartitions) {
|
||||
long beginningOffset = beginningOffsets.getOrDefault(tp, 0L);
|
||||
long endOffset = endOffsets.getOrDefault(tp, beginningOffset);
|
||||
seekOffsets.put(tp, recentPeekStartOffset(
|
||||
beginningOffset, endOffset, messagesPerPartition
|
||||
));
|
||||
}
|
||||
}
|
||||
for (TopicPartition tp : readablePartitions) {
|
||||
snapshotEndOffsets.put(tp, endOffsets.getOrDefault(tp, 0L));
|
||||
}
|
||||
|
||||
consumer.assign(readablePartitions);
|
||||
|
|
@ -1066,27 +1096,89 @@ public final class KafkaAgent {
|
|||
consumer.seek(entry.getKey(), entry.getValue());
|
||||
}
|
||||
|
||||
List<Map<String, Object>> messages = collectPeekedMessages(
|
||||
timeout -> consumer.poll(timeout),
|
||||
() -> {
|
||||
Map<TopicPartition, Long> positions = new LinkedHashMap<>();
|
||||
for (TopicPartition tp : readablePartitions) {
|
||||
positions.put(tp, consumer.position(tp));
|
||||
}
|
||||
return allPeekPartitionsCaughtUp(readablePartitions, positions, endOffsets);
|
||||
},
|
||||
count,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(500)
|
||||
);
|
||||
sortPeekedMessages(messages);
|
||||
long deadlineNs = System.nanoTime() + requestTimeout.toNanos();
|
||||
Duration pollTimeout = Duration.ofMillis(Math.min(500L, requestTimeout.toMillis()));
|
||||
PeekCollectionState collection;
|
||||
if (startPosition == PeekStartPosition.LATEST) {
|
||||
collection = collectLatestPeekedMessages(
|
||||
consumer,
|
||||
readablePartitions,
|
||||
beginningOffsets,
|
||||
seekOffsets,
|
||||
snapshotEndOffsets,
|
||||
messagesPerPartition,
|
||||
scanLimit,
|
||||
deadlineNs,
|
||||
pollTimeout
|
||||
);
|
||||
} else {
|
||||
PeekCollectionCompletionChecker snapshotComplete = state -> allPeekPartitionsComplete(
|
||||
readablePartitions,
|
||||
state.remainingByPartition,
|
||||
currentPeekPositions(consumer, readablePartitions),
|
||||
snapshotEndOffsets
|
||||
);
|
||||
collection = new PeekCollectionState(readablePartitions, messagesPerPartition);
|
||||
collection.incomplete = !collectPeekedMessages(
|
||||
timeout -> consumer.poll(timeout),
|
||||
snapshotComplete,
|
||||
record -> recordIsBeforeEndOffset(record, snapshotEndOffsets),
|
||||
collection,
|
||||
scanLimit,
|
||||
deadlineNs,
|
||||
pollTimeout
|
||||
);
|
||||
}
|
||||
List<Map<String, Object>> messages = collection.messages;
|
||||
sortPeekedMessages(messages, startPosition);
|
||||
if (messages.size() > count) {
|
||||
messages = new ArrayList<>(messages.subList(0, count));
|
||||
}
|
||||
return Collections.singletonMap("messages", messages);
|
||||
return peekMessagesResult(messages, collection.incomplete);
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, Object> peekMessagesResult(List<Map<String, Object>> messages, boolean incomplete) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("messages", messages);
|
||||
result.put("incomplete", incomplete);
|
||||
return result;
|
||||
}
|
||||
|
||||
static int validatedPeekCount(int count) {
|
||||
if (count < 1 || count > MAX_PEEK_MESSAGE_COUNT) {
|
||||
throw new IllegalArgumentException(
|
||||
"Peek message count must be between 1 and " + MAX_PEEK_MESSAGE_COUNT
|
||||
);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
static int peekRequestTimeoutMs(JsonObject conn, Properties props) {
|
||||
Integer connectionTimeout = integerOrNull(conn, "request_timeout_ms");
|
||||
if (connectionTimeout != null) {
|
||||
return positiveTimeoutMs("request_timeout_ms", connectionTimeout);
|
||||
}
|
||||
String configuredTimeout = props.getProperty(
|
||||
ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG,
|
||||
String.valueOf(DEFAULT_REQUEST_TIMEOUT_MS)
|
||||
);
|
||||
try {
|
||||
return positiveTimeoutMs(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG, Integer.parseInt(configuredTimeout));
|
||||
} catch (NumberFormatException error) {
|
||||
throw new IllegalArgumentException(
|
||||
ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG + " must be a positive integer", error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static int positiveTimeoutMs(String name, int timeoutMs) {
|
||||
if (timeoutMs <= 0) {
|
||||
throw new IllegalArgumentException(name + " must be a positive integer");
|
||||
}
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
static Properties peekConsumerProperties(JsonObject conn, int count) {
|
||||
Properties props = new Properties();
|
||||
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers(conn));
|
||||
|
|
@ -1103,19 +1195,45 @@ public final class KafkaAgent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Poll until {@code count} messages are collected, every assigned partition has reached its
|
||||
* end offset, or {@code deadlineNs} expires. Empty polls retry until caught-up or deadline —
|
||||
* they must not abort early (broker / network / first-fetch latency can exceed one poll).
|
||||
* Reads until each partition supplies its share of the page or reaches the snapshot boundary.
|
||||
* This counts retained records, rather than treating an offset range as a record count.
|
||||
*/
|
||||
static List<Map<String, Object>> collectPeekedMessages(
|
||||
PeekRecordPoller poller,
|
||||
PeekCaughtUpChecker caughtUpChecker,
|
||||
int count,
|
||||
PeekRecordFilter recordFilter,
|
||||
List<TopicPartition> partitions,
|
||||
int messagesPerPartition,
|
||||
int maxScanRecords,
|
||||
long deadlineNs,
|
||||
Duration pollTimeout
|
||||
) {
|
||||
List<Map<String, Object>> messages = new ArrayList<>();
|
||||
while (messages.size() < count && System.nanoTime() < deadlineNs) {
|
||||
PeekCollectionState collection = new PeekCollectionState(partitions, messagesPerPartition);
|
||||
collectPeekedMessages(
|
||||
poller,
|
||||
state -> state.allPartitionQuotasSatisfied() || caughtUpChecker.allPartitionsCaughtUp(),
|
||||
recordFilter,
|
||||
collection,
|
||||
maxScanRecords,
|
||||
deadlineNs,
|
||||
pollTimeout
|
||||
);
|
||||
return collection.messages;
|
||||
}
|
||||
|
||||
private static boolean collectPeekedMessages(
|
||||
PeekRecordPoller poller,
|
||||
PeekCollectionCompletionChecker completionChecker,
|
||||
PeekRecordFilter recordFilter,
|
||||
PeekCollectionState collection,
|
||||
int maxScanRecords,
|
||||
long deadlineNs,
|
||||
Duration pollTimeout
|
||||
) {
|
||||
while (System.nanoTime() < deadlineNs) {
|
||||
if (completionChecker.isComplete(collection)) {
|
||||
return true;
|
||||
}
|
||||
long remainingNs = deadlineNs - System.nanoTime();
|
||||
if (remainingNs <= 0) {
|
||||
break;
|
||||
|
|
@ -1125,19 +1243,205 @@ public final class KafkaAgent {
|
|||
: pollTimeout;
|
||||
ConsumerRecords<String, byte[]> records = poller.poll(timeout);
|
||||
if (records.isEmpty()) {
|
||||
if (caughtUpChecker.allPartitionsCaughtUp()) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (ConsumerRecord<String, byte[]> record : records) {
|
||||
messages.add(peekedMessageFromRecord(record));
|
||||
if (messages.size() >= count) {
|
||||
break;
|
||||
if (++collection.scannedRecords > maxScanRecords) {
|
||||
return false;
|
||||
}
|
||||
TopicPartition partition = new TopicPartition(record.topic(), record.partition());
|
||||
int remaining = collection.remainingByPartition.getOrDefault(partition, 0);
|
||||
if (remaining <= 0 || !recordFilter.include(record)) {
|
||||
continue;
|
||||
}
|
||||
collection.messages.add(peekedMessageFromRecord(record));
|
||||
collection.remainingByPartition.put(partition, remaining - 1);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
return completionChecker.isComplete(collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts at the snapshot tail and widens backward when compacted or retained-offset gaps
|
||||
* leave a partition short of its record quota.
|
||||
*/
|
||||
private static PeekCollectionState collectLatestPeekedMessages(
|
||||
KafkaConsumer<String, byte[]> consumer,
|
||||
List<TopicPartition> partitions,
|
||||
Map<TopicPartition, Long> beginningOffsets,
|
||||
Map<TopicPartition, Long> initialSeekOffsets,
|
||||
Map<TopicPartition, Long> snapshotEndOffsets,
|
||||
int messagesPerPartition,
|
||||
int maxScanRecords,
|
||||
long deadlineNs,
|
||||
Duration pollTimeout
|
||||
) {
|
||||
Map<TopicPartition, Long> rangeStartOffsets = new LinkedHashMap<>(initialSeekOffsets);
|
||||
Map<TopicPartition, Long> rangeEndOffsets = new LinkedHashMap<>(snapshotEndOffsets);
|
||||
Map<TopicPartition, Long> rangeWidths = new LinkedHashMap<>();
|
||||
for (TopicPartition partition : partitions) {
|
||||
long rangeStart = rangeStartOffsets.getOrDefault(partition, 0L);
|
||||
long rangeEnd = rangeEndOffsets.getOrDefault(partition, rangeStart);
|
||||
rangeWidths.put(partition, Math.max(1L, rangeEnd - rangeStart));
|
||||
}
|
||||
|
||||
PeekCollectionState collection = new PeekCollectionState(partitions, messagesPerPartition);
|
||||
while (!collection.allPartitionQuotasSatisfied()) {
|
||||
PeekCollectionCompletionChecker rangeComplete = state -> allPeekPartitionsComplete(
|
||||
partitions,
|
||||
state.remainingByPartition,
|
||||
currentPeekPositions(consumer, partitions),
|
||||
rangeEndOffsets
|
||||
);
|
||||
if (!collectLatestPeekRange(
|
||||
timeout -> consumer.poll(timeout),
|
||||
rangeComplete,
|
||||
record -> recordIsBeforeEndOffset(record, rangeEndOffsets),
|
||||
collection,
|
||||
maxScanRecords,
|
||||
deadlineNs,
|
||||
pollTimeout
|
||||
)) {
|
||||
collection.incomplete = true;
|
||||
break;
|
||||
}
|
||||
if (collection.allPartitionQuotasSatisfied()) {
|
||||
break;
|
||||
}
|
||||
|
||||
boolean expanded = false;
|
||||
for (TopicPartition partition : partitions) {
|
||||
if (collection.remainingByPartition.getOrDefault(partition, 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
long beginningOffset = beginningOffsets.getOrDefault(partition, 0L);
|
||||
long currentStart = rangeStartOffsets.getOrDefault(partition, beginningOffset);
|
||||
if (currentStart <= beginningOffset) {
|
||||
continue;
|
||||
}
|
||||
long nextStart = previousLatestPeekStartOffset(
|
||||
beginningOffset,
|
||||
currentStart,
|
||||
rangeWidths.getOrDefault(partition, 1L)
|
||||
);
|
||||
rangeEndOffsets.put(partition, currentStart);
|
||||
rangeStartOffsets.put(partition, nextStart);
|
||||
rangeWidths.put(partition, Math.max(1L, currentStart - nextStart));
|
||||
consumer.seek(partition, nextStart);
|
||||
expanded = true;
|
||||
}
|
||||
if (!expanded) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans one backward-expanded range to its end, retaining only each partition's newest
|
||||
* remaining records. Stopping early would select older records from a widened range.
|
||||
*/
|
||||
private static boolean collectLatestPeekRange(
|
||||
PeekRecordPoller poller,
|
||||
PeekCollectionCompletionChecker rangeComplete,
|
||||
PeekRecordFilter recordFilter,
|
||||
PeekCollectionState collection,
|
||||
int maxScanRecords,
|
||||
long deadlineNs,
|
||||
Duration pollTimeout
|
||||
) {
|
||||
Map<TopicPartition, Deque<Map<String, Object>>> rangeMessages = new HashMap<>();
|
||||
while (System.nanoTime() < deadlineNs) {
|
||||
if (rangeComplete.isComplete(collection)) {
|
||||
break;
|
||||
}
|
||||
long remainingNs = deadlineNs - System.nanoTime();
|
||||
if (remainingNs <= 0) {
|
||||
break;
|
||||
}
|
||||
Duration timeout = pollTimeout.toNanos() > remainingNs
|
||||
? Duration.ofNanos(remainingNs)
|
||||
: pollTimeout;
|
||||
ConsumerRecords<String, byte[]> records = poller.poll(timeout);
|
||||
for (ConsumerRecord<String, byte[]> record : records) {
|
||||
if (++collection.scannedRecords > maxScanRecords) {
|
||||
commitLatestPeekRange(rangeMessages, collection);
|
||||
return false;
|
||||
}
|
||||
TopicPartition partition = new TopicPartition(record.topic(), record.partition());
|
||||
int remaining = collection.remainingByPartition.getOrDefault(partition, 0);
|
||||
if (remaining <= 0 || !recordFilter.include(record)) {
|
||||
continue;
|
||||
}
|
||||
Deque<Map<String, Object>> latestRecords = rangeMessages.computeIfAbsent(
|
||||
partition,
|
||||
ignored -> new ArrayDeque<>()
|
||||
);
|
||||
retainLatestPeekRecord(latestRecords, peekedMessageFromRecord(record), remaining);
|
||||
}
|
||||
}
|
||||
boolean complete = rangeComplete.isComplete(collection);
|
||||
commitLatestPeekRange(rangeMessages, collection);
|
||||
return complete;
|
||||
}
|
||||
|
||||
private static void commitLatestPeekRange(
|
||||
Map<TopicPartition, Deque<Map<String, Object>>> rangeMessages,
|
||||
PeekCollectionState collection
|
||||
) {
|
||||
for (Map.Entry<TopicPartition, Deque<Map<String, Object>>> entry : rangeMessages.entrySet()) {
|
||||
int retainedCount = entry.getValue().size();
|
||||
collection.messages.addAll(entry.getValue());
|
||||
collection.remainingByPartition.computeIfPresent(
|
||||
entry.getKey(),
|
||||
(ignored, remaining) -> remaining - retainedCount
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static <T> void retainLatestPeekRecord(Deque<T> records, T record, int maxRecords) {
|
||||
records.addLast(record);
|
||||
if (records.size() > maxRecords) {
|
||||
records.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean recordIsBeforeEndOffset(
|
||||
ConsumerRecord<String, byte[]> record,
|
||||
Map<TopicPartition, Long> endOffsets
|
||||
) {
|
||||
Long endOffset = endOffsets.get(new TopicPartition(record.topic(), record.partition()));
|
||||
return endOffset != null && record.offset() < endOffset;
|
||||
}
|
||||
|
||||
private static Map<TopicPartition, Long> currentPeekPositions(
|
||||
KafkaConsumer<String, byte[]> consumer,
|
||||
List<TopicPartition> partitions
|
||||
) {
|
||||
Map<TopicPartition, Long> positions = new LinkedHashMap<>();
|
||||
for (TopicPartition partition : partitions) {
|
||||
positions.put(partition, consumer.position(partition));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
static boolean allPeekPartitionsComplete(
|
||||
List<TopicPartition> partitions,
|
||||
Map<TopicPartition, Integer> remainingByPartition,
|
||||
Map<TopicPartition, Long> positions,
|
||||
Map<TopicPartition, Long> endOffsets
|
||||
) {
|
||||
for (TopicPartition partition : partitions) {
|
||||
if (remainingByPartition.getOrDefault(partition, 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
long endOffset = endOffsets.getOrDefault(partition, 0L);
|
||||
long position = positions.getOrDefault(partition, 0L);
|
||||
if (position < endOffset) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean allPeekPartitionsCaughtUp(
|
||||
|
|
@ -1165,16 +1469,44 @@ public final class KafkaAgent {
|
|||
boolean allPartitionsCaughtUp();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface PeekCollectionCompletionChecker {
|
||||
boolean isComplete(PeekCollectionState collection);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface PeekRecordFilter {
|
||||
boolean include(ConsumerRecord<String, byte[]> record);
|
||||
}
|
||||
|
||||
private static final class PeekCollectionState {
|
||||
private final List<Map<String, Object>> messages = new ArrayList<>();
|
||||
private final Map<TopicPartition, Integer> remainingByPartition = new HashMap<>();
|
||||
private int scannedRecords;
|
||||
private boolean incomplete;
|
||||
|
||||
private PeekCollectionState(List<TopicPartition> partitions, int messagesPerPartition) {
|
||||
for (TopicPartition partition : partitions) {
|
||||
remainingByPartition.put(partition, messagesPerPartition);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allPartitionQuotasSatisfied() {
|
||||
return remainingByPartition.values().stream().allMatch(remaining -> remaining <= 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** When partition is null, peek across every partition of the topic. */
|
||||
static List<TopicPartition> resolvePeekPartitions(
|
||||
KafkaConsumer<String, byte[]> consumer,
|
||||
String topic,
|
||||
Integer partition
|
||||
Integer partition,
|
||||
Duration timeout
|
||||
) {
|
||||
if (partition != null) {
|
||||
return resolvePeekPartitions(topic, partition, Collections.emptyList());
|
||||
}
|
||||
List<PartitionInfo> infos = consumer.partitionsFor(topic, Duration.ofSeconds(5));
|
||||
List<PartitionInfo> infos = consumer.partitionsFor(topic, timeout);
|
||||
if (infos == null || infos.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
|
@ -1195,13 +1527,103 @@ public final class KafkaAgent {
|
|||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
enum PeekStartPosition {
|
||||
EARLIEST,
|
||||
LATEST,
|
||||
OFFSET,
|
||||
}
|
||||
|
||||
/** Omitting startPosition preserves the old earliest default (or explicit legacy offset) behavior. */
|
||||
static PeekStartPosition peekStartPosition(JsonObject params) {
|
||||
String value = stringOrNull(params, "startPosition");
|
||||
if (value == null) {
|
||||
return PeekStartPosition.EARLIEST;
|
||||
}
|
||||
return switch (value.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "earliest" -> PeekStartPosition.EARLIEST;
|
||||
case "latest" -> PeekStartPosition.LATEST;
|
||||
case "offset" -> PeekStartPosition.OFFSET;
|
||||
default -> throw new IllegalArgumentException("Unsupported peek startPosition: " + value);
|
||||
};
|
||||
}
|
||||
|
||||
static void validatePeekRequest(
|
||||
PeekStartPosition startPosition,
|
||||
boolean explicitStartPosition,
|
||||
Integer partition,
|
||||
Long offset
|
||||
) {
|
||||
if (partition != null && partition < 0) {
|
||||
throw new IllegalArgumentException("partition must be non-negative");
|
||||
}
|
||||
if (!explicitStartPosition) {
|
||||
// Older clients used offset directly without a startPosition field.
|
||||
if (offset != null && offset < 0) {
|
||||
throw new IllegalArgumentException("offset must be non-negative");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (startPosition != PeekStartPosition.OFFSET) {
|
||||
if (offset != null) {
|
||||
throw new IllegalArgumentException("offset is only supported when startPosition is offset");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (offset == null) {
|
||||
throw new IllegalArgumentException("offset is required when startPosition is offset");
|
||||
}
|
||||
if (offset < 0) {
|
||||
throw new IllegalArgumentException("offset must be non-negative when startPosition is offset");
|
||||
}
|
||||
}
|
||||
|
||||
static Long requestedPeekOffset(
|
||||
PeekStartPosition startPosition,
|
||||
Long offset,
|
||||
boolean legacyOffsetRequest,
|
||||
long beginningOffset,
|
||||
long endOffset
|
||||
) {
|
||||
return switch (startPosition) {
|
||||
case LATEST -> endOffset > beginningOffset ? beginningOffset : null;
|
||||
case OFFSET -> offset;
|
||||
case EARLIEST -> legacyOffsetRequest ? offset : beginningOffset;
|
||||
};
|
||||
}
|
||||
|
||||
static int peekScanLimit(int count, int readablePartitionCount) {
|
||||
int fetchCount = recentPeekFetchCount(
|
||||
peekMessagesPerPartition(count, readablePartitionCount), readablePartitionCount
|
||||
);
|
||||
if (fetchCount > MAX_PEEK_SCAN_RECORDS) {
|
||||
throw new IllegalArgumentException(
|
||||
"Kafka message browse would scan more than " + MAX_PEEK_SCAN_RECORDS + " records"
|
||||
);
|
||||
}
|
||||
return MAX_PEEK_SCAN_RECORDS;
|
||||
}
|
||||
|
||||
static void sortPeekedMessages(List<Map<String, Object>> messages) {
|
||||
messages.sort((left, right) -> {
|
||||
sortPeekedMessages(messages, PeekStartPosition.EARLIEST);
|
||||
}
|
||||
|
||||
static void sortPeekedMessages(List<Map<String, Object>> messages, PeekStartPosition startPosition) {
|
||||
if (startPosition == PeekStartPosition.OFFSET) {
|
||||
messages.sort(Comparator
|
||||
.comparingLong((Map<String, Object> message) ->
|
||||
((Number) message.getOrDefault("offset", 0L)).longValue()
|
||||
)
|
||||
.thenComparingInt(message -> ((Number) message.getOrDefault("partition", 0)).intValue())
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Comparator<Map<String, Object>> comparator = (left, right) -> {
|
||||
long leftTs = ((Number) left.getOrDefault("timestamp", 0L)).longValue();
|
||||
long rightTs = ((Number) right.getOrDefault("timestamp", 0L)).longValue();
|
||||
int byTs = Long.compare(leftTs, rightTs);
|
||||
if (byTs != 0) {
|
||||
return byTs;
|
||||
return startPosition == PeekStartPosition.LATEST ? -byTs : byTs;
|
||||
}
|
||||
int leftPartition = ((Number) left.getOrDefault("partition", 0)).intValue();
|
||||
int rightPartition = ((Number) right.getOrDefault("partition", 0)).intValue();
|
||||
|
|
@ -1212,7 +1634,8 @@ public final class KafkaAgent {
|
|||
long leftOffset = ((Number) left.getOrDefault("offset", 0L)).longValue();
|
||||
long rightOffset = ((Number) right.getOrDefault("offset", 0L)).longValue();
|
||||
return Long.compare(leftOffset, rightOffset);
|
||||
});
|
||||
};
|
||||
messages.sort(comparator);
|
||||
}
|
||||
|
||||
private static Map<String, Object> peekedMessageFromRecord(ConsumerRecord<String, byte[]> record) {
|
||||
|
|
@ -1224,7 +1647,7 @@ public final class KafkaAgent {
|
|||
msg.put("key", record.key());
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
record.headers().forEach(h ->
|
||||
headers.put(h.key(), new String(h.value(), StandardCharsets.UTF_8)));
|
||||
headers.put(h.key(), h.value() == null ? "" : new String(h.value(), StandardCharsets.UTF_8)));
|
||||
msg.put("headers", headers);
|
||||
if (record.value() != null) {
|
||||
msg.put("payloadBase64", Base64.getEncoder().encodeToString(record.value()));
|
||||
|
|
@ -1398,13 +1821,14 @@ public final class KafkaAgent {
|
|||
int timeout = requestTimeout(params);
|
||||
|
||||
DescribeClusterResult cluster = admin.describeCluster();
|
||||
DescribeMetadataQuorumResult metadataQuorum = admin.describeMetadataQuorum();
|
||||
String clusterId = cluster.clusterId().get(timeout, TimeUnit.MILLISECONDS);
|
||||
Node controller = cluster.controller().get(timeout, TimeUnit.MILLISECONDS);
|
||||
Collection<Node> nodes = cluster.nodes().get(timeout, TimeUnit.MILLISECONDS);
|
||||
Map<String, Object> controller = resolveClusterController(metadataQuorum, cluster, nodes, timeout);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("clusterId", clusterId);
|
||||
result.put("controller", controller != null ? nodeToMap(controller) : null);
|
||||
result.put("controller", controller);
|
||||
List<Map<String, Object>> brokerList = new ArrayList<>();
|
||||
for (Node node : nodes) {
|
||||
brokerList.add(nodeToMap(node));
|
||||
|
|
@ -1414,6 +1838,52 @@ public final class KafkaAgent {
|
|||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Object> resolveClusterController(
|
||||
DescribeMetadataQuorumResult metadataQuorum,
|
||||
DescribeClusterResult cluster,
|
||||
Collection<Node> brokers,
|
||||
int timeout
|
||||
) throws Exception {
|
||||
try {
|
||||
QuorumInfo quorum = metadataQuorum.quorumInfo().get(timeout, TimeUnit.MILLISECONDS);
|
||||
Map<Integer, List<RaftVoterEndpoint>> endpointsByNode = new HashMap<>();
|
||||
for (Map.Entry<Integer, QuorumInfo.Node> entry : quorum.nodes().entrySet()) {
|
||||
endpointsByNode.put(entry.getKey(), entry.getValue().endpoints());
|
||||
}
|
||||
return metadataQuorumControllerToMap(quorum.leaderId(), brokers, endpointsByNode);
|
||||
} catch (Exception e) {
|
||||
if (isUnsupportedVersionError(e)) {
|
||||
Node controller = cluster.controller().get(timeout, TimeUnit.MILLISECONDS);
|
||||
return controller != null ? nodeToMap(controller) : null;
|
||||
}
|
||||
logger().warn("Unable to resolve Kafka metadata quorum leader; omitting the controller", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, Object> metadataQuorumControllerToMap(
|
||||
int leaderId,
|
||||
Collection<Node> brokers,
|
||||
Map<Integer, List<RaftVoterEndpoint>> endpointsByNode
|
||||
) {
|
||||
if (leaderId < 0) {
|
||||
return null;
|
||||
}
|
||||
for (Node broker : brokers) {
|
||||
if (broker.id() == leaderId) {
|
||||
return nodeToMap(broker);
|
||||
}
|
||||
}
|
||||
List<RaftVoterEndpoint> endpoints = endpointsByNode.getOrDefault(leaderId, Collections.emptyList());
|
||||
if (!endpoints.isEmpty()) {
|
||||
RaftVoterEndpoint endpoint = endpoints.get(0);
|
||||
return nodeToMap(new Node(leaderId, endpoint.host(), endpoint.port()));
|
||||
}
|
||||
Map<String, Object> controller = new LinkedHashMap<>();
|
||||
controller.put("id", leaderId);
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static Object getConsumerLag(JsonObject params) throws Exception {
|
||||
AdminClient admin = requireAdmin();
|
||||
int timeout = requestTimeout(params);
|
||||
|
|
@ -1584,6 +2054,34 @@ public final class KafkaAgent {
|
|||
return requestedOffset;
|
||||
}
|
||||
|
||||
/** Splits the requested page quota across partitions. */
|
||||
static int peekMessagesPerPartition(int count, int partitionCount) {
|
||||
int safePartitionCount = Math.max(1, partitionCount);
|
||||
return (int) (((long) count + safePartitionCount - 1) / safePartitionCount);
|
||||
}
|
||||
|
||||
static long recentPeekStartOffset(long beginningOffset, long endOffset, int messagesPerPartition) {
|
||||
return Math.max(beginningOffset, endOffset - messagesPerPartition);
|
||||
}
|
||||
|
||||
static long previousLatestPeekStartOffset(
|
||||
long beginningOffset,
|
||||
long currentStartOffset,
|
||||
long currentWindowWidth
|
||||
) {
|
||||
long safeWindowWidth = Math.max(1L, currentWindowWidth);
|
||||
long expandedWindowWidth = safeWindowWidth > Long.MAX_VALUE / 2
|
||||
? Long.MAX_VALUE
|
||||
: safeWindowWidth * 2;
|
||||
long distanceToBeginning = currentStartOffset - beginningOffset;
|
||||
return currentStartOffset - Math.min(distanceToBeginning, expandedWindowWidth);
|
||||
}
|
||||
|
||||
static int recentPeekFetchCount(int messagesPerPartition, int partitionCount) {
|
||||
long total = (long) messagesPerPartition * Math.max(1, partitionCount);
|
||||
return (int) Math.min(Integer.MAX_VALUE, total);
|
||||
}
|
||||
|
||||
private static String stringOrNull(JsonObject object, String key) {
|
||||
JsonElement element = object.get(key);
|
||||
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package com.dbx.agent.kafka;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
|
|
@ -12,8 +14,11 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -26,6 +31,8 @@ import org.apache.kafka.clients.consumer.ConsumerRecords;
|
|||
import org.apache.kafka.clients.admin.AlterConfigOp;
|
||||
import org.apache.kafka.clients.admin.Config;
|
||||
import org.apache.kafka.clients.admin.ConfigEntry;
|
||||
import org.apache.kafka.clients.admin.RaftVoterEndpoint;
|
||||
import org.apache.kafka.common.Node;
|
||||
import org.apache.kafka.common.TopicPartition;
|
||||
import org.apache.zookeeper.CreateMode;
|
||||
import org.apache.zookeeper.Watcher;
|
||||
|
|
@ -242,6 +249,58 @@ class KafkaAgentTest {
|
|||
assertFalse(KafkaAgent.isAclDisabledError(new RuntimeException("Timed out waiting for broker response")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataQuorumControllerUsesMatchingBrokerEndpoint() {
|
||||
Map<String, Object> controller = KafkaAgent.metadataQuorumControllerToMap(
|
||||
1,
|
||||
Arrays.asList(
|
||||
new Node(1, "broker-1", 9092),
|
||||
new Node(2, "broker-2", 9092)
|
||||
),
|
||||
Collections.emptyMap()
|
||||
);
|
||||
|
||||
assertEquals(1, controller.get("id"));
|
||||
assertEquals("broker-1", controller.get("host"));
|
||||
assertEquals(9092, controller.get("port"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataQuorumControllerUsesIsolatedControllerEndpoint() {
|
||||
Map<String, Object> controller = KafkaAgent.metadataQuorumControllerToMap(
|
||||
9,
|
||||
Collections.singletonList(new Node(1, "broker-1", 9092)),
|
||||
Collections.singletonMap(
|
||||
9,
|
||||
Collections.singletonList(new RaftVoterEndpoint("CONTROLLER", "controller-9", 19093))
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(9, controller.get("id"));
|
||||
assertEquals("controller-9", controller.get("host"));
|
||||
assertEquals(19093, controller.get("port"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataQuorumControllerKeepsLeaderIdWithoutEndpoint() {
|
||||
Map<String, Object> controller = KafkaAgent.metadataQuorumControllerToMap(
|
||||
9,
|
||||
Collections.singletonList(new Node(1, "broker-1", 9092)),
|
||||
Collections.emptyMap()
|
||||
);
|
||||
|
||||
assertEquals(Collections.singletonMap("id", 9), controller);
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataQuorumControllerReturnsNullWithoutLeader() {
|
||||
assertNull(KafkaAgent.metadataQuorumControllerToMap(
|
||||
-1,
|
||||
Collections.singletonList(new Node(1, "broker-1", 9092)),
|
||||
Collections.emptyMap()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyTopicConfigAppliesSetAndDeleteWithoutLosingExistingOverrides() {
|
||||
Config current = new Config(Arrays.asList(
|
||||
|
|
@ -302,6 +361,183 @@ class KafkaAgentTest {
|
|||
assertNull(KafkaAgent.normalizePeekOffset(0, 5, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekStartPositionDefaultsToEarliestForOlderClients() {
|
||||
assertEquals(KafkaAgent.PeekStartPosition.EARLIEST,
|
||||
KafkaAgent.peekStartPosition(new JsonObject()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekStartPositionRecognizesEveryExplicitMode() {
|
||||
JsonObject latest = new JsonObject();
|
||||
latest.addProperty("startPosition", "latest");
|
||||
JsonObject earliest = new JsonObject();
|
||||
earliest.addProperty("startPosition", "earliest");
|
||||
JsonObject offset = new JsonObject();
|
||||
offset.addProperty("startPosition", "offset");
|
||||
|
||||
assertEquals(KafkaAgent.PeekStartPosition.LATEST, KafkaAgent.peekStartPosition(latest));
|
||||
assertEquals(KafkaAgent.PeekStartPosition.EARLIEST, KafkaAgent.peekStartPosition(earliest));
|
||||
assertEquals(KafkaAgent.PeekStartPosition.OFFSET, KafkaAgent.peekStartPosition(offset));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekStartPositionRejectsUnknownValues() {
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("startPosition", "middle");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.peekStartPosition(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void offsetStartPositionAllowsAllPartitionsButRequiresANonNegativeOffset() {
|
||||
assertDoesNotThrow(() ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, null, 0L));
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, null));
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, -1, 0L));
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, -1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonOffsetStartPositionsRejectAnOffset() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, 0, 7L));
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, 0, 7L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void latestSkipsEmptyPartitions() {
|
||||
assertNull(KafkaAgent.requestedPeekOffset(
|
||||
KafkaAgent.PeekStartPosition.LATEST, null, false, 5L, 5L
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyStartPositionRejectsNegativePartitions() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, -1, null));
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, -1, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyOffsetWithoutStartPositionKeepsTheExistingReadBehavior() {
|
||||
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, false, null, 7L);
|
||||
assertEquals(7L, KafkaAgent.requestedPeekOffset(
|
||||
KafkaAgent.PeekStartPosition.EARLIEST, 7L, true, 0L, 10L
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitEarliestDoesNotReuseAnOffsetFromAnOlderRequest() {
|
||||
assertEquals(0L, KafkaAgent.requestedPeekOffset(
|
||||
KafkaAgent.PeekStartPosition.EARLIEST, 7L, false, 0L, 10L
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void offsetSortUsesPartitionAsADeterministicTieBreaker() {
|
||||
var messages = new java.util.ArrayList<Map<String, Object>>();
|
||||
messages.add(Map.of("partition", 2, "offset", 7L));
|
||||
messages.add(Map.of("partition", 1, "offset", 7L));
|
||||
messages.add(Map.of("partition", 0, "offset", 8L));
|
||||
|
||||
KafkaAgent.sortPeekedMessages(messages, KafkaAgent.PeekStartPosition.OFFSET);
|
||||
|
||||
assertEquals(1, messages.get(0).get("partition"));
|
||||
assertEquals(2, messages.get(1).get("partition"));
|
||||
assertEquals(8L, messages.get(2).get("offset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsMessageWindowAcrossPartitions() {
|
||||
assertEquals(4, KafkaAgent.peekMessagesPerPartition(10, 3));
|
||||
assertEquals(10, KafkaAgent.peekMessagesPerPartition(10, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsLatestMessageWindowNearThePartitionEnd() {
|
||||
assertEquals(90L, KafkaAgent.recentPeekStartOffset(0, 100, 10));
|
||||
assertEquals(5L, KafkaAgent.recentPeekStartOffset(5, 8, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundsThePerPartitionMessageQuotaBeforeTrimmingTheResult() {
|
||||
assertEquals(12, KafkaAgent.recentPeekFetchCount(4, 3));
|
||||
assertEquals(4, KafkaAgent.peekMessagesPerPartition(10, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekRejectsAWindowThatExceedsTheScanLimit() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
KafkaAgent.peekScanLimit(100, 1_001));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekWindowCalculationDoesNotOverflow() {
|
||||
assertEquals(1, KafkaAgent.peekMessagesPerPartition(
|
||||
Integer.MAX_VALUE, Integer.MAX_VALUE
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void latestPeekExpandsBackwardAcrossSparseOffsetGaps() {
|
||||
assertEquals(6L, KafkaAgent.recentPeekStartOffset(0L, 11L, 5));
|
||||
assertEquals(0L, KafkaAgent.previousLatestPeekStartOffset(0L, 6L, 5L));
|
||||
assertEquals(12L, KafkaAgent.previousLatestPeekStartOffset(0L, 32L, 10L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void latestPeekRetainsTheNewestRecordsFromAnExpandedRange() {
|
||||
Deque<Long> latestOffsets = new ArrayDeque<>();
|
||||
for (long offset = 86L; offset <= 95L; offset++) {
|
||||
KafkaAgent.retainLatestPeekRecord(latestOffsets, offset, 4);
|
||||
}
|
||||
|
||||
assertEquals(List.of(92L, 93L, 94L, 95L), new ArrayList<>(latestOffsets));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekCountMustStayWithinTheServiceLimit() {
|
||||
assertEquals(100, KafkaAgent.validatedPeekCount(100));
|
||||
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.validatedPeekCount(0));
|
||||
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.validatedPeekCount(101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekUsesTheConfiguredConsumerRequestTimeout() {
|
||||
Properties properties = new Properties();
|
||||
properties.put("request.timeout.ms", "1500");
|
||||
|
||||
assertEquals(1_500, KafkaAgent.peekRequestTimeoutMs(new JsonObject(), properties));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekRequestTimeoutPrefersTheConnectionOverrideAndRejectsInvalidValues() {
|
||||
Properties properties = new Properties();
|
||||
properties.put("request.timeout.ms", "1500");
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty("request_timeout_ms", 2_500);
|
||||
|
||||
assertEquals(2_500, KafkaAgent.peekRequestTimeoutMs(connection, properties));
|
||||
|
||||
properties.put("request.timeout.ms", "0");
|
||||
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.peekRequestTimeoutMs(new JsonObject(), properties));
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompletePeekResultsAreExplicitlyMarked() {
|
||||
Map<String, Object> partial = KafkaAgent.peekMessagesResult(List.of(), true);
|
||||
Map<String, Object> complete = KafkaAgent.peekMessagesResult(List.of(), false);
|
||||
|
||||
assertEquals(true, partial.get("incomplete"));
|
||||
assertEquals(false, complete.get("incomplete"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePeekPartitionsUsesSinglePartitionWhenSpecified() {
|
||||
var partitions = KafkaAgent.resolvePeekPartitions("events", 2, List.of(0, 1, 2));
|
||||
|
|
@ -330,6 +566,37 @@ class KafkaAgentTest {
|
|||
assertEquals(20L, messages.get(3).get("timestamp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sortPeekedMessagesCanOrderNewestFirst() {
|
||||
var messages = new java.util.ArrayList<Map<String, Object>>();
|
||||
messages.add(Map.of("timestamp", 20L, "partition", 1, "offset", 1L));
|
||||
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 5L));
|
||||
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 2L));
|
||||
messages.add(Map.of("timestamp", 10L, "partition", 1, "offset", 0L));
|
||||
|
||||
KafkaAgent.sortPeekedMessages(messages, KafkaAgent.PeekStartPosition.LATEST);
|
||||
|
||||
assertEquals(20L, messages.get(0).get("timestamp"));
|
||||
assertEquals(0, messages.get(1).get("partition"));
|
||||
assertEquals(2L, messages.get(1).get("offset"));
|
||||
assertEquals(5L, messages.get(2).get("offset"));
|
||||
assertEquals(1, messages.get(3).get("partition"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sortPeekedMessagesOrdersOffsetModeByOffsetAscending() {
|
||||
var messages = new java.util.ArrayList<Map<String, Object>>();
|
||||
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 5L));
|
||||
messages.add(Map.of("timestamp", 30L, "partition", 0, "offset", 2L));
|
||||
messages.add(Map.of("timestamp", 20L, "partition", 0, "offset", 3L));
|
||||
|
||||
KafkaAgent.sortPeekedMessages(messages, KafkaAgent.PeekStartPosition.OFFSET);
|
||||
|
||||
assertEquals(2L, messages.get(0).get("offset"));
|
||||
assertEquals(3L, messages.get(1).get("offset"));
|
||||
assertEquals(5L, messages.get(2).get("offset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allPeekPartitionsCaughtUpRequiresEveryPartitionAtEndOffset() {
|
||||
TopicPartition p0 = new TopicPartition("events", 0);
|
||||
|
|
@ -348,6 +615,33 @@ class KafkaAgentTest {
|
|||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekCompletionStopsAfterEachPartitionSuppliesItsQuota() {
|
||||
TopicPartition p0 = new TopicPartition("events", 0);
|
||||
TopicPartition p1 = new TopicPartition("events", 1);
|
||||
List<TopicPartition> partitions = List.of(p0, p1);
|
||||
Map<TopicPartition, Long> endOffsets = Map.of(p0, 100L, p1, 100L);
|
||||
|
||||
assertTrue(KafkaAgent.allPeekPartitionsComplete(
|
||||
partitions,
|
||||
Map.of(p0, 0, p1, 0),
|
||||
Map.of(p0, 1L, p1, 1L),
|
||||
endOffsets
|
||||
));
|
||||
assertFalse(KafkaAgent.allPeekPartitionsComplete(
|
||||
partitions,
|
||||
Map.of(p0, 0, p1, 1),
|
||||
Map.of(p0, 1L, p1, 1L),
|
||||
endOffsets
|
||||
));
|
||||
assertTrue(KafkaAgent.allPeekPartitionsComplete(
|
||||
partitions,
|
||||
Map.of(p0, 0, p1, 1),
|
||||
Map.of(p0, 1L, p1, 100L),
|
||||
endOffsets
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectPeekedMessagesRetriesAfterEmptyFirstPoll() {
|
||||
TopicPartition tp = new TopicPartition("events", 0);
|
||||
|
|
@ -365,8 +659,11 @@ class KafkaAgentTest {
|
|||
AtomicInteger polls = new AtomicInteger();
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> polls.getAndIncrement() == 0 ? ConsumerRecords.empty() : withData,
|
||||
() -> false,
|
||||
() -> polls.get() >= 2,
|
||||
ignored -> true,
|
||||
List.of(tp),
|
||||
1,
|
||||
1_000,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
|
@ -378,7 +675,8 @@ class KafkaAgentTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void collectPeekedMessagesStopsOnEmptyPollWhenCaughtUp() {
|
||||
void collectPeekedMessagesDoesNotPollWhenAlreadyCaughtUp() {
|
||||
TopicPartition tp = new TopicPartition("events", 0);
|
||||
AtomicInteger polls = new AtomicInteger();
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> {
|
||||
|
|
@ -386,13 +684,208 @@ class KafkaAgentTest {
|
|||
return ConsumerRecords.empty();
|
||||
},
|
||||
() -> true,
|
||||
record -> true,
|
||||
List.of(tp),
|
||||
10,
|
||||
1_000,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals(0, polls.get());
|
||||
assertTrue(messages.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectPeekedMessagesExcludesRecordsPastTheSnapshotEndOffset() {
|
||||
TopicPartition tp = new TopicPartition("events", 0);
|
||||
ConsumerRecord<String, byte[]> included = new ConsumerRecord<>(
|
||||
"events", 0, 9L, "before", "before".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
ConsumerRecord<String, byte[]> excluded = new ConsumerRecord<>(
|
||||
"events", 0, 10L, "after", "after".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(tp, List.of(included, excluded)));
|
||||
AtomicInteger polls = new AtomicInteger();
|
||||
AtomicInteger caughtUpChecks = new AtomicInteger();
|
||||
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> {
|
||||
polls.incrementAndGet();
|
||||
return batch;
|
||||
},
|
||||
() -> caughtUpChecks.getAndIncrement() > 0,
|
||||
record -> record.offset() < 10L,
|
||||
List.of(tp),
|
||||
2,
|
||||
1_000,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals(1, polls.get());
|
||||
assertTrue(messages.isEmpty());
|
||||
assertEquals(1, messages.size());
|
||||
assertEquals(9L, messages.get(0).get("offset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekCollectsFromEveryPartitionBeforeTrimming() {
|
||||
TopicPartition p0 = new TopicPartition("events", 0);
|
||||
TopicPartition p1 = new TopicPartition("events", 1);
|
||||
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(
|
||||
p0, List.of(
|
||||
new ConsumerRecord<>("events", 0, 9L, "p0-first", "one".getBytes(StandardCharsets.UTF_8)),
|
||||
new ConsumerRecord<>("events", 0, 10L, "p0-second", "two".getBytes(StandardCharsets.UTF_8))
|
||||
),
|
||||
p1, List.of(
|
||||
new ConsumerRecord<>("events", 1, 7L, "p1-first", "three".getBytes(StandardCharsets.UTF_8))
|
||||
)
|
||||
));
|
||||
AtomicInteger polls = new AtomicInteger();
|
||||
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> polls.getAndIncrement() == 0 ? batch : ConsumerRecords.empty(),
|
||||
() -> polls.get() > 0,
|
||||
record -> true,
|
||||
List.of(p0, p1),
|
||||
1,
|
||||
1_000,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals(2, messages.size());
|
||||
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(0)));
|
||||
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekWaitsForEveryPartitionWindowWhenOnePartitionRespondsFirst() {
|
||||
TopicPartition p0 = new TopicPartition("events", 0);
|
||||
TopicPartition p1 = new TopicPartition("events", 1);
|
||||
ConsumerRecords<String, byte[]> firstPartition = new ConsumerRecords<>(Map.of(p0, List.of(
|
||||
new ConsumerRecord<>("events", 0, 0L, "p0-first", "one".getBytes(StandardCharsets.UTF_8)),
|
||||
new ConsumerRecord<>("events", 0, 1L, "p0-second", "two".getBytes(StandardCharsets.UTF_8))
|
||||
)));
|
||||
ConsumerRecords<String, byte[]> secondPartition = new ConsumerRecords<>(Map.of(p1, List.of(
|
||||
new ConsumerRecord<>("events", 1, 0L, "p1-first", "three".getBytes(StandardCharsets.UTF_8))
|
||||
)));
|
||||
AtomicInteger polls = new AtomicInteger();
|
||||
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> polls.getAndIncrement() == 0 ? firstPartition : secondPartition,
|
||||
() -> polls.get() >= 2,
|
||||
ignored -> true,
|
||||
List.of(p0, p1),
|
||||
1,
|
||||
1_000,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals(2, polls.get());
|
||||
assertEquals(2, messages.size());
|
||||
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(0)));
|
||||
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekRetainsRecordsReadBeforeTheScanLimit() {
|
||||
TopicPartition partition = new TopicPartition("events", 0);
|
||||
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(
|
||||
new ConsumerRecord<>("events", 0, 9L, "first", "one".getBytes(StandardCharsets.UTF_8)),
|
||||
new ConsumerRecord<>("events", 0, 10L, "second", "two".getBytes(StandardCharsets.UTF_8))
|
||||
)));
|
||||
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> batch,
|
||||
() -> false,
|
||||
record -> true,
|
||||
List.of(partition),
|
||||
2,
|
||||
1,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals(1, messages.size());
|
||||
assertEquals(9L, messages.get(0).get("offset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekCountsSparseOffsetsAsRecordsInsteadOfOffsetWindowWidth() {
|
||||
TopicPartition partition = new TopicPartition("events", 0);
|
||||
// A compacted topic can retain these two records while offsets 1..9 are absent.
|
||||
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(
|
||||
new ConsumerRecord<>("events", 0, 0L, "first", "one".getBytes(StandardCharsets.UTF_8)),
|
||||
new ConsumerRecord<>("events", 0, 10L, "second", "two".getBytes(StandardCharsets.UTF_8))
|
||||
)));
|
||||
AtomicInteger polls = new AtomicInteger();
|
||||
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> polls.getAndIncrement() == 0 ? batch : ConsumerRecords.empty(),
|
||||
() -> polls.get() > 0,
|
||||
record -> true,
|
||||
List.of(partition),
|
||||
5,
|
||||
1_000,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals(2, messages.size());
|
||||
assertEquals(0L, messages.get(0).get("offset"));
|
||||
assertEquals(10L, messages.get(1).get("offset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekHandlesKafkaHeadersWithNullValues() {
|
||||
TopicPartition partition = new TopicPartition("events", 0);
|
||||
ConsumerRecord<String, byte[]> record = new ConsumerRecord<>(
|
||||
"events", 0, 0L, "key", "value".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
record.headers().add("tombstone", null);
|
||||
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(record)));
|
||||
AtomicInteger polls = new AtomicInteger();
|
||||
|
||||
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> {
|
||||
polls.incrementAndGet();
|
||||
return batch;
|
||||
},
|
||||
() -> polls.get() > 0,
|
||||
ignored -> true,
|
||||
List.of(partition),
|
||||
1,
|
||||
1_000,
|
||||
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals("", ((Map<?, ?>) messages.get(0).get("headers")).get("tombstone"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteCollectCanBeReturnedWithAnExplicitStatus() {
|
||||
TopicPartition partition = new TopicPartition("events", 0);
|
||||
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(
|
||||
new ConsumerRecord<>("events", 0, 0L, "only", "one".getBytes(StandardCharsets.UTF_8))
|
||||
)));
|
||||
|
||||
List<Map<String, Object>> partial = KafkaAgent.collectPeekedMessages(
|
||||
timeout -> batch,
|
||||
() -> false,
|
||||
record -> true,
|
||||
List.of(partition),
|
||||
5,
|
||||
1_000,
|
||||
System.nanoTime() - 1,
|
||||
Duration.ofMillis(1)
|
||||
);
|
||||
|
||||
assertEquals(0, partial.size());
|
||||
Map<String, Object> result = KafkaAgent.peekMessagesResult(partial, true);
|
||||
assertEquals(true, result.get("incomplete"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ func TestKingbaseIntegration(t *testing.T) {
|
|||
if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) {
|
||||
t.Fatalf("get function source failed: source=%v err=%v", source, err)
|
||||
}
|
||||
viewSource, err := server.getObjectSource("public", view, "VIEW")
|
||||
if err != nil || !strings.Contains(fmt.Sprint(viewSource["source"]), parent) {
|
||||
t.Fatalf("get view source failed: source=%v err=%v", viewSource, err)
|
||||
}
|
||||
|
||||
transactionParams := map[string]json.RawMessage{
|
||||
"schema": rawJSON("public"),
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ import (
|
|||
|
||||
const metadataTimeout = 15 * time.Second
|
||||
|
||||
// Escape '_' so only Kingbase internal SYS_/XLOG_ prefixes are hidden; names
|
||||
// such as SYSTEMS and SYSLOG may be user-created schemas in MySQL mode.
|
||||
const kingbaseMySQLCompatListSchemasSQL = `SELECT schema_name FROM information_schema.schemata WHERE UPPER(schema_name) <> 'INFORMATION_SCHEMA' AND UPPER(schema_name) NOT LIKE 'SYS\_%' ESCAPE '\' AND UPPER(schema_name) NOT LIKE 'XLOG\_%' ESCAPE '\' ORDER BY schema_name`
|
||||
const (
|
||||
kingbaseListDatabasesSQL = "SELECT datname FROM sys_catalog.sys_database WHERE datallowconn AND LOWER(datname) NOT IN ('template0', 'template1') ORDER BY datname"
|
||||
kingbaseListDatabasesPostgresSQL = "SELECT datname FROM pg_catalog.pg_database WHERE datallowconn AND LOWER(datname) NOT IN ('template0', 'template1') ORDER BY datname"
|
||||
)
|
||||
|
||||
// Escape '_' so only Kingbase internal SYS_/XLOG_ prefixes are hidden; use a
|
||||
// non-backslash escape because MySQL mode treats backslash as a string escape.
|
||||
const kingbaseMySQLCompatListSchemasSQL = `SELECT schema_name FROM information_schema.schemata WHERE UPPER(schema_name) <> 'INFORMATION_SCHEMA' AND UPPER(schema_name) NOT LIKE 'SYS#_%' ESCAPE '#' AND UPPER(schema_name) NOT LIKE 'XLOG#_%' ESCAPE '#' ORDER BY schema_name`
|
||||
|
||||
var kingbaseDataTypes = []string{
|
||||
"bigint", "bigserial", "bit", "bit varying", "boolean", "bytea", "char", "character",
|
||||
|
|
@ -191,8 +196,8 @@ func (s *server) connectionInfo() (map[string]any, error) {
|
|||
|
||||
func (s *server) listDatabases() ([]databaseInfo, error) {
|
||||
queries := []string{
|
||||
"SELECT datname FROM sys_catalog.sys_database WHERE NOT datistemplate AND datallowconn ORDER BY datname",
|
||||
"SELECT datname FROM pg_catalog.pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname",
|
||||
kingbaseListDatabasesSQL,
|
||||
kingbaseListDatabasesPostgresSQL,
|
||||
"SELECT current_database()",
|
||||
}
|
||||
for _, query := range queries {
|
||||
|
|
@ -539,16 +544,45 @@ func isUndefinedColumn(err error, columnName string) bool {
|
|||
}
|
||||
|
||||
func (s *server) informationSchemaColumns(schema, table string, primary map[string]bool) ([]columnInfo, error) {
|
||||
result, err := s.queryInformationSchemaColumns(schema, table, primary, true)
|
||||
if err != nil && isUndefinedColumn(err, "column_type") {
|
||||
return s.queryInformationSchemaColumns(schema, table, primary, false)
|
||||
// Cache the optional information_schema capabilities for this connection so
|
||||
// subsequent table metadata requests do not repeat known failing probes.
|
||||
includeColumnType := !s.infoColumnTypeUnsupported
|
||||
includeUdtName := !s.infoUdtNameUnsupported
|
||||
for {
|
||||
result, err := s.queryInformationSchemaColumns(schema, table, primary, includeColumnType, includeUdtName)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
switch {
|
||||
case includeColumnType && isUndefinedColumn(err, "column_type"):
|
||||
includeColumnType = false
|
||||
s.infoColumnTypeUnsupported = true
|
||||
case includeUdtName && isUndefinedColumn(err, "udt_name"):
|
||||
includeUdtName = false
|
||||
s.infoUdtNameUnsupported = true
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *server) queryInformationSchemaColumns(schema, table string, primary map[string]bool, includeFullDataType bool) ([]columnInfo, error) {
|
||||
fullDataTypeExpression := "c.column_type"
|
||||
if !includeFullDataType {
|
||||
func (s *server) queryInformationSchemaColumns(schema, table string, primary map[string]bool, includeColumnType, includeUdtName bool) ([]columnInfo, error) {
|
||||
var fullDataTypeExpression string
|
||||
switch {
|
||||
case includeColumnType && includeUdtName:
|
||||
fullDataTypeExpression = `CASE
|
||||
WHEN UPPER(TRIM(c.data_type)) IN ('USER-DEFINED', 'USER_DEFINED')
|
||||
AND UPPER(COALESCE(NULLIF(TRIM(c.column_type), ''), 'USER-DEFINED')) IN ('USER-DEFINED', 'USER_DEFINED')
|
||||
THEN c.udt_name
|
||||
ELSE c.column_type
|
||||
END`
|
||||
case includeColumnType:
|
||||
fullDataTypeExpression = "c.column_type"
|
||||
case includeUdtName:
|
||||
fullDataTypeExpression = `CASE
|
||||
WHEN UPPER(TRIM(c.data_type)) IN ('USER-DEFINED', 'USER_DEFINED') THEN c.udt_name
|
||||
END AS column_type`
|
||||
default:
|
||||
fullDataTypeExpression = "NULL AS column_type"
|
||||
}
|
||||
query := fmt.Sprintf(`SELECT c.column_name, c.data_type, %s, c.is_nullable, c.column_default,
|
||||
|
|
@ -707,17 +741,35 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an
|
|||
catalog, prefix, function := "sys_catalog", "sys", "sys_get_viewdef"
|
||||
if s.mode.postgresCatalog {
|
||||
catalog, prefix, function = "pg_catalog", "pg", "pg_get_viewdef"
|
||||
} else if s.usePgViewDefinition {
|
||||
function = "pg_get_viewdef"
|
||||
}
|
||||
querySource := func(definitionFunction string) error {
|
||||
query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
|
||||
return s.requireDBQueryRow(query, &source)
|
||||
}
|
||||
err = querySource(function)
|
||||
if err != nil && function == "sys_get_viewdef" && isUndefinedFunction(err, function) {
|
||||
s.usePgViewDefinition = true
|
||||
err = querySource("pg_get_viewdef")
|
||||
}
|
||||
query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
|
||||
err = s.requireDBQueryRow(query, &source)
|
||||
}
|
||||
} else if kind == "FUNCTION" || kind == "PROCEDURE" {
|
||||
catalog, prefix, function := "sys_catalog", "sys", "sys_get_functiondef"
|
||||
if s.mode.postgresCatalog {
|
||||
catalog, prefix, function = "pg_catalog", "pg", "pg_get_functiondef"
|
||||
} else if s.usePgFunctionDefinition {
|
||||
function = "pg_get_functiondef"
|
||||
}
|
||||
querySource := func(definitionFunction string) error {
|
||||
query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
|
||||
return s.requireDBQueryRow(query, &source)
|
||||
}
|
||||
err = querySource(function)
|
||||
if err != nil && function == "sys_get_functiondef" && isUndefinedFunction(err, function) {
|
||||
s.usePgFunctionDefinition = true
|
||||
err = querySource("pg_get_functiondef")
|
||||
}
|
||||
query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
|
||||
err = s.requireDBQueryRow(query, &source)
|
||||
}
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -134,7 +134,11 @@ type server struct {
|
|||
params connectParams
|
||||
mode kingbaseMode
|
||||
usePgDefaultExpression bool
|
||||
usePgViewDefinition bool
|
||||
usePgFunctionDefinition bool
|
||||
catalogIdentityUnsupported bool
|
||||
infoColumnTypeUnsupported bool
|
||||
infoUdtNameUnsupported bool
|
||||
currentSchema string
|
||||
schemaSet bool
|
||||
sessions map[string]*querySession
|
||||
|
|
@ -454,7 +458,11 @@ func (s *server) connect(cp connectParams) error {
|
|||
s.params = cp
|
||||
s.mode = detectKingbaseMode(db, cp.MySQLCompatMode)
|
||||
s.usePgDefaultExpression = false
|
||||
s.usePgViewDefinition = false
|
||||
s.usePgFunctionDefinition = false
|
||||
s.catalogIdentityUnsupported = false
|
||||
s.infoColumnTypeUnsupported = false
|
||||
s.infoUdtNameUnsupported = false
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -516,7 +524,11 @@ func (s *server) disconnect() error {
|
|||
s.cancelActiveQuery()
|
||||
s.closeAllQuerySessions()
|
||||
s.usePgDefaultExpression = false
|
||||
s.usePgViewDefinition = false
|
||||
s.usePgFunctionDefinition = false
|
||||
s.catalogIdentityUnsupported = false
|
||||
s.infoColumnTypeUnsupported = false
|
||||
s.infoUdtNameUnsupported = false
|
||||
s.currentSchema = ""
|
||||
s.schemaSet = false
|
||||
if s.db == nil {
|
||||
|
|
@ -904,7 +916,6 @@ func buildDSNWithSSLMode(cp connectParams, sslMode string) string {
|
|||
"password=" + quoteDSNValue(cp.Password),
|
||||
"dbname=" + quoteDSNValue(cp.Database),
|
||||
"sslmode=" + sslMode,
|
||||
"connect_timeout=15",
|
||||
}
|
||||
if cp.CACertPath != "" {
|
||||
parts = append(parts, "sslrootcert="+quoteDSNValue(cp.CACertPath))
|
||||
|
|
@ -915,11 +926,15 @@ func buildDSNWithSSLMode(cp connectParams, sslMode string) string {
|
|||
if cp.ClientKeyPath != "" {
|
||||
parts = append(parts, "sslkey="+quoteDSNValue(cp.ClientKeyPath))
|
||||
}
|
||||
for _, pair := range strings.FieldsFunc(cp.URLParams, func(r rune) bool { return r == '&' || r == ';' }) {
|
||||
key, value, ok := strings.Cut(pair, "=")
|
||||
if ok && isSafeParamKey(key) && !strings.EqualFold(strings.TrimSpace(key), "sslmode") {
|
||||
parts = append(parts, strings.TrimSpace(key)+"="+quoteDSNValue(strings.TrimSpace(value)))
|
||||
}
|
||||
// Classify and de-duplicate the app-supplied url_params. The connect_timeout
|
||||
// default is only applied when the user did not provide one (natively or via
|
||||
// the connectTimeout alias), so the parameter is never emitted twice.
|
||||
urlParams := normalizeURLParams(cp.URLParams)
|
||||
if !hasDSNParam(urlParams, "connect_timeout") {
|
||||
parts = append(parts, "connect_timeout=15")
|
||||
}
|
||||
for _, p := range urlParams {
|
||||
parts = append(parts, p.key+"="+quoteDSNValue(p.value))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
|
@ -994,19 +1009,39 @@ func rewriteNativeConnectionStringSSLMode(value, sslMode string) string {
|
|||
if strings.HasPrefix(strings.ToLower(value), "kingbase://") {
|
||||
baseAndQuery, fragment, hasFragment := strings.Cut(value, "#")
|
||||
base, query, hasQuery := strings.Cut(baseAndQuery, "?")
|
||||
pairs := make([]string, 0)
|
||||
params := make([]dsnParam, 0)
|
||||
if hasQuery {
|
||||
for _, pair := range strings.Split(query, "&") {
|
||||
key, _, _ := strings.Cut(pair, "=")
|
||||
decodedKey, err := url.QueryUnescape(key)
|
||||
if err == nil && strings.EqualFold(decodedKey, "sslmode") {
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
if pair != "" {
|
||||
pairs = append(pairs, pair)
|
||||
rawKey, rawValue, _ := strings.Cut(pair, "=")
|
||||
decodedKey, err := url.QueryUnescape(rawKey)
|
||||
if err != nil {
|
||||
decodedKey = rawKey
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(decodedKey), "sslmode") {
|
||||
continue
|
||||
}
|
||||
decodedValue, err := url.QueryUnescape(rawValue)
|
||||
if err != nil {
|
||||
decodedValue = rawValue
|
||||
}
|
||||
nativeKey, keep := classifyDSNParam(decodedKey, decodedValue)
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
params = append(params, dsnParam{
|
||||
key: nativeKey,
|
||||
value: rawValue, // preserve the original percent-encoding
|
||||
fromAlias: !strings.EqualFold(strings.TrimSpace(decodedKey), nativeKey),
|
||||
})
|
||||
}
|
||||
}
|
||||
pairs := make([]string, 0, len(params)+1)
|
||||
for _, p := range mergeDSNParams(params) {
|
||||
pairs = append(pairs, url.QueryEscape(p.key)+"="+p.value)
|
||||
}
|
||||
pairs = append(pairs, "sslmode="+url.QueryEscape(sslMode))
|
||||
result := base + "?" + strings.Join(pairs, "&")
|
||||
if hasFragment {
|
||||
|
|
@ -1016,14 +1051,32 @@ func rewriteNativeConnectionStringSSLMode(value, sslMode string) string {
|
|||
}
|
||||
|
||||
fields := splitNativeDSNFields(value)
|
||||
result := make([]string, 0, len(fields)+1)
|
||||
params := make([]dsnParam, 0, len(fields))
|
||||
passthrough := make([]string, 0)
|
||||
for _, field := range fields {
|
||||
key, _, ok := strings.Cut(field, "=")
|
||||
if ok && strings.EqualFold(strings.TrimSpace(key), "sslmode") {
|
||||
key, rawValue, ok := strings.Cut(field, "=")
|
||||
if !ok {
|
||||
passthrough = append(passthrough, field)
|
||||
continue
|
||||
}
|
||||
result = append(result, field)
|
||||
if strings.EqualFold(strings.TrimSpace(key), "sslmode") {
|
||||
continue
|
||||
}
|
||||
nativeKey, keep := classifyDSNParam(key, unquoteNativeDSNValue(rawValue))
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
params = append(params, dsnParam{
|
||||
key: nativeKey,
|
||||
value: rawValue, // preserve the original quoting
|
||||
fromAlias: !strings.EqualFold(strings.TrimSpace(key), nativeKey),
|
||||
})
|
||||
}
|
||||
result := make([]string, 0, len(params)+len(passthrough)+1)
|
||||
for _, p := range mergeDSNParams(params) {
|
||||
result = append(result, p.key+"="+p.value)
|
||||
}
|
||||
result = append(result, passthrough...)
|
||||
result = append(result, "sslmode="+sslMode)
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
|
@ -1096,6 +1149,87 @@ func quoteDSNValue(value string) string {
|
|||
return "'" + strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), "'", `\'`) + "'"
|
||||
}
|
||||
|
||||
// supportedDSNParams is the curated set of parameters known to be understood by
|
||||
// the gokb driver or the Kingbase server. It is no longer a strict allow-list:
|
||||
// classifyDSNParam also forwards unknown lower_snake_case names to the server as
|
||||
// run-time parameters, because gokb passes every non-driver-setting to the
|
||||
// startup packet (conn.go startup()). This set is what classifyDSNParam treats
|
||||
// as definitely native, which short-circuits the camelCase JDBC heuristic so
|
||||
// CamelCase GUCs such as DateStyle/TimeZone are still forwarded rather than
|
||||
// dropped.
|
||||
//
|
||||
// The list mirrors the driver's own surface:
|
||||
// - gokb conn.go isDriverSetting(): host, port, password, sslmode, sslcert,
|
||||
// sslkey, sslrootcert, fallback_application_name, connect_timeout,
|
||||
// disable_prepared_binary_result, binary_parameters, krbsrvname, krbspn;
|
||||
// - the standard startup keywords user and dbname;
|
||||
// - connector.go special handling: client_encoding (must be UTF8),
|
||||
// datestyle, extra_float_digits;
|
||||
// - common Kingbase/PostgreSQL run-time parameters that can be set in the
|
||||
// startup packet: application_name, options, search_path,
|
||||
// statement_timeout, work_mem, timezone and friends.
|
||||
var supportedDSNParams = map[string]struct{}{
|
||||
// gokb driver settings (conn.go isDriverSetting) and startup keywords
|
||||
"host": {},
|
||||
"port": {},
|
||||
"user": {},
|
||||
"password": {},
|
||||
"dbname": {},
|
||||
"sslmode": {},
|
||||
"sslcert": {},
|
||||
"sslkey": {},
|
||||
"sslrootcert": {},
|
||||
"fallback_application_name": {},
|
||||
"connect_timeout": {},
|
||||
"disable_prepared_binary_result": {},
|
||||
"binary_parameters": {},
|
||||
"krbsrvname": {},
|
||||
"krbspn": {},
|
||||
|
||||
// connector.go special handling
|
||||
"client_encoding": {},
|
||||
"datestyle": {},
|
||||
"extra_float_digits": {},
|
||||
|
||||
// Common run-time parameters the Kingbase server accepts in the startup
|
||||
// packet (PostgreSQL-compatible GUCs).
|
||||
"application_name": {},
|
||||
"options": {},
|
||||
"search_path": {},
|
||||
"statement_timeout": {},
|
||||
"lock_timeout": {},
|
||||
"idle_in_transaction_session_timeout": {},
|
||||
"idle_session_timeout": {},
|
||||
"work_mem": {},
|
||||
"maintenance_work_mem": {},
|
||||
"temp_buffers": {},
|
||||
"effective_cache_size": {},
|
||||
"timezone": {},
|
||||
"intervalstyle": {},
|
||||
"lc_messages": {},
|
||||
"lc_monetary": {},
|
||||
"lc_numeric": {},
|
||||
"lc_time": {},
|
||||
"default_transaction_isolation": {},
|
||||
"default_transaction_read_only": {},
|
||||
"default_transaction_deferrable": {},
|
||||
"synchronous_commit": {},
|
||||
"client_min_messages": {},
|
||||
"standard_conforming_strings": {},
|
||||
"xmloption": {},
|
||||
"role": {},
|
||||
"session_replication_role": {},
|
||||
"default_tablespace": {},
|
||||
"temp_tablespaces": {},
|
||||
"default_table_access_method": {},
|
||||
"max_parallel_workers_per_gather": {},
|
||||
}
|
||||
|
||||
func isSupportedDSNParam(key string) bool {
|
||||
_, ok := supportedDSNParams[strings.ToLower(strings.TrimSpace(key))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isSafeParamKey(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
|
@ -1109,6 +1243,189 @@ func isSafeParamKey(value string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// dsnParam is a single normalized connection parameter ready to be emitted into
|
||||
// a DSN. value carries the surface-specific text (single-quoted for keyword
|
||||
// DSNs, percent-encoded for kingbase:// URLs, raw for url_params) so callers can
|
||||
// preserve the original quoting/encoding when only the key was rewritten.
|
||||
type dsnParam struct {
|
||||
key string
|
||||
value string
|
||||
fromAlias bool
|
||||
}
|
||||
|
||||
// jdbcAliasParams maps a lowercased JDBC property to the native gokb/server
|
||||
// parameter with equivalent semantics. clientEncoding is handled separately in
|
||||
// classifyDSNParam because it also has to validate the value.
|
||||
var jdbcAliasParams = map[string]string{
|
||||
"connecttimeout": "connect_timeout", // both measured in seconds
|
||||
"currentschema": "search_path", // both accept a comma-separated list
|
||||
"applicationname": "application_name",
|
||||
}
|
||||
|
||||
// jdbcOnlyParams lists client-side JDBC/driver properties that have no meaning to
|
||||
// the Kingbase server. gokb forwards every non-driver-setting to the startup
|
||||
// packet, so a value the server does not recognize fails the whole connection
|
||||
// with "unrecognized configuration parameter". camelCase names are also caught by
|
||||
// the heuristic in classifyDSNParam; this set additionally covers the lowercase
|
||||
// JDBC properties the heuristic cannot detect and documents intent for the common
|
||||
// MySQL/JDBC-style names.
|
||||
var jdbcOnlyParams = map[string]struct{}{
|
||||
"usessl": {},
|
||||
"autoreconnect": {},
|
||||
"characterencoding": {},
|
||||
"servertimezone": {},
|
||||
"rewritebatchedstatements": {},
|
||||
"useserverprepstmts": {},
|
||||
"sockettimeout": {},
|
||||
"usecompression": {},
|
||||
"zerodatetimebehavior": {},
|
||||
"useaffectedrows": {},
|
||||
"usecursorfetch": {},
|
||||
"defaultfetchsize": {},
|
||||
"allowmultiqueries": {},
|
||||
"useunicode": {},
|
||||
// Lowercase PgJDBC/Kingbase-JDBC client properties the camelCase heuristic
|
||||
// would otherwise forward and break the connection.
|
||||
"ssl": {},
|
||||
"sslfactory": {},
|
||||
"stringtype": {},
|
||||
"gsslib": {},
|
||||
"sspiservicename": {},
|
||||
"protocolversion": {},
|
||||
"loglevel": {},
|
||||
}
|
||||
|
||||
// classifyDSNParam decides how one connection parameter should be treated and
|
||||
// returns the native parameter name to emit plus whether to keep it. sslmode is
|
||||
// handled separately by the callers and must not be passed here. decodedValue is
|
||||
// the already-unquoted/decoded value, used only for the client_encoding check.
|
||||
func classifyDSNParam(key, decodedValue string) (nativeKey string, keep bool) {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if !isSafeParamKey(trimmed) {
|
||||
return "", false
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
// client_encoding (native, or via the clientEncoding alias): gokb only
|
||||
// accepts UTF-8, so map compatible values and drop everything else — a
|
||||
// non-UTF8 value would otherwise fail the whole connection.
|
||||
if lower == "client_encoding" || lower == "clientencoding" {
|
||||
if isUTF8Encoding(decodedValue) {
|
||||
return "client_encoding", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// JDBC properties with a direct native equivalent.
|
||||
if native, ok := jdbcAliasParams[lower]; ok {
|
||||
return native, true
|
||||
}
|
||||
|
||||
// Curated native/server parameters are always forwarded. Matching here also
|
||||
// keeps CamelCase GUCs such as DateStyle/TimeZone from being mistaken for JDBC
|
||||
// camelCase properties by the heuristic below.
|
||||
if isSupportedDSNParam(lower) {
|
||||
return lower, true
|
||||
}
|
||||
|
||||
// Known JDBC-only client properties never reach the server.
|
||||
if _, ok := jdbcOnlyParams[lower]; ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Unknown parameter. Server GUCs are conventionally lower_snake_case while
|
||||
// JDBC properties are camelCase, so forward snake_case names as run-time
|
||||
// parameters (gokb passes them to the startup packet) and drop names carrying
|
||||
// an uppercase letter as presumed client-side JDBC settings.
|
||||
if hasUpperASCII(trimmed) {
|
||||
return "", false
|
||||
}
|
||||
return lower, true
|
||||
}
|
||||
|
||||
// mergeDSNParams applies duplicate-parameter precedence: an explicit native
|
||||
// parameter beats a JDBC alias for the same key, and within the same class the
|
||||
// first occurrence wins to preserve gokb's existing DSN behavior. Output order
|
||||
// follows each key's first appearance.
|
||||
func mergeDSNParams(params []dsnParam) []dsnParam {
|
||||
result := make([]dsnParam, 0, len(params))
|
||||
pos := make(map[string]int, len(params))
|
||||
for _, p := range params {
|
||||
if i, ok := pos[p.key]; ok {
|
||||
// A later explicit native parameter may replace an earlier alias, but
|
||||
// same-class duplicates keep the first value just as gokb does.
|
||||
if result[i].fromAlias && !p.fromAlias {
|
||||
result[i] = p
|
||||
}
|
||||
continue
|
||||
}
|
||||
pos[p.key] = len(result)
|
||||
result = append(result, p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeURLParams classifies and de-duplicates the app-supplied url_params
|
||||
// blob (a &/;-separated key=value list), excluding sslmode which is handled
|
||||
// separately. Values are kept raw for later single-quoting.
|
||||
func normalizeURLParams(raw string) []dsnParam {
|
||||
params := make([]dsnParam, 0)
|
||||
for _, pair := range strings.FieldsFunc(raw, func(r rune) bool { return r == '&' || r == ';' }) {
|
||||
key, value, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(key), "sslmode") {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(value)
|
||||
nativeKey, keep := classifyDSNParam(key, val)
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
params = append(params, dsnParam{
|
||||
key: nativeKey,
|
||||
value: val,
|
||||
fromAlias: !strings.EqualFold(strings.TrimSpace(key), nativeKey),
|
||||
})
|
||||
}
|
||||
return mergeDSNParams(params)
|
||||
}
|
||||
|
||||
func hasDSNParam(params []dsnParam, key string) bool {
|
||||
for _, p := range params {
|
||||
if p.key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasUpperASCII(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] >= 'A' && value[i] <= 'Z' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isUTF8Encoding mirrors gokb's isUTF8: it recognizes fuzzy variants of "UTF-8"
|
||||
// (dropping non-alphanumerics, case-insensitively) as well as "unicode".
|
||||
func isUTF8Encoding(name string) bool {
|
||||
var b strings.Builder
|
||||
for _, ch := range name {
|
||||
switch {
|
||||
case ch >= 'A' && ch <= 'Z':
|
||||
b.WriteRune(ch + ('a' - 'A'))
|
||||
case ch >= 'a' && ch <= 'z', ch >= '0' && ch <= '9':
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
return s == "utf8" || s == "unicode"
|
||||
}
|
||||
|
||||
func normalizeValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue