diff --git a/.env.example b/.env.example index 9aedec5eed..3c3ea8ed6f 100644 --- a/.env.example +++ b/.env.example @@ -623,6 +623,12 @@ VICTORIA_LOGS_TENANT_ID= VERCEL_API_TOKEN= VERCEL_TEAM_ID= +# Neon (project, compute, operations, and Object Storage diagnostics) +NEON_API_KEY= +NEON_PROJECT_ID= +# Optional; defaults to the project's default branch +NEON_BRANCH_ID= + # PostHog (REST credentials) POSTHOG_PROJECT_ID= POSTHOG_PERSONAL_API_KEY= diff --git a/.github/ci/test_scope_rules.py b/.github/ci/test_scope_rules.py index 54df9957df..f3abba5eae 100644 --- a/.github/ci/test_scope_rules.py +++ b/.github/ci/test_scope_rules.py @@ -306,6 +306,10 @@ class PathRule: "tests/tools/test_sentry_search_issues_tool.py", ), ), + PathRule( + "integrations/neon/", + ("tests/integrations/neon/",), + ), PathRule( "integrations/supabase/", ("tests/integrations/test_supabase.py",), diff --git a/README.md b/README.md index cca03b6ffd..fbdc755e34 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ OpenSRE connects to 60+ tools and services across the modern cloud stack, from L | **AI / LLM Providers** | Anthropic · OpenAI · OpenAI Codex · Ollama · Google Gemini · OpenRouter · TrustedRouter · NVIDIA NIM · Bedrock | | | **Observability** | Grafana (Loki · Mimir · Tempo · annotations) · Datadog · Honeycomb · Coralogix · groundcover · CloudWatch · Sentry · Elasticsearch · Better Stack · Splunk · Victoria Logs · SignOz · OpenObserve · OpenSearch · Azure Monitor · New Relic | | | **Infrastructure** | Kubernetes · AWS (S3 · Lambda · EKS · EC2 · CloudTrail · Bedrock) · Azure · ArgoCD · Helm | | -| **Database** | MongoDB · ClickHouse · PostgreSQL · MySQL · MariaDB · MongoDB Atlas · Azure SQL · Snowflake · Redis · RDS · Supabase | | +| **Database** | MongoDB · ClickHouse · PostgreSQL · MySQL · MariaDB · MongoDB Atlas · Azure SQL · Snowflake · Redis · RDS · Supabase · Neon | | | **Data Platform** | Apache Airflow · Apache Kafka · Prefect · RabbitMQ · Dagster · Temporal | | | **Dev Tools** | GitHub · GitHub MCP · Bitbucket · GitLab · Jenkins | | | **Incident Management** | PagerDuty · Jira · Alertmanager · incident.io · ServiceNow · Trello (client helpers only) | [Linear](https://github.com/Tracer-Cloud/opensre/issues/124) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6bd7b27b6e..12f436d7e6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -233,7 +233,7 @@ OpenSRE 可连接现代云技术栈中的 60 多种工具与服务,从 LLM 提 | **AI / LLM 提供商** | Anthropic · OpenAI · OpenAI Codex · Ollama · Google Gemini · OpenRouter · TrustedRouter · NVIDIA NIM · Bedrock | | | **可观测性** | Grafana (Loki · Mimir · Tempo · annotations) · Datadog · Honeycomb · Coralogix · groundcover · CloudWatch · Sentry · Elasticsearch · Better Stack · Splunk · Victoria Logs · SignOz · OpenObserve · OpenSearch · Azure Monitor · New Relic | | | **基础设施** | Kubernetes · AWS (S3 · Lambda · EKS · EC2 · CloudTrail · Bedrock) · Azure · ArgoCD · Helm | | -| **数据库** | MongoDB · ClickHouse · PostgreSQL · MySQL · MariaDB · MongoDB Atlas · Azure SQL · Snowflake · Redis · RDS · Supabase | | +| **数据库** | MongoDB · ClickHouse · PostgreSQL · MySQL · MariaDB · MongoDB Atlas · Azure SQL · Snowflake · Redis · RDS · Supabase · Neon | | | **数据平台** | Apache Airflow · Apache Kafka · Prefect · RabbitMQ · Dagster · Temporal | | | **开发工具** | GitHub · GitHub MCP · Bitbucket · GitLab · Jenkins | | | **故障管理** | PagerDuty · Jira · Alertmanager · incident.io · ServiceNow · Trello(仅提供客户端辅助功能) | [Linear](https://github.com/Tracer-Cloud/opensre/issues/124) | diff --git a/config/constants/__init__.py b/config/constants/__init__.py index af10234a3b..76b9d503ef 100644 --- a/config/constants/__init__.py +++ b/config/constants/__init__.py @@ -953,6 +953,18 @@ from config.constants.mysql import ( MYSQL_USERNAME_ENV as MYSQL_USERNAME_ENV, ) + from config.constants.neon import ( + NEON_API_BASE_URL as NEON_API_BASE_URL, + ) + from config.constants.neon import ( + NEON_API_KEY_ENV as NEON_API_KEY_ENV, + ) + from config.constants.neon import ( + NEON_BRANCH_ID_ENV as NEON_BRANCH_ID_ENV, + ) + from config.constants.neon import ( + NEON_PROJECT_ID_ENV as NEON_PROJECT_ID_ENV, + ) from config.constants.new_relic import ( NEW_RELIC_ACCOUNT_ID_ENV as NEW_RELIC_ACCOUNT_ID_ENV, ) diff --git a/config/constants/exports.py b/config/constants/exports.py index 1964508f6d..184f5fdb57 100644 --- a/config/constants/exports.py +++ b/config/constants/exports.py @@ -425,6 +425,11 @@ "MYSQL_PORT_ENV": "mysql", "MYSQL_SSL_MODE_ENV": "mysql", "MYSQL_USERNAME_ENV": "mysql", + # neon + "NEON_API_BASE_URL": "neon", + "NEON_API_KEY_ENV": "neon", + "NEON_BRANCH_ID_ENV": "neon", + "NEON_PROJECT_ID_ENV": "neon", # new_relic "NEW_RELIC_ACCOUNT_ID_ENV": "new_relic", "NEW_RELIC_ALLOWED_BASE_URLS": "new_relic", diff --git a/config/constants/neon.py b/config/constants/neon.py new file mode 100644 index 0000000000..6a14be156f --- /dev/null +++ b/config/constants/neon.py @@ -0,0 +1,21 @@ +"""Neon environment variable names and shared static constants. + +``NEON_API_BASE_URL`` is the Neon control-plane API (projects, branches, +computes, operations, and Object Storage buckets). Postgres connections and +the S3-compatible Object Storage data plane use per-branch hosts that the API +reports, so neither has a fixed constant here. +""" + +from __future__ import annotations + +NEON_API_KEY_ENV = "NEON_API_KEY" +NEON_PROJECT_ID_ENV = "NEON_PROJECT_ID" +NEON_BRANCH_ID_ENV = "NEON_BRANCH_ID" +NEON_API_BASE_URL = "https://console.neon.tech/api/v2" + +__all__ = [ + "NEON_API_BASE_URL", + "NEON_API_KEY_ENV", + "NEON_BRANCH_ID_ENV", + "NEON_PROJECT_ID_ENV", +] diff --git a/docs/docs.json b/docs/docs.json index de65c84b68..045723d5f9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -219,6 +219,7 @@ "integrations/databases/mongodb", "integrations/databases/mongodb-atlas", "integrations/databases/mysql", + "integrations/databases/neon", "integrations/databases/opensearch", "integrations/databases/postgresql", "integrations/databases/rds", diff --git a/docs/install/environment-variables.mdx b/docs/install/environment-variables.mdx index fffe8d9a03..a06f1da970 100644 --- a/docs/install/environment-variables.mdx +++ b/docs/install/environment-variables.mdx @@ -412,12 +412,12 @@ Mirror conversation history and memory to a user-owned object store. Details: [R | Variable | Default | Description | | --- | --- | --- | | `OPENSRE_REMOTE_SYNC` | | Enable remote sync (`1`/`true`) | -| `OPENSRE_REMOTE_SYNC_PROVIDER` | `aws` | Store backend (`aws`, `gcs`, `vercel`, `azure`, or `s3compat`) | +| `OPENSRE_REMOTE_SYNC_PROVIDER` | `aws` | Store backend (`aws`, `gcs`, `vercel`, `azure`, `s3compat`, or `neon`) | | `OPENSRE_REMOTE_SYNC_BUCKET` | | Bucket / Blob store name (required when sync is on) | | `OPENSRE_REMOTE_SYNC_PREFIX` | `opensre` | Key prefix inside the store | | `OPENSRE_REMOTE_SYNC_REGION` | | Region override | | `OPENSRE_REMOTE_SYNC_PROFILE` | | Named credentials profile | -| `OPENSRE_REMOTE_SYNC_ENDPOINT_URL` | | Custom S3 endpoint when provider is `s3compat` | +| `OPENSRE_REMOTE_SYNC_ENDPOINT_URL` | | Custom S3 endpoint when provider is `s3compat` or `neon` | | `OPENSRE_REMOTE_SYNC_EXCLUDE` | | Comma-separated globs to exclude from sync | | `OPENSRE_REMOTE_SYNC_EXCLUDE_OFF` | | Ignore configured exclusions for one run | | `BLOB_READ_WRITE_TOKEN` | | Vercel Blob token (when provider is `vercel`) | @@ -640,6 +640,9 @@ Mirror conversation history and memory to a user-owned object store. Details: [R | `VERCEL_API_TOKEN` | | Vercel API token | | `VERCEL_TEAM_ID` | | Vercel team ID | | `VERCEL_RUNTIME_LOGS_READ_TIMEOUT` | `600` | Seconds to wait per Vercel runtime-log read (minimum `30`) | +| `NEON_API_KEY` | | Neon API key | +| `NEON_PROJECT_ID` | | Neon project the Neon tools read | +| `NEON_BRANCH_ID` | | Branch for the Neon Object Storage tools (defaults to the project default branch) | | `GOOGLE_CREDENTIALS_FILE` | | Path to Google service-account JSON for Docs export | | `GOOGLE_DRIVE_FOLDER_ID` | | Google Drive folder for Docs export | diff --git a/docs/install/remote-sync.mdx b/docs/install/remote-sync.mdx index 6ffb9ed153..8d60f72425 100644 --- a/docs/install/remote-sync.mdx +++ b/docs/install/remote-sync.mdx @@ -1,6 +1,6 @@ --- title: "Sync to your own object store" -description: "Copy sessions and memory between machines using an S3, GCS, Azure, or Vercel Blob store you own." +description: "Copy sessions and memory between machines using an S3, GCS, Azure, Vercel Blob, or Neon Object Storage store you own." --- By default OpenSRE keeps everything on your machine. Turn on remote sync to copy **conversations** and **memory** to a private store you own, so another laptop can continue where you left off. @@ -70,7 +70,7 @@ Or set env vars yourself: export OPENSRE_REMOTE_SYNC=1 export OPENSRE_REMOTE_SYNC_BUCKET=my-opensre-bucket # optional: -# export OPENSRE_REMOTE_SYNC_PROVIDER=aws # default; also gcs, vercel, azure +# export OPENSRE_REMOTE_SYNC_PROVIDER=aws # default; also gcs, vercel, azure, s3compat, neon # export OPENSRE_REMOTE_SYNC_PREFIX=opensre # default ``` @@ -79,12 +79,12 @@ A bucket name alone does nothing — you must also set `OPENSRE_REMOTE_SYNC=1` ( | Variable | What it does | | --- | --- | | `OPENSRE_REMOTE_SYNC` | `1` / `true` turns sync on | -| `OPENSRE_REMOTE_SYNC_PROVIDER` | `aws` (default), `gcs`, `vercel`, `azure`, or `s3compat` | +| `OPENSRE_REMOTE_SYNC_PROVIDER` | `aws` (default), `gcs`, `vercel`, `azure`, `s3compat`, or `neon` | | `OPENSRE_REMOTE_SYNC_BUCKET` | Bucket, container, or Blob store name (required when on) | | `OPENSRE_REMOTE_SYNC_PREFIX` | Key prefix (default `opensre`). Same prefix = shared history | | `OPENSRE_REMOTE_SYNC_REGION` | AWS region (AWS only) | | `OPENSRE_REMOTE_SYNC_PROFILE` | AWS profile name, or Azure storage account name when `provider=azure` | -| `OPENSRE_REMOTE_SYNC_ENDPOINT_URL` | Custom S3 endpoint when `provider=s3compat` (MinIO / R2 / Spaces) | +| `OPENSRE_REMOTE_SYNC_ENDPOINT_URL` | Custom S3 endpoint when `provider=s3compat` (MinIO / R2 / Spaces) or `provider=neon` | | `BLOB_READ_WRITE_TOKEN` | Vercel Blob token (Vercel only) | | `OPENSRE_REMOTE_SYNC_EXCLUDE` | Comma-separated paths to skip (see below) | | `OPENSRE_REMOTE_SYNC_EXCLUDE_OFF` | `1` ignores exclusions for one run | @@ -130,6 +130,8 @@ Remote sync is for a **personal machine**. Org-bound Slack/Discord turns are ref **S3-compatible (MinIO / R2 / Spaces)** — set `OPENSRE_REMOTE_SYNC_PROVIDER=s3compat` and `OPENSRE_REMOTE_SYNC_ENDPOINT_URL` to the custom endpoint (or `AWS_ENDPOINT_URL_S3`). Path-style addressing is enabled for MinIO. Use the same ambient S3 credentials as AWS. +**Neon Object Storage**: private bucket on a Neon branch. Set `OPENSRE_REMOTE_SYNC_PROVIDER=neon` and run `neon env pull` in the shell (or `.env`) OpenSRE uses, which sets `AWS_ENDPOINT_URL_S3`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION` for the linked branch. Without the CLI, set `OPENSRE_REMOTE_SYNC_ENDPOINT_URL` to the branch's `s3_endpoint` and use a key from `neon credentials create` with the `storage:read` and `storage:write` scopes. The endpoint is required: unlike `s3compat`, the `neon` provider refuses to start without one so it never falls back to AWS. Buckets belong to a branch, so every machine must use the same branch endpoint. `status` reports the bucket as public when its access level is `public_read`. + ## Skip some paths By default every session and memory file syncs. To hold some back: diff --git a/docs/integrations/databases/neon.mdx b/docs/integrations/databases/neon.mdx new file mode 100644 index 0000000000..46a3b22052 --- /dev/null +++ b/docs/integrations/databases/neon.mdx @@ -0,0 +1,152 @@ +--- +title: "Neon" +description: "Connect Neon so OpenSRE can check compute state, control-plane operations, and Object Storage buckets during incidents" +--- + +## Overview + +OpenSRE reads your Neon project through the Neon API to answer the questions that come up first in a Postgres incident: is the compute running or scaled to zero, did a restart or branch operation fail, which branch is the app pointed at, and did the expected files land in Neon Object Storage. + +The integration is read-only. It never starts, suspends, resets, or deletes anything. + +Neon also plugs into two other parts of OpenSRE: + +- **Postgres diagnostics** (active queries, locks, slow queries, table stats) come from the [PostgreSQL integration](/integrations/databases/postgresql) pointed at your Neon compute. See [Postgres diagnostics on Neon](#postgres-diagnostics-on-neon). +- **Remote sync** can mirror sessions and memory to a Neon Object Storage bucket. See [Remote sync to Neon Object Storage](#remote-sync-to-neon-object-storage). + +## Prerequisites + +- A Neon project +- A Neon API key that can read that project +- The project ID (for example `little-lab-10567887`) + +## Setup + +### Option 1: Interactive CLI + +```bash +opensre integrations setup neon +``` + +Provide the API key, the project ID, and optionally a branch ID when prompted. Setup verifies the key against the project before saving. + +### Option 2: Environment variables + +Add to your `.env`: + +```bash +NEON_API_KEY=your-neon-api-key +NEON_PROJECT_ID=little-lab-10567887 +NEON_BRANCH_ID=br-summer-art-bdbz1xg2 # optional +``` + +| Variable | Default | Description | +| --- | --- | --- | +| `NEON_API_KEY` | | **Required.** Neon API key | +| `NEON_PROJECT_ID` | | **Required.** The project OpenSRE investigates | +| `NEON_BRANCH_ID` | project default branch | Branch the Object Storage tools read when a call does not name one | + +### Option 3: Persistent store + +```json +{ + "version": 1, + "integrations": [ + { + "id": "neon-prod", + "service": "neon", + "status": "active", + "credentials": { + "api_key": "your-neon-api-key", + "project_id": "little-lab-10567887", + "branch_id": "" + } + } + ] +} +``` + +## Credentials + +1. In the Neon Console, open **Organization settings** → **API keys**. +2. Create a key and, under the project option, limit it to the project OpenSRE should read. +3. Copy the key. It is shown once. +4. Copy the project ID from **Project settings** → **General**, or from the URL of the project page. + +A personal API key (**Account settings** → **API keys**) also works but can read every project you can. + + +The key is injected into each tool call by OpenSRE and hidden from the model, and the project ID is fixed to the configured one. The model can pick a branch inside that project but cannot point the key at another project. + + +OpenSRE calls the Neon API at `https://console.neon.tech/api/v2`. + +## Tools + +| Tool | What it does | +| --- | --- | +| `get_neon_project_status` | Region, Postgres version, IP allowlist, branches and their state, compute endpoints (active or idle, autoscaling CU limits, scale-to-zero timeout, last active time), and recent operations with failed and in-progress ones listed separately. Optional `branch_id` narrows computes and operations to one branch. | +| `get_neon_storage_buckets` | Whether Object Storage is enabled on a branch, its S3 endpoint and region, and every bucket with its access level. Public buckets are listed separately. | +| `list_neon_storage_objects` | One page of objects (key, size, last modified, ETag) in a bucket, with optional `prefix`. Folders are collapsed on `/` unless `recursive` is true. Returns `next_cursor` when more objects exist. | + +The tools are available in both chat and investigation turns. + +## Postgres diagnostics on Neon + +Configure the [PostgreSQL integration](/integrations/databases/postgresql) with your Neon connection details to get active queries, locks, replication, server status, slow queries, and table stats: + +```bash +POSTGRESQL_HOST=ep-sparkling-lake-bd31vdlv.c-8.us-east-2.aws.neon.tech +POSTGRESQL_DATABASE=neondb +POSTGRESQL_USERNAME=neondb_owner +POSTGRESQL_PASSWORD=your-password +POSTGRESQL_SSL_MODE=require +``` + +- Use the **direct** compute host. The pooled host (the one with `-pooler` in its name) rejects the connection, because OpenSRE sets a statement timeout at connect time and Neon's connection pooler does not accept that startup parameter. +- Set `POSTGRESQL_SSL_MODE=require`. Neon only accepts TLS connections. +- For slow queries, run `CREATE EXTENSION pg_stat_statements;` once in the database. Neon already preloads the library. +- A compute that has scaled to zero starts on the first connection, so the first diagnostic call after an idle period takes a moment longer. + +## Remote sync to Neon Object Storage + +[Remote sync](/install/remote-sync) can mirror your sessions and memory to a Neon Object Storage bucket: + +```bash +neon env pull # writes AWS_ENDPOINT_URL_S3, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION +opensre remote-sync setup --provider neon --bucket opensre-sync +opensre remote-sync status +``` + +Keep the bucket private. `opensre remote-sync status` warns when the bucket's access level is `public_read`. + +## Verify + +```bash +opensre integrations verify neon +``` + +Expected output: + +``` +SERVICE │ SOURCE │ STATUS │ DETAIL +neon │ local env │ ✓ passed │ Connected to Neon project opensre-neon-test (little-lab-10567887, aws-us-east-2). +``` + +Inside the REPL: `/integrations verify neon` or `/verify neon`. + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| **Neon rejected the API key (HTTP 401)** | The key was revoked or mistyped. Create a new one. | +| **Neon resource not found (HTTP 404)** | Check `NEON_PROJECT_ID`. A project-scoped key also returns 404 for any other project. | +| **Object Storage is not enabled on this branch** | Create a bucket on that branch first (Console or `neon buckets create`). | +| **Bucket not found** | Buckets belong to a branch. Pass the branch the app uses, or set `NEON_BRANCH_ID`. | +| **PostgreSQL tools fail with "unsupported startup parameter"** | `POSTGRESQL_HOST` points at the `-pooler` host. Use the direct host. | + +## Security + +- Prefer a project-scoped API key so OpenSRE can only read the project it investigates. +- Store the key in `.env` or the OpenSRE credentials store, not in source code. +- Rotate the key periodically and revoke it when you remove the integration. diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx index e6f4bca2d2..4572a958cb 100644 --- a/docs/integrations/index.mdx +++ b/docs/integrations/index.mdx @@ -56,7 +56,7 @@ configured separately under [LLM providers](/platform/llm-providers). | **Code and CI** | [Bitbucket](/integrations/code/bitbucket), [GitHub](/integrations/code/github), [GitHub Actions](/integrations/code/github-actions), [GitLab](/integrations/code/gitlab), [Jenkins](/integrations/code/jenkins) | | **Collaboration** | [Google Docs](/integrations/collaboration/google-docs), [Jira](/integrations/collaboration/jira), [Trello](/integrations/collaboration/trello) | | **Messaging** | [Slack](/integrations/messaging/slack), [Discord](/integrations/messaging/discord), [Telegram](/integrations/messaging/telegram), [Rocket.Chat](/integrations/messaging/rocketchat), [Buzz](/integrations/messaging/buzz), [WhatsApp](/integrations/messaging/whatsapp), [Twilio SMS](/integrations/messaging/twilio-sms), [SMTP](/integrations/messaging/smtp) | -| **Databases** | [Azure SQL](/integrations/databases/azure-sql), [ClickHouse](/integrations/databases/clickhouse), [Elasticsearch](/integrations/databases/elasticsearch), [MariaDB](/integrations/databases/mariadb), [MongoDB](/integrations/databases/mongodb), [MongoDB Atlas](/integrations/databases/mongodb-atlas), [MySQL](/integrations/databases/mysql), [OpenSearch](/integrations/databases/opensearch), [PostgreSQL](/integrations/databases/postgresql), [RDS](/integrations/databases/rds), [Redis](/integrations/databases/redis), [Snowflake](/integrations/databases/snowflake), [Supabase](/integrations/databases/supabase) | +| **Databases** | [Azure SQL](/integrations/databases/azure-sql), [ClickHouse](/integrations/databases/clickhouse), [Elasticsearch](/integrations/databases/elasticsearch), [MariaDB](/integrations/databases/mariadb), [MongoDB](/integrations/databases/mongodb), [MongoDB Atlas](/integrations/databases/mongodb-atlas), [MySQL](/integrations/databases/mysql), [Neon](/integrations/databases/neon), [OpenSearch](/integrations/databases/opensearch), [PostgreSQL](/integrations/databases/postgresql), [RDS](/integrations/databases/rds), [Redis](/integrations/databases/redis), [Snowflake](/integrations/databases/snowflake), [Supabase](/integrations/databases/supabase) | | **Workflows** | [Airflow](/integrations/workflows/airflow), [Dagster](/integrations/workflows/dagster), [Kafka](/integrations/workflows/kafka), [Prefect](/integrations/workflows/prefect), [RabbitMQ](/integrations/workflows/rabbitmq), [Temporal](/integrations/workflows/temporal) | ## Hosted (OpenSRE Cloud) diff --git a/docs/styles/config/vocabularies/Mintlify/accept.txt b/docs/styles/config/vocabularies/Mintlify/accept.txt index 9da6f03c51..592bd5b682 100644 --- a/docs/styles/config/vocabularies/Mintlify/accept.txt +++ b/docs/styles/config/vocabularies/Mintlify/accept.txt @@ -124,6 +124,7 @@ pg_stat_statements prefetch replayable Supabase +Neon Trendshift Trello repos diff --git a/infrastructure/filestorage/enums.py b/infrastructure/filestorage/enums.py index bc507200cc..02ce809a77 100644 --- a/infrastructure/filestorage/enums.py +++ b/infrastructure/filestorage/enums.py @@ -37,6 +37,7 @@ class BuiltInProvider(StrEnum): VERCEL = "vercel" AZURE = "azure" S3COMPAT = "s3compat" + NEON = "neon" class RemoteSyncSubcommand(StrEnum): diff --git a/infrastructure/filestorage/providers/neon.py b/infrastructure/filestorage/providers/neon.py new file mode 100644 index 0000000000..e0e46c89df --- /dev/null +++ b/infrastructure/filestorage/providers/neon.py @@ -0,0 +1,106 @@ +"""Neon Object Storage backend for remote sync. + +Neon Object Storage is S3-compatible and branch-scoped: each Neon branch has its +own path-style endpoint (``https://.storage...aws.neon.tech``) +and its own buckets. This provider is the S3-compatible store with two Neon +rules on top: + +- An endpoint is required. Without one, a plain S3 client would quietly talk to + AWS instead, so the store fails closed with a pointer to ``neon env pull``. +- The endpoint and keys come from the environment ``neon env pull`` writes + (``AWS_ENDPOINT_URL_S3``, ``AWS_ACCESS_KEY_ID``, ``AWS_SECRET_ACCESS_KEY``, + ``AWS_REGION``), or ``OPENSRE_REMOTE_SYNC_ENDPOINT_URL`` and a credential from + ``neon credentials create``. opensre never stores them. + +Bucket exposure comes from ``GetBucketPolicyStatus``, which Neon answers from +the bucket's access level (``public_read`` reports public). +""" + +from __future__ import annotations + +from typing import Any + +from infrastructure.filestorage.config import RemoteSyncConfig +from infrastructure.filestorage.enums import BucketExposure, BuiltInProvider, RemoteSyncField +from infrastructure.filestorage.errors import RemoteSyncConfigError +from infrastructure.filestorage.exposure import PublicAccessStatus +from infrastructure.filestorage.providers._s3_shared import s3_check_public_access +from infrastructure.filestorage.providers.registry import SetupExtraField, register_object_store +from infrastructure.filestorage.providers.s3compat import ( + S3CompatObjectStore, + resolve_endpoint_url, +) + +PROVIDER_NAME = BuiltInProvider.NEON +CREDENTIAL_HINT = ( + "Neon Object Storage credentials come from the environment: run `neon env pull` " + "(AWS_ENDPOINT_URL_S3, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), or set " + "OPENSRE_REMOTE_SYNC_ENDPOINT_URL plus a key from `neon credentials create`." +) +MISSING_ENDPOINT_MESSAGE = ( + "Neon remote sync needs the branch's storage endpoint. Run `neon env pull` to set " + "AWS_ENDPOINT_URL_S3, or set OPENSRE_REMOTE_SYNC_ENDPOINT_URL to the s3_endpoint " + "shown by `neon api /projects//branches//storage`." +) +EXTRA_FIELDS = (SetupExtraField(RemoteSyncField.REGION, "Region (blank to use AWS_REGION)"),) +# Same ceiling as the generic S3-compatible store: boto3 retries throttled writes. +MAX_PARALLEL_UPLOADS = 16 +_UNSUPPORTED_POLICY_STATUS_CODES = frozenset({"NotImplemented", "MethodNotAllowed"}) + + +class NeonObjectStore(S3CompatObjectStore): + """Reads and writes objects under one Neon Object Storage bucket and prefix.""" + + def __init__( + self, + config: RemoteSyncConfig, + *, + endpoint_url: str | None = None, + client: Any | None = None, + ) -> None: + resolved = resolve_endpoint_url(endpoint_url) + if not resolved: + raise RemoteSyncConfigError(MISSING_ENDPOINT_MESSAGE) + super().__init__(config, endpoint_url=resolved, client=client) + + def describe(self) -> str: + return f"neon://{self._config.bucket}/{self._config.prefix}" + + +def _factory(config: RemoteSyncConfig) -> NeonObjectStore: + return NeonObjectStore(config) + + +def check_public_access( + config: RemoteSyncConfig, *, client: Any | None = None +) -> PublicAccessStatus: + """Ask Neon whether ``config.bucket`` is publicly readable (``public_read``).""" + if client is None and not resolve_endpoint_url(): + return PublicAccessStatus(BucketExposure.UNKNOWN, "no Neon storage endpoint configured") + return s3_check_public_access( + config, + build_client=lambda cfg: NeonObjectStore(cfg)._client, + client=client, + unsupported_codes=_UNSUPPORTED_POLICY_STATUS_CODES, + no_policy_status=PublicAccessStatus(BucketExposure.PRIVATE), + ) + + +register_object_store( + PROVIDER_NAME, + _factory, + credential_hint=CREDENTIAL_HINT, + extra_fields=EXTRA_FIELDS, + public_access_checker=check_public_access, + max_parallel_uploads=MAX_PARALLEL_UPLOADS, +) + +__all__ = [ + "CREDENTIAL_HINT", + "EXTRA_FIELDS", + "MAX_PARALLEL_UPLOADS", + "MISSING_ENDPOINT_MESSAGE", + "PROVIDER_NAME", + "NeonObjectStore", + "check_public_access", +] diff --git a/infrastructure/filestorage/providers/registry.py b/infrastructure/filestorage/providers/registry.py index 8c52599f7e..45b760b53f 100644 --- a/infrastructure/filestorage/providers/registry.py +++ b/infrastructure/filestorage/providers/registry.py @@ -71,6 +71,7 @@ class SetupExtraField: BuiltInProvider.VERCEL.value: "infrastructure.filestorage.providers.vercel", BuiltInProvider.AZURE.value: "infrastructure.filestorage.providers.azure", BuiltInProvider.S3COMPAT.value: "infrastructure.filestorage.providers.s3compat", + BuiltInProvider.NEON.value: "infrastructure.filestorage.providers.neon", } diff --git a/infrastructure/filestorage/providers/s3compat.py b/infrastructure/filestorage/providers/s3compat.py index 5a766e4b94..812648fefe 100644 --- a/infrastructure/filestorage/providers/s3compat.py +++ b/infrastructure/filestorage/providers/s3compat.py @@ -50,7 +50,7 @@ def __init__( client: Any | None = None, ) -> None: self._config = config - self._endpoint_url = _resolve_endpoint_url(endpoint_url) + self._endpoint_url = resolve_endpoint_url(endpoint_url) self._client = client if client is not None else _build_client(config, self._endpoint_url) def describe(self) -> str: @@ -67,7 +67,7 @@ def put_object(self, key: str, data: bytes) -> None: raise RemoteSyncUnavailableError(f"cannot write {key} — {s3_reason(exc)}") from exc -def _resolve_endpoint_url(explicit: str | None = None) -> str | None: +def resolve_endpoint_url(explicit: str | None = None) -> str | None: """The endpoint URL to use, else ``None`` for the built-in AWS endpoint. Checked in order: the ``explicit`` argument (tests / direct callers), the @@ -135,7 +135,7 @@ def check_public_access( """ return s3_check_public_access( config, - build_client=lambda cfg: _build_client(cfg, _resolve_endpoint_url()), + build_client=lambda cfg: _build_client(cfg, resolve_endpoint_url()), client=client, unsupported_codes=_UNSUPPORTED_POLICY_STATUS_CODES, no_policy_status=PublicAccessStatus(BucketExposure.UNKNOWN, "no bucket policy present"), @@ -160,4 +160,5 @@ def check_public_access( "PROVIDER_NAME", "S3CompatObjectStore", "check_public_access", + "resolve_endpoint_url", ] diff --git a/integrations/_catalog_impl.py b/integrations/_catalog_impl.py index f1625e2dd0..fa35d2a34a 100644 --- a/integrations/_catalog_impl.py +++ b/integrations/_catalog_impl.py @@ -146,6 +146,7 @@ MYSQL_SSL_MODE_ENV, MYSQL_USERNAME_ENV, ) +from config.constants.neon import NEON_API_KEY_ENV, NEON_BRANCH_ID_ENV, NEON_PROJECT_ID_ENV from config.constants.new_relic import ( NEW_RELIC_ACCOUNT_ID_ENV, NEW_RELIC_API_KEY_ENV, @@ -309,6 +310,8 @@ from integrations.mongodb_atlas import classify as _classify_mongodb_atlas from integrations.mysql import build_mysql_config from integrations.mysql import classify as _classify_mysql +from integrations.neon import build_neon_config +from integrations.neon import classify as _classify_neon from integrations.new_relic import classify as _classify_new_relic from integrations.new_relic.config import NewRelicIntegrationConfig from integrations.openobserve import classify as _classify_openobserve @@ -494,6 +497,7 @@ def classify_integrations(integrations: list[dict[str, Any]]) -> dict[str, Any]: "mongodb_atlas": _classify_mongodb_atlas, "mariadb": _classify_mariadb, "vercel": _classify_vercel, + "neon": _classify_neon, "pagerduty": _classify_pagerduty, "pipedream": _classify_pipedream, "incident_io": _classify_incident_io, @@ -1072,6 +1076,27 @@ def load_env_integrations() -> list[dict[str, Any]]: ) ) + neon_api_key = resolve_env_credential(NEON_API_KEY_ENV) + neon_project_id = os.getenv(NEON_PROJECT_ID_ENV, "").strip() + if neon_api_key and neon_project_id: + try: + neon_config = build_neon_config( + { + "api_key": neon_api_key, + "project_id": neon_project_id, + "branch_id": os.getenv(NEON_BRANCH_ID_ENV, "").strip(), + } + ) + except Exception as exc: + _report_env_loader_failure(exc, integration="neon") + else: + integrations.append( + _active_env_record( + "neon", + neon_config.model_dump(include={"api_key", "project_id", "branch_id"}), + ) + ) + pagerduty_api_key = resolve_env_credential(PAGERDUTY_API_KEY_ENV) if pagerduty_api_key: try: diff --git a/integrations/alert_source_catalog.py b/integrations/alert_source_catalog.py index 33cc9fb06f..80c3a05389 100644 --- a/integrations/alert_source_catalog.py +++ b/integrations/alert_source_catalog.py @@ -53,6 +53,7 @@ "dagster": routing(("dagster",), ("dagster",)), "rabbitmq": routing(("rabbitmq",), ("rabbitmq",)), "supabase": routing(("supabase",), ("supabase",)), + "neon": routing(("neon", "postgresql"), ("neon", "postgresql")), "opensearch": routing(("opensearch",), ("opensearch",)), "openobserve": routing(("openobserve",), ("openobserve",)), "betterstack": routing(("betterstack",), ("betterstack",)), @@ -108,6 +109,7 @@ "kafka": ("kafka",), "rabbitmq": ("rabbitmq", "amqp"), "supabase": ("supabase",), + "neon": ("neon", "neon.tech", "neon postgres", "neon branch", "neon compute"), "opensearch": ("opensearch", "elasticsearch"), "openobserve": ("openobserve",), "betterstack": ("betterstack", "better stack"), diff --git a/integrations/catalog.py b/integrations/catalog.py index 24cc158b5a..87b76eaafb 100644 --- a/integrations/catalog.py +++ b/integrations/catalog.py @@ -155,6 +155,7 @@ def add(service: str, configured: bool) -> None: or _all_env("RAILWAY_PROJECT", "RAILWAY_SERVICE", "RAILWAY_ENVIRONMENT"), ) add("vercel", _env_is_set("VERCEL_API_TOKEN")) + add("neon", _all_env("NEON_API_KEY", "NEON_PROJECT_ID")) add("pagerduty", _env_is_set("PAGERDUTY_API_KEY")) add("incident_io", _env_is_set("INCIDENT_IO_API_KEY")) add("jira", _all_env("JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN")) diff --git a/integrations/cli.py b/integrations/cli.py index e07118877b..f86efa69f4 100644 --- a/integrations/cli.py +++ b/integrations/cli.py @@ -221,6 +221,12 @@ def _setup_vercel() -> None: _run_spec_setup(VERCEL_SETUP) +def _setup_neon() -> None: + from integrations.neon.setup import NEON_SETUP + + _run_spec_setup(NEON_SETUP) + + def _setup_railway() -> None: from integrations.railway.setup import RAILWAY_SETUP @@ -504,6 +510,7 @@ def _setup_google_docs() -> None: "tracer": _setup_tracer, "vercel": _setup_vercel, "railway": _setup_railway, + "neon": _setup_neon, "github": setup_github, "gitlab": _setup_gitlab, "sentry": _setup_sentry, diff --git a/integrations/effective_models.py b/integrations/effective_models.py index b64a839bd4..39e7dec1b5 100644 --- a/integrations/effective_models.py +++ b/integrations/effective_models.py @@ -48,6 +48,7 @@ class EffectiveIntegrations(StrictConfigModel): google_docs: EffectiveIntegrationEntry | None = None gitlab: EffectiveIntegrationEntry | None = None vercel: EffectiveIntegrationEntry | None = None + neon: EffectiveIntegrationEntry | None = None railway: EffectiveIntegrationEntry | None = None jira: EffectiveIntegrationEntry | None = None servicenow: EffectiveIntegrationEntry | None = None diff --git a/integrations/neon/__init__.py b/integrations/neon/__init__.py new file mode 100644 index 0000000000..d6e1befa54 --- /dev/null +++ b/integrations/neon/__init__.py @@ -0,0 +1,17 @@ +"""Neon integration: project, compute, operations, and Object Storage diagnostics.""" + +from __future__ import annotations + +from integrations.neon.config import ( + NeonConfig, + build_neon_config, + classify, + neon_config_from_env, +) + +__all__ = [ + "NeonConfig", + "build_neon_config", + "classify", + "neon_config_from_env", +] diff --git a/integrations/neon/client.py b/integrations/neon/client.py new file mode 100644 index 0000000000..4ce5e6ce19 --- /dev/null +++ b/integrations/neon/client.py @@ -0,0 +1,214 @@ +"""Read-only client for the Neon control-plane API. + +Covers the endpoints an incident investigation reads: the project, its +branches, compute endpoints, recent operations, and the Object Storage state, +buckets, and object listings of one branch. Every method is a GET; nothing here +changes a project. +""" + +from __future__ import annotations + +import logging +import re +from http import HTTPStatus +from typing import Any + +import httpx + +from infrastructure.delivery.notifications.redaction import redact_token +from integrations.neon.config import NeonConfig +from integrations.probes import ProbeResult + +logger = logging.getLogger(__name__) + +_MAX_PATH_SEGMENT_LEN = 128 +# Neon project/branch IDs and bucket names are lowercase slugs: letters, +# digits, hyphens (bucket names may also hold dots). Reject anything else so a +# model-supplied value can never add path segments or traversal. +_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_ERROR_DETAIL_LIMIT = 300 + + +class NeonAPIError(RuntimeError): + """A Neon API call that did not return a usable 2xx response.""" + + def __init__( + self, message: str, *, status_code: int | None = None, invalid_input: bool = False + ) -> None: + super().__init__(message) + self.status_code = status_code + self.invalid_input = invalid_input + + +def safe_path_segment(raw: str, *, label: str) -> str: + """Return ``raw`` stripped, or raise when it is not a plain Neon identifier.""" + cleaned = (raw or "").strip() + if ( + not cleaned + or len(cleaned) > _MAX_PATH_SEGMENT_LEN + or ".." in cleaned + or not _PATH_SEGMENT_RE.fullmatch(cleaned) + ): + raise NeonAPIError( + f"Invalid Neon {label}: {cleaned[:_MAX_PATH_SEGMENT_LEN]!r}.", invalid_input=True + ) + return cleaned + + +def _status_message(status_code: int, body: Any) -> str: + """A short, secret-free reason for a non-2xx Neon response.""" + detail = "" + if isinstance(body, dict): + detail = str(body.get("message") or body.get("error") or "").strip() + if status_code == HTTPStatus.UNAUTHORIZED: + return "Neon rejected the API key (HTTP 401)." + if status_code == HTTPStatus.FORBIDDEN: + reason = f": {detail}" if detail else "." + return f"The Neon API key cannot access this resource (HTTP 403){reason}" + if status_code == HTTPStatus.NOT_FOUND: + reason = f": {detail}" if detail else "." + return f"Neon resource not found (HTTP 404){reason}" + if status_code == HTTPStatus.TOO_MANY_REQUESTS: + return "Neon API rate limit reached (HTTP 429). Retry shortly." + if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + return f"Neon API is unavailable (HTTP {status_code}). Retry shortly." + reason = f": {detail}" if detail else "." + return f"Neon API returned HTTP {status_code}{reason}" + + +class NeonClient: + """Synchronous client for the Neon API, scoped to one configured project.""" + + def __init__(self, config: NeonConfig, *, transport: httpx.BaseTransport | None = None) -> None: + self.config = config + self._transport = transport + self._client: httpx.Client | None = None + + def _http(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client( + base_url=self.config.base_url, + headers=self.config.headers, + timeout=self.config.timeout_seconds, + transport=self._transport, + ) + return self._client + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + if self._client is not None: + self._client.close() + self._client = None + + def __enter__(self) -> NeonClient: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + @property + def project_id(self) -> str: + return safe_path_segment(self.config.project_id, label="project ID") + + def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + query = {key: value for key, value in (params or {}).items() if value not in ("", None)} + try: + response = self._http().get(path, params=query) + except httpx.TimeoutException as exc: + raise NeonAPIError("Neon API request timed out.") from exc + except httpx.HTTPError as exc: + reason = redact_token(str(exc), self.config.api_key)[:_ERROR_DETAIL_LIMIT] + raise NeonAPIError(f"Neon API request failed: {reason}") from exc + try: + body: Any = response.json() + except ValueError: + body = None + if not response.is_success: + raise NeonAPIError( + redact_token(_status_message(response.status_code, body), self.config.api_key), + status_code=response.status_code, + ) + if not isinstance(body, dict): + raise NeonAPIError("Neon API returned a response that is not a JSON object.") + return body + + def get_project(self) -> dict[str, Any]: + """``GET /projects/{project_id}`` — the project record.""" + project = self._get(f"/projects/{self.project_id}").get("project") + return project if isinstance(project, dict) else {} + + def list_branches(self) -> list[dict[str, Any]]: + """``GET /projects/{project_id}/branches``.""" + branches = self._get(f"/projects/{self.project_id}/branches").get("branches") + return [item for item in branches or [] if isinstance(item, dict)] + + def list_endpoints(self) -> list[dict[str, Any]]: + """``GET /projects/{project_id}/endpoints`` — every compute in the project.""" + endpoints = self._get(f"/projects/{self.project_id}/endpoints").get("endpoints") + return [item for item in endpoints or [] if isinstance(item, dict)] + + def list_operations(self, limit: int) -> list[dict[str, Any]]: + """``GET /projects/{project_id}/operations`` — newest first.""" + operations = self._get( + f"/projects/{self.project_id}/operations", {"limit": max(1, min(limit, 1000))} + ).get("operations") + return [item for item in operations or [] if isinstance(item, dict)] + + def resolve_branch_id(self, branch_id: str = "") -> str: + """The requested branch, else the configured one, else the project default.""" + requested = (branch_id or "").strip() or self.config.branch_id + if requested: + return safe_path_segment(requested, label="branch ID") + for branch in self.list_branches(): + if branch.get("default"): + return safe_path_segment(str(branch.get("id", "")), label="branch ID") + raise NeonAPIError("The Neon project has no default branch; pass a branch ID.") + + def get_branch_storage(self, branch_id: str) -> dict[str, Any]: + """``GET /projects/{project_id}/branches/{branch_id}/storage``.""" + branch = safe_path_segment(branch_id, label="branch ID") + return self._get(f"/projects/{self.project_id}/branches/{branch}/storage") + + def list_buckets(self, branch_id: str) -> list[dict[str, Any]]: + """``GET /projects/{project_id}/branches/{branch_id}/buckets``.""" + branch = safe_path_segment(branch_id, label="branch ID") + buckets = self._get(f"/projects/{self.project_id}/branches/{branch}/buckets").get("buckets") + return [item for item in buckets or [] if isinstance(item, dict)] + + def list_objects( + self, + branch_id: str, + bucket: str, + *, + prefix: str = "", + delimiter: str = "", + limit: int = 50, + cursor: str = "", + ) -> dict[str, Any]: + """``GET .../buckets/{bucket}/objects`` — one page of objects and folders.""" + branch = safe_path_segment(branch_id, label="branch ID") + bucket_name = safe_path_segment(bucket, label="bucket name") + return self._get( + f"/projects/{self.project_id}/branches/{branch}/buckets/{bucket_name}/objects", + {"prefix": prefix, "delimiter": delimiter, "limit": limit, "cursor": cursor}, + ) + + def probe_access(self) -> ProbeResult: + """Validate the key and project ID by reading the project.""" + if not self.config.api_key: + return ProbeResult.missing("Missing Neon API key.") + if not self.config.project_id: + return ProbeResult.missing("Missing Neon project ID.") + try: + with self: + project = self.get_project() + except NeonAPIError as exc: + return ProbeResult.failed(str(exc)) + name = str(project.get("name") or self.config.project_id) + region = str(project.get("region_id") or "unknown region") + return ProbeResult.passed( + f"Connected to Neon project {name} ({self.config.project_id}, {region})." + ) + + +__all__ = ["NeonAPIError", "NeonClient", "safe_path_segment"] diff --git a/integrations/neon/config.py b/integrations/neon/config.py new file mode 100644 index 0000000000..1ad5c654c0 --- /dev/null +++ b/integrations/neon/config.py @@ -0,0 +1,116 @@ +"""Neon integration config, env loading, and store classification. + +One record scopes OpenSRE to a single Neon project. ``branch_id`` is optional: +when it is empty, the tools resolve the project's default branch at call time, +so a promoted branch is followed without editing the integration. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from pydantic import Field, field_validator + +from config.constants.neon import ( + NEON_API_BASE_URL, + NEON_API_KEY_ENV, + NEON_BRANCH_ID_ENV, + NEON_PROJECT_ID_ENV, +) +from config.llm_credentials import resolve_env_credential +from config.strict_config import StrictConfigModel +from integrations._validation_helpers import report_classify_failure + +logger = logging.getLogger(__name__) + +DEFAULT_NEON_TIMEOUT_SECONDS = 15.0 +DEFAULT_NEON_MAX_RESULTS = 50 + + +class NeonConfig(StrictConfigModel): + """Normalized Neon API settings for one project.""" + + api_key: str = "" + project_id: str = "" + branch_id: str = "" + base_url: str = NEON_API_BASE_URL + timeout_seconds: float = Field(default=DEFAULT_NEON_TIMEOUT_SECONDS, gt=0) + max_results: int = Field(default=DEFAULT_NEON_MAX_RESULTS, gt=0, le=200) + integration_id: str = "" + + @field_validator("api_key", "project_id", "branch_id", "integration_id", mode="before") + @classmethod + def _strip(cls, value: Any) -> str: + return str(value or "").strip() + + @field_validator("base_url", mode="before") + @classmethod + def _normalize_base_url(cls, value: Any) -> str: + normalized = str(value or "").strip().rstrip("/") + return normalized or NEON_API_BASE_URL + + @property + def is_configured(self) -> bool: + return bool(self.api_key and self.project_id) + + @property + def headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + + +def build_neon_config(raw: dict[str, Any] | None) -> NeonConfig: + """Build a normalized Neon config from env/store data.""" + return NeonConfig.model_validate(raw or {}) + + +def neon_config_from_env() -> NeonConfig | None: + """Load a Neon config from env vars, or ``None`` when the pair is incomplete. + + The API key goes through ``resolve_env_credential`` (env, then the local + credentials file); the project and branch IDs are plain env reads. + """ + api_key = resolve_env_credential(NEON_API_KEY_ENV) + project_id = os.getenv(NEON_PROJECT_ID_ENV, "").strip() + if not api_key or not project_id: + return None + return build_neon_config( + { + "api_key": api_key, + "project_id": project_id, + "branch_id": os.getenv(NEON_BRANCH_ID_ENV, ""), + } + ) + + +def classify(credentials: dict[str, Any], record_id: str) -> tuple[NeonConfig | None, str | None]: + """Turn a stored or env record into a resolved ``neon`` source.""" + try: + config = build_neon_config( + { + "api_key": credentials.get("api_key", ""), + "project_id": credentials.get("project_id", ""), + "branch_id": credentials.get("branch_id", ""), + "integration_id": record_id, + } + ) + except Exception as exc: + report_classify_failure(exc, logger=logger, integration="neon", record_id=record_id) + return None, None + if config.is_configured: + return config, "neon" + return None, None + + +__all__ = [ + "DEFAULT_NEON_MAX_RESULTS", + "DEFAULT_NEON_TIMEOUT_SECONDS", + "NeonConfig", + "build_neon_config", + "classify", + "neon_config_from_env", +] diff --git a/integrations/neon/diagnostics.py b/integrations/neon/diagnostics.py new file mode 100644 index 0000000000..917667b5cb --- /dev/null +++ b/integrations/neon/diagnostics.py @@ -0,0 +1,271 @@ +"""Turn Neon API payloads into compact, agent-facing incident summaries. + +Each function takes a :class:`NeonClient`, never raises for an expected API +failure, and returns either a result dict or the shared unavailable envelope +with the right failure state (missing config, denied, no match, transport). +""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Any + +from core.tool_framework.utils import ( + tool_access_denied, + tool_missing_config, + tool_no_match, + tool_transport_failure, +) +from integrations.neon.client import NeonAPIError, NeonClient + +SOURCE = "neon" +DEFAULT_OPERATIONS_LIMIT = 20 +MAX_OPERATIONS_LIMIT = 100 +# Neon operation statuses that mean the control plane gave up on a change. +FAILED_OPERATION_STATUSES = frozenset({"failed", "error"}) +# Statuses that mean work is still queued or running against the project. +PENDING_OPERATION_STATUSES = frozenset({"running", "scheduling", "cancelling"}) + + +def _failure(exc: NeonAPIError) -> dict[str, Any]: + status = exc.status_code + if status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + return tool_access_denied(SOURCE, str(exc)) + if exc.invalid_input or status == HTTPStatus.NOT_FOUND: + return tool_no_match(SOURCE, str(exc)) + return tool_transport_failure(SOURCE, str(exc)) + + +def _not_configured(client: NeonClient) -> dict[str, Any] | None: + if not client.config.is_configured: + return tool_missing_config( + SOURCE, "Neon is not configured. Set NEON_API_KEY and NEON_PROJECT_ID." + ) + return None + + +def _clamp(value: int, *, low: int, high: int) -> int: + return max(low, min(int(value), high)) + + +def _branch_summary(branch: dict[str, Any]) -> dict[str, Any]: + return { + "id": branch.get("id", ""), + "name": branch.get("name", ""), + "default": bool(branch.get("default", False)), + "protected": bool(branch.get("protected", False)), + "current_state": branch.get("current_state", ""), + "pending_state": branch.get("pending_state"), + "parent_id": branch.get("parent_id"), + "logical_size_bytes": branch.get("logical_size"), + "expires_at": branch.get("expires_at"), + "updated_at": branch.get("updated_at", ""), + } + + +def _endpoint_summary(endpoint: dict[str, Any]) -> dict[str, Any]: + return { + "id": endpoint.get("id", ""), + "branch_id": endpoint.get("branch_id", ""), + "type": endpoint.get("type", ""), + "host": endpoint.get("host", ""), + "current_state": endpoint.get("current_state", ""), + "pending_state": endpoint.get("pending_state"), + "disabled": bool(endpoint.get("disabled", False)), + "autoscaling_limit_min_cu": endpoint.get("autoscaling_limit_min_cu"), + "autoscaling_limit_max_cu": endpoint.get("autoscaling_limit_max_cu"), + "suspend_timeout_seconds": endpoint.get("suspend_timeout_seconds"), + "pooler_enabled": bool(endpoint.get("pooler_enabled", False)), + "last_active": endpoint.get("last_active"), + "started_at": endpoint.get("started_at"), + "suspended_at": endpoint.get("suspended_at"), + } + + +def _operation_summary(operation: dict[str, Any]) -> dict[str, Any]: + summary = { + "id": operation.get("id", ""), + "action": operation.get("action", ""), + "status": operation.get("status", ""), + "branch_id": operation.get("branch_id"), + "endpoint_id": operation.get("endpoint_id"), + "failures_count": operation.get("failures_count", 0), + "created_at": operation.get("created_at", ""), + "updated_at": operation.get("updated_at", ""), + "total_duration_ms": operation.get("total_duration_ms"), + } + if operation.get("error"): + summary["error"] = str(operation["error"]) + return summary + + +def get_project_status( + client: NeonClient, + *, + branch_id: str = "", + operations_limit: int = DEFAULT_OPERATIONS_LIMIT, +) -> dict[str, Any]: + """Project settings, branches, computes, and recent control-plane operations. + + ``branch_id`` narrows computes and operations to one branch; the branch + list always covers the whole project so a missing or renamed branch shows. + """ + if (missing := _not_configured(client)) is not None: + return missing + limit = _clamp(operations_limit, low=1, high=MAX_OPERATIONS_LIMIT) + branch_filter = (branch_id or "").strip() + max_results = client.config.max_results + try: + with client: + project = client.get_project() + branches = client.list_branches() + endpoints = client.list_endpoints() + operations = client.list_operations(limit) + except NeonAPIError as exc: + return _failure(exc) + + if branch_filter: + endpoints = [item for item in endpoints if item.get("branch_id") == branch_filter] + operations = [item for item in operations if item.get("branch_id") == branch_filter] + + endpoint_rows = [_endpoint_summary(item) for item in endpoints] + operation_rows = [_operation_summary(item) for item in operations] + raw_settings = project.get("settings") + settings: dict[str, Any] = raw_settings if isinstance(raw_settings, dict) else {} + raw_allowed_ips = settings.get("allowed_ips") + allowed_ips = raw_allowed_ips.get("ips", []) if isinstance(raw_allowed_ips, dict) else [] + return { + "source": SOURCE, + "available": True, + "project": { + "id": project.get("id", client.config.project_id), + "name": project.get("name", ""), + "region_id": project.get("region_id", ""), + "pg_version": project.get("pg_version"), + "created_at": project.get("created_at", ""), + "compute_last_active_at": project.get("compute_last_active_at"), + "history_retention_seconds": project.get("history_retention_seconds"), + "allowed_ips": allowed_ips, + "block_public_connections": settings.get("block_public_connections"), + "maintenance_window": settings.get("maintenance_window"), + }, + "branch_filter": branch_filter or None, + "total_branches": len(branches), + "branches": [_branch_summary(item) for item in branches[:max_results]], + "branches_truncated": len(branches) > max_results, + "total_endpoints": len(endpoint_rows), + "endpoints": endpoint_rows[:max_results], + "endpoints_truncated": len(endpoint_rows) > max_results, + "active_endpoints": [ + row["id"] for row in endpoint_rows if row["current_state"] == "active" + ], + "disabled_endpoints": [row["id"] for row in endpoint_rows if row["disabled"]], + "operations": operation_rows, + "failed_operations": [ + row for row in operation_rows if row["status"] in FAILED_OPERATION_STATUSES + ], + "pending_operations": [ + row for row in operation_rows if row["status"] in PENDING_OPERATION_STATUSES + ], + } + + +def get_storage_buckets(client: NeonClient, *, branch_id: str = "") -> dict[str, Any]: + """Object Storage state and buckets for one branch (default: the configured one).""" + if (missing := _not_configured(client)) is not None: + return missing + max_results = client.config.max_results + try: + with client: + branch = client.resolve_branch_id(branch_id) + storage = client.get_branch_storage(branch) + buckets = client.list_buckets(branch) if storage.get("enabled", True) else [] + except NeonAPIError as exc: + return _failure(exc) + rows = [ + { + "name": item.get("name", ""), + "access_level": item.get("access_level", ""), + "created_at": item.get("created_at", ""), + } + for item in buckets + ] + return { + "source": SOURCE, + "available": True, + "project_id": client.config.project_id, + "branch_id": branch, + "storage_enabled": bool(storage.get("enabled", False)), + "s3_endpoint": storage.get("s3_endpoint", ""), + "region": storage.get("region", ""), + "force_path_style": storage.get("force_path_style"), + "total_buckets": len(rows), + "buckets": rows[:max_results], + "truncated": len(rows) > max_results, + "public_buckets": [row["name"] for row in rows if row["access_level"] != "private"], + } + + +def list_storage_objects( + client: NeonClient, + *, + bucket: str, + branch_id: str = "", + prefix: str = "", + recursive: bool = False, + limit: int = 0, + cursor: str = "", +) -> dict[str, Any]: + """One page of objects (and collapsed folders unless ``recursive``) in a bucket.""" + if (missing := _not_configured(client)) is not None: + return missing + if not (bucket or "").strip(): + return tool_missing_config(SOURCE, "A bucket name is required.") + page_size = _clamp(limit or client.config.max_results, low=1, high=200) + try: + with client: + branch = client.resolve_branch_id(branch_id) + page = client.list_objects( + branch, + bucket, + prefix=prefix, + delimiter="" if recursive else "/", + limit=page_size, + cursor=cursor, + ) + except NeonAPIError as exc: + return _failure(exc) + objects = [ + { + "key": item.get("key", ""), + "size": item.get("size"), + "last_modified": item.get("last_modified", ""), + "etag": item.get("etag", ""), + } + for item in page.get("objects") or [] + if isinstance(item, dict) + ] + folders = [str(item) for item in page.get("folders") or []] + return { + "source": SOURCE, + "available": True, + "branch_id": branch, + "bucket": bucket.strip(), + "prefix": page.get("prefix", prefix), + "folders": folders, + "objects": objects, + "returned": len(objects) + len(folders), + "truncated": bool(page.get("is_truncated", False)), + "next_cursor": page.get("next_cursor") or None, + } + + +__all__ = [ + "DEFAULT_OPERATIONS_LIMIT", + "FAILED_OPERATION_STATUSES", + "MAX_OPERATIONS_LIMIT", + "PENDING_OPERATION_STATUSES", + "get_project_status", + "get_storage_buckets", + "list_storage_objects", +] diff --git a/integrations/neon/setup.py b/integrations/neon/setup.py new file mode 100644 index 0000000000..34f0dbb12c --- /dev/null +++ b/integrations/neon/setup.py @@ -0,0 +1,49 @@ +"""What Neon needs before it is considered configured. + +An API key and a project ID are required. ``branch_id`` is optional: left +blank, the tools follow the project's default branch. +""" + +from __future__ import annotations + +from config.constants.neon import NEON_API_KEY_ENV, NEON_BRANCH_ID_ENV, NEON_PROJECT_ID_ENV +from integrations.neon.verifier import verify_neon +from integrations.setup_flow import IntegrationSetupSpec, SetupField + +API_KEY_FIELD = "api_key" +PROJECT_ID_FIELD = "project_id" +BRANCH_ID_FIELD = "branch_id" + +NEON_SETUP = IntegrationSetupSpec( + service="neon", + fields=( + SetupField( + name=API_KEY_FIELD, + label="Neon API key", + prompt="Neon API key (personal, organization, or project-scoped)", + env_var=NEON_API_KEY_ENV, + secret=True, + ), + SetupField( + name=PROJECT_ID_FIELD, + label="Neon project ID", + prompt="Neon project ID (e.g. little-lab-10567887)", + env_var=NEON_PROJECT_ID_ENV, + ), + SetupField( + name=BRANCH_ID_FIELD, + label="Neon branch ID", + prompt="Branch ID for storage tools (optional, defaults to the project's default branch)", + env_var=NEON_BRANCH_ID_ENV, + required=False, + ), + ), + verify=verify_neon, +) + +__all__ = [ + "API_KEY_FIELD", + "BRANCH_ID_FIELD", + "NEON_SETUP", + "PROJECT_ID_FIELD", +] diff --git a/integrations/neon/tools/__init__.py b/integrations/neon/tools/__init__.py new file mode 100644 index 0000000000..1faa0e1a16 --- /dev/null +++ b/integrations/neon/tools/__init__.py @@ -0,0 +1 @@ +"""Agent tools for the Neon integration (all read-only).""" diff --git a/integrations/neon/tools/_params.py b/integrations/neon/tools/_params.py new file mode 100644 index 0000000000..f83db1534e --- /dev/null +++ b/integrations/neon/tools/_params.py @@ -0,0 +1,65 @@ +"""Credential and scope plumbing shared by the Neon tools. + +The API key, project ID, and API base URL are injected from the resolved +``neon`` integration and hidden from the model, so a tool call can never point +the key at another project or host. ``branch_id`` is a default the model may +override to look at another branch of the same project. +""" + +from __future__ import annotations + +from typing import Any + +from integrations.neon.client import NeonClient +from integrations.neon.config import build_neon_config + +INJECTED_PARAMS = ("api_key", "project_id", "base_url") + +INJECTED_SCHEMA_PROPERTIES: dict[str, dict[str, Any]] = { + "api_key": {"type": "string", "description": "Neon API key (injected)."}, + "project_id": {"type": "string", "description": "Neon project ID (injected)."}, + "base_url": {"type": "string", "description": "Neon API base URL (injected)."}, +} + +BRANCH_ID_PROPERTY: dict[str, Any] = { + "type": "string", + "default": "", + "description": ( + "Neon branch ID (br-...). Defaults to the configured branch, else the " + "project's default branch." + ), +} + + +def neon_is_available(sources: dict[str, Any]) -> bool: + """True when a resolved Neon integration carries a key and a project.""" + neon = sources.get("neon") or {} + return bool(neon.get("api_key") and neon.get("project_id")) + + +def neon_extract_params(sources: dict[str, Any]) -> dict[str, Any]: + """Injected credentials plus the configured default branch.""" + neon = sources.get("neon") or {} + return { + "api_key": str(neon.get("api_key", "")), + "project_id": str(neon.get("project_id", "")), + "base_url": str(neon.get("base_url", "")), + "branch_id": str(neon.get("branch_id", "")), + } + + +def neon_client(api_key: str, project_id: str, base_url: str = "") -> NeonClient: + """A client for the injected project.""" + return NeonClient( + build_neon_config({"api_key": api_key, "project_id": project_id, "base_url": base_url}) + ) + + +__all__ = [ + "BRANCH_ID_PROPERTY", + "INJECTED_PARAMS", + "INJECTED_SCHEMA_PROPERTIES", + "neon_client", + "neon_extract_params", + "neon_is_available", +] diff --git a/integrations/neon/tools/neon_project_status_tool/__init__.py b/integrations/neon/tools/neon_project_status_tool/__init__.py new file mode 100644 index 0000000000..4610f065a1 --- /dev/null +++ b/integrations/neon/tools/neon_project_status_tool/__init__.py @@ -0,0 +1,12 @@ +"""Registry entrypoint for the Neon project status tool.""" + +from __future__ import annotations + +from integrations.neon.tools.neon_project_status_tool.tool import ( + NeonProjectStatusTool, + get_neon_project_status, +) + +TOOL_MODULES = ("tool",) + +__all__ = ["TOOL_MODULES", "NeonProjectStatusTool", "get_neon_project_status"] diff --git a/integrations/neon/tools/neon_project_status_tool/tool.py b/integrations/neon/tools/neon_project_status_tool/tool.py new file mode 100644 index 0000000000..9da84a784f --- /dev/null +++ b/integrations/neon/tools/neon_project_status_tool/tool.py @@ -0,0 +1,137 @@ +"""Neon project status: branches, computes, and recent control-plane operations.""" + +from __future__ import annotations + +from typing import Any + +from core.domain.types.evidence import record_evidence_entry +from core.domain.types.tools import ToolSurface +from core.tool import BaseTool, SideEffectLevel +from integrations.neon.diagnostics import ( + DEFAULT_OPERATIONS_LIMIT, + MAX_OPERATIONS_LIMIT, + get_project_status, +) +from integrations.neon.tools._params import ( + BRANCH_ID_PROPERTY, + INJECTED_PARAMS, + INJECTED_SCHEMA_PROPERTIES, + neon_client, + neon_extract_params, + neon_is_available, +) + + +def map_neon_project_status( + evidence: dict[str, Any], output: dict[str, Any], _tool_input: dict[str, Any] +) -> None: + """Cite compute states and any failed or pending operations.""" + if not output.get("available"): + return + project = output.get("project") or {} + # Both counts cover every compute: ``endpoints`` is capped at max_results. + total = output.get("total_endpoints", len(output.get("endpoints") or [])) + active = output.get("active_endpoints") or [] + parts = [ + f"{len(active)}/{total} computes active", + f"{output.get('total_branches', 0)} branches", + ] + failed = output.get("failed_operations") or [] + if failed: + actions = ", ".join(sorted({str(item.get("action", "")) for item in failed})) + parts.append(f"{len(failed)} failed operations ({actions})") + pending = output.get("pending_operations") or [] + if pending: + parts.append(f"{len(pending)} operations in progress") + disabled = output.get("disabled_endpoints") or [] + if disabled: + parts.append(f"disabled computes: {', '.join(disabled)}") + record_evidence_entry( + evidence, + source="get_neon_project_status", + label=f"Neon project {project.get('name') or project.get('id', '')}".strip(), + summary=", ".join(parts), + ) + + +class NeonProjectStatusTool(BaseTool): + """Summarize a Neon project's branches, computes, and recent operations.""" + + name = "get_neon_project_status" + source = "neon" + surfaces = (ToolSurface.CHAT, ToolSurface.ACTION) + side_effect_level = SideEffectLevel.READ_ONLY + concurrency_safe = True + evidence_mapper = map_neon_project_status + description = ( + "Read the configured Neon Postgres project: region and Postgres version, branches " + "and their state, compute endpoints (active/idle, autoscaling CU limits, " + "scale-to-zero timeout, last active time), and recent control-plane operations " + "with failed and in-progress ones listed separately." + ) + use_cases = [ + "Checking whether a Neon compute is suspended, disabled, or stuck starting during a connection incident", + "Finding failed or long-running Neon operations (compute restarts, branch resets) near an incident", + "Confirming autoscaling limits and scale-to-zero settings when latency spikes on cold starts", + "Listing branches to spot a reset, deleted, or unexpectedly restored branch", + ] + requires = ["api_key", "project_id"] + injected_params = INJECTED_PARAMS + input_schema = { + "type": "object", + "properties": { + **INJECTED_SCHEMA_PROPERTIES, + "branch_id": { + **BRANCH_ID_PROPERTY, + "description": ( + "Optional Neon branch ID to narrow computes and operations to one branch. " + "Leave empty for the whole project." + ), + }, + "operations_limit": { + "type": "integer", + "default": DEFAULT_OPERATIONS_LIMIT, + "minimum": 1, + "maximum": MAX_OPERATIONS_LIMIT, + "description": "How many recent operations to read (newest first).", + }, + }, + "required": ["api_key", "project_id"], + "additionalProperties": False, + } + outputs = { + "project": "Project region, Postgres version, IP allowlist, and maintenance window", + "branches": "Branches with state, default/protected flags, and parent", + "total_endpoints": "Number of compute endpoints before the list is capped", + "endpoints": "Compute endpoints with state, CU limits, and suspend timeout", + "failed_operations": "Operations whose status is failed or error", + "pending_operations": "Operations still running, scheduling, or cancelling", + } + + def is_available(self, sources: dict[str, Any]) -> bool: + return neon_is_available(sources) + + def extract_params(self, sources: dict[str, Any]) -> dict[str, Any]: + params = neon_extract_params(sources) + # Status covers the whole project unless the model narrows it. + params["branch_id"] = "" + params["operations_limit"] = DEFAULT_OPERATIONS_LIMIT + return params + + def run( + self, + api_key: str, + project_id: str, + base_url: str = "", + branch_id: str = "", + operations_limit: int = DEFAULT_OPERATIONS_LIMIT, + **_kwargs: Any, + ) -> dict[str, Any]: + return get_project_status( + neon_client(api_key, project_id, base_url), + branch_id=branch_id, + operations_limit=operations_limit, + ) + + +get_neon_project_status = NeonProjectStatusTool() diff --git a/integrations/neon/tools/neon_storage_buckets_tool/__init__.py b/integrations/neon/tools/neon_storage_buckets_tool/__init__.py new file mode 100644 index 0000000000..3db2c0cb8c --- /dev/null +++ b/integrations/neon/tools/neon_storage_buckets_tool/__init__.py @@ -0,0 +1,12 @@ +"""Registry entrypoint for the Neon Object Storage buckets tool.""" + +from __future__ import annotations + +from integrations.neon.tools.neon_storage_buckets_tool.tool import ( + NeonStorageBucketsTool, + get_neon_storage_buckets, +) + +TOOL_MODULES = ("tool",) + +__all__ = ["TOOL_MODULES", "NeonStorageBucketsTool", "get_neon_storage_buckets"] diff --git a/integrations/neon/tools/neon_storage_buckets_tool/tool.py b/integrations/neon/tools/neon_storage_buckets_tool/tool.py new file mode 100644 index 0000000000..f093e82eee --- /dev/null +++ b/integrations/neon/tools/neon_storage_buckets_tool/tool.py @@ -0,0 +1,97 @@ +"""Neon Object Storage buckets for one branch.""" + +from __future__ import annotations + +from typing import Any + +from core.domain.types.evidence import record_evidence_entry +from core.domain.types.tools import ToolSurface +from core.tool import BaseTool, SideEffectLevel +from integrations.neon.diagnostics import get_storage_buckets +from integrations.neon.tools._params import ( + BRANCH_ID_PROPERTY, + INJECTED_PARAMS, + INJECTED_SCHEMA_PROPERTIES, + neon_client, + neon_extract_params, + neon_is_available, +) + + +def map_neon_storage_buckets( + evidence: dict[str, Any], output: dict[str, Any], _tool_input: dict[str, Any] +) -> None: + """Cite whether storage is on, the bucket count, and any public buckets.""" + if not output.get("available"): + return + if not output.get("storage_enabled"): + summary = "Object Storage is not enabled on this branch" + else: + summary = f"{output.get('total_buckets', 0)} buckets" + public = output.get("public_buckets") or [] + if public: + summary += f", public: {', '.join(public)}" + record_evidence_entry( + evidence, + source="get_neon_storage_buckets", + label=f"Neon Object Storage ({output.get('branch_id', '')})", + summary=summary, + url=output.get("s3_endpoint") or None, + ) + + +class NeonStorageBucketsTool(BaseTool): + """List Neon Object Storage buckets and the branch's S3 endpoint.""" + + name = "get_neon_storage_buckets" + source = "neon" + surfaces = (ToolSurface.CHAT, ToolSurface.ACTION) + side_effect_level = SideEffectLevel.READ_ONLY + concurrency_safe = True + evidence_mapper = map_neon_storage_buckets + description = ( + "List the Neon Object Storage buckets on a branch with their access level " + "(private or public_read), plus whether storage is enabled and the branch's " + "S3-compatible endpoint and region." + ) + use_cases = [ + "Checking whether a bucket an app uploads to exists on the branch it is deployed against", + "Auditing which Neon buckets are publicly readable after an access or data-exposure report", + "Getting the branch's S3 endpoint to compare with an app's AWS_ENDPOINT_URL_S3", + ] + requires = ["api_key", "project_id"] + injected_params = INJECTED_PARAMS + input_schema = { + "type": "object", + "properties": { + **INJECTED_SCHEMA_PROPERTIES, + "branch_id": BRANCH_ID_PROPERTY, + }, + "required": ["api_key", "project_id"], + "additionalProperties": False, + } + outputs = { + "storage_enabled": "Whether Object Storage is enabled on the branch", + "s3_endpoint": "The branch's S3-compatible endpoint (path-style)", + "buckets": "Buckets with access level and creation time", + "public_buckets": "Names of buckets that are not private", + } + + def is_available(self, sources: dict[str, Any]) -> bool: + return neon_is_available(sources) + + def extract_params(self, sources: dict[str, Any]) -> dict[str, Any]: + return neon_extract_params(sources) + + def run( + self, + api_key: str, + project_id: str, + base_url: str = "", + branch_id: str = "", + **_kwargs: Any, + ) -> dict[str, Any]: + return get_storage_buckets(neon_client(api_key, project_id, base_url), branch_id=branch_id) + + +get_neon_storage_buckets = NeonStorageBucketsTool() diff --git a/integrations/neon/tools/neon_storage_objects_tool/__init__.py b/integrations/neon/tools/neon_storage_objects_tool/__init__.py new file mode 100644 index 0000000000..c6b2d79695 --- /dev/null +++ b/integrations/neon/tools/neon_storage_objects_tool/__init__.py @@ -0,0 +1,12 @@ +"""Registry entrypoint for the Neon Object Storage object listing tool.""" + +from __future__ import annotations + +from integrations.neon.tools.neon_storage_objects_tool.tool import ( + NeonStorageObjectsTool, + list_neon_storage_objects, +) + +TOOL_MODULES = ("tool",) + +__all__ = ["TOOL_MODULES", "NeonStorageObjectsTool", "list_neon_storage_objects"] diff --git a/integrations/neon/tools/neon_storage_objects_tool/tool.py b/integrations/neon/tools/neon_storage_objects_tool/tool.py new file mode 100644 index 0000000000..e2e4b6ef21 --- /dev/null +++ b/integrations/neon/tools/neon_storage_objects_tool/tool.py @@ -0,0 +1,136 @@ +"""List objects in a Neon Object Storage bucket.""" + +from __future__ import annotations + +from typing import Any + +from core.domain.types.evidence import record_evidence_entry +from core.domain.types.tools import ToolSurface +from core.tool import BaseTool, SideEffectLevel +from integrations.neon.diagnostics import list_storage_objects +from integrations.neon.tools._params import ( + BRANCH_ID_PROPERTY, + INJECTED_PARAMS, + INJECTED_SCHEMA_PROPERTIES, + neon_client, + neon_extract_params, + neon_is_available, +) + + +def map_neon_storage_objects( + evidence: dict[str, Any], output: dict[str, Any], _tool_input: dict[str, Any] +) -> None: + """Cite the listed location and how much it holds.""" + if not output.get("available"): + return + objects = output.get("objects") or [] + folders = output.get("folders") or [] + summary = f"{len(objects)} objects" + if folders: + summary += f", {len(folders)} folders" + if output.get("truncated"): + summary += " (more available)" + newest = max((str(item.get("last_modified", "")) for item in objects), default="") + if newest: + summary += f", newest {newest}" + location = f"{output.get('bucket', '')}/{output.get('prefix', '')}" + record_evidence_entry( + evidence, + source="list_neon_storage_objects", + label=f"Neon bucket {location}", + summary=summary, + ) + + +class NeonStorageObjectsTool(BaseTool): + """List one page of objects in a Neon Object Storage bucket.""" + + name = "list_neon_storage_objects" + source = "neon" + surfaces = (ToolSurface.CHAT, ToolSurface.ACTION) + side_effect_level = SideEffectLevel.READ_ONLY + concurrency_safe = True + evidence_mapper = map_neon_storage_objects + description = ( + "List objects (key, size, last modified, ETag) in a Neon Object Storage bucket, " + "optionally under a key prefix. Folders are collapsed on '/' unless recursive is " + "true. Returns a next_cursor when more results exist." + ) + use_cases = [ + "Checking whether an expected upload, export, or backup object landed in a Neon bucket", + "Finding the newest objects under a prefix to correlate with an incident timeline", + "Browsing a bucket folder by folder to locate artifacts referenced in logs", + ] + requires = ["api_key", "project_id"] + injected_params = INJECTED_PARAMS + input_schema = { + "type": "object", + "properties": { + **INJECTED_SCHEMA_PROPERTIES, + "bucket": {"type": "string", "description": "Bucket name on the branch."}, + "branch_id": BRANCH_ID_PROPERTY, + "prefix": { + "type": "string", + "default": "", + "description": "Only list keys that start with this prefix (e.g. 'logs/2026-11-05/').", + }, + "recursive": { + "type": "boolean", + "default": False, + "description": "List every key under the prefix instead of collapsing folders.", + }, + "limit": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200, + "description": "Maximum objects plus folders to return.", + }, + "cursor": { + "type": "string", + "default": "", + "description": "next_cursor from a previous call, to read the next page.", + }, + }, + "required": ["api_key", "project_id", "bucket"], + "additionalProperties": False, + } + outputs = { + "folders": "Collapsed key prefixes ending in '/' (empty when recursive)", + "objects": "Objects with key, size, last_modified, and etag", + "truncated": "True when another page exists", + "next_cursor": "Cursor for the next page, or null", + } + + def is_available(self, sources: dict[str, Any]) -> bool: + return neon_is_available(sources) + + def extract_params(self, sources: dict[str, Any]) -> dict[str, Any]: + return neon_extract_params(sources) + + def run( + self, + api_key: str, + project_id: str, + bucket: str = "", + base_url: str = "", + branch_id: str = "", + prefix: str = "", + recursive: bool = False, + limit: int = 50, + cursor: str = "", + **_kwargs: Any, + ) -> dict[str, Any]: + return list_storage_objects( + neon_client(api_key, project_id, base_url), + bucket=bucket, + branch_id=branch_id, + prefix=prefix, + recursive=recursive, + limit=limit, + cursor=cursor, + ) + + +list_neon_storage_objects = NeonStorageObjectsTool() diff --git a/integrations/neon/verifier.py b/integrations/neon/verifier.py new file mode 100644 index 0000000000..d14d202d9e --- /dev/null +++ b/integrations/neon/verifier.py @@ -0,0 +1,13 @@ +"""Neon integration verifier: reads the configured project with the API key.""" + +from __future__ import annotations + +from integrations.neon.client import NeonClient +from integrations.neon.config import build_neon_config +from integrations.verification import register_probe_verifier + +verify_neon = register_probe_verifier( + "neon", + config=build_neon_config, + client=NeonClient, +) diff --git a/integrations/registry.py b/integrations/registry.py index 0a2a66264e..a101543f6e 100644 --- a/integrations/registry.py +++ b/integrations/registry.py @@ -385,6 +385,13 @@ class IntegrationSpec: ), IntegrationSpec(service="trello"), IntegrationSpec(service="rds", setup_order=11), + IntegrationSpec( + service="neon", + has_verifier=True, + direct_effective=True, + setup_order=60, + verify_order=60, + ), IntegrationSpec( service="supabase", has_verifier=True, diff --git a/tests/filestorage/test_neon_provider.py b/tests/filestorage/test_neon_provider.py new file mode 100644 index 0000000000..2bec838328 --- /dev/null +++ b/tests/filestorage/test_neon_provider.py @@ -0,0 +1,120 @@ +"""Neon Object Storage remote-sync provider. + +Covers what differs from the generic S3-compatible store: the endpoint is +mandatory (no silent fallback to AWS), every endpoint source is honored in the +documented order, and exposure comes from ``GetBucketPolicyStatus``, which Neon +answers from the bucket's access level. No live calls. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from botocore.exceptions import ClientError + +from config.constants.filestorage import REMOTE_SYNC_ENDPOINT_URL_ENV +from infrastructure.filestorage.config import RemoteSyncConfig +from infrastructure.filestorage.enums import BucketExposure, BuiltInProvider +from infrastructure.filestorage.errors import RemoteSyncConfigError +from infrastructure.filestorage.providers.neon import ( + MISSING_ENDPOINT_MESSAGE, + NeonObjectStore, + check_public_access, +) +from infrastructure.filestorage.providers.registry import ( + build_object_store, + builtin_providers, + credential_hint_for_provider, + max_parallel_uploads_for_provider, +) + +NEON_ENDPOINT = "https://br-summer-art-bdbz1xg2.storage.c-8.us-east-2.aws.neon.tech" +_ENDPOINT_ENVS = (REMOTE_SYNC_ENDPOINT_URL_ENV, "AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL") + + +@pytest.fixture(autouse=True) +def _no_ambient_endpoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + for name in _ENDPOINT_ENVS: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OPENSRE_HOME", str(tmp_path)) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "nak_live_test") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + + +def _config(bucket: str = "incident-artifacts") -> RemoteSyncConfig: + return RemoteSyncConfig(bucket=bucket, provider=BuiltInProvider.NEON, prefix="opensre") + + +class _PolicyClient: + def __init__(self, *, is_public: bool | None = None, error_code: str = "") -> None: + self.is_public = is_public + self.error_code = error_code + + def get_bucket_policy_status(self, Bucket: str) -> dict[str, Any]: # noqa: ARG002, N803 + if self.error_code: + raise ClientError({"Error": {"Code": self.error_code}}, "GetBucketPolicyStatus") + return {"PolicyStatus": {"IsPublic": self.is_public}} + + +def test_neon_is_a_builtin_provider_with_its_own_hint() -> None: + assert "neon" in builtin_providers() + assert "neon env pull" in credential_hint_for_provider("neon") + assert max_parallel_uploads_for_provider("neon") == 16 + + +def test_missing_endpoint_fails_closed_instead_of_falling_back_to_aws() -> None: + with pytest.raises(RemoteSyncConfigError) as err: + build_object_store(_config()) + assert str(err.value) == MISSING_ENDPOINT_MESSAGE + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({"AWS_ENDPOINT_URL_S3": NEON_ENDPOINT}, NEON_ENDPOINT), + ({"AWS_ENDPOINT_URL": NEON_ENDPOINT}, NEON_ENDPOINT), + ( + {REMOTE_SYNC_ENDPOINT_URL_ENV: NEON_ENDPOINT, "AWS_ENDPOINT_URL_S3": "https://other"}, + NEON_ENDPOINT, + ), + ], +) +def test_endpoint_sources_follow_documented_precedence( + monkeypatch: pytest.MonkeyPatch, env: dict[str, str], expected: str +) -> None: + for name, value in env.items(): + monkeypatch.setenv(name, value) + store = build_object_store(_config()) + assert isinstance(store, NeonObjectStore) + client = store._client + assert client.meta.endpoint_url == expected + assert client.meta.config.s3["addressing_style"] == "path" + + +def test_explicit_endpoint_argument_wins_and_describe_names_neon( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_ENDPOINT_URL_S3", "https://ignored.example") + store = NeonObjectStore(_config(), endpoint_url=NEON_ENDPOINT) + assert store._client.meta.endpoint_url == NEON_ENDPOINT + assert store.describe() == "neon://incident-artifacts/opensre" + + +@pytest.mark.parametrize( + ("client", "exposure"), + [ + (_PolicyClient(is_public=True), BucketExposure.PUBLIC), + (_PolicyClient(is_public=False), BucketExposure.PRIVATE), + (_PolicyClient(error_code="AccessDenied"), BucketExposure.UNKNOWN), + (_PolicyClient(error_code="NotImplemented"), BucketExposure.UNKNOWN), + ], +) +def test_exposure_reflects_neon_access_level(client: _PolicyClient, exposure: str) -> None: + assert check_public_access(_config(), client=client).exposure == exposure + + +def test_exposure_without_endpoint_is_unknown_not_an_aws_call() -> None: + status = check_public_access(_config()) + assert status.exposure == BucketExposure.UNKNOWN + assert "endpoint" in status.detail diff --git a/tests/integrations/neon/__init__.py b/tests/integrations/neon/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/neon/fixtures.py b/tests/integrations/neon/fixtures.py new file mode 100644 index 0000000000..227e6f6963 --- /dev/null +++ b/tests/integrations/neon/fixtures.py @@ -0,0 +1,213 @@ +"""Neon API payloads captured from a live project (IDs kept, secrets absent). + +Shapes match ``GET /projects/{id}``, ``/branches``, ``/endpoints``, +``/operations``, ``/branches/{id}/storage``, ``/buckets`` and +``/buckets/{name}/objects`` as returned on 2026-11-05. A failed operation and a +second, non-default branch were added to cover the incident-relevant paths. +""" + +from __future__ import annotations + +from typing import Any + +PROJECT_ID = "little-lab-10567887" +MAIN_BRANCH = "br-summer-art-bdbz1xg2" +PREVIEW_BRANCH = "br-quiet-moon-a1b2c3d4" +ENDPOINT_ID = "ep-sparkling-lake-bd31vdlv" + +PROJECT: dict[str, Any] = { + "project": { + "id": PROJECT_ID, + "name": "opensre-neon-test", + "region_id": "aws-us-east-2", + "pg_version": 18, + "created_at": "2026-11-05T10:26:04Z", + "compute_last_active_at": "2026-11-05T10:26:07Z", + "history_retention_seconds": 86400, + "settings": { + "allowed_ips": {"ips": [], "protected_branches_only": False}, + "enable_logical_replication": False, + "maintenance_window": {"weekdays": [1], "start_time": "05:00", "end_time": "06:00"}, + "block_public_connections": False, + "block_vpc_connections": False, + "hipaa": False, + }, + } +} + +BRANCHES: dict[str, Any] = { + "branches": [ + { + "id": MAIN_BRANCH, + "project_id": PROJECT_ID, + "name": "main", + "current_state": "ready", + "state_changed_at": "2026-11-05T10:26:06Z", + "logical_size": 31686656, + "primary": True, + "default": True, + "protected": False, + "created_at": "2026-11-05T10:26:04Z", + "updated_at": "2026-11-05T10:31:15Z", + }, + { + "id": PREVIEW_BRANCH, + "project_id": PROJECT_ID, + "parent_id": MAIN_BRANCH, + "name": "preview/checkout-fix", + "current_state": "init", + "pending_state": "ready", + "default": False, + "protected": False, + "created_at": "2026-11-05T11:00:00Z", + "updated_at": "2026-11-05T11:00:01Z", + }, + ] +} + +ENDPOINTS: dict[str, Any] = { + "endpoints": [ + { + "host": f"{ENDPOINT_ID}.c-8.us-east-2.aws.neon.tech", + "id": ENDPOINT_ID, + "project_id": PROJECT_ID, + "branch_id": MAIN_BRANCH, + "autoscaling_limit_min_cu": 1, + "autoscaling_limit_max_cu": 1, + "region_id": "aws-us-east-2", + "type": "read_write", + "current_state": "idle", + "pooler_enabled": False, + "pooler_mode": "transaction", + "disabled": False, + "passwordless_access": True, + "last_active": "2026-11-05T10:26:07Z", + "suspended_at": "2026-11-05T10:31:15Z", + "suspend_timeout_seconds": 0, + "group": {"size": {"min": 1, "max": 1}, "computes": []}, + }, + { + "host": "ep-young-sun-q9w8e7r6.c-8.us-east-2.aws.neon.tech", + "id": "ep-young-sun-q9w8e7r6", + "project_id": PROJECT_ID, + "branch_id": PREVIEW_BRANCH, + "autoscaling_limit_min_cu": 0.25, + "autoscaling_limit_max_cu": 2, + "type": "read_write", + "current_state": "init", + "pending_state": "active", + "disabled": True, + "suspend_timeout_seconds": 300, + }, + ] +} + +OPERATIONS: dict[str, Any] = { + "operations": [ + { + "id": "9f0e1d2c-0000-4000-8000-000000000001", + "project_id": PROJECT_ID, + "branch_id": PREVIEW_BRANCH, + "endpoint_id": "ep-young-sun-q9w8e7r6", + "action": "start_compute", + "status": "failed", + "error": "compute quota exceeded", + "failures_count": 3, + "created_at": "2026-11-05T11:00:02Z", + "updated_at": "2026-11-05T11:00:40Z", + "total_duration_ms": 38000, + }, + { + "id": "9f0e1d2c-0000-4000-8000-000000000002", + "project_id": PROJECT_ID, + "branch_id": PREVIEW_BRANCH, + "action": "create_timeline", + "status": "running", + "failures_count": 0, + "created_at": "2026-11-05T11:00:00Z", + "updated_at": "2026-11-05T11:00:01Z", + }, + { + "id": "5c878edb-d79a-46f2-b906-b4ea3285cb6a", + "project_id": PROJECT_ID, + "branch_id": MAIN_BRANCH, + "endpoint_id": ENDPOINT_ID, + "action": "suspend_compute", + "status": "finished", + "failures_count": 0, + "created_at": "2026-11-05T10:31:15Z", + "updated_at": "2026-11-05T10:31:15Z", + "total_duration_ms": 826, + }, + ], + "pagination": {"cursor": "2026-11-05T10:26:04.069482Z"}, +} + +STORAGE: dict[str, Any] = { + "enabled": True, + "s3_endpoint": f"https://{MAIN_BRANCH}.storage.c-8.us-east-2.aws.neon.tech", + "region": "us-east-2", + "force_path_style": True, +} + +BUCKETS: dict[str, Any] = { + "buckets": [ + { + "name": "incident-artifacts", + "access_level": "private", + "created_at": "2026-11-05T10:26:26Z", + }, + { + "name": "public-assets", + "access_level": "public_read", + "created_at": "2026-11-05T10:26:27Z", + }, + ] +} + +OBJECTS_FOLDED: dict[str, Any] = { + "folders": ["logs/"], + "objects": [ + { + "key": "root.txt", + "size": 7, + "last_modified": "2026-11-05T10:26:47Z", + "etag": "5c2ce561e1e263695dbd267271b86fb8", + } + ], + "prefix": "", + "is_truncated": False, +} + +OBJECTS_PAGE: dict[str, Any] = { + "folders": [], + "objects": [ + { + "key": "logs/2026-11-05/app.json", + "size": 20, + "last_modified": "2026-11-05T10:26:30Z", + "etag": "ba15e5bbca0e53abdfd9a730301d90b1", + }, + { + "key": "logs/2026-11-05/worker-1.txt", + "size": 7, + "last_modified": "2026-11-05T10:26:39Z", + "etag": "5c2ce561e1e263695dbd267271b86fb8", + }, + ], + "prefix": "logs/", + "next_cursor": "logs/2026-11-05/worker-1.txt", + "is_truncated": True, +} + +ROUTES: dict[str, dict[str, Any]] = { + f"/api/v2/projects/{PROJECT_ID}": PROJECT, + f"/api/v2/projects/{PROJECT_ID}/branches": BRANCHES, + f"/api/v2/projects/{PROJECT_ID}/endpoints": ENDPOINTS, + f"/api/v2/projects/{PROJECT_ID}/operations": OPERATIONS, + f"/api/v2/projects/{PROJECT_ID}/branches/{MAIN_BRANCH}/storage": STORAGE, + f"/api/v2/projects/{PROJECT_ID}/branches/{MAIN_BRANCH}/buckets": BUCKETS, + f"/api/v2/projects/{PROJECT_ID}/branches/{MAIN_BRANCH}/buckets/incident-artifacts/objects": ( + OBJECTS_FOLDED + ), +} diff --git a/tests/integrations/neon/test_neon.py b/tests/integrations/neon/test_neon.py new file mode 100644 index 0000000000..92c16cb2b1 --- /dev/null +++ b/tests/integrations/neon/test_neon.py @@ -0,0 +1,384 @@ +"""Neon integration: config resolution, API client, diagnostics, tools, and wiring.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from http import HTTPStatus +from typing import Any + +import httpx +import pytest + +from config.constants.neon import NEON_API_KEY_ENV, NEON_BRANCH_ID_ENV, NEON_PROJECT_ID_ENV +from core.domain.types.evidence import CATALOG_ENTRIES_KEY +from core.domain.types.tools import ToolSurface +from core.llm.types import ToolCall +from core.tool.execution import execute_tools +from integrations.neon import build_neon_config, classify +from integrations.neon.client import NeonAPIError, NeonClient +from integrations.neon.diagnostics import ( + get_project_status, + get_storage_buckets, + list_storage_objects, +) +from integrations.neon.tools.neon_project_status_tool.tool import map_neon_project_status +from integrations.neon.tools.neon_storage_buckets_tool.tool import map_neon_storage_buckets +from integrations.neon.tools.neon_storage_objects_tool.tool import map_neon_storage_objects +from tests.integrations.neon import fixtures as fx + +API_KEY = "napi_test_key_do_not_log" +NEON_TOOL_NAMES = { + "get_neon_project_status", + "get_neon_storage_buckets", + "list_neon_storage_objects", +} + + +class _FakeNeonAPI: + """httpx transport serving captured Neon payloads and recording requests.""" + + def __init__( + self, + routes: dict[str, dict[str, Any]] | None = None, + *, + status_code: int = HTTPStatus.OK, + body: Any = None, + raise_exc: Exception | None = None, + ) -> None: + self.routes = fx.ROUTES if routes is None else routes + self.status_code = status_code + self.body = body + self.raise_exc = raise_exc + self.requests: list[httpx.Request] = [] + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if self.raise_exc is not None: + raise self.raise_exc + if self.status_code != HTTPStatus.OK: + return httpx.Response(self.status_code, json=self.body or {}) + if request.url.params.get("prefix") == "logs/": + return httpx.Response(HTTPStatus.OK, json=fx.OBJECTS_PAGE) + payload = self.routes.get(request.url.path) + if payload is None: + return httpx.Response(HTTPStatus.NOT_FOUND, json={"message": "not found"}) + return httpx.Response(HTTPStatus.OK, json=payload) + + @property + def transport(self) -> httpx.MockTransport: + return httpx.MockTransport(self.handler) + + +def _client(api: _FakeNeonAPI, **overrides: Any) -> NeonClient: + config = build_neon_config({"api_key": API_KEY, "project_id": fx.PROJECT_ID, **overrides}) + return NeonClient(config, transport=api.transport) + + +@pytest.fixture +def neon_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setenv("OPENSRE_DISABLE_KEYRING", "1") + monkeypatch.setenv(NEON_API_KEY_ENV, API_KEY) + monkeypatch.setenv(NEON_PROJECT_ID_ENV, fx.PROJECT_ID) + monkeypatch.delenv(NEON_BRANCH_ID_ENV, raising=False) + yield + + +# --------------------------------------------------------------------------- config + + +def test_classify_requires_key_and_project_and_normalizes() -> None: + config, service = classify( + {"api_key": f" {API_KEY} ", "project_id": f" {fx.PROJECT_ID}\n", "branch_id": ""}, + "neon-1", + ) + assert service == "neon" + assert config is not None + assert (config.api_key, config.project_id, config.integration_id) == ( + API_KEY, + fx.PROJECT_ID, + "neon-1", + ) + assert config.base_url == "https://console.neon.tech/api/v2" + assert classify({"api_key": API_KEY}, "neon-2") == (None, None) + assert classify({"project_id": fx.PROJECT_ID}, "neon-3") == (None, None) + + +def test_env_loader_and_detection_publish_neon( + neon_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + from integrations.catalog import ( + classify_integrations, + load_env_integration_services, + load_env_integrations, + ) + + monkeypatch.setenv(NEON_BRANCH_ID_ENV, fx.MAIN_BRANCH) + records = [record for record in load_env_integrations() if record["service"] == "neon"] + assert len(records) == 1 + assert records[0]["credentials"] == { + "api_key": API_KEY, + "project_id": fx.PROJECT_ID, + "branch_id": fx.MAIN_BRANCH, + } + resolved = classify_integrations(records) + assert resolved["neon"].branch_id == fx.MAIN_BRANCH + assert "neon" in load_env_integration_services() + + monkeypatch.delenv(NEON_PROJECT_ID_ENV) + assert not [record for record in load_env_integrations() if record["service"] == "neon"] + + +# --------------------------------------------------------------------------- client + + +def test_client_sends_bearer_key_to_project_scoped_paths() -> None: + api = _FakeNeonAPI() + with _client(api) as client: + assert client.get_project()["name"] == "opensre-neon-test" + client.list_operations(5000) + first, second = api.requests + assert first.headers["Authorization"] == f"Bearer {API_KEY}" + assert first.url.path == f"/api/v2/projects/{fx.PROJECT_ID}" + assert second.url.params["limit"] == "1000" + + +@pytest.mark.parametrize( + "bad", + ["../other-project", "a/b", "bucket?x=1", "", "x" * 200, ".hidden"], +) +def test_untrusted_identifiers_never_reach_the_network(bad: str) -> None: + api = _FakeNeonAPI() + with _client(api) as client, pytest.raises(NeonAPIError) as err: + client.list_objects(fx.MAIN_BRANCH, bad) + assert err.value.invalid_input is True + assert api.requests == [] + + +def test_branch_resolution_prefers_explicit_then_configured_then_default() -> None: + api = _FakeNeonAPI() + assert _client(api).resolve_branch_id("br-explicit-1") == "br-explicit-1" + assert _client(api, branch_id="br-configured-1").resolve_branch_id() == "br-configured-1" + assert api.requests == [] + assert _client(api).resolve_branch_id() == fx.MAIN_BRANCH + assert api.requests[-1].url.path.endswith("/branches") + + +@pytest.mark.parametrize( + ("status", "failure_state", "message"), + [ + (HTTPStatus.UNAUTHORIZED, "denied", "rejected the API key"), + (HTTPStatus.FORBIDDEN, "denied", "cannot access"), + (HTTPStatus.NOT_FOUND, "no_match", "not found"), + (HTTPStatus.TOO_MANY_REQUESTS, "transport_failure", "rate limit"), + (HTTPStatus.SERVICE_UNAVAILABLE, "transport_failure", "unavailable"), + ], +) +def test_api_errors_become_structured_failures( + status: HTTPStatus, failure_state: str, message: str +) -> None: + api = _FakeNeonAPI(status_code=status, body={"message": "details from neon"}) + result = get_project_status(_client(api)) + assert result["available"] is False + assert result["failure_state"] == failure_state + assert message in result["error"] + assert API_KEY not in json.dumps(result) + + +def test_transport_errors_are_redacted_and_reported() -> None: + api = _FakeNeonAPI(raise_exc=httpx.ConnectError(f"refused while sending {API_KEY}")) + result = get_storage_buckets(_client(api)) + assert result["failure_state"] == "transport_failure" + assert API_KEY not in result["error"] + assert "" in result["error"] + + +def test_probe_reports_project_or_missing_config() -> None: + probe = _client(_FakeNeonAPI()).probe_access() + assert probe.ok + assert fx.PROJECT_ID in probe.detail + assert not NeonClient(build_neon_config({"api_key": API_KEY})).probe_access().ok + + +# --------------------------------------------------------------------------- diagnostics + + +def test_project_status_surfaces_failed_pending_and_disabled_computes() -> None: + result = get_project_status(_client(_FakeNeonAPI())) + + assert result["project"]["pg_version"] == 18 + assert result["total_branches"] == 2 + assert [row["action"] for row in result["failed_operations"]] == ["start_compute"] + assert result["failed_operations"][0]["error"] == "compute quota exceeded" + assert [row["action"] for row in result["pending_operations"]] == ["create_timeline"] + assert result["active_endpoints"] == [] + assert result["disabled_endpoints"] == ["ep-young-sun-q9w8e7r6"] + main = next(row for row in result["endpoints"] if row["id"] == fx.ENDPOINT_ID) + assert main["current_state"] == "idle" + assert main["suspended_at"] == "2026-11-05T10:31:15Z" + + +def test_project_status_branch_filter_narrows_computes_and_operations() -> None: + result = get_project_status(_client(_FakeNeonAPI()), branch_id=fx.MAIN_BRANCH) + assert {row["branch_id"] for row in result["endpoints"]} == {fx.MAIN_BRANCH} + assert [row["action"] for row in result["operations"]] == ["suspend_compute"] + assert result["failed_operations"] == [] + assert result["total_branches"] == 2 + + +def test_storage_buckets_flags_public_buckets_on_default_branch() -> None: + result = get_storage_buckets(_client(_FakeNeonAPI())) + assert result["branch_id"] == fx.MAIN_BRANCH + assert result["storage_enabled"] is True + assert result["s3_endpoint"] == fx.STORAGE["s3_endpoint"] + assert result["public_buckets"] == ["public-assets"] + + +def test_storage_disabled_branch_skips_bucket_listing() -> None: + routes = dict(fx.ROUTES) + routes[f"/api/v2/projects/{fx.PROJECT_ID}/branches/{fx.MAIN_BRANCH}/storage"] = { + "enabled": False + } + api = _FakeNeonAPI(routes) + result = get_storage_buckets(_client(api, branch_id=fx.MAIN_BRANCH)) + assert result["storage_enabled"] is False + assert result["buckets"] == [] + assert not any(request.url.path.endswith("/buckets") for request in api.requests) + + +def test_object_listing_folds_folders_and_paginates() -> None: + api = _FakeNeonAPI() + folded = list_storage_objects(_client(api), bucket="incident-artifacts") + assert folded["folders"] == ["logs/"] + assert [item["key"] for item in folded["objects"]] == ["root.txt"] + assert api.requests[-1].url.params["delimiter"] == "/" + + page = list_storage_objects( + _client(api), bucket="incident-artifacts", prefix="logs/", recursive=True, limit=2 + ) + assert "delimiter" not in api.requests[-1].url.params + assert api.requests[-1].url.params["limit"] == "2" + assert page["truncated"] is True + assert page["next_cursor"] == "logs/2026-11-05/worker-1.txt" + + +def test_unknown_bucket_is_a_no_match() -> None: + result = list_storage_objects(_client(_FakeNeonAPI()), bucket="missing-bucket") + assert result["failure_state"] == "no_match" + + +# --------------------------------------------------------------------------- evidence + + +def test_evidence_mappers_cite_incident_signals() -> None: + evidence: dict[str, Any] = {} + map_neon_project_status(evidence, get_project_status(_client(_FakeNeonAPI())), {}) + map_neon_storage_buckets(evidence, get_storage_buckets(_client(_FakeNeonAPI())), {}) + map_neon_storage_objects( + evidence, + list_storage_objects(_client(_FakeNeonAPI()), bucket="incident-artifacts"), + {}, + ) + entries = {entry["source"]: entry["summary"] for entry in evidence[CATALOG_ENTRIES_KEY]} + assert "1 failed operations (start_compute)" in entries["get_neon_project_status"] + assert "disabled computes: ep-young-sun-q9w8e7r6" in entries["get_neon_project_status"] + assert entries["get_neon_storage_buckets"] == "2 buckets, public: public-assets" + assert entries["list_neon_storage_objects"].startswith("1 objects, 1 folders") + + untouched: dict[str, Any] = {} + map_neon_project_status(untouched, {"available": False, "error": "x"}, {}) + assert untouched == {} + + +def test_compute_counts_cover_every_endpoint_when_the_list_is_capped() -> None: + routes = dict(fx.ROUTES) + routes[f"/api/v2/projects/{fx.PROJECT_ID}/endpoints"] = { + "endpoints": [ + {"id": f"ep-busy-{index}", "branch_id": fx.MAIN_BRANCH, "current_state": "active"} + for index in range(60) + ] + } + result = get_project_status(_client(_FakeNeonAPI(routes))) + assert len(result["endpoints"]) == 50 + assert result["endpoints_truncated"] is True + assert result["total_endpoints"] == 60 + + evidence: dict[str, Any] = {} + map_neon_project_status(evidence, result, {}) + assert evidence[CATALOG_ENTRIES_KEY][0]["summary"].startswith("60/60 computes active") + + +# --------------------------------------------------------------------------- tools + runtime + + +def test_tools_are_registered_on_chat_and_action_with_hidden_credentials() -> None: + from tools import registry + + for surface in (ToolSurface.CHAT, ToolSurface.ACTION): + names = {tool.name for tool in registry.get_registered_tools(surface)} + assert names >= NEON_TOOL_NAMES + by_name = {tool.name: tool for tool in registry.get_registered_tools()} + for name in NEON_TOOL_NAMES: + public = by_name[name].public_input_schema["properties"] + assert not {"api_key", "project_id", "base_url"} & set(public) + assert by_name[name].is_available({}) is False + + +def test_runtime_injects_credentials_and_refuses_model_overrides( + neon_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + from integrations.catalog import classify_integrations, load_env_integrations + from tools import registry + + api = _FakeNeonAPI() + monkeypatch.setattr( + "integrations.neon.tools._params.NeonClient", + lambda config: NeonClient(config, transport=api.transport), + ) + resolved = classify_integrations( + [record for record in load_env_integrations() if record["service"] == "neon"] + ) + tools = [tool for tool in registry.get_registered_tools() if tool.name in NEON_TOOL_NAMES] + + ok, refused = execute_tools( + [ + ToolCall(id="1", name="get_neon_storage_buckets", input={}), + ToolCall( + id="2", + name="list_neon_storage_objects", + input={"bucket": "incident-artifacts", "project_id": "someone-elses-project"}, + ), + ], + tools, + resolved, + ) + + assert ok["public_buckets"] == ["public-assets"] + assert "project_id" in refused["error"] + assert all( + request.url.path.startswith(f"/api/v2/projects/{fx.PROJECT_ID}") for request in api.requests + ) + assert all(request.headers["Authorization"] == f"Bearer {API_KEY}" for request in api.requests) + assert API_KEY not in json.dumps([ok, refused]) + + +# --------------------------------------------------------------------------- wiring + + +def test_neon_is_a_verifiable_setup_integration() -> None: + from integrations.cli import _HANDLERS + from integrations.neon.setup import NEON_SETUP + from integrations.registry import INTEGRATION_SPECS, SUPPORTED_SETUP_SERVICES + from integrations.verification import get_verifier + from integrations.verify import register_all_verifiers + + register_all_verifiers() + spec = next(spec for spec in INTEGRATION_SPECS if spec.service == "neon") + assert spec.has_verifier and spec.direct_effective + assert "neon" in SUPPORTED_SETUP_SERVICES + assert "neon" in _HANDLERS + assert get_verifier("neon") is not None + fields = {field.name: field for field in NEON_SETUP.fields} + assert fields["api_key"].secret and fields["api_key"].env_var == NEON_API_KEY_ENV + assert fields["project_id"].required + assert not fields["branch_id"].required diff --git a/tests/tools/test_telemetry.py b/tests/tools/test_telemetry.py index 2c7cdc9d86..0bb7dbed27 100644 --- a/tests/tools/test_telemetry.py +++ b/tests/tools/test_telemetry.py @@ -1300,6 +1300,8 @@ def _describe(_cluster: str, ng: str) -> dict[str, Any]: "get_mysql_server_status", "get_mysql_slow_queries", "get_mysql_table_stats", + "get_neon_project_status", + "get_neon_storage_buckets", "get_pods_on_node", "get_postgresql_current_queries", "get_postgresql_lock_status", @@ -1359,6 +1361,7 @@ def _describe(_cluster: str, ng: str) -> dict[str, Any]: "list_jenkins_builds", "list_jenkins_jobs", "list_jenkins_running_builds", + "list_neon_storage_objects", "list_s3_objects", "list_sentry_issue_events", "list_sentry_uptime_alerts", diff --git a/tools/registry_discovery.py b/tools/registry_discovery.py index f1547f9f86..95656d8c0e 100644 --- a/tools/registry_discovery.py +++ b/tools/registry_discovery.py @@ -61,6 +61,7 @@ "integrations.mongodb.tools", "integrations.mongodb_atlas.tools", "integrations.mysql.tools", + "integrations.neon.tools", "integrations.new_relic.tools", "integrations.openobserve.tools", "integrations.opensearch.tools",