Skip to content

Commit 2609dc1

Browse files
brittianwarnerclaude
andcommitted
test(python-wheels): full dbt + s3:// + env_var('BUCKET') + SigV4 smoke
Closes the verification gap between the kernel-runtime SAB smoke (verify_kernel_runtime_s3.mjs — proves SAB+SigV4+MinIO via raw DuckDB) and the dbt-on-Pyodide smoke (verify_pyodide_httpfs_dbt.mjs — uses HTTPS GitHub raw URLs, no SigV4, no env_var resolution). This new smoke is the closest-to-production scenario: 1. Bun.S3Client uploads a 3-row CSV to MinIO at the canonical `data-sources/<sourceId>/<name>` shape finalize-upload uses. 2. Pyodide worker boots with BUCKET / BUCKET_REGION / BUCKET_ACCESS_KEY_ID / BUCKET_SECRET_ACCESS_KEY in loadPyodide({ env }) — same channel kernel-runtime's applyExecOverrides reaches Python os.environ through. 3. dbt project's sources.yml uses `external_location: s3://{{ env_var('BUCKET') }}/...` verbatim (matches what the playground AGENTS.md tells Pi). 4. dbt build → parse resolves env_var → pyodide_httpfs plugin loads → fsspec FS handles s3:// → SAB side worker SigV4-signs → MinIO 200s the bytes → DuckDB materializes the staging model. Verified locally: os.environ['BUCKET'] = 'layerr-dev' parse OK build OK — 1 of 1 OK created sql table model main.stg_sample Done. PASS=1 WARN=0 ERROR=0 | n | | 3 | warehouse.duckdb size: 12288 bytes DBT_S3_BUILD_OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bec8b10 commit 2609dc1

1 file changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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

Comments
 (0)