diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index a73453c..e335bbc 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -436,6 +436,11 @@ jobs: bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" done + bash ./tools/verify-driver-agent-revisions.sh \ + --assets-dir drivers \ + --platform "$TARGET_PLATFORM" \ + --drivers "$CHANGED_DRIVER_AGENTS" + # macOS Packaging - name: Package macOS DMG if: contains(matrix.platform, 'darwin') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ae6071..c359b9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -428,6 +428,11 @@ jobs: bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" done + bash ./tools/verify-driver-agent-revisions.sh \ + --assets-dir drivers \ + --platform "$TARGET_PLATFORM" \ + --drivers "$CHANGED_DRIVER_AGENTS" + # macOS Packaging - name: Package macOS DMG if: contains(matrix.platform, 'darwin') diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 13dd899..c373914 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -1096,6 +1096,43 @@ describe('Sidebar locate toolbar', () => { expect(filterV2ExplorerTreeByKind(tree, 'events')[0].children?.map((node) => node.key)).toEqual(['conn-main-events']); }); + it('hides external SQL roots from v2 object kind filters', () => { + const tree = [ + { + title: 'front_end_sys', + key: 'conn-main', + type: 'database' as const, + children: [ + { + title: '表', + key: 'conn-main-tables', + type: 'object-group' as const, + dataRef: { groupKey: 'tables' }, + children: [{ title: 'users', key: 'users', type: 'table' as const }], + }, + ], + }, + { + title: '外部 SQL 目录', + key: 'external-sql-root', + type: 'external-sql-root' as const, + children: [ + { + title: 'scripts', + key: 'external-sql-folder:scripts', + type: 'external-sql-folder' as const, + }, + ], + }, + ]; + + expect(filterV2ExplorerTreeByKind(tree, 'all').map((node) => node.key)).toEqual([ + 'conn-main', + 'external-sql-root', + ]); + expect(filterV2ExplorerTreeByKind(tree, 'tables').map((node) => node.key)).toEqual(['conn-main']); + }); + it('adds rename to the saved query context menu', () => { const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d678fe2..9484c24 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -491,7 +491,7 @@ export const filterV2ExplorerTreeByKind = ( const visit = (node: TreeNode): TreeNode | null => { if (node.type === 'external-sql-root') { - return node; + return null; } const groupKey = String(node?.dataRef?.groupKey || ''); if (node.type === 'object-group') { diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 2393b96..022f0bb 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -3374,6 +3374,19 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut if a != nil { a.emitDriverDownloadProgress(driverType, "downloading", 10, 100, planMessage) } + validateInstalledCandidateRevision := func(source string) error { + if _, revisionErr := verifyInstalledOptionalDriverAgentRevision(driverType, executablePath, selectedVersion); revisionErr != nil { + _ = os.Remove(executablePath) + for _, supportName := range optionalDriverSupportFileNames(driverType) { + _ = os.Remove(filepath.Join(filepath.Dir(executablePath), supportName)) + } + if strings.TrimSpace(source) != "" { + return fmt.Errorf("%s: %w", source, revisionErr) + } + return revisionErr + } + return nil + } if !skipReuseCandidate { if sourcePath, ok := findExistingOptionalDriverAgentCandidate(definition, executablePath); ok { if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil { @@ -3387,6 +3400,9 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut if hashErr != nil { return "", "", fmt.Errorf("计算预置 %s 驱动代理摘要失败:%w", displayName, hashErr) } + if revisionErr := validateInstalledCandidateRevision(sourcePath); revisionErr != nil { + return "", "", fmt.Errorf("预置 %s 驱动代理 revision 校验失败:%w", displayName, revisionErr) + } return "file://" + sourcePath, hash, nil } } @@ -3421,6 +3437,11 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut } hash, dlErr := downloadOptionalDriverAgentBinary(a, definition, candidateURL, executablePath) if dlErr == nil { + if revisionErr := validateInstalledCandidateRevision(candidateURL); revisionErr != nil { + logger.Warnf("预编译 %s 驱动代理 revision 校验失败,url=%s err=%v", displayName, candidateURL, revisionErr) + downloadErrs = append(downloadErrs, fmt.Sprintf("%s: %s", candidateURL, strings.TrimSpace(revisionErr.Error()))) + continue + } return candidateURL, hash, nil } logger.Warnf("下载预编译 %s 驱动代理失败,url=%s err=%v", displayName, candidateURL, dlErr) @@ -3439,6 +3460,11 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut } source, hash, bundleErr := downloadOptionalDriverAgentFromBundle(a, definition, bundleURL, executablePath) if bundleErr == nil { + if revisionErr := validateInstalledCandidateRevision(source); revisionErr != nil { + logger.Warnf("驱动总包 %s 代理 revision 校验失败,source=%s err=%v", displayName, source, revisionErr) + downloadErrs = append(downloadErrs, fmt.Sprintf("%s: %s", source, strings.TrimSpace(revisionErr.Error()))) + continue + } return source, hash, nil } logger.Warnf("从驱动总包提取 %s 驱动代理失败,url=%s err=%v", displayName, bundleURL, bundleErr) @@ -3847,7 +3873,7 @@ func shouldPreferSourceBuildBeforeDownloadForBuildType(buildType string, driverT func shouldRequireSourceBuildBeforeDownloadForBuildType(buildType string, driverType string, selectedVersion string) bool { _ = selectedVersion - if shouldUseDuckDBWindowsDynamicLibrary(driverType) { + if normalizeDriverType(driverType) == "duckdb" { return false } return shouldPreferDevelopmentDriverAgentSourceBuild(buildType, driverType) diff --git a/internal/app/methods_driver_version_test.go b/internal/app/methods_driver_version_test.go index 17c9df7..5b24161 100644 --- a/internal/app/methods_driver_version_test.go +++ b/internal/app/methods_driver_version_test.go @@ -13,6 +13,8 @@ import ( "sync/atomic" "testing" "time" + + "GoNavi-Wails/internal/db" ) func TestResolveVersionedDriverOptionUsesPublishedMongoV1Release(t *testing.T) { @@ -727,6 +729,95 @@ func TestDownloadOptionalDriverAgentFromBundleSharesConcurrentDownload(t *testin } } +func TestEnsureOptionalDriverAgentBinaryFallsBackAfterStaleDownloadRevision(t *testing.T) { + originalProbe := optionalDriverAgentMetadataProbe + originalGoBinaryLookPath := goBinaryLookPath + t.Cleanup(func() { + optionalDriverAgentMetadataProbe = originalProbe + goBinaryLookPath = originalGoBinaryLookPath + }) + + tmpDir := t.TempDir() + staleAgent := filepath.Join(tmpDir, "stale-driver-agent") + if runtime.GOOS == "windows" { + staleAgent += ".exe" + } + writeSelfExecutable(t, staleAgent) + + staleServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, staleAgent) + })) + defer staleServer.Close() + + projectRoot := filepath.Join(tmpDir, "project") + if err := os.MkdirAll(filepath.Join(projectRoot, "cmd", "optional-driver-agent"), 0o755); err != nil { + t.Fatalf("create project root failed: %v", err) + } + if err := os.WriteFile(filepath.Join(projectRoot, "go.mod"), []byte("module GoNavi-Wails\n"), 0o644); err != nil { + t.Fatalf("write go.mod failed: %v", err) + } + if err := os.WriteFile(filepath.Join(projectRoot, "cmd", "optional-driver-agent", "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatalf("write optional agent main failed: %v", err) + } + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd failed: %v", err) + } + if err := os.Chdir(projectRoot); err != nil { + t.Fatalf("chdir project root failed: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(wd); err != nil { + t.Fatalf("restore cwd failed: %v", err) + } + }) + goScript := filepath.Join(tmpDir, "fake-go") + if runtime.GOOS == "windows" { + goScript += ".bat" + } + if runtime.GOOS == "windows" { + if err := os.WriteFile(goScript, []byte("@echo off\r\nset out=\r\n:loop\r\nif \"%1\"==\"\" goto done\r\nif \"%1\"==\"-o\" (set out=%2& shift& shift& goto loop)\r\nshift\r\ngoto loop\r\n:done\r\ncopy /Y \"%GONAVI_TEST_BUILT_AGENT%\" \"%out%\" >nul\r\n"), 0o755); err != nil { + t.Fatalf("write fake go script failed: %v", err) + } + } else { + if err := os.WriteFile(goScript, []byte("#!/usr/bin/env sh\nout=\"\"\nwhile [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = \"-o\" ]; then out=\"$2\"; shift 2; continue; fi\n shift\ndone\ncp \"$GONAVI_TEST_BUILT_AGENT\" \"$out\"\n"), 0o755); err != nil { + t.Fatalf("write fake go script failed: %v", err) + } + } + goBinaryLookPath = func(file string) (string, error) { + return goScript, nil + } + t.Setenv("GONAVI_TEST_BUILT_AGENT", staleAgent) + + probeCount := 0 + optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) { + probeCount++ + revision := "src-stale-agent" + if probeCount > 1 { + revision = db.OptionalDriverAgentRevision(driverType) + } + return db.OptionalDriverAgentMetadata{ + DriverType: driverType, + AgentRevision: revision, + }, nil + } + + targetPath := filepath.Join(tmpDir, optionalDriverExecutableBaseName("sqlserver")) + source, _, err := ensureOptionalDriverAgentBinary( + nil, + driverDefinition{Type: "sqlserver", Name: "SQL Server"}, + targetPath, + staleServer.URL, + "1.9.6", + ) + if err != nil { + t.Fatalf("expected stale direct download to fall back to source build, got %v", err) + } + if source != "local://go-build/sqlserver-driver-agent" { + t.Fatalf("expected source build fallback, got %q", source) + } +} + func seedReleaseAssetSizeCache(t *testing.T, cacheKey string, sizeByKey map[string]int64) { t.Helper() diff --git a/tools/verify-driver-agent-revisions.sh b/tools/verify-driver-agent-revisions.sh new file mode 100755 index 0000000..74a7ade --- /dev/null +++ b/tools/verify-driver-agent-revisions.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +usage() { + cat <<'EOF' +用法: + ./tools/verify-driver-agent-revisions.sh --assets-dir <目录> --platform --drivers <列表> + +说明: + 校验已构建 driver-agent 资产返回的 agentRevision 是否等于当前源码生成的 revision。 +EOF +} + +assets_dir="" +target_platform="" +driver_csv="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --assets-dir) + assets_dir="${2:-}" + shift 2 + ;; + --platform) + target_platform="${2:-}" + shift 2 + ;; + --drivers) + driver_csv="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$assets_dir" || -z "$target_platform" || -z "$driver_csv" ]]; then + usage >&2 + exit 1 +fi +if [[ "$target_platform" != */* ]]; then + echo "--platform 参数格式错误,应为 GOOS/GOARCH,例如 darwin/arm64" >&2 + exit 1 +fi + +goos="${target_platform%%/*}" +goarch="${target_platform##*/}" +platform_dir="Unknown" +case "$goos" in + windows) platform_dir="Windows" ;; + darwin) platform_dir="MacOS" ;; + linux) platform_dir="Linux" ;; +esac + +normalize_driver() { + local value + value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + case "$value" in + doris|diros) echo "diros" ;; + opengauss|open_gauss|open-gauss) echo "opengauss" ;; + elasticsearch|elastic) echo "elasticsearch" ;; + mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|iris|mongodb|tdengine|clickhouse) + echo "$value" + ;; + *) + return 1 + ;; + esac +} + +public_driver_name() { + case "$1" in + diros) echo "doris" ;; + *) echo "$1" ;; + esac +} + +expected_revision_for() { + local target="$1" + awk -v target="$target" ' + $0 ~ "\"" target "\"" { + if (match($0, /"src-[^"]+"/)) { + value=substr($0, RSTART + 1, RLENGTH - 2) + print value + exit + } + } + ' internal/db/driver_agent_revisions_gen.go +} + +agent_path_for() { + local driver="$1" + local public_name asset + public_name="$(public_driver_name "$driver")" + asset="${public_name}-driver-agent-${goos}-${goarch}" + if [[ "$goos" == "windows" ]]; then + asset="${asset}.exe" + fi + printf '%s\n' "${assets_dir%/}/${platform_dir}/${asset}" +} + +probe_agent_revision() { + local agent_path="$1" + local request + request='{"id":1,"method":"metadata"}' + printf '%s\n' "$request" | "$agent_path" | python3 -c ' +import json +import sys + +line = sys.stdin.readline() +payload = json.loads(line) +data = payload.get("data") or {} +print(data.get("agentRevision", "")) +' +} + +declare -a raw_drivers=() +IFS=',' read -r -a raw_drivers <<<"$driver_csv" + +failed=0 +for raw_driver in "${raw_drivers[@]}"; do + [[ -n "$raw_driver" ]] || continue + driver="$(normalize_driver "$raw_driver")" + if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then + echo "⚠️ 跳过 duckdb revision 校验($target_platform 不构建 agent)" + continue + fi + + expected="$(expected_revision_for "$driver")" + if [[ -z "$expected" ]]; then + echo "❌ $driver 缺少期望 revision" + failed=1 + continue + fi + + agent_path="$(agent_path_for "$driver")" + if [[ ! -f "$agent_path" ]]; then + echo "❌ $driver 缺少 driver-agent 资产:$agent_path" + failed=1 + continue + fi + chmod +x "$agent_path" 2>/dev/null || true + + actual="$(probe_agent_revision "$agent_path" || true)" + if [[ "$actual" != "$expected" ]]; then + echo "❌ $driver driver-agent revision 不匹配:asset=$agent_path actual=${actual:-空} expected=$expected" + failed=1 + continue + fi + echo "✅ $driver driver-agent revision 校验通过:$actual" +done + +exit "$failed"