diff --git a/docker-setup/Dockerfile b/docker-setup/Dockerfile index 295570c74..bea861d36 100644 --- a/docker-setup/Dockerfile +++ b/docker-setup/Dockerfile @@ -1,4 +1,7 @@ -FROM codercom/code-server:latest +# Pin the code-server (browser IDE) version with --build-arg CS_TAG=. +# Default "latest" preserves existing behaviour for deploy.sh / vsix-smoke.sh. +ARG CS_TAG=latest +FROM codercom/code-server:${CS_TAG} USER root @@ -21,6 +24,25 @@ RUN pip3 install --break-system-packages dbt-duckdb USER coder +# Give the non-root `coder` user a writable npm global prefix. Without this, +# `npm install -g` writes to the root-owned /usr prefix and fails with EACCES — +# which is exactly what the extension's in-IDE "Install Altimate Code" step +# (npm install -g altimate-code) hit. Setting NPM_CONFIG_PREFIX (an env var npm +# honors) routes global installs to a coder-owned dir and puts its bin on PATH. +ENV NPM_CONFIG_PREFIX=/home/coder/.npm-global +ENV PATH=/home/coder/.npm-global/bin:$PATH +# ENV PATH covers the code-server process + extension host (non-login). The +# integrated terminal runs a login shell that re-sources /etc/profile and resets +# PATH, so also persist the prefix in the user's shell profiles — otherwise +# `altimate-code` is on PATH for the extension but not when typed in the terminal. +RUN mkdir -p /home/coder/.npm-global/bin \ + && printf '\nexport PATH=/home/coder/.npm-global/bin:$PATH\n' \ + | tee -a /home/coder/.bashrc /home/coder/.profile >/dev/null + +# Pre-install the Altimate Code CLI so the web IDE is ready out of the box (the +# "Install Altimate Code" panel's "Check Again" then passes without manual steps). +RUN npm install -g altimate-code + # Create altimate directory RUN mkdir -p ~/.altimate diff --git a/docker-setup/README.md b/docker-setup/README.md index f4dcfce96..e84ea557d 100644 --- a/docker-setup/README.md +++ b/docker-setup/README.md @@ -10,6 +10,31 @@ npm run docker:deploy This builds the extension, starts the container, and enters watch mode. Open http://localhost:3001/?folder=/home/coder/project in your browser. +## Reproduce a pinned IDE + extension + dbt-core version + +`docker-setup/launch-pinned.sh` brings up code-server (VS Code in the browser) in a +throwaway container with an **exact** dbt Power User version, dbt-core version, and +(optionally) code-server version — useful for reproducing a customer's environment +without installing anything on your machine. + +```bash +# extension 0.61.5 + dbt-core 1.10.18 on http://localhost:3001 +bash docker-setup/launch-pinned.sh --extension 0.61.5 --dbt 1.10.18 + +# pin the IDE version too, and use a different port +bash docker-setup/launch-pinned.sh --extension 0.61.5 --dbt 1.10.18 --code-server 4.99.1 --port 3002 +``` + +Every run is a **clean install**: it removes any previous `dbt-pu-demo` container, +builds a fresh one, installs the published extension version from OpenVSX, pins +dbt-core, and copies the sample dbt projects in (fixing ownership + the +`require-dbt-version` guard so they build under the pinned dbt). You only need +**Docker + this repo cloned** — the IDE, extension, dbt, and the jaffle-shop project +all live in the container. It prints a ready-to-open URL; stop with `docker rm -f dbt-pu-demo`. + +To pin a **native** (non-Docker) VS Code build instead, use +`node test-matrix/version-install.mjs --vscode 1.117.0 --extension 0.61.5 --dbt 1.10.18`. + ## How It Works The extension source is **volume-mounted** into the container (read-only), so you don't need to rebuild a VSIX or the Docker image for every change: diff --git a/docker-setup/launch-pinned.sh b/docker-setup/launch-pinned.sh new file mode 100755 index 000000000..2755314d7 --- /dev/null +++ b/docker-setup/launch-pinned.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# launch-pinned.sh — ONE command to bring up dbt Power User in code-server +# (VS Code in the browser, running in Docker) with a SPECIFIC code-server/IDE +# version, a SPECIFIC dbt Power User extension version, and a SPECIFIC dbt-core +# version. Copies the sample dbt project in and leaves the container running. +# +# Nothing is installed on your host — everything lives in a throwaway container. +# +# Usage: +# bash docker-setup/launch-pinned.sh --extension 0.61.5 --dbt 1.10.18 +# bash docker-setup/launch-pinned.sh --extension 0.61.5 --dbt 1.10.18 --code-server 4.99.1 --port 3001 +# +# Options: +# --extension dbt Power User version (marketplace/OpenVSX). Default: latest. +# --dbt dbt-core version to pin in the container. Optional. +# --code-server code-server (browser IDE) version. Default: latest. +# --port Host port. Default: 3001. +# --name Container name. Default: dbt-pu-demo. +set -euo pipefail + +EXT_ID="innoverio.vscode-dbt-power-user" +EXT_VERSION="latest" +DBT_VERSION="" +CS_TAG="latest" +PORT="${PORT:-3001}" +NAME="dbt-pu-demo" + +while [ $# -gt 0 ]; do + case "$1" in + --extension) EXT_VERSION="$2"; shift 2;; + --dbt) DBT_VERSION="$2"; shift 2;; + --code-server|--ide) CS_TAG="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + --name) NAME="$2"; shift 2;; + -h|--help) sed -n '2,20p' "$0"; exit 0;; + *) echo "unknown arg: $1" >&2; exit 2;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +FIXTURES_DIR="$REPO_ROOT/test-fixtures" +# Open the self-contained jaffle-shop project by default — it `dbt build`s cleanly +# on a pinned dbt-core (seeds + SQL only). dbt-core-sample is also copied in but +# needs an external MySQL source, so it can't fully build offline. +PROJECT_DIR="/home/coder/jaffle-shop-duckdb" + +green() { printf '\033[0;32m%s\033[0m\n' "$*"; } +say() { printf '\033[0;36m==> %s\033[0m\n' "$*"; } +die() { printf '\033[0;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +command -v docker >/dev/null || die "docker not found" +docker info >/dev/null 2>&1 || die "docker daemon not running" +[ -d "$FIXTURES_DIR/jaffle-shop-duckdb" ] || die "fixture not found: $FIXTURES_DIR/jaffle-shop-duckdb" + +# code-server installs from OpenVSX, which treats a bare extension id as "any +# version is fine". Resolve "latest" to a concrete semver so the pin is exact. +if [ "$EXT_VERSION" = "latest" ]; then + EXT_VERSION="$(curl -sf "https://open-vsx.org/api/innoverio/vscode-dbt-power-user/latest" \ + | python3 -c 'import json,sys;print(json.load(sys.stdin)["version"])')" \ + || die "could not resolve latest extension version from OpenVSX" + say "resolved latest extension -> $EXT_VERSION" +fi + +IMAGE="dbt-pu-pinned:${CS_TAG}" +say "Building code-server image (code-server=${CS_TAG}; layers cached after first build)" +docker build -t "$IMAGE" --build-arg "CS_TAG=${CS_TAG}" \ + -f "$SCRIPT_DIR/Dockerfile" "$SCRIPT_DIR" >/tmp/launch-pinned-build.log 2>&1 \ + || { tail -30 /tmp/launch-pinned-build.log; die "docker build"; } +green " image $IMAGE built" + +docker rm -f "$NAME" >/dev/null 2>&1 || true +say "Starting code-server on port ${PORT}" +docker run -d --rm --name "$NAME" -p "${PORT}:3001" -e PORT=3001 "$IMAGE" >/dev/null + +say "Waiting for code-server health" +for i in $(seq 1 45); do + curl -sf "http://localhost:${PORT}/healthz" >/dev/null 2>&1 && break + [ "$i" -eq 45 ] && { docker logs "$NAME" 2>&1 | tail -30; die "code-server did not respond on /healthz within 90s"; } + sleep 2 +done +green " code-server healthy" + +say "Installing dbt Power User ${EXT_VERSION}" +docker exec "$NAME" code-server --install-extension "${EXT_ID}@${EXT_VERSION}" >/tmp/launch-pinned-ext.log 2>&1 \ + || { cat /tmp/launch-pinned-ext.log; die "extension install exited non-zero"; } +docker exec "$NAME" code-server --list-extensions --show-versions \ + | grep -Fq "${EXT_ID}@${EXT_VERSION}" \ + || { docker exec "$NAME" code-server --list-extensions --show-versions; die "${EXT_ID}@${EXT_VERSION} not present after install"; } +green " ${EXT_ID}@${EXT_VERSION} installed" + +if [ -n "$DBT_VERSION" ]; then + maj="${DBT_VERSION%%.*}"; rest="${DBT_VERSION#*.}"; min="${rest%%.*}" + adapter="dbt-duckdb>=${maj}.${min},<${maj}.$((min + 1))" + say "Pinning dbt-core==${DBT_VERSION} (+ ${adapter})" + docker exec "$NAME" pip3 install --break-system-packages --quiet \ + "dbt-core==${DBT_VERSION}" "$adapter" >/tmp/launch-pinned-dbt.log 2>&1 \ + || { cat /tmp/launch-pinned-dbt.log; die "dbt-core pin failed"; } + got="$(docker exec "$NAME" dbt --version 2>&1 | grep -oE 'installed: [0-9]+\.[0-9]+\.[0-9]+' | head -1 | awk '{print $2}')" + [ "$got" = "$DBT_VERSION" ] || die "dbt-core resolved to ${got:-unknown}, expected ${DBT_VERSION}" + green " dbt-core ${got} active" +fi + +# Copy the sample dbt projects into the container (no source mount => the image's +# startup copy is skipped, so /home/coder/{jaffle-shop,dbt-core-sample}-duckdb +# don't exist and code-server's default workspace 404s). Seed BOTH so whichever +# folder code-server opens is real, then resolve packages so they open ready. +say "Loading sample dbt projects" +for proj in jaffle-shop-duckdb dbt-core-sample-duckdb; do + src="$FIXTURES_DIR/$proj" + [ -d "$src" ] || continue + dst="/home/coder/$proj" + docker exec "$NAME" rm -rf "$dst" >/dev/null 2>&1 || true + docker cp "$src" "${NAME}:${dst}" >/dev/null + # docker cp lands files as root/host-uid, but code-server runs as `coder` — fix + # ownership so dbt can write logs/, target/, and the duckdb file. + docker exec -u root "$NAME" chown -R coder:coder "$dst" >/dev/null 2>&1 || true + # Fixtures can ship a stale absolute duckdb path and a require-dbt-version guard + # that rejects the version we deliberately pinned — fix both so it just runs. + docker exec -u coder "$NAME" sed -i 's#/home/u0001/#/home/coder/#g' "$dst/profiles.yml" >/dev/null 2>&1 || true + if [ -n "$DBT_VERSION" ]; then + docker exec -u coder "$NAME" sed -i 's/^require-dbt-version:.*/require-dbt-version: [">=1.0.0", "<99.0.0"]/' "$dst/dbt_project.yml" >/dev/null 2>&1 || true + fi + docker exec -u coder "$NAME" bash -lc "cd '$dst' && dbt deps" >/dev/null 2>&1 || true + green " project ready at $dst" +done + +echo +green "READY — open in your browser:" +echo " http://localhost:${PORT}/?folder=${PROJECT_DIR}" +echo +echo " IDE (code-server): ${CS_TAG}" +echo " Extension: ${EXT_ID}@${EXT_VERSION}" +[ -n "$DBT_VERSION" ] && echo " dbt-core: ${DBT_VERSION}" +echo " Logs: docker logs -f ${NAME}" +echo " Stop: docker rm -f ${NAME}" diff --git a/test-matrix/version-install.mjs b/test-matrix/version-install.mjs new file mode 100644 index 000000000..e8e126855 --- /dev/null +++ b/test-matrix/version-install.mjs @@ -0,0 +1,244 @@ +#!/usr/bin/env node +// Standalone local tester: install a SPECIFIC dbt Power User extension version on +// a SPECIFIC VS Code version, optionally against a SPECIFIC dbt-core version, in +// full isolation, and verify it all landed. +// +// - VS Code: downloaded as a throwaway build via @vscode/test-electron (cached +// under .vscode-test/). Your installed editor/extensions/settings are untouched. +// - Extension: installed from the marketplace (or a local .vsix) into a temp +// --extensions-dir, then read back with --list-extensions --show-versions. +// - dbt-core (optional): a hermetic venv pinned to dbt-core== + a matching +// dbt-duckdb adapter; the duckdb fixture is parsed to prove the stack works, and +// the extension is pointed at that interpreter. +// No Docker required (so it sidesteps the code-server mount setup entirely). +// +// Usage: +// node test-matrix/version-install.mjs --vscode \ +// --extension [--dbt ] [--from ] [--launch] +// +// Examples: +// node test-matrix/version-install.mjs --extension 0.61.5 --dbt 1.10.18 +// node test-matrix/version-install.mjs --vscode 1.117.0 --extension 0.61.5 --dbt 1.10.18 +// node test-matrix/version-install.mjs --vscode 1.117.0 --extension 0.61.6 --from 0.61.4 # upgrade +// node test-matrix/version-install.mjs --extension 0.61.5 --dbt 1.10.18 --launch # open it to click around +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + downloadAndUnzipVSCode, + resolveCliArgsFromVSCodeExecutablePath, +} from "@vscode/test-electron"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT_ID = "innoverio.vscode-dbt-power-user"; +// Activation dependencies — only installed when --launch is used (a bare install +// check doesn't need them, and skipping keeps the default path fast + offline-ish). +const DEPS = ["samuelcolvin.jinjahtml", "ms-python.python"]; +const isSemver = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v); + +function arg(name, def = undefined) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return def; + const v = process.argv[i + 1]; + return v && !v.startsWith("--") ? v : true; +} + +const C = { red: "\x1b[1;31m", grn: "\x1b[1;32m", cyn: "\x1b[1;36m", rst: "\x1b[0m" }; +const say = (m) => console.log(`${C.cyn}== ${m}${C.rst}`); +const ok = (m) => console.log(`${C.grn}✓ ${m}${C.rst}`); +const bad = (m) => console.log(`${C.red}✗ ${m}${C.rst}`); +const sh = (cmd, args, opts = {}) => + execFileSync(cmd, args, { stdio: "pipe", encoding: "utf8", ...opts }); + +// Build a hermetic venv pinned to a specific dbt-core version (+ a matching +// dbt-duckdb adapter for the duckdb fixture), prove the fixture parses with it, +// and return the interpreter path so the extension can be pointed at it. +function buildDbtVenv(dbtVersion, fixture) { + const venv = mkdtempSync(join(tmpdir(), "vi-dbt-")); + const binDir = join(venv, process.platform === "win32" ? "Scripts" : "bin"); + const py = join(binDir, process.platform === "win32" ? "python.exe" : "python"); + const dbtBin = join(binDir, process.platform === "win32" ? "dbt.exe" : "dbt"); + const [maj, min] = dbtVersion.split("."); + const adapter = `dbt-duckdb>=${maj}.${min},<${maj}.${Number(min) + 1}`; + + say(`Creating dbt venv pinned to dbt-core==${dbtVersion} (+ ${adapter})`); + sh("python3", ["-m", "venv", venv]); + sh(py, ["-m", "pip", "install", "--quiet", "--upgrade", "pip"]); + sh(py, ["-m", "pip", "install", "--quiet", `dbt-core==${dbtVersion}`, adapter]); + + const verOut = sh(dbtBin, ["--version"]); + const m = verOut.match(/installed:\s*([0-9]+\.[0-9]+\.[0-9]+)/i); + const got = m ? m[1] : "unknown"; + if (got !== dbtVersion) { + throw new Error(`dbt-core version mismatch: requested ${dbtVersion}, venv resolved ${got}`); + } + ok(`dbt-core ${got} installed in venv`); + + // Hermetic profiles, then parse the fixture to prove the stack is coherent. + const profilesDir = mkdtempSync(join(tmpdir(), "vi-prof-")); + writeFileSync( + join(profilesDir, "profiles.yml"), + `dbt_core_sample_duckdb:\n target: go_sales\n outputs:\n go_sales:\n type: duckdb\n path: '${join(profilesDir, "go_sales.duckdb")}'\n`, + ); + const env = { ...process.env, DBT_PROFILES_DIR: profilesDir }; + try { + sh(dbtBin, ["deps"], { cwd: fixture, env }); + } catch { + /* deps may need network / be optional for this fixture */ + } + try { + sh(dbtBin, ["parse"], { cwd: fixture, env }); + ok(`fixture parses with dbt-core ${got}`); + } catch (e) { + const why = String((e && (e.stderr || e.message)) || e).trim().slice(0, 200); + bad(`fixture parse failed under dbt-core ${got} (extension install still valid): ${why}`); + } + return { py, dbtVersion: got, profilesDir }; +} + +async function main() { + const vscodeVersion = arg("vscode", "stable"); // x.y.z | stable | insiders + const extension = arg("extension", "latest"); // x.y.z | latest | path to .vsix + const dbtVersion = arg("dbt", null); // optional: pin dbt-core to this version + const fromVersion = arg("from", null); // optional: install this first, then upgrade + const launch = arg("launch", false); + const fixture = join(resolve(join(HERE, "..")), "test-fixtures", "dbt-core-sample-duckdb"); + + if (dbtVersion && !isSemver(dbtVersion)) { + console.log(`${C.red}FAIL${C.rst} --dbt must be x.y.z (got '${dbtVersion}')`); + process.exit(2); + } + + // Isolated, throwaway dirs — nothing is written to your real VS Code profile. + const extDir = mkdtempSync(join(tmpdir(), "vi-ext-")); + const uddDir = mkdtempSync(join(tmpdir(), "vi-udd-")); + + say(`Downloading VS Code '${vscodeVersion}' (throwaway, cached under .vscode-test/)`); + const exe = await downloadAndUnzipVSCode(vscodeVersion); + const [cli, ...baseArgs] = resolveCliArgsFromVSCodeExecutablePath(exe); + // On Windows the resolved CLI is code.cmd — execFileSync needs a shell to run it. + const run = (extra) => + execFileSync( + cli, + [...baseArgs, "--extensions-dir", extDir, "--user-data-dir", uddDir, ...extra], + { stdio: "pipe", encoding: "utf8", shell: process.platform === "win32" }, + ); + ok(`VS Code ready: ${exe}`); + + // Marketplace --install-extension is network-flaky (it can transiently resolve an + // empty version, especially for older builds). Retry, and verify it actually landed. + const sleep = (ms) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + const install = (spec, label) => { + let last; + for (let a = 1; a <= 3; a++) { + try { + run(["--install-extension", spec, "--force"]); + const listed = run(["--list-extensions", "--show-versions"]); + if (listed.toLowerCase().includes(EXT_ID.toLowerCase())) return; + last = new Error(`installed but ${EXT_ID} not listed`); + } catch (e) { + last = e; + } + if (a < 3) sleep(4000 * a); + } + const why = (last && (last.stderr || last.message)) || "unknown"; + throw new Error(`${label} failed after 3 tries: ${String(why).trim()}`.slice(0, 500)); + }; + + let dbt = null; + try { + // Pin dbt-core first (so an env problem surfaces before the editor work). + if (dbtVersion) { + dbt = buildDbtVenv(dbtVersion, fixture); + // Seed isolated user settings so a --launch'd editor uses this exact dbt. + const userDir = join(uddDir, "User"); + mkdirSync(userDir, { recursive: true }); + writeFileSync( + join(userDir, "settings.json"), + JSON.stringify( + { + "dbt.dbtIntegration": "core", + "dbt.dbtPythonPathOverride": dbt.py, + "telemetry.telemetryLevel": "off", + "redhat.telemetry.enabled": false, + "workbench.startupEditor": "none", + }, + null, + 2, + ), + ); + } + + if (launch) { + say("Installing activation dependencies"); + for (const d of DEPS) { + try { + run(["--install-extension", d, "--force"]); + ok(`dep ${d}`); + } catch { + bad(`dep ${d} (continuing)`); + } + } + } + + if (fromVersion) { + say(`Installing baseline dbt Power User ${fromVersion}`); + install(isSemver(fromVersion) ? `${EXT_ID}@${fromVersion}` : EXT_ID, `baseline ${fromVersion}`); + ok(`baseline ${fromVersion} installed`); + } + + say(`Installing target dbt Power User '${extension}'${fromVersion ? " (upgrade)" : ""}`); + if (extension === "latest") { + install(EXT_ID, "latest"); + } else if (isSemver(extension)) { + install(`${EXT_ID}@${extension}`, `version ${extension}`); + } else { + const p = resolve(extension); + if (!existsSync(p)) throw new Error(`vsix not found: ${p}`); + run(["--install-extension", p, "--force"]); + } + + // Read back what actually installed. + const listed = run(["--list-extensions", "--show-versions"]); + const line = listed + .split("\n") + .find((l) => l.toLowerCase().includes(EXT_ID.toLowerCase())); + if (!line) throw new Error(`extension not present after install:\n${listed}`); + const installedVersion = (line.split("@")[1] || "").trim(); + ok(`installed: ${line.trim()}`); + if (isSemver(extension) && installedVersion !== extension) { + bad(`version mismatch: requested ${extension}, marketplace gave ${installedVersion}`); + } + + if (launch) { + say("Launching VS Code so you can click around (close the window to finish)"); + const child = spawn( + cli, + [...baseArgs, "--extensions-dir", extDir, "--user-data-dir", uddDir, "--disable-workspace-trust", fixture], + { + stdio: "inherit", + shell: process.platform === "win32", + env: { ...process.env, ...(dbt ? { DBT_PROFILES_DIR: dbt.profilesDir } : {}) }, + }, + ); + await new Promise((res) => child.on("exit", res)); + } + + console.log( + `\n${C.grn}PASS${C.rst} VS Code ${vscodeVersion} + dbt Power User ${installedVersion}` + + (dbt ? ` + dbt-core ${dbt.dbtVersion}` : "") + + (fromVersion ? ` (upgraded from ${fromVersion})` : ""), + ); + console.log(` isolated extensions-dir: ${extDir}`); + if (dbt) console.log(` dbt interpreter: ${dbt.py}`); + process.exit(0); + } catch (e) { + console.log(`\n${C.red}FAIL${C.rst} ${String((e && e.message) || e).slice(0, 500)}`); + process.exit(1); + } +} + +main();