diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dc53b60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +env: + # Immutable SDK commit (Perfscale/sdk-libraries, feat/wasm-step-sdk): + # defineSteps + --kind steps build + settings delivery. + SDK_COMMIT: 522cfdd590062652c04337e6dfdcfc1c2c79389e + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + path: repo + # The SDK is a file: sibling dependency (../sdk-step-authoring/ts); + # pin it to an exact public commit. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: Perfscale/sdk-libraries + ref: feat/wasm-step-sdk + path: sdk-step-authoring + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + - name: Verify the pinned SDK commit + working-directory: sdk-step-authoring + run: test "$(git rev-parse HEAD)" = "$SDK_COMMIT" + - name: Install and build SDK (dist + type declarations) + working-directory: sdk-step-authoring/ts + run: | + npm ci + npm run build + - name: Install, typecheck, unit-test + working-directory: repo + run: | + npm install + npm run typecheck + npm test + - name: Build the WASM component + working-directory: repo + run: npm run build + - name: Inspect component imports/exports + working-directory: repo + run: ../sdk-step-authoring/ts/node_modules/.bin/jco wit python.wasm + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: python-wasm + path: repo/python.wasm + retention-days: 7 + + integration: + needs: build-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-wasm + path: . + - uses: dtolnay/rust-toolchain@stable + - name: Build Perfscale v0.30.0 from source + run: | + git clone --depth 1 --branch v0.30.0 https://github.com/Perfscale/perfscale.git /tmp/perfscale-src + cargo build --release -p perfscale-cli --manifest-path /tmp/perfscale-src/Cargo.toml + cp /tmp/perfscale-src/target/release/perfscale "$GITHUB_WORKSPACE/perfscale" + "$GITHUB_WORKSPACE/perfscale" --version + - name: Run the example through the engine (settings-driven install + exec) + run: | + "$GITHUB_WORKSPACE/perfscale" run -f examples/python.test.yaml -c examples/python.config.yaml | tee run.log + grep -q 'hello from python 3.13' run.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..be5d177 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# @perfscale/library-python + +Trusted local Python steps for perfscale (RFC 009 `process` capability), +built with [`@perfscale/library-sdk`](../sdk-step-authoring/ts) against +`perfscale:step@0.1.0`. Three steps over the bounded host `exec` import: + +- `lib/python/install` — download an official + [astral-sh/python-build-standalone](https://github.com/astral-sh/python-build-standalone) + release tarball, verify its SHA-256 against the release's own + `SHA256SUMS`, and extract it into a local toolcache (idempotent). +- `lib/python/exec` — run an inline script (`python -c`) or a script file + with a chosen python binary; exit code preserved. +- `lib/python/install-exec` — compose both. + +## Security model — read this first + +This component exists to run **host processes**. That is powerful and +dangerous on a shared runner, so the engine keeps it fail-closed: the +component only loads when the run grants the capability twice over — +`capabilities: [process]` on the library entry AND +`allow_process_actions: true` + `allow_library_capabilities: true` in the +config. Every spawn is direct argv (no shell) with a replacement +environment — host variables are never inherited. + +## Installing and granting the capability + +```yaml +# perfscale.config.yaml +allow_library_capabilities: true +allow_process_actions: true +libraries: + - use: ./python.wasm + capabilities: [process] +``` + +## Steps + +### `lib/python/install` + +`with:` keys (unknown keys are rejected): + +- `python-version` (string, **required** unless set via settings) — exact + full version X.Y.Z (`3.13.18`). +- `release` (string, optional) — python-build-standalone release tag + (`20261026`); unset resolves the latest release carrying the version. +- `install-dir` (string, optional, default `/tmp/perfscale-toolcache/python`). + +Output: `{ "path", "python-path", "version", "release", "stdout", "stderr", +"exit_code" }`. `success` is true only after the installed +`python --version` confirms the install (or the cache). + +### `lib/python/exec` + +Exactly one of `script` / `file` is required. + +`with:` keys (unknown keys are rejected): + +- `script` / `file` (string, **exactly one required**) +- `python-path` (absolute path, optional, default `/usr/bin/python3`) +- `args` (array of strings, optional) +- `working-directory` / `cwd` (string, optional alias pair) +- `env` (map of string→string, optional; merges over the default PATH) +- `timeout_ms` / `timeoutMs` (positive number or numeric string, optional) + +Output: `{ "exit_code", "stdout", "stderr" }`; `success` is +`exit_code == 0`. + +### `lib/python/install-exec` + +Install (or reuse the cache), then exec with the installed python — no +`python-path` needed. `with:` is the strict union of the `install` and +`exec` keys (minus `python-path`). Installation failure skips exec. + +## Run settings (RFC 009) + +Every step knob may be set once for the whole run instead of repeated per +step. Precedence (highest first): + +1. the step's explicit `with:` params +2. the `python:` config extension block (claimed via the component's config + schema — unknown keys fail the run) +3. the `variables.python` map +4. the library entry's `with:` block +5. the built-in defaults + +`env` maps merge in that same order (a step's `env` wins per key). Accepted +settings keys: `python-version` (alias `version`), `release`, +`install-dir`, `python-path` (alias `executable`), `working-directory` / +`cwd`, `timeout_ms` / `timeoutMs`, `env`. Invalid blocks (unknown key, +wrong type, relative `python-path`, bad version) are hard errors — at +instantiation for the library `with:`, at step resolution for the rest. + +```yaml +python: # config extension, validated against the schema + python-version: 3.13.18 + install-dir: /tmp/perfscale-toolcache/python # absolute (the default) +variables: + python: { timeout_ms: 30000 } +libraries: + - use: ./python.wasm + capabilities: [process] + with: { env: { PYTHONDONTWRITEBYTECODE: "1" } } +steps: + - name: smoke + use: lib/python/install-exec + with: + script: import sys; print(sys.version) +``` + +## Orchestration, not hot path + +Each step invocation is **real host process spawns** (and `install` may +download tens of MB on a cold cache). Use these steps for setup, fixtures, +and one-off environment checks — the one-shot `before:` / `after:` blocks +are the natural home. Do not put `exec` in the per-iteration VU loop of a +high-RPS test. + +## Local e2e + +```console +$ npm install +$ npm run build # → python.wasm +$ perfscale run -f examples/python.test.yaml -c examples/python.config.yaml +``` + +## Development + +```console +$ npm install # file: dependency on the SDK checkout +$ npm test # unit tests (node:test, fake host exec) +$ npm run typecheck +$ npm run build +``` + +## License + +Dual-licensed under [Apache-2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT), at +your option. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..39bf45c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security Policy + +`@perfscale/library-python` is a **trusted-code** component: its whole +purpose is downloading CPython and executing it on the host via the +perfscale `process` capability (RFC 009). Treat any report of "this library +can run python scripts" as working as intended. + +## What is in scope + +Genuinely security-relevant findings are cases where the component exceeds +its contract, for example: + +- Downloading from anything other than + `https://github.com/astral-sh/python-build-standalone` over HTTPS, or + skipping the SHA-256 verification against the release's `SHA256SUMS` + before extraction. +- Spawning anything other than the documented `/usr/bin/uname`, + `/usr/bin/test` (cache probe), `/usr/bin/mkdir`, `/usr/bin/curl`, + `/usr/bin/sha256sum`, `/usr/bin/tar`, `/usr/bin/rm` (the downloaded + archive only), or the selected python binary. +- Leaking host environment variables into a child (the host import already + guarantees a replacement environment; a bug that bypasses it matters). +- The cache-idempotency or bad-archive cleanup deleting anything other than + the downloaded `.tar.gz` archive. +- Validation gaps that let crafted `with:` params or settings blocks + (extension / variables / library `with:`) alter the spawned argv or + environment in undocumented ways. + +## What is out of scope + +- "The step ran the script the YAML told it to run." Capability grants + (`capabilities: [process]`, `allow_process_actions: true`, + `allow_library_capabilities: true`) are the operator's explicit, + double opt-in decision. +- Denial of service via long-running or noisy scripts — timeouts are + host-capped; tune `timeout_ms`. + +## Reporting + +Do not open a public issue for a vulnerability. Report privately to the +Perfscale maintainers (see the contact in the main perfscale repository's +SECURITY.md) with a minimal YAML + script reproducer. We aim to acknowledge +within 7 days. + +## Supported versions + +Only the latest release receives security fixes. diff --git a/examples/python.config.yaml b/examples/python.config.yaml new file mode 100644 index 0000000..cf1ac36 --- /dev/null +++ b/examples/python.config.yaml @@ -0,0 +1,31 @@ +# Config side of the python e2e: profile, library registration with the +# process capability, the one-shot `before:` install, and the double opt-in. +vus: 1 +duration: 1s +seed: 42 +# Double opt-in for the process capability: these config gates AND +# `capabilities: [process]` on the libraries entry below. Missing either +# side, the engine refuses to load the component (fail-closed, like `net`). +allow_library_capabilities: true +allow_process_actions: true +# Run-level settings (RFC 009): the `python` extension block is claimed by +# the component's config schema; a step's explicit `with:` still wins over +# it, `variables.python` sits one level below it, and the library entry's +# `with:` provides defaults below both. +python: + timeout_ms: 60000 +variables: + python: { timeout_ms: 30000 } +libraries: + - use: ../python.wasm + capabilities: [process] +before: + # Pinned exact version, checksum-verified against the release's + # SHA256SUMS. Installs into the default toolcache + # (/tmp/perfscale-toolcache/python); subsequent runs reuse it — nothing is + # re-downloaded. + - name: python + use: lib/python/install + with: + python-version: 3.13.18 + outputs: setup # → ${{ config.setup.python-path }} in test steps diff --git a/examples/python.test.yaml b/examples/python.test.yaml new file mode 100644 index 0000000..a1a76a9 --- /dev/null +++ b/examples/python.test.yaml @@ -0,0 +1,30 @@ +# Local e2e for the `python` step library (RFC 009 process capability). +# Steps only — the capability double opt-in and the `before:` install live +# in python.config.yaml. Build first: +# +# npm run build # → ../python.wasm relative to this file's examples dir +# +# perfscale run -f examples/python.test.yaml -c examples/python.config.yaml +# +# NOTE: process steps are orchestration (setup, fixtures, one-off checks), +# not a high-RPS hot path — each invocation is a real host spawn. +steps: + # Uses the exact python installed by the config's `before:` block. + - name: inline-script + use: lib/python/exec + with: + python-path: "${{ config.setup.python-path }}" + script: import sys; print(f"hello from python {sys.version_info.major}.{sys.version_info.minor}") + outputs: said + check: + message_contains: hello from python 3.13 + + - name: install-exec-one-shot + use: lib/python/install-exec + with: + python-version: 3.13.18 + script: import sys; print(sys.argv[1:]) + args: ["perfscale"] + outputs: combo + check: + message_contains: perfscale diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f3fd33a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,71 @@ +{ + "name": "@perfscale/library-python", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@perfscale/library-python", + "version": "0.1.0", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@perfscale/library-sdk": "file:../sdk-step-authoring/ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } + }, + "../sdk-step-authoring/ts": { + "name": "@perfscale/library-sdk", + "version": "0.3.0", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@bytecodealliance/jco": "^1.35.0", + "@bytecodealliance/preview2-shim": "^0.25.0" + }, + "bin": { + "perfscale-library-build": "bin/perfscale-library-build.mjs" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@perfscale/library-sdk": { + "resolved": "../sdk-step-authoring/ts", + "link": true + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/src/python.ts b/src/python.ts index f1e95f2..49e6208 100644 --- a/src/python.ts +++ b/src/python.ts @@ -27,6 +27,12 @@ import { * AND `allow_process_actions: true` + `allow_library_capabilities: true` in * the config. * + * Settings (RFC 009): every step knob may also come from the run — + * `extensions[python]` > `variables[python]` > the library entry's `with:` + * > built-in defaults; a step's explicit `with:` param beats them all, and + * `env` maps merge in that order. The component claims the `python` config + * extension key via its config schema (unknown keys fail the run). + * * Build: node ../sdk-step-authoring/ts/bin/perfscale-library-build.mjs \ * --kind steps --process src/python.ts -o python.wasm */ @@ -42,6 +48,163 @@ const RELEASES = "https://github.com/astral-sh/python-build-standalone/releases" const TARBALL_TIMEOUT_MS = 600_000; const SMALL_TIMEOUT_MS = 60_000; +// --------------------------------------------------------------------------- +// Settings (RFC 009): library `with:` defaults + run settings +// --------------------------------------------------------------------------- +// +// Every knob a step accepts may also be set once for the whole run. +// Precedence (highest first): +// +// 1. the step's explicit `with:` params +// 2. the run's `extensions[python]` block (claimed via configSchema) +// 3. the run's `variables[python]` map +// 4. the library entry's `with:` block (delivered to `init`) +// 5. the built-in defaults below +// +// `env` maps merge in that same order (a step's `env` wins over the same +// key set anywhere else); scalar keys take the highest-precedence value. +// Invalid settings are hard errors at resolution time — never silently +// ignored. + +/** The accepted keys of any settings block (step `with:` adds more). */ +const SETTINGS_KEYS = [ + "python-version", "version", "release", "install-dir", "python-path", + "executable", "working-directory", "cwd", "timeout_ms", "timeoutMs", "env", +]; + +/** Normalized per-run defaults collected from the settings sources. */ +interface PythonSettings { + pythonVersion?: string | undefined; + release?: string | undefined; + installDir?: string | undefined; + pythonPath?: string | undefined; + cwd?: string | undefined; + timeoutMs?: number | undefined; + env?: Record | undefined; +} + +/** JSON Schema for the claimed `python` config extension key. */ +const PYTHON_CONFIG_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + "python-version": { type: "string", minLength: 1 }, + version: { type: "string", minLength: 1 }, + release: { type: "string", minLength: 1 }, + "install-dir": { type: "string", minLength: 1 }, + "python-path": { type: "string", minLength: 1 }, + executable: { type: "string", minLength: 1 }, + "working-directory": { type: "string", minLength: 1 }, + cwd: { type: "string", minLength: 1 }, + timeout_ms: { type: ["number", "string"] }, + timeoutMs: { type: ["number", "string"] }, + env: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, +} as const; + +function parseSettingsBlock(source: string, raw: unknown): PythonSettings { + if (raw === undefined || raw === null) return {}; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new StepError(`python settings (${source}): must be an object`); + } + const block = raw as Record; + for (const k of Object.keys(block)) { + if (!SETTINGS_KEYS.includes(k)) { + throw new StepError(`python settings (${source}): unknown key '${k}'`); + } + } + const s: PythonSettings = {}; + const str = (key: string): string | undefined => { + const v = block[key]; + if (v === undefined) return undefined; + if (typeof v !== "string" || v === "") { + throw new StepError(`python settings (${source}): '${key}' must be a non-empty string`); + } + return v; + }; + const version = str("python-version") ?? str("version"); + if (version !== undefined) { + if (!EXACT_VERSION.test(version)) { + throw new StepError( + `python settings (${source}): 'python-version' must be an exact full version X.Y.Z (e.g. 3.13.18)`, + ); + } + s.pythonVersion = version; + } + const release = str("release"); + if (release !== undefined && !RELEASE_TAG.test(release)) { + throw new StepError( + `python settings (${source}): 'release' must be a python-build-standalone release tag (e.g. 20261026)`, + ); + } + s.release = release; + s.installDir = str("install-dir"); + s.pythonPath = str("python-path") ?? str("executable"); + if (s.pythonPath !== undefined && !s.pythonPath.startsWith("/")) { + throw new StepError(`python settings (${source}): 'python-path' must be an absolute path`); + } + const wd = str("working-directory"); + const cwd = str("cwd"); + if (wd !== undefined && cwd !== undefined && wd !== cwd) { + throw new StepError(`python settings (${source}): 'working-directory' and 'cwd' disagree`); + } + s.cwd = wd ?? cwd; + const t = block["timeout_ms"] ?? block["timeoutMs"]; + if (t !== undefined) { + const n = typeof t === "number" ? t : typeof t === "string" ? Number(t) : NaN; + if (!Number.isFinite(n) || n <= 0) { + throw new StepError(`python settings (${source}): timeout must be a positive number of milliseconds`); + } + s.timeoutMs = n; + } + const env = block["env"]; + if (env !== undefined) { + if (typeof env !== "object" || env === null || Array.isArray(env)) { + throw new StepError(`python settings (${source}): 'env' must be a map of string to string`); + } + s.env = {}; + for (const [k, val] of Object.entries(env as Record)) { + if (typeof val !== "string") { + throw new StepError(`python settings (${source}): env['${k}'] must be a string`); + } + s.env[k] = val; + } + } + return s; +} + +/** Library-level `with:` defaults, captured by `init` at instantiation. */ +let libraryDefaults: PythonSettings = {}; + +/** + * Resolve the effective settings for one step invocation: + * run extension > run variables > library `with:` (the step's own `with:` + * params are applied by the caller on top). Invalid blocks fail the step. + */ +function resolveSettings(ctx: StepCtx): PythonSettings { + const ext = parseSettingsBlock( + "extensions[python]", + ctx.settings.extensions?.["python"], + ); + const vars = parseSettingsBlock( + "variables[python]", + ctx.settings.variables?.["python"], + ); + const lib = libraryDefaults; + return { + pythonVersion: ext.pythonVersion ?? vars.pythonVersion ?? lib.pythonVersion, + release: ext.release ?? vars.release ?? lib.release, + installDir: ext.installDir ?? vars.installDir ?? lib.installDir, + pythonPath: ext.pythonPath ?? vars.pythonPath ?? lib.pythonPath, + cwd: ext.cwd ?? vars.cwd ?? lib.cwd, + timeoutMs: ext.timeoutMs ?? vars.timeoutMs ?? lib.timeoutMs, + env: { ...lib.env, ...vars.env, ...ext.env }, + }; +} + type Log = { tag: "out" | "err" | "sys"; line: string }; type Out = Record; @@ -60,14 +223,6 @@ function strictKeys(step: string, params: Record, allowed: stri } } -function reqString(step: string, params: Record, key: string): string { - const v = params[key]; - if (typeof v !== "string" || v === "") { - throw new StepError(`python/${step}: '${key}' is required and must be a non-empty string`); - } - return v; -} - function optString(step: string, params: Record, key: string): string | undefined { const v = params[key]; if (v === undefined) return undefined; @@ -92,9 +247,13 @@ function optCwd(step: string, params: Record): string | undefin return (wd ?? cwd) as string | undefined; } -/** User env merges over the minimal PATH; every value must be a string. */ -function optEnv(step: string, params: Record): Record { - const env: Record = { PATH: DEFAULT_PATH }; +/** User env merges over the minimal PATH and any settings-level env. */ +function optEnv( + step: string, + params: Record, + base: Record = {}, +): Record { + const env: Record = { PATH: DEFAULT_PATH, ...base }; const v = params["env"]; if (v === undefined) return env; if (typeof v !== "object" || v === null || Array.isArray(v)) { @@ -175,20 +334,29 @@ interface InstallParsed { installDir: string; } -function parseInstallParams(step: string, params: Record): InstallParsed { - const pythonVersion = reqString(step, params, "python-version"); +function parseInstallParams( + step: string, + params: Record, + settings: PythonSettings, +): InstallParsed { + const explicit = optString(step, params, "python-version"); + const pythonVersion = explicit ?? settings.pythonVersion; + if (pythonVersion === undefined) { + throw new StepError(`python/${step}: 'python-version' is required and must be a non-empty string`); + } if (!EXACT_VERSION.test(pythonVersion)) { throw new StepError( `python/${step}: 'python-version' must be an exact full version X.Y.Z (e.g. 3.13.18)`, ); } - const release = optString(step, params, "release"); + const release = optString(step, params, "release") ?? settings.release; if (release !== undefined && !RELEASE_TAG.test(release)) { throw new StepError( `python/${step}: 'release' must be a python-build-standalone release tag (e.g. 20261026)`, ); } - const installDir = optString(step, params, "install-dir") ?? DEFAULT_INSTALL_DIR; + const installDir = + optString(step, params, "install-dir") ?? settings.installDir ?? DEFAULT_INSTALL_DIR; return { version: pythonVersion, release, installDir }; } @@ -284,7 +452,7 @@ function resolveRelease( function runInstall(step: string, params: Record, ctx: StepCtx): StepOutcome { const logs: Log[] = []; - const { version, release: requestedRelease, installDir } = parseInstallParams(step, params); + const { version, release: requestedRelease, installDir } = parseInstallParams(step, params, resolveSettings(ctx)); const triple = hostTriple(ctx); const resolved = resolveRelease(ctx, version, requestedRelease, triple, logs); @@ -296,27 +464,36 @@ function runInstall(step: string, params: Record, ctx: StepCtx) const path = `${installDir}/${base}`; const pythonPath = `${path}/python/bin/python3`; - // Idempotent: a working cached python is reused, never re-downloaded. - const cached = runHost(ctx, { - executable: pythonPath, - argv: ["--version"], + // Idempotent: a working cached python is reused, never re-downloaded. The + // host import hard-errors on spawn-ENOENT, so probe with /usr/bin/test + // first and only run `python --version` when the binary exists. + const probe = runHost(ctx, { + executable: "/usr/bin/test", + argv: ["-x", pythonPath], timeoutMs: SMALL_TIMEOUT_MS, }); - if (cached.exitCode === 0) { - logs.push({ tag: "sys", line: `python: ${base} already installed at ${path} (${cached.stdout.trim()})` }); - return { - success: true, - output: { - path, - "python-path": pythonPath, - version, - release, - exit_code: 0, - stdout: cached.stdout, - stderr: cached.stderr, - }, - logs, - }; + if (probe.exitCode === 0) { + const cached = runHost(ctx, { + executable: pythonPath, + argv: ["--version"], + timeoutMs: SMALL_TIMEOUT_MS, + }); + if (cached.exitCode === 0) { + logs.push({ tag: "sys", line: `python: ${base} already installed at ${path} (${cached.stdout.trim()})` }); + return { + success: true, + output: { + path, + "python-path": pythonPath, + version, + release, + exit_code: 0, + stdout: cached.stdout, + stderr: cached.stderr, + }, + logs, + }; + } } const tarballUrl = `${RELEASES}/download/${release}/${asset}`; @@ -422,19 +599,21 @@ function runExec( ctx: StepCtx, installedPythonPath?: string, ): StepOutcome { + const settings = resolveSettings(ctx); const script = optString(step, params, "script"); const file = optString(step, params, "file"); if ((script === undefined) === (file === undefined)) { throw new StepError(`python/${step}: exactly one of 'script' or 'file' is required`); } - const pythonPath = installedPythonPath ?? optString(step, params, "python-path") ?? DEFAULT_PYTHON_PATH; + const pythonPath = + installedPythonPath ?? optString(step, params, "python-path") ?? settings.pythonPath ?? DEFAULT_PYTHON_PATH; if (!pythonPath.startsWith("/")) { throw new StepError(`python/${step}: 'python-path' must be an absolute path to the python executable`); } const args = optArgs(step, params); - const cwd = optCwd(step, params); - const env = optEnv(step, params); - const timeoutMs = optTimeoutMs(step, params); + const cwd = optCwd(step, params) ?? settings.cwd; + const env = optEnv(step, params, settings.env); + const timeoutMs = optTimeoutMs(step, params) ?? settings.timeoutMs; const argv = script !== undefined ? ["-c", script, ...args] : [file!, ...args]; const res = ctx.exec({ @@ -461,6 +640,12 @@ function runExec( export default defineSteps({ name: "python", version: "0.1.0", + configSchema: { key: "python", schema: PYTHON_CONFIG_SCHEMA }, + init(config) { + // Library-level `with:` defaults — validated once at instantiation; + // invalid defaults are fatal to the run, never silently ignored. + libraryDefaults = parseSettingsBlock("library with", config); + }, steps: { install: { description: diff --git a/test/python.test.ts b/test/python.test.ts new file mode 100644 index 0000000..dcabf36 --- /dev/null +++ b/test/python.test.ts @@ -0,0 +1,306 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + StepCtx, + StepError, + testStep, + type ExecRequest, + type ExecResult, + type HostExec, +} from "@perfscale/library-sdk"; +import def from "../src/python.ts"; + +/** + * Record every host spawn and answer from a script. The harness fake is a + * WIT-boundary `HostExec` (env as entry pairs); tests see the ergonomic + * ExecRequest shape (env as a Record) the component itself uses. + */ +function fakeExec(handler: (req: ExecRequest, call: number) => ExecResult) { + const calls: ExecRequest[] = []; + const exec: HostExec = (wreq) => { + const req: ExecRequest = { + executable: wreq.executable, + env: Object.fromEntries(wreq.env ?? []), + }; + if (wreq.argv !== undefined) req.argv = wreq.argv; + if (wreq.cwd !== undefined) req.cwd = wreq.cwd; + if (wreq.timeoutMs !== undefined) req.timeoutMs = wreq.timeoutMs; + calls.push(req); + return handler(req, calls.length - 1); + }; + return { calls, exec }; +} + +function ctx(exec?: HostExec): StepCtx { + const c = new StepCtx(exec); + c.stepName = "t"; + return c; +} + +const ok = (stdout = ""): ExecResult => ({ exitCode: 0, stdout, stderr: "" }); +const SHA = "a".repeat(64); +const VERSION = "3.13.18"; +const RELEASE = "20261026"; +const TRIPLE = "x86_64-unknown-linux-gnu"; +const ASSET = `cpython-${VERSION}+${RELEASE}-${TRIPLE}-install_only_stripped.tar.gz`; +const sums = (sha = SHA): string => `${sha} ${ASSET}\n`; + +/** + * A full Linux host for install flows. `python --version` fails on the + * first invocation (cache miss) and succeeds afterwards (post-install + * verify), unless `cached` — then it succeeds immediately and nothing is + * downloaded. + */ +function installHost(opts: { cached?: boolean; badSum?: boolean } = {}) { + return fakeExec((req) => { + const argv = req.argv ?? []; + if (req.executable === "/usr/bin/uname") { + return ok(argv[0] === "-s" ? "Linux\n" : "x86_64\n"); + } + // The cache probe: ENOENT at spawn is a host error, so the component + // checks existence with test(1) before running the cached binary. + if (req.executable === "/usr/bin/test") { + return opts.cached ? ok() : { exitCode: 1, stdout: "", stderr: "" }; + } + if (req.executable.endsWith("/bin/python3")) { + return ok(`Python ${VERSION}\n`); + } + if (req.executable === "/usr/bin/curl") { + if (argv.includes("-o")) return ok(); + return ok(sums(opts.badSum ? "c".repeat(64) : SHA)); + } + if (req.executable === "/usr/bin/sha256sum") return ok(`${SHA} ${argv[0]}\n`); + if (req.executable === "/usr/bin/mkdir") return ok(); + if (req.executable === "/usr/bin/tar") return ok(); + if (req.executable === "/usr/bin/rm") return ok(); + return { exitCode: 99, stdout: "", stderr: `unhandled: ${req.executable}` }; + }); +} + +describe("python/install", () => { + it("installs an exact version: download, checksum, extract, verify", () => { + const { calls, exec } = installHost(); + const res = testStep(def, ctx(exec), "install", { + "python-version": VERSION, + release: RELEASE, + }); + assert.equal(res.success, true); + const out = res.output as Record; + assert.equal(out["version"], VERSION); + assert.equal(out["release"], RELEASE); + assert.equal( + out["python-path"], + `/tmp/perfscale-toolcache/python/cpython-${VERSION}+${RELEASE}-${TRIPLE}/python/bin/python3`, + ); + const dl = calls.find((c) => c.executable === "/usr/bin/curl" && c.argv?.includes("-o"))!; + assert.ok(dl.argv!.includes(`https://github.com/astral-sh/python-build-standalone/releases/download/${RELEASE}/${ASSET}`)); + // every spawn is direct argv with a replacement env + assert.ok(calls.every((c) => c.env?.PATH === "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin")); + }); + + it("rejects a checksum mismatch and deletes the archive", () => { + const { calls, exec } = installHost({ badSum: true }); + const res = testStep(def, ctx(exec), "install", { + "python-version": VERSION, + release: RELEASE, + }); + assert.equal(res.success, false); + assert.ok(calls.some((c) => c.executable === "/usr/bin/rm")); + assert.ok(!calls.some((c) => c.executable === "/usr/bin/tar")); + }); + + it("rejects unknown keys and bad install params", () => { + const { exec } = installHost({ cached: true }); + for (const p of [ + { "python-version": VERSION, shell: true }, + { "python-version": "3.13" }, + { "python-version": VERSION, release: "latest" }, + ]) { + assert.throws(() => testStep(def, ctx(exec), "install", p), StepError, JSON.stringify(p)); + } + }); +}); + +describe("python/exec", () => { + it("runs an inline script with the default python", () => { + const { calls, exec } = fakeExec(() => ok("hi\n")); + const res = testStep(def, ctx(exec), "exec", { script: "print('hi')" }); + assert.equal(res.success, true); + assert.equal(calls[0]!.executable, "/usr/bin/python3"); + assert.deepEqual(calls[0]!.argv, ["-c", "print('hi')"]); + }); + + it("preserves a non-zero exit code", () => { + const { exec } = fakeExec(() => ({ exitCode: 4, stdout: "", stderr: "boom\n" })); + const res = testStep(def, ctx(exec), "exec", { file: "t.py" }); + assert.equal(res.success, false); + assert.equal((res.output as Record)["exit_code"], 4); + }); +}); + +// --------------------------------------------------------------------------- +// Settings (RFC 009): extensions[python] > variables[python] > library with +// > built-in defaults; explicit step `with:` wins over everything. +// --------------------------------------------------------------------------- + +function settingsCtx( + exec: HostExec | undefined, + settings: { extensions?: Record; variables?: Record }, +): StepCtx { + const c = ctx(exec); + c.settings = { + vus: null, + duration_ms: null, + seed: null, + stages: null, + arrival: null, + variables: settings.variables ?? {}, + extensions: settings.extensions ?? {}, + }; + return c; +} + +/** Reset library-level `with:` defaults so module state never leaks between tests. */ +function resetLibraryWith() { + def.init?.(null); +} + +describe("python settings", () => { + it("install takes python-version, release and install-dir from extensions[python]", () => { + resetLibraryWith(); + const { exec } = installHost(); + const res = testStep( + def, + settingsCtx(exec, { + extensions: { + python: { "python-version": VERSION, release: RELEASE, "install-dir": "/opt/ext" }, + }, + }), + "install", + {}, + ); + assert.equal(res.success, true); + assert.equal( + (res.output as Record)["python-path"], + `/opt/ext/cpython-${VERSION}+${RELEASE}-${TRIPLE}/python/bin/python3`, + ); + }); + + it("explicit step params beat the run extension", () => { + resetLibraryWith(); + const { exec } = installHost(); + const res = testStep( + def, + settingsCtx(exec, { extensions: { python: { "install-dir": "/opt/ext" } } }), + "install", + { "python-version": VERSION, release: RELEASE, "install-dir": "/opt/step" }, + ); + assert.equal(res.success, true); + assert.equal( + (res.output as Record)["path"], + `/opt/step/cpython-${VERSION}+${RELEASE}-${TRIPLE}`, + ); + }); + + it("variables[python] apply when no extension, library with applies below variables", () => { + def.init?.({ "install-dir": "/opt/lib", "python-version": VERSION, release: RELEASE }); + try { + const { exec } = installHost(); + const res = testStep( + def, + settingsCtx(exec, { variables: { python: { "install-dir": "/opt/vars" } } }), + "install", + {}, + ); + assert.equal(res.success, true); + assert.equal( + (res.output as Record)["path"], + `/opt/vars/cpython-${VERSION}+${RELEASE}-${TRIPLE}`, + ); + } finally { + resetLibraryWith(); + } + }); + + it("library with defaults apply when run settings are silent", () => { + def.init?.({ "install-dir": "/opt/lib", "python-version": VERSION, release: RELEASE }); + try { + const { exec } = installHost(); + const res = testStep(def, settingsCtx(exec, {}), "install", {}); + assert.equal(res.success, true); + assert.equal( + (res.output as Record)["path"], + `/opt/lib/cpython-${VERSION}+${RELEASE}-${TRIPLE}`, + ); + } finally { + resetLibraryWith(); + } + }); + + it("exec merges env in precedence order: step > extension > variables > library with", () => { + def.init?.({ env: { A: "lib", B: "lib", C: "lib", D: "lib" } }); + try { + const { calls, exec } = fakeExec(() => ok()); + const res = testStep( + def, + settingsCtx(exec, { + variables: { python: { env: { B: "vars", C: "vars", D: "vars" } } }, + extensions: { python: { env: { C: "ext", D: "ext" }, cwd: "/ext/cwd", timeout_ms: 5000 } }, + }), + "exec", + { script: "pass", env: { D: "step" } }, + ); + assert.equal(res.success, true); + const run = calls.find((c) => c.executable.endsWith("/python3"))!; + assert.equal(run.env?.A, "lib"); + assert.equal(run.env?.B, "vars"); + assert.equal(run.env?.C, "ext"); + assert.equal(run.env?.D, "step"); + assert.equal(run.env?.PATH, "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + assert.equal(run.cwd, "/ext/cwd"); + assert.equal(run.timeoutMs, 5000); + } finally { + resetLibraryWith(); + } + }); + + it("exec takes python-path from settings when the step omits it", () => { + resetLibraryWith(); + const { calls, exec } = fakeExec(() => ok()); + const res = testStep( + def, + settingsCtx(exec, { extensions: { python: { "python-path": "/opt/py/bin/python3" } } }), + "exec", + { script: "pass" }, + ); + assert.equal(res.success, true); + assert.equal(calls[0]!.executable, "/opt/py/bin/python3"); + }); + + it("rejects invalid settings blocks", () => { + resetLibraryWith(); + const { exec } = fakeExec(() => ok()); + for (const settings of [ + { extensions: { python: { bogus: 1 } } }, + { extensions: { python: { env: { A: 1 } } } }, + { extensions: { python: { "python-path": "relative/python" } } }, + { extensions: { python: { "python-version": "3.13" } } }, + { extensions: { python: { release: "latest" } } }, + { extensions: { python: { timeout_ms: -5 } } }, + { variables: { python: "not-an-object" } }, + { variables: { python: { cwd: "/a", "working-directory": "/b" } } }, + ]) { + assert.throws( + () => testStep(def, settingsCtx(exec, settings), "exec", { script: "pass" }), + StepError, + JSON.stringify(settings), + ); + } + }); + + it("rejects an invalid library with block at init", () => { + assert.throws(() => def.init?.({ "install-dir": 42 }), StepError); + assert.throws(() => def.init?.({ unknown: true }), StepError); + resetLibraryWith(); + }); +});