|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * End-to-end smoke: dbt-on-Pyodide + s3:// + env_var('BUCKET') + SigV4 |
| 4 | + * against a real MinIO at http://localhost:9000. |
| 5 | + * |
| 6 | + * This is the closest-to-production smoke we have. It exercises the |
| 7 | + * exact path the playground takes: |
| 8 | + * |
| 9 | + * 1. Upload a tiny CSV to MinIO at data-sources/<sourceId>/<name>.csv |
| 10 | + * (the canonical key shape finalize-upload writes to). |
| 11 | + * 2. Spawn a Pyodide worker w/ SAB-fetch bridge + pyodide-httpfs + |
| 12 | + * dbt closure. Pass BUCKET / BUCKET_REGION / BUCKET_ACCESS_KEY_ID / |
| 13 | + * BUCKET_SECRET_ACCESS_KEY through to os.environ — same shape |
| 14 | + * workspace.runDbt's env injection produces. |
| 15 | + * 3. Build a tiny dbt project whose sources.yml uses |
| 16 | + * `external_location: "s3://{{ env_var('BUCKET') }}/data-sources/ |
| 17 | + * <sourceId>/<name>.csv"` — exactly what the playground AGENTS.md |
| 18 | + * tells Pi to write. |
| 19 | + * 4. Run dbt build. The flow: |
| 20 | + * - dbt parse resolves env_var('BUCKET') to "layerr-dev" via os.environ |
| 21 | + * - dbt-duckdb's pyodide_httpfs plugin loads and registers fsspec |
| 22 | + * - read_csv_auto('s3://layerr-dev/...') walks the fsspec FS |
| 23 | + * - Each chunk fetch fires the SAB+side-worker primitive |
| 24 | + * - Side worker reads BUCKET_ACCESS_KEY_ID + BUCKET_SECRET_ACCESS_KEY |
| 25 | + * from process.env (Node-level), SigV4-signs, GETs MinIO |
| 26 | + * - DuckDB ingests bytes, materializes the staging model |
| 27 | + * |
| 28 | + * Pass: stdout contains DBT_S3_BUILD_OK with row_count > 0. |
| 29 | + * |
| 30 | + * Requires: |
| 31 | + * - MinIO running at http://localhost:9000 with creds layerr/localdev123 |
| 32 | + * - Bucket "layerr-dev" pre-created |
| 33 | + * |
| 34 | + * Setup is bun-driven so we can use Bun.S3Client for the upload step. |
| 35 | + */ |
| 36 | +import { Worker } from "node:worker_threads"; |
| 37 | +import { dirname } from "node:path"; |
| 38 | +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; |
| 39 | +import { S3Client } from "bun"; |
| 40 | + |
| 41 | +// ─────────────────────────────────────────────────────────────────── |
| 42 | +// MinIO env — must match Layerr playground .env defaults so the |
| 43 | +// SAB side worker reads the same creds. |
| 44 | +// ─────────────────────────────────────────────────────────────────── |
| 45 | +const MINIO_BUCKET = "layerr-dev"; |
| 46 | +const MINIO_ACCESS = "layerr"; |
| 47 | +const MINIO_SECRET = "localdev123"; |
| 48 | +const MINIO_ENDPOINT = "http://localhost:9000"; |
| 49 | +const MINIO_REGION = "us-east-1"; |
| 50 | + |
| 51 | +process.env.BUCKET = MINIO_BUCKET; |
| 52 | +process.env.BUCKET_ACCESS_KEY_ID = MINIO_ACCESS; |
| 53 | +process.env.BUCKET_SECRET_ACCESS_KEY = MINIO_SECRET; |
| 54 | +process.env.BUCKET_ENDPOINT = MINIO_ENDPOINT; |
| 55 | +process.env.BUCKET_REGION = MINIO_REGION; |
| 56 | + |
| 57 | +// ─────────────────────────────────────────────────────────────────── |
| 58 | +// Step 1: upload a 3-row CSV to MinIO. |
| 59 | +// ─────────────────────────────────────────────────────────────────── |
| 60 | +const SOURCE_ID = "test-dbt-s3-" + Date.now(); |
| 61 | +const FILE_NAME = "sample.csv"; |
| 62 | +const KEY = `data-sources/${SOURCE_ID}/${FILE_NAME}`; |
| 63 | +const CSV_BODY = "id,name,country\n1,alice,US\n2,bob,UK\n3,charlie,US\n"; |
| 64 | + |
| 65 | +const s3 = new S3Client({ |
| 66 | + accessKeyId: MINIO_ACCESS, |
| 67 | + secretAccessKey: MINIO_SECRET, |
| 68 | + bucket: MINIO_BUCKET, |
| 69 | + endpoint: MINIO_ENDPOINT, |
| 70 | + region: MINIO_REGION, |
| 71 | +}); |
| 72 | +await s3.write(KEY, CSV_BODY, { type: "text/csv" }); |
| 73 | +console.log(`uploaded s3://${MINIO_BUCKET}/${KEY} (${CSV_BODY.length} bytes)`); |
| 74 | + |
| 75 | +// ─────────────────────────────────────────────────────────────────── |
| 76 | +// Step 2: write the dbt project that references the s3:// URL via |
| 77 | +// env_var('BUCKET'). This is byte-for-byte the shape the playground |
| 78 | +// tells Pi to author. |
| 79 | +// ─────────────────────────────────────────────────────────────────── |
| 80 | +const PROJECT_DIR = "/tmp/pyodide-dbt-s3-envvar"; |
| 81 | +rmSync(PROJECT_DIR, { recursive: true, force: true }); |
| 82 | +mkdirSync(`${PROJECT_DIR}/models`, { recursive: true }); |
| 83 | +writeFileSync( |
| 84 | + `${PROJECT_DIR}/dbt_project.yml`, |
| 85 | + `name: smoke |
| 86 | +version: '1.0.0' |
| 87 | +config-version: 2 |
| 88 | +profile: smoke |
| 89 | +model-paths: ["models"] |
| 90 | +target-path: target |
| 91 | +clean-targets: [target] |
| 92 | +`, |
| 93 | +); |
| 94 | +writeFileSync( |
| 95 | + `${PROJECT_DIR}/profiles.yml`, |
| 96 | + `smoke: |
| 97 | + target: dev |
| 98 | + outputs: |
| 99 | + dev: |
| 100 | + type: duckdb |
| 101 | + path: /dbt-project/warehouse.duckdb |
| 102 | + threads: 1 |
| 103 | + plugins: |
| 104 | + - module: pyodide_httpfs.dbt_plugin |
| 105 | +`, |
| 106 | +); |
| 107 | +writeFileSync( |
| 108 | + `${PROJECT_DIR}/models/sources.yml`, |
| 109 | + `version: 2 |
| 110 | +sources: |
| 111 | + - name: raw |
| 112 | + tables: |
| 113 | + - name: sample |
| 114 | + meta: |
| 115 | + external_location: "s3://{{ env_var('BUCKET') }}/data-sources/${SOURCE_ID}/${FILE_NAME}" |
| 116 | +`, |
| 117 | +); |
| 118 | +writeFileSync( |
| 119 | + `${PROJECT_DIR}/models/stg_sample.sql`, |
| 120 | + `{{ config(materialized='table') }} |
| 121 | +SELECT id, name, country FROM {{ source('raw', 'sample') }} |
| 122 | +`, |
| 123 | +); |
| 124 | + |
| 125 | +// ─────────────────────────────────────────────────────────────────── |
| 126 | +// Step 3+4: spawn the Pyodide worker, install the dbt closure, run. |
| 127 | +// ─────────────────────────────────────────────────────────────────── |
| 128 | +const PYODIDE_INDEX = |
| 129 | + "/Users/brittianwarner/goods/agent-os/node_modules/.pnpm/pyodide@0.29.3/node_modules/pyodide/pyodide.mjs"; |
| 130 | +const WHEELS_DIR = |
| 131 | + "/Users/brittianwarner/goods/agent-os/registry/software/python-wheels/wheels"; |
| 132 | + |
| 133 | +const { WORKER_SAB_FETCH_JS } = await import( |
| 134 | + "/Users/brittianwarner/goods/agent-os/packages/python/dist/sab-fetch-bootstrap.js" |
| 135 | +); |
| 136 | +const { DBT_BOOTSTRAP_SCRIPT } = await import( |
| 137 | + "/Users/brittianwarner/goods/agent-os/packages/python/dist/dbt-bootstrap.js" |
| 138 | +); |
| 139 | + |
| 140 | +const WORKER_SRC = ` |
| 141 | +const { parentPort, workerData, Worker } = require("node:worker_threads"); |
| 142 | +
|
| 143 | +(async () => { |
| 144 | + try { |
| 145 | + ${WORKER_SAB_FETCH_JS} |
| 146 | + const sabFetch = startSabFetch(); |
| 147 | +
|
| 148 | + const { loadPyodide } = await import(workerData.pyodideMjsUrl); |
| 149 | + const py = await loadPyodide({ |
| 150 | + indexURL: workerData.indexPath, |
| 151 | + env: workerData.pythonEnv, |
| 152 | + stdout: (m) => parentPort.postMessage({ type: "stdout", msg: m }), |
| 153 | + stderr: (m) => parentPort.postMessage({ type: "stderr", msg: m }), |
| 154 | + }); |
| 155 | +
|
| 156 | + registerSabFetchModule(py, sabFetch); |
| 157 | + py.FS.mkdirTree("/wheels"); |
| 158 | + py.FS.mount(py.FS.filesystems.NODEFS, { root: workerData.wheelsDir }, "/wheels"); |
| 159 | + py.FS.mkdirTree("/dbt-project"); |
| 160 | + py.FS.mount(py.FS.filesystems.NODEFS, { root: workerData.projectDir }, "/dbt-project"); |
| 161 | +
|
| 162 | + await py.runPythonAsync(workerData.code.replace("__bootstrap_script__", workerData.dbtBootstrap)); |
| 163 | + parentPort.postMessage({ type: "done", ok: true }); |
| 164 | + } catch (err) { |
| 165 | + parentPort.postMessage({ type: "done", ok: false, error: err && err.message ? err.message : String(err), stack: err && err.stack }); |
| 166 | + } |
| 167 | +})(); |
| 168 | +`; |
| 169 | + |
| 170 | +const code = ` |
| 171 | +import pyodide_js |
| 172 | +await pyodide_js.loadPackage("micropip") |
| 173 | +import micropip |
| 174 | +
|
| 175 | +# Pyodide-bundled deps (matches DBT_PYODIDE_BUNDLED_DEPS in agent-os). |
| 176 | +# fsspec is required by pyodide_httpfs. |
| 177 | +await pyodide_js.loadPackage([ |
| 178 | + "jinja2", "markupsafe", "click", "jsonschema", "jsonschema-specifications", |
| 179 | + "msgpack", "networkx", "packaging", "protobuf", "pydantic", "pydantic-core", |
| 180 | + "pyyaml", "python-dateutil", "pytz", "referencing", "requests", "rpds-py", |
| 181 | + "more-itertools", "typing-extensions", "urllib3", "charset-normalizer", |
| 182 | + "certifi", "idna", "six", "attrs", "annotated-types", |
| 183 | + "fsspec", |
| 184 | +]) |
| 185 | +
|
| 186 | +import os, glob |
| 187 | +wheels = sorted(glob.glob("/wheels/*.whl")) |
| 188 | +urls = [f"emfs:/wheels/{os.path.basename(w)}" for w in wheels] |
| 189 | +print(f"installing {len(urls)} wheels") |
| 190 | +await micropip.install(urls, deps=False) |
| 191 | +
|
| 192 | +__bootstrap_script__ |
| 193 | +print("dbt bootstrap shim applied") |
| 194 | +
|
| 195 | +# Verify the BUCKET env var is visible to Python — this is what dbt's |
| 196 | +# env_var('BUCKET') template reads via os.environ['BUCKET']. |
| 197 | +bucket = os.environ.get("BUCKET") |
| 198 | +print(f"os.environ['BUCKET'] = {bucket!r}") |
| 199 | +if bucket != "${MINIO_BUCKET}": |
| 200 | + raise RuntimeError( |
| 201 | + f"env injection failed: expected BUCKET='${MINIO_BUCKET}', got {bucket!r}" |
| 202 | + ) |
| 203 | +
|
| 204 | +import pyodide_httpfs |
| 205 | +print(f"pyodide_httpfs OK: {pyodide_httpfs.__file__}") |
| 206 | +
|
| 207 | +os.environ["DBT_PROFILES_DIR"] = "/dbt-project" |
| 208 | +
|
| 209 | +from dbt.cli.main import dbtRunner |
| 210 | +runner = dbtRunner() |
| 211 | +
|
| 212 | +print("\\n--- dbt parse (should resolve env_var('BUCKET')) ---") |
| 213 | +res = runner.invoke(["parse", "--project-dir", "/dbt-project", "--profiles-dir", "/dbt-project"]) |
| 214 | +if not res.success: |
| 215 | + print(f" parse FAILED: {res.exception}") |
| 216 | + raise SystemExit(1) |
| 217 | +print(" parse OK") |
| 218 | +
|
| 219 | +print("\\n--- dbt build ---") |
| 220 | +res = runner.invoke(["build", "--project-dir", "/dbt-project", "--profiles-dir", "/dbt-project"]) |
| 221 | +if not res.success: |
| 222 | + print(f" build FAILED: {res.exception}") |
| 223 | + raise SystemExit(1) |
| 224 | +print(" build OK") |
| 225 | +
|
| 226 | +# Materialization sanity-check via dbt show — uses dbt's already-active |
| 227 | +# warehouse connection, no parallel-handle conflict. |
| 228 | +print("\\n--- verify stg_sample row count ---") |
| 229 | +res = runner.invoke([ |
| 230 | + "show", "--inline", "SELECT COUNT(*) AS n FROM {{ ref('stg_sample') }}", |
| 231 | + "--project-dir", "/dbt-project", "--profiles-dir", "/dbt-project", |
| 232 | +]) |
| 233 | +if not res.success: |
| 234 | + print(f" show FAILED: {res.exception}") |
| 235 | + raise SystemExit(1) |
| 236 | +
|
| 237 | +warehouse_size = os.path.getsize("/dbt-project/warehouse.duckdb") |
| 238 | +print(f" warehouse.duckdb size: {warehouse_size} bytes") |
| 239 | +
|
| 240 | +if warehouse_size > 0: |
| 241 | + print("\\nDBT_S3_BUILD_OK") |
| 242 | +else: |
| 243 | + print("\\nDBT_S3_BUILD_EMPTY") |
| 244 | + raise SystemExit(1) |
| 245 | +`; |
| 246 | + |
| 247 | +// Pass the BUCKET env into Pyodide's os.environ via the loadPyodide |
| 248 | +// `env:` option — same channel workspace.runDbt's env injection |
| 249 | +// reaches Python through. |
| 250 | +const pythonEnv = { |
| 251 | + HOME: "/home/user", |
| 252 | + BUCKET: MINIO_BUCKET, |
| 253 | + BUCKET_ACCESS_KEY_ID: MINIO_ACCESS, |
| 254 | + BUCKET_SECRET_ACCESS_KEY: MINIO_SECRET, |
| 255 | + BUCKET_ENDPOINT: MINIO_ENDPOINT, |
| 256 | + BUCKET_REGION: MINIO_REGION, |
| 257 | +}; |
| 258 | + |
| 259 | +const indexPath = `${dirname(PYODIDE_INDEX)}/`; |
| 260 | +const w = new Worker(WORKER_SRC, { |
| 261 | + eval: true, |
| 262 | + workerData: { |
| 263 | + indexPath, |
| 264 | + pyodideMjsUrl: `file://${PYODIDE_INDEX}`, |
| 265 | + wheelsDir: WHEELS_DIR, |
| 266 | + projectDir: PROJECT_DIR, |
| 267 | + code, |
| 268 | + dbtBootstrap: DBT_BOOTSTRAP_SCRIPT, |
| 269 | + pythonEnv, |
| 270 | + }, |
| 271 | +}); |
| 272 | + |
| 273 | +let sawOk = false; |
| 274 | +w.on("message", (m) => { |
| 275 | + if (m.type === "stdout") { |
| 276 | + process.stdout.write(`[py] ${m.msg}\n`); |
| 277 | + if (m.msg.includes("DBT_S3_BUILD_OK")) sawOk = true; |
| 278 | + } else if (m.type === "stderr") { |
| 279 | + process.stderr.write(`[py:err] ${m.msg}\n`); |
| 280 | + } else if (m.type === "done") { |
| 281 | + void w.terminate(); |
| 282 | + if (!m.ok) { |
| 283 | + console.error("worker FAIL:", m.error); |
| 284 | + console.error(m.stack); |
| 285 | + process.exit(1); |
| 286 | + } |
| 287 | + if (!sawOk) { |
| 288 | + console.error("expected DBT_S3_BUILD_OK marker; not seen"); |
| 289 | + process.exit(1); |
| 290 | + } |
| 291 | + console.log("\nverify_pyodide_dbt_s3_envvar: PASS"); |
| 292 | + process.exit(0); |
| 293 | + } |
| 294 | +}); |
| 295 | +w.on("error", (e) => { |
| 296 | + console.error(e); |
| 297 | + process.exit(1); |
| 298 | +}); |
0 commit comments