mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(jdbc): include managed drivers in offline bundles
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_jdbc_payload.mjs");
|
||||
const ZIP_BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_zip.sh");
|
||||
const RELEASE_VERIFIER = join(REPO_ROOT, "agents/scripts/verify_offline_jdbc_release.mjs");
|
||||
const ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json");
|
||||
const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"];
|
||||
|
||||
function flattenBundles(manifest) {
|
||||
return Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {}));
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { encoding: "utf8", ...options });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("builds and verifies all platform ZIPs from the managed JDBC asset manifest", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "dbx-offline-jdbc-builder-test-"));
|
||||
try {
|
||||
const releaseDir = join(root, "release");
|
||||
const pluginZip = join(root, "plugin.zip");
|
||||
const jarRoot = join(root, "jar");
|
||||
const artifact = join(root, "driver.jar");
|
||||
const resolver = join(root, "resolver.mjs");
|
||||
const testManifestPath = join(root, "managed-jdbc-assets.json");
|
||||
mkdirSync(releaseDir, { recursive: true });
|
||||
mkdirSync(join(jarRoot, "META-INF"), { recursive: true });
|
||||
writeFileSync(pluginZip, "plugin");
|
||||
writeFileSync(join(jarRoot, "META-INF/LICENSE"), "Apache License 2.0\n");
|
||||
writeFileSync(join(jarRoot, "META-INF/NOTICE"), "Test notice\n");
|
||||
run("zip", ["-qr", artifact, "."], { cwd: jarRoot });
|
||||
|
||||
const digest = createHash("sha256").update(readFileSync(artifact)).digest("hex");
|
||||
const productionManifest = JSON.parse(readFileSync(ASSET_MANIFEST, "utf8"));
|
||||
const testManifest = structuredClone(productionManifest);
|
||||
const resolverBundles = {};
|
||||
for (const bundle of flattenBundles(testManifest)) {
|
||||
bundle.artifact.file_name = "driver.jar";
|
||||
bundle.artifact.size = statSync(artifact).size;
|
||||
bundle.artifact.sha256 = digest;
|
||||
bundle.redistribution.license_entries = ["META-INF/LICENSE"];
|
||||
bundle.redistribution.notice_entries = ["META-INF/NOTICE"];
|
||||
resolverBundles[bundle.coordinate] = {
|
||||
coordinate: bundle.coordinate,
|
||||
scope: "runtime",
|
||||
repositories: [testManifest.repository],
|
||||
artifacts: [
|
||||
{
|
||||
groupId: bundle.artifact.group_id,
|
||||
artifactId: bundle.artifact.artifact_id,
|
||||
version: bundle.artifact.version,
|
||||
classifier: bundle.artifact.classifier,
|
||||
extension: bundle.artifact.extension,
|
||||
file: artifact,
|
||||
size: bundle.artifact.size,
|
||||
sha256: digest,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
writeFileSync(testManifestPath, `${JSON.stringify(testManifest, null, 2)}\n`);
|
||||
writeFileSync(
|
||||
resolver,
|
||||
`#!/usr/bin/env node
|
||||
const bundles = ${JSON.stringify(resolverBundles)};
|
||||
const coordinate = process.argv[process.argv.indexOf("--coordinate") + 1];
|
||||
if (!bundles[coordinate]) process.exit(2);
|
||||
process.stdout.write(JSON.stringify(bundles[coordinate]));
|
||||
`,
|
||||
);
|
||||
chmodSync(resolver, 0o755);
|
||||
|
||||
run(process.execPath, [BUILDER, releaseDir, pluginZip, resolver, testManifestPath], {
|
||||
env: { ...process.env, DBX_OFFLINE_JDBC_MAVEN_CACHE: join(root, "cache") },
|
||||
});
|
||||
|
||||
const payloadDir = join(releaseDir, "offline-jdbc/jdbc");
|
||||
const offlineManifest = JSON.parse(readFileSync(join(payloadDir, "offline-manifest.json"), "utf8"));
|
||||
assert.equal(offlineManifest.format_version, 1);
|
||||
assert.equal(offlineManifest.plugin_entry, "jdbc/plugin.zip");
|
||||
assert.equal(offlineManifest.third_party_entry, "jdbc/third-party-notices.json");
|
||||
assert.deepEqual(
|
||||
offlineManifest.bundles.map((bundle) => bundle.coordinate),
|
||||
flattenBundles(productionManifest).map((bundle) => bundle.coordinate),
|
||||
);
|
||||
for (const bundle of offlineManifest.bundles) {
|
||||
const bundleManifest = JSON.parse(readFileSync(join(payloadDir, "maven", bundle.id, "manifest.json"), "utf8"));
|
||||
assert.equal(bundleManifest.coordinate, bundle.coordinate);
|
||||
assert.equal(bundleManifest.artifacts.length, 1);
|
||||
assert.equal(bundleManifest.artifacts[0].sha256, digest);
|
||||
}
|
||||
|
||||
writeFileSync(join(releaseDir, "agent-registry.json"), "{}\n");
|
||||
for (const platform of PLATFORMS) writeFileSync(join(releaseDir, `dbx-jre-21-${platform}.tar.zst`), platform);
|
||||
run("bash", [ZIP_BUILDER, releaseDir]);
|
||||
run(process.execPath, [RELEASE_VERIFIER, releaseDir, testManifestPath]);
|
||||
for (const platform of PLATFORMS) assert.equal(existsSync(join(releaseDir, `dbx-agents-offline-${platform}.zip`)), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -969,6 +969,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ steps.release-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
@@ -1172,9 +1177,22 @@ jobs:
|
||||
echo "=== agent-registry.json ==="
|
||||
cat release/agent-registry.json
|
||||
|
||||
- name: Build managed JDBC offline payload
|
||||
shell: bash
|
||||
run: |
|
||||
./plugins/jdbc/package.sh
|
||||
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
|
||||
node agents/scripts/build_offline_jdbc_payload.mjs \
|
||||
release \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver"
|
||||
|
||||
- name: Build offline ZIP bundles
|
||||
run: bash agents/scripts/build_offline_zip.sh release
|
||||
|
||||
- name: Verify managed JDBC assets in all offline ZIPs
|
||||
run: node agents/scripts/verify_offline_jdbc_release.mjs release
|
||||
|
||||
- name: Build single-driver packages
|
||||
run: |
|
||||
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
|
||||
|
||||
@@ -526,6 +526,46 @@ jobs:
|
||||
- name: JDBC plugin package check
|
||||
run: ./plugins/jdbc/package.sh
|
||||
|
||||
offline-jdbc-release:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offline_jdbc == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
- name: Cache approved Maven assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/dbx-offline-jdbc-maven
|
||||
key: offline-jdbc-${{ hashFiles('apps/desktop/src/lib/database/managedJdbcAssets.json') }}
|
||||
|
||||
- name: Build real managed JDBC payload
|
||||
env:
|
||||
DBX_OFFLINE_JDBC_MAVEN_CACHE: ~/.cache/dbx-offline-jdbc-maven
|
||||
run: |
|
||||
./plugins/jdbc/package.sh
|
||||
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
|
||||
node agents/scripts/build_offline_jdbc_payload.mjs \
|
||||
release \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver"
|
||||
|
||||
- name: Build and unpack all six platform ZIPs
|
||||
run: |
|
||||
printf '{}\n' > release/agent-registry.json
|
||||
for platform in macos-aarch64 macos-x64 linux-x64 linux-aarch64 windows-x64 windows-aarch64; do
|
||||
printf '%s\n' "$platform" > "release/dbx-jre-21-${platform}.tar.zst"
|
||||
done
|
||||
bash agents/scripts/build_offline_zip.sh release
|
||||
node agents/scripts/verify_offline_jdbc_release.mjs release
|
||||
|
||||
nix-packaging:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.nix == 'true'
|
||||
@@ -548,6 +588,7 @@ jobs:
|
||||
rust: ${{ steps.filter.outputs.rust }}
|
||||
rust_full: ${{ steps.rust-mode.outputs.full }}
|
||||
jdbc: ${{ steps.filter.outputs.jdbc }}
|
||||
offline_jdbc: ${{ steps.filter.outputs.offline_jdbc }}
|
||||
agents: ${{ steps.filter.outputs.agents }}
|
||||
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows }}
|
||||
nix: ${{ steps.filter.outputs.nix }}
|
||||
@@ -595,6 +636,15 @@ jobs:
|
||||
- '.github/workflows/ci.yml'
|
||||
jdbc:
|
||||
- 'plugins/jdbc/**'
|
||||
offline_jdbc:
|
||||
- 'plugins/jdbc/**'
|
||||
- 'agents/scripts/build_offline_jdbc_payload.mjs'
|
||||
- 'agents/scripts/build_offline_zip.sh'
|
||||
- 'agents/scripts/verify_offline_jdbc_release.mjs'
|
||||
- 'apps/desktop/src/lib/database/managedJdbcAssets.json'
|
||||
- '.github/scripts/offline-jdbc-payload.test.mjs'
|
||||
- '.github/workflows/agents-release.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
agents:
|
||||
- 'agents/**'
|
||||
- 'crates/dbx-core/Cargo.toml'
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const DEFAULT_ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json");
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function sha256(path) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function loadAssetManifest(path) {
|
||||
const manifest = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (manifest.format_version !== 1) fail(`Unsupported managed JDBC asset manifest version: ${manifest.format_version}`);
|
||||
if (!manifest.repository || !manifest.drivers || typeof manifest.drivers !== "object") fail("Invalid managed JDBC asset manifest");
|
||||
|
||||
const bundles = Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {}));
|
||||
if (bundles.length === 0) fail("Managed JDBC asset manifest contains no bundles");
|
||||
const ids = new Set();
|
||||
const coordinates = new Set();
|
||||
for (const bundle of bundles) {
|
||||
if (!bundle.id || !bundle.coordinate || !bundle.artifact || !bundle.redistribution) fail("Managed JDBC bundle is incomplete");
|
||||
if (ids.has(bundle.id)) fail(`Duplicate managed JDBC bundle id: ${bundle.id}`);
|
||||
if (coordinates.has(bundle.coordinate)) fail(`Duplicate managed JDBC coordinate: ${bundle.coordinate}`);
|
||||
ids.add(bundle.id);
|
||||
coordinates.add(bundle.coordinate);
|
||||
}
|
||||
return { manifest, bundles };
|
||||
}
|
||||
|
||||
function resolveBundle(resolver, coordinate, localRepository, repositories) {
|
||||
const javaToolOptions = [
|
||||
process.env.JAVA_TOOL_OPTIONS,
|
||||
"-Djava.net.preferIPv4Stack=true",
|
||||
"-Daether.connector.connectTimeout=30000",
|
||||
"-Daether.connector.requestTimeout=300000",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const args = ["resolve", "--coordinate", coordinate, "--local-repo", localRepository];
|
||||
for (const repository of repositories) args.push("--repo", repository);
|
||||
const result = spawnSync(resolver, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
env: { ...process.env, JAVA_TOOL_OPTIONS: javaToolOptions },
|
||||
});
|
||||
if (result.error) fail(`Failed to start JDBC Maven resolver for ${coordinate}: ${result.error.message}`);
|
||||
if (result.status !== 0) fail(`Failed to resolve ${coordinate}: ${(result.stderr || result.stdout).trim()}`);
|
||||
try {
|
||||
return JSON.parse(result.stdout);
|
||||
} catch (error) {
|
||||
fail(`Failed to parse Maven resolver output for ${coordinate}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function artifactIdentity(artifact) {
|
||||
return [artifact.groupId, artifact.artifactId, artifact.version, artifact.classifier || "", artifact.extension].join(":");
|
||||
}
|
||||
|
||||
function expectedArtifactIdentity(artifact) {
|
||||
return [artifact.group_id, artifact.artifact_id, artifact.version, artifact.classifier || "", artifact.extension].join(":");
|
||||
}
|
||||
|
||||
function jarEntries(path) {
|
||||
const result = spawnSync("unzip", ["-Z1", path], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
||||
if (result.error) fail(`Failed to inspect JDBC JAR ${path}: ${result.error.message}`);
|
||||
if (result.status !== 0) fail(`Failed to inspect JDBC JAR ${path}: ${(result.stderr || result.stdout).trim()}`);
|
||||
return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
|
||||
}
|
||||
|
||||
async function validateAndCopyBundle(bundle, resolved, mavenDir) {
|
||||
const resolvedArtifacts = (resolved.artifacts || []).filter((artifact) => String(artifact.extension).toLowerCase() === "jar");
|
||||
const identities = new Set(resolvedArtifacts.map(artifactIdentity));
|
||||
if (identities.size !== resolvedArtifacts.length) fail(`Maven resolver returned duplicate JAR artifacts for ${bundle.coordinate}`);
|
||||
if (resolvedArtifacts.length !== 1) {
|
||||
fail(`Resolved dependency set changed for ${bundle.coordinate}: expected 1 approved shaded JAR, received ${resolvedArtifacts.length}`);
|
||||
}
|
||||
|
||||
const resolvedArtifact = resolvedArtifacts[0];
|
||||
const approved = bundle.artifact;
|
||||
if (artifactIdentity(resolvedArtifact) !== expectedArtifactIdentity(approved)) {
|
||||
fail(`Resolved artifact identity does not match the approved asset for ${bundle.coordinate}`);
|
||||
}
|
||||
if (!existsSync(resolvedArtifact.file)) fail(`Resolved artifact does not exist: ${resolvedArtifact.file}`);
|
||||
if (basename(resolvedArtifact.file) !== approved.file_name) fail(`Resolved artifact file name does not match the approved asset for ${bundle.coordinate}`);
|
||||
|
||||
const actualSize = statSync(resolvedArtifact.file).size;
|
||||
const actualDigest = await sha256(resolvedArtifact.file);
|
||||
if (Number(resolvedArtifact.size) !== actualSize || String(resolvedArtifact.sha256).toLowerCase() !== actualDigest) {
|
||||
fail(`Maven resolver metadata mismatch: ${resolvedArtifact.file}`);
|
||||
}
|
||||
if (approved.size !== actualSize || approved.sha256.toLowerCase() !== actualDigest) {
|
||||
fail(`Resolved artifact checksum is not approved for redistribution: ${bundle.coordinate}`);
|
||||
}
|
||||
|
||||
const entries = jarEntries(resolvedArtifact.file);
|
||||
for (const entry of [...bundle.redistribution.license_entries, ...bundle.redistribution.notice_entries]) {
|
||||
if (!entries.has(entry)) fail(`Approved license/notice entry is missing from ${bundle.coordinate}: ${entry}`);
|
||||
}
|
||||
|
||||
const bundleDir = join(mavenDir, bundle.id);
|
||||
const jarsDir = join(bundleDir, "jars");
|
||||
mkdirSync(jarsDir, { recursive: true });
|
||||
copyFileSync(resolvedArtifact.file, join(jarsDir, approved.file_name));
|
||||
const artifact = {
|
||||
group_id: approved.group_id,
|
||||
artifact_id: approved.artifact_id,
|
||||
version: approved.version,
|
||||
classifier: approved.classifier || "",
|
||||
extension: approved.extension,
|
||||
file_name: approved.file_name,
|
||||
path: "",
|
||||
size: actualSize,
|
||||
sha256: actualDigest,
|
||||
};
|
||||
const bundleManifest = {
|
||||
id: bundle.id,
|
||||
coordinate: bundle.coordinate,
|
||||
scope: resolved.scope || "runtime",
|
||||
repositories: resolved.repositories?.length ? resolved.repositories : [],
|
||||
installed_at: "",
|
||||
path: "",
|
||||
artifacts: [artifact],
|
||||
};
|
||||
writeFileSync(join(bundleDir, "manifest.json"), `${JSON.stringify(bundleManifest, null, 2)}\n`);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [releaseArg, pluginZipArg, resolverArg, assetManifestArg] = process.argv.slice(2);
|
||||
if (!releaseArg || !pluginZipArg || !resolverArg) {
|
||||
fail("Usage: build_offline_jdbc_payload.mjs <release-dir> <jdbc-plugin-zip> <maven-resolver> [asset-manifest]");
|
||||
}
|
||||
|
||||
const releaseDir = resolve(releaseArg);
|
||||
const pluginZip = resolve(pluginZipArg);
|
||||
const resolver = resolve(resolverArg);
|
||||
const assetManifestPath = resolve(assetManifestArg || DEFAULT_ASSET_MANIFEST);
|
||||
if (!existsSync(pluginZip)) fail(`JDBC plugin ZIP not found: ${pluginZip}`);
|
||||
if (!existsSync(resolver)) fail(`JDBC Maven resolver not found: ${resolver}`);
|
||||
if (!existsSync(assetManifestPath)) fail(`Managed JDBC asset manifest not found: ${assetManifestPath}`);
|
||||
const { manifest: assetManifest, bundles } = loadAssetManifest(assetManifestPath);
|
||||
|
||||
const payloadDir = join(releaseDir, "offline-jdbc", "jdbc");
|
||||
const mavenDir = join(payloadDir, "maven");
|
||||
rmSync(payloadDir, { recursive: true, force: true });
|
||||
mkdirSync(mavenDir, { recursive: true });
|
||||
copyFileSync(pluginZip, join(payloadDir, "plugin.zip"));
|
||||
|
||||
const externalCache = process.env.DBX_OFFLINE_JDBC_MAVEN_CACHE?.trim();
|
||||
const resolverRepository = process.env.DBX_OFFLINE_JDBC_MAVEN_REPOSITORY?.trim() || assetManifest.repository;
|
||||
const workDir = externalCache ? null : mkdtempSync(join(tmpdir(), "dbx-offline-jdbc-"));
|
||||
const localRepository = externalCache ? resolve(externalCache) : join(workDir, "maven-cache");
|
||||
mkdirSync(localRepository, { recursive: true });
|
||||
const manifestBundles = [];
|
||||
const notices = [];
|
||||
try {
|
||||
for (const bundle of bundles) {
|
||||
console.log(`Resolving offline JDBC bundle ${bundle.coordinate}`);
|
||||
const resolved = resolveBundle(resolver, bundle.coordinate, localRepository, [resolverRepository]);
|
||||
const artifact = await validateAndCopyBundle(bundle, resolved, mavenDir);
|
||||
manifestBundles.push({ id: bundle.id, coordinate: bundle.coordinate });
|
||||
notices.push({ id: bundle.id, coordinate: bundle.coordinate, artifact, redistribution: bundle.redistribution });
|
||||
console.log(`Prepared ${bundle.coordinate} (approved SHA-256 ${artifact.sha256})`);
|
||||
}
|
||||
} finally {
|
||||
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(payloadDir, "third-party-notices.json"),
|
||||
`${JSON.stringify({ format_version: 1, bundles: notices }, null, 2)}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(payloadDir, "offline-manifest.json"),
|
||||
`${JSON.stringify({ format_version: 1, plugin_entry: "jdbc/plugin.zip", third_party_entry: "jdbc/third-party-notices.json", bundles: manifestBundles }, null, 2)}\n`,
|
||||
);
|
||||
console.log(`Prepared offline JDBC payload in ${payloadDir}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`ERROR: ${error?.stack || error?.message || String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -3,7 +3,8 @@ set -euo pipefail
|
||||
|
||||
# Build offline ZIP bundles for each platform.
|
||||
# Usage: ./scripts/build_offline_zip.sh <release-dir>
|
||||
# The release-dir must contain agent-registry.json, raw driver artifacts, and dbx-jre-*.tar.zst
|
||||
# The release-dir must contain agent-registry.json, raw driver artifacts, and dbx-jre-*.tar.zst.
|
||||
# If offline-jdbc/jdbc is present, it is included as the managed JDBC payload.
|
||||
|
||||
RELEASE_DIR="$(cd "${1:?Usage: build_offline_zip.sh <release-dir>}" && pwd)"
|
||||
|
||||
@@ -30,6 +31,10 @@ for platform in "${PLATFORMS[@]}"; do
|
||||
|
||||
cp "$RELEASE_DIR/agent-registry.json" "$WORK/"
|
||||
|
||||
if [ -d "$RELEASE_DIR/offline-jdbc/jdbc" ]; then
|
||||
cp -R "$RELEASE_DIR/offline-jdbc/jdbc" "$WORK/"
|
||||
fi
|
||||
|
||||
# Copy all JRE archives for this platform
|
||||
JRE_COUNT=0
|
||||
for jre_file in "$RELEASE_DIR"/dbx-jre-*-"$platform".tar.zst; do
|
||||
@@ -56,7 +61,9 @@ for platform in "${PLATFORMS[@]}"; do
|
||||
done
|
||||
|
||||
ZIP_NAME="dbx-agents-offline-${platform}.zip"
|
||||
(cd "$WORK" && zip -r "$RELEASE_DIR/$ZIP_NAME" agent-registry.json jre/ drivers/)
|
||||
ZIP_ENTRIES=(agent-registry.json jre/ drivers/)
|
||||
[ -d "$WORK/jdbc" ] && ZIP_ENTRIES+=(jdbc/)
|
||||
(cd "$WORK" && zip -r "$RELEASE_DIR/$ZIP_NAME" "${ZIP_ENTRIES[@]}")
|
||||
SIZE=$(du -h "$RELEASE_DIR/$ZIP_NAME" | cut -f1)
|
||||
echo "Created $ZIP_NAME ($SIZE)"
|
||||
done
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const DEFAULT_ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json");
|
||||
const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function sha256(path) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function filesBelow(root) {
|
||||
const files = [];
|
||||
function visit(directory) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(path);
|
||||
else if (entry.isFile()) files.push(relative(root, path).replaceAll("\\", "/"));
|
||||
}
|
||||
}
|
||||
visit(root);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function flattenBundles(manifest) {
|
||||
return Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {}));
|
||||
}
|
||||
|
||||
async function validatePayload(payloadDir, assetManifestPath) {
|
||||
const assets = JSON.parse(readFileSync(assetManifestPath, "utf8"));
|
||||
const approved = flattenBundles(assets);
|
||||
const offline = JSON.parse(readFileSync(join(payloadDir, "offline-manifest.json"), "utf8"));
|
||||
const notices = JSON.parse(readFileSync(join(payloadDir, "third-party-notices.json"), "utf8"));
|
||||
if (offline.third_party_entry !== "jdbc/third-party-notices.json") fail("Offline JDBC third-party notice entry is missing");
|
||||
if (JSON.stringify(offline.bundles) !== JSON.stringify(approved.map(({ id, coordinate }) => ({ id, coordinate })))) {
|
||||
fail("Offline JDBC bundle list differs from the managed JDBC asset manifest");
|
||||
}
|
||||
if (notices.bundles.length !== approved.length) fail("Offline JDBC third-party notice list is incomplete");
|
||||
|
||||
for (const bundle of approved) {
|
||||
const manifest = JSON.parse(readFileSync(join(payloadDir, "maven", bundle.id, "manifest.json"), "utf8"));
|
||||
if (manifest.coordinate !== bundle.coordinate || manifest.artifacts.length !== 1) fail(`Invalid offline JDBC bundle: ${bundle.coordinate}`);
|
||||
const artifact = manifest.artifacts[0];
|
||||
const jar = join(payloadDir, "maven", bundle.id, "jars", artifact.file_name);
|
||||
if (artifact.size !== statSync(jar).size || artifact.sha256 !== (await sha256(jar))) fail(`Invalid bundled JDBC artifact: ${bundle.coordinate}`);
|
||||
if (artifact.sha256 !== bundle.artifact.sha256 || artifact.size !== bundle.artifact.size) fail(`Unapproved bundled JDBC artifact: ${bundle.coordinate}`);
|
||||
const notice = notices.bundles.find((candidate) => candidate.id === bundle.id);
|
||||
if (!notice || JSON.stringify(notice.redistribution) !== JSON.stringify(bundle.redistribution)) fail(`Missing redistribution metadata: ${bundle.coordinate}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [releaseArg, assetManifestArg] = process.argv.slice(2);
|
||||
if (!releaseArg) fail("Usage: verify_offline_jdbc_release.mjs <release-dir> [asset-manifest]");
|
||||
const releaseDir = resolve(releaseArg);
|
||||
const payloadDir = join(releaseDir, "offline-jdbc", "jdbc");
|
||||
const assetManifestPath = resolve(assetManifestArg || DEFAULT_ASSET_MANIFEST);
|
||||
if (!existsSync(payloadDir)) fail(`Offline JDBC payload not found: ${payloadDir}`);
|
||||
await validatePayload(payloadDir, assetManifestPath);
|
||||
|
||||
const expectedFiles = filesBelow(payloadDir);
|
||||
const expectedHashes = new Map();
|
||||
for (const file of expectedFiles) expectedHashes.set(file, await sha256(join(payloadDir, file)));
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
const zip = join(releaseDir, `dbx-agents-offline-${platform}.zip`);
|
||||
if (!existsSync(zip)) fail(`Offline ZIP not found: ${zip}`);
|
||||
const extractionRoot = mkdtempSync(join(tmpdir(), `dbx-offline-verify-${platform}-`));
|
||||
try {
|
||||
const result = spawnSync("unzip", ["-q", zip, "-d", extractionRoot], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
||||
if (result.error) fail(`Failed to unpack ${zip}: ${result.error.message}`);
|
||||
if (result.status !== 0) fail(`Failed to unpack ${zip}: ${(result.stderr || result.stdout).trim()}`);
|
||||
const extractedPayload = join(extractionRoot, "jdbc");
|
||||
const actualFiles = filesBelow(extractedPayload);
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) fail(`Incomplete jdbc/ directory in ${zip}`);
|
||||
for (const file of actualFiles) {
|
||||
if ((await sha256(join(extractedPayload, file))) !== expectedHashes.get(file)) fail(`JDBC payload checksum mismatch in ${zip}: ${file}`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`Verified complete jdbc/ payload in dbx-agents-offline-${platform}.zip`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`ERROR: ${error?.stack || error?.message || String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -628,7 +628,7 @@ async function importOfflineZip() {
|
||||
resetAgentInstallProgress();
|
||||
try {
|
||||
const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value);
|
||||
await refreshAgents();
|
||||
await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]);
|
||||
toast(t("driverStore.offlineImportSuccess", { count }));
|
||||
} catch (e: any) {
|
||||
toast(t("driverStore.offlineImportFailed", { error: backendError(e) }));
|
||||
@@ -660,7 +660,7 @@ async function importDriverFile(driver: AgentDriverInfo) {
|
||||
resetAgentInstallProgress();
|
||||
try {
|
||||
const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value);
|
||||
await refreshAgents();
|
||||
await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]);
|
||||
toast(t("driverStore.offlineImportSuccess", { count }));
|
||||
} finally {
|
||||
activeAgentOperationId.value = null;
|
||||
|
||||
@@ -24,6 +24,26 @@ describe("managedJdbcDrivers", () => {
|
||||
expect(managedJdbcDriverRows([], pluginStatus()).map(({ db_type }) => db_type)).toEqual(["prestosql", "phoenix"]);
|
||||
});
|
||||
|
||||
it("marks both managed drivers installed when the offline payload contains every bundle", () => {
|
||||
const coordinates = [PRESTOSQL_JDBC_DRIVER_COORDINATE, ...PHOENIX_MANAGED_MAVEN_COORDINATES];
|
||||
const bundles = coordinates.map(
|
||||
(coordinate): JdbcMavenBundleInfo => ({
|
||||
id: coordinate.replace(/[^A-Za-z0-9.-]/g, "_"),
|
||||
coordinate,
|
||||
scope: "runtime",
|
||||
repositories: ["https://repo.maven.apache.org/maven2/"],
|
||||
installed_at: "2026-08-14T00:00:00Z",
|
||||
path: `/plugins/jdbc/drivers/maven/${coordinate}`,
|
||||
artifacts: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(managedJdbcDriverRows(bundles, pluginStatus()).map(({ db_type, installed }) => ({ db_type, installed }))).toEqual([
|
||||
{ db_type: "prestosql", installed: true },
|
||||
{ db_type: "phoenix", installed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("installs PrestoSQL through the generic Maven resolver", async () => {
|
||||
const installed: JdbcMavenBundleInfo = {
|
||||
id: "io.prestosql_presto-jdbc_350",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"repository": "https://repo.maven.apache.org/maven2/",
|
||||
"drivers": {
|
||||
"prestosql": {
|
||||
"bundles": {
|
||||
"driver": {
|
||||
"id": "io.prestosql_presto-jdbc_350",
|
||||
"coordinate": "io.prestosql:presto-jdbc:350",
|
||||
"artifact": {
|
||||
"group_id": "io.prestosql",
|
||||
"artifact_id": "presto-jdbc",
|
||||
"version": "350",
|
||||
"classifier": "",
|
||||
"extension": "jar",
|
||||
"file_name": "presto-jdbc-350.jar",
|
||||
"size": 6656599,
|
||||
"sha256": "664a23e517c120c1d5a9bb88b215612b117415b9f1649bc22c48298eaf3f979b"
|
||||
},
|
||||
"redistribution": {
|
||||
"project_url": "https://prestosql.io/",
|
||||
"source_url": "https://github.com/prestosql/presto/tree/350",
|
||||
"root_license_expression": "Apache-2.0",
|
||||
"license_entries": ["META-INF/LICENSE", "META-INF/LICENSE.txt"],
|
||||
"notice_entries": ["META-INF/NOTICE", "META-INF/NOTICE.txt"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"phoenix": {
|
||||
"bundles": {
|
||||
"direct": {
|
||||
"id": "org.apache.phoenix_phoenix-client-embedded-hbase-2.5_5.2.1",
|
||||
"coordinate": "org.apache.phoenix:phoenix-client-embedded-hbase-2.5:5.2.1",
|
||||
"artifact": {
|
||||
"group_id": "org.apache.phoenix",
|
||||
"artifact_id": "phoenix-client-embedded-hbase-2.5",
|
||||
"version": "5.2.1",
|
||||
"classifier": "",
|
||||
"extension": "jar",
|
||||
"file_name": "phoenix-client-embedded-hbase-2.5-5.2.1.jar",
|
||||
"size": 158572775,
|
||||
"sha256": "c698a81f4250dbfe0af4b656b6de9c72bd3fee26e7945ccb893f741fbe05c4ca"
|
||||
},
|
||||
"redistribution": {
|
||||
"project_url": "https://phoenix.apache.org/",
|
||||
"source_url": "https://github.com/apache/phoenix/tree/5.2.1",
|
||||
"root_license_expression": "Apache-2.0",
|
||||
"license_entries": ["META-INF/LICENSE", "META-INF/LICENSE.txt", "LICENSE", "META-INF/licenses-binary/LICENSE.protobuf.txt"],
|
||||
"notice_entries": ["META-INF/DEPENDENCIES", "META-INF/NOTICE.txt", "META-INF/NOTICE-binary", "META-INF/NOTICE.md", "META-INF/NOTICE.markdown"]
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"id": "ch.qos.reload4j_reload4j_1.2.26",
|
||||
"coordinate": "ch.qos.reload4j:reload4j:1.2.26",
|
||||
"artifact": {
|
||||
"group_id": "ch.qos.reload4j",
|
||||
"artifact_id": "reload4j",
|
||||
"version": "1.2.26",
|
||||
"classifier": "",
|
||||
"extension": "jar",
|
||||
"file_name": "reload4j-1.2.26.jar",
|
||||
"size": 338688,
|
||||
"sha256": "a7e86ae144541d04d24d1b7123d7425415477d50f387bb868e5c955aeaedd96f"
|
||||
},
|
||||
"redistribution": {
|
||||
"project_url": "https://reload4j.qos.ch/",
|
||||
"source_url": "https://github.com/qos-ch/reload4j/tree/v_1.2.26",
|
||||
"root_license_expression": "Apache-2.0",
|
||||
"license_entries": ["META-INF/LICENSE"],
|
||||
"notice_entries": ["META-INF/NOTICE"]
|
||||
}
|
||||
},
|
||||
"query_server": {
|
||||
"id": "org.apache.phoenix_phoenix-queryserver-client_6.0.0",
|
||||
"coordinate": "org.apache.phoenix:phoenix-queryserver-client:6.0.0",
|
||||
"artifact": {
|
||||
"group_id": "org.apache.phoenix",
|
||||
"artifact_id": "phoenix-queryserver-client",
|
||||
"version": "6.0.0",
|
||||
"classifier": "",
|
||||
"extension": "jar",
|
||||
"file_name": "phoenix-queryserver-client-6.0.0.jar",
|
||||
"size": 6294972,
|
||||
"sha256": "94e634ded958525f1dee090999c77ab1d0e7e022ea2767b0c73416cc67c8ebcf"
|
||||
},
|
||||
"redistribution": {
|
||||
"project_url": "https://phoenix.apache.org/",
|
||||
"source_url": "https://github.com/apache/phoenix-queryserver/tree/6.0.0",
|
||||
"root_license_expression": "Apache-2.0",
|
||||
"license_entries": ["META-INF/LICENSE"],
|
||||
"notice_entries": ["META-INF/DEPENDENCIES", "META-INF/NOTICE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { JdbcProductProfileDefinition } from "@/lib/database/jdbcProductProfile";
|
||||
import managedJdbcAssets from "@/lib/database/managedJdbcAssets.json";
|
||||
|
||||
export const PHOENIX_DRIVER_PROFILE = "phoenix";
|
||||
export const PHOENIX_DIRECT_JDBC_URL = "jdbc:phoenix:localhost";
|
||||
export const PHOENIX_DIRECT_JDBC_DRIVER_CLASS = "org.apache.phoenix.jdbc.PhoenixDriver";
|
||||
export const PHOENIX_QUERY_SERVER_JDBC_URL = "jdbc:phoenix:thin:url=http://127.0.0.1:8765;serialization=PROTOBUF";
|
||||
export const PHOENIX_QUERY_SERVER_JDBC_DRIVER_CLASS = "org.apache.phoenix.queryserver.client.Driver";
|
||||
export const PHOENIX_MAVEN_REPOSITORY = "https://repo.maven.apache.org/maven2/";
|
||||
export const PHOENIX_DIRECT_MAVEN_COORDINATE = "org.apache.phoenix:phoenix-client-embedded-hbase-2.5:5.2.1";
|
||||
export const PHOENIX_QUERY_SERVER_MAVEN_COORDINATE = "org.apache.phoenix:phoenix-queryserver-client:6.0.0";
|
||||
export const PHOENIX_DIRECT_LOGGING_MAVEN_COORDINATE = "ch.qos.reload4j:reload4j:1.2.26";
|
||||
const PHOENIX_JDBC_ASSETS = managedJdbcAssets.drivers.phoenix.bundles;
|
||||
export const PHOENIX_MAVEN_REPOSITORY = managedJdbcAssets.repository;
|
||||
export const PHOENIX_DIRECT_MAVEN_COORDINATE = PHOENIX_JDBC_ASSETS.direct.coordinate;
|
||||
export const PHOENIX_QUERY_SERVER_MAVEN_COORDINATE = PHOENIX_JDBC_ASSETS.query_server.coordinate;
|
||||
export const PHOENIX_DIRECT_LOGGING_MAVEN_COORDINATE = PHOENIX_JDBC_ASSETS.logging.coordinate;
|
||||
export const PHOENIX_DRIVER_NOT_INSTALLED_ERROR = "Apache Phoenix JDBC driver is not installed. Install it from the Driver Manager, then retry.";
|
||||
export const PHOENIX_JDBC_PLUGIN_NOT_INSTALLED_ERROR = "DBX JDBC plugin is not installed. Install Apache Phoenix JDBC from the Driver Manager, then retry.";
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { JdbcMavenBundleInfo } from "@/types/database";
|
||||
import { managedJdbcDriverPaths, type ManagedJdbcDriverDefinition } from "@/lib/database/managedJdbcDriver";
|
||||
import managedJdbcAssets from "@/lib/database/managedJdbcAssets.json";
|
||||
|
||||
export const PRESTOSQL_DRIVER_DB_TYPE = "prestosql";
|
||||
export const PRESTOSQL_JDBC_DRIVER_VERSION = "350";
|
||||
export const PRESTOSQL_JDBC_DRIVER_COORDINATE = `io.prestosql:presto-jdbc:${PRESTOSQL_JDBC_DRIVER_VERSION}`;
|
||||
export const PRESTOSQL_JDBC_DRIVER_REPOSITORY = "https://repo.maven.apache.org/maven2/";
|
||||
const PRESTOSQL_JDBC_ASSET = managedJdbcAssets.drivers.prestosql.bundles.driver;
|
||||
export const PRESTOSQL_JDBC_DRIVER_VERSION = PRESTOSQL_JDBC_ASSET.artifact.version;
|
||||
export const PRESTOSQL_JDBC_DRIVER_COORDINATE = PRESTOSQL_JDBC_ASSET.coordinate;
|
||||
export const PRESTOSQL_JDBC_DRIVER_REPOSITORY = managedJdbcAssets.repository;
|
||||
export const PRESTOSQL_MANAGED_JDBC_DRIVER: ManagedJdbcDriverDefinition = {
|
||||
id: PRESTOSQL_DRIVER_DB_TYPE,
|
||||
label: "PrestoSQL",
|
||||
|
||||
+321
-2
@@ -4,11 +4,15 @@ use crate::plugins::{PluginManifest, SUPPORTED_PLUGIN_PROTOCOL_VERSION};
|
||||
use crate::update::{fetch_latest_release, is_newer_version, JdbcPluginLatest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const JDBC_PLUGIN_DOWNLOAD_URL: &str =
|
||||
"https://github.com/t8y2/dbx/releases/latest/download/dbx-jdbc-plugin-latest.zip";
|
||||
const JDBC_PLUGIN_R2_PATH: &str = "releases/latest/dbx-jdbc-plugin-latest.zip";
|
||||
const OFFLINE_JDBC_MANIFEST_ENTRY: &str = "jdbc/offline-manifest.json";
|
||||
const OFFLINE_JDBC_FORMAT_VERSION: u32 = 1;
|
||||
const OFFLINE_JDBC_PLUGIN_ENTRY: &str = "jdbc/plugin.zip";
|
||||
pub const PRESTOSQL_JDBC_DRIVER_VERSION: &str = "350";
|
||||
pub const PRESTOSQL_JDBC_DRIVER_COORDINATE: &str = "io.prestosql:presto-jdbc:350";
|
||||
pub const PRESTOSQL_JDBC_DRIVER_REPOSITORY: &str = "https://repo.maven.apache.org/maven2/";
|
||||
@@ -83,6 +87,25 @@ pub struct JdbcPluginStatus {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfflineJdbcImportResult {
|
||||
pub plugin_installed: bool,
|
||||
pub bundles_installed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OfflineJdbcManifest {
|
||||
format_version: u32,
|
||||
plugin_entry: String,
|
||||
bundles: Vec<OfflineJdbcBundle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OfflineJdbcBundle {
|
||||
id: String,
|
||||
coordinate: String,
|
||||
}
|
||||
|
||||
// ---- JDBC Drivers ----
|
||||
|
||||
pub fn list_jdbc_drivers(plugins_root: &Path) -> Result<Vec<JdbcDriverInfo>, String> {
|
||||
@@ -255,6 +278,66 @@ pub async fn install_prestosql_jdbc_driver(plugins_root: &Path) -> Result<Vec<Jd
|
||||
list_jdbc_drivers(plugins_root)
|
||||
}
|
||||
|
||||
/// Imports the optional JDBC payload embedded in a full offline Agent ZIP.
|
||||
/// Older Agent packages have no JDBC manifest and remain valid.
|
||||
pub fn import_offline_jdbc_payload(
|
||||
plugins_root: &Path,
|
||||
zip_path: &Path,
|
||||
) -> Result<Option<OfflineJdbcImportResult>, String> {
|
||||
if !zip_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.to_ascii_lowercase().ends_with(".zip"))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let file = std::fs::File::open(zip_path).map_err(|err| format!("Failed to open offline package: {err}"))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|err| format!("Invalid offline package ZIP: {err}"))?;
|
||||
let manifest_raw = match read_zip_entry(&mut archive, OFFLINE_JDBC_MANIFEST_ENTRY, 1024 * 1024) {
|
||||
Ok(raw) => raw,
|
||||
Err(error) if error == format!("Offline package entry not found: {OFFLINE_JDBC_MANIFEST_ENTRY}") => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let manifest: OfflineJdbcManifest =
|
||||
serde_json::from_slice(&manifest_raw).map_err(|err| format!("Failed to parse offline JDBC manifest: {err}"))?;
|
||||
validate_offline_jdbc_manifest(&manifest)?;
|
||||
|
||||
let plugin_bytes = read_zip_entry(&mut archive, &manifest.plugin_entry, 512 * 1024 * 1024)?;
|
||||
let maven_dir = jdbc_maven_drivers_dir(plugins_root);
|
||||
std::fs::create_dir_all(&maven_dir).map_err(|err| err.to_string())?;
|
||||
let staging_root = maven_dir.join(format!(".offline-import-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
let import_result = (|| {
|
||||
std::fs::create_dir_all(&staging_root).map_err(|err| err.to_string())?;
|
||||
let mut staged_bundles = Vec::with_capacity(manifest.bundles.len());
|
||||
for bundle in &manifest.bundles {
|
||||
let staged = stage_offline_jdbc_bundle(&mut archive, bundle, &staging_root, &maven_dir)?;
|
||||
staged_bundles.push((bundle.id.clone(), staged));
|
||||
}
|
||||
|
||||
install_jdbc_plugin_zip(&plugin_bytes, &plugins_root.join("jdbc"))?;
|
||||
|
||||
for (bundle_id, staged_dir) in &staged_bundles {
|
||||
let target = maven_dir.join(bundle_id);
|
||||
if target.exists() {
|
||||
std::fs::remove_dir_all(&target).map_err(|err| err.to_string())?;
|
||||
}
|
||||
std::fs::rename(staged_dir, &target)
|
||||
.map_err(|err| format!("Failed to install offline JDBC bundle {bundle_id}: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(Some(OfflineJdbcImportResult {
|
||||
plugin_installed: true,
|
||||
bundles_installed: manifest.bundles.iter().map(|bundle| bundle.coordinate.clone()).collect(),
|
||||
}))
|
||||
})();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&staging_root);
|
||||
import_result
|
||||
}
|
||||
|
||||
// ---- JDBC Plugin ----
|
||||
|
||||
pub async fn get_jdbc_plugin_status(plugins_root: &Path) -> Result<JdbcPluginStatus, String> {
|
||||
@@ -540,6 +623,123 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_zip_entry(
|
||||
archive: &mut zip::ZipArchive<std::fs::File>,
|
||||
entry_name: &str,
|
||||
max_size: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut entry = archive.by_name(entry_name).map_err(|err| match err {
|
||||
zip::result::ZipError::FileNotFound => format!("Offline package entry not found: {entry_name}"),
|
||||
_ => format!("Failed to read offline package entry {entry_name}: {err}"),
|
||||
})?;
|
||||
if entry.size() > max_size {
|
||||
return Err(format!("Offline package entry is too large: {entry_name}"));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(entry.size().try_into().unwrap_or(0));
|
||||
entry.read_to_end(&mut bytes).map_err(|err| format!("Failed to read offline package entry {entry_name}: {err}"))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn validate_offline_jdbc_manifest(manifest: &OfflineJdbcManifest) -> Result<(), String> {
|
||||
if manifest.format_version != OFFLINE_JDBC_FORMAT_VERSION {
|
||||
return Err(format!("Unsupported offline JDBC format version: {}", manifest.format_version));
|
||||
}
|
||||
if manifest.plugin_entry != OFFLINE_JDBC_PLUGIN_ENTRY {
|
||||
return Err(format!("Unexpected offline JDBC plugin entry: {}", manifest.plugin_entry));
|
||||
}
|
||||
if manifest.bundles.is_empty() {
|
||||
return Err("Offline JDBC payload contains no Maven bundles".to_string());
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut coordinates = std::collections::HashSet::new();
|
||||
for bundle in &manifest.bundles {
|
||||
if !is_safe_bundle_id(&bundle.id) {
|
||||
return Err(format!("Invalid offline JDBC bundle id: {}", bundle.id));
|
||||
}
|
||||
if bundle.id != maven_bundle_id(&bundle.coordinate) {
|
||||
return Err(format!("Offline JDBC bundle id does not match coordinate: {}", bundle.coordinate));
|
||||
}
|
||||
if !ids.insert(bundle.id.as_str()) {
|
||||
return Err(format!("Duplicate offline JDBC bundle id: {}", bundle.id));
|
||||
}
|
||||
if !coordinates.insert(bundle.coordinate.as_str()) {
|
||||
return Err(format!("Duplicate offline JDBC coordinate: {}", bundle.coordinate));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage_offline_jdbc_bundle(
|
||||
archive: &mut zip::ZipArchive<std::fs::File>,
|
||||
expected: &OfflineJdbcBundle,
|
||||
staging_root: &Path,
|
||||
final_maven_dir: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
let prefix = format!("jdbc/maven/{}/", expected.id);
|
||||
let manifest_entry = format!("{prefix}manifest.json");
|
||||
let manifest_raw = read_zip_entry(archive, &manifest_entry, 4 * 1024 * 1024)?;
|
||||
let portable: JdbcMavenBundleInfo = serde_json::from_slice(&manifest_raw)
|
||||
.map_err(|err| format!("Failed to parse offline JDBC bundle manifest {manifest_entry}: {err}"))?;
|
||||
if portable.id != expected.id || portable.coordinate != expected.coordinate {
|
||||
return Err(format!("Offline JDBC bundle manifest does not match {}", expected.coordinate));
|
||||
}
|
||||
if portable.artifacts.is_empty() {
|
||||
return Err(format!("Offline JDBC bundle contains no JAR artifacts: {}", expected.coordinate));
|
||||
}
|
||||
|
||||
let staged_dir = staging_root.join(&expected.id);
|
||||
let jars_dir = staged_dir.join("jars");
|
||||
std::fs::create_dir_all(&jars_dir).map_err(|err| err.to_string())?;
|
||||
let final_bundle_dir = final_maven_dir.join(&expected.id);
|
||||
let mut artifacts = Vec::with_capacity(portable.artifacts.len());
|
||||
let mut file_names = std::collections::HashSet::new();
|
||||
|
||||
for mut artifact in portable.artifacts {
|
||||
let file_name_path = Path::new(&artifact.file_name);
|
||||
if artifact.extension != "jar"
|
||||
|| file_name_path.file_name().and_then(|name| name.to_str()) != Some(artifact.file_name.as_str())
|
||||
|| !file_names.insert(artifact.file_name.clone())
|
||||
{
|
||||
return Err(format!("Invalid offline JDBC artifact name: {}", artifact.file_name));
|
||||
}
|
||||
let entry_name = format!("{prefix}jars/{}", artifact.file_name);
|
||||
let mut entry = archive.by_name(&entry_name).map_err(|err| match err {
|
||||
zip::result::ZipError::FileNotFound => format!("Offline package entry not found: {entry_name}"),
|
||||
_ => format!("Failed to read offline package entry {entry_name}: {err}"),
|
||||
})?;
|
||||
if entry.size() != artifact.size {
|
||||
return Err(format!("Offline JDBC artifact size mismatch: {entry_name}"));
|
||||
}
|
||||
let target = jars_dir.join(&artifact.file_name);
|
||||
let mut output = std::fs::File::create(&target).map_err(|err| err.to_string())?;
|
||||
std::io::copy(&mut entry, &mut output)
|
||||
.map_err(|err| format!("Failed to extract offline JDBC artifact {entry_name}: {err}"))?;
|
||||
drop(output);
|
||||
let sha256 = file_sha256(&target)?;
|
||||
if !sha256.eq_ignore_ascii_case(&artifact.sha256) {
|
||||
return Err(format!("Offline JDBC artifact checksum mismatch: {entry_name}"));
|
||||
}
|
||||
artifact.path = final_bundle_dir.join("jars").join(&artifact.file_name).to_string_lossy().to_string();
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
|
||||
let installed = JdbcMavenBundleInfo {
|
||||
id: portable.id,
|
||||
coordinate: portable.coordinate,
|
||||
scope: portable.scope,
|
||||
repositories: portable.repositories,
|
||||
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||
path: final_bundle_dir.to_string_lossy().to_string(),
|
||||
artifacts,
|
||||
};
|
||||
std::fs::write(
|
||||
staged_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&installed).map_err(|err| err.to_string())?,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(staged_dir)
|
||||
}
|
||||
|
||||
fn list_jdbc_drivers_from_dir(drivers_dir: &Path) -> Result<Vec<JdbcDriverInfo>, String> {
|
||||
let entries = match std::fs::read_dir(drivers_dir) {
|
||||
Ok(entries) => entries,
|
||||
@@ -858,8 +1058,17 @@ fn is_safe_bundle_id(bundle_id: &str) -> bool {
|
||||
}
|
||||
|
||||
fn file_sha256(path: &Path) -> Result<String, String> {
|
||||
let bytes = std::fs::read(path).map_err(|err| err.to_string())?;
|
||||
let hash = Sha256::digest(bytes);
|
||||
let mut file = std::fs::File::open(path).map_err(|err| err.to_string())?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer).map_err(|err| err.to_string())?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..read]);
|
||||
}
|
||||
let hash = hash.finalize();
|
||||
Ok(format!("{hash:x}"))
|
||||
}
|
||||
|
||||
@@ -911,6 +1120,71 @@ fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugins::PluginRuntimeEnv;
|
||||
use std::io::Write;
|
||||
|
||||
fn test_jdbc_plugin_zip() -> Vec<u8> {
|
||||
let cursor = std::io::Cursor::new(Vec::new());
|
||||
let mut zip = zip::ZipWriter::new(cursor);
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
zip.start_file("dbx-jdbc-plugin-test/manifest.json", options).unwrap();
|
||||
zip.write_all(
|
||||
br#"{
|
||||
"id": "jdbc",
|
||||
"name": "DBX JDBC Plugin",
|
||||
"version": "0.1.30",
|
||||
"protocol_version": 1,
|
||||
"executable": "bin/dbx-jdbc-plugin",
|
||||
"drivers": [{"id":"jdbc","label":"JDBC","kind":"external","database_type":"jdbc"}]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
zip.start_file("dbx-jdbc-plugin-test/bin/dbx-jdbc-plugin", options).unwrap();
|
||||
zip.write_all(b"#!/bin/sh\n").unwrap();
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn write_offline_jdbc_package(path: &Path, artifact_sha256: &str) {
|
||||
let coordinate = "com.example:demo-driver:1.0.0";
|
||||
let bundle_id = maven_bundle_id(coordinate);
|
||||
let artifact = b"offline-driver";
|
||||
let bundle = JdbcMavenBundleInfo {
|
||||
id: bundle_id.clone(),
|
||||
coordinate: coordinate.to_string(),
|
||||
scope: "runtime".to_string(),
|
||||
repositories: vec!["https://repo.maven.apache.org/maven2/".to_string()],
|
||||
installed_at: String::new(),
|
||||
path: String::new(),
|
||||
artifacts: vec![JdbcMavenArtifactInfo {
|
||||
group_id: "com.example".to_string(),
|
||||
artifact_id: "demo-driver".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
classifier: String::new(),
|
||||
extension: "jar".to_string(),
|
||||
file_name: "demo-driver-1.0.0.jar".to_string(),
|
||||
path: String::new(),
|
||||
size: artifact.len() as u64,
|
||||
sha256: artifact_sha256.to_string(),
|
||||
}],
|
||||
};
|
||||
let manifest = serde_json::json!({
|
||||
"format_version": 1,
|
||||
"plugin_entry": "jdbc/plugin.zip",
|
||||
"bundles": [{"id": bundle_id, "coordinate": coordinate}],
|
||||
});
|
||||
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
zip.start_file(OFFLINE_JDBC_MANIFEST_ENTRY, options).unwrap();
|
||||
zip.write_all(serde_json::to_string(&manifest).unwrap().as_bytes()).unwrap();
|
||||
zip.start_file(OFFLINE_JDBC_PLUGIN_ENTRY, options).unwrap();
|
||||
zip.write_all(&test_jdbc_plugin_zip()).unwrap();
|
||||
zip.start_file(format!("jdbc/maven/{}/manifest.json", bundle.id), options).unwrap();
|
||||
zip.write_all(serde_json::to_string(&bundle).unwrap().as_bytes()).unwrap();
|
||||
zip.start_file(format!("jdbc/maven/{}/jars/demo-driver-1.0.0.jar", bundle.id), options).unwrap();
|
||||
zip.write_all(artifact).unwrap();
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_bundle_install_lists_nested_jars() {
|
||||
@@ -957,6 +1231,51 @@ mod tests {
|
||||
assert!(!is_safe_bundle_id("../hive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_offline_jdbc_plugin_and_rewrites_bundle_paths() {
|
||||
let root = std::env::temp_dir().join(format!("dbx-offline-jdbc-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let package = root.join("offline.zip");
|
||||
let artifact_sha256 = format!("{:x}", Sha256::digest(b"offline-driver"));
|
||||
write_offline_jdbc_package(&package, &artifact_sha256);
|
||||
|
||||
let plugins_root = root.join("plugins");
|
||||
let result = import_offline_jdbc_payload(&plugins_root, &package).unwrap().unwrap();
|
||||
|
||||
assert!(result.plugin_installed);
|
||||
assert_eq!(result.bundles_installed, vec!["com.example:demo-driver:1.0.0"]);
|
||||
assert!(plugins_root.join("jdbc/manifest.json").exists());
|
||||
let bundles = list_jdbc_maven_bundles(&plugins_root).unwrap();
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(
|
||||
bundles[0].path,
|
||||
plugins_root.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0").to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
bundles[0].artifacts[0].path,
|
||||
plugins_root
|
||||
.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0/jars/demo-driver-1.0.0.jar")
|
||||
.to_string_lossy()
|
||||
);
|
||||
assert_eq!(std::fs::read(&bundles[0].artifacts[0].path).unwrap(), b"offline-driver");
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupt_offline_jdbc_artifact_before_installing_plugin() {
|
||||
let root = std::env::temp_dir().join(format!("dbx-offline-jdbc-corrupt-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let package = root.join("offline.zip");
|
||||
write_offline_jdbc_package(&package, &"0".repeat(64));
|
||||
|
||||
let plugins_root = root.join("plugins");
|
||||
let error = import_offline_jdbc_payload(&plugins_root, &package).unwrap_err();
|
||||
|
||||
assert!(error.contains("checksum mismatch"));
|
||||
assert!(!plugins_root.join("jdbc/manifest.json").exists());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_local_jars_are_imported_as_one_bundle() {
|
||||
let root = std::env::temp_dir().join(format!("dbx-jdbc-local-bundle-test-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
@@ -236,11 +236,14 @@ pub async fn import_agents_from_zip(
|
||||
|
||||
let plan = inspect_offline_package(&package_path).map_err(AppError::from)?;
|
||||
ensure_no_offline_import_blockers(&state.app, &plan).await.map_err(AppError::from)?;
|
||||
import_agents_from_package_core(&state.app.agent_manager, &package_path, |event| {
|
||||
let import_result = import_agents_from_package_core(&state.app.agent_manager, &package_path, |event| {
|
||||
send_progress_event(&tx, event.with_operation_id(&operation_id))
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
.map_err(AppError::from)?;
|
||||
dbx_core::jdbc::import_offline_jdbc_payload(state.app.plugins.root_dir(), &package_path)
|
||||
.map_err(AppError::from)?;
|
||||
Ok::<_, AppError>(import_result)
|
||||
}
|
||||
.await;
|
||||
let _ = std::fs::remove_file(&package_path);
|
||||
|
||||
@@ -176,6 +176,7 @@ pub async fn import_agents_from_zip(
|
||||
emit_agent_progress(&app_handle, &operation_id, event)
|
||||
})
|
||||
.await?;
|
||||
dbx_core::jdbc::import_offline_jdbc_payload(state.plugins.root_dir(), &package_path)?;
|
||||
let count = result.drivers_installed.len() as u32;
|
||||
emit_agent_progress(&app, &operation_id, AgentProgressEvent::step("done"));
|
||||
Ok(count)
|
||||
|
||||
Reference in New Issue
Block a user