diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c8709b..7796944 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ concurrency: permissions: contents: read - jobs: lint-and-typecheck: name: Code Quality (Ruff & Mypy) @@ -104,3 +103,83 @@ jobs: with: name: dist path: dist/ + + deployment-config-validation: + name: Offline Deployment Configuration Validation + runs-on: ubuntu-latest + # Pinned validators: Compose v2.30.3; kubectl v1.32.2; yq v4.45.4; + # Python v3.13.2; Prometheus v3.4.0; Nginx v1.27.4; Caddy v2.9.1; + # Traefik v3.3.4. systemd-analyze is the runner-provided Ubuntu version. + steps: + - uses: actions/checkout@v6 + - name: Set up Docker Compose v2.30.3 + uses: docker/setup-compose-action@v1 + with: + version: v2.30.3 + - name: Validate Docker Compose configuration + run: docker compose -f docker-compose.yml config --quiet + - name: Render Kubernetes base and validate Kubernetes YAML + run: | + set -Eeuo pipefail + docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work registry.k8s.io/kubectl:v1.32.2 kustomize deploy/k8s > /tmp/blazeserve-k8s.yaml + docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work mikefarah/yq:4.45.4 eval-all -e '.' deploy/k8s/*.yaml > /dev/null + kinds="$(docker run --rm -i mikefarah/yq:4.45.4 eval -N -r '.kind' - < /tmp/blazeserve-k8s.yaml | sort)" + test "$kinds" = $'Deployment\nService' || { + printf 'default deploy/k8s base must render only Deployment and Service; got:\n%s\n' "$kinds" >&2 + exit 1 + } + - name: Validate Linux tuning syntax and documented format subset + run: | + set -Eeuo pipefail + docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly bash:5.2.37 -n /work/deploy/linux-tuning/tuning.sh + docker run --rm -i --mount type=bind,src="$PWD",dst=/work,readonly python:3.13.2-alpine3.21 python - <<'PY' + from pathlib import Path + import re + + root = Path("/work/deploy/linux-tuning") + limits = [line.split() for line in root.joinpath("limits.conf").read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#")] + if not limits or any( + len(fields) != 4 + or fields[0] != "blazeserve" + or fields[1] not in {"soft", "hard"} + or fields[2] not in {"nofile", "nproc"} + or not fields[3].isdigit() + or int(fields[3]) <= 0 + for fields in limits + ): + raise SystemExit("deploy/linux-tuning/limits.conf must use: blazeserve soft|hard nofile|nproc positive-integer") + + assignment = re.compile(r"[a-z0-9_.-]+\s*=\s*\S+(?:\s+\S+)*$") + sysctl_lines = [line for line in root.joinpath("sysctl-blazeserve.conf").read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#")] + if not sysctl_lines or any(not assignment.fullmatch(line) for line in sysctl_lines): + raise SystemExit("deploy/linux-tuning/sysctl-blazeserve.conf must use: lowercase.key = non-empty whitespace-separated value") + PY + - name: Validate monitoring configuration + run: | + set -Eeuo pipefail + docker run --rm --mount type=bind,src="$PWD/deploy/monitoring",dst=/etc/prometheus,readonly --entrypoint /bin/promtool prom/prometheus:v3.4.0 check config /etc/prometheus/prometheus.yml + docker run --rm --mount type=bind,src="$PWD/deploy/monitoring",dst=/etc/prometheus,readonly --entrypoint /bin/promtool prom/prometheus:v3.4.0 check rules /etc/prometheus/alerts.yml + docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly python:3.13.2-alpine3.21 python -m json.tool /work/deploy/monitoring/grafana-dashboard.json > /dev/null + - name: Validate reverse-proxy configuration + run: | + set -Eeuo pipefail + docker run --rm --mount type=bind,src="$PWD/deploy/reverse-proxy/nginx.conf",dst=/etc/nginx/nginx.conf,readonly nginx:1.27.4-alpine nginx -t -c /etc/nginx/nginx.conf + docker run --rm --mount type=bind,src="$PWD/deploy/reverse-proxy/Caddyfile",dst=/etc/caddy/Caddyfile,readonly caddy:2.9.1-alpine caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + set +e + timeout 5s docker run --rm --mount type=bind,src="$PWD/deploy/reverse-proxy",dst=/etc/traefik,readonly --entrypoint traefik traefik:v3.3.4 --configFile=/etc/traefik/traefik.yml + status=$? + set -e + test "$status" -eq 124 || { + printf 'Traefik configuration validation exited unexpectedly with status %s\n' "$status" >&2 + exit 1 + } + docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work mikefarah/yq:4.45.4 eval -e '.' deploy/reverse-proxy/traefik-dynamic.yml > /dev/null + - name: Validate systemd unit without starting it + # ubuntu-latest supplies systemd-analyze; its version tracks the runner image. + # systemd-analyze verify checks unit syntax and validates that ExecStart is executable. + run: | + sudo ln -sf /bin/true /usr/local/bin/blaze + trap 'sudo rm -f /usr/local/bin/blaze' EXIT + systemd-analyze verify deploy/systemd/blazeserve.service diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 0000000..fd2b54c --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,187 @@ +# Compatibility and Deprecation Policy + +This policy defines how BlazeServe evaluates compatibility for released behavior. +It is normative for maintainers and contributors. It does not change a current +interface, version, release gate, or deployment contract. + +For release mechanics, use [RELEASE.md](RELEASE.md); for contribution review, use +[CONTRIBUTING.md](CONTRIBUTING.md); for operations, use +[DEPLOYMENT.md](DEPLOYMENT.md). Those documents remain canonical for their +respective subjects. + +## Scope of a compatibility commitment + +The following released, documented surfaces are public compatibility commitments: + +| Surface | Commitment | Primary evidence | +| --- | --- | --- | +| CLI | `blaze` commands, options, argument validation, exit behavior, and observable human or machine-readable output documented for users. | [`pyproject.toml`](pyproject.toml), [`blazeserve/cli.py`](blazeserve/cli.py), [`tests/e2e/`](tests/e2e/) | +| Python API | Names explicitly exported through a module's `__all__`, including the package version export and `blazeserve.server` exports. | [`blazeserve/__init__.py`](blazeserve/__init__.py), [`blazeserve/server.py`](blazeserve/server.py) | +| HTTP contract | Documented request methods, paths, status codes, response headers, representations, and authentication behavior. | [`blazeserve/handlers.py`](blazeserve/handlers.py), [`tests/integration/`](tests/integration/) | +| Metrics and logs | Documented Prometheus metric names/types and structured JSON log field names. | [`blazeserve/metrics.py`](blazeserve/metrics.py), [`blazeserve/logging.py`](blazeserve/logging.py), [`DEPLOYMENT.md`](DEPLOYMENT.md) | +| Package metadata | Project name, version, Python requirement, console-script entry point, runtime dependency bounds, and published wheel/source distribution identity. | [`pyproject.toml`](pyproject.toml), [release workflow](.github/workflows/release.yml) | +| Container tags | Published `ghcr.io//:v` and `:latest` tag meanings and the image entrypoint/health behavior documented by the release assets. | [`RELEASE.md`](RELEASE.md), [release workflow](.github/workflows/release.yml), [`Dockerfile`](Dockerfile) | +| Documented deployment contracts | The explicitly documented behavior and prerequisites of checked-in Docker, Compose, Kubernetes, systemd, monitoring, and reverse-proxy assets, subject to the support matrix below. | [`DEPLOYMENT.md`](DEPLOYMENT.md), [`docker-compose.yml`](docker-compose.yml), [`deploy/`](deploy/) | + +A user-facing surface is not exempt merely because it is simple or has no type +annotation. A documented output field, endpoint, option, metric, or tag is public +when users can reasonably automate against it. + +The following are **not** compatibility commitments unless they are expressly +exported or documented as public: + +- private names and implementation details, including leading-underscore helpers + that are not explicitly exported; +- internal module layout, call graphs, dynamic classes, data structures, locks, + and implementation-specific performance techniques; +- test fixtures, test-only helpers, and development tooling behavior; +- examples, snippets, or deployment combinations that have no stated validation + evidence; and +- an operator's local configuration, credentials, content, proxy controller, + storage provider, or kernel tuning result. + +Changing an excluded implementation detail must still preserve every public +observable behavior it affects. + +## Versioning intent and compatibility window + +BlazeServe follows the Semantic Versioning intent stated in +[RELEASE.md](RELEASE.md): patch releases are backward-compatible fixes, minor +releases are backward-compatible features, and a major release is the normal place +for incompatible public behavior. While the project is pre-1.0, a release remains +stable enough for operators to rely on the public surfaces in this policy, but the +project does **not** promise a fixed number of supported releases, months, or +calendar duration. + +For a planned incompatibility before 1.0, maintainers MUST choose and document a +reasonable compatibility window based on user impact, adoption evidence, security +risk, and feasible migration. The window begins with the release that first +announces the deprecation and ends no earlier than the removal release stated in +that notice. A minor version alone does not waive the requirements below. + +A change may be released without a deprecation window only under the documented +exception process. A removal is not retroactively compatible because a changelog +mentions it after the fact. + +## Deprecation process + +Unless an approved exception applies, a public-surface deprecation proceeds through +these stages: + +1. **Proposal and evidence.** The issue and pull request identify the affected + public surface, current and replacement behavior, affected platforms/artifacts, + compatibility risk, focused evidence, and the planned removal release or + condition. +2. **Announcement.** The release notes and affected canonical documentation mark + the old behavior as deprecated, name the replacement, and give a concrete + migration path. Documentation must not leave two conflicting interfaces equally + canonical. +3. **Warning or migration aid.** When an interface is invoked by an interactive + user or application code, provide an actionable warning or compatible migration + path where technically safe. A warning MUST identify the replacement and MUST + NOT disclose secrets or make machine-readable output unusable. For a network, + metric, log, or container surface where runtime warning is inappropriate, the + documented announcement and migration instructions are the notice mechanism. +4. **Supported overlap.** Keep the old behavior working for the announced window; + preserve its documented contract and test the migration boundary where a + plausible regression exists. +5. **Removal.** Remove the obsolete behavior, compatibility aliases, obsolete + documentation, and tests that solely preserve it. Update canonical documents, + release notes, and migration instructions so the replacement is the only + current path. Record the removal and its compatibility impact in the release + notes. + +A deprecation notice MUST state what changes, who is affected, the replacement, +how to migrate, the planned removal release or condition, and any material +platform/deployment limitation. A deprecation MUST NOT silently change defaults, +status codes, metric/log field names, output formats, or container-tag meaning. + +## Exceptions + +A maintainer may approve a shorter window or immediate change only for a credible +security issue, legal requirement, data-loss/corruption risk, severe operational +hazard, or an objectively impossible compatibility path. The approval MUST be +recorded in the linked issue and pull request, with: + +- the public surface and reason the ordinary process is unsafe or infeasible; +- the affected releases, users, platforms, and artifacts; +- the evidence supporting urgency; +- the mitigation or replacement path; and +- the release-note wording. + +The maintainer has final responsibility for compatibility under +[GOVERNANCE.md](GOVERNANCE.md). Security-sensitive details follow the private +process in [SECURITY.md](SECURITY.md); public release notes should disclose only +what is safe to disclose. + +## Evidence standard + +A support or compatibility claim MUST be traceable to current repository evidence. +Use direct source and focused tests for behavior; package metadata for interpreter +and package claims; CI/release workflow jobs for tested combinations and artifacts; +and deployment assets plus their documentation for operational examples. Do not +upgrade a claim from “example” to “supported” because a file is checked in. + +Evidence must identify its limitation. For example, a package classifier establishes +portability intent, not a successful test on every host; an Ubuntu container probe +does not validate Docker Desktop, Kubernetes, or every proxy. + +## Support matrix + +| Surface or artifact | Current scope | Evidence | Limitation and review trigger | +| --- | --- | --- | --- | +| Python package portability | Python `>=3.10`; classifiers identify Python 3.10–3.13 and OS-independent portability. | [`pyproject.toml`](pyproject.toml) | Metadata expresses package portability intent. It is not proof of every interpreter implementation, OS release, architecture, or environment. Review when Python requirements, classifiers, dependencies, or packaging change. | +| Tested CPython/OS combinations | CPython 3.10, 3.11, 3.12, and 3.13 on Ubuntu, Windows, and macOS. | [CI matrix](.github/workflows/ci.yml) | CI tests this 3×4 matrix; it does not create a universal claim for other Python implementations or operating-system versions. Review when the matrix, platform-sensitive code, or supported Python range changes. | +| Release package artifact | Wheel and source distribution are built and checked on Ubuntu with Python 3.12 in CI; release package gate uses Ubuntu with Python 3.13. | [CI workflow](.github/workflows/ci.yml), [release workflow](.github/workflows/release.yml) | Artifact build validation is Ubuntu-only; runtime testing remains the separate CPython/OS matrix. Review when build backend, package metadata, or release workflow changes. | +| Docker image and published tags | Dockerfile image is built and liveness/readiness probes are exercised in Ubuntu CI; release publishes `:v` and `:latest`. | [`Dockerfile`](Dockerfile), [CI workflow](.github/workflows/ci.yml), [release workflow](.github/workflows/release.yml), [`RELEASE.md`](RELEASE.md) | Docker image build/probe coverage is Ubuntu CI only. This does not claim Docker/Desktop/engine portability on every host or orchestrator. Review when Dockerfile, probes, entrypoint, tags, or release workflow changes. | +| Docker Compose example | Checked-in Compose service defines a hardened single service, read-only data mount, JSON logging, and a liveness healthcheck. | [`docker-compose.yml`](docker-compose.yml), [`DEPLOYMENT.md`](DEPLOYMENT.md) | An operational example, not a separately validated universal support target in CI; the default mount is read-only, so production uploads require an explicitly configured writable volume and authentication per `DEPLOYMENT.md`. Review when Compose configuration, documented command, mounts, or health behavior changes. | +| Kubernetes manifests | Default Kustomize assets define one hardened pod and internal ClusterIP Service; ingress and ServiceMonitor are optional. | [`deploy/k8s/README.md`](deploy/k8s/README.md), [`deploy/k8s/`](deploy/k8s/), [`DEPLOYMENT.md`](DEPLOYMENT.md) | Checked-in operational examples; CI does not prove a Kubernetes-version, ingress-controller, storage-provider, or Prometheus-operator support matrix. Review when manifests, probes, volume semantics, or documented prerequisites change. | +| systemd, reverse proxy, and monitoring assets | Checked-in Linux systemd unit, Nginx/Caddy/Traefik blueprints, and monitoring configurations document their required assumptions. | [`deploy/systemd/README.md`](deploy/systemd/README.md), [`deploy/reverse-proxy/`](deploy/reverse-proxy/), [`deploy/monitoring/`](deploy/monitoring/), [`DEPLOYMENT.md`](DEPLOYMENT.md) | Operational examples with differing host/controller dependencies; they are not universal support claims or CI-validated deployment combinations. Review when an asset, required prerequisite, bind address, endpoint, or proxy behavior changes. | + +## Contribution, pull-request, and release checklist + +### Issue and pull request + +For any change that may affect a public surface, the author MUST: + +- identify the affected surface from this policy and whether the change is + compatible, deprecated, removed, or excluded; +- link direct evidence for the current behavior and state the affected + Python/OS/container/deployment row from the support matrix; +- document user-visible migration, warnings, output/HTTP/metric/log changes, and + exception approval when applicable; +- update the canonical documentation and focused tests appropriate to the changed + contract; and +- state the compatibility implication and platform impact in the pull request, + consistent with the repository's [pull-request template](.github/PULL_REQUEST_TEMPLATE.md). + +### Release review + +Before releasing a change with compatibility impact, maintainers MUST confirm that +its deprecation notice or exception is recorded, migration instructions are current, +and release notes describe the user-visible change. Follow the required release +commands and gates in [RELEASE.md](RELEASE.md); this policy does not duplicate +those mechanics. + +## Worked scenario: retiring a CLI option + +Suppose a future release replaces a public `blaze serve --old-option` with +`--new-option`. This example does not deprecate either option today. + +1. The proposal identifies `--old-option` as a CLI compatibility surface, links the + current Click command and e2e evidence, and states which CPython/OS and + container/deployment matrix rows could be affected. +2. The deprecation release continues to accept `--old-option`, documents + `--new-option` as its replacement, emits an actionable warning without + corrupting `--json` output, and states the planned removal release or condition + in the changelog and CLI documentation. +3. During the announced overlap, focused tests cover both the existing option and + the migration behavior; release review carries the notice into release notes. +4. At the stated removal point, the implementation, alias, old-option docs, and + old-only tests are removed together. The release notes identify the removal and + point users to `--new-option`. + +An analogous HTTP endpoint retirement preserves the old method/path/status and +representation during the announced window, publishes the replacement endpoint +and migration instructions, and records any immediate removal as an approved +exception. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82cdcc3..d73d76d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,14 @@ uv run pytest -m unit -q uv run pytest -m "integration and not slow" -q ``` +For one portable end-to-end check of local request handling, run: + +```console +uv run python scripts/contributor_smoke.py +``` + +It creates deterministic fixture data in a temporary directory, starts an authenticated BlazeServe listener on `127.0.0.1` with an OS-selected port, verifies a static response, byte range, upload/readback, and live/readiness probes, then removes all temporary state. Its one-line JSON output contains only logical fixture metadata and response summaries; it does not expose credentials or temporary paths. + Run the full quality gate before handoff: ```console @@ -89,6 +97,31 @@ uv run pytest -n auto -q --cov=blazeserve --cov-report=xml --cov-report=term-mis Coverage is branch-aware and must remain at least 85%. Behavior changes should include tests where practical. Use `uv run pre-commit run --all-files` when pre-commit is available. To install the optional hooks, run `uv run pre-commit install`. GNU Make is optional; the existing `make install`, `make test`, `make lint`, `make typecheck`, `make check`, `make build`, `make pre-commit`, and `make clean` shortcuts are available where supported. The uv commands above work without Make, including on Windows. +## Offline deployment configuration validation + +The CI `Offline Deployment Configuration Validation` job only parses, renders, and validates checked-in deployment files. It never starts BlazeServe or a proxy, applies to a cluster, writes tuning values, or needs credentials. Reproduce its checks on a POSIX host with Docker Engine, Docker Compose **v2.30.3**, `systemd-analyze` from the target Linux distribution, and these exact validator images: `registry.k8s.io/kubectl:v1.32.2`, `mikefarah/yq:4.45.4`, `bash:5.2.37`, `python:3.13.2-alpine3.21`, `prom/prometheus:v3.4.0`, `nginx:1.27.4-alpine`, `caddy:2.9.1-alpine`, and `traefik:v3.3.4`. + +From the repository root, use read-only mounts for every containerized check: + +```bash +docker compose -f docker-compose.yml config --quiet +docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work registry.k8s.io/kubectl:v1.32.2 kustomize deploy/k8s +docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work mikefarah/yq:4.45.4 eval-all -e '.' deploy/k8s/*.yaml >/dev/null +docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly bash:5.2.37 -n /work/deploy/linux-tuning/tuning.sh +docker run --rm --mount type=bind,src="$PWD/deploy/monitoring",dst=/etc/prometheus,readonly --entrypoint /bin/promtool prom/prometheus:v3.4.0 check config /etc/prometheus/prometheus.yml +docker run --rm --mount type=bind,src="$PWD/deploy/monitoring",dst=/etc/prometheus,readonly --entrypoint /bin/promtool prom/prometheus:v3.4.0 check rules /etc/prometheus/alerts.yml +docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly python:3.13.2-alpine3.21 python -m json.tool /work/deploy/monitoring/grafana-dashboard.json >/dev/null +docker run --rm --mount type=bind,src="$PWD/deploy/reverse-proxy/nginx.conf",dst=/etc/nginx/nginx.conf,readonly nginx:1.27.4-alpine nginx -t -c /etc/nginx/nginx.conf +docker run --rm --mount type=bind,src="$PWD/deploy/reverse-proxy/Caddyfile",dst=/etc/caddy/Caddyfile,readonly caddy:2.9.1-alpine caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +timeout 5s docker run --rm --mount type=bind,src="$PWD/deploy/reverse-proxy",dst=/etc/traefik,readonly --entrypoint traefik traefik:v3.3.4 --configFile=/etc/traefik/traefik.yml +docker run --rm --mount type=bind,src="$PWD",dst=/work,readonly --workdir /work mikefarah/yq:4.45.4 eval -e '.' deploy/reverse-proxy/traefik-dynamic.yml >/dev/null +systemd-analyze verify deploy/systemd/blazeserve.service +``` + +CI additionally asserts that the default `kubectl kustomize deploy/k8s` render contains only `Deployment` and `Service`, and uses an inline Python format validator for non-comment tuning lines: `limits.conf` must contain `blazeserve soft|hard nofile|nproc positive-integer`; `sysctl-blazeserve.conf` must contain `lowercase/digit/dot/hyphen key = non-empty whitespace-separated value`. `systemd-analyze` is intentionally runner-/host-provided rather than container-pinned, so its diagnostics can vary with the Ubuntu runner or local target distribution; it validates unit syntax and verifies that `ExecStart` is executable (locally stub with `sudo ln -sf /bin/true /usr/local/bin/blaze` if not yet installed). Nginx, Caddy, and Prometheus receive their validation subcommands; Traefik verifies static configuration on startup. + +To confirm that a validator catches a defect, make a temporary malformed local copy or change of the relevant configuration, run its corresponding command and confirm it fails, then revert the temporary change before committing. Do not add malformed fixtures to the repository. + ## Pull requests - Create a feature branch from `main` and keep the change focused. @@ -130,3 +163,4 @@ Use `area:*` labels to identify the affected subsystem and `platform:*` labels f - [Support](SUPPORT.md) - [Governance](GOVERNANCE.md) - [Security policy](SECURITY.md) +- [Compatibility and deprecation policy](COMPATIBILITY.md) diff --git a/README.md b/README.md index 1190ba4..bbb7b9d 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,14 @@ blaze benchmark --url http://127.0.0.1:8000 --size-mb 100 Explicit `--url` mode never starts a replacement server. A connection-refused error means the target server or port is unavailable; confirm that the server is running and that its port matches `--url`. +For CI, request one machine-readable result with no progress or result-table text on standard output: + +```console +blaze benchmark --size-mb 100 --json +``` + +The JSON object has `base_url` (the normalized URL prefix), integer `requested_bytes` and `downloaded_bytes`, and numeric `elapsed_seconds` and `throughput_mib_per_second`. Byte counts are bytes; throughput is MiB/s. + Throughput, latency, and memory usage depend on the host kernel, storage, network, TLS, and reverse-proxy configuration. Publish benchmark figures only with the command, workload, machine specifications, and comparison methodology used to obtain them. @@ -222,16 +230,27 @@ blaze send archive.tar.gz --port 8443 --tls-cert cert.pem --tls-key key.pem # Check a relative directory and port blaze doctor data --port 8080 +# Emit deployment diagnostics as JSON (POSIX shell) +blaze doctor data --port 8080 --json + +# Emit deployment diagnostics as JSON (PowerShell) +blaze doctor .\data --port 8080 --json + # Run a self-contained benchmark with a temporary loopback-only server blaze benchmark # Benchmark the existing server at this URL blaze benchmark --url http://127.0.0.1:8000 --size-mb 200 +# Emit one CI-friendly benchmark JSON object +blaze benchmark --size-mb 200 --json + # Print machine-readable version data blaze version --json ``` +`blaze doctor --json` writes one object containing an absolute `path`, integer `port`, boolean `success`, and `checks`. Each check has an `id`, `outcome`, and `details`; IDs are `base_path`, `port_binding`, `zero_copy_io`, and `sequential_read_ahead`. Outcomes are `pass`, `fail`, or `fallback`. Optional unavailable OS optimizations use `fallback` and do not make the diagnostic fail. + ## 📦 Production Deployment Workflows diff --git a/blazeserve/cli.py b/blazeserve/cli.py index 1199a30..96d3645 100644 --- a/blazeserve/cli.py +++ b/blazeserve/cli.py @@ -463,62 +463,123 @@ def version_cmd(json_output: bool = False) -> None: @click.option( "-p", "--port", - type=click.IntRange(1, 65535), + type=click.IntRange(0, 65535), default=8000, help="Port to check availability.", ) -def doctor_cmd(path: str, port: int) -> None: +@click.option("--json", "json_output", is_flag=True, help="Display machine-readable JSON.") +def doctor_cmd(path: str, port: int, json_output: bool) -> None: """Run production readiness diagnostics on paths, ports, and OS capabilities.""" + import json import socket from rich.table import Table - tbl = Table(title="⚡ BlazeServe Production Diagnostics", border_style="cyan") - tbl.add_column("Component", style="bold") - tbl.add_column("Status", style="bold") - tbl.add_column("Details") - all_ok = True abs_p = os.path.abspath(path) + checks: list[dict[str, str]] = [] # Check 1: Base directory if os.path.isdir(abs_p) and os.access(abs_p, os.R_OK): - tbl.add_row("Base Path", "[green]OK[/]", f"Readable directory: {abs_p}") + checks.append( + { + "id": "base_path", + "outcome": "pass", + "details": f"Readable directory: {abs_p}", + } + ) else: - tbl.add_row("Base Path", "[red]FAIL[/]", f"Cannot read: {abs_p}") + checks.append( + { + "id": "base_path", + "outcome": "fail", + "details": f"Cannot read: {abs_p}", + } + ) all_ok = False + # Check 2: Port availability with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: s.bind(("127.0.0.1", port)) - tbl.add_row("Port Binding", "[green]OK[/]", f"Port {port} is free to bind") + checks.append( + { + "id": "port_binding", + "outcome": "pass", + "details": f"Port {port} is free to bind", + } + ) except OSError as e: - tbl.add_row( - "Port Binding", - "[red]FAIL[/]", - f"Port {port} could not be bound: {e}. {_port_diagnostic_hint(port)}", + checks.append( + { + "id": "port_binding", + "outcome": "fail", + "details": f"Port {port} could not be bound: {e}. {_port_diagnostic_hint(port)}", + } ) all_ok = False # Check 3: Zero-Copy Kernel Sendfile has_sendfile = hasattr(os, "sendfile") or hasattr(socket.socket, "sendfile") - status_str = "[green]ENABLED[/]" if has_sendfile else "[yellow]FALLBACK[/]" - details_str = ( - "Zero-copy kernel sendfile available" - if has_sendfile - else "Using mmap/buffered I/O fallback" + checks.append( + { + "id": "zero_copy_io", + "outcome": "pass" if has_sendfile else "fallback", + "details": ( + "Zero-copy kernel sendfile available" + if has_sendfile + else "Using mmap/buffered I/O fallback" + ), + } ) - tbl.add_row("Zero-Copy I/O", status_str, details_str) # Check 4: Sequential Read Ahead has_fadvise = hasattr(os, "posix_fadvise") - fadvise_status = "[green]YES[/]" if has_fadvise else "[yellow]FALLBACK[/]" - fadvise_details = ( - "POSIX_FADV_SEQUENTIAL optimization" - if has_fadvise - else "Not available on this platform; using regular sequential reads" + checks.append( + { + "id": "sequential_read_ahead", + "outcome": "pass" if has_fadvise else "fallback", + "details": ( + "POSIX_FADV_SEQUENTIAL optimization" + if has_fadvise + else "Not available on this platform; using regular sequential reads" + ), + } + ) + + if json_output: + click.echo( + json.dumps( + { + "path": abs_p, + "port": port, + "success": all_ok, + "checks": checks, + } + ) + ) + if not all_ok: + raise SystemExit(1) + return + + tbl = Table(title="⚡ BlazeServe Production Diagnostics", border_style="cyan") + tbl.add_column("Component", style="bold") + tbl.add_column("Status", style="bold") + tbl.add_column("Details") + component_rows = ( + ("Base Path", "OK", "FAIL"), + ("Port Binding", "OK", "FAIL"), + ("Zero-Copy I/O", "ENABLED", "FALLBACK"), + ("Sequential Read Ahead", "YES", "FALLBACK"), ) - tbl.add_row("Sequential Read Ahead", fadvise_status, fadvise_details) + for check, (component, passed_status, other_status) in zip(checks, component_rows, strict=True): + if check["outcome"] == "pass": + status = f"[green]{passed_status}[/]" + elif check["outcome"] == "fail": + status = f"[red]{other_status}[/]" + else: + status = f"[yellow]{other_status}[/]" + tbl.add_row(component, status, check["details"]) console.print(tbl) if not all_ok: @@ -538,7 +599,8 @@ def doctor_cmd(path: str, port: int) -> None: show_default=True, help="Size of test download in MB.", ) -def benchmark_cmd(url: str | None, size_mb: int) -> None: +@click.option("--json", "json_output", is_flag=True, help="Display machine-readable JSON.") +def benchmark_cmd(url: str | None, size_mb: int, json_output: bool) -> None: """Run a speed benchmark against a BlazeServe server.""" import tempfile import threading @@ -574,6 +636,7 @@ def benchmark_cmd(url: str | None, size_mb: int) -> None: benchmark_origin = "" expected_bytes = size_mb * 1024 * 1024 + benchmark_log_json: bool | None = False if json_output else None benchmark_directory = None benchmark_server = None benchmark_thread = None @@ -586,6 +649,7 @@ def benchmark_cmd(url: str | None, size_mb: int) -> None: host="127.0.0.1", port=0, base=benchmark_directory.name, + log_json=benchmark_log_json, ) benchmark_thread = threading.Thread( target=benchmark_server.serve_forever, @@ -597,47 +661,67 @@ def benchmark_cmd(url: str | None, size_mb: int) -> None: test_url = f"{benchmark_origin}/__speed__?bytes={expected_bytes}" - console.print(f"[cyan]Benchmarking:[/] {benchmark_origin}") - console.print(f"[cyan]Download size:[/] {size_mb} MB\n") - - # Warn for very large benchmarks - if size_mb > 500: - console.print( - f"[yellow]⚠ Warning:[/] Large benchmark size ({size_mb} MB) " - f"may impact server performance\n" - ) - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - DownloadColumn(), - TransferSpeedColumn(), - console=console, - ) as progress: - task = progress.add_task("[cyan]Downloading...", total=expected_bytes) - + if json_output: start_time = time.perf_counter() downloaded = 0 with urllib.request.urlopen(test_url) as response: chunk_size = 1024 * 1024 # 1MB chunks - while True: - chunk = response.read(chunk_size) - if not chunk: - break + while chunk := response.read(chunk_size): downloaded += len(chunk) - progress.update(task, advance=len(chunk)) - - elapsed = time.perf_counter() - start_time - if downloaded != expected_bytes: - raise click.ClickException( - f"Benchmark download was incomplete: expected {expected_bytes} bytes, " - f"received {downloaded}." + else: + console.print(f"[cyan]Benchmarking:[/] {benchmark_origin}") + console.print(f"[cyan]Download size:[/] {size_mb} MB\n") + + # Warn for very large benchmarks + if size_mb > 500: + console.print( + f"[yellow]⚠ Warning:[/] Large benchmark size ({size_mb} MB) " + f"may impact server performance\n" ) + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + console=console, + ) as progress: + task = progress.add_task("[cyan]Downloading...", total=expected_bytes) + start_time = time.perf_counter() + downloaded = 0 + + with urllib.request.urlopen(test_url) as response: + chunk_size = 1024 * 1024 # 1MB chunks + while chunk := response.read(chunk_size): + downloaded += len(chunk) + progress.update(task, advance=len(chunk)) + + elapsed = time.perf_counter() - start_time + if downloaded != expected_bytes: + raise click.ClickException( + f"Benchmark download was incomplete: expected {expected_bytes} bytes, " + f"received {downloaded}." + ) + # Display results speed_mbps = (downloaded / (1024 * 1024)) / elapsed + if json_output: + import json + + click.echo( + json.dumps( + { + "base_url": benchmark_origin, + "requested_bytes": expected_bytes, + "downloaded_bytes": downloaded, + "elapsed_seconds": elapsed, + "throughput_mib_per_second": speed_mbps, + } + ) + ) + return result_table = Table(show_header=False, box=box.SIMPLE) result_table.add_column(style="bold cyan") diff --git a/blazeserve/handlers.py b/blazeserve/handlers.py index eba31e5..faada62 100644 --- a/blazeserve/handlers.py +++ b/blazeserve/handlers.py @@ -148,7 +148,7 @@ class BlazeHandler(SimpleHTTPRequestHandler): INDEX: list[str] = [] PRECOMPRESS: bool = True MAX_UPLOAD: int = 0 - LOG_JSON: bool = False + LOG_JSON: bool | None = None ZIP_COMPRESSION: int = zipfile.ZIP_STORED _buf: bytearray | None = None @@ -156,8 +156,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, directory=self.BASE, **kwargs) def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - """Override logging to support structured JSON output when configured.""" - if self.LOG_JSON or os.environ.get("BLAZE_LOG_JSON") == "1": + """Emit structured request logs when enabled for this handler or environment.""" + if self.LOG_JSON is True or ( + self.LOG_JSON is None and os.environ.get("BLAZE_LOG_JSON") == "1" + ): req_line = args[0] if args else "-" code = args[1] if len(args) > 1 else "-" size = args[2] if len(args) > 2 else "-" @@ -312,12 +314,16 @@ def do_PUT(self) -> None: self.send_error(HTTPStatus.BAD_REQUEST) return - dst = os.path.abspath(os.path.join(self.BASE, fn)) - if not is_safe_path(self.BASE, dst): + real_base = os.path.realpath(self.BASE) + real_dst = os.path.realpath(os.path.join(self.BASE, fn)) + if not real_dst.startswith(real_base): + self.send_error(HTTPStatus.FORBIDDEN) + return + if not is_safe_path(self.BASE, real_dst): self.send_error(HTTPStatus.FORBIDDEN) return - if os.path.lexists(dst): + if os.path.lexists(real_dst): self.send_error(HTTPStatus.CONFLICT) return @@ -340,7 +346,7 @@ def do_PUT(self) -> None: return try: - with create_upload_file(self.BASE, dst) as out: + with create_upload_file(self.BASE, real_dst) as out: remain = length buf = self._buf or bytearray(self.WINDOW) mv = memoryview(buf) @@ -362,15 +368,16 @@ def do_PUT(self) -> None: return except EOFError: with contextlib.suppress(OSError): - os.unlink(dst) + if real_dst.startswith(real_base) and is_safe_path(self.BASE, real_dst): + os.unlink(real_dst) self.send_error(HTTPStatus.BAD_REQUEST) return except OSError: with contextlib.suppress(OSError): - os.unlink(dst) + if real_dst.startswith(real_base) and is_safe_path(self.BASE, real_dst): + os.unlink(real_dst) self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR) return - self.send_response(HTTPStatus.CREATED) self._cors_headers() self._send_security_headers() @@ -922,11 +929,15 @@ def _zip(self, parsed: Any) -> None: if not raw: self.send_error(HTTPStatus.BAD_REQUEST) return - path = os.path.abspath(os.path.join(self.BASE, raw)) - if not is_safe_path(self.BASE, path): + real_base = os.path.realpath(self.BASE) + real_path = os.path.realpath(os.path.join(self.BASE, raw)) + if not real_path.startswith(real_base): self.send_error(HTTPStatus.FORBIDDEN) return - if not os.path.exists(path): + if not is_safe_path(self.BASE, real_path): + self.send_error(HTTPStatus.FORBIDDEN) + return + if not os.path.exists(real_path): self.send_error(HTTPStatus.NOT_FOUND) return @@ -934,8 +945,16 @@ def _zip(self, parsed: Any) -> None: self._cors_headers() self._send_security_headers() self.send_header("Content-Type", "application/zip") - name = os.path.basename(path.rstrip(os.sep)) or "archive" - self.send_header("Content-Disposition", f'attachment; filename="{name}.zip"') + name = os.path.basename(real_path.rstrip(os.sep)) or "archive" + safe_name = ( + name.replace("\r", "") + .replace("\n", "") + .replace('"', "") + .replace(";", "") + .replace(":", "") + or "archive" + ) + self.send_header("Content-Disposition", f'attachment; filename="{safe_name}.zip"') self.send_header("Cache-Control", "no-store") self.send_header("Connection", "close") self.close_connection = True @@ -949,17 +968,19 @@ def _zip(self, parsed: Any) -> None: cast(IO[bytes], stream), "w", compression=self.ZIP_COMPRESSION, allowZip64=True ) try: - if os.path.isdir(path): - for root, _, files in os.walk(path): + if not real_path.startswith(real_base): + return + if os.path.isdir(real_path): + for root, _, files in os.walk(real_path): for fn in files: ap = os.path.join(root, fn) if not is_safe_path(self.BASE, ap): continue - arc = os.path.relpath(ap, path) + arc = os.path.relpath(ap, real_path) with contextlib.suppress(OSError): z.write(ap, arcname=arc) else: - z.write(path, arcname=os.path.basename(path)) + z.write(real_path, arcname=os.path.basename(real_path)) except _ClientDisconnectedError: pass finally: diff --git a/blazeserve/security.py b/blazeserve/security.py index a121d76..fddb015 100644 --- a/blazeserve/security.py +++ b/blazeserve/security.py @@ -20,9 +20,12 @@ def is_safe_path(base_dir: str, target_path: str) -> bool: real_base = os.path.realpath(base_dir) real_target = os.path.realpath(target_path) try: - return os.path.commonpath((real_base, real_target)) == real_base + if os.path.commonpath((real_base, real_target)) != real_base: + return False except ValueError: return False + base_prefix = real_base if real_base.endswith(os.sep) else real_base + os.sep + return real_target == real_base or real_target.startswith(base_prefix) def create_upload_file(base_dir: str, target_path: str) -> BinaryIO: @@ -33,12 +36,21 @@ def create_upload_file(base_dir: str, target_path: str) -> BinaryIO: ``O_NOFOLLOW`` protects the final component where the platform supports it. """ real_base = os.path.realpath(base_dir) - absolute_target = os.path.abspath(target_path) - if not is_safe_path(real_base, absolute_target): + canonical_target = os.path.realpath(target_path) + if not canonical_target.startswith(real_base): raise UnsafePathError("upload path escapes the configured root") + if not is_safe_path(real_base, canonical_target): + raise UnsafePathError("upload path escapes the configured root") + + parent = os.path.dirname(canonical_target) + if not parent.startswith(real_base): + raise UnsafePathError("upload parent escapes the configured root") + if not is_safe_path(real_base, parent): + raise UnsafePathError("upload parent escapes the configured root") - parent = os.path.dirname(absolute_target) os.makedirs(parent, exist_ok=True) + if not parent.startswith(real_base): + raise UnsafePathError("upload parent escapes the configured root") if not is_safe_path(real_base, parent): raise UnsafePathError("upload parent escapes the configured root") @@ -46,7 +58,7 @@ def create_upload_file(base_dir: str, target_path: str) -> BinaryIO: flags |= getattr(os, "O_BINARY", 0) flags |= getattr(os, "O_NOFOLLOW", 0) try: - fd = os.open(absolute_target, flags, 0o600) + fd = os.open(canonical_target, flags, 0o600) except OSError as exc: if not is_safe_path(real_base, parent): raise UnsafePathError("upload parent changed during creation") from exc diff --git a/blazeserve/server.py b/blazeserve/server.py index 29c80cb..85e929d 100644 --- a/blazeserve/server.py +++ b/blazeserve/server.py @@ -128,9 +128,12 @@ def create_server( precompress: bool = True, max_upload_mb: int = 0, verbose: bool = False, - log_json: bool = False, + log_json: bool | None = None, ) -> BlazeServer: - """Instantiate and configure a high-performance BlazeServer.""" + """Instantiate and configure a high-performance BlazeServer. + + ``log_json=None`` inherits ``BLAZE_LOG_JSON``; explicit booleans override it. + """ if rate_mbps is not None and rate_mbps <= 0: raise ValueError("rate_mbps must be greater than zero when configured") if bool(tls_cert) != bool(tls_key): @@ -150,7 +153,7 @@ class _Handler(BlazeHandler): _Handler.INDEX = list(index or []) _Handler.PRECOMPRESS = bool(precompress) _Handler.MAX_UPLOAD = max(0, int(max_upload_mb)) * 1024 * 1024 - _Handler.LOG_JSON = bool(log_json) + _Handler.LOG_JSON = log_json if auth: if ":" not in auth: diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..5fb11ba --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Contributor-only helper scripts.""" diff --git a/scripts/contributor_smoke.py b/scripts/contributor_smoke.py new file mode 100644 index 0000000..3a6ef49 --- /dev/null +++ b/scripts/contributor_smoke.py @@ -0,0 +1,294 @@ +"""Run a deterministic, local BlazeServe contributor smoke flow. + +Invoke from a checkout with ``uv run python scripts/contributor_smoke.py``. +The script intentionally exposes no persistent configuration, files, listeners, or +credentials: it creates all state below one temporary directory and binds only +loopback on an operating-system-selected port. +""" + +from __future__ import annotations + +import base64 +import contextlib +import hashlib +import json +import secrets +import sys +import tempfile +import threading +import time +from http.client import HTTPConnection, HTTPResponse +from pathlib import Path +from typing import Any + +from blazeserve.server import create_server + +_HOST = "127.0.0.1" +_CLIENT_TIMEOUT_SECONDS = 2.0 +_READY_TIMEOUT_SECONDS = 3.0 +_POLL_INTERVAL_SECONDS = 0.02 + +# These payloads and names form the portable logical fixture manifest. Do not +# add host metadata (timestamps, permissions, or absolute paths) to the result. +_FIXTURES = { + "static/hello.txt": b"Hello from the BlazeServe contributor smoke fixture.\n", + "static/nested/note.txt": b"Nested fixture for deterministic local serving.\n", +} +_UPLOAD_PATH = "uploads/contributor-upload.bin" +_UPLOAD_CONTENT = b"Authenticated contributor upload payload.\n" + + +class SmokeError(RuntimeError): + """Raised when the smoke flow's observable HTTP contract is not met.""" + + +def build_fixture(root: Path) -> dict[str, dict[str, object]]: + """Create deterministic fixtures under *root* and return their logical manifest.""" + manifest: dict[str, dict[str, object]] = {} + for relative_path, content in _FIXTURES.items(): + destination = root / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + manifest[relative_path] = { + "path": relative_path, + "bytes": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + } + return manifest + + +def _authorization_header(username: str, password: str) -> str: + token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + return f"Basic {token}" + + +def _request( + host: str, + port: int, + method: str, + path: str, + *, + body: bytes | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, bytes]: + """Make one bounded request and return only status plus response bytes.""" + connection = HTTPConnection(host, port, timeout=_CLIENT_TIMEOUT_SECONDS) + try: + connection.request(method, path, body=body, headers=headers or {}) + response: HTTPResponse = connection.getresponse() + return response.status, response.read() + finally: + connection.close() + + +def _require( + name: str, + status: int, + body: bytes, + *, + expected_status: int, + expected_body: bytes | None = None, +) -> dict[str, object]: + """Validate a response without retaining sensitive request metadata.""" + if status != expected_status: + raise SmokeError(f"{name}: expected status {expected_status}, received {status}") + if expected_body is not None and body != expected_body: + raise SmokeError(f"{name}: response body did not match the deterministic fixture") + return {"name": name, "status": status, "bytes": len(body)} + + +def _wait_until_ready(host: str, port: int, headers: dict[str, str]) -> None: + """Poll authenticated readiness with a bounded clock deadline.""" + deadline = time.monotonic() + _READY_TIMEOUT_SECONDS + last_error: BaseException | None = None + while time.monotonic() < deadline: + try: + status, _ = _request(host, port, "GET", "/__ready__", headers=headers) + if status == 200: + return + last_error = SmokeError(f"readiness probe returned status {status}") + except (OSError, TimeoutError) as exc: + last_error = exc + time.sleep(_POLL_INTERVAL_SECONDS) + if last_error is None: + raise SmokeError("readiness probe timed out") + raise SmokeError("server did not become ready before the bounded deadline") from last_error + + +def _clear_handler_credentials(server: Any) -> None: + """Remove the credential tuple held on the generated request handler class.""" + handler = getattr(server, "RequestHandlerClass", None) + if handler is not None: + handler.AUTH_PAIR = None + + +def _sanitized_failure(exc: BaseException) -> dict[str, str]: + """Return useful diagnostics without echoing dependency exception text.""" + if isinstance(exc, SmokeError): + detail = str(exc) + elif isinstance(exc, KeyboardInterrupt): + detail = "interrupted" + else: + detail = f"unexpected {type(exc).__name__}" + return {"error": "smoke failed", "detail": detail} + + +def run_smoke() -> dict[str, object]: + """Run the complete ephemeral smoke flow and return sanitized logical evidence. + + Raises: + SmokeError: A server startup, response, or body expectation failed. + """ + server: Any | None = None + worker: threading.Thread | None = None + credential = bytearray(secrets.token_urlsafe(24).encode("ascii")) + username = "contributor" + result: dict[str, object] | None = None + try: + with tempfile.TemporaryDirectory(prefix="blazeserve-contributor-smoke-") as temporary_root: + root = Path(temporary_root) + manifest = build_fixture(root) + password = credential.decode("ascii") + try: + server = create_server( + host=_HOST, + port=0, + base=str(root), + auth=f"{username}:{password}", + max_upload_mb=1, + timeout=5, + log_json=False, + ) + finally: + password = "" + + endpoint = f"http://{_HOST}:{server.server_port}" + auth_headers = { + "Authorization": _authorization_header(username, credential.decode("ascii")) + } + upload_headers: dict[str, str] = {} + worker = threading.Thread( + target=server.serve_forever, + name="blazeserve-contributor-smoke", + daemon=True, + ) + worker.start() + _wait_until_ready(_HOST, server.server_port, auth_headers) + + try: + scenarios = [] + status, body = _request( + _HOST, server.server_port, "GET", "/static/hello.txt", headers=auth_headers + ) + scenarios.append( + _require( + "static_get", + status, + body, + expected_status=200, + expected_body=_FIXTURES["static/hello.txt"], + ) + ) + + status, body = _request( + _HOST, + server.server_port, + "GET", + "/static/hello.txt", + headers={**auth_headers, "Range": "bytes=0-4"}, + ) + scenarios.append( + _require( + "byte_range", status, body, expected_status=206, expected_body=b"Hello" + ) + ) + + upload_headers = {**auth_headers, "Content-Length": str(len(_UPLOAD_CONTENT))} + status, body = _request( + _HOST, + server.server_port, + "PUT", + f"/__upload__/{_UPLOAD_PATH}", + body=_UPLOAD_CONTENT, + headers=upload_headers, + ) + scenarios.append( + _require("authenticated_upload", status, body, expected_status=201) + ) + + status, body = _request( + _HOST, + server.server_port, + "GET", + f"/{_UPLOAD_PATH}", + headers=auth_headers, + ) + scenarios.append( + _require( + "authenticated_readback", + status, + body, + expected_status=200, + expected_body=_UPLOAD_CONTENT, + ) + ) + + status, body = _request( + _HOST, server.server_port, "GET", "/__live__", headers=auth_headers + ) + scenarios.append(_require("live", status, body, expected_status=200)) + + status, body = _request( + _HOST, server.server_port, "GET", "/__ready__", headers=auth_headers + ) + scenarios.append(_require("ready", status, body, expected_status=200)) + finally: + auth_headers.clear() + upload_headers.clear() + if server is not None: + if worker is not None and worker.is_alive(): + with contextlib.suppress(Exception): + server.shutdown() + _clear_handler_credentials(server) + with contextlib.suppress(Exception): + server.server_close() + if worker is not None: + worker.join(timeout=_CLIENT_TIMEOUT_SECONDS) + + result = {"endpoint": endpoint, "manifest": manifest, "scenarios": scenarios} + except KeyboardInterrupt: + raise + except SmokeError: + raise + except BaseException as exc: + raise SmokeError(f"unexpected {type(exc).__name__}") from None + finally: + if server is not None: + if worker is not None and worker.is_alive(): + with contextlib.suppress(Exception): + server.shutdown() + _clear_handler_credentials(server) + with contextlib.suppress(Exception): + server.server_close() + if worker is not None: + worker.join(timeout=_CLIENT_TIMEOUT_SECONDS) + credential.clear() + + if result is None: + raise SmokeError("smoke flow did not produce a result") + return result + + +def main() -> int: + """Print one sanitized JSON report and return a process exit status.""" + try: + result = run_smoke() + except BaseException as exc: + sys.stderr.write(f"{json.dumps(_sanitized_failure(exc), sort_keys=True)}\n") + return 1 + sys.stdout.write(f"{json.dumps(result, sort_keys=True)}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index efdcf66..702f44a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,11 +15,17 @@ from blazeserve.server import create_server -def wait_for_port(host: str, port: int, timeout: float = 3.0) -> bool: +def wait_for_port( + host: str, + port: int, + timeout: float = 3.0, + family: socket.AddressFamily | None = None, +) -> bool: """Poll socket until server accepts connections.""" + resolved_family = family or (socket.AF_INET6 if ":" in host else socket.AF_INET) start = time.perf_counter() while time.perf_counter() - start < timeout: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + with socket.socket(resolved_family, socket.SOCK_STREAM) as s: s.settimeout(0.1) if s.connect_ex((host, port)) == 0: return True @@ -27,6 +33,16 @@ def wait_for_port(host: str, port: int, timeout: float = 3.0) -> bool: return False +@pytest.fixture +def ipv6_loopback() -> None: + """Skip live IPv6 tests when the local loopback address cannot bind.""" + try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s: + s.bind(("::1", 0)) + except OSError as exc: + pytest.skip(f"IPv6 loopback unavailable: {exc}") + + @pytest.fixture def test_dir(tmp_path: Path) -> Path: """Create a temporary directory with test files and subdirectories.""" @@ -57,7 +73,7 @@ def _spawn(**kwargs: Any) -> tuple[str, int]: t = threading.Thread(target=httpd.serve_forever, daemon=True) t.start() - if not wait_for_port(host, actual_port, timeout=3.0): + if not wait_for_port(host, actual_port, timeout=3.0, family=httpd.socket.family): httpd.shutdown() httpd.server_close() raise RuntimeError(f"Server failed to bind on port {actual_port}") diff --git a/tests/e2e/test_cli_commands.py b/tests/e2e/test_cli_commands.py index 7b5064a..9b23571 100644 --- a/tests/e2e/test_cli_commands.py +++ b/tests/e2e/test_cli_commands.py @@ -1,5 +1,7 @@ """End-to-end tests for doctor, checksum, and CLI error handling.""" +import json +import socket import urllib.error import urllib.request from pathlib import Path @@ -19,6 +21,45 @@ def test_cli_doctor_valid_path(tmp_path: Path): assert "Diagnostics" in result.output or "OK" in result.output +@pytest.mark.e2e +def test_cli_doctor_json_reports_complete_machine_readable_diagnostics(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(cli, ["doctor", str(tmp_path), "--port", "0", "--json"]) + + assert result.exit_code == 0 + report = json.loads(result.output) + assert report["path"] == str(tmp_path.resolve()) + assert report["port"] == 0 + assert report["success"] is True + assert [check["id"] for check in report["checks"]] == [ + "base_path", + "port_binding", + "zero_copy_io", + "sequential_read_ahead", + ] + assert all(set(check) == {"id", "outcome", "details"} for check in report["checks"]) + assert all(check["outcome"] in {"pass", "fail", "fallback"} for check in report["checks"]) + + +@pytest.mark.e2e +def test_cli_doctor_json_reports_required_failures_before_exiting(tmp_path: Path): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as occupied_socket: + occupied_socket.bind(("127.0.0.1", 0)) + port = occupied_socket.getsockname()[1] + runner = CliRunner() + result = runner.invoke( + cli, ["doctor", str(tmp_path / "missing"), "--port", str(port), "--json"] + ) + + assert result.exit_code != 0 + report = json.loads(result.output) + assert report["success"] is False + assert report["path"] == str((tmp_path / "missing").resolve()) + assert report["port"] == port + assert [check["outcome"] for check in report["checks"][:2]] == ["fail", "fail"] + assert len(report["checks"]) == 4 + + @pytest.mark.e2e def test_cli_doctor_invalid_path(): runner = CliRunner() @@ -83,6 +124,32 @@ def test_cli_benchmark_starts_temporary_server(): assert "1.00 MB" in result.output +@pytest.mark.e2e +def test_cli_benchmark_json_emits_only_one_typed_result(monkeypatch): + monkeypatch.setenv("BLAZE_LOG_JSON", "1") + runner = CliRunner() + result = runner.invoke(cli, ["benchmark", "--size-mb", "1", "--json"]) + + assert result.exit_code == 0 + output_lines = result.output.splitlines() + assert len(output_lines) == 1 + report = json.loads(output_lines[0]) + assert set(report) == { + "base_url", + "requested_bytes", + "downloaded_bytes", + "elapsed_seconds", + "throughput_mib_per_second", + } + assert isinstance(report["base_url"], str) + assert report["requested_bytes"] == 1024 * 1024 + assert report["downloaded_bytes"] == 1024 * 1024 + assert isinstance(report["elapsed_seconds"], float) + assert report["elapsed_seconds"] > 0 + assert isinstance(report["throughput_mib_per_second"], float) + assert report["throughput_mib_per_second"] > 0 + + @pytest.mark.e2e def test_cli_benchmark_temporary_server_does_not_serve_cwd(tmp_path: Path, monkeypatch): sentinel = tmp_path / "private-sentinel.txt" diff --git a/tests/integration/test_caching_and_headers.py b/tests/integration/test_caching_and_headers.py index 1434889..083202f 100644 --- a/tests/integration/test_caching_and_headers.py +++ b/tests/integration/test_caching_and_headers.py @@ -1,5 +1,6 @@ """Integration tests for RFC 7232 caching, security headers, and CORS.""" +import gzip from collections.abc import Callable from http.client import HTTPConnection from pathlib import Path @@ -7,6 +8,47 @@ import pytest +def _header_token_set(headers, name: str, *, case_sensitive: bool) -> set[str]: + """Return comma-separated header tokens without depending on their order.""" + tokens = { + token.strip() + for value in headers.get_all(name, []) + for token in value.split(",") + if token.strip() + } + return tokens if case_sensitive else {token.casefold() for token in tokens} + + +def _assert_cors_headers(response, origin: str) -> None: + assert response.headers.get("Access-Control-Allow-Origin") == origin + assert _header_token_set( + response.headers, "Access-Control-Allow-Methods", case_sensitive=True + ) == {"GET", "HEAD", "OPTIONS", "PUT", "POST"} + assert _header_token_set( + response.headers, "Access-Control-Allow-Headers", case_sensitive=False + ) == {"range", "content-type", "authorization", "x-request-id"} + assert _header_token_set( + response.headers, "Access-Control-Expose-Headers", case_sensitive=False + ) == { + "accept-ranges", + "content-length", + "content-range", + "etag", + "last-modified", + "x-request-id", + "x-ratelimit-limit", + "x-ratelimit-remaining", + } + + +@pytest.fixture +def configured_cors_server( + server_factory: Callable[..., tuple[str, int]], test_dir: Path +) -> tuple[str, int]: + """Serve test files with a non-default CORS origin.""" + return server_factory(base=str(test_dir), cors=True, cors_origin="https://client.example.test") + + @pytest.mark.integration def test_etag_and_last_modified_present(server: tuple[str, int]): host, port = server @@ -136,15 +178,79 @@ def test_security_headers_present(server: tuple[str, int]): @pytest.mark.integration -def test_cors_preflight_options(server: tuple[str, int]): - host, port = server +def test_cors_preflight_options(configured_cors_server: tuple[str, int]): + host, port = configured_cors_server conn = HTTPConnection(host, port) try: conn.request("OPTIONS", "/test.txt") resp = conn.getresponse() assert resp.status == 204 - assert "Access-Control-Allow-Methods" in resp.headers - assert "GET, HEAD, OPTIONS, PUT, POST" in resp.headers["Access-Control-Allow-Methods"] + assert resp.read() == b"" + _assert_cors_headers(resp, "https://client.example.test") + finally: + conn.close() + + +@pytest.mark.integration +def test_cors_get_and_head_vary_by_origin(configured_cors_server: tuple[str, int]): + host, port = configured_cors_server + conn = HTTPConnection(host, port) + try: + conn.request("GET", "/test.txt") + get_response = conn.getresponse() + assert get_response.status == 200 + assert get_response.read() == b"Hello, BlazeServe!" + _assert_cors_headers(get_response, "https://client.example.test") + assert _header_token_set(get_response.headers, "Vary", case_sensitive=False) == {"origin"} + + conn.request("HEAD", "/test.txt") + head_response = conn.getresponse() + assert head_response.status == 200 + assert head_response.read() == b"" + _assert_cors_headers(head_response, "https://client.example.test") + assert _header_token_set(head_response.headers, "Vary", case_sensitive=False) == {"origin"} + finally: + conn.close() + + +@pytest.mark.integration +def test_cors_gzip_get_varies_by_origin_and_encoding(configured_cors_server: tuple[str, int]): + host, port = configured_cors_server + conn = HTTPConnection(host, port) + try: + conn.request("GET", "/test.txt", headers={"Accept-Encoding": "gzip"}) + resp = conn.getresponse() + assert resp.status == 200 + assert resp.headers.get("Content-Encoding") == "gzip" + assert gzip.decompress(resp.read()) == b"Precompressed gzip content" + _assert_cors_headers(resp, "https://client.example.test") + assert _header_token_set(resp.headers, "Vary", case_sensitive=False) == { + "origin", + "accept-encoding", + } + finally: + conn.close() + + +@pytest.mark.integration +def test_cors_disabled_omits_cors_headers_and_origin_vary( + server_factory: Callable[..., tuple[str, int]], test_dir: Path +): + host, port = server_factory(base=str(test_dir), cors=False) + conn = HTTPConnection(host, port) + try: + conn.request("GET", "/test.txt") + resp = conn.getresponse() + assert resp.status == 200 + assert resp.read() == b"Hello, BlazeServe!" + for name in ( + "Access-Control-Allow-Origin", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Expose-Headers", + ): + assert resp.headers.get(name) is None + assert "origin" not in _header_token_set(resp.headers, "Vary", case_sensitive=False) finally: conn.close() diff --git a/tests/integration/test_http_serving.py b/tests/integration/test_http_serving.py index ab6fae7..c8e585d 100644 --- a/tests/integration/test_http_serving.py +++ b/tests/integration/test_http_serving.py @@ -21,6 +21,21 @@ def test_serve_file(server: tuple[str, int]): conn.close() +@pytest.mark.integration +def test_serve_file_over_ipv6_loopback( + server_factory: Callable[..., tuple[str, int]], test_dir: Path, ipv6_loopback: None +): + host, port = server_factory(host="::1", port=0, base=str(test_dir)) + conn = HTTPConnection(host, port) + try: + conn.request("GET", "/test.txt") + resp = conn.getresponse() + assert resp.status == 200 + assert resp.read() == b"Hello, BlazeServe!" + finally: + conn.close() + + @pytest.mark.integration def test_serve_large_binary(server: tuple[str, int]): host, port = server diff --git a/tests/integration/test_range_requests.py b/tests/integration/test_range_requests.py index b612a1b..5a116ec 100644 --- a/tests/integration/test_range_requests.py +++ b/tests/integration/test_range_requests.py @@ -5,6 +5,16 @@ import pytest +def _header_token_set(headers, name: str) -> set[str]: + """Return case-insensitive comma-separated header tokens.""" + return { + token.strip().casefold() + for value in headers.get_all(name, []) + for token in value.split(",") + if token.strip() + } + + @pytest.mark.integration def test_single_byte_range(server: tuple[str, int]): host, port = server @@ -19,6 +29,25 @@ def test_single_byte_range(server: tuple[str, int]): conn.close() +@pytest.mark.integration +def test_range_request_with_gzip_acceptance_stays_uncompressed(server: tuple[str, int]): + host, port = server + conn = HTTPConnection(host, port) + try: + conn.request( + "GET", + "/test.txt", + headers={"Range": "bytes=0-4", "Accept-Encoding": "gzip"}, + ) + resp = conn.getresponse() + assert resp.status == 206 + assert resp.read() == b"Hello" + assert resp.headers.get("Content-Encoding") is None + assert _header_token_set(resp.headers, "Vary") == {"origin"} + finally: + conn.close() + + @pytest.mark.integration def test_suffix_byte_range(server: tuple[str, int]): host, port = server diff --git a/tests/unit/test_contributor_smoke.py b/tests/unit/test_contributor_smoke.py new file mode 100644 index 0000000..725fd66 --- /dev/null +++ b/tests/unit/test_contributor_smoke.py @@ -0,0 +1,142 @@ +"""Consumer-observable checks for the contributor smoke environment.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +_SMOKE_PATH = Path(__file__).parents[2] / "scripts" / "contributor_smoke.py" +_SPEC = importlib.util.spec_from_file_location("contributor_smoke", _SMOKE_PATH) +if _SPEC is None or _SPEC.loader is None: + raise RuntimeError("unable to load contributor smoke script") +smoke = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(smoke) + + +@pytest.mark.unit +def test_build_fixture_returns_stable_logical_manifest(tmp_path: Path): + first_root = tmp_path / "first" + second_root = tmp_path / "second" + first_root.mkdir() + second_root.mkdir() + + first = smoke.build_fixture(first_root) + second = smoke.build_fixture(second_root) + + assert first == second + assert first + assert all(isinstance(path, str) for path in first) + + for logical_name, entry in first.items(): + assert set(entry) == {"path", "bytes", "sha256"} + relative_path = Path(entry["path"]) + assert logical_name == entry["path"] + assert not relative_path.is_absolute() + assert ".." not in relative_path.parts + assert isinstance(entry["bytes"], int) + assert entry["bytes"] >= 0 + assert isinstance(entry["sha256"], str) + assert len(entry["sha256"]) == 64 + + content = (first_root / relative_path).read_bytes() + assert entry["bytes"] == len(content) + assert entry["sha256"] == hashlib.sha256(content).hexdigest() + + +def _assert_sanitized(value: object, forbidden: tuple[str, ...]) -> None: + rendered = json.dumps(value, sort_keys=True) + for secret in forbidden: + assert secret not in rendered + assert "authorization" not in rendered.lower() + assert "basic " not in rendered.lower() + + +def _assert_completed_result(result: dict[str, object]) -> None: + assert set(result) >= {"endpoint", "manifest", "scenarios"} + + endpoint = result["endpoint"] + assert isinstance(endpoint, str) + parsed = urlparse(endpoint) + assert parsed.scheme == "http" + assert parsed.hostname == "127.0.0.1" + assert parsed.port is not None + assert 0 < parsed.port <= 65535 + + manifest = result["manifest"] + assert isinstance(manifest, dict) + assert manifest + for entry in manifest.values(): + assert isinstance(entry, dict) + assert set(entry) == {"path", "bytes", "sha256"} + assert not Path(entry["path"]).is_absolute() + + scenarios = result["scenarios"] + assert [scenario["name"] for scenario in scenarios] == [ + "static_get", + "byte_range", + "authenticated_upload", + "authenticated_readback", + "live", + "ready", + ] + for scenario in scenarios: + assert isinstance(scenario, dict) + assert set(scenario) == {"name", "status", "bytes"} + assert isinstance(scenario["status"], int) + assert 200 <= scenario["status"] < 300 + assert isinstance(scenario["bytes"], int) + assert scenario["bytes"] >= 0 + + +@pytest.mark.unit +def test_run_smoke_returns_completed_sanitized_result(): + result = smoke.run_smoke() + + _assert_completed_result(result) + _assert_sanitized(result, (str(Path.cwd()),)) + + +@pytest.mark.unit +def test_main_emits_one_completed_json_result_with_inherited_json_logging(monkeypatch, capsys): + monkeypatch.setenv("BLAZE_LOG_JSON", "1") + assert smoke.main() == 0 + + captured = capsys.readouterr() + assert captured.err == "" + lines = captured.out.splitlines() + assert len(lines) == 1 + result = json.loads(lines[0]) + assert isinstance(result, dict) + _assert_completed_result(result) + _assert_sanitized(result, (str(Path.cwd()),)) + + +@pytest.mark.unit +def test_main_sanitizes_server_creation_failure_and_recovers(monkeypatch, capsys, tmp_path: Path): + username = "contributor-smoke-test-user" + password = "contributor-smoke-test-password" + raw_path = tmp_path / "sensitive-fixture-root" + + def fail_server_creation(*_args: object, **_kwargs: object) -> object: + raise RuntimeError(f"cannot bind {username}:{password} in {raw_path}") + + original_create_server = smoke.create_server + monkeypatch.setattr(smoke, "create_server", fail_server_creation) + + assert smoke.main() != 0 + failed = capsys.readouterr() + assert failed.out == "" + lines = failed.err.splitlines() + assert len(lines) == 1 + report = json.loads(lines[0]) + assert isinstance(report, dict) + _assert_sanitized(report, (username, password, str(raw_path), str(tmp_path))) + + monkeypatch.setattr(smoke, "create_server", original_create_server) + result = smoke.run_smoke() + _assert_completed_result(result)