diff --git a/aiac/.gitignore b/aiac/.gitignore index 314fa2ad4..2ed13bf16 100644 --- a/aiac/.gitignore +++ b/aiac/.gitignore @@ -1,7 +1,9 @@ # AIAC working artefacts (regenerated; not source of truth) -docs/issues/ docs/handoffs/ -docs/gh-issues/ + +# Matt Pocock engineering-skill config — local per-clone (set up via +# /setup-matt-pocock-skills); the skills read these, they are not repo source. +docs/agents/ # PDP policy writer integration-test scratch output (test/pdp/policy/generate_rego.py) test/pdp/policy/rego_out/ @@ -10,3 +12,6 @@ test/pdp/policy/rego_out/ # regenerated per run, left on disk for eyeballing — not source of truth. probe.rego is a # committed fixture and is not under rego_out/, so it stays tracked. test/integration/rego_out/ + +# uc1-onboarding demo's captured .rego snapshots — regenerated per run, not source of truth. +demo/use-cases/uc1-onboarding/generated/ diff --git a/aiac/CLAUDE.md b/aiac/CLAUDE.md index 2ee40d876..0a6e04e55 100644 --- a/aiac/CLAUDE.md +++ b/aiac/CLAUDE.md @@ -15,11 +15,30 @@ When a document under `docs/specs/` contains a markdown link to another file, us ## Issue tracking -Issues are tracked as local markdown files under `docs/issues/`, not on GitHub. -Never use `gh` commands to create, update, or list issues — always read/write the local files directly. +Issues are tracked as **GitHub issues** on the `s-and-p-team/cortex` repo, +organized in the org-level **AIAC** Project (Projects v2): +. Use `gh` to read and manage +them. + +Note: `s-and-p-team/cortex` is the AIAC team's fork of the canonical upstream +`rossoctl/cortex`. Tracking issues on the fork (not upstream) is deliberate — +PRs still target upstream, but issue tracking stays on the team fork, so the +`-R s-and-p-team/cortex` scoping below is intentional. + +Hierarchy: the Project groups **Feature**-typed container issues — one per +component area, nested via GitHub **native sub-issues** to form the tree — over +**Task**-typed leaf issues. Every issue carries the `aiac` label plus cumulative +`area:` labels; open issues also carry a `status:` label, and the +Project's built-in **Status** field mirrors that value. -`docs/issues/implementation-plan.md` — overall implementation plan. -For current issue list, `ls` the subdirectories under `docs/issues/`. +```bash +# list / view (filter to the AIAC set) +gh issue list -R s-and-p-team/cortex --label aiac --state all +gh issue view -R s-and-p-team/cortex +``` + +Filtered web list: + ## Issue tracking — codebase inspection policy @@ -178,3 +197,17 @@ demo workloads, which don't) and CPU/memory requests + limits. ## External references - [Kagenti Developer Guide](https://github.com/kagenti/kagenti/blob/main/docs/dev-guide.md) — upstream Kagenti dev guide: per-persona workflows (agent, tool, extensions developers, MCP gateway operators), Git/PR process, pre-commit hooks, feature flags, local Kagenti UI v2 development (React frontend + FastAPI backend, building/deploying images to Kubernetes), and HyperShift-based testing on ephemeral OpenShift clusters (cluster lifecycle, cost management, troubleshooting). + +## Agent skills + +### Issue tracker + +GitHub issues on `s-and-p-team/cortex`, filtered by the `aiac` label, tracked in the org-level AIAC Project. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Issue status is tracked with `status:` labels; the board's built-in **Status** field carries the same values. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context, scoped to `aiac/` (`CONTEXT.md` + `docs/adr/` at the `aiac/` root). See `docs/agents/domain.md`. diff --git a/aiac/demo/assets/INSTALL.md b/aiac/demo/assets/INSTALL.md new file mode 100644 index 000000000..4d2849988 --- /dev/null +++ b/aiac/demo/assets/INSTALL.md @@ -0,0 +1,99 @@ +# Installing the demo assets (`github-tool` + `github-agent`) + +Single install guide for both reusable demo workloads under `demo/assets/`, deployed to a +Kagenti/Kind cluster in namespace `team1`. Consolidates what used to be split across +[`docs/specs/demo/github-tool.md`](../../docs/specs/demo/github-tool.md) §7–8 and the agent +README's former "Deploying to Kagenti" section, so the two installation paths can't drift apart +again. Prefer [`install.sh`](install.sh) over doing this by hand; the steps below are what it +automates. + +## Prerequisites + +- A running Kagenti/Kind cluster with the kagenti-operator installed. +- Namespace **`team1`** already exists — created by the Rossoctl/Kagenti installer, not by + anything in this repo. Nothing here creates cluster-owned resources. +- `kubectl`, `kind`, and a container runtime (`docker` or `podman`) on `PATH`. + +## What gets deployed + +| Workload | Image | Manifests, in order | Objects created (ns `team1`) | +|---|---|---|---| +| tool | `localhost/github-tool:latest` | `tools/github_tool/k8s/github-tool-deployment.yaml` | ServiceAccount + Deployment + Service `github-tool` (`:9090`) + `AgentRuntime` `github-tool` (`type: tool`) | +| agent | `localhost/github-agent:latest` | `agents/github_agent/k8s/configmaps.yaml`, **then** `agents/github_agent/k8s/github-agent-deployment.yaml` | ServiceAccount + Deployment + Service `github-agent` (`:8001`, `:8080`) + `AgentRuntime` `github-agent` (`type: agent`); the ConfigMaps are `authbridge-config` + `authproxy-routes` | + +Per workload, in order: build the image → `kind load docker-image --name ` → +apply the manifests → `kubectl rollout status`. + +## Non-obvious invariants + +These each fail far from their cause — read before editing the manifests or install path. + +- **The tool `Service` must carry `protocol.kagenti.io/mcp: "true"`.** This is a *deploy-time* + label that the kagenti operator does **not** add. Without it, UC-1's `analyze_tool` returns + **502** — during onboarding, long after deployment looked fine. It is already present in the + committed manifest; don't drop it when editing. +- **`kagenti.io/type` is applied by the operator** from the `AgentRuntime` CR. Don't hand-set it + on the pod. +- **The tool's declared `PORT` must not be `9090`.** The AuthBridge sidecar reuses the declared + `PORT` as its own reverse-proxy listener and shifts the app's real listen port to `PORT+1`. + AuthBridge also has a *fixed* health-check listener hardcoded to `9091`. `PORT: 9090` shifts the + app to `9091`, colliding with that fixed listener — the container crash-loops fighting the + sidecar for the port. The manifest uses `PORT: 9095` (shifted: `9096`), which clears every + AuthBridge-fixed port (`8080`, `8081`, `9091`, `9093`, `9094`); the Service's external port stays + `9090`. +- **Keycloak client registration is asynchronous.** The operator registers each workload as a + client named `team1/` *after* the pods come up, so "rollout complete" is **not** + "ready to onboard". Anything that needs the client must poll for it (the UC-1 onboarding demo + does). +- **`github-tool-mcp` is not required for this install.** The agent README names the production + 44-tool `github-tool-mcp` server (deployed from `authbridge/demos/github-issue/`) as its + `MCP_URL` target, and that is a different thing from `tools/github_tool/` (a 4-tool stub used + only for UC-1 onboarding discovery). The MCP connection happens **per request inside + `GithubExecutor.execute()`** (`a2a_agent.py:188-211`), not at startup — `run()` only builds the + AgentCard and serves it. So the agent pod becomes ready and serves + `/.well-known/agent-card.json` with no MCP server present, which is all UC-1 discovery needs. + **Do not add `github-tool-mcp` to this install path.** +- **Namespace `team1` is a precondition, not an output.** No manifest here creates it; the + Rossoctl/Kagenti installer owns it (and any labels it carries). `install.sh` fails fast with a + pointer to the installer if the namespace is missing. + +## Manual steps (what `install.sh` automates) + +**Tool:** +```bash +cd tools/github_tool +podman build -t localhost/github-tool:latest . # or docker; the localhost/ prefix must match the manifest's image ref +kind load docker-image localhost/github-tool:latest --name kagenti +kubectl apply -f k8s/github-tool-deployment.yaml +kubectl rollout status deployment/github-tool -n team1 +``` + +**Agent:** +```bash +cd agents/github_agent +podman build -t localhost/github-agent:latest . # or docker; the localhost/ prefix must match the manifest's image ref +kind load docker-image localhost/github-agent:latest --name kagenti +kubectl apply -f k8s/configmaps.yaml +kubectl apply -f k8s/github-agent-deployment.yaml +kubectl rollout status deployment/github-agent -n team1 +``` + +## Verifying + +```bash +# Tool got its MCP label and the operator stamped kagenti.io/type +kubectl get svc github-tool -n team1 -o jsonpath='{.metadata.labels.protocol\.kagenti\.io/mcp}' +kubectl get pod -l app=github-tool -n team1 -o jsonpath='{.items[0].metadata.labels.kagenti\.io/type}' + +# Agent serves its card with no MCP server present +kubectl port-forward svc/github-agent 8080:8080 -n team1 & +curl -s http://localhost:8080/.well-known/agent-card.json | python3 -m json.tool +``` + +## Out of scope + +- Deploying `github-tool-mcp`, Keycloak, SPIRE, the kagenti operator, or the cluster itself. +- Deploying the AIAC stack (`k8s/`) — see `k8s/aiac-deployment-guide.md`. +- Waiting for Keycloak client registration — that needs Keycloak credentials this install path + has no business holding; it belongs to whatever use-case demo consumes the client (e.g. UC-1's + `00-prereqs.py`). diff --git a/aiac/demo/agents/github_agent/.dockerignore b/aiac/demo/assets/agents/github_agent/.dockerignore similarity index 100% rename from aiac/demo/agents/github_agent/.dockerignore rename to aiac/demo/assets/agents/github_agent/.dockerignore diff --git a/aiac/demo/agents/github_agent/.env.template b/aiac/demo/assets/agents/github_agent/.env.template similarity index 100% rename from aiac/demo/agents/github_agent/.env.template rename to aiac/demo/assets/agents/github_agent/.env.template diff --git a/aiac/demo/agents/github_agent/Dockerfile b/aiac/demo/assets/agents/github_agent/Dockerfile similarity index 100% rename from aiac/demo/agents/github_agent/Dockerfile rename to aiac/demo/assets/agents/github_agent/Dockerfile diff --git a/aiac/demo/agents/github_agent/README.md b/aiac/demo/assets/agents/github_agent/README.md similarity index 72% rename from aiac/demo/agents/github_agent/README.md rename to aiac/demo/assets/agents/github_agent/README.md index 3eaf180bc..74463642e 100644 --- a/aiac/demo/agents/github_agent/README.md +++ b/aiac/demo/assets/agents/github_agent/README.md @@ -20,8 +20,8 @@ Deploy it before starting the agent: authbridge/demos/github-issue/k8s/github-tool-deployment.yaml ``` -> **Not the same as `demo/tools/github_tool/`.** -> `demo/tools/github_tool/` is a simplified 4-tool stub (`source-read`, `source-write`, `issues-read`, +> **Not the same as `aiac/demo/assets/tools/github_tool/`.** +> `aiac/demo/assets/tools/github_tool/` is a simplified 4-tool stub (`source-read`, `source-write`, `issues-read`, > `issues-write`) deployed as Service `github-tool` for **UC-1 onboarding discovery** only. > The agent never connects to it — it connects to the production `github-tool-mcp` server which > exposes the 44-tool GitHub API federation. @@ -58,7 +58,7 @@ All settings are read from environment variables (or a `.env` file). Copy one of ## Running locally ```bash -cd aiac/demo/agents/github_agent +cd aiac/demo/assets/agents/github_agent cp .env.ollama .env # or another preset uv sync uv run server @@ -70,38 +70,8 @@ Optionally, run `expect -f test_startup.exp` instead to check startup automatica ## Deploying to Kagenti (Kind cluster) -Prerequisites: a running Kagenti cluster (Keycloak realm `kagenti`, namespace `team1`) with `github-tool-mcp` already deployed. - -1. **Build the image:** - ```bash - cd aiac/demo/agents/github_agent - podman build -t github-agent:latest . - # or: docker build -t github-agent:latest . - ``` - -2. **Load into the Kind cluster:** - ```bash - kind load docker-image github-agent:latest --name kagenti - ``` - -3. **Apply manifests:** - ```bash - kubectl apply -f k8s/configmaps.yaml - kubectl apply -f k8s/github-agent-deployment.yaml - ``` - -4. **Confirm AuthBridge injection:** - ```bash - kubectl get pod -n team1 -l app.kubernetes.io/name=github-agent -o jsonpath='{.items[0].spec.containers[*].name}' - ``` - You should see the `authbridge-proxy` (or `envoy-proxy`) sidecar alongside `agent`. - -5. **Port-forward and send a message:** - ```bash - kubectl port-forward svc/github-agent 8080:8080 -n team1 & - # Send an A2A message/send request: - curl -s http://localhost:8080/.well-known/agent-card.json | python3 -m json.tool - ``` +See [`../../INSTALL.md`](../../INSTALL.md) — the single install guide for this agent and the +`github_tool` stub together (build, `kind load`, manifests, invariants, verification). ## Architecture diff --git a/aiac/demo/agents/github_agent/a2a_agent.py b/aiac/demo/assets/agents/github_agent/a2a_agent.py similarity index 86% rename from aiac/demo/agents/github_agent/a2a_agent.py rename to aiac/demo/assets/agents/github_agent/a2a_agent.py index bb7703a90..f812ce633 100644 --- a/aiac/demo/agents/github_agent/a2a_agent.py +++ b/aiac/demo/assets/agents/github_agent/a2a_agent.py @@ -3,6 +3,7 @@ """ import asyncio +import concurrent.futures import logging import os import sys @@ -196,19 +197,24 @@ async def execute(self, context: RequestContext, event_queue: EventQueue): "headers": headers, } adapter = MCPServerAdapter(server_params, connect_timeout=settings.MCP_TIMEOUT) - # MCPServerAdapter.__enter__/__exit__ perform blocking MCP I/O; - # run them off the event loop so we don't stall other async tasks. - mcp_tools = await asyncio.to_thread(adapter.__enter__) - try: - curated_tools = select_enabled_tools(mcp_tools, settings) - if not curated_tools: - raise RuntimeError( - "No enabled tools found from the GitHub MCP server. " - "Check the ENABLED_TOOLS setting and ensure the server is reachable." - ) - await self._run_agent(messages, settings, event_emitter, curated_tools) - finally: - await asyncio.to_thread(adapter.__exit__, None, None, None) + # MCPServerAdapter.__enter__/__exit__ perform blocking MCP I/O; run them off the + # event loop so we don't stall other async tasks. They MUST run on the SAME thread + # (the adapter binds MCP session state to the entering thread), so use a dedicated + # single-worker executor rather than asyncio.to_thread's shared pool, which could + # otherwise dispatch __enter__ and __exit__ to different workers. + loop = asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as mcp_exec: + mcp_tools = await loop.run_in_executor(mcp_exec, adapter.__enter__) + try: + curated_tools = select_enabled_tools(mcp_tools, settings) + if not curated_tools: + raise RuntimeError( + "No enabled tools found from the GitHub MCP server. " + "Check the ENABLED_TOOLS setting and ensure the server is reachable." + ) + await self._run_agent(messages, settings, event_emitter, curated_tools) + finally: + await loop.run_in_executor(mcp_exec, adapter.__exit__, None, None, None) else: await self._run_agent(messages, settings, event_emitter, None) diff --git a/aiac/demo/agents/github_agent/github_agent/__init__.py b/aiac/demo/assets/agents/github_agent/github_agent/__init__.py similarity index 100% rename from aiac/demo/agents/github_agent/github_agent/__init__.py rename to aiac/demo/assets/agents/github_agent/github_agent/__init__.py diff --git a/aiac/demo/agents/github_agent/github_agent/agents.py b/aiac/demo/assets/agents/github_agent/github_agent/agents.py similarity index 100% rename from aiac/demo/agents/github_agent/github_agent/agents.py rename to aiac/demo/assets/agents/github_agent/github_agent/agents.py diff --git a/aiac/demo/agents/github_agent/github_agent/config.py b/aiac/demo/assets/agents/github_agent/github_agent/config.py similarity index 100% rename from aiac/demo/agents/github_agent/github_agent/config.py rename to aiac/demo/assets/agents/github_agent/github_agent/config.py diff --git a/aiac/demo/agents/github_agent/github_agent/data_types.py b/aiac/demo/assets/agents/github_agent/github_agent/data_types.py similarity index 100% rename from aiac/demo/agents/github_agent/github_agent/data_types.py rename to aiac/demo/assets/agents/github_agent/github_agent/data_types.py diff --git a/aiac/demo/agents/github_agent/github_agent/event.py b/aiac/demo/assets/agents/github_agent/github_agent/event.py similarity index 100% rename from aiac/demo/agents/github_agent/github_agent/event.py rename to aiac/demo/assets/agents/github_agent/github_agent/event.py diff --git a/aiac/demo/agents/github_agent/github_agent/llm.py b/aiac/demo/assets/agents/github_agent/github_agent/llm.py similarity index 100% rename from aiac/demo/agents/github_agent/github_agent/llm.py rename to aiac/demo/assets/agents/github_agent/github_agent/llm.py diff --git a/aiac/demo/agents/github_agent/github_agent/main.py b/aiac/demo/assets/agents/github_agent/github_agent/main.py similarity index 95% rename from aiac/demo/agents/github_agent/github_agent/main.py rename to aiac/demo/assets/agents/github_agent/github_agent/main.py index 7f9d54df2..8b748b64c 100644 --- a/aiac/demo/agents/github_agent/github_agent/main.py +++ b/aiac/demo/assets/agents/github_agent/github_agent/main.py @@ -110,4 +110,7 @@ async def execute(self, user_input): "numbers": prereq.numbers or [], } ) - return self.agents.github_query_task.output.raw + output = self.agents.github_query_task.output + if output is None or output.raw is None: + return "The agent produced no output for this request." + return output.raw diff --git a/aiac/demo/agents/github_agent/github_agent/prompts.py b/aiac/demo/assets/agents/github_agent/github_agent/prompts.py similarity index 97% rename from aiac/demo/agents/github_agent/github_agent/prompts.py rename to aiac/demo/assets/agents/github_agent/github_agent/prompts.py index 88dca7925..d8880653a 100644 --- a/aiac/demo/agents/github_agent/github_agent/prompts.py +++ b/aiac/demo/assets/agents/github_agent/github_agent/prompts.py @@ -96,7 +96,7 @@ - "List branches of owner/repo" → list_branches - "Open issues in kagenti/agent-examples" → list_issues - "Find issues mentioning timeout across all repos" → search_issues -- "Sub-issues under #134 in openai/triton" → sub_issue_write / issue_read with issue_number +- "Sub-issues under #134 in openai/triton" → issue_read with issue_number (read-only; never sub_issue_write) - "PR #42 in owner/repo" → pull_request_read Carefully inspect the user's request for filters such as labels, date ranges, keywords, state (open/closed), etc. Use available optional parameters where appropriate. @@ -112,8 +112,8 @@ Extraction Rules: - Copy owner/organization names, repository names, ref names, file paths, and issue/PR identifiers exactly as the user typed them. Preserve casing, punctuation, spacing, diacritics, and hyphenation; never rewrite, normalize, or translate these strings. -- Only return values that are explicitly present in the user request. If any item is missing, output None for that field. -- Do not infer or guess missing identifiers. If you are unsure about any value, leave it as None. +- Only return values that are explicitly present in the user request. If any item is missing, output null for that field (JSON null, not the string "None"). +- Do not infer or guess missing identifiers. If you are unsure about any value, leave it as null. Output format: a JSON object with keys "owner", "repo", "ref", "path", "numbers". Example: {"owner": "kagenti", "repo": "kagenti", "ref": null, "path": null, "numbers": null} diff --git a/aiac/demo/agents/github_agent/github_agent/tools.py b/aiac/demo/assets/agents/github_agent/github_agent/tools.py similarity index 100% rename from aiac/demo/agents/github_agent/github_agent/tools.py rename to aiac/demo/assets/agents/github_agent/github_agent/tools.py diff --git a/aiac/demo/agents/github_agent/k8s/configmaps.yaml b/aiac/demo/assets/agents/github_agent/k8s/configmaps.yaml similarity index 100% rename from aiac/demo/agents/github_agent/k8s/configmaps.yaml rename to aiac/demo/assets/agents/github_agent/k8s/configmaps.yaml diff --git a/aiac/demo/agents/github_agent/k8s/github-agent-deployment.yaml b/aiac/demo/assets/agents/github_agent/k8s/github-agent-deployment.yaml similarity index 100% rename from aiac/demo/agents/github_agent/k8s/github-agent-deployment.yaml rename to aiac/demo/assets/agents/github_agent/k8s/github-agent-deployment.yaml diff --git a/aiac/demo/agents/github_agent/pyproject.toml b/aiac/demo/assets/agents/github_agent/pyproject.toml similarity index 87% rename from aiac/demo/agents/github_agent/pyproject.toml rename to aiac/demo/assets/agents/github_agent/pyproject.toml index b97188052..d51b92cd8 100644 --- a/aiac/demo/agents/github_agent/pyproject.toml +++ b/aiac/demo/assets/agents/github_agent/pyproject.toml @@ -6,6 +6,7 @@ description = "Github Agent module" dependencies = [ "python-dotenv>=1.2.2", "a2a-sdk>=1.1.0,<2", + "aiohttp>=3.14.3", # Indirect; prevents GHSA-cq5v-8q36-5273 / GHSA-mfx4-hv73-q22v / GHSA-mq44-7p77-q5h7 # We use 1.6.1 instead of the latest because # crewai requires openai>=2.30.0 # but litellm requires openai==2.24.0 @@ -14,7 +15,7 @@ dependencies = [ "crewai-tools[mcp]>=1.6.1", "urllib3>=2.7.0", # Indirect; prevents CVE-2025-66418 "python-multipart>=0.0.32", # Indirect; prevents CVE-2026-24486 - "cryptography>=48.0.0,<49", # Indirect; prevents CVE-2026-26007 + "cryptography>=50.0.0", # Indirect; prevents CVE-2026-26007 + GHSA-g6cj-pr64-35w5 "pyasn1>=0.6.3", # Indirect; prevents CVE-2026-30922 "starlette>=1.3.1", # Indirect; prevents CVE-2025-62727 "pillow>=12.3.0", # Indirect; prevents CVE-2026-40192 diff --git a/aiac/demo/agents/github_agent/test/test_agent_card.py b/aiac/demo/assets/agents/github_agent/test/test_agent_card.py similarity index 100% rename from aiac/demo/agents/github_agent/test/test_agent_card.py rename to aiac/demo/assets/agents/github_agent/test/test_agent_card.py diff --git a/aiac/demo/agents/github_agent/test/test_prereq.py b/aiac/demo/assets/agents/github_agent/test/test_prereq.py similarity index 100% rename from aiac/demo/agents/github_agent/test/test_prereq.py rename to aiac/demo/assets/agents/github_agent/test/test_prereq.py diff --git a/aiac/demo/agents/github_agent/test/test_tools.py b/aiac/demo/assets/agents/github_agent/test/test_tools.py similarity index 100% rename from aiac/demo/agents/github_agent/test/test_tools.py rename to aiac/demo/assets/agents/github_agent/test/test_tools.py diff --git a/aiac/demo/agents/github_agent/test_startup.exp b/aiac/demo/assets/agents/github_agent/test_startup.exp similarity index 100% rename from aiac/demo/agents/github_agent/test_startup.exp rename to aiac/demo/assets/agents/github_agent/test_startup.exp diff --git a/aiac/demo/agents/github_agent/uv.lock b/aiac/demo/assets/agents/github_agent/uv.lock similarity index 94% rename from aiac/demo/agents/github_agent/uv.lock rename to aiac/demo/assets/agents/github_agent/uv.lock index 321afbc09..a333a727b 100644 --- a/aiac/demo/agents/github_agent/uv.lock +++ b/aiac/demo/assets/agents/github_agent/uv.lock @@ -46,7 +46,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -58,44 +58,44 @@ dependencies = [ { name = "typing-extensions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, ] [[package]] @@ -549,47 +549,45 @@ mcp = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, - { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] [[package]] @@ -765,6 +763,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, + { name = "aiohttp" }, { name = "crewai", extra = ["litellm"] }, { name = "crewai-tools", extra = ["mcp"] }, { name = "cryptography" }, @@ -788,9 +787,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", specifier = ">=1.1.0,<2" }, + { name = "aiohttp", specifier = ">=3.14.3" }, { name = "crewai", extras = ["litellm"], specifier = ">=1.6.1" }, { name = "crewai-tools", extras = ["mcp"], specifier = ">=1.6.1" }, - { name = "cryptography", specifier = ">=48.0.0,<49" }, + { name = "cryptography", specifier = ">=50.0.0" }, { name = "json-repair", specifier = ">=0.60.1" }, { name = "litellm", specifier = ">=1.90.2" }, { name = "lxml", specifier = ">=6.1.1" }, diff --git a/aiac/demo/assets/install.sh b/aiac/demo/assets/install.sh new file mode 100755 index 000000000..08afe66a1 --- /dev/null +++ b/aiac/demo/assets/install.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Idempotent installer for the demo/assets workloads (github-tool, github-agent) into a +# Kagenti/Kind cluster. See INSTALL.md for the manual steps this automates and the +# non-obvious invariants (MCP service label, async Keycloak registration, etc). +# +# This script does NOT wait for Keycloak client registration — that needs Keycloak +# credentials this script has no business holding. It belongs to whatever use-case demo +# consumes the client (e.g. the UC-1 onboarding demo's 00-prereqs.py). Do not "fix" that +# omission here. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +CLUSTER_NAME="${CLUSTER_NAME:-kagenti}" +NAMESPACE="${NAMESPACE:-team1}" +# Tags must carry the localhost/ prefix to match the Deployment manifests' image refs +# (image: localhost/github-*:latest, imagePullPolicy: IfNotPresent). docker does not auto-prefix +# built tags, so a bare github-*:latest would load into the kind node under a different repository +# name and the IfNotPresent pods would try to pull localhost/github-*:latest → ImagePullBackOff. +TOOL_IMAGE="${TOOL_IMAGE:-localhost/github-tool:latest}" +AGENT_IMAGE="${AGENT_IMAGE:-localhost/github-agent:latest}" + +DO_TOOL=1 +DO_AGENT=1 +REBUILD=0 + +for arg in "$@"; do + case "$arg" in + --agent-only) DO_TOOL=0 ;; + --tool-only) DO_AGENT=0 ;; + --rebuild) REBUILD=1 ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Usage: $0 [--agent-only|--tool-only] [--rebuild]" >&2 + exit 1 + ;; + esac +done + +log() { echo "[install.sh] $*" >&2; } + +detect_runtime() { + if command -v podman >/dev/null 2>&1; then + echo podman + elif command -v docker >/dev/null 2>&1; then + echo docker + else + log "ERROR: neither podman nor docker found on PATH." + exit 1 + fi +} + +RUNTIME="${CONTAINER_RUNTIME:-$(detect_runtime)}" + +preflight() { + local missing=0 + for bin in kubectl kind "$RUNTIME"; do + if ! command -v "$bin" >/dev/null 2>&1; then + log "ERROR: required binary '$bin' not found on PATH." + missing=1 + fi + done + [ "$missing" -eq 0 ] || exit 1 + + if ! kubectl cluster-info >/dev/null 2>&1; then + log "ERROR: kubectl cannot reach a cluster. Is your kubeconfig pointing at '$CLUSTER_NAME'?" + exit 1 + fi + + if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then + log "ERROR: namespace '$NAMESPACE' does not exist." + log "This script does not create cluster-owned resources — run the Rossoctl/Kagenti installer first." + exit 1 + fi + + if ! kubectl get crd agentruntimes.agent.kagenti.dev >/dev/null 2>&1; then + log "ERROR: AgentRuntime CRD not found. Is the kagenti-operator installed?" + exit 1 + fi +} + +image_exists() { + "$RUNTIME" image exists "$1" >/dev/null 2>&1 || "$RUNTIME" image inspect "$1" >/dev/null 2>&1 +} + +load_image_to_kind() { + local image="$1" + # `kind load docker-image` shells out to the docker binary and does not work with podman; + # for podman, save to an archive and use `kind load image-archive` instead. + if [ "$RUNTIME" = "podman" ]; then + local tar_file + tar_file="$(mktemp "${TMPDIR:-/tmp}/install-image.XXXXXX")" + trap 'rm -f "$tar_file"' RETURN + "$RUNTIME" save "$image" -o "$tar_file" + kind load image-archive "$tar_file" --name "$CLUSTER_NAME" + else + kind load docker-image "$image" --name "$CLUSTER_NAME" + fi +} + +build_and_load() { + local image="$1" context="$2" + if [ "$REBUILD" -eq 0 ] && image_exists "$image"; then + log "Image '$image' already present locally, skipping build (pass --rebuild to force)." + else + log "Building '$image' from $context" + "$RUNTIME" build -t "$image" "$context" + fi + log "Loading '$image' into kind cluster '$CLUSTER_NAME'" + load_image_to_kind "$image" +} + +install_tool() { + local dir="$SCRIPT_DIR/tools/github_tool" + build_and_load "$TOOL_IMAGE" "$dir" + log "Applying tool manifests" + kubectl apply -n "$NAMESPACE" -f "$dir/k8s/github-tool-deployment.yaml" + kubectl rollout status -n "$NAMESPACE" deployment/github-tool +} + +install_agent() { + local dir="$SCRIPT_DIR/agents/github_agent" + build_and_load "$AGENT_IMAGE" "$dir" + log "Applying agent configmaps" + kubectl apply -n "$NAMESPACE" -f "$dir/k8s/configmaps.yaml" + log "Applying agent manifests" + kubectl apply -n "$NAMESPACE" -f "$dir/k8s/github-agent-deployment.yaml" + kubectl rollout status -n "$NAMESPACE" deployment/github-agent +} + +preflight + +[ "$DO_TOOL" -eq 1 ] && install_tool +[ "$DO_AGENT" -eq 1 ] && install_agent + +log "Done." diff --git a/aiac/demo/tools/github_tool/Dockerfile b/aiac/demo/assets/tools/github_tool/Dockerfile similarity index 100% rename from aiac/demo/tools/github_tool/Dockerfile rename to aiac/demo/assets/tools/github_tool/Dockerfile diff --git a/aiac/demo/tools/github_tool/k8s/github-tool-deployment.yaml b/aiac/demo/assets/tools/github_tool/k8s/github-tool-deployment.yaml similarity index 81% rename from aiac/demo/tools/github_tool/k8s/github-tool-deployment.yaml rename to aiac/demo/assets/tools/github_tool/k8s/github-tool-deployment.yaml index b9bd060c7..7413b0ed8 100644 --- a/aiac/demo/tools/github_tool/k8s/github-tool-deployment.yaml +++ b/aiac/demo/assets/tools/github_tool/k8s/github-tool-deployment.yaml @@ -12,6 +12,16 @@ # # kagenti.io/type=tool is applied by the kagenti-operator when it reconciles # the AgentRuntime below; do not hand-set it here. +# +# In-cluster PORT override (9095, not the image's default 9090): the +# AuthBridge sidecar shifts the app's real listen port to (declared PORT)+1 +# and reuses the declared PORT itself for its own reverse-proxy listener. +# AuthBridge also has a *fixed* health-check listener hardcoded to 9091. If +# the declared PORT is 9090, the app's shifted port (9091) collides with that +# fixed health listener and the container crash-loops. 9095 (shifted: 9096) +# clears every AuthBridge-fixed port (8080, 8081, 9091, 9093, 9094). The +# Service keeps its external port at 9090 — only containerPort/PORT/probes/ +# targetPort move to 9095. --- apiVersion: v1 kind: ServiceAccount @@ -49,7 +59,7 @@ spec: image: localhost/github-tool:latest imagePullPolicy: IfNotPresent ports: - - containerPort: 9090 + - containerPort: 9095 # readOnlyRootFilesystem is on; temp writes go to the /tmp emptyDir. securityContext: allowPrivilegeEscalation: false @@ -59,7 +69,7 @@ spec: - ALL env: - name: PORT - value: "9090" + value: "9095" - name: LOG_LEVEL value: "INFO" resources: @@ -73,12 +83,12 @@ spec: # MCP port accepting TCP connections. readinessProbe: tcpSocket: - port: 9090 + port: 9095 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: tcpSocket: - port: 9090 + port: 9095 initialDelaySeconds: 10 periodSeconds: 20 volumeMounts: @@ -105,7 +115,7 @@ spec: ports: - name: mcp port: 9090 - targetPort: 9090 + targetPort: 9095 type: ClusterIP --- # AgentRuntime — enrolls the workload so the kagenti-operator: diff --git a/aiac/demo/tools/github_tool/pytest.ini b/aiac/demo/assets/tools/github_tool/pytest.ini similarity index 100% rename from aiac/demo/tools/github_tool/pytest.ini rename to aiac/demo/assets/tools/github_tool/pytest.ini diff --git a/aiac/demo/tools/github_tool/requirements.txt b/aiac/demo/assets/tools/github_tool/requirements.txt similarity index 100% rename from aiac/demo/tools/github_tool/requirements.txt rename to aiac/demo/assets/tools/github_tool/requirements.txt diff --git a/aiac/demo/tools/github_tool/server.py b/aiac/demo/assets/tools/github_tool/server.py similarity index 100% rename from aiac/demo/tools/github_tool/server.py rename to aiac/demo/assets/tools/github_tool/server.py diff --git a/aiac/demo/tools/github_tool/test/__init__.py b/aiac/demo/assets/tools/github_tool/test/__init__.py similarity index 100% rename from aiac/demo/tools/github_tool/test/__init__.py rename to aiac/demo/assets/tools/github_tool/test/__init__.py diff --git a/aiac/demo/tools/github_tool/test/conftest.py b/aiac/demo/assets/tools/github_tool/test/conftest.py similarity index 73% rename from aiac/demo/tools/github_tool/test/conftest.py rename to aiac/demo/assets/tools/github_tool/test/conftest.py index 3f93ad297..fe23200cb 100644 --- a/aiac/demo/tools/github_tool/test/conftest.py +++ b/aiac/demo/assets/tools/github_tool/test/conftest.py @@ -5,4 +5,4 @@ sys.path.insert(0, str(Path(__file__).parents[1])) # scenario.py importable as `scenario` -sys.path.insert(0, str(Path(__file__).parents[4] / "test" / "integration")) +sys.path.insert(0, str(Path(__file__).parents[5] / "test" / "integration")) diff --git a/aiac/demo/tools/github_tool/test/test_server.py b/aiac/demo/assets/tools/github_tool/test/test_server.py similarity index 100% rename from aiac/demo/tools/github_tool/test/test_server.py rename to aiac/demo/assets/tools/github_tool/test/test_server.py diff --git a/aiac/demo/use-cases/.gitkeep b/aiac/demo/use-cases/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/demo/use-cases/uc1-onboarding/Makefile b/aiac/demo/use-cases/uc1-onboarding/Makefile new file mode 100644 index 000000000..a8ccdbc2f --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/Makefile @@ -0,0 +1,62 @@ +# uc1-onboarding demo — AIAC discovers an agent and a tool, reads a two-line plain-English +# policy, and generates enforceable least-privilege authorization. Strictly live: every target +# talks to a real cluster/Keycloak/LLM, no offline replay and no fixtures. +# +# make demo # prereqs -> clear -> setup -> onboard-agent -> show -> onboard-tool -> show +# make dev # drive dev-user's intents through the after-tool snapshot +# +# Prereqs: a Kagenti/kind cluster with SPIRE + Keycloak + the kagenti operator (see +# ../../assets/INSTALL.md and ../../../k8s/aiac-deployment-guide.md), and KEYCLOAK_URL / +# KEYCLOAK_ADMIN_USERNAME / KEYCLOAK_ADMIN_PASSWORD in the environment. + +.PHONY: help prereqs clear setup onboard-agent onboard-tool show diff dev test devops demo + +.DEFAULT_GOAL := help + +# AIAC_ROOT is fixed relative to this Makefile's location (demo/use-cases/uc1-onboarding/), not +# an override knob — the scripts resolve their own paths the same way. +AIAC_ROOT := ../../.. +VENV := $(AIAC_ROOT)/.venv +PYTHON ?= $(VENV)/bin/python + +help: ## Show this menu + @printf "\nuc1-onboarding demo — live AIAC onboarding of a real agent + tool.\n\n" + @printf "Run:\n \033[1mmake demo\033[0m (prereqs -> clear -> setup -> onboard-agent -> show -> onboard-tool -> show)\n" + @printf " \033[1mmake dev / make test / make devops\033[0m (drive one user's intents)\n\n" + @printf "Targets:\n" + @awk 'BEGIN{FS=":.*## "} /^[a-zA-Z_-]+:.*## /{printf " \033[36m%-16s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST) + @printf "\nVars: PYTHON(=%s)\n\n" "$(PYTHON)" + +prereqs: ## Verify (and install if absent) everything the demo needs before touching Keycloak + $(PYTHON) init/00-prereqs.py + +clear: ## Reset to a clean slate: Keycloak roles/scopes, Policy Store, writer's /rego, generated/ + $(PYTHON) init/01-clear.py + +setup: ## Provision users/roles, mount policy.md, configure token exchange; prints both UUIDs + $(PYTHON) init/02-setup.py + +onboard-agent: ## POST /apply/service on github-agent; captures generated/01-after-agent/ + $(PYTHON) onboard/03-onboard-agent.py + +onboard-tool: ## POST /apply/service on github-tool; captures generated/02-after-tool/ + $(PYTHON) onboard/04-onboard-tool.py + +show: ## Show live Keycloak roles/scopes + the latest generated .rego (or "no policy yet") + $(PYTHON) show-state.py + +diff: ## Show the latest snapshot, diffed against generated/ (e.g. make diff PRIOR=01-after-agent) + $(PYTHON) show-state.py --diff $(PRIOR) + +dev: ## Drive dev-user's intents against generated/02-after-tool/ + $(PYTHON) run/run-developer.py + +test: ## Drive test-user's intents against generated/02-after-tool/ + $(PYTHON) run/run-tester.py + +devops: ## Drive devops-user — denied at the inbound gate (the intended story) + $(PYTHON) run/run-devops.py + +demo: prereqs clear setup onboard-agent show onboard-tool show ## Full end-to-end: provision through both onboardings + @echo + @echo "Onboarding complete. Now: make dev / make test / make devops" diff --git a/aiac/demo/use-cases/uc1-onboarding/demo.md b/aiac/demo/use-cases/uc1-onboarding/demo.md new file mode 100644 index 000000000..da239281e --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/demo.md @@ -0,0 +1,147 @@ +# UC-1: Onboarding an agent and a tool + +**Nobody wrote these access rules.** AIAC discovered a GitHub agent and a GitHub tool already +running in the cluster, read a two-line plain-English policy, and generated enforceable +least-privilege authorization for both — who may call the agent, and what the agent may do on the +tool on their behalf. + +## The policy + +This is the entire input a human wrote. No YAML, no scope tables, no per-endpoint rules: + +``` +Grant access on a least-privilege basis: allow only what this policy states; deny by default. + +- Developers may read and modify source, and read issues. +- Testers may read and modify issues. +``` + +## What comes out the other side + +AIAC turns that into two Rego files per agent — one gating who may call it, one gating what it may +do downstream — derived from the policy text plus the realm-role descriptions already in Keycloak +and the tool's own discovered capabilities. An excerpt of the generated outbound gate: + +```rego +package authz.team1_github_agent.outbound + +subject_role_scopes := { + "developer": ["github-tool.issues-read", "github-tool.source-write", "github-tool.source-read"], + "tester": ["github-tool.issues-write", "github-tool.issues-read"], +} + +subject_ok if { + some role in subject_roles[input.subject] + input.function_name in subject_role_scopes[role] +} + +target_ok if { + input.function_name in target_scopes[input.target] +} + +default allow := false +allow if { subject_ok; target_ok } +``` + +Every access decision is a two-gate AND: the calling user's role must be granted the scope +(`subject_ok`), *and* the agent's own discovered capabilities must reach it (`target_ok`). A +developer can read and write source and read issues; a tester can read and write issues but never +touches source — exactly the two-line policy, and nothing it didn't say. + +## Running it + +Everything below is a real cluster, a real Keycloak, a real LLM call, and a real RFC 8693 token +exchange — there is no offline mode. Bring up a Kagenti cluster with SPIRE + Keycloak + the +kagenti operator first (see [../../assets/INSTALL.md](../../assets/INSTALL.md) and +[../../../k8s/aiac-deployment-guide.md](../../../k8s/aiac-deployment-guide.md) for reference, not as a +manual checklist — `make prereqs` below verifies and, where safe, installs what's missing) and +export `KEYCLOAK_URL` / `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD`. + +```bash +make prereqs # verify/install cluster + AIAC stack + demo workloads; wait for Keycloak registration +make clear # reset to a clean slate +make setup # provision demo users/roles, mount policy.md, configure token exchange +``` + +**Pause 1 — baseline.** `make show` reports three users with roles, no `github-*` roles or scopes +yet, and no generated `.rego` at all. Nothing has been onboarded; there is nothing to enforce yet. + +```bash +make onboard-agent # AIAC discovers github-agent, reads policy.md, generates the inbound gate +make show +``` + +**Pause 2 — the agent alone.** The inbound gate is now populated: developers and testers can reach +the agent's discovered scopes. The outbound gate exists but every map in it is still empty — +there's no tool yet for the agent to act on. + +```bash +make onboard-tool # AIAC discovers github-tool's capabilities and completes the agent's outbound gate +make show +``` + +**Pause 3 — both onboarded.** `make diff PRIOR=01-after-agent` +shows the outbound gate's maps filling in: `target_scopes` keyed by the tool's SPIFFE identity, and +per-role grants for every discovered tool scope. This is the moment least-privilege access to a +downstream tool exists — generated, not hand-written. + +Now drive real users through it: + +```bash +make dev # dev-user: read a file, commit a fix, read an issue (allowed) / close an issue (denied) +make test # test-user: read/file issues (allowed) / read source (denied) +make devops # devops-user: blocked at the inbound gate — no role sources any agent scope +``` + +Each target does a real `grant_type=password` login, checks the inbound gate, performs a real RFC +8693 token exchange for the tool's audience, and checks the outbound gate per intent — printing a +result table. `devops-user`'s inbound denial is the intended story, not a failure: nothing in the +policy grants devops-user access to the agent at all. + +Run `make demo` for the whole provisioning ladder (`prereqs` through the second `show`) in one +shot, then drive the three users separately. + +## Architecture + +``` + dev-user/test-user/devops-user + │ grant_type=password + ▼ + Keycloak ──────────────────────────────┐ + │ access_token │ RFC 8693 token exchange + ▼ │ (subject token -> tool-audience token) + [inbound gate: may this user call │ + the agent? — generated from policy.md] ▼ + │ [outbound gate: may the agent reach + ▼ this tool scope, for this user? — + github-agent generated from policy.md + tool capabilities] + │ │ + └──────────────────────────────────┴──► github-tool +``` + +The gates are plain Rego, evaluated with `opa eval` against the files AIAC's Policy Computation +Engine writes — this demo runs them the same way a live enforcement point would query them, but +does not itself sit in the request path (see the appendix). + +## Troubleshooting + +- **`make prereqs` hangs waiting on client registration** — Keycloak client registration is async + after the operator injects a workload; give it a couple of minutes, then check the operator's + webhook logs. +- **`make onboard-agent`/`make onboard-tool` times out** — onboarding drives the Policy Rules + Builder's LLM calls and can genuinely take minutes; re-run with a larger `AIAC_ONBOARD_TIMEOUT` if + your LLM endpoint is slow. +- **`make setup` / `make dev` fails with a Keycloak profile error** — Keycloak 26's declarative user + profile requires `email`/`firstName`/`lastName` before `grant_type=password` succeeds; `02-setup.py` + sets these, so this points at a realm that was provisioned some other way. +- **A `run-*` target aborts with "no policy found"** — the drivers always run against + `generated/02-after-tool/`; run `make onboard-agent && make onboard-tool` first. + +## Appendix: known gaps + +- The generated Rego is enforced by evaluating it directly with `opa eval`, mirroring how a gateway + would query it — this demo does not itself sit in front of live agent/tool traffic (that gateway + integration is separate, ongoing work). +- `run-*.py` performs a real token exchange to prove the RFC 8693 flow end to end, but does not feed + the exchanged token into a live call against `github-tool` — the outbound verdict is read from the + same generated Rego, not from an intercepted request. diff --git a/aiac/demo/use-cases/uc1-onboarding/init/00-prereqs.py b/aiac/demo/use-cases/uc1-onboarding/init/00-prereqs.py new file mode 100644 index 000000000..5ad5227eb --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/init/00-prereqs.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Verify (and, where safe, install) everything the UC-1 onboarding demo needs before ``make +setup`` touches Keycloak. Two classes of check, per the handoff: + + 1. VERIFY ONLY, else abort with a pointer to the installer: cluster reachable, the + ``agentruntimes``/``agentcards`` CRDs, Keycloak reachable, namespace ``team1``, SPIRE. + 2. VERIFY AND INSTALL IF ABSENT: the four AIAC images (build + kind load + apply, in dependency + order), then the demo workloads via ``demo/assets/install.sh`` (not reimplemented here). + +Then the real readiness condition: poll Keycloak until both the ``team1/github-agent`` and +``team1/github-tool`` clients exist (registration is async — "rollout complete" is not "ready to +onboard"), and assert the tool Service carries the ``protocol.kagenti.io/mcp`` LABEL (the handoff's +own spec text calls it an annotation; it is not). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +import scenario as scn +from _lib import Config, abort, kubectl, kubectl_get_json, kubectl_rollout_status, load_config, note, ok, rule, say + +HERE = Path(__file__).resolve().parent +AIAC_ROOT = HERE.parents[3] # demo/use-cases/uc1-onboarding/init/ -> aiac/ +ASSETS_DIR = HERE.parents[2] / "assets" # demo/assets/ + +AIAC_NAMESPACE = "aiac-system" +AIAC_IMAGES = [ + ("localhost/aiac-pdp-config:local", "src/aiac/idp/service/configuration/keycloak/Dockerfile", "src/aiac/idp/service/configuration/keycloak"), + ("localhost/aiac-pdp-policy-opa:local", "src/aiac/pdp/service/policy/opa/Dockerfile", "src"), + ("localhost/aiac-policy-store:local", "src/aiac/policy/store/service/Dockerfile", "src"), + ("localhost/aiac-agent:local", "src/aiac/agent/controller/Dockerfile", "src"), +] +AIAC_MANIFESTS = ["pdp-interface-deployment.yaml", "policy-store-statefulset.yaml", "agent-deployment.yaml"] + + +# --- class 1: verify only --------------------------------------------------------------------- + + +def verify_cluster_reachable() -> None: + try: + kubectl("cluster-info", timeout=15) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + abort("kubectl cannot reach a cluster. Set up a Kagenti/Kind cluster first (see the Rossoctl/Kagenti installer).") + ok("cluster reachable") + + +def verify_crds() -> None: + for crd in ("agentruntimes.agent.kagenti.dev", "agentcards.agent.kagenti.dev"): + try: + kubectl("get", "crd", crd, timeout=15) + except subprocess.CalledProcessError: + abort(f"CRD {crd!r} not found — is the kagenti-operator installed?") + ok("agentruntimes/agentcards CRDs present") + + +def verify_namespace(namespace: str) -> None: + try: + kubectl("get", "namespace", namespace, timeout=15) + except subprocess.CalledProcessError: + abort(f"namespace {namespace!r} does not exist — run the Rossoctl/Kagenti installer first.") + ok(f"namespace {namespace!r} exists") + + +def verify_spire() -> None: + out = kubectl( + "get", "pods", "-A", "-l", "app.kubernetes.io/name=agent,app.kubernetes.io/instance=spire", + "-o", "jsonpath={.items[*].status.phase}", timeout=15, + ) + if not out.split() or any(phase != "Running" for phase in out.split()): + abort(f"SPIRE agent not Running (got phases: {out or ''}) — is SPIRE installed?") + ok("SPIRE agent Running") + + +def verify_keycloak(cfg: Config) -> None: + import requests + + try: + resp = requests.get(f"{cfg.keycloak_url}/realms/master/.well-known/openid-configuration", timeout=10) + except requests.RequestException as exc: + abort(f"Keycloak unreachable at {cfg.keycloak_url}: {exc}") + if resp.status_code != 200: + abort(f"Keycloak at {cfg.keycloak_url} returned HTTP {resp.status_code}") + ok(f"Keycloak reachable at {cfg.keycloak_url}") + + +# --- class 2: verify and install if absent ------------------------------------------------------ + + +def _image_present(image: str) -> bool: + for runtime in ("podman", "docker"): + try: + subprocess.run([runtime, "image", "inspect", image], capture_output=True, timeout=15, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + continue + return False + + +def _container_runtime() -> str: + for runtime in ("podman", "docker"): + if subprocess.run(["which", runtime], capture_output=True).returncode == 0: + return runtime + abort("neither podman nor docker found on PATH") + + +def ensure_aiac_deployed(cfg: Config) -> None: + try: + deployments = kubectl_get_json("deployments", namespace=AIAC_NAMESPACE) + names = {d["metadata"]["name"] for d in deployments.get("items", [])} + except subprocess.CalledProcessError: + names = set() + + if {"aiac-interface", "aiac-agent"} <= names: + note(f"AIAC stack already deployed in {AIAC_NAMESPACE!r} — skipping build/apply") + return + + note("AIAC stack not found — building images, loading into kind, and applying manifests") + runtime = _container_runtime() + cluster_name = "kagenti" + + for image, dockerfile, context in AIAC_IMAGES: + if _image_present(image): + note(f"{image} already present locally, skipping build") + else: + subprocess.run([runtime, "build", "-f", dockerfile, "-t", image, context], cwd=AIAC_ROOT, check=True) + subprocess.run(["kind", "load", "docker-image", image, "--name", cluster_name], check=True) + + for manifest in AIAC_MANIFESTS: + kubectl("apply", "-f", str(AIAC_ROOT / "k8s" / manifest)) + + kubectl("wait", "deployment/aiac-interface", "-n", AIAC_NAMESPACE, "--for=condition=Available", "--timeout=120s") + kubectl("wait", "statefulset/aiac-policy-store", "-n", AIAC_NAMESPACE, "--for=jsonpath={.status.readyReplicas}=1", "--timeout=120s") + kubectl("wait", "deployment/aiac-agent", "-n", AIAC_NAMESPACE, "--for=condition=Available", "--timeout=120s") + ok("AIAC stack deployed") + + +def ensure_workloads_deployed(namespace: str) -> None: + try: + deployments = kubectl_get_json("deployments", namespace=namespace) + names = {d["metadata"]["name"] for d in deployments.get("items", [])} + except subprocess.CalledProcessError: + names = set() + + if {scn.AGENT_WORKLOAD, scn.TOOL_WORKLOAD} <= names: + note(f"demo workloads already deployed in {namespace!r} — skipping install.sh") + # A Deployment object existing is not the same as it being available; a partial or failed + # prior install would otherwise skip repair and fail later at client registration. Wait for + # both to roll out so "already deployed" also means "actually up". + for workload in (scn.AGENT_WORKLOAD, scn.TOOL_WORKLOAD): + kubectl_rollout_status(f"deployment/{workload}", namespace=namespace) + return + + note("demo workloads not found — running demo/assets/install.sh") + subprocess.run(["bash", str(ASSETS_DIR / "install.sh")], env={**os.environ, "NAMESPACE": namespace}, check=True) + ok("demo workloads deployed") + + +# --- the real readiness condition: async Keycloak client registration ------------------------- + + +def wait_for_client_registration(cfg: Config, timeout: float = 180.0) -> None: + from _lib import connect_admin + + admin = connect_admin(cfg) + admin.change_current_realm(cfg.realm) + wanted = {f"{cfg.namespace}/{scn.AGENT_WORKLOAD}", f"{cfg.namespace}/{scn.TOOL_WORKLOAD}"} + deadline = time.time() + timeout + while time.time() < deadline: + names = {c.get("name") for c in admin.get_clients()} + if wanted <= names: + ok(f"both clients registered: {sorted(wanted)}") + return + missing = wanted - names + note(f"waiting on Keycloak client registration (missing: {sorted(missing)}) ...") + time.sleep(5) + abort(f"clients {sorted(wanted - names)} never registered within {timeout}s — check the operator's webhook logs.") + + +def verify_mcp_label(namespace: str) -> None: + label = kubectl( + "get", "service", scn.TOOL_WORKLOAD, "-n", namespace, + "-o", "jsonpath={.metadata.labels.protocol\\.kagenti\\.io/mcp}", + ).strip() + if label != "true": + abort( + f"Service {scn.TOOL_WORKLOAD!r} in {namespace!r} is missing the " + f"protocol.kagenti.io/mcp='true' LABEL (found: {label!r}) — UC-1's analyze_tool will " + f"502 during onboarding. See demo/assets/INSTALL.md." + ) + ok(f"Service {scn.TOOL_WORKLOAD!r} carries protocol.kagenti.io/mcp='true'") + + +def main() -> None: + cfg = load_config() + + say("1", "4", "Verify: cluster + CRDs + namespace + SPIRE + Keycloak") + verify_cluster_reachable() + verify_crds() + verify_namespace(cfg.namespace) + verify_spire() + verify_keycloak(cfg) + + say("2", "4", "Verify/install: AIAC stack") + ensure_aiac_deployed(cfg) + + say("3", "4", "Verify/install: demo workloads (github-agent, github-tool)") + ensure_workloads_deployed(cfg.namespace) + + say("4", "4", "Wait: Keycloak client registration + MCP service label") + wait_for_client_registration(cfg) + verify_mcp_label(cfg.namespace) + + rule() + print("All prerequisites satisfied.") + + +if __name__ == "__main__": + main() diff --git a/aiac/demo/use-cases/uc1-onboarding/init/01-clear.py b/aiac/demo/use-cases/uc1-onboarding/init/01-clear.py new file mode 100644 index 000000000..d9eda5cd2 --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/init/01-clear.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Reset the demo to a clean slate: delete UC-1's provisioned Keycloak roles/scopes, clear the +Policy Store (non-optional — its SQLite survives on a PV and onboarding appends with +``override=False``), and clear both the writer pod's ``/rego`` and the local ``generated/`` copy. + +Kept separate from ``02-setup.py`` so a presenter can re-run just the reset between takes. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +from _lib import GENERATED, cleanup_provisioned, clear_policy_store, clear_writer_rego, connect_admin, load_config, ok, say, writer_pod + + +def main() -> None: + cfg = load_config() + + say("1", "4", "Clear Keycloak: delete github-agent.*/github-tool.* roles + scopes") + admin = connect_admin(cfg) + cleanup_provisioned(admin, cfg) + ok("Keycloak provisioned entities cleared") + + say("2", "4", "Clear Policy Store: DELETE /policy/services") + clear_policy_store(cfg) + ok("Policy Store cleared") + + say("3", "4", "Clear writer pod's /rego") + pod = writer_pod(cfg) + clear_writer_rego(cfg, pod) + ok(f"cleared /rego on pod {pod!r}") + + say("4", "4", "Clear local generated/ snapshots") + if GENERATED.exists(): + shutil.rmtree(GENERATED) + GENERATED.mkdir(parents=True, exist_ok=True) + ok(f"cleared {GENERATED}") + + print("\nBaseline is clean.") + + +if __name__ == "__main__": + main() diff --git a/aiac/demo/use-cases/uc1-onboarding/init/02-setup.py b/aiac/demo/use-cases/uc1-onboarding/init/02-setup.py new file mode 100644 index 000000000..1bedeab6d --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/init/02-setup.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Provision the demo's Keycloak users/roles, mount the PRB's policy.md on the Controller, run the +token-exchange Keycloak setup, and resolve + print both workloads' internal client UUIDs (the +trigger ids ``03-onboard-agent.py``/``04-onboard-tool.py`` need — the ``clientId`` is a slash-bearing +SPIFFE URI the single-segment ``/apply/service/{id}`` route can't carry). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +import scenario as scn +import setup_keycloak +from _lib import connect_admin, ensure_agent_policy, load_config, note, ok, provision_realm_and_users, resolve_service_id, say + + +def main() -> None: + cfg = load_config() + + say("1", "4", "Provision users + roles (with the login-profile fix)") + admin = connect_admin(cfg) + provision_realm_and_users(admin, cfg) + for username, role in scn.USERS.items(): + ok(f"{username} -> {role}") + + say("2", "4", "Mount policy.md on the Controller") + ensure_agent_policy(cfg) + ok(f"policy.md mounted ({len(scn.POLICY_ABSTRACT.splitlines())} lines)") + + say("3", "4", "Resolve client UUIDs + configure token exchange") + agent_uuid = resolve_service_id(admin, cfg, f"{cfg.namespace}/{scn.AGENT_WORKLOAD}") + tool_uuid = resolve_service_id(admin, cfg, f"{cfg.namespace}/{scn.TOOL_WORKLOAD}") + note(f"{scn.AGENT_WORKLOAD} client uuid: {agent_uuid}") + note(f"{scn.TOOL_WORKLOAD} client uuid: {tool_uuid}") + setup_keycloak.run(admin, cfg, agent_uuid=agent_uuid) + ok("token exchange configured") + + say("4", "4", "Done") + print(f"\nAgent service id: {agent_uuid}") + print(f"Tool service id: {tool_uuid}") + print("\nNext: make onboard-agent") + + +if __name__ == "__main__": + main() diff --git a/aiac/demo/use-cases/uc1-onboarding/lib/_lib.py b/aiac/demo/use-cases/uc1-onboarding/lib/_lib.py new file mode 100644 index 000000000..055cc99f8 --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/lib/_lib.py @@ -0,0 +1,567 @@ +"""Shared machinery for the UC-1 onboarding demo's numbered scripts: config, kubectl/opa helpers, +Keycloak helpers, and terminal narration. Standalone by design (some overlap with +``test/integration/launcher.py`` and ``uc1_onboard.py`` is deliberate) — this demo ships and runs +independently of the ``test/`` tree. + +Strictly-live, no-fallback demo: every helper here either succeeds or calls ``abort()``/raises. There +is no offline mode and no silent skip — a missing ``opa`` binary or an unreachable cluster stops the +demo with a clear message, it never quietly does less. +""" + +from __future__ import annotations + +import base64 +import json +import os +import shutil +import subprocess +import sys +import threading +import time +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + +import requests + +import scenario as scn + +HERE = Path(__file__).resolve().parent.parent # lib/ -> uc1-onboarding/ +GENERATED = HERE / "generated" + + +# ====================================================================================== +# Narration — terminal output in the authbridge/demos house style +# ====================================================================================== + +_COLOR = sys.stdout.isatty() + + +def _c(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if _COLOR else text + + +def rule() -> None: + print(_c("2", "-" * 72)) + + +def say(step: str, total: str, title: str) -> None: + """Bold ``[step/total] title`` section header.""" + print() + rule() + print(_c("1", f"[{step}/{total}] {title}")) + rule() + + +def note(msg: str) -> None: + print(f" {_c('2', '▸')} {msg}") + + +def ok(msg: str) -> None: + print(f" {_c('32', '✓')} {msg}") + + +def fail(msg: str) -> None: + print(f" {_c('31', '✗')} {msg}") + + +def blocked(msg: str) -> None: + print(f" {_c('33', '⛔')} {msg}") + + +def table(rows: list[tuple[str, ...]], headers: tuple[str, ...] | None = None) -> None: + all_rows = ([headers] if headers else []) + rows + widths = [max(len(str(r[i])) for r in all_rows) for i in range(len(all_rows[0]))] + if headers: + print(" " + " ".join(str(h).ljust(w) for h, w in zip(headers, widths))) + print(" " + " ".join("-" * w for w in widths)) + for row in rows: + print(" " + " ".join(str(c).ljust(w) for c, w in zip(row, widths))) + + +def abort(msg: str) -> None: + print(f"\n{_c('31;1', 'ABORT')}: {msg}", file=sys.stderr) + raise SystemExit(1) + + +# ====================================================================================== +# Config — read env at call time (not import time), so scripts can be re-run in one process +# ====================================================================================== + + +@dataclass(frozen=True) +class Config: + realm: str + namespace: str + admin_realm: str + + controller_namespace: str + controller_target: str + controller_local_port: int + controller_remote_port: int + + store_namespace: str + store_target: str + store_local_port: int + store_remote_port: int + + controller_deployment: str + policy_configmap: str + policy_mount_path: str + + onboard_timeout: float + + opa_namespace: str + opa_selector: str + opa_container: str + opa_pod: str | None + opa_rego_path: str + + keycloak_url: str + keycloak_admin_username: str + keycloak_admin_password: str + + @property + def agent_slug(self) -> str: + return f"{self.namespace}_{scn.AGENT_WORKLOAD}".replace("-", "_") + + @property + def inbound_rego(self) -> str: + return f"{self.agent_slug}.inbound.rego" + + @property + def outbound_rego(self) -> str: + return f"{self.agent_slug}.outbound.rego" + + +def require_env(*names: str) -> dict[str, str]: + missing = [n for n in names if not os.environ.get(n)] + if missing: + abort("required environment variable(s) not set: " + ", ".join(missing)) + return {n: os.environ[n] for n in names} + + +def load_config() -> Config: + creds = require_env("KEYCLOAK_URL", "KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD") + return Config( + realm=os.environ.get("AIAC_TEST_REALM", scn.REALM_DEFAULT), + namespace=os.environ.get("AIAC_DEMO_NAMESPACE", scn.DEMO_NAMESPACE_DEFAULT), + admin_realm=os.environ.get("KEYCLOAK_ADMIN_REALM", "master"), + controller_namespace=os.environ.get("AIAC_CONTROLLER_NAMESPACE", "aiac-system"), + controller_target=os.environ.get("AIAC_CONTROLLER_TARGET", "svc/aiac-agent-service"), + controller_local_port=int(os.environ.get("AIAC_CONTROLLER_LOCAL_PORT", "7070")), + controller_remote_port=int(os.environ.get("AIAC_CONTROLLER_REMOTE_PORT", "7070")), + store_namespace=os.environ.get("AIAC_STORE_NAMESPACE", "aiac-system"), + store_target=os.environ.get("AIAC_STORE_TARGET", "svc/aiac-policy-store-service"), + store_local_port=int(os.environ.get("AIAC_STORE_LOCAL_PORT", "7074")), + store_remote_port=int(os.environ.get("AIAC_STORE_REMOTE_PORT", "7074")), + controller_deployment=os.environ.get("AIAC_CONTROLLER_DEPLOYMENT", "aiac-agent"), + policy_configmap=os.environ.get("AIAC_POLICY_CONFIGMAP", "aiac-policy"), + policy_mount_path=os.environ.get("AIAC_POLICY_MOUNT_PATH", "/etc/aiac"), + onboard_timeout=float(os.environ.get("AIAC_ONBOARD_TIMEOUT", "900")), + opa_namespace=os.environ.get("AIAC_OPA_NAMESPACE", "aiac-system"), + opa_selector=os.environ.get("AIAC_OPA_SELECTOR", "app=aiac-interface"), + opa_container=os.environ.get("AIAC_OPA_CONTAINER", "aiac-pdp-policy-opa"), + opa_pod=os.environ.get("AIAC_OPA_POD"), + opa_rego_path=os.environ.get("AIAC_OPA_REGO_PATH", "/rego"), + keycloak_url=creds["KEYCLOAK_URL"], + keycloak_admin_username=creds["KEYCLOAK_ADMIN_USERNAME"], + keycloak_admin_password=creds["KEYCLOAK_ADMIN_PASSWORD"], + ) + + +# ====================================================================================== +# kubectl + opa +# ====================================================================================== + + +def kubectl(*args: str, input_text: str | None = None, timeout: float = 60.0) -> str: + proc = subprocess.run( + ["kubectl", *args], input=input_text, capture_output=True, text=True, timeout=timeout + ) + if proc.returncode != 0: + raise subprocess.CalledProcessError( + proc.returncode, ["kubectl", *args], output=proc.stdout, stderr=proc.stderr + ) + return proc.stdout + + +def kubectl_get_json(resource: str, *, namespace: str | None = None) -> dict: + args = ["get", resource, "-o", "json"] + if namespace: + args += ["-n", namespace] + return json.loads(kubectl(*args)) + + +def kubectl_rollout_status(resource: str, *, namespace: str, timeout: float = 180.0) -> None: + kubectl("rollout", "status", resource, "-n", namespace, f"--timeout={int(timeout)}s", timeout=timeout + 10) + + +def kubectl_cp(pod: str, remote_path: str, local_path: Path, *, namespace: str, container: str | None = None) -> None: + args = ["cp", f"{namespace}/{pod}:{remote_path}", str(local_path)] + if container: + args += ["-c", container] + kubectl(*args, timeout=120.0) + + +def resolve_pod(selector: str, *, namespace: str) -> str: + out = kubectl("get", "pods", "-n", namespace, "-l", selector, "-o", "jsonpath={.items[0].metadata.name}") + if not out.strip(): + abort(f"no pod matches selector {selector!r} in namespace {namespace!r}") + return out.strip() + + +def terminate(proc: subprocess.Popen) -> None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +@contextmanager +def port_forward( + target: str, *, namespace: str, local_port: int, remote_port: int, + ready_url: str | None = None, timeout: float = 30.0, +) -> Iterator[str]: + proc = subprocess.Popen( + ["kubectl", "port-forward", "-n", namespace, target, f"{local_port}:{remote_port}"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + base_url = f"http://127.0.0.1:{local_port}" + output: list[str] = [] + forwarding = threading.Event() + + def _drain() -> None: + assert proc.stdout is not None + for line in proc.stdout: + output.append(line) + if "Forwarding from" in line: + forwarding.set() + + reader = threading.Thread(target=_drain, daemon=True) + reader.start() + try: + deadline = time.time() + timeout + ready = False + while time.time() < deadline: + if proc.poll() is not None: + reader.join(timeout=1) + abort(f"port-forward to {target} exited early: {''.join(output).strip()}") + if ready_url is None: + if forwarding.wait(timeout=0.3): + ready = True + break + else: + try: + requests.get(ready_url, timeout=1) + ready = True + break + except requests.RequestException: + time.sleep(0.3) + if not ready: + abort(f"port-forward to {target} not ready within {timeout}s: {''.join(output).strip()}") + yield base_url + finally: + terminate(proc) + reader.join(timeout=1) + + +def opa_bin() -> str: + found = os.environ.get("OPA_BIN") or shutil.which("opa") + if not found: + abort("opa binary not found — install opa or set OPA_BIN (this demo has no fallback verifier)") + return found + + +def opa_eval(rego_paths: list[Path], query: str, input_doc: dict) -> object: + cmd = [opa_bin(), "eval", "-f", "json", *sum((["-d", str(p)] for p in rego_paths), []), "--stdin-input", query] + try: + proc = subprocess.run(cmd, input=json.dumps(input_doc), capture_output=True, text=True, timeout=30) + except subprocess.TimeoutExpired: + abort(f"opa eval timed out after 30s for {query!r} against {rego_paths}") + if proc.returncode != 0: + abort(f"opa eval failed for {query!r}: {proc.stderr.strip()}") + try: + return json.loads(proc.stdout)["result"][0]["expressions"][0]["value"] + except (KeyError, IndexError) as exc: + abort(f"opa eval produced no value for {query!r} against {rego_paths}: {exc}") + + +# ====================================================================================== +# Keycloak +# ====================================================================================== + + +def connect_admin(cfg: Config): + from keycloak import KeycloakAdmin + + return KeycloakAdmin( + server_url=cfg.keycloak_url, + realm_name=cfg.admin_realm, + user_realm_name=cfg.admin_realm, + username=cfg.keycloak_admin_username, + password=cfg.keycloak_admin_password, + ) + + +def provision_realm_and_users(admin, cfg: Config) -> None: + """Idempotently ensure the realm holds the demo's users + realm roles, with the description + text the PRB reads and the ``aiac.managed`` marker that makes the IdP populate ``actorIds``. + + Also sets ``email``/``firstName``/``lastName``/``emailVerified`` on every user — Keycloak 26's + declarative user profile requires them for role ``user`` before ``grant_type=password`` will + succeed, and this demo, unlike the pytest ladder, actually performs a real ROPC login.""" + from keycloak.exceptions import KeycloakError + + try: + admin.create_realm({"realm": cfg.realm, "enabled": True}) + except KeycloakError: + pass + admin.change_current_realm(cfg.realm) + + for name, description in scn.USER_ROLES.items(): + payload = {"name": name, "description": description, "attributes": {"aiac.managed": ["true"]}} + admin.create_realm_role(payload, skip_exists=True) + admin.update_realm_role(name, payload) + + for username, role_name in scn.USERS.items(): + user_id = admin.create_user({"username": username, "enabled": True}, exist_ok=True) + admin.set_user_password(user_id, scn.USER_PASSWORD, temporary=False) + admin.assign_realm_roles(user_id, [admin.get_realm_role(role_name)]) + profile = scn.USER_PROFILE[username] + admin.update_user(user_id, {**profile, "emailVerified": True}) + + +def resolve_service_id(admin, cfg: Config, client_name: str) -> str: + admin.change_current_realm(cfg.realm) + for client in admin.get_clients(): + if client.get("name") == client_name: + return client["id"] + abort(f"no Keycloak client with name {client_name!r} in realm {cfg.realm!r}") + + +def cleanup_provisioned(admin, cfg: Config) -> None: + """Delete the realm roles + client scopes UC-1 onboarding provisions (prefixed + ``github-agent.``/``github-tool.``). Leaves the demo's own developer/tester/devops roles and the + operator's audience client scopes (``*-aud``) in place.""" + from keycloak.exceptions import KeycloakError + + admin.change_current_realm(cfg.realm) + prefixes = (f"{scn.AGENT_WORKLOAD}.", f"{scn.TOOL_WORKLOAD}.") + + for role in admin.get_realm_roles(): + name = role.get("name", "") + if name.startswith(prefixes): + try: + admin.delete_realm_role(name) + except KeycloakError: + pass + + for scope in admin.get_client_scopes(): + name = scope.get("name", "") + if name.startswith(prefixes): + try: + admin.delete_client_scope(scope["id"]) + except KeycloakError: + pass + + +def client_secret(admin, cfg: Config, client_uuid: str) -> str: + admin.change_current_realm(cfg.realm) + return admin.get_client_secrets(client_uuid)["value"] + + +def clear_policy_store(cfg: Config) -> None: + """``DELETE /policy/services`` on the in-cluster Policy Store — non-optional. Its SQLite lives + on a surviving PV and onboarding appends with ``override=False``, so a store that answers with a + non-2xx means the clear actually failed and this run would proceed on dirty state.""" + with port_forward( + cfg.store_target, namespace=cfg.store_namespace, + local_port=cfg.store_local_port, remote_port=cfg.store_remote_port, + ready_url=f"http://127.0.0.1:{cfg.store_local_port}/health", + ) as base_url: + resp = requests.delete(f"{base_url}/policy/services", timeout=30) + if not (200 <= resp.status_code < 300): + abort(f"clear_policy_store: DELETE /policy/services returned HTTP {resp.status_code}: {resp.text[:500]}") + + +def ensure_agent_policy(cfg: Config) -> None: + """Ensure the PRB's ``policy.md`` (``scenario.POLICY_ABSTRACT``) is mounted on the Controller + Deployment, rolling out only when the ConfigMap content or the mount actually changed.""" + cm = { + "apiVersion": "v1", "kind": "ConfigMap", + "metadata": {"name": cfg.policy_configmap, "namespace": cfg.controller_namespace}, + "data": {"policy.md": scn.POLICY_ABSTRACT}, + } + apply_out = kubectl("apply", "-f", "-", input_text=json.dumps(cm)) + cm_changed = "unchanged" not in apply_out + + mounted = kubectl( + "get", "deployment", cfg.controller_deployment, "-n", cfg.controller_namespace, + "-o", "jsonpath={.spec.template.spec.volumes[*].configMap.name}", + ) + if cfg.policy_configmap in mounted.split(): + if cm_changed: + kubectl("rollout", "restart", f"deployment/{cfg.controller_deployment}", "-n", cfg.controller_namespace) + kubectl_rollout_status(f"deployment/{cfg.controller_deployment}", namespace=cfg.controller_namespace) + return + + patch = { + "spec": {"template": {"spec": { + "volumes": [{"name": "aiac-policy", "configMap": {"name": cfg.policy_configmap}}], + "containers": [{ + "name": cfg.controller_deployment, + "volumeMounts": [{"name": "aiac-policy", "mountPath": cfg.policy_mount_path, "readOnly": True}], + }], + }}} + } + kubectl("patch", "deployment", cfg.controller_deployment, "-n", cfg.controller_namespace, "--type", "strategic", "-p", json.dumps(patch)) + kubectl_rollout_status(f"deployment/{cfg.controller_deployment}", namespace=cfg.controller_namespace) + + +def onboard(cfg: Config, base_url: str, service_id: str) -> None: + resp = requests.post(f"{base_url}/apply/service/{service_id}", timeout=cfg.onboard_timeout) + if resp.status_code != 200: + abort(f"onboard {service_id!r} at {base_url}: HTTP {resp.status_code} — {resp.text[:500]}") + + +def writer_pod(cfg: Config) -> str: + return cfg.opa_pod or resolve_pod(cfg.opa_selector, namespace=cfg.opa_namespace) + + +def clear_writer_rego(cfg: Config, pod: str) -> None: + kubectl( + "exec", "-n", cfg.opa_namespace, pod, "-c", cfg.opa_container, "--", + "sh", "-c", f"rm -f {cfg.opa_rego_path.rstrip('/')}/*.rego", + ) + + +def capture_rego(cfg: Config, pod: str, rego_dir: Path) -> None: + rego_dir.mkdir(parents=True, exist_ok=True) + for filename in (cfg.inbound_rego, cfg.outbound_rego): + kubectl_cp( + pod, f"{cfg.opa_rego_path.rstrip('/')}/{filename}", rego_dir / filename, + namespace=cfg.opa_namespace, container=cfg.opa_container, + ) + + +# ====================================================================================== +# ROPC login + RFC 8693 token exchange +# ====================================================================================== + + +def decode_jwt_claims(token: str) -> dict: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + + +def ropc_login(cfg: Config, client_id: str, username: str, password: str) -> dict: + """``grant_type=password`` against ``client_id`` (a public client with direct-access-grants + enabled). Aborts on any non-token response — this demo does a real login, not a stub.""" + resp = requests.post( + f"{cfg.keycloak_url}/realms/{cfg.realm}/protocol/openid-connect/token", + data={"grant_type": "password", "client_id": client_id, "username": username, "password": password, "scope": "openid"}, + timeout=15, + ) + body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + if resp.status_code != 200 or "access_token" not in body: + abort(f"ROPC login for {username!r} via {client_id!r} failed: HTTP {resp.status_code} — {body or resp.text[:300]}") + return body + + +def token_exchange(cfg: Config, *, client_id: str, client_secret_value: str, subject_token: str, audience: str) -> dict: + """RFC 8693 token exchange. Client auth MUST be form-encoded, not HTTP Basic — ``client_id`` is + a SPIFFE URI containing ``://``, which breaks Basic-auth credential parsing.""" + resp = requests.post( + f"{cfg.keycloak_url}/realms/{cfg.realm}/protocol/openid-connect/token", + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "client_id": client_id, + "client_secret": client_secret_value, + "subject_token": subject_token, + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "audience": audience, + }, + timeout=15, + ) + body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + if resp.status_code != 200 or "access_token" not in body: + abort(f"token exchange (audience={audience!r}) failed: HTTP {resp.status_code} — {body or resp.text[:300]}") + return body + + +# ====================================================================================== +# drive() — the shared engine behind run-developer.py / run-tester.py / run-devops.py +# ====================================================================================== + + +def drive(username: str) -> None: + """Run one user's intents end to end against ``generated/02-after-tool/``: ROPC login, the + inbound gate (stopping — as a feature, not an error — on denial), an RFC 8693 exchange proving + the live flow, then the per-intent outbound gate. Every verdict is checked against + ``scenario.expected_inbound``/``expected_outbound``; any mismatch aborts naming the offending + ``(subject, function_name)`` rather than printing a quietly-wrong table.""" + cfg = load_config() + role = scn.USERS[username] + rego_dir = GENERATED / "02-after-tool" + inbound_rego = rego_dir / cfg.inbound_rego + outbound_rego = rego_dir / cfg.outbound_rego + if not (inbound_rego.is_file() and outbound_rego.is_file()): + abort( + f"no policy found at {rego_dir} — run `make onboard-agent` and `make onboard-tool` first " + "(run-*.py always drives against the after-tool snapshot)" + ) + + admin = connect_admin(cfg) + + say("1", "3", f"{username} ({role}): ROPC login") + login = ropc_login(cfg, scn.ROPC_CLIENT_ID, username, scn.USER_PASSWORD) + subject_token = login["access_token"] + ok("logged in") + + say("2", "3", "Inbound gate: may this user call the agent?") + inbound_allowed = bool(opa_eval([inbound_rego], f"data.authz.{cfg.agent_slug}.inbound.allow", {"subject": username})) + expected_in = scn.expected_inbound(username) + if inbound_allowed != expected_in: + abort(f"inbound mismatch for subject={username!r}: opa said {inbound_allowed}, expected {expected_in}") + + if not inbound_allowed: + intent = scn.INTENTS[username][0] + blocked(f"{intent.label!r} -> blocked at inbound (the intended {username} story, not an error)") + table([(username, role, intent.label, "blocked at inbound")], headers=("user", "role", "intent", "result")) + return + ok("inbound allowed") + + say("3", "3", "Per-intent outbound gate (via a real RFC 8693 exchange)") + agent_uuid = resolve_service_id(admin, cfg, f"{cfg.namespace}/{scn.AGENT_WORKLOAD}") + agent_client_id = admin.get_client(agent_uuid)["clientId"] + secret = client_secret(admin, cfg, agent_uuid) + + target_scopes = opa_eval([outbound_rego], f"data.authz.{cfg.agent_slug}.outbound.target_scopes", {}) or {} + if not target_scopes: + abort(f"outbound rego at {outbound_rego} has no target_scopes — is the tool onboarded?") + target_uri = next(iter(target_scopes)) + + token_exchange(cfg, client_id=agent_client_id, client_secret_value=secret, subject_token=subject_token, audience=target_uri) + note(f"exchanged token; aud includes {target_uri}") + + rows: list[tuple[str, ...]] = [] + for intent in scn.INTENTS[username]: + allowed = bool(opa_eval( + [outbound_rego], f"data.authz.{cfg.agent_slug}.outbound.allow", + {"subject": username, "function_name": intent.function_name, "target": target_uri}, + )) + expected_out = scn.expected_outbound(username, intent.function_name) + if allowed != expected_out: + abort( + f"outbound mismatch for (subject={username!r}, function_name={intent.function_name!r}): " + f"opa said {allowed}, expected {expected_out}" + ) + (ok if allowed else fail)(f"{intent.label} -> {intent.function_name}: {'allowed' if allowed else 'denied'}") + rows.append((username, intent.label, intent.function_name, "allowed" if allowed else "denied")) + + print() + table(rows, headers=("user", "intent", "tool scope", "result")) diff --git a/aiac/demo/use-cases/uc1-onboarding/lib/scenario.py b/aiac/demo/use-cases/uc1-onboarding/lib/scenario.py new file mode 100644 index 000000000..34371bb69 --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/lib/scenario.py @@ -0,0 +1,164 @@ +"""The UC-1 onboarding demo's scenario data — a standalone copy of the facts +``test/integration/scenario_uc1.py`` encodes for the pytest ladder, plus the extra data this demo +needs and pytest does not (login profile fields, human-readable intents). Deliberately not imported +from ``test/`` — this demo ships outside the test tree and must run with no ``aiac`` checkout beyond +``demo/``. + +Pure data + pure functions: no imports beyond the standard library, no cluster/Keycloak access. The +``expected_*`` helpers are the oracle every driver script (``run-developer.py`` etc.) checks live +decisions against — computed from the pair-lists below, never read from the Rego under test. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# --- Realm + deployment identifiers --------------------------------------------------------- + +REALM_DEFAULT = "kagenti" +DEMO_NAMESPACE_DEFAULT = "team1" +AGENT_WORKLOAD = "github-agent" +TOOL_WORKLOAD = "github-tool" + +# Public ROPC client the demo's run-*.py drivers log the demo users in with (see lib/setup_keycloak.py). +ROPC_CLIENT_ID = "aiac-demo-cli" + +# username -> the realm role the user holds +USERS: dict[str, str] = { + "dev-user": "developer", + "test-user": "tester", + "devops-user": "devops", +} + +# Demo-only shared credential for the ephemeral Keycloak users; never used outside +# local/CI throwaway clusters. Not a production or secret value. +USER_PASSWORD = "password" + +# Keycloak 26's declarative user profile requires email/firstName/lastName for role "user" before +# grant_type=password will succeed (VERIFY_PROFILE). Not needed by the pytest ladder (it never logs +# in as these users), but load-bearing here — the whole point of this demo is a real ROPC login. +USER_PROFILE: dict[str, dict[str, str]] = { + "dev-user": {"email": "dev-user@uc1.demo", "firstName": "Dev", "lastName": "User"}, + "test-user": {"email": "test-user@uc1.demo", "firstName": "Test", "lastName": "User"}, + "devops-user": {"email": "devops-user@uc1.demo", "firstName": "Devops", "lastName": "User"}, +} + +# --- Realm-role descriptions (the PRB reads these when expanding the abstract policy) -------- + +USER_ROLES: dict[str, str] = { + "developer": ( + "Developer — an engineering user who develops the source codebase (writing and maintaining " + "code) and fixes code defects reported in the issue tracker; works primarily in source and " + "consults issues for defect reports." + ), + "tester": ( + "Tester — a quality-assurance user who verifies software quality and tracks defects through " + "the issue tracker: filing, triaging, and updating issue reports; works in the issue " + "tracker, not in source." + ), + "devops": ( + "DevOps — an operations user who manages deployment infrastructure and runtime " + "environments; does not author source code and does not manage the issue tracker." + ), +} + +# --- Discovered entities (what real UC-1 onboarding provisions; recorded here for the oracle) - + +AGENT_SCOPES: dict[str, str] = { + "github-agent.source_operations": ( + "Browse and search code; read, create, and modify repository file contents, branches, " + "and commits." + ), + "github-agent.issue_operations": ( + "Read, search, create, and update issues, comments, sub-issues, and pull requests." + ), +} + +AGENT_ROLES: dict[str, str] = dict(AGENT_SCOPES) + +TOOL_SCOPES: dict[str, str] = { + "github-tool.source-read": "Read source repository contents: file listings and file bodies. Read-only.", + "github-tool.source-write": "Create, modify, or delete source repository contents; commit file changes.", + "github-tool.issues-read": "Read issues and their comment threads. Read-only.", + "github-tool.issues-write": "Create and update issues: open, edit, comment, and close.", +} + +# --- Role -> access facts (the single source of truth the oracle is computed from) ----------- + +INBOUND_PAIRS: list[tuple[str, str]] = [ + ("developer", "github-agent.source_operations"), + ("developer", "github-agent.issue_operations"), + ("tester", "github-agent.issue_operations"), +] + +OUTBOUND_SUBJECT_PAIRS: list[tuple[str, str]] = [ + ("developer", "github-tool.source-read"), + ("developer", "github-tool.source-write"), + ("developer", "github-tool.issues-read"), + ("tester", "github-tool.issues-read"), + ("tester", "github-tool.issues-write"), +] + +OUTBOUND_TARGET_PAIRS: list[tuple[str, str]] = [ + ("github-agent.source_operations", "github-tool.source-read"), + ("github-agent.source_operations", "github-tool.source-write"), + ("github-agent.issue_operations", "github-tool.issues-read"), + ("github-agent.issue_operations", "github-tool.issues-write"), +] + +# --- The single abstract policy.md the PRB reads (verbatim; also shown in demo.md) ----------- + +POLICY_ABSTRACT = """\ +Grant access on a least-privilege basis: allow only what this policy states; deny by default. + +- Developers may read and modify source, and read issues. +- Testers may read and modify issues. +""" + + +# --- Human intents driven at the terminal — fixed mapping, no LLM, so the demo is deterministic + +@dataclass(frozen=True) +class Intent: + label: str # what the driver script prints before asking the tool + function_name: str | None # the tool scope this intent maps to (None = inbound-only denial case) + + +INTENTS: dict[str, list[Intent]] = { + "dev-user": [ + Intent("read a file from the repo", "github-tool.source-read"), + Intent("commit a small fix", "github-tool.source-write"), + Intent("check an issue for repro steps", "github-tool.issues-read"), + Intent("close out the issue", "github-tool.issues-write"), + ], + "test-user": [ + Intent("check an issue for repro steps", "github-tool.issues-read"), + Intent("file a new bug", "github-tool.issues-write"), + Intent("read a file from the repo", "github-tool.source-read"), + ], + "devops-user": [ + Intent("ask the agent anything", None), # inbound denial — no function_name reached + ], +} + + +# --- Oracle: expected verdicts, computed from the pair-lists above, never from generated Rego - + +_INBOUND_SOURCES = {role for role, _ in INBOUND_PAIRS} +INBOUND_GRANT_SET: set[tuple[str, str]] = set(INBOUND_PAIRS) +OUTBOUND_SUBJECT_GRANT_SET: set[tuple[str, str]] = set(OUTBOUND_SUBJECT_PAIRS) +OUTBOUND_TARGET_GRANT_SET: set[str] = {fn for _, fn in OUTBOUND_TARGET_PAIRS} + + +def expected_inbound(subject: str) -> bool: + """A user may call the agent iff their realm role sources some agent scope.""" + return USERS[subject] in _INBOUND_SOURCES + + +def expected_outbound(subject: str, function_name: str) -> bool: + """A user may reach a tool scope iff both gates pass (per-scope AND): their realm role is + granted it in the user->tool subject gate, and the agent's own operator roles reach it in the + capability gate.""" + user_ok = (USERS[subject], function_name) in OUTBOUND_SUBJECT_GRANT_SET + agent_ok = function_name in OUTBOUND_TARGET_GRANT_SET + return user_ok and agent_ok diff --git a/aiac/demo/use-cases/uc1-onboarding/lib/setup_keycloak.py b/aiac/demo/use-cases/uc1-onboarding/lib/setup_keycloak.py new file mode 100644 index 000000000..06cb70a2b --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/lib/setup_keycloak.py @@ -0,0 +1,82 @@ +"""Idempotent Keycloak setup for the RFC 8693 token exchange this demo's ``run-*.py`` scripts +perform: an ROPC client for the demo users, and the exchange enablement on the agent client. + +Every step is wrapped so an already-satisfied condition prints a note rather than aborting — this +demo runs against both the freshly-installed cluster (where none of this exists yet) and the +already-configured one this was developed against (where all of it is already true). + +Not a standalone script — imported by ``02-setup.py``, its sole caller (unlike ``scenario.py``/ +``_lib.py``, which are shared across ``init/``/``onboard/``/``run/`` and live in ``lib/``). +""" + +from __future__ import annotations + +import scenario as scn +from _lib import Config, note, ok + + +def ensure_ropc_client(admin, cfg: Config) -> None: + """Public client with direct-access-grants enabled, so ``grant_type=password`` needs no client + secret. Full-scope-allowed (the admin API default for a freshly created client) is what makes + the demo users' access tokens carry ``realm_access.roles`` and the tool audience — a client + created with a narrower default would silently produce tokens this demo can't drive with.""" + admin.change_current_realm(cfg.realm) + if admin.get_client_id(scn.ROPC_CLIENT_ID) is not None: + note(f"ROPC client {scn.ROPC_CLIENT_ID!r} already exists") + return + admin.create_client({ + "clientId": scn.ROPC_CLIENT_ID, + "publicClient": True, + "directAccessGrantsEnabled": True, + "standardFlowEnabled": False, + "enabled": True, + }) + ok(f"created ROPC client {scn.ROPC_CLIENT_ID!r}") + + +def ensure_token_exchange_enabled(admin, cfg: Config, client_uuid: str, client_name: str) -> None: + """Set ``standard.token.exchange.enabled=true`` on the agent client — the modern (Keycloak 26) + RFC 8693 enablement, a client attribute rather than an authorization-services permission.""" + admin.change_current_realm(cfg.realm) + client = admin.get_client(client_uuid) + attrs = client.get("attributes", {}) + if attrs.get("standard.token.exchange.enabled") == "true": + note(f"token exchange already enabled on {client_name!r}") + return + attrs["standard.token.exchange.enabled"] = "true" + # update_client issues a PUT that replaces the whole client representation, so send the full + # fetched client with only ``attributes`` overridden — a bare {"attributes": ...} would clobber + # the client's other fields. + admin.update_client(client_uuid, {**client, "attributes": attrs}) + ok(f"enabled standard.token.exchange.enabled on {client_name!r}") + + +def ensure_default_audience_scope(admin, cfg: Config, client_uuid: str, client_name: str, scope_name: str) -> bool: + """Ensure ``scope_name`` (the tool's ``*-aud`` audience client scope) is a DEFAULT scope on the + agent client, so an exchanged token's ``aud`` includes the tool without the caller requesting it + explicitly. + + Returns ``True`` when the scope is now assigned (added here or already present) and ``False`` + when the scope does not exist yet, so the scope was skipped. The ``02-setup.py`` path calls this + before the tool is onboarded, where ``False`` is expected; the ``04-onboard-tool.py`` path calls + it afterwards, where ``False`` means the token-exchange audience is missing and onboarding must + not report success.""" + admin.change_current_realm(cfg.realm) + scope = admin.get_client_scope_by_name(scope_name) + if scope is None: + note(f"client scope {scope_name!r} does not exist yet (tool not onboarded?) — skipping") + return False + existing = {s["name"] for s in admin.get_client_default_client_scopes(client_uuid)} + if scope_name in existing: + note(f"{scope_name!r} already a default scope on {client_name!r}") + return True + admin.add_client_default_client_scope(client_uuid, scope["id"], {}) + ok(f"added {scope_name!r} as a default scope on {client_name!r}") + return True + + +def run(admin, cfg: Config, *, agent_uuid: str) -> None: + ensure_ropc_client(admin, cfg) + ensure_token_exchange_enabled(admin, cfg, agent_uuid, scn.AGENT_WORKLOAD) + tool_aud_scope = f"agent-{cfg.namespace}-{scn.TOOL_WORKLOAD}-aud" + ensure_default_audience_scope(admin, cfg, agent_uuid, scn.AGENT_WORKLOAD, tool_aud_scope) diff --git a/aiac/demo/use-cases/uc1-onboarding/onboard/03-onboard-agent.py b/aiac/demo/use-cases/uc1-onboarding/onboard/03-onboard-agent.py new file mode 100644 index 000000000..1fa564b13 --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/onboard/03-onboard-agent.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Onboard the ``github-agent`` workload: ``POST /apply/service/{uuid}`` behind a port-forward to +the Controller, then capture the generated ``.rego`` into ``generated/01-after-agent/`` — the first +pause's evidence (before the tool exists, the agent's outbound gate is still empty).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +import scenario as scn +from _lib import GENERATED, capture_rego, connect_admin, load_config, note, ok, onboard, port_forward, resolve_service_id, say, writer_pod + + +def main() -> None: + cfg = load_config() + admin = connect_admin(cfg) + + say("1", "3", f"Resolve {scn.AGENT_WORKLOAD} service id") + service_id = resolve_service_id(admin, cfg, f"{cfg.namespace}/{scn.AGENT_WORKLOAD}") + note(f"service id: {service_id}") + + say("2", "3", "Onboard (POST /apply/service/{id}) — this drives the PRB and can take minutes") + with port_forward(cfg.controller_target, namespace=cfg.controller_namespace, local_port=cfg.controller_local_port, remote_port=cfg.controller_remote_port) as base_url: + onboard(cfg, base_url, service_id) + ok("onboarding call returned 200") + + say("3", "3", "Capture generated Rego") + rego_dir = GENERATED / "01-after-agent" + pod = writer_pod(cfg) + capture_rego(cfg, pod, rego_dir) + for f in (cfg.inbound_rego, cfg.outbound_rego): + ok(f"{rego_dir / f}") + + print(f"\nAgent onboarded. Snapshot: {rego_dir}") + print("Next: make show (or: make onboard-tool)") + + +if __name__ == "__main__": + main() diff --git a/aiac/demo/use-cases/uc1-onboarding/onboard/04-onboard-tool.py b/aiac/demo/use-cases/uc1-onboarding/onboard/04-onboard-tool.py new file mode 100644 index 000000000..5b7029a11 --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/onboard/04-onboard-tool.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Onboard the ``github-tool`` workload: ``POST /apply/service/{uuid}`` behind a port-forward to +the Controller, then capture the agent's ``.rego`` into ``generated/02-after-tool/`` — the second +pause's evidence. Onboarding the tool retroactively completes the agent's outbound gate (the tool +is a pure target: no ``.rego`` is emitted for it directly), so only the agent's two files are +copied, into a directory separate from ``01-after-agent/`` — the before/after diff is the point.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +import scenario as scn +import setup_keycloak +from _lib import GENERATED, abort, capture_rego, connect_admin, load_config, note, ok, onboard, port_forward, resolve_service_id, say, writer_pod + + +def main() -> None: + cfg = load_config() + admin = connect_admin(cfg) + + say("1", "4", f"Resolve {scn.TOOL_WORKLOAD} service id") + service_id = resolve_service_id(admin, cfg, f"{cfg.namespace}/{scn.TOOL_WORKLOAD}") + note(f"service id: {service_id}") + + say("2", "4", "Onboard (POST /apply/service/{id}) — this drives the PRB and can take minutes") + with port_forward(cfg.controller_target, namespace=cfg.controller_namespace, local_port=cfg.controller_local_port, remote_port=cfg.controller_remote_port) as base_url: + onboard(cfg, base_url, service_id) + ok("onboarding call returned 200") + + say("3", "4", "Capture generated Rego (agent's, retroactively completed)") + rego_dir = GENERATED / "02-after-tool" + pod = writer_pod(cfg) + capture_rego(cfg, pod, rego_dir) + for f in (cfg.inbound_rego, cfg.outbound_rego): + ok(f"{rego_dir / f}") + + # The tool's ``*-aud`` audience client scope only exists once the tool is onboarded, so 02-setup.py + # could not yet assign it as a default scope on the agent client. Do it now, so an exchanged token's + # ``aud`` reaches the tool without the caller requesting the scope explicitly. Idempotent. + say("4", "4", "Assign the tool-audience default scope to the agent client") + agent_uuid = resolve_service_id(admin, cfg, f"{cfg.namespace}/{scn.AGENT_WORKLOAD}") + tool_aud_scope = f"agent-{cfg.namespace}-{scn.TOOL_WORKLOAD}-aud" + # Unlike 02-setup.py (which runs before the tool exists, so a missing scope is expected), here the + # tool has just been onboarded — the ``*-aud`` scope must exist now. If it doesn't, the exchanged + # token would lack the tool audience and downstream calls would silently fail, so abort rather + # than report a success the token exchange can't back up. + if not setup_keycloak.ensure_default_audience_scope(admin, cfg, agent_uuid, scn.AGENT_WORKLOAD, tool_aud_scope): + abort(f"tool-audience scope {tool_aud_scope!r} not found after onboarding {scn.TOOL_WORKLOAD} — " + "the agent's exchanged tokens would lack the tool audience; check the onboarding call above") + + print(f"\nTool onboarded. Snapshot: {rego_dir}") + print("Next: make show (or: make dev / make test / make devops)") + + +if __name__ == "__main__": + main() diff --git a/aiac/demo/use-cases/uc1-onboarding/run/run-developer.py b/aiac/demo/use-cases/uc1-onboarding/run/run-developer.py new file mode 100644 index 000000000..b2af7532e --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/run/run-developer.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Drive dev-user's intents through the demo: ROPC login, inbound gate, a real RFC 8693 token +exchange, then the per-intent outbound gate — proving developer least-privilege end to end against +``generated/02-after-tool/``.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +from _lib import drive + +if __name__ == "__main__": + drive("dev-user") diff --git a/aiac/demo/use-cases/uc1-onboarding/run/run-devops.py b/aiac/demo/use-cases/uc1-onboarding/run/run-devops.py new file mode 100644 index 000000000..345a5ab95 --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/run/run-devops.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Drive devops-user through the demo: ROPC login, then the inbound gate — where devops-user is +denied. Stopping there is the intended story (devops-user has no realm role that sources any agent +scope), not an error; the outbound gate and the RFC 8693 exchange are never reached for this user.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +from _lib import drive + +if __name__ == "__main__": + drive("devops-user") diff --git a/aiac/demo/use-cases/uc1-onboarding/run/run-tester.py b/aiac/demo/use-cases/uc1-onboarding/run/run-tester.py new file mode 100644 index 000000000..22ab96b7b --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/run/run-tester.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Drive test-user's intents through the demo: ROPC login, inbound gate, a real RFC 8693 token +exchange, then the per-intent outbound gate — proving tester least-privilege end to end against +``generated/02-after-tool/``.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +from _lib import drive + +if __name__ == "__main__": + drive("test-user") diff --git a/aiac/demo/use-cases/uc1-onboarding/show-state.py b/aiac/demo/use-cases/uc1-onboarding/show-state.py new file mode 100644 index 000000000..9a305d6b5 --- /dev/null +++ b/aiac/demo/use-cases/uc1-onboarding/show-state.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Show the demo's current state: live Keycloak roles/scopes, and the generated Rego for a +snapshot. Serves all three pauses (baseline, after-agent, after-tool) from one implementation. + + show-state.py # latest snapshot (or "no policy generated yet" at baseline) + show-state.py --snapshot NAME # a specific generated/ snapshot + show-state.py --diff PRIOR # diff the current default snapshot against generated/ + +The diff narrates the raw ``.rego`` text (line-oriented, so a presenter can see it) but only +*asserts* on order-independent ``(role, scope)`` sets — the writer's list ordering is not stable +across runs, and a text diff on unstable ordering would show noise, not signal. +""" + +from __future__ import annotations + +import argparse +import difflib +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) + +import scenario as scn +from _lib import GENERATED, abort, connect_admin, load_config, note, opa_eval, rule, say, table + + +def latest_snapshot() -> Path | None: + if not GENERATED.exists(): + return None + snapshots = sorted(p for p in GENERATED.iterdir() if p.is_dir()) + return snapshots[-1] if snapshots else None + + +def show_keycloak(admin, cfg) -> None: + admin.change_current_realm(cfg.realm) + say("A", "B", "Live Keycloak state") + + print(" Users + roles:") + for username, role in scn.USERS.items(): + print(f" {username:14s} -> {role}") + + all_roles = {r["name"]: r.get("description", "") for r in admin.get_realm_roles()} + prefixes = (f"{scn.AGENT_WORKLOAD}.", f"{scn.TOOL_WORKLOAD}.") + provisioned_roles = {n: d for n, d in all_roles.items() if n.startswith(prefixes)} + print(f"\n Provisioned operator roles ({scn.AGENT_WORKLOAD}.*): {len(provisioned_roles) or 'none yet'}") + for name, desc in sorted(provisioned_roles.items()): + note(f"{name} — {desc}") + + all_scopes = {s["name"]: s.get("description", "") for s in admin.get_client_scopes()} + provisioned_scopes = {n: d for n, d in all_scopes.items() if n.startswith(prefixes)} + print(f"\n Provisioned client scopes ({scn.AGENT_WORKLOAD}.*/{scn.TOOL_WORKLOAD}.*): {len(provisioned_scopes) or 'none yet'}") + for name, desc in sorted(provisioned_scopes.items()): + note(f"{name} — {desc}") + + +def grant_sets(cfg, rego_dir: Path) -> tuple[set[tuple[str, str]], set[tuple[str, str]]]: + inbound_rego = rego_dir / cfg.inbound_rego + outbound_rego = rego_dir / cfg.outbound_rego + role_scopes = opa_eval([inbound_rego], f"data.authz.{cfg.agent_slug}.inbound.role_scopes", {}) or {} + agent_scopes = set(opa_eval([inbound_rego], f"data.authz.{cfg.agent_slug}.inbound.agent_scopes", {}) or []) + inbound = {(role, scope) for role, scopes in role_scopes.items() for scope in scopes if scope in agent_scopes} + + subj_scopes = opa_eval([outbound_rego], f"data.authz.{cfg.agent_slug}.outbound.subject_role_scopes", {}) or {} + outbound = {(role, scope) for role, scopes in subj_scopes.items() for scope in scopes} + return inbound, outbound + + +def show_snapshot(cfg, rego_dir: Path) -> None: + say("B", "B", f"Generated policy: {rego_dir.relative_to(GENERATED.parent)}") + files_present = [f for f in (cfg.inbound_rego, cfg.outbound_rego) if (rego_dir / f).is_file()] + if not files_present: + print(" (no policy generated yet)") + return + + for f in files_present: + rule() + print(f" {f}:") + print((rego_dir / f).read_text()) + + # grant_sets loads BOTH Rego files; an interrupted capture that left only one would abort here + # after printing partial state. Only tally grants when the snapshot is complete. + if len(files_present) < 2: + rule() + print(" (incomplete snapshot — grant tallies need both Rego files; skipping)") + return + + inbound, outbound = grant_sets(cfg, rego_dir) + rule() + print(f" inbound grants: {len(inbound)}") + table(sorted(inbound), headers=("role", "agent scope")) + print(f"\n outbound grants: {len(outbound)}") + table(sorted(outbound), headers=("role", "tool scope")) + + +def _complete(cfg, d: Path) -> bool: + """True only when both Rego files are present — grant_sets loads both, so a half-captured + snapshot cannot be diffed.""" + return (d / cfg.inbound_rego).is_file() and (d / cfg.outbound_rego).is_file() + + +def show_diff(cfg, prior_name: str, current_dir: Path) -> None: + prior_dir = GENERATED / prior_name + if not _complete(cfg, prior_dir): + abort( + f"prior snapshot {prior_name!r} is incomplete under {prior_dir} — need both " + f"{cfg.inbound_rego} and {cfg.outbound_rego}. Run the earlier onboarding step first, " + "or pass a snapshot that exists." + ) + if not _complete(cfg, current_dir): + abort( + f"current snapshot {current_dir} is incomplete — need both {cfg.inbound_rego} and " + f"{cfg.outbound_rego}. Re-run the onboarding step that captures it." + ) + say("B", "B", f"Diff: {prior_dir.name} -> {current_dir.name}") + + # Narrate the raw Rego text first (line-oriented, so a presenter can see exactly what changed), + # then assert on the order-independent (role, scope) sets below. + for f in (cfg.inbound_rego, cfg.outbound_rego): + rule() + print(f" {f}:") + diff = difflib.unified_diff( + (prior_dir / f).read_text().splitlines(), + (current_dir / f).read_text().splitlines(), + fromfile=f"{prior_dir.name}/{f}", + tofile=f"{current_dir.name}/{f}", + lineterm="", + ) + lines = list(diff) + print("\n".join(lines) if lines else " (no textual change)") + rule() + + prior_in, prior_out = grant_sets(cfg, prior_dir) + cur_in, cur_out = grant_sets(cfg, current_dir) + + print(" inbound:") + print(f" + added: {sorted(cur_in - prior_in) or 'none'}") + print(f" - removed: {sorted(prior_in - cur_in) or 'none'}") + print(" outbound:") + print(f" + added: {sorted(cur_out - prior_out) or 'none'}") + print(f" - removed: {sorted(prior_out - cur_out) or 'none'}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--snapshot", help="generated/ to show (default: latest)") + parser.add_argument("--diff", help="prior generated/ to diff against the default snapshot") + args = parser.parse_args() + + cfg = load_config() + admin = connect_admin(cfg) + show_keycloak(admin, cfg) + + if args.snapshot: + rego_dir = GENERATED / args.snapshot + else: + rego_dir = latest_snapshot() or GENERATED / "baseline" + + show_snapshot(cfg, rego_dir) + + if args.diff: + show_diff(cfg, args.diff, rego_dir) + + +if __name__ == "__main__": + main() diff --git a/aiac/docs/specs/demo/github-agent.md b/aiac/docs/specs/demo/github-agent.md index bc1f35e4c..d06939e18 100644 --- a/aiac/docs/specs/demo/github-agent.md +++ b/aiac/docs/specs/demo/github-agent.md @@ -195,7 +195,7 @@ Config via `github_agent/config.py` (`pydantic-settings`), adapted from the refe ## 7. Deployment (aiac level) -Manifests live under `aiac/demo/agents/github_agent/k8s/`, adapted from the github-issue demo. Namespace +Manifests live under `aiac/demo/assets/agents/github_agent/k8s/`, adapted from the github-issue demo. Namespace `team1` (installer-provided ConfigMaps/secrets assumed present). - **`github-agent-deployment.yaml`** — `ServiceAccount` + `Deployment` + `Service` + `AgentRuntime`: @@ -218,7 +218,7 @@ Manifests live under `aiac/demo/agents/github_agent/k8s/`, adapted from the gith (`authbridge/demos/github-issue/k8s/github-tool-deployment.yaml`, Service name `github-tool-mcp`) + `github-tool-secrets`, a running Kagenti cluster (Keycloak realm `kagenti`, namespace `team1` — installer-provided and enrolled for AuthBridge injection). - The sibling UC-1 stub at `demo/tools/github_tool/` (Service `github-tool`) is a separate deployment + The sibling UC-1 stub at `aiac/demo/assets/tools/github_tool/` (Service `github-tool`) is a separate deployment for AIAC onboarding discovery and is **not** a runtime dependency of this agent. **Wiring invariant:** agent `MCP_URL` host (`github-tool-mcp`) == `authproxy-routes` host == tool @@ -229,7 +229,7 @@ Service name; exchanged audience (`github-tool`) == tool `AUDIENCE`. ## 8. Verification **Local (no cluster — primary gate):** -1. `cd aiac/demo/agents/github_agent && uv lock && uv sync`. +1. `cd aiac/demo/assets/agents/github_agent && uv lock && uv sync`. 2. `podman build -t github-agent:latest .`. 3. Startup + card: run `uv run --no-sync server` (or `test_startup.exp`), then `curl -s localhost:8000/.well-known/agent-card.json | jq '.name, .skills[].id'` → diff --git a/aiac/docs/specs/demo/github-tool.md b/aiac/docs/specs/demo/github-tool.md index fa1f8b0e4..bc13b07c4 100644 --- a/aiac/docs/specs/demo/github-tool.md +++ b/aiac/docs/specs/demo/github-tool.md @@ -140,8 +140,8 @@ answers `tools/list` with the four tools of §3. `"stub: not implemented in phase-1 demo"` message). They perform **no** GitHub work. This is acceptable because phase 1 drives no live traffic — but the endpoint must still be a **real, deployable MCP server** that answers `tools/list`. -- **Location:** self-contained under `aiac/demo/tools/github_tool/`, mirroring the agent's - `aiac/demo/agents/github_agent/` layout. Ships its own `Dockerfile`, dependency manifest, and the +- **Location:** self-contained under `aiac/demo/assets/tools/github_tool/`, mirroring the agent's + `aiac/demo/assets/agents/github_agent/` layout. Ships its own `Dockerfile`, dependency manifest, and the `k8s/` manifests of §7. - **Listen:** binds `0.0.0.0` on a container `PORT` (see §5) and serves `/mcp`. @@ -199,20 +199,30 @@ State these explicitly; UC-1 identity resolution depends on all of them holding: ## 7. Deployment (aiac level) -Manifests live under `aiac/demo/tools/github_tool/k8s/`, adapted from the sibling agent's §7 and the +Manifests live under `aiac/demo/assets/tools/github_tool/k8s/`, adapted from the sibling agent's §7 and the github-issue demo's `github-tool-deployment.yaml`. Namespace **`team1`** (installer-provided ConfigMaps/secrets assumed present), consistent with the agent spec. - **`github-tool-deployment.yaml`** — `Deployment` + `Service` + `AgentRuntime`: - - **`Deployment`** named `github-tool`. Container port serves `/mcp` (default `9090`). Env `PORT`, - `LOG_LEVEL`. Image `github-tool:latest`, `imagePullPolicy: IfNotPresent` (kind-load; name is a - documented knob). No GitHub PAT / issuer / JWKS / audience env (unlike the github-issue tool). + - **`Deployment`** named `github-tool`. Container port serves `/mcp`; the image's own default is + `9090`, but the in-cluster manifest overrides `PORT` to **`9095`** (see the port-shift invariant + below). Env `PORT`, `LOG_LEVEL`. Image `github-tool:latest`, `imagePullPolicy: IfNotPresent` + (kind-load; name is a documented knob). No GitHub PAT / issuer / JWKS / audience env (unlike the + github-issue tool). - **Pod label `kagenti.io/type: tool`** — this is what `classify_service` reads. Applied by the operator via the `AgentRuntime` (see below); relying on the operator to stamp it, rather than hand-setting it, keeps it consistent with the operator's own discriminator. - **`Service`** named `github-tool` (ClusterIP), selecting the Deployment's pods. Its **first port** - maps to the container's `/mcp` port (e.g. `port: 9090 → targetPort: 9090`). `analyze_tool` uses the - Service's **first** port, so keep `/mcp`'s port first. + maps to the container's `/mcp` port (`port: 9090 → targetPort: 9095` — see below for why these + differ). `analyze_tool` uses the Service's **first** port, so keep `/mcp`'s port first. + - **AuthBridge port-shift invariant — declared `PORT` must not be `9090`.** The AuthBridge sidecar + reuses the declared `PORT` value as its own reverse-proxy listener and shifts the app's real + listen port to `PORT+1`. AuthBridge also has a *fixed* health-check listener hardcoded to `9091`. + Declaring `PORT: 9090` shifts the app to `9091`, colliding with that fixed health listener — the + `github-tool` container crash-loops fighting the sidecar for the port. `PORT: 9095` (shifted: + `9096`) clears every AuthBridge-fixed port (`8080`, `8081`, `9091`, `9093`, `9094`). The Service's + **external** port stays `9090` (unaffected — `analyze_tool` and the `kubectl port-forward` + examples below are unchanged); only `containerPort`/`PORT`/the probes/`targetPort` moved to `9095`. - **Service label `protocol.kagenti.io/mcp` MUST be present** — a **deploy-time prerequisite** for `analyze_tool` (the operator does **not** stamp it; `analyze_tool` returns `502` if absent). Set it explicitly on the Service metadata (e.g. `protocol.kagenti.io/mcp: "true"`). @@ -241,7 +251,7 @@ port; the operator-applied pod label is `kagenti.io/type=tool`; the operator-reg ## 8. Verification **Local (no cluster — primary gate):** -1. `cd aiac/demo/tools/github_tool` and build: `podman build -t github-tool:latest .`. +1. `cd aiac/demo/assets/tools/github_tool` and build: `podman build -t github-tool:latest .`. 2. Run the container (`-e PORT=9090 -p 9090:9090`), then POST a JSON-RPC `tools/list` to `/mcp` and confirm the four tool names: ```bash diff --git a/scripts/opa-kind-enable.sh b/scripts/opa-kind-enable.sh new file mode 100755 index 000000000..d3f95cfa9 --- /dev/null +++ b/scripts/opa-kind-enable.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# opa-kind-enable.sh — authbridge/docs/opa-kind-runbook.md Steps 1-5, on the fly. +# +# Wires the OPA plugin into every agent's inbound AND outbound AuthBridge +# pipeline on a Kind cluster, alongside the full parser set (a2a-parser, +# mcp-parser, inference-parser) so OPA policies have input.a2a / input.mcp / +# input.inference available on both legs, not just input.host. +# +# Does NOT modify charts/rossoctl/values.yaml on disk. The pipeline override +# lives in a throwaway temp file merged on top of the real values.yaml via a +# second `helm upgrade -f` — Helm layers -f files left-to-right, so the repo +# file is only ever read, never written. Run opa-kind-restore.sh to revert. +# +# Requires: kubectl, helm, kind, docker (or podman), python3 not needed here. +# Env vars: +# OPERATOR_DIR path to the rossoctl/operator repo clone (bundle-service) +# ROSSOCTL_DIR path to the rossoctl/rossoctl repo clone (the chart) +# CLUSTER_NAME kind cluster name (default: rossoctl) +# RELEASE_NAME helm release name (default: rossoctl) +# RELEASE_NAMESPACE namespace the chart is installed in (default: rossoctl-system) +# AGENT_NAMESPACE namespace to restart agent pods in (default: team1) +# IMAGE_TAG local authbridge-proxy image tag (default: localhost/authbridge:local) +# CONTAINER_RUNTIME docker | podman (default: docker, auto-falls back to podman) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CORTEX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +OPERATOR_DIR="${OPERATOR_DIR:-$(cd "$CORTEX_DIR/../operator" 2>/dev/null && pwd || echo "")}" +ROSSOCTL_DIR="${ROSSOCTL_DIR:-$(cd "$CORTEX_DIR/../rossoctl" 2>/dev/null && pwd || echo "")}" +CLUSTER_NAME="${CLUSTER_NAME:-rossoctl}" +RELEASE_NAME="${RELEASE_NAME:-rossoctl}" +RELEASE_NAMESPACE="${RELEASE_NAMESPACE:-rossoctl-system}" +AGENT_NAMESPACE="${AGENT_NAMESPACE:-team1}" +IMAGE_TAG="${IMAGE_TAG:-localhost/authbridge:local}" + +if [ -z "$OPERATOR_DIR" ] || [ ! -d "$OPERATOR_DIR" ]; then + echo "ERROR: Set OPERATOR_DIR to point to your rossoctl/operator repo clone" >&2 + exit 1 +fi +if [ -z "$ROSSOCTL_DIR" ] || [ ! -d "$ROSSOCTL_DIR" ]; then + echo "ERROR: Set ROSSOCTL_DIR to point to your rossoctl/rossoctl repo clone" >&2 + exit 1 +fi + +VALUES_FILE="${ROSSOCTL_DIR}/charts/rossoctl/values.yaml" +CHART_DIR="${ROSSOCTL_DIR}/charts/rossoctl" +if [ ! -f "$VALUES_FILE" ]; then + echo "ERROR: ${VALUES_FILE} not found — check ROSSOCTL_DIR" >&2 + exit 1 +fi + +if [ "${KIND_EXPERIMENTAL_PROVIDER:-}" = "podman" ]; then + CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-podman}" +elif ! command -v docker &> /dev/null && command -v podman &> /dev/null; then + CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-podman}" +else + CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" +fi + +# Track every temp file we create and remove them on exit, so an early failure +# (set -e) under any step still cleans up. Trailing-X templates only (no suffix +# after the Xs) for portability across GNU and BSD/macOS mktemp. +TMPFILES=() +cleanup() { [ "${#TMPFILES[@]}" -gt 0 ] && rm -f "${TMPFILES[@]}"; } +trap cleanup EXIT + +load_image_to_kind() { + local image_name="$1" + if [ "$CONTAINER_RUNTIME" = "podman" ]; then + local tar_file + tar_file="$(mktemp "${TMPDIR:-/tmp}/opa-kind-enable-image.XXXXXX")" + TMPFILES+=("$tar_file") + "$CONTAINER_RUNTIME" save "$image_name" -o "$tar_file" + kind load image-archive "$tar_file" --name "$CLUSTER_NAME" + rm -f "$tar_file" + else + kind load docker-image "$image_name" --name "$CLUSTER_NAME" + fi +} + +OVERLAY_FILE="$(mktemp "${TMPDIR:-/tmp}/opa-kind-enable-overlay.XXXXXX")" +TMPFILES+=("$OVERLAY_FILE") + +echo "==> Step 1/5: deploying bundle-service (${OPERATOR_DIR})" +( cd "$OPERATOR_DIR" && ./hack/bundle-service-kind.sh "$CLUSTER_NAME" "$RELEASE_NAMESPACE" ) +kubectl get pods -n "$RELEASE_NAMESPACE" -l app=bundle-service + +echo "==> Step 2/5: building + loading authbridge-proxy (${IMAGE_TAG}) via ${CONTAINER_RUNTIME}" +( cd "$CORTEX_DIR/authbridge" && "$CONTAINER_RUNTIME" build -t "$IMAGE_TAG" -f cmd/authbridge-proxy/Dockerfile . ) +load_image_to_kind "$IMAGE_TAG" + +echo "==> Step 3/5: writing throwaway pipeline overlay (${VALUES_FILE} stays untouched)" +cat > "$OVERLAY_FILE" < Step 4/5: helm upgrade (base values.yaml + overlay — base file not modified)" +helm upgrade "$RELEASE_NAME" "$CHART_DIR" -n "$RELEASE_NAMESPACE" \ + -f "$VALUES_FILE" \ + -f "$OVERLAY_FILE" \ + --set openshift=false \ + --set featureFlags.agentSandbox=true \ + --set operator-chart.defaults.images.authbridge="$IMAGE_TAG" \ + --wait --timeout 5m + +echo "==> Step 5/5: restarting authbridge pods in ${AGENT_NAMESPACE}" +# --ignore-not-found so this no-ops cleanly when the namespace has no agent pods yet. +kubectl delete pods -n "$AGENT_NAMESPACE" -l rossoctl.io/type=agent --ignore-not-found + +cat < Done. + +Verify OPA + parsers are wired into both legs (expect 2 'name: opa' matches): + kubectl get configmap authbridge-runtime-config -n ${AGENT_NAMESPACE} \\ + -o jsonpath='{.data.config\.yaml}' | grep -c 'name: opa' + +Restore the original pipeline with: + ./scripts/opa-kind-restore.sh +EOF diff --git a/scripts/opa-kind-restore.sh b/scripts/opa-kind-restore.sh new file mode 100755 index 000000000..25ec60b98 --- /dev/null +++ b/scripts/opa-kind-restore.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# opa-kind-restore.sh — revert opa-kind-enable.sh. +# +# Re-applies the rossoctl chart's real, untouched charts/rossoctl/values.yaml +# (no OPA/parser overlay) and restarts the authbridge sidecars so they pick +# up the reverted pipeline. Mirrors the "Rollback" section of +# authbridge/docs/opa-kind-runbook.md verbatim — since opa-kind-enable.sh +# never wrote to values.yaml, "restoring" it is just re-running helm upgrade +# against that same file with no overlay on top. +# +# Note: like the runbook's documented rollback, this omits the --set flags +# used by the enable step (openshift, featureFlags.agentSandbox, the local +# image override). Without --reuse-values, Helm falls back to the chart's +# own defaults for anything not passed here — if your cluster relies on +# those --set values for reasons unrelated to OPA, re-add them or pass +# --reuse-values instead. +# +# Env vars: +# ROSSOCTL_DIR path to the rossoctl/rossoctl repo clone (the chart) +# RELEASE_NAME helm release name (default: rossoctl) +# RELEASE_NAMESPACE namespace the chart is installed in (default: rossoctl-system) +# AGENT_NAMESPACE namespace to restart agent pods in (default: team1) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CORTEX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +ROSSOCTL_DIR="${ROSSOCTL_DIR:-$(cd "$CORTEX_DIR/../rossoctl" 2>/dev/null && pwd || echo "")}" +RELEASE_NAME="${RELEASE_NAME:-rossoctl}" +RELEASE_NAMESPACE="${RELEASE_NAMESPACE:-rossoctl-system}" +AGENT_NAMESPACE="${AGENT_NAMESPACE:-team1}" + +if [ -z "$ROSSOCTL_DIR" ] || [ ! -d "$ROSSOCTL_DIR" ]; then + echo "ERROR: Set ROSSOCTL_DIR to point to your rossoctl/rossoctl repo clone" >&2 + exit 1 +fi + +VALUES_FILE="${ROSSOCTL_DIR}/charts/rossoctl/values.yaml" +CHART_DIR="${ROSSOCTL_DIR}/charts/rossoctl" +if [ ! -f "$VALUES_FILE" ]; then + echo "ERROR: ${VALUES_FILE} not found — check ROSSOCTL_DIR" >&2 + exit 1 +fi + +echo "==> Restoring original pipeline from ${VALUES_FILE} (no OPA/parser overlay)" +helm upgrade "$RELEASE_NAME" "$CHART_DIR" -n "$RELEASE_NAMESPACE" \ + -f "$VALUES_FILE" \ + --wait --timeout 5m + +echo "==> Restarting authbridge pods in ${AGENT_NAMESPACE}" +kubectl delete pods -n "$AGENT_NAMESPACE" -l rossoctl.io/type=agent + +cat < Done. + +Verify the pipeline is back to its original state (count depends on what +values.yaml originally shipped — 0 if it never had OPA): + kubectl get configmap authbridge-runtime-config -n ${AGENT_NAMESPACE} \\ + -o jsonpath='{.data.config\.yaml}' | grep -c 'name: opa' +EOF