diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2f5398f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +.gitignore +.github +.env +.venv +venv +__pycache__ +*.py[cod] +.pytest_cache +.mypy_cache +.ruff_cache +node_modules +demo-output +.quickstart-ci +data +*.db +*.sqlite +*.sqlite3 +*.pem +*.key +*.bin +*.zip +dist +build diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml new file mode 100644 index 0000000..b442b0d --- /dev/null +++ b/.github/workflows/publish-container.yml @@ -0,0 +1,77 @@ +name: Publish container + +on: + push: + branches: [main] + paths: + - 'app/**' + - 'verifier/**' + - 'requirements.txt' + - 'run.py' + - 'Dockerfile' + - '.dockerignore' + - '.github/workflows/publish-container.yml' + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: publish-container-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=raw,value=edge,enable=${{ github.event_name == 'push' }} + type=sha,prefix=sha-,format=short,enable=${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + type=raw,value=manual,enable=${{ github.event_name == 'workflow_dispatch' }} + type=semver,pattern={{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} + type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + + - name: Build and push multi-platform image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d3db0e8..95cc2d6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,9 @@ jobs: - run: python -m pip install -r requirements.txt - run: python -m pytest -q - run: python -m py_compile app/*.py verifier/*.py + - name: Run quickstart smoke test + if: matrix.python-version == '3.12' + run: python scripts/quickstart.py --output-dir .quickstart-ci javascript-sdk: runs-on: ubuntu-latest @@ -42,3 +45,19 @@ jobs: - uses: actions/checkout@v7 - name: Build LoopGrid image run: docker build -t loopgrid-ci . + - name: Smoke test container startup + shell: bash + run: | + set -euo pipefail + docker run -d --name loopgrid-ci-run -p 8000:8000 loopgrid-ci + trap 'docker rm -f loopgrid-ci-run >/dev/null 2>&1 || true' EXIT + for attempt in {1..30}; do + if curl --fail --silent --show-error http://127.0.0.1:8000/health > /tmp/loopgrid-health.json; then + cat /tmp/loopgrid-health.json + exit 0 + fi + sleep 1 + done + echo 'LoopGrid container did not become healthy in time.' >&2 + docker logs loopgrid-ci-run >&2 || true + exit 1 diff --git a/.gitignore b/.gitignore index 2a2e517..20fe8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ build/ *.egg-info/ node_modules/ .DS_Store +demo-output/ +.quickstart-ci/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6adc116..58fb08a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# Unreleased — Developer onboarding and container distribution + +## Added + +- Cross-platform `scripts/quickstart.py` local evaluation path using isolated SQLite state and a temporary local Ed25519 signer. +- Quickstart exports `demo-output/evidence.zip` and independently verifies it against an out-of-band trusted public key. +- GitHub Container Registry publishing workflow for `ghcr.io/cybertechsoft/loopgrid`. +- Multi-platform container builds for `linux/amd64` and `linux/arm64`. +- Docker build context hardening via `.dockerignore` so local secrets, keys, databases and generated artifacts are not copied into images. +- CI now smoke-tests container startup and the `/health` endpoint after building the image. +- `edge` container tag for tested `main`; future releases publish semantic-version and `latest` tags. + +## Compatibility + +- No evidence schema, signing, verifier, API, Python SDK or JavaScript SDK contract changes. +- GitHub Marketplace verifier `v1.0.0` remains compatible. + +--- + # LoopGrid v0.8.1 — OTLP Interoperability ## Added / changed diff --git a/README.md b/README.md index 7cd4fa5..dd5c58f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,39 @@ The append-only decision lifecycle includes `decision_created`, `model_completed - Optional live OpenAI/Anthropic replay/provider validation helpers. - Optional RFC3161 timestamp and AWS KMS adapter paths. +## Fast local evaluation + +For a no-Docker, no-account evaluation path, install the repository dependencies once and run the cross-platform quickstart: + +```bash +python -m pip install -r requirements.txt +python scripts/quickstart.py +``` + +The quickstart uses an isolated temporary SQLite database and temporary local Ed25519 signer. It creates a synthetic refund decision with policy evaluation, human approval, tool execution and observed outcome, exports a portable evidence bundle, and verifies that bundle with an out-of-band trusted public key. It does not modify an existing LoopGrid database or deployment. + +Expected final output: + +```text +[OK] Decision captured: dec_... +[OK] Evidence exported: .../demo-output/evidence.zip +[OK] Evidence coverage: 100% +[OK] VERIFIED +``` + +Artifacts are written to `demo-output/` and are synthetic evaluation data only. + +## Container image + +The repository publishes a multi-platform development image from `main` to GitHub Container Registry: + +```bash +docker pull ghcr.io/cybertechsoft/loopgrid:edge +docker run --rm -p 8000:8000 -v loopgrid_demo_data:/app/data ghcr.io/cybertechsoft/loopgrid:edge +``` + +Then open `http://127.0.0.1:8000/`. The `edge` tag tracks tested `main`; versioned and `latest` container tags are published from future GitHub releases. The default container invocation above is for local evaluation, not the production design-partner posture described below. + ## Quickest design-partner deployment Requires Docker Desktop/Engine and Python 3.10+. diff --git a/scripts/quickstart.py b/scripts/quickstart.py new file mode 100644 index 0000000..bffafe5 --- /dev/null +++ b/scripts/quickstart.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _sqlite_url(path: Path) -> str: + return "sqlite:///" + path.resolve().as_posix() + + +def _prepare_local_environment(state_dir: Path) -> None: + """Configure an isolated, local-only LoopGrid evaluation environment. + + Environment variables are set before importing the LoopGrid application because + runtime settings and the signing provider are initialized at import time. + """ + state_dir.mkdir(parents=True, exist_ok=True) + os.environ["LOOPGRID_ENV"] = "development" + os.environ["LOOPGRID_REQUIRE_USER_AUTH"] = "false" + os.environ["LOOPGRID_DATABASE_URL"] = _sqlite_url(state_dir / "loopgrid.db") + os.environ["LOOPGRID_SIGNER_PROVIDER"] = "local_ed25519" + os.environ["LOOPGRID_SIGNING_KEY_PATH"] = str(state_dir / "signing-key.pem") + os.environ["LOOPGRID_PUBLIC_KEY_PATH"] = str(state_dir / "signing-public.pem") + os.environ["LOOPGRID_PAYLOAD_KEY_PATH"] = str(state_dir / "payload-key.bin") + os.environ["LOOPGRID_STRICT_PRODUCTION_SAFETY"] = "true" + + +def run(output_dir: Path) -> int: + output_dir = output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + # Never reuse an existing evaluation database or signing key. The quickstart is + # intended to be deterministic from the user's point of view and isolated from + # any real/local LoopGrid deployment they may already have. + with tempfile.TemporaryDirectory(prefix="loopgrid-quickstart-") as tmp: + state_dir = Path(tmp) + _prepare_local_environment(state_dir) + + try: + from fastapi.testclient import TestClient + from app.main import app + from verifier.loopgrid_verify import verify_bundle + except Exception as exc: # pragma: no cover - friendly CLI path + print("[FAIL] LoopGrid dependencies are not installed.") + print("Run: python -m pip install -r requirements.txt") + print(f"Detail: {type(exc).__name__}: {exc}") + return 1 + + bundle_path = output_dir / "evidence.zip" + trusted_key_path = output_dir / "trusted-public-key.pem" + summary_path = output_dir / "quickstart-result.json" + + for path in (bundle_path, trusted_key_path, summary_path): + if path.exists(): + path.unlink() + + print("LoopGrid quickstart") + print("-------------------") + print("Creating an isolated local evaluation using SQLite and a temporary Ed25519 signer...") + + try: + with TestClient(app) as client: + demo = client.post("/api/v1/demo/refund") + demo.raise_for_status() + payload = demo.json() + decision_id = payload["summary"]["decision_id"] + + evidence = client.get( + f"/api/v1/decisions/{decision_id}/evidence", + params={"include_payloads": "false"}, + ) + evidence.raise_for_status() + bundle_path.write_bytes(evidence.content) + + public_key = client.get("/api/v1/public-key.pem") + public_key.raise_for_status() + trusted_key_path.write_bytes(public_key.content) + + result = verify_bundle( + str(bundle_path), + trusted_public_key=str(trusted_key_path), + ) + except Exception as exc: + print(f"[FAIL] Quickstart failed: {type(exc).__name__}: {exc}") + return 1 + + verification_valid = bool(result.get("valid")) + failure_count = len(result.get("failures") or []) + warning_count = len(result.get("warnings") or []) + coverage_score = int((payload.get("coverage") or {}).get("score") or 0) + + # Keep the machine-readable quickstart summary intentionally minimal. + # Verifier internals and key material/paths are not persisted or echoed here. + summary = { + "decision_id": decision_id, + "workspace_id": payload["summary"].get("workspace_id"), + "decision_type": payload["summary"].get("decision_type"), + "coverage_score": coverage_score, + "verification_status": "VERIFIED" if verification_valid else "INVALID", + "failure_count": failure_count, + "warning_count": warning_count, + "evidence_file": bundle_path.name, + } + summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + + print(f"[OK] Decision captured: {decision_id}") + print(f"[OK] Evidence exported: {bundle_path}") + print(f"[OK] Trusted public key exported: {trusted_key_path}") + print(f"[OK] Evidence coverage: {coverage_score}%") + if verification_valid: + print("[OK] VERIFIED") + print(" The exported bundle verified with an out-of-band trusted public key.") + print(f" Details: {summary_path}") + return 0 + + print("[FAIL] INVALID") + print(f" Verifier reported {failure_count} failure(s) and {warning_count} warning(s).") + print(f" Details: {summary_path}") + return 2 + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Create and independently verify a synthetic LoopGrid evidence bundle locally." + ) + parser.add_argument( + "--output-dir", + default="demo-output", + help="Directory for evidence.zip, trusted-public-key.pem, and result JSON (default: demo-output)", + ) + args = parser.parse_args() + raise SystemExit(run(Path(args.output_dir))) + + +if __name__ == "__main__": + main()