chore: import upstream snapshot with attribution
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(uv run *)",
|
||||
"Bash(python *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(python3*)",
|
||||
"PowerShell(uv run *)",
|
||||
"PowerShell(python *)",
|
||||
"PowerShell(python3 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Code style and conventions
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Code Style
|
||||
|
||||
## Formatting
|
||||
|
||||
- 100-character line length (enforced by ruff and `.editorconfig`)
|
||||
- 4-space indentation for Python
|
||||
- 2-space indentation for YAML, JSON, TOML
|
||||
- Tabs for Makefiles
|
||||
|
||||
## Python Conventions
|
||||
|
||||
- Use `from __future__ import annotations` for forward references
|
||||
- Absolute imports only: `from tools.base import BaseTool`
|
||||
- Type hints on all function parameters and return types
|
||||
- `TypedDict` for graph state, Pydantic `StrictConfigModel` for configs
|
||||
- One clear purpose per file (separation of concerns)
|
||||
|
||||
## Tooling
|
||||
|
||||
- **Linter:** ruff (rules: E, W, F, I, B, C4, UP, ARG, SIM)
|
||||
- **Type checker:** mypy (Python 3.13 target, `warn_return_any`)
|
||||
- **Formatter:** ruff format (Black-compatible)
|
||||
- **Python version:** >=3.12 (tooling targets 3.13)
|
||||
|
||||
## Quality Commands
|
||||
|
||||
Ruff and mypy run over the source packages (`config core gateway integrations
|
||||
platform surfaces tools`) plus `tests/`; the Makefile keeps the canonical path
|
||||
list in `PYTHON_SOURCE_PATHS`.
|
||||
|
||||
```bash
|
||||
make lint # ruff check
|
||||
make format-check # ruff format --check (read-only; CI enforces this)
|
||||
make format # ruff format (write fixes locally)
|
||||
make typecheck # mypy
|
||||
make test-cov # pytest with coverage
|
||||
make check # lint + format-check + typecheck + test-full
|
||||
```
|
||||
|
||||
Before opening a PR, run `make format-check` (or `make check`) in addition to `make lint`.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Investigation pipeline architecture and stage development
|
||||
globs:
|
||||
- core/orchestration/**
|
||||
- core/runtime/**
|
||||
- core/domain/state/**
|
||||
---
|
||||
|
||||
# Investigation pipeline
|
||||
|
||||
## Coordinator
|
||||
|
||||
`core/orchestration/pipeline.py` runs **resolve integrations → extract alert → investigation agent → deliver**.
|
||||
|
||||
`core/orchestration/entrypoints.py` exposes `run_investigation` and async streaming helpers.
|
||||
|
||||
## Key packages
|
||||
|
||||
- `core/orchestration/node/` for pipeline stages (extract, investigate, diagnose, publish findings)
|
||||
- `core/runtime/` for shared LLM tool-calling runtime primitives
|
||||
- `interactive_shell/chat/` for live REPL conversational assistant
|
||||
- `core/domain/state/agent_state.py` for `AgentState` / `InvestigationState`
|
||||
- `core/domain/state/runtime_slices.py` for investigation slice TypedDicts and stage ownership
|
||||
|
||||
## Conventions
|
||||
|
||||
- Prefer `@traceable` from `platform.observability.tracing` on externally-visible orchestration helpers.
|
||||
- Stages read full state and return **partial dict** updates.
|
||||
- Use `get_tracker()` for CLI progress when appropriate.
|
||||
- Keep new persisted keys in the state `TypedDict` and any matching validators.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Integration client patterns
|
||||
globs:
|
||||
- integrations/**
|
||||
---
|
||||
|
||||
# Integration Clients
|
||||
|
||||
## Directory Structure
|
||||
|
||||
All vendor integrations live under `integrations/<vendor>/`. There is no
|
||||
`services/` directory — it was removed in the V0.2 Phase 1 refactor.
|
||||
|
||||
```
|
||||
integrations/
|
||||
├── grafana/
|
||||
│ └── tools/ # Vendor tools (moved from tools/ in T-3)
|
||||
├── datadog/
|
||||
│ ├── tools/
|
||||
│ └── correlation/ # Vendor-specific sub-packages where needed
|
||||
├── <vendor>/ # One directory per vendor (60+ vendors)
|
||||
│ └── tools/ # Vendor tools live here
|
||||
├── opensre_mcp.py # MCP server implementation
|
||||
└── ...
|
||||
```
|
||||
## Adding a New Integration
|
||||
|
||||
1. Create a directory `integrations/<vendor>/`
|
||||
2. Add client code (connection, auth, API methods) directly in the vendor dir
|
||||
3. Create a tool under `integrations/<vendor>/tools/<tool_name>/` that uses the client
|
||||
4. Wire availability into `resolve_integrations` node if the integration requires
|
||||
runtime discovery
|
||||
5. Add the vendor to `EvidenceSource` literal in `core/domain/types/evidence.py`
|
||||
if it is a new source type
|
||||
|
||||
## Conventions
|
||||
|
||||
- Clients should be stateless or use context managers for connection lifecycle
|
||||
- Return `{"success": bool, "data": ..., "error": ...}` from client methods
|
||||
- Keep API-specific logic in the client; the tool handles param extraction and
|
||||
result formatting
|
||||
- MCP tools are registered via `opensre-mcp` entry point in `pyproject.toml`
|
||||
- All directory names use **snake_case** — no PascalCase
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: Always include live turn tests
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Live Turn Test Policy
|
||||
|
||||
- When validating interactive-shell turn scenarios, run the live suite by default.
|
||||
- Do not exclude live turn tests with deselection filters like `-k "not live_llm"`.
|
||||
- Never "fix" turn scenario failures by forcing deterministic command paths, bypassing live
|
||||
planner decisions, or disabling fail-closed/live checks.
|
||||
- Scenario failures should be resolved by correcting planner/tool behavior or by intentional
|
||||
fixture updates approved as behavior changes.
|
||||
- Prefer full live turn commands such as:
|
||||
- `uv run python -m pytest interactive_shell/harness/tests/test_turn_scenarios.py`
|
||||
- For fast local iteration you MAY narrow the suite with the opt-in `--turn-select`
|
||||
flag / `TURN_SELECT` env var (`complex:N` for the most complex scenarios, `sample:N`
|
||||
for a random sample). This still runs live — it is not a `-k "not live_llm"`-style
|
||||
bypass. The full suite remains the default and the required validation pass; never
|
||||
set the selection in CI, and never use it to skip a scenario that is failing.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Pre-commit quality checklist and PR standards
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Quality Standards
|
||||
|
||||
## Before Every PR
|
||||
|
||||
All three must pass — CI blocks merging if any fail:
|
||||
|
||||
```bash
|
||||
make lint # ruff: style and import checking
|
||||
make typecheck # mypy: type annotation checking
|
||||
make test-cov # pytest: tests with coverage report
|
||||
```
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- Link to the relevant GitHub issue (`Fixes #123`)
|
||||
- All local checks pass
|
||||
- Tests added for bug fixes and new features
|
||||
- Documentation updated if behavior changed
|
||||
- Self-reviewed your own code
|
||||
- Edge cases considered
|
||||
- Breaking changes called out explicitly
|
||||
|
||||
## AI-Assisted Code
|
||||
|
||||
If AI tools were used to generate code:
|
||||
- Review every line (not just skim)
|
||||
- Understand the logic and can explain it
|
||||
- Test edge cases
|
||||
- Match project conventions
|
||||
- Verify tests pass
|
||||
|
||||
## Code Quality Principles
|
||||
|
||||
- Clarity over cleverness
|
||||
- DRY: extract common patterns
|
||||
- Strong typing on all function signatures
|
||||
- One responsibility per function/class
|
||||
- Comments explain "why", not "what"
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: Testing conventions and patterns
|
||||
globs:
|
||||
- tests/**
|
||||
- gateway/tests/**
|
||||
- "**/*_test.py"
|
||||
- "**/test_*.py"
|
||||
---
|
||||
|
||||
# Testing Conventions
|
||||
|
||||
## Running Tests
|
||||
|
||||
- `make test-cov` — standard test suite with coverage (excludes synthetic + k8s simulation)
|
||||
- `make test-rca` — real alert end-to-end tests
|
||||
- `make test-full` — full pytest run
|
||||
- `make test-synthetic` — synthetic LLM-based RCA tests (separate CI workflow)
|
||||
|
||||
## File Placement
|
||||
|
||||
Both patterns are valid and auto-discovered by pytest:
|
||||
- `tools/registry_test.py` — co-located next to source
|
||||
- `tests/tools/test_registry.py` — under `tests/` directory
|
||||
|
||||
## Markers
|
||||
|
||||
Apply markers to categorize tests:
|
||||
- `@pytest.mark.integration` — requires external services
|
||||
- `@pytest.mark.synthetic` — LLM-based RCA tests (excluded from `make test-cov`)
|
||||
- `@pytest.mark.e2e` — end-to-end scenarios
|
||||
- `@pytest.mark.axis2` — axis2 tests
|
||||
|
||||
## Conventions
|
||||
|
||||
- Bug fixes must include a test that would have caught the bug
|
||||
- New features must have corresponding tests
|
||||
- Aim for >80% code coverage
|
||||
- Root `conftest.py` loads `.env` and disables keyring for isolation
|
||||
- `tests/e2e/` contains scenario-specific folders (Prefect, Lambda, Kubernetes, etc.)
|
||||
- Tests calling `get_registered_tools()` should call `clear_tool_registry_cache()` in fixtures
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
description: Tool development patterns for investigation and chat tools
|
||||
globs:
|
||||
- tools/**
|
||||
- integrations/**/tools/**
|
||||
---
|
||||
|
||||
# Tool Development
|
||||
|
||||
## Where tools live
|
||||
|
||||
After the V0.2 Phase 1 refactor there are two locations:
|
||||
|
||||
- `integrations/<vendor>/tools/` — vendor-specific tools (one folder per vendor)
|
||||
Example: `integrations/grafana/tools/`
|
||||
- `tools/` — non-vendor tools only (fleet monitoring, SRE guidance, investigation
|
||||
helpers, etc.)
|
||||
|
||||
Do NOT create new vendor tool directories directly under `tools/`.
|
||||
|
||||
## Two Registration Patterns
|
||||
|
||||
### 1. Function + `@tool` decorator (preferred for simple tools, required for vendor tools)
|
||||
|
||||
- Vendor tools: add `@tool`-decorated functions in `integrations/<vendor>/tools/__init__.py`
|
||||
- Non-vendor tools: single file under `tools/`
|
||||
|
||||
```python
|
||||
from tools.tool_decorator import tool
|
||||
|
||||
@tool(
|
||||
name="my_tool_name",
|
||||
source="grafana", # Required: EvidenceSource literal
|
||||
description="What this tool does.",
|
||||
use_cases=["When to use it"],
|
||||
requires=["api_key"],
|
||||
input_schema={...}, # Optional: inferred from signature if omitted
|
||||
is_available=my_check_fn, # (sources: dict) -> bool
|
||||
extract_params=my_extract, # (sources: dict) -> dict
|
||||
surfaces=("investigation",), # Default; add "chat" to expose in chat mode
|
||||
)
|
||||
def my_tool_name(param: str) -> dict:
|
||||
...
|
||||
```
|
||||
|
||||
### 2. `BaseTool` subclass (for complex non-vendor tools with helpers)
|
||||
|
||||
Package directory: `tools/<tool_name>/__init__.py`. Instantiate at module level.
|
||||
|
||||
```python
|
||||
from tools.base import BaseTool
|
||||
|
||||
class MyToolName(BaseTool):
|
||||
name = "my_tool_name"
|
||||
source = "grafana"
|
||||
description = "..."
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"properties": {...},
|
||||
"required": [...],
|
||||
}
|
||||
use_cases = [...]
|
||||
requires = [...]
|
||||
outputs = {"field": "description"}
|
||||
|
||||
def is_available(self, sources: dict) -> bool: ...
|
||||
def extract_params(self, sources: dict) -> dict: ...
|
||||
def run(self, param: str, **_kwargs) -> dict: ...
|
||||
|
||||
my_tool_name = MyToolName()
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- `source` is **required** — must be a valid `EvidenceSource` literal from
|
||||
`core/domain/types/evidence.py`
|
||||
- Tool packages under `tools/` use **snake_case** directory names — no PascalCase, no `Tool` suffix in the dir name
|
||||
- The registry auto-discovers tools via `pkgutil.iter_modules`
|
||||
- Do NOT add your module to `_SKIP_MODULE_NAMES` in `registry.py`
|
||||
- Module names ending in `_test` are auto-skipped by the registry
|
||||
- `surfaces` defaults to `("investigation",)` — add `"chat"` explicitly if needed
|
||||
- For `BaseTool`: instantiate the class at module level so the registry can find it
|
||||
- Return `dict` from `run()` with an `"error"` key on failure, structured data on success
|
||||
|
||||
## Valid `EvidenceSource` Values
|
||||
|
||||
`storage`, `batch`, `tracer_web`, `cloudwatch`, `aws_sdk`, `knowledge`, `grafana`,
|
||||
`datadog`, `honeycomb`, `coralogix`, `eks`, `github`, `sentry`, `google_docs`,
|
||||
`vercel`, `opsgenie`, `elasticsearch`
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"posthog": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"make install"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.13-bookworm
|
||||
|
||||
ARG USERNAME=vscode
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git make sudo \
|
||||
&& groupadd --gid "${USER_GID}" "${USERNAME}" \
|
||||
&& useradd --uid "${USER_UID}" --gid "${USER_GID}" -m "${USERNAME}" --shell /bin/bash \
|
||||
&& echo "${USERNAME} ALL=(root) NOPASSWD:ALL" > "/etc/sudoers.d/${USERNAME}" \
|
||||
&& chmod 0440 "/etc/sudoers.d/${USERNAME}" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "OpenSRE",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".."
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||
"dockerDashComposeVersion": "v2"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
"remoteEnv": {
|
||||
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
|
||||
"PATH": "${containerWorkspaceFolder}/.venv-devcontainer/bin:${containerEnv:PATH}"
|
||||
},
|
||||
"containerEnv": {
|
||||
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONUTF8": "1"
|
||||
},
|
||||
"forwardPorts": [
|
||||
8000
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8000": {
|
||||
"label": "OpenSRE health app",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"editorconfig.editorconfig"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv-devcontainer/bin/python",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "python -m venv --clear .venv-devcontainer && .venv-devcontainer/bin/python -m pip install -e '.[dev]'",
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Ignore node_modules and other dependency directories
|
||||
node_modules
|
||||
bower_components
|
||||
vendor
|
||||
|
||||
# Ignore logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore .env files and other environment files
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Ignore git-related files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore Docker-related files and configs
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore build and cache directories
|
||||
dist
|
||||
build
|
||||
.cache
|
||||
__pycache__
|
||||
|
||||
# Ignore IDE and editor configurations
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.DS_Store # macOS-specific
|
||||
|
||||
# Ignore test and coverage files
|
||||
coverage
|
||||
*.coverage
|
||||
*.test.js
|
||||
*.spec.js
|
||||
tests
|
||||
@@ -0,0 +1,21 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.py]
|
||||
max_line_length = 100
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{json,toml}]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,66 @@
|
||||
# Minimal .env for EC2 deployment.
|
||||
# Copy to .env in repo root (or export vars) before running:
|
||||
# make deploy
|
||||
# make destroy
|
||||
#
|
||||
# Provisions one EC2 instance with opensre-web and opensre-gateway containers.
|
||||
# NEVER commit a file with real credentials.
|
||||
# See also: .env.example for the full application env reference.
|
||||
|
||||
# ─── AWS SDK (EC2 / ECR / SSM provisioning) ──────────────────────────────────
|
||||
# Use static keys or a cross-account role (not both).
|
||||
|
||||
# Option A: static credentials
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
# AWS_SESSION_TOKEN=optional_session_token
|
||||
|
||||
# Option B: cross-account role
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ROLE_ARN=arn:aws:iam::123456789012:role/YourDeployRole
|
||||
# AWS_EXTERNAL_ID=optional_external_id
|
||||
|
||||
# ─── Gateway deploy (make bake-gateway / make deploy-gateway) ───────────────
|
||||
# Optional: git ref (commit SHA, tag, or branch) to bake into the gateway AMI.
|
||||
# Defaults to the local git HEAD commit SHA at bake time.
|
||||
# The ref must exist on GitHub — push your branch/commit before baking.
|
||||
# OPENSRE_GATEWAY_GIT_REF=main
|
||||
|
||||
# Optional: skip baking by providing an existing AMI id directly.
|
||||
# OPENSRE_GATEWAY_AMI_ID=ami-0abc123456789def0
|
||||
|
||||
# Optional: set to 1 to also deregister the AMI when running make destroy-gateway.
|
||||
# OPENSRE_GATEWAY_DESTROY_PURGE_AMI=1
|
||||
|
||||
# Optional: personal suffix to isolate your resources from teammates in a shared AWS account.
|
||||
# Set to your username or initials (e.g. joe, alice). All AWS resources (EC2 instance,
|
||||
# IAM role/profile, ECR repo, container names) will be named with this suffix.
|
||||
# Without this, all developers share the same resource names and will conflict.
|
||||
# OPENSRE_STACK_SUFFIX=joe
|
||||
|
||||
# Optional: fail instead of auto-destroying when a prior deploy is still recorded.
|
||||
# OPENSRE_DEPLOY_ABORT_IF_EXISTS=1
|
||||
|
||||
# Optional: override which key pair is attached to the instance for SSH debug access.
|
||||
# Leave unset — SSM Run Command is used for gateway checks (no key pair required).
|
||||
# EC2_KEY_NAME=your-key-pair-name
|
||||
|
||||
# ─── Telegram (gateway container) ─────────────────────────────────────────────
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Comma-separated Telegram user IDs permitted to DM the bot.
|
||||
# Get your ID from @userinfobot.
|
||||
TELEGRAM_ALLOWED_USERS=123456789
|
||||
|
||||
# ─── LLM provider ─────────────────────────────────────────────────────────────
|
||||
# Set one of: openai | anthropic | bedrock
|
||||
LLM_PROVIDER=openai
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Anthropic (alternative)
|
||||
# LLM_PROVIDER=anthropic
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_MODEL=claude-sonnet-4-5
|
||||
@@ -0,0 +1,629 @@
|
||||
# OpenSRE example environment
|
||||
#
|
||||
# Fast path:
|
||||
# 1. Set `LLM_PROVIDER` and the matching API key below.
|
||||
# 2. Run `opensre onboard` for guided local setup.
|
||||
# 3. Configure one integration with `opensre integrations setup <service>`.
|
||||
# 4. Verify with `opensre health` and `opensre integrations verify <service>`.
|
||||
#
|
||||
# `~/.config/opensre/integrations.json` is the preferred local integration store.
|
||||
# The env vars below are still supported as fallback/direct configuration.
|
||||
|
||||
# --- Most important ---------------------------------------------------------
|
||||
|
||||
# Provider used for LLM calls. Common values: anthropic, openai, openrouter,
|
||||
# deepseek, gemini, nvidia, minimax, bedrock, ollama, codex, claude-code,
|
||||
# opencode, kimi, copilot, antigravity-cli.
|
||||
LLM_PROVIDER=anthropic
|
||||
|
||||
# Codex CLI works for `opensre investigate` after `codex login`.
|
||||
# Leave CODEX_MODEL empty to use the CLI's currently configured model.
|
||||
CODEX_MODEL=
|
||||
CODEX_BIN=
|
||||
|
||||
# Claude Code CLI works for `opensre investigate` after `claude login` or setting ANTHROPIC_API_KEY.
|
||||
# Install: npm i -g @anthropic-ai/claude-code
|
||||
# Leave CLAUDE_CODE_MODEL empty to use the CLI's currently configured model.
|
||||
CLAUDE_CODE_MODEL=
|
||||
CLAUDE_CODE_BIN=
|
||||
|
||||
# Gemini CLI works for `opensre investigate` after `gemini` auth setup.
|
||||
# Install: npm i -g @google/gemini-cli
|
||||
# Leave GEMINI_CLI_MODEL empty to use the CLI's configured default model.
|
||||
GEMINI_CLI_MODEL=
|
||||
GEMINI_CLI_BIN=
|
||||
|
||||
# Antigravity CLI (agy) works for `opensre investigate` after running `agy`
|
||||
# (browser OAuth on first run; token cached by OS keyring).
|
||||
# Install: curl -fsSL https://antigravity.google/cli/install.sh | bash
|
||||
# Leave ANTIGRAVITY_CLI_MODEL empty to use agy's currently configured model.
|
||||
# Note: ANTIGRAVITY_CLI_MODEL is forward-compat; agy v1.0.2 does not yet expose
|
||||
# --model in headless mode. Switch models interactively via `/models` inside agy.
|
||||
ANTIGRAVITY_CLI_MODEL=
|
||||
ANTIGRAVITY_CLI_BIN=
|
||||
# ANTIGRAVITY_CLI_TIMEOUT_SECONDS=
|
||||
|
||||
# OpenCode CLI works for `opensre investigate` after `opencode auth login`.
|
||||
# Leave OPENCODE_MODEL empty to use the CLI's currently configured model
|
||||
OPENCODE_MODEL=
|
||||
OPENCODE_BIN=
|
||||
|
||||
# Cursor Agent CLI
|
||||
# Leave CURSOR_MODEL empty to use the CLI's currently configured model.
|
||||
CURSOR_MODEL=
|
||||
CURSOR_BIN=
|
||||
|
||||
# Kimi Code CLI works for `opensre investigate` after `kimi login`.
|
||||
# Leave KIMI_MODEL empty to use the CLI's currently configured model.
|
||||
KIMI_MODEL=
|
||||
KIMI_BIN=
|
||||
KIMI_API_KEY=
|
||||
# KIMI_SHARE_DIR=~/.kimi
|
||||
|
||||
# GitHub Copilot CLI works for `opensre investigate` after running `copilot`
|
||||
# and authenticating with the interactive `/login` slash command.
|
||||
# Install: npm i -g @github/copilot
|
||||
# Leave COPILOT_MODEL empty to use the CLI's currently configured model.
|
||||
COPILOT_MODEL=
|
||||
COPILOT_BIN=
|
||||
# Optional auth fallbacks (only used when no stored Copilot CLI login exists):
|
||||
# COPILOT_GITHUB_TOKEN=
|
||||
# GH_TOKEN=
|
||||
# GITHUB_TOKEN=
|
||||
# Optional config dir override (default: ~/.copilot):
|
||||
# COPILOT_HOME=
|
||||
|
||||
# xAI Grok Build CLI works for `opensre investigate` after `grok login`,
|
||||
# or set XAI_API_KEY for headless/CI use.
|
||||
# Install: curl -fsSL https://x.ai/cli/install.sh | bash
|
||||
# Leave GROK_CLI_MODEL empty to use the CLI's currently configured model.
|
||||
GROK_CLI_MODEL=
|
||||
GROK_CLI_BIN=
|
||||
XAI_API_KEY=
|
||||
# Optional xAI API base URL override.
|
||||
# XAI_BASE_URL=
|
||||
# Optional exec timeout in seconds (clamped 30-600; default 300).
|
||||
# GROK_CLI_TIMEOUT_SECONDS=
|
||||
|
||||
# --- Pi coding tool (lets OpenSRE submit coding tasks to the Pi agent) ---
|
||||
# Separate from using Pi as an LLM provider. This tool edits the working tree
|
||||
# and returns a diff; it never commits or pushes. Mutating + approval-required,
|
||||
# so it is OFF unless PI_CODING_ENABLED is truthy. Install: npm i -g @earendil-works/pi-coding-agent
|
||||
# PI_CODING_ENABLED=1
|
||||
# PI_CODING_MODEL=anthropic/claude-haiku-4-5
|
||||
# PI_CODING_WORKSPACE=
|
||||
# Optional per-task timeout in seconds (clamped 60-1800; default 600).
|
||||
# PI_CODING_TIMEOUT_SECONDS=
|
||||
|
||||
# --- Fix a Sentry issue (paste a Sentry issue URL, Pi proposes the fix) ---
|
||||
# Resolves the issue from Sentry (needs SENTRY_ORG_SLUG + SENTRY_AUTH_TOKEN below)
|
||||
# and runs the Pi coding agent in the current repo. Returns a diff for review.
|
||||
# OFF unless PI_ISSUE_FIX_ENABLED is truthy. Reuses the PI_CODING_MODEL /
|
||||
# PI_CODING_WORKSPACE / PI_CODING_TIMEOUT_SECONDS settings.
|
||||
# PI_ISSUE_FIX_ENABLED=1
|
||||
|
||||
# Maximum tokens for LLM responses (default: 4096).
|
||||
LLM_MAX_TOKENS=4096
|
||||
|
||||
# LLM transport: unset or "sdk" for native vendor SDKs; "litellm" routes all API providers
|
||||
# through LiteLLM (anthropic, openai, bedrock, and OpenAI-compatible endpoints).
|
||||
# CLI-backed providers (codex, claude-code, etc.) are unaffected.
|
||||
# OPENSRE_LLM_TRANSPORT=litellm
|
||||
|
||||
# Set the key for the provider you choose above.
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_REASONING_MODEL=
|
||||
ANTHROPIC_CLASSIFICATION_MODEL=
|
||||
ANTHROPIC_TOOLCALL_MODEL=
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_REASONING_MODEL=
|
||||
OPENAI_CLASSIFICATION_MODEL=
|
||||
OPENAI_TOOLCALL_MODEL=
|
||||
|
||||
# OpenRouter supports many hosted models behind one API key.
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_MODEL=
|
||||
OPENROUTER_REASONING_MODEL=
|
||||
OPENROUTER_CLASSIFICATION_MODEL=
|
||||
OPENROUTER_TOOLCALL_MODEL=
|
||||
|
||||
# DeepSeek official API (OpenAI-compatible endpoint).
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_MODEL=
|
||||
DEEPSEEK_REASONING_MODEL=
|
||||
DEEPSEEK_CLASSIFICATION_MODEL=
|
||||
DEEPSEEK_TOOLCALL_MODEL=
|
||||
|
||||
# Gemini uses the OpenAI-compatible endpoint in this project.
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=
|
||||
GEMINI_REASONING_MODEL=
|
||||
GEMINI_CLASSIFICATION_MODEL=
|
||||
GEMINI_TOOLCALL_MODEL=
|
||||
|
||||
# NVIDIA NIM / hosted inference.
|
||||
NVIDIA_API_KEY=
|
||||
NVIDIA_MODEL=
|
||||
NVIDIA_REASONING_MODEL=
|
||||
NVIDIA_CLASSIFICATION_MODEL=
|
||||
NVIDIA_TOOLCALL_MODEL=
|
||||
|
||||
# Amazon Bedrock — set `LLM_PROVIDER=bedrock` above. Uses the same AWS credential
|
||||
# chain as the AWS integration block below (region, keys, or IAM role). No LLM API key.
|
||||
BEDROCK_REASONING_MODEL=
|
||||
BEDROCK_CLASSIFICATION_MODEL=
|
||||
BEDROCK_TOOLCALL_MODEL=
|
||||
|
||||
# Ollama (local)
|
||||
OLLAMA_MODEL=
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_MODEL=
|
||||
MINIMAX_REASONING_MODEL=
|
||||
MINIMAX_CLASSIFICATION_MODEL=
|
||||
MINIMAX_TOOLCALL_MODEL=
|
||||
|
||||
# Groq
|
||||
GROQ_API_KEY=
|
||||
GROQ_MODEL=
|
||||
GROQ_REASONING_MODEL=
|
||||
GROQ_CLASSIFICATION_MODEL=
|
||||
GROQ_TOOLCALL_MODEL=
|
||||
|
||||
# Azure OpenAI (routes via LiteLLM; model values are deployment names in your resource)
|
||||
AZURE_OPENAI_BASE_URL=
|
||||
AZURE_OPENAI_API_KEY=
|
||||
AZURE_OPENAI_API_VERSION=2024-10-21
|
||||
AZURE_OPENAI_REASONING_MODEL=
|
||||
AZURE_OPENAI_CLASSIFICATION_MODEL=
|
||||
AZURE_OPENAI_TOOLCALL_MODEL=
|
||||
|
||||
# --- First integrations to set up ------------------------------------------
|
||||
|
||||
# For a first real RCA run, one of Grafana / Datadog / Honeycomb / Coralogix
|
||||
# is usually enough. Prefer `opensre integrations setup <service>` when local.
|
||||
|
||||
# Grafana
|
||||
# For `make grafana-local-up`, use `http://localhost:3000` and any placeholder
|
||||
# token such as `local`.
|
||||
GRAFANA_READ_TOKEN=
|
||||
GRAFANA_INSTANCE_URL=
|
||||
GRAFANA_LOKI_DATASOURCE_UID=
|
||||
GRAFANA_TEMPO_DATASOURCE_UID=
|
||||
|
||||
# Optional multi-instance override. When set, this takes precedence over the
|
||||
# single-instance Grafana vars above.
|
||||
# GRAFANA_INSTANCES='[
|
||||
# {"name":"prod","tags":{"env":"prod"},"endpoint":"https://prod.grafana.net","api_key":"..."}
|
||||
# ]'
|
||||
GRAFANA_INSTANCES=
|
||||
|
||||
# Argo CD (read-only REST API integration)
|
||||
# Use exactly one auth method: ARGOCD_AUTH_TOKEN/ARGOCD_TOKEN or username/password.
|
||||
ARGOCD_BASE_URL=
|
||||
ARGOCD_AUTH_TOKEN=
|
||||
ARGOCD_TOKEN=
|
||||
ARGOCD_USERNAME=
|
||||
ARGOCD_PASSWORD=
|
||||
ARGOCD_PROJECT=
|
||||
ARGOCD_APP_NAMESPACE=
|
||||
ARGOCD_VERIFY_SSL=true
|
||||
|
||||
# Multi-instance Argo CD (optional). When set, takes precedence over the
|
||||
# single-instance ARGOCD_* vars above. See docs/multi-instance-integrations.mdx.
|
||||
# ARGOCD_INSTANCES='[{"name":"prod","base_url":"https://argocd.example.com","bearer_token":"***","project":"default"}]'
|
||||
ARGOCD_INSTANCES=
|
||||
|
||||
# Helm 3 (read-only CLI — list/status/history/get values/get manifest)
|
||||
# Requires OSRE_HELM_INTEGRATION=1 (or true/yes) to activate from env.
|
||||
OSRE_HELM_INTEGRATION=
|
||||
HELM_PATH=helm
|
||||
HELM_KUBE_CONTEXT=
|
||||
HELM_KUBECONFIG=
|
||||
HELM_NAMESPACE=
|
||||
# Optional: cap manifest size from helm get manifest (integer, min 1024; default 600000).
|
||||
# HELM_MANIFEST_MAX_CHARS=
|
||||
|
||||
# Datadog
|
||||
DD_API_KEY=
|
||||
DD_APP_KEY=
|
||||
DD_SITE=datadoghq.com
|
||||
|
||||
# Optional multi-instance override.
|
||||
# DD_INSTANCES='[{"name":"prod","api_key":"...","app_key":"...","site":"datadoghq.com"}]'
|
||||
DD_INSTANCES=
|
||||
|
||||
# groundcover
|
||||
# Read-only service-account token (Bearer). GROUNDCOVER_MCP_TOKEN is accepted as an alias.
|
||||
GROUNDCOVER_API_KEY=
|
||||
GROUNDCOVER_MCP_URL=https://mcp.groundcover.com/api/mcp
|
||||
# Optional routing — only needed for multi-workspace / multi-backend accounts.
|
||||
GROUNDCOVER_TENANT_UUID=
|
||||
GROUNDCOVER_BACKEND_ID=
|
||||
GROUNDCOVER_TIMEZONE=UTC
|
||||
|
||||
# Optional multi-instance override.
|
||||
# GROUNDCOVER_INSTANCES='[{"name":"prod","api_key":"...","tenant_uuid":"...","backend_id":"..."}]'
|
||||
GROUNDCOVER_INSTANCES=
|
||||
|
||||
# Honeycomb
|
||||
# `HONEYCOMB_DATASET` can be a dataset slug or `__all__`.
|
||||
HONEYCOMB_API_KEY=
|
||||
HONEYCOMB_DATASET=__all__
|
||||
HONEYCOMB_API_URL=https://api.honeycomb.io
|
||||
|
||||
# Optional multi-instance override.
|
||||
# HONEYCOMB_INSTANCES='[{"name":"prod","api_key":"...","dataset":"__all__"}]'
|
||||
HONEYCOMB_INSTANCES=
|
||||
|
||||
# Coralogix
|
||||
CORALOGIX_API_KEY=
|
||||
CORALOGIX_API_URL=https://api.coralogix.com
|
||||
CORALOGIX_APPLICATION_NAME=
|
||||
CORALOGIX_SUBSYSTEM_NAME=
|
||||
|
||||
# Optional multi-instance override.
|
||||
# CORALOGIX_INSTANCES='[{"name":"prod","api_key":"...","base_url":"https://api.coralogix.com"}]'
|
||||
CORALOGIX_INSTANCES=
|
||||
|
||||
# SigNoz (Query API — logs, metrics, traces)
|
||||
# Local Docker stack: http://localhost:8080 (see docs/signoz.mdx)
|
||||
# API key: Settings → Service Accounts → Keys
|
||||
SIGNOZ_URL=
|
||||
SIGNOZ_API_KEY=
|
||||
|
||||
# Grafana Tempo (standalone — direct HTTP API, no Grafana datasource proxy)
|
||||
# Default local Tempo HTTP port is 3200. Auth is optional: provide either
|
||||
# TEMPO_API_KEY (bearer token) OR TEMPO_USERNAME/TEMPO_PASSWORD (basic auth),
|
||||
# not both. TEMPO_ORG_ID is only needed for multi-tenant deployments.
|
||||
TEMPO_URL=
|
||||
TEMPO_API_KEY=
|
||||
TEMPO_USERNAME=
|
||||
TEMPO_PASSWORD=
|
||||
TEMPO_ORG_ID=
|
||||
|
||||
# Temporal (self-hosted only — talks to the Temporal HTTP API, not gRPC or Cloud)
|
||||
# Point TEMPORAL_API_URL at the frontend HTTP port (default 7243), not the gRPC
|
||||
# port (7233) or Web UI (8233). TEMPORAL_NAMESPACE defaults to "default".
|
||||
# TEMPORAL_API_KEY is optional — leave empty for unauthenticated self-hosted clusters.
|
||||
TEMPORAL_API_URL=
|
||||
TEMPORAL_NAMESPACE=default
|
||||
TEMPORAL_API_KEY=
|
||||
|
||||
# AWS
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ROLE_ARN=
|
||||
AWS_EXTERNAL_ID=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_SESSION_TOKEN=
|
||||
|
||||
# Optional multi-instance override.
|
||||
# AWS_INSTANCES='[
|
||||
# {"name":"prod","tags":{"account":"prod"},"role_arn":"arn:aws:iam::111:role/opensre"}
|
||||
# ]'
|
||||
AWS_INSTANCES=
|
||||
|
||||
# GitHub MCP
|
||||
GITHUB_MCP_URL=
|
||||
GITHUB_MCP_MODE=streamable-http
|
||||
GITHUB_MCP_COMMAND=
|
||||
GITHUB_MCP_ARGS=
|
||||
GITHUB_MCP_AUTH_TOKEN=
|
||||
GITHUB_MCP_TOOLSETS=repos,issues,pull_requests,actions,search
|
||||
# Optional: 5-500, default 50.
|
||||
# OPENSRE_GITHUB_MCP_REPO_PROBE_LIMIT=
|
||||
|
||||
# Sentry
|
||||
# Runtime error monitoring for OpenSRE itself uses the project Sentry DSN constant.
|
||||
# Optional: override for operator-side DSN rotation without rebuilding.
|
||||
# OPENSRE_SENTRY_DSN=
|
||||
SENTRY_ERROR_SAMPLE_RATE=1.0
|
||||
SENTRY_TRACES_SAMPLE_RATE=1.0
|
||||
OPENSRE_SENTRY_DISABLED=0
|
||||
OPENSRE_SENTRY_LOGGING_DISABLED=0
|
||||
# Tag value attached to Sentry events to identify how this process is deployed.
|
||||
# Common values: railway, ec2, vercel, local. Defaults to "local" when unset.
|
||||
# OPENSRE_DEPLOYMENT_METHOD=local
|
||||
|
||||
# Interactive-shell prompt logging (for local eval mining + PostHog LLM analytics).
|
||||
# Set OPENSRE_PROMPT_LOG_DISABLED=1 to disable prompt/response logging entirely.
|
||||
OPENSRE_PROMPT_LOG_DISABLED=0
|
||||
# Disable only local JSONL prompt log file.
|
||||
OPENSRE_PROMPT_LOG_LOCAL_DISABLED=0
|
||||
# Redact prompt/response content (known token shapes) before local or PostHog
|
||||
# sinks. On by default — set to 0 to log/send raw, unredacted content.
|
||||
OPENSRE_PROMPT_LOG_REDACT=1
|
||||
# Optional override for local prompt log path (default ~/.config/opensre/prompt_log.jsonl).
|
||||
# OPENSRE_PROMPT_LOG_PATH=
|
||||
|
||||
# Sentry investigation integration
|
||||
SENTRY_URL=https://sentry.io
|
||||
SENTRY_ORG_SLUG=
|
||||
SENTRY_PROJECT_SLUG=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Slack delivery and Slack-thread investigation context
|
||||
SLACK_WEBHOOK_URL=
|
||||
SLACK_BOT_TOKEN=
|
||||
# SLACK_ACCESS_TOKEN=
|
||||
|
||||
# --- Additional RCA integrations -------------------------------------------
|
||||
|
||||
# Alertmanager
|
||||
ALERTMANAGER_URL=
|
||||
ALERTMANAGER_BEARER_TOKEN=
|
||||
ALERTMANAGER_USERNAME=
|
||||
ALERTMANAGER_PASSWORD=
|
||||
|
||||
# MongoDB
|
||||
MONGODB_CONNECTION_STRING=
|
||||
MONGODB_DATABASE=
|
||||
MONGODB_AUTH_SOURCE=admin
|
||||
MONGODB_TLS=true
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=6379
|
||||
REDIS_USERNAME=
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DATABASE=0
|
||||
REDIS_SSL=false
|
||||
|
||||
# MongoDB Atlas
|
||||
MONGODB_ATLAS_PUBLIC_KEY=
|
||||
MONGODB_ATLAS_PRIVATE_KEY=
|
||||
MONGODB_ATLAS_PROJECT_ID=
|
||||
MONGODB_ATLAS_BASE_URL=https://cloud.mongodb.com/api/atlas/v2
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRESQL_HOST=
|
||||
POSTGRESQL_PORT=5432
|
||||
POSTGRESQL_DATABASE=
|
||||
POSTGRESQL_USERNAME=postgres
|
||||
POSTGRESQL_PASSWORD=
|
||||
POSTGRESQL_SSL_MODE=prefer
|
||||
|
||||
# MySQL
|
||||
MYSQL_HOST=
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=
|
||||
MYSQL_USERNAME=root
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_SSL_MODE=preferred
|
||||
|
||||
# MariaDB
|
||||
MARIADB_HOST=
|
||||
MARIADB_PORT=3306
|
||||
MARIADB_DATABASE=
|
||||
MARIADB_USERNAME=
|
||||
MARIADB_PASSWORD=
|
||||
MARIADB_SSL=true
|
||||
|
||||
# Dagster workflow orchestration
|
||||
DAGSTER_ENDPOINT=
|
||||
DAGSTER_API_TOKEN=
|
||||
|
||||
# RabbitMQ management API
|
||||
RABBITMQ_HOST=
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
RABBITMQ_USERNAME=
|
||||
RABBITMQ_PASSWORD=
|
||||
RABBITMQ_VHOST=/
|
||||
RABBITMQ_SSL=false
|
||||
RABBITMQ_VERIFY_SSL=true
|
||||
|
||||
# Better Stack Telemetry
|
||||
BETTERSTACK_QUERY_ENDPOINT=
|
||||
BETTERSTACK_USERNAME=
|
||||
BETTERSTACK_PASSWORD=
|
||||
BETTERSTACK_SOURCES=
|
||||
|
||||
# Kafka
|
||||
KAFKA_BOOTSTRAP_SERVERS=
|
||||
KAFKA_SECURITY_PROTOCOL=PLAINTEXT
|
||||
KAFKA_SASL_MECHANISM=
|
||||
KAFKA_SASL_USERNAME=
|
||||
KAFKA_SASL_PASSWORD=
|
||||
|
||||
# ClickHouse
|
||||
CLICKHOUSE_HOST=
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_DATABASE=default
|
||||
CLICKHOUSE_USER=default
|
||||
CLICKHOUSE_PASSWORD=
|
||||
CLICKHOUSE_SECURE=false
|
||||
|
||||
# Bitbucket
|
||||
BITBUCKET_WORKSPACE=
|
||||
BITBUCKET_USERNAME=
|
||||
BITBUCKET_APP_PASSWORD=
|
||||
|
||||
# GitLab
|
||||
GITLAB_BASE_URL=https://gitlab.com/api/v4
|
||||
GITLAB_MR_WRITEBACK=false
|
||||
GITLAB_MR_IID=
|
||||
GITLAB_ACCESS_TOKEN=
|
||||
GITLAB_PROJECT_ID=
|
||||
GITLAB_REPO_URL=
|
||||
|
||||
# Jira
|
||||
JIRA_BASE_URL=
|
||||
JIRA_EMAIL=
|
||||
JIRA_API_TOKEN=
|
||||
JIRA_PROJECT_KEY=
|
||||
|
||||
# OpsGenie
|
||||
OPSGENIE_API_KEY=
|
||||
OPSGENIE_REGION=us
|
||||
|
||||
# incident.io
|
||||
INCIDENT_IO_API_KEY=
|
||||
INCIDENT_IO_BASE_URL=
|
||||
|
||||
# Splunk
|
||||
SPLUNK_URL=
|
||||
SPLUNK_TOKEN=
|
||||
SPLUNK_INDEX=main
|
||||
SPLUNK_VERIFY_SSL=true
|
||||
|
||||
# Hermes
|
||||
HERMES_API_URL=
|
||||
HERMES_API_KEY=
|
||||
|
||||
# OpenObserve
|
||||
OPENOBSERVE_URL=
|
||||
OPENOBSERVE_TOKEN=
|
||||
OPENOBSERVE_USERNAME=
|
||||
OPENOBSERVE_PASSWORD=
|
||||
OPENOBSERVE_ORG=default
|
||||
OPENOBSERVE_STREAM=
|
||||
OPENOBSERVE_MAX_RESULTS=100
|
||||
|
||||
# Azure Monitor (Log Analytics)
|
||||
AZURE_LOG_ANALYTICS_WORKSPACE_ID=
|
||||
AZURE_LOG_ANALYTICS_TOKEN=
|
||||
AZURE_TENANT_ID=
|
||||
AZURE_SUBSCRIPTION_ID=
|
||||
AZURE_MAX_RESULTS=100
|
||||
|
||||
# VictoriaLogs
|
||||
VICTORIA_LOGS_URL=
|
||||
VICTORIA_LOGS_TENANT_ID=
|
||||
|
||||
# Vercel
|
||||
VERCEL_API_TOKEN=
|
||||
VERCEL_TEAM_ID=
|
||||
|
||||
# PostHog (REST bounce-rate alerting)
|
||||
POSTHOG_PROJECT_ID=
|
||||
POSTHOG_PERSONAL_API_KEY=
|
||||
POSTHOG_BASE_URL=https://us.i.posthog.com
|
||||
POSTHOG_TIMEOUT_SECONDS=15.0
|
||||
POSTHOG_BOUNCE_THRESHOLD=0.6
|
||||
POSTHOG_BOUNCE_WINDOW=24h
|
||||
|
||||
# PostHog MCP (analytics, feature flags, error tracking, HogQL via the hosted MCP server)
|
||||
POSTHOG_MCP_MODE=streamable-http
|
||||
POSTHOG_MCP_URL=https://mcp.posthog.com/mcp
|
||||
POSTHOG_MCP_AUTH_TOKEN=
|
||||
POSTHOG_MCP_ORGANIZATION_ID=
|
||||
POSTHOG_MCP_PROJECT_ID=
|
||||
POSTHOG_MCP_FEATURES=
|
||||
POSTHOG_MCP_READ_ONLY=true
|
||||
POSTHOG_MCP_COMMAND=
|
||||
POSTHOG_MCP_ARGS=
|
||||
|
||||
# Sentry MCP (issues, events, traces, releases, Seer root-cause via the hosted MCP server)
|
||||
SENTRY_MCP_MODE=streamable-http
|
||||
SENTRY_MCP_URL=https://mcp.sentry.dev/mcp
|
||||
SENTRY_MCP_AUTH_TOKEN=
|
||||
SENTRY_MCP_HOST=
|
||||
SENTRY_MCP_ORGANIZATION_SLUG=
|
||||
SENTRY_MCP_PROJECT_SLUG=
|
||||
SENTRY_MCP_SKILLS=
|
||||
SENTRY_MCP_COMMAND=
|
||||
SENTRY_MCP_ARGS=
|
||||
|
||||
# X (Twitter) MCP (post, search, timelines, likes via https://github.com/xdevplatform/xmcp)
|
||||
# XMCP runs locally by default; point X_MCP_URL at your own instance
|
||||
# (optionally tunneled) or launch it directly with X_MCP_COMMAND/X_MCP_ARGS.
|
||||
X_MCP_MODE=streamable-http
|
||||
X_MCP_URL=http://127.0.0.1:8000/mcp
|
||||
X_MCP_AUTH_TOKEN=
|
||||
X_MCP_COMMAND=
|
||||
X_MCP_ARGS=
|
||||
# X API bearer token forwarded to the local xmcp server (its own X API auth,
|
||||
# not the MCP transport auth above).
|
||||
X_BEARER_TOKEN=
|
||||
|
||||
# Google Docs export
|
||||
GOOGLE_CREDENTIALS_FILE=
|
||||
GOOGLE_DRIVE_FOLDER_ID=
|
||||
|
||||
# OpenClaw MCP
|
||||
OPENCLAW_MCP_MODE=stdio
|
||||
OPENCLAW_MCP_URL=
|
||||
OPENCLAW_MCP_COMMAND=openclaw
|
||||
OPENCLAW_MCP_ARGS=mcp serve
|
||||
OPENCLAW_MCP_AUTH_TOKEN=
|
||||
|
||||
# Splunk
|
||||
SPLUNK_URL=
|
||||
SPLUNK_TOKEN=
|
||||
SPLUNK_INDEX=main
|
||||
SPLUNK_VERIFY_SSL=true
|
||||
|
||||
# Hermes
|
||||
HERMES_API_URL=
|
||||
HERMES_API_KEY=
|
||||
|
||||
# OpenObserve
|
||||
OPENOBSERVE_URL=
|
||||
OPENOBSERVE_TOKEN=
|
||||
OPENOBSERVE_USERNAME=
|
||||
OPENOBSERVE_PASSWORD=
|
||||
OPENOBSERVE_ORG=default
|
||||
OPENOBSERVE_STREAM=
|
||||
OPENOBSERVE_MAX_RESULTS=100
|
||||
|
||||
# Azure Monitor (Log Analytics)
|
||||
AZURE_LOG_ANALYTICS_WORKSPACE_ID=
|
||||
AZURE_LOG_ANALYTICS_TOKEN=
|
||||
AZURE_TENANT_ID=
|
||||
AZURE_SUBSCRIPTION_ID=
|
||||
AZURE_MAX_RESULTS=100
|
||||
|
||||
# VictoriaLogs
|
||||
VICTORIA_LOGS_URL=
|
||||
VICTORIA_LOGS_TENANT_ID=
|
||||
|
||||
# Discord
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_PUBLIC_KEY=
|
||||
DISCORD_DEFAULT_CHANNEL_ID=
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_DEFAULT_CHAT_ID=
|
||||
|
||||
# WhatsApp (Twilio)
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_WHATSAPP_FROM=
|
||||
WHATSAPP_DEFAULT_TO=
|
||||
|
||||
# Twilio SMS — shares TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN from the block
|
||||
# above (do NOT re-declare them; dotenv keeps only the first occurrence).
|
||||
# Set either TWILIO_SMS_FROM (E.164 number) or TWILIO_SMS_MESSAGING_SERVICE_SID.
|
||||
TWILIO_SMS_FROM=
|
||||
# TWILIO_SMS_MESSAGING_SERVICE_SID=
|
||||
TWILIO_SMS_DEFAULT_TO=
|
||||
|
||||
# --- Web app / hosted runtime only -----------------------------------------
|
||||
|
||||
# Required only when using the Tracer web app / hosted integration path.
|
||||
JWT_TOKEN=
|
||||
TRACER_API_URL=
|
||||
|
||||
# Remote server API key for `opensre remote ...` and hosted agent access.
|
||||
OPENSRE_API_KEY=
|
||||
|
||||
# --- Deployment / runtime ---------------------------------------------------
|
||||
|
||||
# Required for hosted OpenSRE runtimes that need persistent storage.
|
||||
DATABASE_URI=
|
||||
REDIS_URI=
|
||||
|
||||
ENV=development
|
||||
|
||||
# Reversible masking before external LLM calls. Off by default.
|
||||
OPENSRE_MASK_ENABLED=false
|
||||
# Comma-separated kinds to mask. Empty = project defaults.
|
||||
OPENSRE_MASK_KINDS=
|
||||
# Example: '{"jira_key": "\\b[A-Z]+-\\d+\\b"}'
|
||||
OPENSRE_MASK_EXTRA_REGEX=
|
||||
@@ -0,0 +1,8 @@
|
||||
# Normalize line endings for shell scripts so they stay LF on all platforms.
|
||||
*.sh text eol=lf
|
||||
|
||||
# Mark vendored third-party code so GitHub's analysis tools skip it.
|
||||
# This suppresses false-positive code scanning alerts in code we don't own.
|
||||
tests/e2e/upstream_lambda/pipeline_code/** linguist-vendored=true
|
||||
tests/e2e/upstream_apache_flink_ecs/pipeline_code/** linguist-vendored=true
|
||||
tests/e2e/upstream_prefect_ecs_fargate/pipeline_code/** linguist-vendored=true
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Bug Report
|
||||
description: Report something that isn't working
|
||||
title: "[BUG] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Security notice:** Never paste real API keys, tokens, or passwords in this form. Replace them with placeholders like `glsa_***`, `sk-***`, or `your-token-here` before submitting. Tokens posted here are publicly visible and cannot be fully deleted from GitHub's history.
|
||||
|
||||
Thanks for the report. Please include **observed** behavior and repro steps — guesses make triage slower.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-line description of the bug. Be specific (e.g. agent fails when `.env` contains `=` in values). Avoid vague phrases like "something doesn't work."
|
||||
placeholder: "Example: Agent fails to parse config when .env vars contain special characters"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected_actual
|
||||
attributes:
|
||||
label: Expected vs actual behavior
|
||||
description: What should happen, and what happens instead? Include exact errors, exit codes, or unexpected output where relevant.
|
||||
placeholder: |
|
||||
**Expected:** …
|
||||
|
||||
**Actual:** …
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps that consistently trigger the bug (commands, config snippets — redact secrets).
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: reproducible
|
||||
attributes:
|
||||
label: Can you reproduce it consistently?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
- "Sometimes"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: frequency
|
||||
attributes:
|
||||
label: How often does it occur?
|
||||
options:
|
||||
- Every time
|
||||
- Intermittent
|
||||
- Under specific conditions
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: Pick the closest match.
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and error output
|
||||
description: Full error message and relevant logs. Redact API keys, tokens, and passwords.
|
||||
placeholder: |
|
||||
```
|
||||
[paste error / logs]
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Screenshots, related issues (`#123`), what you were trying to do when this happened.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "Questions or Support"
|
||||
url: https://github.com/Tracer-Cloud/open-sre-agent/discussions
|
||||
about: "Ask questions or get help in Discussions"
|
||||
- name: "Security Issue"
|
||||
url: https://github.com/Tracer-Cloud/open-sre-agent/security/policy
|
||||
about: "Report a security vulnerability safely"
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or capability
|
||||
title: "[FEATURE] "
|
||||
labels:
|
||||
- enhancement
|
||||
- pending triage
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem statement
|
||||
description: Why is this needed? User pain, limitation, or gap today (not the solution yet).
|
||||
placeholder: "Example: Users can't export agent logs to external systems without custom scripts."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How should it work? Commands, APIs, UX, inputs/outputs, example snippets.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Improvement
|
||||
description: Suggest a refactor, optimization, or quality improvement
|
||||
title: "[IMPROVEMENT] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: current
|
||||
attributes:
|
||||
label: Current state
|
||||
description: How does it work now? Link to code (`file:line`), snippets, or behavior/architecture.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: desired
|
||||
attributes:
|
||||
label: Desired state
|
||||
description: What should change, why it is better (metrics if possible), and any architectural notes.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,51 @@
|
||||
Fixes #
|
||||
|
||||
<!-- Add issue number above -->
|
||||
|
||||
#### Describe the changes you have made in this PR -
|
||||
|
||||
### Demo/Screenshot for feature changes and bug fixes -
|
||||
<!-- Include at least one proof of the change: UI screenshot, terminal screenshot/log snippet, short video/GIF, or equivalent demo output. -->
|
||||
<!-- Do not add code diff here -->
|
||||
|
||||
---
|
||||
|
||||
## Code Understanding and AI Usage
|
||||
|
||||
**Did you use AI assistance (ChatGPT, Claude, Copilot, etc.) to write any part of this code?**
|
||||
- [ ] No, I wrote all the code myself
|
||||
- [ ] Yes, I used AI assistance (continue below)
|
||||
|
||||
**If you used AI assistance:**
|
||||
- [ ] I have reviewed every single line of the AI-generated code
|
||||
- [ ] I can explain the purpose and logic of each function/component I added
|
||||
- [ ] I have tested edge cases and understand how the code handles them
|
||||
- [ ] I have modified the AI output to follow this project's coding standards and conventions
|
||||
|
||||
**Explain your implementation approach:**
|
||||
<!--
|
||||
Describe in your own words:
|
||||
- What problem does your code solve?
|
||||
- What alternative approaches did you consider?
|
||||
- Why did you choose this specific implementation?
|
||||
- What are the key functions/components and what do they do?
|
||||
|
||||
This helps reviewers understand your thought process and ensures you understand the code.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Checklist before requesting a review
|
||||
- [ ] I have added proper PR title and linked to the issue
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] **I can explain the purpose of every function, class, and logic block I added**
|
||||
- [ ] I understand why my changes work and have tested them thoroughly
|
||||
- [ ] I have considered potential edge cases and how my code handles them
|
||||
- [ ] If it is a core feature, I have added thorough tests
|
||||
- [ ] My code follows the project's style guidelines and conventions
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
Note: Please check **Allow edits from maintainers** if you would like us to assist in the PR.
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 817 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 890 B |
@@ -0,0 +1,237 @@
|
||||
"""Enforce forbidden *direct* import edges between top-level packages.
|
||||
|
||||
Unlike import-linter (which flags transitive chains), this checker looks at
|
||||
**module-top-level** and **nested** (function/class-body) ``import`` /
|
||||
``from … import`` statements. Module-top-level uses the same AST walk as
|
||||
``check_import_cycles``; nested imports close the loophole where lazy
|
||||
``surfaces.*`` imports bypass the module-level graph.
|
||||
|
||||
Used by ``make check-imports`` (and ``check_imports``) alongside
|
||||
import-linter's config contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_CI_DIR = Path(__file__).resolve().parent
|
||||
if str(_CI_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_CI_DIR))
|
||||
|
||||
from check_import_cycles import ( # noqa: E402
|
||||
_build_graph,
|
||||
_nested_imports,
|
||||
discover_first_party_roots,
|
||||
module_from_path,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# ``source_prefix -> forbidden destination roots`` for direct imports only.
|
||||
# Enforces the layering contract documented in ``surfaces/__init__.py``:
|
||||
# "Nothing first-party may import from surfaces/". Adds an explicit bound
|
||||
# on ``platform``, ``core``, ``gateway`` so the surfaces ban
|
||||
# is CI-enforced, not just doc-described.
|
||||
_FORBIDDEN_DIRECT: dict[str, frozenset[str]] = {
|
||||
"platform": frozenset({"surfaces"}),
|
||||
"core": frozenset({"surfaces"}),
|
||||
"gateway": frozenset({"surfaces"}),
|
||||
"integrations": frozenset({"tools", "surfaces"}),
|
||||
"tools": frozenset({"surfaces"}),
|
||||
}
|
||||
|
||||
# Known direct violations being burned down — remove entries as fixes land.
|
||||
# Format: ``"source.module -> dest.module"`` (exact modules from the graph).
|
||||
_BASELINE_IGNORES: frozenset[str] = frozenset(
|
||||
{
|
||||
# Gateway hosts the interactive_shell runtime — pre-existing reuse
|
||||
# to be burned down by extracting shared runtime primitives out of
|
||||
# ``surfaces/interactive_shell/`` and into a layer below ``surfaces``.
|
||||
"gateway.storage.session.resolver -> surfaces.interactive_shell.runtime.context",
|
||||
# tools/interactive_shell action tools reach UP into surfaces/interactive_shell
|
||||
# for runtime / command_registry / UI primitives. Clears when the action
|
||||
# tools themselves are refactored to be UI-agnostic (e.g. return
|
||||
# "approval-required" sentinels instead of calling execution_confirm
|
||||
# directly) so the surface owns its own confirmation UX.
|
||||
"tools.interactive_shell.actions.cli_command -> surfaces.interactive_shell.runtime.subprocess_runner",
|
||||
# These shell action tools type against the shell ``Session`` (investigation_launch
|
||||
# reads session.terminal.background_mode_enabled). Clears when they are made
|
||||
# session-core-agnostic so they no longer import the shell session type.
|
||||
"tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.session",
|
||||
"tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.session",
|
||||
"tools.interactive_shell.shared.investigation_launch -> surfaces.interactive_shell.session",
|
||||
"tools.interactive_shell.actions.llm_provider -> surfaces.interactive_shell.command_registry",
|
||||
"tools.interactive_shell.actions.llm_provider -> surfaces.interactive_shell.ui.execution_confirm",
|
||||
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.command_registry",
|
||||
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.command_registry.slash_catalog",
|
||||
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.ui",
|
||||
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.ui.execution_confirm",
|
||||
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.utils.telemetry.turn_outcome",
|
||||
"tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.command_registry",
|
||||
"tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.runtime",
|
||||
"tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.ui.execution_confirm",
|
||||
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.runtime",
|
||||
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.runtime.subprocess_runner.task_streaming",
|
||||
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.ui",
|
||||
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.ui.execution_confirm",
|
||||
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.utils.error_handling.exception_reporting",
|
||||
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.runtime",
|
||||
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.runtime.subprocess_runner.task_streaming",
|
||||
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.ui",
|
||||
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.ui.execution_confirm",
|
||||
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.utils.error_handling.exception_reporting",
|
||||
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.runtime",
|
||||
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.runtime.subprocess_runner.task_streaming",
|
||||
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.ui",
|
||||
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.ui.execution_confirm",
|
||||
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.utils.error_handling.exception_reporting",
|
||||
}
|
||||
)
|
||||
|
||||
# Function/class-body lazy imports that bypass the module-level graph.
|
||||
# Format matches ``_BASELINE_IGNORES``; burn down by moving shared code
|
||||
# below ``surfaces/`` or making tools UI-agnostic.
|
||||
_NESTED_BASELINE_IGNORES: frozenset[str] = frozenset(
|
||||
{
|
||||
"tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.runtime.background.runner",
|
||||
"tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.runtime.investigation_adapter",
|
||||
"tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.runtime.background.runner",
|
||||
"tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.runtime.investigation_adapter",
|
||||
"tools.interactive_shell.actions.llm_provider -> surfaces.cli.wizard.config",
|
||||
# Nested only: runner.py also has a module-level ui import (see _BASELINE_IGNORES).
|
||||
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.ui",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectViolation:
|
||||
source: str
|
||||
target: str
|
||||
|
||||
@property
|
||||
def edge(self) -> str:
|
||||
return f"{self.source} -> {self.target}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestedViolation:
|
||||
source: str
|
||||
target: str
|
||||
lineno: int
|
||||
|
||||
@property
|
||||
def edge(self) -> str:
|
||||
return f"{self.source} -> {self.target}"
|
||||
|
||||
|
||||
def _source_root(module: str) -> str:
|
||||
return module.split(".", 1)[0]
|
||||
|
||||
|
||||
def find_direct_violations(
|
||||
graph: dict[str, set[str]],
|
||||
*,
|
||||
forbidden: dict[str, frozenset[str]] | None = None,
|
||||
baseline_ignores: frozenset[str] | None = None,
|
||||
) -> list[DirectViolation]:
|
||||
rules = forbidden or _FORBIDDEN_DIRECT
|
||||
ignores = baseline_ignores if baseline_ignores is not None else _BASELINE_IGNORES
|
||||
violations: list[DirectViolation] = []
|
||||
|
||||
for source_module, targets in sorted(graph.items()):
|
||||
source_root = _source_root(source_module)
|
||||
forbidden_roots = rules.get(source_root)
|
||||
if not forbidden_roots:
|
||||
continue
|
||||
for target_module in sorted(targets):
|
||||
target_root = _source_root(target_module)
|
||||
if target_root not in forbidden_roots:
|
||||
continue
|
||||
edge = DirectViolation(source_module, target_module)
|
||||
if edge.edge in ignores:
|
||||
continue
|
||||
violations.append(edge)
|
||||
return violations
|
||||
|
||||
|
||||
def find_nested_direct_violations(
|
||||
root: Path,
|
||||
first_party_roots: tuple[str, ...],
|
||||
*,
|
||||
forbidden: dict[str, frozenset[str]] | None = None,
|
||||
baseline_ignores: frozenset[str] | None = None,
|
||||
) -> list[NestedViolation]:
|
||||
rules = forbidden or _FORBIDDEN_DIRECT
|
||||
ignores = baseline_ignores if baseline_ignores is not None else _NESTED_BASELINE_IGNORES
|
||||
roots = frozenset(first_party_roots)
|
||||
violations: list[NestedViolation] = []
|
||||
|
||||
for pkg in first_party_roots:
|
||||
if pkg not in rules:
|
||||
continue
|
||||
pkg_path = root / pkg
|
||||
if not pkg_path.exists():
|
||||
continue
|
||||
for py in pkg_path.rglob("*.py"):
|
||||
if "__pycache__" in py.parts:
|
||||
continue
|
||||
source_module = module_from_path(root, py)
|
||||
source_root = _source_root(source_module)
|
||||
forbidden_roots = rules.get(source_root)
|
||||
if not forbidden_roots:
|
||||
continue
|
||||
source = py.read_text(encoding="utf-8")
|
||||
for target_module, lineno in _nested_imports(source, first_party_roots=roots):
|
||||
target_root = _source_root(target_module)
|
||||
if target_root not in forbidden_roots:
|
||||
continue
|
||||
violation = NestedViolation(source_module, target_module, lineno)
|
||||
if violation.edge in ignores:
|
||||
continue
|
||||
violations.append(violation)
|
||||
|
||||
return sorted(violations, key=lambda item: (item.source, item.lineno, item.target))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
del argv
|
||||
root = _REPO_ROOT
|
||||
first_party_roots = discover_first_party_roots(root)
|
||||
graph = _build_graph(root, first_party_roots)
|
||||
module_violations = find_direct_violations(graph)
|
||||
nested_violations = find_nested_direct_violations(root, first_party_roots)
|
||||
|
||||
if not module_violations and not nested_violations:
|
||||
print(
|
||||
"No forbidden direct import edges found "
|
||||
f"(module baseline: {len(_BASELINE_IGNORES)}, "
|
||||
f"nested baseline: {len(_NESTED_BASELINE_IGNORES)})."
|
||||
)
|
||||
return 0
|
||||
|
||||
if module_violations:
|
||||
print(f"FAIL: {len(module_violations)} forbidden module-level direct import edge(s):")
|
||||
for violation in module_violations:
|
||||
print(f" {violation.edge}")
|
||||
|
||||
if nested_violations:
|
||||
if module_violations:
|
||||
print()
|
||||
print(f"FAIL: {len(nested_violations)} forbidden nested direct import edge(s):")
|
||||
for violation in nested_violations:
|
||||
print(f" {violation.edge} (line {violation.lineno})")
|
||||
|
||||
print(
|
||||
"\nFix by moving shared code to a lower layer (platform/common, core/contracts) "
|
||||
"or add a temporary baseline entry in .github/ci/check_direct_imports.py "
|
||||
"with a linked issue — do not use function-level lazy imports to hide "
|
||||
"new direct edges."
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Detect first-party module-load import cycles via Tarjan's SCC.
|
||||
|
||||
Walks every first-party Python module in the repo, builds an import
|
||||
graph from **top-level** ``import`` / ``from ... import`` statements
|
||||
only (function-level lazy imports are intentional runtime breaks and
|
||||
not counted), then reports any strongly-connected component of size
|
||||
> 1, plus any single-module self-loop.
|
||||
|
||||
Used by ``make check-imports`` (via ``check_imports``) locally and by CI.
|
||||
|
||||
Exit codes:
|
||||
0 — zero cycles found
|
||||
1 — at least one cycle found (output lists every SCC + its edges)
|
||||
|
||||
|
||||
How to break a cycle
|
||||
====================
|
||||
|
||||
Two patterns work. Pick by what consumers do with the name.
|
||||
|
||||
1. ``import pkg.sub as sub`` (preferred when consumers monkeypatch)
|
||||
|
||||
Switch ::
|
||||
|
||||
from pkg.sub import name # binds ``name`` at import time
|
||||
...
|
||||
name(args) # uses the bound reference
|
||||
|
||||
to ::
|
||||
|
||||
import pkg.sub as sub # imports the submodule directly
|
||||
...
|
||||
sub.name(args) # attribute lookup at call time
|
||||
|
||||
Both break the static cycle (no edge from the consumer back to the
|
||||
``pkg`` package). The second form keeps attribute-lookup semantics,
|
||||
which matters whenever consumers monkeypatch ``pkg.sub.name`` in
|
||||
tests — the patched attribute IS looked up at each call. The first
|
||||
form binds the name at import time and ignores later patching.
|
||||
|
||||
2. Port / Protocol (when the cycle crosses architectural layers)
|
||||
|
||||
When the cycle is ``layerA <-> layerB`` (e.g. analytics ↔ sentry,
|
||||
integrations ↔ services), neither side should depend on the other
|
||||
directly. Extract a third module — a ``Protocol`` or a small
|
||||
abstract dataclass — that both depend on, and inject the concrete
|
||||
implementation at startup. See the verifier plugin-registry
|
||||
refactor (``integrations/verification/registry.py``) for the
|
||||
canonical example.
|
||||
|
||||
Avoid ``from pkg import sub`` (where ``sub`` is a submodule of ``pkg``)
|
||||
inside that ``pkg`` itself or any of its children — that's the form
|
||||
this script flags. It triggers ``pkg``'s ``__init__`` even when you
|
||||
just want the submodule, and re-export patterns in ``__init__`` close
|
||||
the loop.
|
||||
|
||||
Function-local imports are NOT flagged. They're a legitimate Python
|
||||
pattern for startup-cost deferral (heavy modules in click subcommand
|
||||
bodies), optional dependencies, and conditional / platform-specific
|
||||
code paths. Use sparingly — keep top-level the default — and comment
|
||||
the *why* so future readers don't mistake them for cycle workarounds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
# Directories at the repo root that are never first-party import roots.
|
||||
_SKIP_ROOT_DIRS = frozenset(
|
||||
{
|
||||
".git",
|
||||
".github",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"docs",
|
||||
"opensre.egg-info",
|
||||
"packaging",
|
||||
"tests",
|
||||
"venv",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def discover_first_party_roots(repo_root: Path | None = None) -> tuple[str, ...]:
|
||||
"""Return top-level package names that contain importable Python code."""
|
||||
root = repo_root or Path(__file__).resolve().parents[2]
|
||||
names: list[str] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
if child.name in _SKIP_ROOT_DIRS:
|
||||
continue
|
||||
if not any(child.rglob("*.py")):
|
||||
continue
|
||||
names.append(child.name)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def _top_level_imports(source: str, *, first_party_roots: frozenset[str]) -> set[str]:
|
||||
"""Return first-party module paths imported at the module top level.
|
||||
|
||||
Function-bodies, class-bodies, conditional / try-except wrappers all
|
||||
count as top-level if they are direct module statements — the only
|
||||
imports skipped are those nested **inside a function or class body**.
|
||||
A lazy ``from X import Y`` inside a function does not deadlock at
|
||||
module load, so it should not be flagged as a cycle.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
return set()
|
||||
|
||||
names: set[str] = set()
|
||||
|
||||
def _add(module_path: str) -> None:
|
||||
top = module_path.split(".", 1)[0]
|
||||
if top in first_party_roots:
|
||||
names.add(module_path)
|
||||
|
||||
def _walk_top(body: Iterable[ast.stmt]) -> None:
|
||||
for node in body:
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
_add(alias.name)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level or not node.module:
|
||||
continue
|
||||
_add(node.module)
|
||||
elif isinstance(node, ast.If):
|
||||
_walk_top(node.body)
|
||||
_walk_top(node.orelse)
|
||||
elif isinstance(node, ast.Try | ast.TryStar):
|
||||
# ``ast.TryStar`` (Python 3.11+, ``try/except*``) shares
|
||||
# the same handler/orelse/finalbody shape as ``ast.Try``.
|
||||
_walk_top(node.body)
|
||||
for handler in node.handlers:
|
||||
_walk_top(handler.body)
|
||||
_walk_top(node.orelse)
|
||||
_walk_top(node.finalbody)
|
||||
elif isinstance(node, ast.With | ast.AsyncWith):
|
||||
_walk_top(node.body)
|
||||
|
||||
_walk_top(tree.body)
|
||||
return names
|
||||
|
||||
|
||||
def _nested_imports(source: str, *, first_party_roots: frozenset[str]) -> list[tuple[str, int]]:
|
||||
"""Return ``(target_module, lineno)`` for imports inside function/class bodies.
|
||||
|
||||
Complements ``_top_level_imports``: catches lazy imports that bypass the
|
||||
module-level direct-edge checker while still being layering violations.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
return []
|
||||
|
||||
results: list[tuple[str, int]] = []
|
||||
|
||||
def _add(module_path: str, lineno: int) -> None:
|
||||
top = module_path.split(".", 1)[0]
|
||||
if top in first_party_roots:
|
||||
results.append((module_path, lineno))
|
||||
|
||||
def _walk_nested(body: Iterable[ast.stmt], *, nested: bool) -> None:
|
||||
for node in body:
|
||||
if isinstance(node, ast.Import):
|
||||
if nested:
|
||||
for alias in node.names:
|
||||
_add(alias.name, node.lineno)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if nested and not node.level and node.module:
|
||||
_add(node.module, node.lineno)
|
||||
elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
||||
_walk_nested(node.body, nested=True)
|
||||
elif isinstance(node, ast.If):
|
||||
_walk_nested(node.body, nested=nested)
|
||||
_walk_nested(node.orelse, nested=nested)
|
||||
elif isinstance(node, ast.Try | ast.TryStar):
|
||||
_walk_nested(node.body, nested=nested)
|
||||
for handler in node.handlers:
|
||||
_walk_nested(handler.body, nested=nested)
|
||||
_walk_nested(node.orelse, nested=nested)
|
||||
_walk_nested(node.finalbody, nested=nested)
|
||||
elif isinstance(node, ast.With | ast.AsyncWith):
|
||||
_walk_nested(node.body, nested=nested)
|
||||
elif isinstance(node, ast.For | ast.AsyncFor | ast.While):
|
||||
_walk_nested(node.body, nested=nested)
|
||||
_walk_nested(node.orelse, nested=nested)
|
||||
elif isinstance(node, ast.Match):
|
||||
for case in node.cases:
|
||||
_walk_nested(case.body, nested=nested)
|
||||
|
||||
_walk_nested(tree.body, nested=False)
|
||||
return results
|
||||
|
||||
|
||||
def module_from_path(root: Path, py: Path) -> str:
|
||||
"""Resolve a repo-relative ``.py`` path to its dotted module name."""
|
||||
module = ".".join(py.with_suffix("").relative_to(root).parts)
|
||||
return module.removesuffix(".__init__")
|
||||
|
||||
|
||||
def _build_graph(root: Path, first_party_roots: tuple[str, ...]) -> dict[str, set[str]]:
|
||||
"""Build the first-party module-level import graph rooted at ``root``."""
|
||||
roots = frozenset(first_party_roots)
|
||||
graph: dict[str, set[str]] = defaultdict(set)
|
||||
for pkg in first_party_roots:
|
||||
pkg_path = root / pkg
|
||||
if not pkg_path.exists():
|
||||
continue
|
||||
for py in pkg_path.rglob("*.py"):
|
||||
if "__pycache__" in py.parts:
|
||||
continue
|
||||
module = module_from_path(root, py)
|
||||
source = py.read_text(encoding="utf-8")
|
||||
graph[module].update(_top_level_imports(source, first_party_roots=roots))
|
||||
return graph
|
||||
|
||||
|
||||
def _tarjan_sccs(graph: dict[str, set[str]]) -> list[list[str]]:
|
||||
"""Return every strongly-connected component of size > 1, plus any
|
||||
single-module self-loop."""
|
||||
index: dict[str, int] = {}
|
||||
lowlink: dict[str, int] = {}
|
||||
on_stack: dict[str, bool] = {}
|
||||
stack: list[str] = []
|
||||
sccs: list[list[str]] = []
|
||||
counter = [0]
|
||||
|
||||
def strongconnect(v: str) -> None:
|
||||
index[v] = counter[0]
|
||||
lowlink[v] = counter[0]
|
||||
counter[0] += 1
|
||||
stack.append(v)
|
||||
on_stack[v] = True
|
||||
|
||||
for w in graph.get(v, ()):
|
||||
if w not in index:
|
||||
strongconnect(w)
|
||||
lowlink[v] = min(lowlink[v], lowlink[w])
|
||||
elif on_stack.get(w):
|
||||
lowlink[v] = min(lowlink[v], index[w])
|
||||
|
||||
if lowlink[v] == index[v]:
|
||||
component: list[str] = []
|
||||
while True:
|
||||
w = stack.pop()
|
||||
on_stack[w] = False
|
||||
component.append(w)
|
||||
if w == v:
|
||||
break
|
||||
# ``while True`` above guarantees ``component`` is non-empty here.
|
||||
if len(component) > 1 or component[0] in graph.get(component[0], ()):
|
||||
sccs.append(component)
|
||||
|
||||
sys.setrecursionlimit(10000)
|
||||
for vertex in list(graph.keys()):
|
||||
if vertex not in index:
|
||||
strongconnect(vertex)
|
||||
|
||||
return sccs
|
||||
|
||||
|
||||
def _format_scc(scc: list[str], graph: dict[str, set[str]]) -> str:
|
||||
"""Format an SCC for human-readable output: members + edges within."""
|
||||
members = sorted(scc)
|
||||
in_scc = set(scc)
|
||||
is_self_loop = len(scc) == 1
|
||||
edges: list[str] = []
|
||||
for module in members:
|
||||
for target in sorted(graph.get(module, ())):
|
||||
if target not in in_scc:
|
||||
continue
|
||||
# Single-module self-loops have only the self-edge; show it
|
||||
# explicitly so the developer knows which import closes the
|
||||
# loop. Multi-module SCCs hide self-edges to keep the diff
|
||||
# focused on the cross-module edges that close the cycle.
|
||||
if target == module and not is_self_loop:
|
||||
continue
|
||||
edges.append(f" {module} -> {target}")
|
||||
|
||||
lines = [f" Modules ({len(scc)}):"]
|
||||
lines.extend(f" - {m}" for m in members)
|
||||
if is_self_loop and not edges:
|
||||
lines.append(" Self-import detected (module imports itself at top level).")
|
||||
elif edges:
|
||||
lines.append(" Edges within SCC:")
|
||||
lines.extend(edges)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
del argv
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
first_party_roots = discover_first_party_roots(root)
|
||||
graph = _build_graph(root, first_party_roots)
|
||||
sccs = _tarjan_sccs(graph)
|
||||
|
||||
if not sccs:
|
||||
print(
|
||||
f"No import cycles found across {len(graph)} first-party modules "
|
||||
f"({len(first_party_roots)} roots)."
|
||||
)
|
||||
return 0
|
||||
|
||||
print(f"FAIL: {len(sccs)} import cycle(s) found across {len(graph)} first-party modules.")
|
||||
for i, scc in enumerate(sorted(sccs, key=lambda s: -len(s)), 1):
|
||||
print(f"\n## SCC #{i} ({len(scc)} module{'s' if len(scc) > 1 else ''}):")
|
||||
print(_format_scc(scc, graph))
|
||||
print(
|
||||
"\nTo break a cycle, prefer:\n"
|
||||
" import pkg.sub as sub\n"
|
||||
" ...\n"
|
||||
" sub.name(args)\n"
|
||||
"over:\n"
|
||||
" from pkg.sub import name\n"
|
||||
" ...\n"
|
||||
" name(args)\n"
|
||||
"\n"
|
||||
"Both fix the static cycle; only the first keeps attribute-lookup\n"
|
||||
"semantics so tests that monkeypatch ``pkg.sub.name`` still work.\n"
|
||||
"\n"
|
||||
"For cross-layer cycles, introduce a Protocol/port both sides\n"
|
||||
"depend on (see ``integrations/verification/registry.py`` for the\n"
|
||||
"canonical example).\n"
|
||||
"\n"
|
||||
"Full pattern reference: docstring at the top of this script."
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Run all import-graph quality checks in one command.
|
||||
|
||||
Orchestrates, in order:
|
||||
|
||||
1. **Cycles** — module-load SCCs (``check_import_cycles``)
|
||||
2. **Layers** — ``config`` independence via import-linter (``.importlinter``)
|
||||
3. **Direct edges** — forbidden top-level imports (``check_direct_imports``)
|
||||
|
||||
Used by ``make check-imports``, ``make check``, and CI.
|
||||
|
||||
Exit codes:
|
||||
0 — all checks passed
|
||||
1 — one or more checks failed (each section prints its own detail)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_CI_DIR = Path(__file__).resolve().parent
|
||||
if str(_CI_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_CI_DIR))
|
||||
|
||||
from check_direct_imports import main as check_direct_imports # noqa: E402
|
||||
from check_import_cycles import main as check_import_cycles # noqa: E402
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportCheck:
|
||||
name: str
|
||||
run: Callable[[], int]
|
||||
|
||||
|
||||
def _run_importlinter(*, config: Path | None = None) -> int:
|
||||
lint_imports = Path(sys.executable).with_name("lint-imports")
|
||||
if not lint_imports.is_file():
|
||||
print(
|
||||
"lint-imports not found — install dev deps (import-linter package).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
command = [str(lint_imports)]
|
||||
if config is not None:
|
||||
command.extend(["--config", str(config)])
|
||||
completed = subprocess.run(command, cwd=_REPO_ROOT, check=False)
|
||||
return int(completed.returncode)
|
||||
|
||||
|
||||
def import_checks(*, strict_layers: bool = False) -> Sequence[ImportCheck]:
|
||||
layer_config = _REPO_ROOT / ".importlinter.strict" if strict_layers else None
|
||||
return (
|
||||
ImportCheck("Import cycles (Tarjan SCC)", check_import_cycles),
|
||||
ImportCheck(
|
||||
"Import layers (import-linter)",
|
||||
lambda: _run_importlinter(config=layer_config),
|
||||
),
|
||||
ImportCheck("Forbidden direct import edges (module + nested)", check_direct_imports),
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(argv or [])
|
||||
strict_layers = False
|
||||
if args == ["--strict"]:
|
||||
strict_layers = True
|
||||
args = []
|
||||
if args:
|
||||
print(f"Unknown arguments: {' '.join(args)}", file=sys.stderr)
|
||||
print("Usage: check_imports.py [--strict]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
checks = import_checks(strict_layers=strict_layers)
|
||||
failures: list[str] = []
|
||||
|
||||
for index, check in enumerate(checks, start=1):
|
||||
print(f"=== [{index}/{len(checks)}] {check.name} ===")
|
||||
exit_code = check.run()
|
||||
if exit_code != 0:
|
||||
failures.append(check.name)
|
||||
print()
|
||||
|
||||
if not failures:
|
||||
print(f"All {len(checks)} import checks passed.")
|
||||
return 0
|
||||
|
||||
print(f"FAIL: {len(failures)} import check(s) failed:")
|
||||
for name in failures:
|
||||
print(f" - {name}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Run the live-LLM turn scenario suite sharded across local processes.
|
||||
|
||||
This mirrors the CI ``turn-live`` job
|
||||
(``.github/workflows/interactive-shell-live.yml``): each shard sets
|
||||
``TURN_SHARD_TOTAL`` / ``TURN_SHARD_INDEX`` and runs the live pytest selection
|
||||
``-m live_llm -k "test_live_turn_execution_oracle or test_live_action_planning"``
|
||||
against ``tests/core/agent/test_turn_scenarios.py``.
|
||||
|
||||
The suite is IO-bound (it waits on real LLM API calls), so running all shards
|
||||
concurrently finishes in roughly one shard's wall time instead of the serial
|
||||
total. Each shard runs as its own pytest process; per-shard output is streamed
|
||||
to a log file and the exit codes are aggregated into a final summary.
|
||||
|
||||
Usage:
|
||||
|
||||
uv run python .github/ci/run_live_turn_shards.py # all 8 shards
|
||||
uv run python .github/ci/run_live_turn_shards.py --shards 4
|
||||
uv run python .github/ci/run_live_turn_shards.py --indexes 0,3
|
||||
uv run python .github/ci/run_live_turn_shards.py -- -x # extra pytest args
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import IO
|
||||
|
||||
_TARGET = "tests/core/agent/test_turn_scenarios.py"
|
||||
_K_EXPR = "test_live_turn_execution_oracle or test_live_action_planning"
|
||||
_LOG_DIR = Path(".turn-shard-logs")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShardResult:
|
||||
index: int
|
||||
exit_code: int
|
||||
duration_s: float
|
||||
log_path: Path
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--shards",
|
||||
type=int,
|
||||
default=int(os.getenv("TURN_SHARD_TOTAL", "8")),
|
||||
help="Total number of shards to split the suite into (default: 8).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--indexes",
|
||||
type=str,
|
||||
default="",
|
||||
help="Comma-separated subset of shard indexes to run (default: all).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers-per-shard",
|
||||
type=str,
|
||||
default="auto",
|
||||
help="pytest-xdist worker count per shard, passed to -n (default: auto).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
type=str,
|
||||
default=os.getenv("LLM_PROVIDER", "openai"),
|
||||
help="LLM provider for the live run (default: $LLM_PROVIDER or openai).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"pytest_args",
|
||||
nargs="*",
|
||||
help="Extra args forwarded to each pytest shard (after a -- separator).",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _resolve_indexes(shards: int, indexes: str) -> list[int]:
|
||||
if shards < 1:
|
||||
raise SystemExit("--shards must be >= 1")
|
||||
if not indexes.strip():
|
||||
return list(range(shards))
|
||||
selected = sorted({int(part) for part in indexes.split(",") if part.strip()})
|
||||
out_of_range = [i for i in selected if i < 0 or i >= shards]
|
||||
if out_of_range:
|
||||
raise SystemExit(f"shard indexes out of range for --shards {shards}: {out_of_range}")
|
||||
return selected
|
||||
|
||||
|
||||
def _build_command(workers: str, pytest_args: list[str]) -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"-n",
|
||||
workers,
|
||||
"-v",
|
||||
"-m",
|
||||
"live_llm",
|
||||
_TARGET,
|
||||
"-k",
|
||||
_K_EXPR,
|
||||
*pytest_args,
|
||||
]
|
||||
|
||||
|
||||
def _shard_env(*, shard_total: int, shard_index: int, provider: str) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["TURN_SHARD_TOTAL"] = str(shard_total)
|
||||
env["TURN_SHARD_INDEX"] = str(shard_index)
|
||||
env["LLM_PROVIDER"] = provider
|
||||
env.setdefault("OPENSRE_DISABLE_KEYRING", "1")
|
||||
env.setdefault("PYTHONUTF8", "1")
|
||||
return env
|
||||
|
||||
|
||||
def _run_shards(
|
||||
*, shard_total: int, indexes: list[int], workers: str, provider: str, pytest_args: list[str]
|
||||
) -> list[ShardResult]:
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
command = _build_command(workers, pytest_args)
|
||||
print(f"Launching {len(indexes)} shard(s) of {shard_total} (provider={provider}):")
|
||||
print(f" {' '.join(command)}\n")
|
||||
|
||||
started: dict[int, tuple[subprocess.Popen[bytes], float, Path, IO[bytes]]] = {}
|
||||
for shard_index in indexes:
|
||||
log_path = _LOG_DIR / f"shard-{shard_index}.log"
|
||||
log_handle: IO[bytes] = log_path.open("wb")
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
env=_shard_env(shard_total=shard_total, shard_index=shard_index, provider=provider),
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
started[shard_index] = (process, time.monotonic(), log_path, log_handle)
|
||||
print(f" shard {shard_index} -> pid {process.pid}, log {log_path}")
|
||||
|
||||
print("\nWaiting for shards to finish...\n")
|
||||
results: list[ShardResult] = []
|
||||
pending = set(started)
|
||||
while pending:
|
||||
for shard_index in sorted(pending):
|
||||
process, start_time, log_path, log_handle = started[shard_index]
|
||||
code = process.poll()
|
||||
if code is None:
|
||||
continue
|
||||
log_handle.close()
|
||||
duration = time.monotonic() - start_time
|
||||
status = "PASS" if code == 0 else f"FAIL (exit {code})"
|
||||
print(f" shard {shard_index} {status} in {duration:.0f}s")
|
||||
results.append(
|
||||
ShardResult(
|
||||
index=shard_index,
|
||||
exit_code=code,
|
||||
duration_s=duration,
|
||||
log_path=log_path,
|
||||
)
|
||||
)
|
||||
pending.discard(shard_index)
|
||||
if pending:
|
||||
time.sleep(2.0)
|
||||
return sorted(results, key=lambda r: r.index)
|
||||
|
||||
|
||||
def _print_summary(results: list[ShardResult]) -> int:
|
||||
failures = [r for r in results if r.exit_code != 0]
|
||||
print("\n==================== live turn shard summary ====================")
|
||||
for result in results:
|
||||
status = "PASS" if result.exit_code == 0 else f"FAIL (exit {result.exit_code})"
|
||||
print(f" shard {result.index}: {status} [{result.duration_s:.0f}s] {result.log_path}")
|
||||
if failures:
|
||||
print(f"\n{len(failures)} shard(s) failed. Inspect the logs above.")
|
||||
return 1
|
||||
print("\nAll shards passed.")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
||||
indexes = _resolve_indexes(args.shards, args.indexes)
|
||||
results = _run_shards(
|
||||
shard_total=args.shards,
|
||||
indexes=indexes,
|
||||
workers=args.workers_per_shard,
|
||||
provider=args.provider,
|
||||
pytest_args=list(args.pytest_args),
|
||||
)
|
||||
return _print_summary(results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run pytest targets relevant to files changed on this branch.
|
||||
|
||||
Usage
|
||||
-----
|
||||
make test-scope
|
||||
make test-scope ARGS=--dry-run
|
||||
python .github/ci/run_test_scope.py [--dry-run] [--base <ref>]
|
||||
|
||||
Exit codes mirror pytest: 0 = all pass, non-zero = failure or config error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_CI_DIR = Path(__file__).resolve().parent
|
||||
if str(_CI_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_CI_DIR))
|
||||
|
||||
from test_scope_rules import classify # noqa: E402
|
||||
|
||||
|
||||
def _commit_ref_exists(ref: str) -> bool:
|
||||
return (
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def _resolve_base_ref(base: str) -> str:
|
||||
"""Prefer branch refs for unqualified bases so same-named tags do not win."""
|
||||
if "/" in base or base.startswith("refs/"):
|
||||
return base
|
||||
for ref in (
|
||||
f"refs/heads/{base}",
|
||||
f"refs/remotes/origin/{base}",
|
||||
f"refs/remotes/upstream/{base}",
|
||||
):
|
||||
if _commit_ref_exists(ref):
|
||||
return ref
|
||||
return base
|
||||
|
||||
|
||||
def _git_changed_files(base: str) -> list[str]:
|
||||
resolved_base = _resolve_base_ref(base)
|
||||
try:
|
||||
merge_base = subprocess.check_output(
|
||||
["git", "merge-base", "HEAD", resolved_base],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
merge_base = "HEAD~1"
|
||||
result = subprocess.check_output(
|
||||
["git", "diff", "--name-only", merge_base],
|
||||
text=True,
|
||||
)
|
||||
return [f.strip() for f in result.splitlines() if f.strip()]
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, dry_run: bool) -> int:
|
||||
print(f"\n $ {' '.join(cmd)}\n", flush=True)
|
||||
if dry_run:
|
||||
return 0
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print command without running.")
|
||||
parser.add_argument(
|
||||
"--base",
|
||||
default="main",
|
||||
help="Ref to diff against (default: main). Falls back to HEAD~1 if unavailable.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
changed = _git_changed_files(args.base)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"error: could not determine changed files: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not changed:
|
||||
print("No changed files detected — nothing to test.")
|
||||
return 0
|
||||
|
||||
print(f"Changed files ({len(changed)}):")
|
||||
for path in changed:
|
||||
print(f" {path}")
|
||||
|
||||
escalate, targets, areas = classify(changed)
|
||||
|
||||
if escalate:
|
||||
print("\nEscalating to full unit suite (core/shared code or 3+ areas touched).")
|
||||
return _run(["make", "test-cov"], dry_run=args.dry_run)
|
||||
|
||||
if not targets:
|
||||
print("\nNo test targets matched — running full unit suite as fallback.")
|
||||
return _run(["make", "test-cov"], dry_run=args.dry_run)
|
||||
|
||||
print(f"\nAreas touched: {', '.join(areas)}")
|
||||
print(f"Running scoped tests: {' '.join(targets)}")
|
||||
return _run(
|
||||
[sys.executable, "-m", "pytest", *targets, "-v"],
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,608 @@
|
||||
"""Path → pytest target mapping for branch-scoped test runs (CI.md §2).
|
||||
|
||||
This module is the single source of truth for ``make test-scope``. Edit rules
|
||||
here only — do not duplicate the mapping table in CI.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Distinct app areas in one diff that trigger escalation to ``make test-cov``.
|
||||
ESCALATION_AREA_THRESHOLD = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PathRule:
|
||||
"""Map changed paths under ``path_prefix`` to pytest targets."""
|
||||
|
||||
path_prefix: str
|
||||
test_targets: tuple[str, ...]
|
||||
always_escalate: bool = False
|
||||
|
||||
|
||||
# Matched in list order — more specific prefixes must appear before parents.
|
||||
RULES: tuple[PathRule, ...] = (
|
||||
# Shared core (always escalate)
|
||||
PathRule("core/domain/", (), always_escalate=True),
|
||||
PathRule("core/", ("tests/core/",)),
|
||||
PathRule("tools/investigation/reporting/", ("tests/delivery/",)),
|
||||
PathRule("tools/investigation/", (), always_escalate=True),
|
||||
PathRule("utils/", (), always_escalate=True),
|
||||
# Specific sub-packages before their parent
|
||||
PathRule("integrations/llm_cli/", ("tests/integrations/llm_cli/",)),
|
||||
PathRule("integrations/opensre/", ("tests/integrations/opensre/",)),
|
||||
PathRule("integrations/hermes/", ("tests/hermes/",)),
|
||||
PathRule(
|
||||
"integrations/alertmanager/",
|
||||
("tests/integrations/alertmanager/", "tests/e2e/alertmanager/"),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/dagster/",
|
||||
("tests/integrations/dagster/", "tests/synthetic/test_dagster_scenario.py"),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/eks/",
|
||||
(
|
||||
"tests/integrations/eks/",
|
||||
"tests/tools/test_eks_deployment_status_tool.py",
|
||||
"tests/tools/test_eks_describe_addon_tool.py",
|
||||
"tests/tools/test_eks_describe_cluster_tool.py",
|
||||
"tests/tools/test_eks_events_tool.py",
|
||||
"tests/tools/test_eks_list_clusters_tool.py",
|
||||
"tests/tools/test_eks_list_deployments_tool.py",
|
||||
"tests/tools/test_eks_list_namespaces_tool.py",
|
||||
"tests/tools/test_eks_list_pods_tool.py",
|
||||
"tests/tools/test_eks_node_health_tool.py",
|
||||
"tests/tools/test_eks_nodegroup_health_tool.py",
|
||||
"tests/tools/test_eks_pod_logs_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
"tests/benchmarks/cloudopsbench/tests/test_bench_agent.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/elasticsearch/",
|
||||
(
|
||||
"tests/integrations/elasticsearch/",
|
||||
"tests/tools/test_elasticsearch_logs_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/google_docs/",
|
||||
(
|
||||
"tests/integrations/google_docs/",
|
||||
"tests/tools/test_google_docs_create_report_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/groundcover/",
|
||||
("tests/integrations/groundcover/", "tests/tools/test_groundcover_tools.py"),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/helm/",
|
||||
("tests/integrations/helm/", "tests/tools/test_helm_tools.py"),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/incident_io/",
|
||||
("tests/integrations/incident_io/", "tests/tools/test_incident_io_tool.py"),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/jira/",
|
||||
(
|
||||
"tests/integrations/jira/",
|
||||
"tests/tools/test_jira_add_comment_tool.py",
|
||||
"tests/tools/test_jira_create_issue_tool.py",
|
||||
"tests/tools/test_jira_issue_detail_tool.py",
|
||||
"tests/tools/test_jira_search_issues_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/clickhouse/",
|
||||
(
|
||||
"tests/integrations/clickhouse/",
|
||||
"tests/tools/test_clickhouse_query_activity_tool.py",
|
||||
"tests/tools/test_clickhouse_system_health_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/mariadb/",
|
||||
(
|
||||
"tests/integrations/mariadb/",
|
||||
"tests/tools/test_mariadb_innodb_status_tool.py",
|
||||
"tests/tools/test_mariadb_process_list_tool.py",
|
||||
"tests/tools/test_mariadb_replication_tool.py",
|
||||
"tests/tools/test_mariadb_slow_queries_tool.py",
|
||||
"tests/tools/test_mariadb_status_tool.py",
|
||||
"tests/e2e/mariadb/",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/mongodb_atlas/",
|
||||
(
|
||||
"tests/integrations/mongodb_atlas/",
|
||||
"tests/tools/test_mongodb_atlas_alerts_tool.py",
|
||||
"tests/tools/test_mongodb_atlas_clusters_tool.py",
|
||||
"tests/tools/test_mongodb_atlas_events_tool.py",
|
||||
"tests/tools/test_mongodb_atlas_metrics_tool.py",
|
||||
"tests/tools/test_mongodb_atlas_performance_advisor_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/mongodb/",
|
||||
(
|
||||
"tests/integrations/mongodb/",
|
||||
"tests/tools/test_mongodb_collection_stats_tool.py",
|
||||
"tests/tools/test_mongodb_current_ops_tool.py",
|
||||
"tests/tools/test_mongodb_profiler_tool.py",
|
||||
"tests/tools/test_mongodb_replica_status_tool.py",
|
||||
"tests/tools/test_mongodb_server_status_tool.py",
|
||||
"tests/e2e/mongodb/",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/mysql/",
|
||||
(
|
||||
"tests/integrations/mysql/",
|
||||
"tests/tools/test_mysql_current_processes_tool.py",
|
||||
"tests/tools/test_mysql_replication_status_tool.py",
|
||||
"tests/tools/test_mysql_server_status_tool.py",
|
||||
"tests/tools/test_mysql_slow_queries_tool.py",
|
||||
"tests/tools/test_mysql_table_stats_tool.py",
|
||||
"tests/e2e/mysql/",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/postgresql/",
|
||||
(
|
||||
"tests/integrations/postgresql/",
|
||||
"tests/tools/test_postgresql_current_queries_tool.py",
|
||||
"tests/tools/test_postgresql_locks_tool.py",
|
||||
"tests/tools/test_postgresql_replication_status_tool.py",
|
||||
"tests/tools/test_postgresql_server_status_tool.py",
|
||||
"tests/tools/test_postgresql_slow_queries_tool.py",
|
||||
"tests/tools/test_postgresql_table_stats_tool.py",
|
||||
"tests/e2e/postgresql/",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/redis/",
|
||||
(
|
||||
"tests/integrations/redis/",
|
||||
"tests/tools/test_redis_client_list_tool.py",
|
||||
"tests/tools/test_redis_key_scan_tool.py",
|
||||
"tests/tools/test_redis_latency_doctor_tool.py",
|
||||
"tests/tools/test_redis_list_depth_tool.py",
|
||||
"tests/tools/test_redis_replication_tool.py",
|
||||
"tests/tools/test_redis_server_info_tool.py",
|
||||
"tests/tools/test_redis_slowlog_tool.py",
|
||||
"tests/e2e/redis/",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/snowflake/",
|
||||
(
|
||||
"tests/integrations/snowflake/",
|
||||
"tests/tools/test_snowflake_query_history_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/azure/",
|
||||
(
|
||||
"tests/tools/test_azure_monitor_logs_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/azure_sql/",
|
||||
(
|
||||
"tests/integrations/test_azure_sql.py",
|
||||
"tests/tools/test_azure_sql_current_queries_tool.py",
|
||||
"tests/tools/test_azure_sql_resource_stats_tool.py",
|
||||
"tests/tools/test_azure_sql_server_status_tool.py",
|
||||
"tests/tools/test_azure_sql_slow_queries_tool.py",
|
||||
"tests/tools/test_azure_sql_wait_stats_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/betterstack/",
|
||||
(
|
||||
"tests/integrations/test_betterstack.py",
|
||||
"tests/tools/test_betterstack_logs_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/hermes/tools/",
|
||||
(
|
||||
"tests/tools/test_hermes_logs_tool.py",
|
||||
"tests/tools/test_hermes_session_evidence_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/kafka/",
|
||||
(
|
||||
"tests/integrations/test_kafka.py",
|
||||
"tests/tools/test_kafka_consumer_group_tool.py",
|
||||
"tests/tools/test_kafka_topic_health_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/openclaw/",
|
||||
(
|
||||
"tests/tools/test_openclaw_mcp_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/openobserve/",
|
||||
(
|
||||
"tests/tools/test_openobserve_logs_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/opensearch/",
|
||||
(
|
||||
"tests/integrations/test_opensearch_catalog.py",
|
||||
"tests/tools/test_opensearch_analytics_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/posthog_mcp/",
|
||||
(
|
||||
"tests/integrations/test_posthog_mcp.py",
|
||||
"tests/tools/test_posthog_mcp_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/rabbitmq/",
|
||||
(
|
||||
"tests/integrations/test_rabbitmq.py",
|
||||
"tests/tools/test_rabbitmq_broker_overview_tool.py",
|
||||
"tests/tools/test_rabbitmq_connection_stats_tool.py",
|
||||
"tests/tools/test_rabbitmq_consumer_health_tool.py",
|
||||
"tests/tools/test_rabbitmq_node_health_tool.py",
|
||||
"tests/tools/test_rabbitmq_queue_backlog_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/sentry_mcp/",
|
||||
(
|
||||
"tests/integrations/test_sentry_mcp.py",
|
||||
"tests/tools/test_sentry_mcp_tool.py",
|
||||
"tests/tools/test_telemetry.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/sentry/",
|
||||
(
|
||||
"tests/tools/test_sentry_issue_details_tool.py",
|
||||
"tests/tools/test_sentry_issue_events_tool.py",
|
||||
"tests/tools/test_sentry_search_issues_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/supabase/",
|
||||
(
|
||||
"tests/integrations/test_supabase.py",
|
||||
"tests/tools/test_supabase_health_tool.py",
|
||||
"tests/tools/test_supabase_storage_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/bitbucket/",
|
||||
(
|
||||
"tests/integrations/test_bitbucket.py",
|
||||
"tests/tools/test_bitbucket_commits_tool.py",
|
||||
"tests/tools/test_bitbucket_file_contents_tool.py",
|
||||
"tests/tools/test_bitbucket_search_code_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/telegram/tools/",
|
||||
("tests/tools/test_telegram_send_message_tool.py",),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/tracer/tools/",
|
||||
(
|
||||
"tests/tools/test_tracer_airflow_metrics_tool.py",
|
||||
"tests/tools/test_tracer_batch_statistics_tool.py",
|
||||
"tests/tools/test_tracer_error_logs_tool.py",
|
||||
"tests/tools/test_tracer_failed_jobs_tool.py",
|
||||
"tests/tools/test_tracer_failed_run_tool.py",
|
||||
"tests/tools/test_tracer_failed_tools_tool.py",
|
||||
"tests/tools/test_tracer_host_metrics_tool.py",
|
||||
"tests/tools/test_tracer_run_tool.py",
|
||||
"tests/tools/test_tracer_tasks_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/twilio/",
|
||||
(
|
||||
"tests/integrations/test_twilio.py",
|
||||
"tests/tools/test_twilio_notify_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/github/tools/",
|
||||
(
|
||||
"tests/tools/test_github_actions_tool.py",
|
||||
"tests/tools/test_github_commits_tool.py",
|
||||
"tests/tools/test_github_file_contents_tool.py",
|
||||
"tests/tools/test_github_helpers.py",
|
||||
"tests/tools/test_github_issues_tool.py",
|
||||
"tests/tools/test_github_repo_scope.py",
|
||||
"tests/tools/test_github_repository_tool.py",
|
||||
"tests/tools/test_github_repository_tree_tool.py",
|
||||
"tests/tools/test_github_search_code_tool.py",
|
||||
"tests/tools/test_github_workflow_tools.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/gitlab/",
|
||||
(
|
||||
"tests/integrations/test_gitlab.py",
|
||||
"tests/tools/test_gitlab_commits_tool.py",
|
||||
"tests/tools/test_gitlab_file_tool.py",
|
||||
"tests/tools/test_gitlab_mrs_tool.py",
|
||||
"tests/tools/test_gitlab_pipelines_tool.py",
|
||||
"tests/e2e/gitlab/",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/aws/tools/",
|
||||
("tests/tools/test_aws_operation_tool.py",),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/aws_lambda/",
|
||||
(
|
||||
"tests/integrations/aws/test_lambda_client.py",
|
||||
"tests/tools/test_lambda_config_tool.py",
|
||||
"tests/tools/test_lambda_errors_tool.py",
|
||||
"tests/tools/test_lambda_inspect_tool.py",
|
||||
"tests/tools/test_lambda_invocation_logs_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/cloudtrail/",
|
||||
("tests/tools/test_cloudtrail_events.py",),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/cloudwatch/",
|
||||
(
|
||||
"tests/integrations/aws/test_cloudwatch_client.py",
|
||||
"tests/tools/test_cloudwatch_batch_metrics_tool.py",
|
||||
"tests/tools/test_cloudwatch_logs_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/ec2/",
|
||||
("tests/tools/test_ec2_instances_by_tag_tool.py",),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/elb/",
|
||||
("tests/tools/test_elb_target_health_tool.py",),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/rds/",
|
||||
(
|
||||
"tests/integrations/test_rds.py",
|
||||
"tests/tools/test_rds_tools.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/s3/",
|
||||
(
|
||||
"tests/integrations/aws/test_s3_client.py",
|
||||
"tests/tools/test_s3_get_object_tool.py",
|
||||
"tests/tools/test_s3_inspect_tool.py",
|
||||
"tests/tools/test_s3_list_tool.py",
|
||||
"tests/tools/test_s3_marker_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/opsgenie/",
|
||||
(
|
||||
"tests/integrations/opsgenie/",
|
||||
"tests/tools/test_opsgenie_alert_detail_tool.py",
|
||||
"tests/tools/test_opsgenie_alerts_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/pagerduty/",
|
||||
(
|
||||
"tests/integrations/pagerduty/",
|
||||
"tests/tools/test_pagerduty_incident_detail_tool.py",
|
||||
"tests/tools/test_pagerduty_incidents_tool.py",
|
||||
"tests/tools/test_pagerduty_oncall_tool.py",
|
||||
"tests/tools/test_pagerduty_services_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/prefect/",
|
||||
(
|
||||
"tests/integrations/prefect/",
|
||||
"tests/tools/test_prefect_flow_runs_tool.py",
|
||||
"tests/tools/test_prefect_worker_health_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/signoz/",
|
||||
(
|
||||
"tests/integrations/signoz/",
|
||||
"tests/tools/test_signoz_tools.py",
|
||||
"tests/synthetic/test_signoz_scenario.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/splunk/",
|
||||
("tests/integrations/splunk/", "tests/tools/test_splunk_search_tool.py"),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/tempo/",
|
||||
(
|
||||
"tests/integrations/tempo/",
|
||||
"tests/tools/test_tempo_tools.py",
|
||||
"tests/synthetic/test_tempo_scenario.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/temporal/",
|
||||
(
|
||||
"tests/integrations/temporal/",
|
||||
"tests/integrations/test_temporal_catalog.py",
|
||||
"tests/synthetic/test_temporal_scenario.py",
|
||||
"tests/tools/test_temporal_namespace_info_tool.py",
|
||||
"tests/tools/test_temporal_task_queue_tool.py",
|
||||
"tests/tools/test_temporal_workflow_history_tool.py",
|
||||
"tests/tools/test_temporal_workflows_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/vercel/",
|
||||
(
|
||||
"tests/integrations/vercel/",
|
||||
"tests/tools/test_vercel_deployment_status_tool.py",
|
||||
"tests/tools/test_vercel_logs_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/victoria_logs/",
|
||||
(
|
||||
"tests/integrations/victoria_logs/",
|
||||
"tests/tools/test_victoria_logs_tool.py",
|
||||
"tests/e2e/victoria_logs/",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/x_mcp/",
|
||||
(
|
||||
"tests/integrations/test_x_mcp.py",
|
||||
"tests/tools/test_x_mcp_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/argocd/",
|
||||
(
|
||||
"tests/integrations/argocd/",
|
||||
"tests/tools/test_argocd_tools.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/coralogix/",
|
||||
(
|
||||
"tests/integrations/coralogix/",
|
||||
"tests/tools/test_coralogix_logs_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/honeycomb/",
|
||||
(
|
||||
"tests/integrations/honeycomb/",
|
||||
"tests/tools/test_honeycomb_traces_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/jenkins/",
|
||||
("tests/integrations/test_jenkins.py", "tests/synthetic/test_jenkins_scenario.py"),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/datadog/",
|
||||
(
|
||||
"tests/integrations/datadog/",
|
||||
"tests/tools/test_datadog_context_tool.py",
|
||||
"tests/tools/test_datadog_events_tool.py",
|
||||
"tests/tools/test_datadog_logs_tool.py",
|
||||
"tests/tools/test_datadog_metrics_tool.py",
|
||||
"tests/tools/test_datadog_monitors_tool.py",
|
||||
"tests/tools/test_datadog_node_pods_tool.py",
|
||||
),
|
||||
),
|
||||
PathRule(
|
||||
"integrations/grafana/",
|
||||
(
|
||||
"tests/integrations/grafana/",
|
||||
"tests/tools/test_grafana_alert_rules_tool.py",
|
||||
"tests/tools/test_grafana_annotations_tool.py",
|
||||
"tests/tools/test_grafana_logs_tool.py",
|
||||
"tests/tools/test_grafana_metrics_tool.py",
|
||||
"tests/tools/test_grafana_service_names_tool.py",
|
||||
"tests/tools/test_grafana_traces_tool.py",
|
||||
"tests/e2e/grafana_validation/",
|
||||
),
|
||||
),
|
||||
PathRule("integrations/", ("tests/integrations/",)),
|
||||
PathRule("tools/system/fleet_monitoring/", ("tests/agent/", "tests/fleet_monitoring/")),
|
||||
PathRule("surfaces/cli/", ("tests/cli/",)),
|
||||
PathRule("surfaces/interactive_shell/", ("tests/interactive_shell/",)),
|
||||
PathRule("gateway/", ("gateway/tests/",)),
|
||||
PathRule("tools/system/watch_dog/", ("tests/watch_dog/",)),
|
||||
PathRule("tools/", ("tests/tools/",)),
|
||||
PathRule("platform/analytics/", ("tests/analytics/",)),
|
||||
PathRule("platform/guardrails/", ("tests/platform/guardrails/",)),
|
||||
PathRule("platform/masking/", ("tests/masking/",)),
|
||||
PathRule("platform/packaging/", ("tests/packaging/",)),
|
||||
PathRule("platform/sandbox/", ("tests/sandbox/",)),
|
||||
PathRule(
|
||||
"platform/deployment/",
|
||||
("tests/deployment/", "tests/platform/deployment/test_deployment_health.py"),
|
||||
),
|
||||
PathRule("platform/auth/", ("tests/platform/auth/",)),
|
||||
PathRule("gateway/webapp.py", ("gateway/tests/test_webapp.py",)),
|
||||
# Repo-wide config
|
||||
PathRule("pyproject.toml", (), always_escalate=True),
|
||||
PathRule("uv.lock", (), always_escalate=True),
|
||||
PathRule("pytest.ini", (), always_escalate=True),
|
||||
PathRule("Makefile", (), always_escalate=True),
|
||||
PathRule(".github/ci/", ("tests/github_ci/",)),
|
||||
)
|
||||
|
||||
|
||||
def _matches(path: str, prefix: str) -> bool:
|
||||
return path.startswith(prefix) or path == prefix.rstrip("/")
|
||||
|
||||
|
||||
def _area_key(prefix: str) -> str:
|
||||
parts = prefix.split("/")
|
||||
if parts[0] == "deployment" or parts[:2] == ["platform", "deployment"]:
|
||||
return "deployment"
|
||||
return prefix
|
||||
|
||||
|
||||
def classify(changed: list[str]) -> tuple[bool, list[str], list[str]]:
|
||||
"""Return ``(should_escalate, test_targets, matched_areas)``."""
|
||||
escalate = False
|
||||
targets: list[str] = []
|
||||
areas: list[str] = []
|
||||
|
||||
for path in changed:
|
||||
matched = False
|
||||
for rule in RULES:
|
||||
if not _matches(path, rule.path_prefix):
|
||||
continue
|
||||
matched = True
|
||||
if rule.always_escalate:
|
||||
escalate = True
|
||||
else:
|
||||
area = _area_key(rule.path_prefix)
|
||||
if area not in areas:
|
||||
areas.append(area)
|
||||
for target in rule.test_targets:
|
||||
if target not in targets:
|
||||
targets.append(target)
|
||||
break
|
||||
|
||||
if not matched and path.startswith("tests/") and path not in targets:
|
||||
targets.append(path)
|
||||
|
||||
if len(areas) >= ESCALATION_AREA_THRESHOLD:
|
||||
escalate = True
|
||||
|
||||
existing = [t for t in targets if Path(t).exists()]
|
||||
dropped = [t for t in targets if t not in existing]
|
||||
if dropped:
|
||||
print(f" (skipping non-existent targets: {', '.join(dropped)})", flush=True)
|
||||
return escalate, existing, areas
|
||||
@@ -0,0 +1,7 @@
|
||||
name: "CodeQL config"
|
||||
|
||||
paths-ignore:
|
||||
# Vendored third-party pipeline code in test fixtures — not owned by this project.
|
||||
# Covers every tests/e2e/<case>/pipeline_code/ fixture (lambda, flink, prefect,
|
||||
# datadog, kubernetes, …) so new cases are ignored automatically.
|
||||
- tests/e2e/**/pipeline_code/**
|
||||
@@ -0,0 +1,15 @@
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Discord release announcement JSON payload.
|
||||
|
||||
Reads from environment variables set by the GitHub Actions step:
|
||||
RELEASE_TAG, RELEASE_URL,
|
||||
DISCORD_RELEASES_ROLE_ID, DISCORD_RELEASE_LOGO_EMOJI, DISCORD_RELEASE_LOGO_URL,
|
||||
CHANGELOG_FILE — path to DISCORD_NARRATIVE.md or GENERATED_CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
MAX_CHANGELOG_CHARS = 1400
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
url = os.environ["RELEASE_URL"]
|
||||
role_id = os.environ.get("DISCORD_RELEASES_ROLE_ID", "")
|
||||
logo_emoji = os.environ.get("DISCORD_RELEASE_LOGO_EMOJI", "")
|
||||
logo_url = os.environ.get("DISCORD_RELEASE_LOGO_URL", "")
|
||||
changelog_file = os.environ.get("CHANGELOG_FILE", "")
|
||||
|
||||
if changelog_file and os.path.isfile(changelog_file):
|
||||
with open(changelog_file) as f:
|
||||
changelog = f.read().replace("\r", "").strip()
|
||||
else:
|
||||
changelog = "No changelog available."
|
||||
|
||||
if len(changelog) > MAX_CHANGELOG_CHARS:
|
||||
changelog = changelog[: MAX_CHANGELOG_CHARS - 3] + "..."
|
||||
|
||||
mention = f"<@&{role_id}>\n" if role_id else ""
|
||||
logo_prefix = f"{logo_emoji} " if logo_emoji else ""
|
||||
bt = "`"
|
||||
|
||||
content = (
|
||||
mention + logo_prefix + f"🚀 **opensre {bt}{tag}{bt} is live**\n" + f"🔗 {url}\n\n" + changelog
|
||||
)
|
||||
|
||||
allowed_mentions: dict = {"parse": []}
|
||||
if role_id:
|
||||
allowed_mentions["roles"] = [role_id]
|
||||
|
||||
payload: dict = {"content": content, "allowed_mentions": allowed_mentions}
|
||||
if logo_url:
|
||||
payload["username"] = "OpenSRE"
|
||||
payload["avatar_url"] = logo_url
|
||||
|
||||
json.dump(payload, sys.stdout)
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Assign and notify **new** contributors on `good first issue` threads.
|
||||
|
||||
Here *new* means the commenter has **no merged PRs and no open PRs** in this repo where
|
||||
they are the PR author (GitHub Search API). **One or more merged PRs** means they are not
|
||||
treated as a first-time contributor for this automation (GitHub's ``FIRST_TIME_CONTRIBUTOR``
|
||||
/ ``FIRST_TIMER`` flags are not used).
|
||||
|
||||
Also skips repo insiders (OWNER / MEMBER / COLLABORATOR), bots, closed issues,
|
||||
comments on **pull request** threads (``issue_comment`` fires for PRs too; those
|
||||
use ``issue.pull_request``), and commenters already listed as assignees.
|
||||
|
||||
**One open assignment per eligible new contributor** in this repo: if they already
|
||||
have another **open** issue assigned (Search API), they cannot be auto-assigned here.
|
||||
|
||||
At most **one** auto-assignment per issue: if anyone else is already an assignee,
|
||||
further eligible commenters are skipped (manual pre-assignments count).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
GOOD_FIRST_LABEL = "good first issue"
|
||||
# Do not auto-assign maintainers/collaborators;
|
||||
# eligibility is 0 merged + 0 open PRs as author + not insider.
|
||||
EXCLUDED_COMMENTER_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
|
||||
GITHUB_API = "https://api.github.com"
|
||||
|
||||
|
||||
def _github_api_url(path: str, query: dict[str, str]) -> str:
|
||||
encoded = urllib.parse.urlencode(query)
|
||||
return f"{GITHUB_API}{path}?{encoded}"
|
||||
|
||||
|
||||
def screen_event_without_api(event: dict[str, Any]) -> str | None:
|
||||
"""Return a skip reason before calling the GitHub API, or None if checks should continue."""
|
||||
issue = event.get("issue") or {}
|
||||
comment = event.get("comment") or {}
|
||||
if issue.get("pull_request") is not None:
|
||||
return "comment_on_pull_request"
|
||||
if issue.get("state") != "open":
|
||||
return "issue_not_open"
|
||||
labels = issue.get("labels") or []
|
||||
if not isinstance(labels, list):
|
||||
return "invalid_labels"
|
||||
names = {item.get("name") for item in labels if isinstance(item, dict)}
|
||||
if GOOD_FIRST_LABEL not in names:
|
||||
return "not_good_first_issue"
|
||||
|
||||
c_user = comment.get("user") or {}
|
||||
if c_user.get("type") == "Bot":
|
||||
return "bot_commenter"
|
||||
c_login = c_user.get("login") or ""
|
||||
if not c_login:
|
||||
return "missing_commenter_login"
|
||||
|
||||
c_assoc = comment.get("author_association") or ""
|
||||
if c_assoc in EXCLUDED_COMMENTER_ASSOCIATIONS:
|
||||
return "commenter_repo_insider"
|
||||
|
||||
assignees = issue.get("assignees") or []
|
||||
if isinstance(assignees, list):
|
||||
assigned_logins = {
|
||||
a.get("login") for a in assignees if isinstance(a, dict) and a.get("login")
|
||||
}
|
||||
if c_login in assigned_logins:
|
||||
return "already_assignee"
|
||||
if assigned_logins:
|
||||
return "issue_already_claimed"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def assign_decision(
|
||||
*,
|
||||
skip_reason_pre_api: str | None,
|
||||
merged_pr_count_for_commenter: int,
|
||||
open_pr_count_for_commenter: int,
|
||||
open_assigned_issue_count_for_commenter: int,
|
||||
) -> tuple[bool, str]:
|
||||
"""Return (should_assign_and_comment, skip_reason_or_empty).
|
||||
|
||||
Eligible "new contributor" means ``merged_pr_count_for_commenter == 0`` and
|
||||
``open_pr_count_for_commenter == 0`` (PR author in this repo, via Search API).
|
||||
They must also have no other open issues assigned in this repo
|
||||
(``open_assigned_issue_count_for_commenter == 0``).
|
||||
"""
|
||||
if skip_reason_pre_api is not None:
|
||||
return False, skip_reason_pre_api
|
||||
if open_pr_count_for_commenter > 0:
|
||||
return False, "has_open_prs"
|
||||
if merged_pr_count_for_commenter > 0:
|
||||
return False, "has_merged_prs"
|
||||
if open_assigned_issue_count_for_commenter > 0:
|
||||
return False, "already_has_open_assigned_issue"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _request_json(url: str, token: str) -> Any:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme != "https" or parsed.netloc != "api.github.com":
|
||||
raise ValueError("GitHub API URL must target https://api.github.com")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
with (
|
||||
urllib.request.urlopen(req, timeout=60) as resp
|
||||
): # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _search_issue_total_count(query: str, token: str) -> int:
|
||||
url = _github_api_url("/search/issues", {"q": query})
|
||||
try:
|
||||
data = _request_json(url, token)
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"GitHub search failed: {exc}", file=sys.stderr)
|
||||
raise
|
||||
total = data.get("total_count")
|
||||
if not isinstance(total, int):
|
||||
return 0
|
||||
return total
|
||||
|
||||
|
||||
def fetch_merged_pr_count(owner: str, repo: str, login: str, token: str) -> int:
|
||||
q = f"repo:{owner}/{repo} is:pr is:merged author:{login}"
|
||||
return _search_issue_total_count(q, token)
|
||||
|
||||
|
||||
def fetch_open_pr_count(owner: str, repo: str, login: str, token: str) -> int:
|
||||
q = f"repo:{owner}/{repo} is:pr is:open author:{login}"
|
||||
return _search_issue_total_count(q, token)
|
||||
|
||||
|
||||
def fetch_open_assigned_issue_count(owner: str, repo: str, login: str, token: str) -> int:
|
||||
"""Count open issues in this repo where ``login`` is an assignee."""
|
||||
q = f"repo:{owner}/{repo} is:issue is:open assignee:{login}"
|
||||
return _search_issue_total_count(q, token)
|
||||
|
||||
|
||||
def build_assign_notice_body(*, assignee_login: str) -> str:
|
||||
return f"@{assignee_login} You've been **assigned** to this issue. Thanks for picking it up."
|
||||
|
||||
|
||||
def set_github_output(name: str, value: str) -> None:
|
||||
path = os.environ.get("GITHUB_OUTPUT")
|
||||
if not path:
|
||||
return
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
||||
repository = os.environ.get("GITHUB_REPOSITORY")
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if not event_path or not repository or not token:
|
||||
print("Missing GITHUB_EVENT_PATH, GITHUB_REPOSITORY, or GITHUB_TOKEN.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
raw = Path(event_path).read_text(encoding="utf-8")
|
||||
event = json.loads(raw)
|
||||
|
||||
pre = screen_event_without_api(event)
|
||||
merged_count = 0
|
||||
open_count = 0
|
||||
open_assigned_issue_count = 0
|
||||
|
||||
if pre is None:
|
||||
owner, _, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
print("Invalid GITHUB_REPOSITORY.", file=sys.stderr)
|
||||
return 1
|
||||
comment = event.get("comment") or {}
|
||||
c_login = (comment.get("user") or {}).get("login") or ""
|
||||
try:
|
||||
merged_count = fetch_merged_pr_count(owner, repo, c_login, token)
|
||||
open_count = fetch_open_pr_count(owner, repo, c_login, token)
|
||||
open_assigned_issue_count = fetch_open_assigned_issue_count(owner, repo, c_login, token)
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"GitHub API request failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
should, reason = assign_decision(
|
||||
skip_reason_pre_api=pre,
|
||||
merged_pr_count_for_commenter=merged_count,
|
||||
open_pr_count_for_commenter=open_count,
|
||||
open_assigned_issue_count_for_commenter=open_assigned_issue_count,
|
||||
)
|
||||
|
||||
if not should:
|
||||
print(f"Skip: {reason}")
|
||||
set_github_output("should_assign", "false")
|
||||
return 0
|
||||
|
||||
comment_user = (event.get("comment") or {}).get("user") or {}
|
||||
login = comment_user.get("login") if isinstance(comment_user, dict) else ""
|
||||
if not isinstance(login, str) or not login:
|
||||
print("Missing commenter login.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
body = build_assign_notice_body(assignee_login=login)
|
||||
Path("assign_comment.md").write_text(body, encoding="utf-8")
|
||||
set_github_output("should_assign", "true")
|
||||
print("Wrote assign_comment.md; assignment will be applied in workflow.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Write celebrate-merge PR comment body to comment.md (run from Actions after merge)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
|
||||
discord = os.environ["DISCORD_INVITE_URL"]
|
||||
contributor = os.environ["CONTRIBUTOR_LOGIN"]
|
||||
|
||||
templates: list[str] = [
|
||||
(f"🎉 **MERGED!** @{contributor} just shipped something. The diff gods are pleased. 🙌"),
|
||||
(
|
||||
f"🚀 **Houston, we have a merge.** @{contributor} your PR is in orbit. "
|
||||
"Thanks for launching this one!"
|
||||
),
|
||||
(
|
||||
f"💜 **One more reason the project grows.** Thanks @{contributor} — "
|
||||
"your contribution just landed!"
|
||||
),
|
||||
(
|
||||
f"🎊 **Achievement unlocked: PR Merged.** @{contributor} passed code review, "
|
||||
"survived CI, and shipped. Respect. 🤝"
|
||||
),
|
||||
(
|
||||
f'🔥 **Another one.** @{contributor} said "here\'s a PR" and maintainers said '
|
||||
"\"ship it\". That's how it's done."
|
||||
),
|
||||
(
|
||||
f"🧑💻 **@{contributor} has entered the contributor hall of fame.** "
|
||||
"Merged. Done. Shipped. Go touch grass (then come back with another PR). 🌱"
|
||||
),
|
||||
(
|
||||
f"🎯 **Bullseye.** @{contributor} opened a PR, kept the vibes clean, "
|
||||
"and got it merged. Absolute cinema. 🎬"
|
||||
),
|
||||
(
|
||||
f"⚡ **LGTM → Merged.** @{contributor}, your work is in. "
|
||||
"Every commit counts — thank you for this one."
|
||||
),
|
||||
# new additions
|
||||
(
|
||||
f'😤 **@{contributor} said "I will fix this" and then actually fixed it.** '
|
||||
"Legendary behavior."
|
||||
),
|
||||
(
|
||||
f"🍕 **@{contributor}'s PR:** crispy edges, no unnecessary toppings, delivered on time. "
|
||||
"Understood the assignment. 🔥"
|
||||
),
|
||||
(f"🌊 **Merged.** @{contributor} is now permanently woven into git history. No take-backs. 😄"),
|
||||
(
|
||||
f"🤖 **CI passed. Linter didn't scream. Reviewer typed LGTM.** "
|
||||
f"@{contributor}, every machine in this pipeline just slow-clapped. 🖥️✨"
|
||||
),
|
||||
(f"🧠 **@{contributor} opened a PR.** Maintainers feared them. CI genuflected. It merged. 🚨"),
|
||||
(
|
||||
f"😭 **Clear commit message. Green tests. Kind review.** "
|
||||
f"@{contributor}, stop making the rest of us look bad."
|
||||
),
|
||||
(
|
||||
f"🐸 **Rebase? Handled. Conflicts? Squashed. CI? Vibing.** "
|
||||
f"@{contributor} touched the untouchable and lived. 🫡"
|
||||
),
|
||||
(
|
||||
f"🏆 **@{contributor} did not come to play.** "
|
||||
"PR opened. Review survived. Merged clean. Retire the jersey. 🎽"
|
||||
),
|
||||
(
|
||||
f"🎲 **Researchers are baffled.** @{contributor} opened a PR, got it reviewed without drama, "
|
||||
"and merged clean. This violates known laws of open source. 🔬"
|
||||
),
|
||||
(
|
||||
f"🌮 **@{contributor}'s PR:** showed up unannounced, improved everything, left zero bugs. "
|
||||
"Just like a perfect taco. 🌮"
|
||||
),
|
||||
(
|
||||
f"🐉 **Legend says** enough merged PRs and you ascend. "
|
||||
f"@{contributor} is dangerously close. 🌤️"
|
||||
),
|
||||
(
|
||||
f"🛸 **Aliens watching our repo** just upgraded @{contributor}'s threat level to: "
|
||||
"*do not engage — too competent*. 👽"
|
||||
),
|
||||
(
|
||||
f'🎻 **"The diff was clean, the tests did pass, the reviewer wept."** '
|
||||
f"That poem was about @{contributor}'s PR. 🥹"
|
||||
),
|
||||
(f"🍵 **@{contributor} made tea, opened a PR, and merged before it cooled.** No notes. ☕"),
|
||||
(
|
||||
f"🏄 **Some PRs rot in review for six weeks.** "
|
||||
f'@{contributor}\'s said "not today" and merged like it owned the place. 🌊'
|
||||
),
|
||||
(
|
||||
f"💼 **Interviewer:** describe a time you shipped something impactful.\n\n"
|
||||
f"**@{contributor}:** *points at this PR*\n\n"
|
||||
"**Interviewer:** you're hired. 🤝"
|
||||
),
|
||||
]
|
||||
|
||||
# GIFs are repo-hosted under .github/assets/celebrations/ so GitHub's own CDN serves them.
|
||||
_base = "https://raw.githubusercontent.com/Tracer-Cloud/opensre/main/.github/assets/celebrations"
|
||||
gif_blocks: list[str] = [
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
f"\n\n",
|
||||
]
|
||||
|
||||
head = random.choice(templates) + random.choice(gif_blocks)
|
||||
footer = (
|
||||
"---\n\n"
|
||||
f"👋 **Join us on [Discord - OpenSRE]({discord})** : hang out, contribute, "
|
||||
"or hunt for features and issues. Everyone's welcome."
|
||||
)
|
||||
body = f"{head}\n\n{footer}"
|
||||
|
||||
with open("comment.md", "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync Tracer-Cloud/homebrew-tap Formula/opensre.rb version and archive SHAs.
|
||||
#
|
||||
# Usage (CI):
|
||||
# HOMEBREW_TAP_GITHUB_TOKEN=... VERSION=2026.5.29 ASSET_DIR=release-assets ./sync-homebrew-tap-formula.sh
|
||||
#
|
||||
# Local dry-run (no push):
|
||||
# DRY_RUN=1 VERSION=2026.5.29 ASSET_DIR=/path/to/sha256-files ./sync-homebrew-tap-formula.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:-}"
|
||||
ASSET_DIR="${ASSET_DIR:-release-assets}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "VERSION is required (e.g. 2026.5.29)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for platform in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
|
||||
sha_file="${ASSET_DIR}/opensre_${VERSION}_${platform}.tar.gz.sha256"
|
||||
if [ ! -f "$sha_file" ]; then
|
||||
echo "Missing SHA256 file: $sha_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
linux_x64_sha="$(awk '{print $1}' "${ASSET_DIR}/opensre_${VERSION}_linux-x64.tar.gz.sha256")"
|
||||
linux_arm64_sha="$(awk '{print $1}' "${ASSET_DIR}/opensre_${VERSION}_linux-arm64.tar.gz.sha256")"
|
||||
darwin_x64_sha="$(awk '{print $1}' "${ASSET_DIR}/opensre_${VERSION}_darwin-x64.tar.gz.sha256")"
|
||||
darwin_arm64_sha="$(awk '{print $1}' "${ASSET_DIR}/opensre_${VERSION}_darwin-arm64.tar.gz.sha256")"
|
||||
|
||||
tap_dir="$(mktemp -d)/homebrew-tap"
|
||||
if [ -n "${HOMEBREW_TAP_GITHUB_TOKEN:-}" ]; then
|
||||
git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/Tracer-Cloud/homebrew-tap.git" "$tap_dir"
|
||||
else
|
||||
git clone "https://github.com/Tracer-Cloud/homebrew-tap.git" "$tap_dir"
|
||||
fi
|
||||
|
||||
python3 - "$tap_dir/Formula/opensre.rb" "$VERSION" "$darwin_arm64_sha" "$darwin_x64_sha" "$linux_arm64_sha" "$linux_x64_sha" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
formula_path = Path(sys.argv[1])
|
||||
version = sys.argv[2]
|
||||
darwin_arm64_sha = sys.argv[3]
|
||||
darwin_x64_sha = sys.argv[4]
|
||||
linux_arm64_sha = sys.argv[5]
|
||||
linux_x64_sha = sys.argv[6]
|
||||
|
||||
text = formula_path.read_text()
|
||||
text = re.sub(r'version "[^"]+"', f'version "{version}"', text, count=1)
|
||||
replacements = {
|
||||
r'(opensre_#\{version\}_darwin-arm64\.tar\.gz"\n\s*sha256 ")[^"]+(")': darwin_arm64_sha,
|
||||
r'(opensre_#\{version\}_darwin-x64\.tar\.gz"\n\s*sha256 ")[^"]+(")': darwin_x64_sha,
|
||||
r'(opensre_#\{version\}_linux-arm64\.tar\.gz"\n\s*sha256 ")[^"]+(")': linux_arm64_sha,
|
||||
r'(opensre_#\{version\}_linux-x64\.tar\.gz"\n\s*sha256 ")[^"]+(")': linux_x64_sha,
|
||||
}
|
||||
for pattern, sha in replacements.items():
|
||||
text, count = re.subn(pattern, rf'\g<1>{sha}\g<2>', text, count=1)
|
||||
if count != 1:
|
||||
raise SystemExit(f"Failed to update checksum with pattern: {pattern}")
|
||||
formula_path.write_text(text)
|
||||
PY
|
||||
|
||||
cd "$tap_dir"
|
||||
if git diff --quiet -- Formula/opensre.rb; then
|
||||
echo "Homebrew tap formula already up to date for version ${VERSION}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "=== Formula diff ==="
|
||||
git diff Formula/opensre.rb
|
||||
echo "===================="
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "DRY_RUN=1 — not committing or pushing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${HOMEBREW_TAP_GITHUB_TOKEN:-}" ]; then
|
||||
echo "HOMEBREW_TAP_GITHUB_TOKEN is required to push." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add Formula/opensre.rb
|
||||
git commit -m "chore: update opensre formula to ${VERSION}"
|
||||
git push origin HEAD:main
|
||||
echo "Pushed homebrew-tap update for version ${VERSION}."
|
||||
@@ -0,0 +1,17 @@
|
||||
# GitHub Actions (maintainer reference)
|
||||
|
||||
Internal notes for repository automation under `.github/workflows/`. Not published on the docs site.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Purpose |
|
||||
| -------- | ------- |
|
||||
| [`ci.yml`](ci.yml) | PR/push quality gates and sharded pytest |
|
||||
| [`ci-labels-windows.yml`](ci-labels-windows.yml) | Optional Windows CI (`ci:windows` label) |
|
||||
| [`codeql.yml`](codeql.yml) | CodeQL security analysis |
|
||||
| [`greptile-pr-reminder.yml`](greptile-pr-reminder.yml) | Greptile review nudge on PR open |
|
||||
| [`celebrate-merged-pr.yml`](celebrate-merged-pr.yml) | Post-merge celebration comment |
|
||||
| [`good-first-issue-assign.yml`](good-first-issue-assign.yml) | Auto-assign good first issues |
|
||||
| [`release.yml`](release.yml) | Release builds and artifacts |
|
||||
|
||||
See [CI.md](../../CI.md) for local parity commands before push.
|
||||
@@ -0,0 +1,176 @@
|
||||
name: Benchmark image — build + push to ECR (any adapter)
|
||||
|
||||
# Adapter-agnostic image build. The bench image carries the full
|
||||
# ``tests/benchmarks/`` tree, so every registered adapter ships in the
|
||||
# same image. ``benchmark-run.yml`` selects which adapter actually runs
|
||||
# via its ``config`` input. See ``tests/benchmarks/_framework/registry.py``
|
||||
# for the registration contract.
|
||||
|
||||
# Builds tests/benchmarks/cloudopsbench/infra/Dockerfile.bench and pushes the resulting image to the
|
||||
# opensre-bench ECR repository. The bench container is what
|
||||
# `Benchmark run (manual)` invokes on AWS Fargate, so this workflow's
|
||||
# output is the input to that one.
|
||||
#
|
||||
# Triggered automatically on changes to the bench code (or the Dockerfile
|
||||
# itself) and manually via workflow_dispatch.
|
||||
#
|
||||
# After the image is pushed, update the task definition by re-applying the
|
||||
# Terraform with the new tag:
|
||||
#
|
||||
# cd tests/benchmarks/cloudopsbench/infra
|
||||
# terraform apply -var="image_tag=<TAG>"
|
||||
#
|
||||
# (Or wire a follow-up step into this workflow to update task definition
|
||||
# automatically — out of scope for v1.)
|
||||
#
|
||||
# Tag format: short git SHA (`git rev-parse --short HEAD`). Stable, unique
|
||||
# per commit, recognizable in `aws ecr describe-images` output. ECR is
|
||||
# IMMUTABLE — a tag pushed once cannot be overwritten, so re-running the
|
||||
# workflow on the same commit just re-tags (no-op layer push).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Image tag to push (default = short git SHA)
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "tests/benchmarks/cloudopsbench/infra/Dockerfile.bench"
|
||||
- "tests/benchmarks/cloudopsbench/infra/Dockerfile.bench.dockerignore"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "surfaces/**"
|
||||
- "config/**"
|
||||
- "core/**"
|
||||
- "platform/deployment/**"
|
||||
- "integrations/**"
|
||||
- "platform/**"
|
||||
- "tools/**"
|
||||
- "tests/benchmarks/**"
|
||||
- ".github/workflows/benchmark-image.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # required for AWS OIDC role assumption
|
||||
|
||||
concurrency:
|
||||
# Allow one in-flight build per ref so two pushes in quick succession
|
||||
# don't race ECR. The newer push cancels the older.
|
||||
group: bench-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
# Account ID is repo-level configuration, not a secret. Set as a GitHub
|
||||
# repository Variable (Settings > Secrets and variables > Actions >
|
||||
# Variables) so it doesn't need to be edited in every workflow file
|
||||
# when the bench moves accounts.
|
||||
ACCOUNT_ID: ${{ vars.AWS_ACCOUNT_ID }}
|
||||
ECR_REPOSITORY: opensre-bench
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
name: build + push
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
# Fetch enough history that `git rev-parse --short HEAD` is stable
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
env:
|
||||
# Route `inputs.tag` through env so the shell treats it as DATA,
|
||||
# not code. Without this, a workflow_dispatch caller could inject
|
||||
# shell commands via a crafted `tag` value containing $() or
|
||||
# backticks. See:
|
||||
# https://securitylab.github.com/research/github-actions-untrusted-input/
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
TAG="$INPUT_TAG"
|
||||
else
|
||||
TAG="$(git rev-parse --short HEAD)"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Will push: $ECR_REPOSITORY:$TAG"
|
||||
|
||||
- name: Configure AWS credentials (OIDC role assumption)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/opensre-bench-github-actions
|
||||
role-session-name: github-actions-bench-image-${{ github.run_id }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: ecr-login
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
# Buildx adds multi-platform support + better cache control. Even
|
||||
# for single-platform builds, it's the modern default.
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: tests/benchmarks/cloudopsbench/infra/Dockerfile.bench
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.tag.outputs.tag }}
|
||||
# Stamp the COMMIT SHA into the image so the runtime can read it
|
||||
# via the OPENSRE_SHA env var. We use github.sha (the full 40-char
|
||||
# commit being built), NOT steps.tag.outputs.tag (which resolves
|
||||
# to the user-supplied inputs.tag like ``hotfix-june`` on
|
||||
# workflow_dispatch and would stamp an unverifiable string as if
|
||||
# it were a SHA). The image tag (for ECR naming) and OPENSRE_SHA
|
||||
# (for provenance) are intentionally decoupled — the tag is for
|
||||
# humans/operators; the SHA is for reproducibility. The runtime
|
||||
# gate also validates SHA shape (7-40 lowercase hex chars), so
|
||||
# even a manually-built image with a bad OPENSRE_SHA fails loudly.
|
||||
build-args: |
|
||||
OPENSRE_SHA=${{ github.sha }}
|
||||
# GitHub Actions cache for Docker layers - speeds up rebuilds
|
||||
# when only the source changes (deps stay in the cached layer).
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false # smaller manifest; matches ECR's tolerance for OCI
|
||||
|
||||
- name: Summarise
|
||||
# Route step outputs through env so the shell sees them as DATA,
|
||||
# not code. `steps.tag.outputs.tag` is derived verbatim from the
|
||||
# `inputs.tag` user input — without env scoping, a workflow_dispatch
|
||||
# caller could inject shell commands here even though the
|
||||
# "Resolve image tag" step is hardened. Same risk applies to any
|
||||
# tag-derived chain.
|
||||
env:
|
||||
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
echo "## Image pushed" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| field | value |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| --- | --- |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Registry | \`$REGISTRY\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Repository | \`$ECR_REPOSITORY\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Tag | \`$TAG\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Digest | \`$DIGEST\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "To deploy this image:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`\`\`bash" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "cd tests/benchmarks/cloudopsbench/infra" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "terraform apply -var=\"image_tag=$TAG\"" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,135 @@
|
||||
name: Benchmark image — promote tag to task definition (any adapter)
|
||||
|
||||
# Adapter-agnostic image promotion. Rebinds the ECS task definition to
|
||||
# a specific image tag. The image carries every registered adapter; the
|
||||
# config supplied to ``benchmark-run.yml`` picks which one runs.
|
||||
#
|
||||
# Manually-triggered workflow that runs `terraform apply -var=image_tag=<TAG>`
|
||||
# in tests/benchmarks/cloudopsbench/infra/ to register a new ECS task definition revision pointing at
|
||||
# the chosen ECR image. This is the privileged "deploy" step that comes
|
||||
# between an image push (automatic) and a bench run (manual).
|
||||
#
|
||||
# Why not auto-promote on every image push? An image build is a code-change
|
||||
# event. A task-def update is a deploy event. Decoupling them lets you
|
||||
# stage many images and choose deliberately which one production runs.
|
||||
#
|
||||
# Trigger from the GitHub UI:
|
||||
# Actions → "Benchmark image — promote tag to task definition" → Run
|
||||
#
|
||||
# Pre-reqs (one-time):
|
||||
# - tests/benchmarks/cloudopsbench/infra/ Terraform applied at least once. The opensre-bench-github-actions
|
||||
# OIDC role's permissions (ecs:RegisterTaskDefinition, state-bucket read/write,
|
||||
# lock-table read/write, iam:PassRole) are granted by the github_actions_run_bench
|
||||
# inline policy in tests/benchmarks/cloudopsbench/infra/iam_oidc.tf — any apply of that module attaches them.
|
||||
# - Repo secrets seeded into AWS Secrets Manager
|
||||
# - Repo vars set (AWS_ACCOUNT_ID etc., see tests/benchmarks/cloudopsbench/infra/AWS_BENCH_SETUP.md step 4)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: 'ECR image tag to promote (e.g. 3792493)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # required for AWS OIDC role assumption
|
||||
|
||||
concurrency:
|
||||
group: benchmark-promote-image
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
name: terraform apply image_tag=${{ inputs.image_tag }}
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
|
||||
steps:
|
||||
- name: Verify required repo variables
|
||||
env:
|
||||
AWS_ACCOUNT_ID: ${{ vars.AWS_ACCOUNT_ID }}
|
||||
run: |
|
||||
if [ -z "${AWS_ACCOUNT_ID:-}" ]; then
|
||||
echo "::error::Missing repo variable AWS_ACCOUNT_ID. See tests/benchmarks/cloudopsbench/infra/AWS_BENCH_SETUP.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Configure AWS credentials (OIDC role assumption)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/opensre-bench-github-actions
|
||||
role-session-name: bench-promote-${{ github.run_id }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Verify image tag exists in ECR
|
||||
# Fail loudly if the operator typos the tag, before Terraform tries
|
||||
# to register a task definition pointing at a missing image.
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
if ! aws ecr describe-images \
|
||||
--repository-name opensre-bench \
|
||||
--image-ids imageTag="$IMAGE_TAG" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "::error::Image tag $IMAGE_TAG not found in ECR repo opensre-bench."
|
||||
echo "::error::Push it first via 'Benchmark image — build + push to ECR'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: 1.7.5
|
||||
|
||||
- name: Terraform init
|
||||
working-directory: tests/benchmarks/cloudopsbench/infra
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform apply
|
||||
# Plan is captured in the workflow log; review it in the run page.
|
||||
# -auto-approve is intentional — this workflow IS the human approval
|
||||
# (the operator triggered it manually with a specific tag).
|
||||
#
|
||||
# -target=aws_ecs_task_definition.bench scopes apply to the task
|
||||
# definition (and its data-source / role dependencies) only. Without
|
||||
# this, every dispatch tries to reconcile every resource in the
|
||||
# module — IAM, S3, ECR, etc. — against whatever ref the workflow
|
||||
# was dispatched from. Out-of-band local applies cause that
|
||||
# reconciliation to attempt rollbacks the workflow role isn't
|
||||
# permitted to perform (iam:DetachRolePolicy, iam:PutRolePolicy),
|
||||
# failing the run even when the task-def update itself succeeded.
|
||||
working-directory: tests/benchmarks/cloudopsbench/infra
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
terraform apply -input=false -auto-approve \
|
||||
-target=aws_ecs_task_definition.bench \
|
||||
-var="image_tag=$IMAGE_TAG"
|
||||
|
||||
- name: Surface the new task definition revision in the job summary
|
||||
working-directory: tests/benchmarks/cloudopsbench/infra
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
TASK_DEF_ARN=$(terraform output -raw task_definition_arn)
|
||||
IMAGE_URI=$(aws ecs describe-task-definition \
|
||||
--task-definition "$TASK_DEF_ARN" \
|
||||
--query 'taskDefinition.containerDefinitions[0].image' \
|
||||
--output text)
|
||||
{
|
||||
echo "## Image promoted"
|
||||
echo ""
|
||||
echo "- Promoted tag: \`$IMAGE_TAG\`"
|
||||
echo "- New task definition ARN: \`$TASK_DEF_ARN\`"
|
||||
echo "- Image now in task definition: \`$IMAGE_URI\`"
|
||||
echo ""
|
||||
echo "### Next step"
|
||||
echo ""
|
||||
echo "Trigger **Benchmark — run on Fargate** to launch a run against this image."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Update Benchmark README Section
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/benchmarks/results.md"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-benchmark-readme:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Update README benchmark section
|
||||
run: uv run python -m tests.benchmarks.toolcall_model_benchmark.readme_updater
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config user.name "benchmark-readme-action"
|
||||
git config user.email "benchmark-readme-action@noreply.github.com"
|
||||
git add README.md
|
||||
git diff --staged --quiet || git commit -m "docs(benchmark): update README with latest benchmark results"
|
||||
git pull --rebase origin main
|
||||
git push
|
||||
@@ -0,0 +1,191 @@
|
||||
name: Benchmark — run on Fargate (any adapter)
|
||||
|
||||
# Manually-triggered benchmark run on AWS Fargate.
|
||||
#
|
||||
# Adapter-agnostic. This workflow does NOT know or care which benchmark
|
||||
# is running. The ``config`` input names a YAML file; the YAML names an
|
||||
# adapter (``benchmark: <name>``); the framework's registry resolves it
|
||||
# to a registered ``BenchmarkAdapter`` subclass. The same workflow runs
|
||||
# any adapter packaged into the bench image — CloudOpsBench today,
|
||||
# ToolCallBench / future adapters tomorrow, with zero changes
|
||||
# here. See ``tests/benchmarks/_framework/registry.py``.
|
||||
#
|
||||
# Why ECS RunTask instead of a GitHub-hosted ubuntu runner: a full bench
|
||||
# grid runs for hours and writes hundreds of MB of artifacts — Fargate
|
||||
# handles it cleanly, has AWS Secrets Manager wired in via tests/benchmarks/cloudopsbench/infra/,
|
||||
# and writes to the per-run S3 bucket. The workflow's job is just to
|
||||
# launch the task and print where to watch.
|
||||
#
|
||||
# Trigger from the GitHub UI:
|
||||
# Actions → "Benchmark run (manual)" → Run workflow → fill inputs
|
||||
#
|
||||
# Pre-reqs (one-time, see tests/benchmarks/cloudopsbench/infra/README.md):
|
||||
# - tests/benchmarks/cloudopsbench/infra/ Terraform applied
|
||||
# - Bench image pushed to ECR via benchmark-image.yml
|
||||
# - Repo secrets seeded into AWS Secrets Manager via benchmark-seed-secret.yml
|
||||
# - Repo variables set:
|
||||
# AWS_ACCOUNT_ID, BENCH_ECS_CLUSTER, BENCH_TASK_DEFINITION_FAMILY,
|
||||
# BENCH_SUBNET_IDS (comma-separated), BENCH_SECURITY_GROUP_ID
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
config:
|
||||
# No adapter-specific default — the operator must point at a
|
||||
# specific config. Leaving the field empty surfaces the choice
|
||||
# rather than silently dispatching the CloudOpsBench smoke. The
|
||||
# path is relative to the container's repo root. Examples:
|
||||
# tests/benchmarks/cloudopsbench/configs/cloudopsbench_smoke.yml
|
||||
# tests/benchmarks/<adapter>/configs/<config>.yml
|
||||
description: 'Path to YAML config inside the container (e.g. tests/benchmarks/<adapter>/configs/<config>.yml)'
|
||||
required: true
|
||||
dev_mode:
|
||||
description: 'Dev mode (skip integrity gates, no pre-reg needed)'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# Note: there is intentionally no `image_tag` input here. ECS RunTask
|
||||
# container overrides do NOT support overriding the image URI — that lives
|
||||
# on the task definition itself. The image actually pulled is whatever
|
||||
# was registered the last time `terraform apply -var=image_tag=<tag>` ran
|
||||
# in tests/benchmarks/cloudopsbench/infra/. Adding a workflow input here would silently mislead
|
||||
# operators into thinking they control the image per run. To change the
|
||||
# image, push it via `Benchmark image — build + push to ECR`, then re-apply
|
||||
# Terraform with the new tag.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # required for AWS OIDC role assumption
|
||||
|
||||
concurrency:
|
||||
group: benchmark-run
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
launch:
|
||||
name: launch ECS task
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15 # we only LAUNCH the task; we don't wait for it
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
|
||||
steps:
|
||||
- name: Verify required repo variables
|
||||
# Fail loudly BEFORE the AWS auth step if any required repo variable
|
||||
# is missing. Without this, an unset var would surface downstream as
|
||||
# an opaque error like "Task Definition can not be blank" from
|
||||
# describe-task-definition. See tests/benchmarks/cloudopsbench/infra/AWS_BENCH_SETUP.md step 4 for how
|
||||
# to set these.
|
||||
env:
|
||||
AWS_ACCOUNT_ID: ${{ vars.AWS_ACCOUNT_ID }}
|
||||
BENCH_ECS_CLUSTER: ${{ vars.BENCH_ECS_CLUSTER }}
|
||||
BENCH_TASK_DEFINITION_FAMILY: ${{ vars.BENCH_TASK_DEFINITION_FAMILY }}
|
||||
BENCH_SUBNET_IDS: ${{ vars.BENCH_SUBNET_IDS }}
|
||||
BENCH_SECURITY_GROUP_ID: ${{ vars.BENCH_SECURITY_GROUP_ID }}
|
||||
run: |
|
||||
missing=()
|
||||
for var in AWS_ACCOUNT_ID BENCH_ECS_CLUSTER BENCH_TASK_DEFINITION_FAMILY \
|
||||
BENCH_SUBNET_IDS BENCH_SECURITY_GROUP_ID; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
missing+=("$var")
|
||||
fi
|
||||
done
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "::error::Missing repo variable(s): ${missing[*]}"
|
||||
echo "::error::Set them under Settings → Secrets and variables → Actions → Variables."
|
||||
echo "::error::Values come from \`cd tests/benchmarks/cloudopsbench/infra && terraform output\` — see tests/benchmarks/cloudopsbench/infra/AWS_BENCH_SETUP.md step 4."
|
||||
exit 1
|
||||
fi
|
||||
echo "All 5 required repo variables are set."
|
||||
|
||||
- name: Configure AWS credentials (OIDC role assumption)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/opensre-bench-github-actions
|
||||
role-session-name: bench-run-${{ github.run_id }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Launch ECS RunTask
|
||||
# Inputs bound to env vars so the shell does the interpolation —
|
||||
# GitHub Actions template syntax (${{ ... }}) is expanded before
|
||||
# the shell sees it, which would let a crafted input inject shell
|
||||
# metacharacters. Env vars are quoted at use-site.
|
||||
env:
|
||||
BENCH_CONFIG: ${{ inputs.config }}
|
||||
BENCH_DEV_FLAG: ${{ inputs.dev_mode == true && '--dev' || '' }}
|
||||
CLUSTER: ${{ vars.BENCH_ECS_CLUSTER }}
|
||||
TASK_FAMILY: ${{ vars.BENCH_TASK_DEFINITION_FAMILY }}
|
||||
SUBNETS: ${{ vars.BENCH_SUBNET_IDS }}
|
||||
SECURITY_GROUP: ${{ vars.BENCH_SECURITY_GROUP_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Surface the image the task definition will actually pull, so
|
||||
# operators see the truth (not a workflow input that ECS ignores).
|
||||
IMAGE_URI=$(aws ecs describe-task-definition \
|
||||
--task-definition "$TASK_FAMILY" \
|
||||
--query 'taskDefinition.containerDefinitions[0].image' \
|
||||
--output text)
|
||||
|
||||
# Overrides JSON: container env vars passed at runtime. The
|
||||
# container's entrypoint reads BENCH_CONFIG + BENCH_DEV_FLAG
|
||||
# and invokes:
|
||||
# uv run python -m tests.benchmarks._framework.cli run \
|
||||
# "$BENCH_CONFIG" $BENCH_DEV_FLAG
|
||||
OVERRIDES=$(jq -nc \
|
||||
--arg config "$BENCH_CONFIG" \
|
||||
--arg dev_flag "$BENCH_DEV_FLAG" \
|
||||
'{
|
||||
containerOverrides: [{
|
||||
name: "bench",
|
||||
environment: [
|
||||
{name: "BENCH_CONFIG", value: $config},
|
||||
{name: "BENCH_DEV_FLAG", value: $dev_flag}
|
||||
]
|
||||
}]
|
||||
}')
|
||||
|
||||
TASK_ARN=$(aws ecs run-task \
|
||||
--cluster "$CLUSTER" \
|
||||
--task-definition "$TASK_FAMILY" \
|
||||
--launch-type FARGATE \
|
||||
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SECURITY_GROUP],assignPublicIp=ENABLED}" \
|
||||
--overrides "$OVERRIDES" \
|
||||
--query 'tasks[0].taskArn' \
|
||||
--output text)
|
||||
|
||||
if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then
|
||||
echo "::error::run-task returned no taskArn"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TASK_ID="${TASK_ARN##*/}"
|
||||
echo "task_arn=$TASK_ARN" >> "$GITHUB_OUTPUT"
|
||||
echo "task_id=$TASK_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Job summary — visible in the workflow run page
|
||||
{
|
||||
echo "## Bench run launched"
|
||||
echo ""
|
||||
echo "- Image (from task definition): \`$IMAGE_URI\`"
|
||||
echo "- Config: \`$BENCH_CONFIG\`"
|
||||
echo "- Dev mode: \`${BENCH_DEV_FLAG:-(off)}\`"
|
||||
echo "- Task ARN: \`$TASK_ARN\`"
|
||||
echo ""
|
||||
echo "### Watch it"
|
||||
echo ""
|
||||
echo "Stream logs locally:"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
echo "aws logs tail /ecs/opensre-bench --follow"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Or via AWS Console:"
|
||||
echo "https://us-east-1.console.aws.amazon.com/ecs/v2/clusters/$CLUSTER/tasks/$TASK_ID"
|
||||
echo ""
|
||||
echo "Artifacts land in the bench S3 bucket under \`runs/\` when the task completes."
|
||||
echo ""
|
||||
echo "_To change the running image: push a new tag via \`Benchmark image — build + push to ECR\`, then \`cd tests/benchmarks/cloudopsbench/infra && terraform apply -var=image_tag=<tag>\`._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,102 @@
|
||||
name: Benchmark secret — seed GitHub repo secret into AWS Secrets Manager
|
||||
|
||||
# Manually-triggered workflow that copies a GitHub repo secret into AWS
|
||||
# Secrets Manager at opensre-bench/llm/<secret>. The bench container reads
|
||||
# its LLM API keys from Secrets Manager at runtime; this workflow is how
|
||||
# you put them there without touching a developer laptop.
|
||||
#
|
||||
# The `secret` dropdown enforces which target is valid — must match one
|
||||
# of the four IAM-granted secret ARNs in tests/benchmarks/cloudopsbench/infra/iam_oidc.tf
|
||||
# (anthropic / openai / deepseek / hf_token).
|
||||
#
|
||||
# Why a workflow instead of a developer running `aws secretsmanager
|
||||
# put-secret-value` locally: keeps keys off developer laptops + out of
|
||||
# shell history, and centralizes rotation — rotate the value in the GH
|
||||
# repo secret and re-run this workflow to propagate.
|
||||
#
|
||||
# Pre-reqs:
|
||||
# - tests/benchmarks/cloudopsbench/infra/ Terraform has been applied (the secret resource exists)
|
||||
# - Corresponding GitHub repo secret is set:
|
||||
# ANTHROPIC_API_KEY / OPENAI_API_KEY / DEEPSEEK_API_KEY / HF_TOKEN
|
||||
#
|
||||
# Order of operations:
|
||||
# 1. terraform-bench.yml (plan) on PR
|
||||
# 2. terraform apply locally
|
||||
# 3. this workflow (workflow_dispatch) — pick the secret to seed
|
||||
# from the dropdown, repeat once per LLM provider key
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
secret:
|
||||
description: Which secret to seed (must match the AWS resource name)
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- anthropic_api_key
|
||||
- openai_api_key
|
||||
- deepseek_api_key
|
||||
- hf_token
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # required for AWS OIDC role assumption
|
||||
|
||||
concurrency:
|
||||
group: bench-seed-${{ inputs.secret }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
seed:
|
||||
name: seed ${{ inputs.secret }}
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
SECRET_ID: opensre-bench/llm/${{ inputs.secret }}
|
||||
|
||||
steps:
|
||||
- name: Configure AWS credentials (OIDC role assumption)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/opensre-bench-github-actions
|
||||
role-session-name: github-actions-seed-${{ inputs.secret }}-${{ github.run_id }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Verify secret resource exists
|
||||
run: |
|
||||
if ! aws secretsmanager describe-secret --secret-id "$SECRET_ID" >/dev/null 2>&1; then
|
||||
echo "::error::Secret $SECRET_ID not found. Run \`terraform apply\` in tests/benchmarks/cloudopsbench/infra/ first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Put secret value
|
||||
# All four candidate values are bound at workflow level. The case
|
||||
# statement picks the one matching the chosen target — unused
|
||||
# values stay as masked env vars (GH redacts secret refs in logs).
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
TARGET: ${{ inputs.secret }}
|
||||
run: |
|
||||
case "$TARGET" in
|
||||
anthropic_api_key) V="$ANTHROPIC_API_KEY" ;;
|
||||
openai_api_key) V="$OPENAI_API_KEY" ;;
|
||||
deepseek_api_key) V="$DEEPSEEK_API_KEY" ;;
|
||||
hf_token) V="$HF_TOKEN" ;;
|
||||
*)
|
||||
echo "::error::Unknown target: $TARGET (dropdown should have prevented this)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ -z "$V" ]; then
|
||||
echo "::error::GitHub repo secret for $TARGET is unset. Configure it under Settings > Secrets and variables > Actions."
|
||||
exit 1
|
||||
fi
|
||||
aws secretsmanager put-secret-value \
|
||||
--secret-id "$SECRET_ID" \
|
||||
--secret-string "$V" >/dev/null
|
||||
echo "Seeded $SECRET_ID (length=${#V})."
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Celebrate merged pull requests
|
||||
|
||||
# Runs after a PR lands (merged into the target branch). Uses pull_request_target so the
|
||||
# token can comment on merged PRs from forks; checkout is base/default only — no PR head.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
celebrate-merge:
|
||||
name: Post-merge celebration comment
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.user.type != 'Bot' &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build celebration comment
|
||||
id: celebrate
|
||||
env:
|
||||
DISCORD_INVITE_URL: https://discord.com/invite/opensre
|
||||
CONTRIBUTOR_LOGIN: ${{ github.event.pull_request.user.login }}
|
||||
run: python3 .github/scripts/merged_pr_celebration_message.py
|
||||
|
||||
- name: Comment on merged PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >
|
||||
gh pr comment "${{ github.event.pull_request.number }}"
|
||||
--repo "${{ github.repository }}"
|
||||
--body-file comment.md
|
||||
@@ -0,0 +1,109 @@
|
||||
# Optional Windows CI when PR label `ci:windows` is present.
|
||||
# Separate workflow + concurrency group so Windows label runs are isolated.
|
||||
|
||||
name: CI Labels (Windows)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
# Keep in sync with ci.yml — single source of truth for lint/typecheck/coverage targets.
|
||||
PYTHON_SOURCE_PATHS: "config core integrations platform surfaces tools"
|
||||
|
||||
concurrency:
|
||||
group: ci-labels-windows-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
windows-quality:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'ci:windows')
|
||||
name: windows quality
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Lint
|
||||
run: uv run python -m ruff check ${{ env.PYTHON_SOURCE_PATHS }} tests/
|
||||
|
||||
- name: Format check
|
||||
run: uv run python -m ruff format --check ${{ env.PYTHON_SOURCE_PATHS }} tests/
|
||||
|
||||
- name: Typecheck
|
||||
run: uv run python -m mypy ${{ env.PYTHON_SOURCE_PATHS }}
|
||||
|
||||
windows-test:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'ci:windows')
|
||||
name: windows test
|
||||
needs: [windows-quality]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
continue-on-error: true
|
||||
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
JWT_TOKEN: ${{ secrets.JWT_TOKEN }}
|
||||
TRACER_ORG_ID: ${{ secrets.TRACER_ORG_ID }}
|
||||
TRACER_WEB_APP_URL: ${{ secrets.TRACER_WEB_APP_URL }}
|
||||
GRAFANA_READ_TOKEN: ${{ secrets.GRAFANA_READ_TOKEN }}
|
||||
GCLOUD_HOSTED_METRICS_ID: ${{ secrets.GCLOUD_HOSTED_METRICS_ID }}
|
||||
GCLOUD_HOSTED_METRICS_URL: ${{ secrets.GCLOUD_HOSTED_METRICS_URL }}
|
||||
GCLOUD_HOSTED_LOGS_ID: ${{ secrets.GCLOUD_HOSTED_LOGS_ID }}
|
||||
GCLOUD_HOSTED_LOGS_URL: ${{ secrets.GCLOUD_HOSTED_LOGS_URL }}
|
||||
GCLOUD_RW_API_KEY: ${{ secrets.GCLOUD_RW_API_KEY }}
|
||||
GCLOUD_OTLP_ENDPOINT: ${{ secrets.GCLOUD_OTLP_ENDPOINT }}
|
||||
GCLOUD_OTLP_AUTH_HEADER: ${{ secrets.GCLOUD_OTLP_AUTH_HEADER }}
|
||||
PYTHONUTF8: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Run full tests
|
||||
run: >-
|
||||
uv run python -m pytest -n auto -v
|
||||
--cov=config --cov=core --cov=platform.deployment.aws --cov=platform.deployment --cov=integrations --cov=platform --cov=surfaces --cov=tools
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--ignore=tests/e2e/kubernetes_local_alert_simulation
|
||||
--ignore=tests/synthetic
|
||||
-m "not synthetic and not integration"
|
||||
@@ -0,0 +1,468 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "surfaces/**"
|
||||
- "config/**"
|
||||
- "core/**"
|
||||
- "platform/deployment/**"
|
||||
- "integrations/**"
|
||||
- "platform/**"
|
||||
- "tools/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "pytest.ini"
|
||||
- "ruff.toml"
|
||||
- "mypy.ini"
|
||||
- "Makefile"
|
||||
- ".importlinter"
|
||||
- ".importlinter.strict"
|
||||
- ".github/ci/**"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "surfaces/**"
|
||||
- "config/**"
|
||||
- "core/**"
|
||||
- "platform/deployment/**"
|
||||
- "integrations/**"
|
||||
- "platform/**"
|
||||
- "tools/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "pytest.ini"
|
||||
- "ruff.toml"
|
||||
- "mypy.ini"
|
||||
- "Makefile"
|
||||
- ".importlinter"
|
||||
- ".importlinter.strict"
|
||||
- ".github/ci/**"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PYTHON_SOURCE_PATHS: "config core integrations platform surfaces tools"
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: quality (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
continue-on-error: ${{ matrix.os == 'windows-latest' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Lint
|
||||
run: uv run python -m ruff check $PYTHON_SOURCE_PATHS tests/
|
||||
|
||||
- name: Format check
|
||||
run: uv run python -m ruff format --check $PYTHON_SOURCE_PATHS tests/
|
||||
|
||||
- name: Typecheck
|
||||
run: uv run python -m mypy $PYTHON_SOURCE_PATHS
|
||||
|
||||
- name: Import graph
|
||||
run: uv run python .github/ci/check_imports.py --strict
|
||||
|
||||
# Full layered contracts (.importlinter.strict), not the minimal default
|
||||
# .importlinter config used by ``make check-imports``.
|
||||
- name: Import boundaries
|
||||
run: uv run lint-imports --config .importlinter.strict
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.shard }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
shard: tools-runtime
|
||||
# ``tests/core/agent`` is excluded here; it runs in cli-runtime,
|
||||
# which pins openai for its live action-planning oracles.
|
||||
extra_pytest_args: >-
|
||||
--ignore=tests/core/agent
|
||||
pytest_paths: >-
|
||||
tests/tools
|
||||
tests/core
|
||||
tests/platform
|
||||
tests/utils
|
||||
tests/masking
|
||||
tests/watch_dog
|
||||
- os: ubuntu-latest
|
||||
shard: cli-runtime
|
||||
# Live shell action-agent contracts are validated on openai,
|
||||
# matching interactive-shell-live.yml. The
|
||||
# default anthropic tool-call model (haiku) cannot reliably perform
|
||||
# compound action planning, so pin this shard to openai.
|
||||
# ``tests/core/agent`` runs here because it contains the live
|
||||
# action-planning oracles (``test_live_action_planning`` and
|
||||
# ``test_live_turn_execution_oracle``) that need the openai pin —
|
||||
# plus the static ``test_import_boundaries`` guard the surfaces
|
||||
# restructure (#3299) added.
|
||||
llm_provider: openai
|
||||
pytest_paths: >-
|
||||
tests/cli
|
||||
tests/interactive_shell
|
||||
tests/agent
|
||||
tests/core/agent
|
||||
tests/fleet_monitoring
|
||||
tests/analytics
|
||||
tests/tools/investigation
|
||||
tests/delivery
|
||||
tests/sandbox
|
||||
tests/hermes
|
||||
- os: ubuntu-latest
|
||||
shard: integrations-and-misc
|
||||
pytest_paths: >-
|
||||
tests/integrations
|
||||
tests/deployment
|
||||
tests/benchmarks
|
||||
tests/chaos_engineering
|
||||
tests/shared
|
||||
tests/config
|
||||
tests/github_ci
|
||||
tests/packaging
|
||||
tests/scheduler
|
||||
gateway/tests
|
||||
- os: ubuntu-latest
|
||||
shard: e2e-general
|
||||
pytest_paths: >-
|
||||
tests/e2e
|
||||
extra_pytest_args: >-
|
||||
--ignore=tests/e2e/kubernetes
|
||||
--ignore=tests/e2e/openclaw
|
||||
--ignore=tests/e2e/upstream_lambda
|
||||
--ignore=tests/e2e/upstream_prefect_ecs_fargate
|
||||
--ignore=tests/e2e/upstream_apache_flink_ecs
|
||||
- os: ubuntu-latest
|
||||
shard: e2e-provider-and-openclaw
|
||||
pytest_paths: >-
|
||||
tests/e2e/openclaw
|
||||
tests/e2e/upstream_lambda
|
||||
tests/e2e/upstream_prefect_ecs_fargate
|
||||
tests/e2e/upstream_apache_flink_ecs
|
||||
tests/e2e/kubernetes
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
# Per-shard LLM provider for live_llm contracts; defaults to anthropic.
|
||||
LLM_PROVIDER: ${{ matrix.llm_provider || 'anthropic' }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
JWT_TOKEN: ${{ secrets.JWT_TOKEN }}
|
||||
GRAFANA_READ_TOKEN: ${{ secrets.GRAFANA_READ_TOKEN }}
|
||||
GCLOUD_HOSTED_METRICS_ID: ${{ secrets.GCLOUD_HOSTED_METRICS_ID }}
|
||||
GCLOUD_HOSTED_METRICS_URL: ${{ secrets.GCLOUD_HOSTED_METRICS_URL }}
|
||||
GCLOUD_HOSTED_LOGS_ID: ${{ secrets.GCLOUD_HOSTED_LOGS_ID }}
|
||||
GCLOUD_HOSTED_LOGS_URL: ${{ secrets.GCLOUD_HOSTED_LOGS_URL }}
|
||||
GCLOUD_RW_API_KEY: ${{ secrets.GCLOUD_RW_API_KEY }}
|
||||
GCLOUD_OTLP_ENDPOINT: ${{ secrets.GCLOUD_OTLP_ENDPOINT }}
|
||||
GCLOUD_OTLP_AUTH_HEADER: ${{ secrets.GCLOUD_OTLP_AUTH_HEADER }}
|
||||
PYTHONUTF8: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Validate pytest marker
|
||||
env:
|
||||
# Fork PRs do not receive repository secrets; skip live_llm contracts
|
||||
# (see interactive-shell-live.yml). Index a JSON string array so the
|
||||
# value is always a marker string — never a bare boolean from
|
||||
# ``a && 'x' || 'y'`` (boolean false → ``-m false`` → xdist
|
||||
# ``N workers [0 items]``, which can still exit 0).
|
||||
PYTEST_MARKER_EXPR: ${{ fromJSON('["not synthetic", "not (synthetic or live_llm)"]')[github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true] }}
|
||||
run: |
|
||||
echo "PYTEST_MARKER_EXPR=${PYTEST_MARKER_EXPR}"
|
||||
case "${PYTEST_MARKER_EXPR}" in
|
||||
"not synthetic"|"not (synthetic or live_llm)") ;;
|
||||
*)
|
||||
echo "Refusing unexpected PYTEST_MARKER_EXPR: '${PYTEST_MARKER_EXPR}'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
COVERAGE_FILE: .coverage.${{ matrix.shard }}
|
||||
PYTEST_MARKER_EXPR: ${{ fromJSON('["not synthetic", "not (synthetic or live_llm)"]')[github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true] }}
|
||||
# Keep ``run: >`` (folded) so ``matrix.pytest_paths`` stays one argv list.
|
||||
# A ``run: |`` + ``\`` rewrite can drop/split those paths and yield
|
||||
# ``N workers [0 items]`` even when the marker is correct.
|
||||
run: >
|
||||
uv run python -m pytest -n auto -v
|
||||
${{ matrix.pytest_paths }}
|
||||
--cov=config
|
||||
--cov=core
|
||||
--cov=platform.deployment.aws --cov=platform.deployment
|
||||
--cov=integrations
|
||||
--cov=platform
|
||||
--cov=surfaces
|
||||
--cov=tools
|
||||
--cov-report=
|
||||
--ignore=tests/e2e/kubernetes_local_alert_simulation
|
||||
--ignore=tests/synthetic
|
||||
${{ matrix.extra_pytest_args }}
|
||||
-m "${PYTEST_MARKER_EXPR}"
|
||||
|
||||
- name: Upload shard coverage data
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-data-${{ matrix.shard }}
|
||||
path: .coverage.${{ matrix.shard }}*
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
coverage-report:
|
||||
name: coverage-report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Download shard coverage artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-data-*
|
||||
path: ./
|
||||
merge-multiple: true
|
||||
|
||||
- name: Combine coverage and build report
|
||||
run: |
|
||||
uv run python -m coverage combine .
|
||||
uv run python -m coverage html
|
||||
|
||||
- name: Upload combined coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-ubuntu-latest
|
||||
path: |
|
||||
htmlcov/
|
||||
.coverage
|
||||
retention-days: 7
|
||||
|
||||
test-kubernetes:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
|
||||
if: >-
|
||||
github.repository == 'Tracer-Cloud/opensre' &&
|
||||
(github.event_name == 'push' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
(
|
||||
contains(github.event.pull_request.title, 'k8s') ||
|
||||
contains(github.event.pull_request.title, 'kubernetes')
|
||||
)
|
||||
))
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Run Kubernetes tests
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: uv run python -m tests.e2e.kubernetes.test_local
|
||||
|
||||
should-run-thorough:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'Tracer-Cloud/opensre'
|
||||
outputs:
|
||||
thorough: ${{ steps.filter.outputs.thorough }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
thorough:
|
||||
- ".github/workflows/ci.yml"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "platform/deployment/**"
|
||||
- "integrations/**"
|
||||
- "tools/investigation/**"
|
||||
- "tools/**"
|
||||
- "tests/e2e/cloudwatch_demo/**"
|
||||
- "tests/e2e/upstream_lambda/**"
|
||||
- "tests/e2e/upstream_prefect_ecs_fargate/**"
|
||||
- "tests/e2e/upstream_apache_flink_ecs/**"
|
||||
|
||||
test-thorough:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, should-run-thorough]
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.repository == 'Tracer-Cloud/opensre' &&
|
||||
needs.should-run-thorough.outputs.thorough == 'true'
|
||||
timeout-minutes: 30
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: cloudwatch-demo
|
||||
command: python3 -m tests.e2e.cloudwatch_demo.test_aws
|
||||
- name: upstream-lambda
|
||||
command: python3 -m tests.e2e.upstream_lambda.test_agent_e2e
|
||||
- name: prefect-ecs-fargate
|
||||
command: python3 -m tests.e2e.upstream_prefect_ecs_fargate.test_agent_e2e
|
||||
- name: flink-ecs
|
||||
command: python3 -m tests.e2e.upstream_apache_flink_ecs.test_agent_e2e
|
||||
|
||||
name: test-thorough (${{ matrix.name }})
|
||||
|
||||
env:
|
||||
# Thorough e2e suites make live LLM calls; run them on openai because the
|
||||
# default anthropic account is credit-exhausted. Requires a valid, funded
|
||||
# OPENAI_API_KEY secret.
|
||||
LLM_PROVIDER: openai
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
JWT_TOKEN: ${{ secrets.JWT_TOKEN }}
|
||||
TRACER_API_URL: ${{ secrets.TRACER_API_URL }}
|
||||
TRACER_INGEST_TOKEN: ${{ secrets.TRACER_INGEST_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: us-east-1
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
GRAFANA_READ_TOKEN: ${{ secrets.GRAFANA_READ_TOKEN }}
|
||||
GCLOUD_HOSTED_METRICS_ID: ${{ secrets.GCLOUD_HOSTED_METRICS_ID }}
|
||||
GCLOUD_HOSTED_METRICS_URL: ${{ secrets.GCLOUD_HOSTED_METRICS_URL }}
|
||||
GCLOUD_HOSTED_LOGS_ID: ${{ secrets.GCLOUD_HOSTED_LOGS_ID }}
|
||||
GCLOUD_HOSTED_LOGS_URL: ${{ secrets.GCLOUD_HOSTED_LOGS_URL }}
|
||||
GCLOUD_RW_API_KEY: ${{ secrets.GCLOUD_RW_API_KEY }}
|
||||
CLOUDWATCH_VERIFY_LOGS: ${{ matrix.name == 'cloudwatch-demo' && '0' || '1' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Install tracer CLI
|
||||
run: |
|
||||
curl -sSL https://install.tracer.cloud | sh -s user_36fbN6K6FwEgJFHsQv1pUByo8K6
|
||||
|
||||
- name: Initialize tracer (optional)
|
||||
run: |
|
||||
sudo tracer init --token ${{ secrets.JWT_TOKEN }}
|
||||
continue-on-error: true
|
||||
timeout-minutes: 2
|
||||
|
||||
- name: Run test
|
||||
run: uv run ${{ matrix.command }}
|
||||
timeout-minutes: 30
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Closed-loop learning (weekly miss triage)
|
||||
|
||||
# Weekly reminder workflow for the closed-loop learning process documented
|
||||
# in docs/closed-loop-learning.mdx. Runs at 09:00 UTC every Monday and
|
||||
# also supports manual triggering.
|
||||
#
|
||||
# The workflow does not consume the per-user ``~/.opensre/misses.jsonl``
|
||||
# store directly (that lives on engineer machines, not on the runner).
|
||||
# Its purpose is to:
|
||||
# 1) Open or refresh a tracking issue that nudges the on-call engineer
|
||||
# to run ``opensre misses stats --since 7d`` and then
|
||||
# ``opensre misses export --since 7d --top 10 --out tests/benchmarks/production_misses/``.
|
||||
# 2) Verify the ``misses_command`` CLI surface is still wired and exits
|
||||
# cleanly, so the weekly process is not blocked by a CLI regression.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Verify opensre misses CLI
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Smoke test misses CLI
|
||||
run: |
|
||||
uv run opensre misses --help
|
||||
uv run opensre misses list --json
|
||||
uv run opensre misses stats --json
|
||||
|
||||
remind:
|
||||
name: Open weekly triage reminder
|
||||
needs: smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Open weekly tracking issue (close last week's first)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// One issue per week. Previous week's tracker is closed before
|
||||
// opening this week's so triage history is per-week (GitHub's
|
||||
// natural unit) rather than one ever-growing comment thread.
|
||||
// Uses the existing "pending triage" label so we do not
|
||||
// auto-create a new label on first run.
|
||||
const week = new Date().toISOString().slice(0, 10);
|
||||
const titlePrefix = "Weekly closed-loop learning triage";
|
||||
const title = `${titlePrefix} — week of ${week}`;
|
||||
const body = [
|
||||
"Weekly nudge per docs/closed-loop-learning.mdx.",
|
||||
"",
|
||||
"Steps for this week's on-call:",
|
||||
"- [ ] `opensre misses stats --since 7d`",
|
||||
"- [ ] Review recurring `(alert, taxonomy)` pairs",
|
||||
"- [ ] `opensre misses export --since 7d --top 10 --out tests/benchmarks/production_misses/`",
|
||||
"- [ ] Open a PR labelled `benchmark` with the new scenarios",
|
||||
"- [ ] Trigger the benchmark workflow on the PR branch",
|
||||
].join("\n");
|
||||
|
||||
const { data: openIssues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
labels: "pending triage",
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const stale = openIssues.filter(
|
||||
(i) => i.title.startsWith(titlePrefix) && i.title !== title,
|
||||
);
|
||||
for (const issue of stale) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "completed",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = openIssues.find((i) => i.title === title);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ["pending triage"],
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# Skip CodeQL for docs-only PRs (Markdown/MDX and the docs site tree) to
|
||||
# save CI minutes. Not a required check, so skipping cannot block merge.
|
||||
# `push` to main and the weekly schedule keep full coverage regardless.
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '**/*.mdx'
|
||||
- 'docs/**'
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
name: Analyze (python)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: python
|
||||
build-mode: none
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: /language:python
|
||||
@@ -0,0 +1,148 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
schedule:
|
||||
- cron: "30 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to build (e.g. v0.1.2026.7.8). Defaults to the latest release."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: docker-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve release tag and image tags
|
||||
id: meta
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
DISPATCH_TAG: ${{ inputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
image="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
latest_tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
|
||||
|
||||
case "$EVENT_NAME" in
|
||||
release)
|
||||
if [ "$RELEASE_PRERELEASE" = "true" ]; then
|
||||
echo "Skipping prerelease $RELEASE_TAG (rolling main builds are not published to GHCR)."
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
tag="$RELEASE_TAG"
|
||||
;;
|
||||
workflow_dispatch)
|
||||
tag="${DISPATCH_TAG:-$latest_tag}"
|
||||
gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
;;
|
||||
*)
|
||||
tag="$latest_tag"
|
||||
;;
|
||||
esac
|
||||
|
||||
version="${tag#v}"
|
||||
|
||||
# The scheduled run rebuilds the newest release; skip it when that
|
||||
# version is already in the registry (e.g. the release event or a
|
||||
# dispatch already published it).
|
||||
if [ "$EVENT_NAME" = "schedule" ] \
|
||||
&& docker manifest inspect "${image}:${version}" >/dev/null 2>&1; then
|
||||
echo "Image ${image}:${version} already published; nothing to do."
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tags="${image}:${version}"
|
||||
if [ "$tag" = "$latest_tag" ]; then
|
||||
tags="${tags}"$'\n'"${image}:latest"
|
||||
else
|
||||
echo "Tag $tag is older than latest release $latest_tag; not moving :latest."
|
||||
fi
|
||||
|
||||
{
|
||||
echo "build=true"
|
||||
echo "tag=${tag}"
|
||||
echo "version=${version}"
|
||||
echo "image=${image}"
|
||||
echo "tags<<EOF"
|
||||
echo "$tags"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.meta.outputs.build == 'true'
|
||||
with:
|
||||
ref: ${{ steps.meta.outputs.tag }}
|
||||
|
||||
- name: Sync release version into pyproject.toml
|
||||
if: steps.meta.outputs.build == 'true'
|
||||
shell: bash
|
||||
run: python3 platform/packaging/sync_release_version.py --tag "${{ steps.meta.outputs.tag }}"
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.meta.outputs.build == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.meta.outputs.build == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push image
|
||||
if: steps.meta.outputs.build == 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=opensre
|
||||
org.opencontainers.image.description=OpenSRE unified image (MODE=web FastAPI health app, MODE=gateway Telegram gateway)
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Smoke test published image
|
||||
if: steps.meta.outputs.build == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
|
||||
docker pull "$image"
|
||||
version_output="$(docker run --rm --entrypoint opensre "$image" --version)"
|
||||
printf '%s\n' "$version_output"
|
||||
case "$version_output" in
|
||||
*"${{ steps.meta.outputs.version }}"*) ;;
|
||||
*)
|
||||
echo "Image version mismatch: expected ${{ steps.meta.outputs.version }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,186 @@
|
||||
# OpenClaw end-to-end CI.
|
||||
#
|
||||
# Triggers, in order of intent:
|
||||
# 1. PRs that touch OpenClaw-relevant paths (auto, primary signal)
|
||||
# 2. Pushes to main that touch the same paths (post-merge regression catch)
|
||||
# 3. Daily schedule at 06:00 UTC (catches environment drift —
|
||||
# OpenClaw version bumps, MCP SDK
|
||||
# breaks, npm install regressions)
|
||||
# 4. Adding the `ci:openclaw` label to a PR (manual re-run on matching PRs)
|
||||
# 5. Workflow_dispatch from the Actions UI (manual force-run on any ref,
|
||||
# no PR needed)
|
||||
#
|
||||
# The path filter at the trigger level is the primary gate, so non-OpenClaw PRs
|
||||
# pay no CI cost. The label is only useful as a re-trigger on PRs that already
|
||||
# matched paths; for force-running against an unrelated branch, use the manual
|
||||
# dispatch button in the Actions UI.
|
||||
|
||||
name: CI (OpenClaw E2E)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
paths:
|
||||
- "integrations/openclaw.py"
|
||||
- "tools/OpenClawMCPTool/**"
|
||||
- "tests/e2e/openclaw/**"
|
||||
- "tests/utils/alert_factory/**"
|
||||
- ".github/workflows/e2e-openclaw.yml"
|
||||
- "docs/openclaw.mdx"
|
||||
- ".tool-versions"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "integrations/openclaw.py"
|
||||
- "tools/OpenClawMCPTool/**"
|
||||
- "tests/e2e/openclaw/**"
|
||||
- "tests/utils/alert_factory/**"
|
||||
- ".github/workflows/e2e-openclaw.yml"
|
||||
- "docs/openclaw.mdx"
|
||||
- ".tool-versions"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: ci-openclaw-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Quality gates (lint / format-check / mypy) are run by the main
|
||||
# ``ci.yml`` workflow for every PR. We don't duplicate them here —
|
||||
# this workflow's sole job is the OpenClaw e2e suite. If you reach
|
||||
# this workflow, your PR has already cleared the quality gates.
|
||||
openclaw-test:
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
name: openclaw test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
# No ``env:`` block by design. CI runs the boot + use-case
|
||||
# scenarios only — the full-RCA sub-tests call an LLM, cost API
|
||||
# credits, and take ~25s each, so we deliberately don't wire any
|
||||
# LLM secret in. They auto-skip via
|
||||
# ``LLM_CREDENTIAL_SKIP_REASON`` when no key is present in env.
|
||||
# Contributors with ANTHROPIC_API_KEY / OPENAI_API_KEY /
|
||||
# GEMINI_API_KEY set locally still run them via
|
||||
# ``make test-openclaw``. Lang Smith env vars stay scoped to the
|
||||
# local-RCA path for the same reason — no value tracing the
|
||||
# use-case-only CI runs.
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node (OpenClaw requires >=22.12; version pinned in .tool-versions)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".tool-versions"
|
||||
|
||||
- name: Cache npm downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-openclaw-npm-${{ hashFiles('.tool-versions') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-openclaw-npm-
|
||||
|
||||
- name: Install OpenClaw CLI
|
||||
# ``continue-on-error: true`` so an npm-registry hiccup, a package-name
|
||||
# rename on OpenClaw's side, or a network blip does not kill the whole
|
||||
# workflow. The "Probe OpenClaw availability" step below catches the
|
||||
# failure and downgrades the run to "tests skipped" with a clear
|
||||
# explanation rather than crashing.
|
||||
continue-on-error: true
|
||||
id: install_openclaw
|
||||
run: npm install -g openclaw --prefer-offline --no-audit
|
||||
|
||||
- name: Probe OpenClaw availability
|
||||
# Whether or not ``npm install`` succeeded, check if ``openclaw`` is
|
||||
# actually runnable. Failure modes we surface explicitly:
|
||||
# - CLI not installed (npm step failed silently)
|
||||
# - CLI installed but Node version mismatch (older runner image)
|
||||
# - CLI installed but the bundled JS errored on first run
|
||||
# ``openclaw_runnable`` env var feeds the next step's gating.
|
||||
run: |
|
||||
set +e
|
||||
if ! command -v openclaw >/dev/null 2>&1; then
|
||||
echo "::warning::openclaw CLI is not on PATH (npm install may have failed); the e2e suite will be skipped this run."
|
||||
echo "openclaw_runnable=false" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
version_output="$(openclaw --version 2>&1)"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "::warning::openclaw is on PATH but errors when run:"
|
||||
echo "$version_output"
|
||||
echo "::warning::Skipping e2e suite this run."
|
||||
echo "openclaw_runnable=false" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
echo "✓ OpenClaw verified: $version_output"
|
||||
echo "openclaw_runnable=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Note LLM credential policy
|
||||
# CI deliberately does not wire any LLM key, so the full-RCA
|
||||
# sub-tests skip every run. This step is informational — it
|
||||
# makes the policy obvious in the run log instead of leaving a
|
||||
# silent skip that a reviewer has to dig for. Run RCA locally
|
||||
# with ``make test-openclaw`` if you have a key configured.
|
||||
run: |
|
||||
echo "ℹ︎ CI policy: full-RCA sub-tests are skipped (LLM credentials intentionally not wired)."
|
||||
echo " Boot + use-case sub-tests still run and gate the merge."
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Run OpenClaw E2E suite
|
||||
# Skips with a clear message if the OpenClaw probe failed earlier,
|
||||
# so the workflow finishes green with a documented "couldn't run"
|
||||
# status rather than a confusing red on the install step.
|
||||
run: |
|
||||
if [ "$openclaw_runnable" != "true" ]; then
|
||||
echo "::warning::Skipping pytest invocation — openclaw CLI unavailable in this environment. See earlier warnings."
|
||||
exit 0
|
||||
fi
|
||||
uv run python -m pytest -m e2e -v tests/e2e/openclaw/
|
||||
|
||||
- name: Upload gateway logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openclaw-gateway-logs
|
||||
path: /tmp/openclaw-e2e-logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Summary
|
||||
# Always runs, regardless of earlier step outcomes. The probe
|
||||
# step above downgrades a missing-CLI / wrong-Node environment
|
||||
# to "tests skipped" rather than failing the workflow, so this
|
||||
# summary is the one place a reviewer sees why fewer tests ran
|
||||
# than expected. Real pytest failures still fail the job.
|
||||
if: always()
|
||||
run: |
|
||||
echo "### OpenClaw E2E run summary"
|
||||
echo " OpenClaw runnable: ${openclaw_runnable:-unknown}"
|
||||
echo " Full-RCA sub-tests: skipped by CI policy (no LLM key wired)"
|
||||
echo " Run RCA locally with: make test-openclaw"
|
||||
@@ -0,0 +1,52 @@
|
||||
# When someone with zero merged PRs in this repo (and not OWNER/MEMBER/COLLABORATOR) comments
|
||||
# on an open issue labeled `good first issue`, assign them and reply — only if the issue has no
|
||||
# assignees yet (first eligible commenter wins; includes when a human pre-assigned someone).
|
||||
# issue_comment also fires for PR review threads; those payloads include issue.pull_request.
|
||||
name: Good first issue — auto-assign
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: good-first-issue-assign-${{ github.repository }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
assign:
|
||||
name: Assign new contributor on good first issue
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.issue.state == 'open' &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
!github.event.issue.pull_request
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Decide assign + notice body
|
||||
id: decide
|
||||
env:
|
||||
GITHUB_EVENT_PATH: ${{ github.event_path }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 .github/scripts/good_first_issue_assign.py
|
||||
|
||||
- name: Add assignee and notify
|
||||
if: steps.decide.outputs.should_assign == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REPO_FULL: ${{ github.repository }}
|
||||
COMMENTER_LOGIN: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
gh issue edit "${ISSUE_NUMBER}" \
|
||||
--repo "${REPO_FULL}" \
|
||||
--add-assignee "${COMMENTER_LOGIN}"
|
||||
gh issue comment "${ISSUE_NUMBER}" \
|
||||
--repo "${REPO_FULL}" \
|
||||
--body-file assign_comment.md
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Greptile PR reminder
|
||||
|
||||
# One-time nudge when a PR is opened or reopened. Uses pull_request_target so the token can
|
||||
# comment on PRs from forks. Does not checkout the PR head — only posts a static reminder (see
|
||||
# SECURITY: pull_request_target guidance in GitHub Actions docs).
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remind:
|
||||
name: Post Greptile review reminder
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.user.type != 'Bot'
|
||||
steps:
|
||||
- name: Comment with Greptile guidance
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="${BASE_REF}"
|
||||
contributing_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/${base_ref}/CONTRIBUTING.md#greptile-code-review"
|
||||
pr="${PR_NUMBER}"
|
||||
repo="${GITHUB_REPOSITORY}"
|
||||
{
|
||||
echo '### Greptile code review'
|
||||
echo ''
|
||||
echo "This repo uses **Greptile** for automated review. Before merge, aim for **Confidence Score: 5/5** with **zero unresolved** review threads — see [CONTRIBUTING.md](${contributing_url})."
|
||||
echo ''
|
||||
echo '**Run a review** — add a PR comment with:'
|
||||
echo ''
|
||||
echo '```'
|
||||
echo '@greptile review'
|
||||
echo '```'
|
||||
echo ''
|
||||
echo 'Give it **~5-10 minutes** (sometimes longer) for results, then fix feedback and re-trigger until you reach **Confidence Score: 5/5**.'
|
||||
echo ''
|
||||
echo '**Optional:** automate with the [greploop skill](https://skills.sh/greptileai/skills/greploop).'
|
||||
} > comment.md
|
||||
gh pr comment "$pr" --repo "$repo" --body-file comment.md
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Hermes Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "tests/synthetic/hermes_rca/**"
|
||||
- "tests/e2e/hermes/**"
|
||||
- "tests/synthetic/mock_hermes_backend/**"
|
||||
- "tools/HermesSessionEvidenceTool/**"
|
||||
- ".github/workflows/hermes-tests.yml"
|
||||
- "Makefile"
|
||||
- "pytest.ini"
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
synthetic:
|
||||
name: Hermes synthetic offline
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Run Hermes synthetic tests
|
||||
run: uv run pytest tests/synthetic/hermes_rca -q
|
||||
|
||||
- name: Run Hermes offline suite
|
||||
run: uv run python -m tests.synthetic.hermes_rca.run_suite --offline-only
|
||||
|
||||
e2e:
|
||||
name: Hermes e2e/meta
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Run Hermes e2e/meta tests
|
||||
run: uv run pytest tests/e2e/hermes -q
|
||||
@@ -0,0 +1,160 @@
|
||||
name: Interactive Shell Live (PR + post-merge)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "core/agent/**"
|
||||
- "core/agent_harness/**"
|
||||
- "tests/core/agent/**"
|
||||
- "surfaces/interactive_shell/**"
|
||||
- "core/runtime/**"
|
||||
- "tools/**"
|
||||
- "integrations/**"
|
||||
- "pytest.ini"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/interactive-shell-live.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "core/agent/**"
|
||||
- "core/agent_harness/**"
|
||||
- "tests/core/agent/**"
|
||||
- "surfaces/interactive_shell/**"
|
||||
- "core/runtime/**"
|
||||
- "tools/**"
|
||||
- "integrations/**"
|
||||
- "pytest.ini"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/interactive-shell-live.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
shard_indexes:
|
||||
description: "Comma-separated shard indexes to run (0-7)"
|
||||
required: false
|
||||
default: "0,1,2,3,4,5,6,7"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: interactive-shell-live-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
turn-checks:
|
||||
name: turn-checks (no-LLM)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
OPENSRE_DISABLE_KEYRING: "1"
|
||||
PYTHONUTF8: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Deterministic turn + fixture integrity
|
||||
run: >-
|
||||
uv run python -m pytest -n auto -v
|
||||
tests/core/agent/
|
||||
-m "not live_llm"
|
||||
|
||||
turn-live:
|
||||
# Run on push to main, manual dispatch, and same-repo PRs. Fork PRs have no
|
||||
# secrets, so they skip the live job (the no-LLM turn-checks job above
|
||||
# still gates them) instead of failing the credential-validation step.
|
||||
if: >-
|
||||
github.repository == 'Tracer-Cloud/opensre' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
|
||||
name: turn-live shard ${{ matrix.shard_index }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard_index: ${{ fromJSON(github.event_name == 'workflow_dispatch' && format('[{0}]', github.event.inputs.shard_indexes) || '[0, 1, 2, 3, 4, 5, 6, 7]') }}
|
||||
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
LLM_PROVIDER: openai
|
||||
OPENSRE_DISABLE_KEYRING: "1"
|
||||
TURN_SHARD_TOTAL: "8"
|
||||
TURN_SHARD_INDEX: ${{ matrix.shard_index }}
|
||||
PYTHONUTF8: "1"
|
||||
# @live gather scenarios (316, 333–335, 337) require these secrets in CI.
|
||||
# Missing values fail the job in preflight and via skip_or_fail in tests.
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG_SLUG: tracer-30
|
||||
SENTRY_PROJECT_SLUG: python
|
||||
SENTRY_URL: https://sentry.io
|
||||
DD_API_KEY: ${{ secrets.DD_API_KEY }}
|
||||
DD_APP_KEY: ${{ secrets.DD_APP_KEY }}
|
||||
DD_SITE: datadoghq.com
|
||||
GRAFANA_READ_TOKEN: ${{ secrets.GRAFANA_READ_TOKEN }}
|
||||
GRAFANA_INSTANCE_URL: ${{ secrets.GRAFANA_INSTANCE_URL }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Validate live turn credentials
|
||||
run: |
|
||||
missing=()
|
||||
if [ -z "${OPENAI_API_KEY}" ]; then
|
||||
missing+=(OPENAI_API_KEY)
|
||||
fi
|
||||
if [ -z "${SENTRY_AUTH_TOKEN}" ]; then
|
||||
missing+=(SENTRY_AUTH_TOKEN)
|
||||
fi
|
||||
if [ -z "${DD_API_KEY}" ]; then
|
||||
missing+=(DD_API_KEY)
|
||||
fi
|
||||
if [ -z "${DD_APP_KEY}" ]; then
|
||||
missing+=(DD_APP_KEY)
|
||||
fi
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "::error::Missing secrets for live turn tests: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run sharded live turn suite
|
||||
run: >-
|
||||
uv run python -m pytest -n auto -v
|
||||
-m live_llm
|
||||
tests/core/agent/test_turn_scenarios.py
|
||||
-k "test_live_turn_execution_oracle or test_live_action_planning"
|
||||
@@ -0,0 +1,678 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "tests/benchmarks/cloudopsbench/infra/**"
|
||||
- "platform/deployment/install-proxy/**"
|
||||
- "tests/e2e/kubernetes/helm/scripts/**"
|
||||
- "tests/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- ".claude/**"
|
||||
schedule:
|
||||
- cron: "30 0 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Release channel to publish."
|
||||
required: false
|
||||
default: release
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- main
|
||||
tag:
|
||||
description: "Optional tag to release (e.g. v0.1.2026.6.26 or v0.1). Ignored for the main channel."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
models: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event_name == 'push' && 'main' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'main' && 'main') || 'stable' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
channel: ${{ steps.meta.outputs.channel }}
|
||||
tag_name: ${{ steps.meta.outputs.tag_name }}
|
||||
version_name: ${{ steps.meta.outputs.version_name }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release metadata
|
||||
id: meta
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
DISPATCH_CHANNEL: ${{ inputs.channel }}
|
||||
DISPATCH_TAG: ${{ inputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
channel="release"
|
||||
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
channel="main"
|
||||
fi
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$DISPATCH_CHANNEL" = "main" ]; then
|
||||
channel="main"
|
||||
fi
|
||||
|
||||
if [ "$channel" = "main" ]; then
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$DISPATCH_TAG" ]; then
|
||||
echo "The main channel does not accept a custom tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
year="$(date -u +%Y)"
|
||||
month="$(date -u +%-m)"
|
||||
day="$(date -u +%-d)"
|
||||
short_sha="${GITHUB_SHA:0:7}"
|
||||
main_version="0.1.${year}.${month}.${day}+main.${short_sha}"
|
||||
|
||||
echo "channel=main" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=main-build" >> "$GITHUB_OUTPUT"
|
||||
echo "version_name=${main_version}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$DISPATCH_TAG" ]; then
|
||||
tag_name="$DISPATCH_TAG"
|
||||
else
|
||||
year="$(date -u +%Y)"
|
||||
month="$(date -u +%-m)"
|
||||
day="$(date -u +%-d)"
|
||||
# Keep the v0.1 prefix so the tag makes clear the product is still
|
||||
# v0.1, while the date stays useful (e.g. v0.1.2026.6.26).
|
||||
tag_name="v0.1.${year}.${month}.${day}"
|
||||
fi
|
||||
|
||||
echo "channel=release" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=${tag_name}" >> "$GITHUB_OUTPUT"
|
||||
echo "version_name=${tag_name#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare
|
||||
if: github.event_name != 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
- name: Type check
|
||||
run: make typecheck
|
||||
|
||||
- name: CLI smoke tests
|
||||
run: make test-cli-smoke
|
||||
|
||||
build-python-dist:
|
||||
if: needs.prepare.outputs.channel == 'release' && needs.verify.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [verify, prepare]
|
||||
env:
|
||||
TAG_NAME: ${{ needs.prepare.outputs.tag_name }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Sync release version
|
||||
shell: bash
|
||||
run: python platform/packaging/sync_release_version.py --tag "$TAG_NAME"
|
||||
|
||||
- name: Build Python distributions
|
||||
run: |
|
||||
uv sync --frozen --extra release-dist
|
||||
uv run python -m build
|
||||
uv run twine check dist/*
|
||||
|
||||
- name: Verify Python distribution version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${TAG_NAME#v}"
|
||||
test -f "dist/opensre-${VERSION}.tar.gz"
|
||||
test -f "dist/opensre-${VERSION}-py3-none-any.whl"
|
||||
|
||||
- name: Upload Python distributions
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-python-dist
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-binaries:
|
||||
if: always() && (needs.prepare.outputs.channel == 'main' || needs.verify.result == 'success')
|
||||
needs: [verify, prepare]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-22.04
|
||||
target: linux-x64
|
||||
binary_name: opensre
|
||||
archive_ext: tar.gz
|
||||
pyinstaller_mode: onedir
|
||||
- runner: ubuntu-22.04-arm
|
||||
target: linux-arm64
|
||||
binary_name: opensre
|
||||
archive_ext: tar.gz
|
||||
pyinstaller_mode: onedir
|
||||
- runner: macos-15-intel
|
||||
target: darwin-x64
|
||||
binary_name: opensre
|
||||
archive_ext: tar.gz
|
||||
pyinstaller_mode: onedir
|
||||
- runner: macos-latest
|
||||
target: darwin-arm64
|
||||
binary_name: opensre
|
||||
archive_ext: tar.gz
|
||||
pyinstaller_mode: onedir
|
||||
- runner: windows-latest
|
||||
target: windows-x64
|
||||
binary_name: opensre.exe
|
||||
archive_ext: zip
|
||||
pyinstaller_mode: onefile
|
||||
# windows-arm64 is currently excluded from the default release matrix:
|
||||
# cryptography does not publish win_arm64 wheels, so dependency install
|
||||
# falls back to a source build that requires an OpenSSL toolchain on the
|
||||
# GitHub-hosted runner.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }}
|
||||
TAG_NAME: ${{ needs.prepare.outputs.tag_name }}
|
||||
VERSION_NAME: ${{ needs.prepare.outputs.version_name }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Sync binary version
|
||||
shell: bash
|
||||
run: python platform/packaging/sync_release_version.py --version "$VERSION_NAME"
|
||||
|
||||
- name: Install binary build dependencies
|
||||
shell: bash
|
||||
run: uv sync --frozen --extra release-binary
|
||||
|
||||
- name: Stage stdlib platform module for bundling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The first-party ``platform`` package shadows the stdlib ``platform``
|
||||
# module. PyInstaller does not lay out the stdlib as loose ``.py`` files,
|
||||
# so bundle a copy of the genuine module that platform/__init__.py can
|
||||
# load from ``sys._MEIPASS`` at runtime (see _FROZEN_STDLIB_DIR).
|
||||
rm -rf .stdlib_vendor
|
||||
mkdir -p .stdlib_vendor
|
||||
uv run python -c "import os, shutil, sysconfig; src = os.path.join(sysconfig.get_path('stdlib'), 'platform.py'); shutil.copy(src, os.path.join('.stdlib_vendor', 'platform.py')); print('Staged stdlib platform from ' + src)"
|
||||
|
||||
- name: Build binary
|
||||
run: >-
|
||||
uv run pyinstaller surfaces/cli/__main__.py
|
||||
--name opensre
|
||||
--${{ matrix.pyinstaller_mode }}
|
||||
--clean
|
||||
--noconfirm
|
||||
--collect-data surfaces.cli
|
||||
--collect-data config
|
||||
--copy-metadata opensre
|
||||
--collect-data litellm
|
||||
--hidden-import tiktoken_ext
|
||||
--hidden-import tiktoken_ext.openai_public
|
||||
--collect-submodules integrations
|
||||
--collect-submodules surfaces.interactive_shell
|
||||
--collect-submodules tools
|
||||
--add-data "platform:platform"
|
||||
--add-data ".stdlib_vendor:_opensre_stdlib_platform"
|
||||
|
||||
- name: Smoke test binary (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Capture the exit status explicitly so a crashing binary still prints
|
||||
# its stdout/stderr (under `set -e` the failed substitution aborted the
|
||||
# step before the output was ever shown, hiding the real error).
|
||||
# onefile builds place the executable at ./dist/<name>; onedir builds
|
||||
# produce a ./dist/opensre/ directory containing the executable. A bare
|
||||
# `-x` test matches the onedir directory too (dirs carry the search
|
||||
# bit), so require a regular file before treating it as the binary.
|
||||
if [ -f "./dist/${{ matrix.binary_name }}" ] && [ -x "./dist/${{ matrix.binary_name }}" ]; then
|
||||
BINARY_PATH="./dist/${{ matrix.binary_name }}"
|
||||
else
|
||||
BINARY_PATH="./dist/opensre/${{ matrix.binary_name }}"
|
||||
fi
|
||||
set +e
|
||||
VERSION_OUTPUT="$("$BINARY_PATH" --version 2>&1)"
|
||||
VERSION_STATUS=$?
|
||||
set -e
|
||||
printf '%s\n' "$VERSION_OUTPUT"
|
||||
if [ "$VERSION_STATUS" -ne 0 ]; then
|
||||
printf '::error::%s --version exited with status %s\n' "${{ matrix.binary_name }}" "$VERSION_STATUS" >&2
|
||||
exit "$VERSION_STATUS"
|
||||
fi
|
||||
case "$VERSION_OUTPUT" in
|
||||
*"$VERSION_NAME"*) ;;
|
||||
*)
|
||||
printf 'Binary version mismatch: expected %s but saw %s\n' "$VERSION_NAME" "$VERSION_OUTPUT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
"$BINARY_PATH" -h >/dev/null
|
||||
LITELLM_DATA="./dist/opensre/_internal/litellm/model_prices_and_context_window_backup.json"
|
||||
if [ ! -f "$LITELLM_DATA" ]; then
|
||||
echo "LiteLLM package data missing from Unix onedir bundle: ${LITELLM_DATA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check Linux binary glibc compatibility
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
max_glibc="$(
|
||||
find dist/opensre -type f \( -name opensre -o -name '*.so' -o -name '*.so.*' \) -print0 \
|
||||
| xargs -0 strings 2>/dev/null \
|
||||
| grep -Eo 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \
|
||||
| sort -Vu \
|
||||
| tail -n 1 \
|
||||
|| true
|
||||
)"
|
||||
echo "Max required glibc symbol: ${max_glibc:-none}"
|
||||
if [ -n "$max_glibc" ] && [ "$(printf '%s\n' "$max_glibc" GLIBC_2.35 | sort -V | tail -n 1)" != "GLIBC_2.35" ]; then
|
||||
echo "Linux binary requires ${max_glibc}; pin the Linux runner back to Ubuntu 22.04 or lower dependency wheel requirements." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Smoke test binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$versionOutput = & ".\dist\${{ matrix.binary_name }}" --version 2>&1 | Out-String
|
||||
$versionText = $versionOutput.Trim()
|
||||
Write-Host $versionText
|
||||
$expectedVersion = $env:VERSION_NAME
|
||||
if ($versionText -notmatch [regex]::Escape($expectedVersion)) {
|
||||
throw "Binary version mismatch. Expected '$expectedVersion' but saw '$versionText'."
|
||||
}
|
||||
& ".\dist\${{ matrix.binary_name }}" -h | Out-Null
|
||||
|
||||
- name: Package binary archive (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$RELEASE_CHANNEL" = "release" ]; then
|
||||
ASSET_BASENAME="opensre_${TAG_NAME#v}_${{ matrix.target }}"
|
||||
else
|
||||
ASSET_BASENAME="opensre_main_${{ matrix.target }}"
|
||||
fi
|
||||
tar -C dist -czf "${ASSET_BASENAME}.tar.gz" "${{ matrix.binary_name }}"
|
||||
shasum -a 256 "${ASSET_BASENAME}.tar.gz" > "${ASSET_BASENAME}.tar.gz.sha256"
|
||||
|
||||
- name: Package binary archive (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:RELEASE_CHANNEL -eq "release") {
|
||||
$assetBaseName = "opensre_$($env:TAG_NAME.TrimStart('v'))_${{ matrix.target }}"
|
||||
} else {
|
||||
$assetBaseName = "opensre_main_${{ matrix.target }}"
|
||||
}
|
||||
Compress-Archive -Path "dist\${{ matrix.binary_name }}" -DestinationPath "${assetBaseName}.zip"
|
||||
$hash = (Get-FileHash -Algorithm SHA256 "${assetBaseName}.zip").Hash.ToLowerInvariant()
|
||||
Set-Content -Path "${assetBaseName}.zip.sha256" -Value "$hash ${assetBaseName}.zip"
|
||||
|
||||
- name: Upload binary archive
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.RELEASE_CHANNEL == 'main' && 'main-' || '' }}release-${{ matrix.target }}
|
||||
path: |
|
||||
opensre_*_${{ matrix.target }}.${{ matrix.archive_ext }}
|
||||
opensre_*_${{ matrix.target }}.${{ matrix.archive_ext }}.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
publish-release:
|
||||
if: needs.prepare.outputs.channel == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prepare
|
||||
- build-python-dist
|
||||
- build-binaries
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve release context
|
||||
id: release_ctx
|
||||
env:
|
||||
TAG_NAME: ${{ needs.prepare.outputs.tag_name }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
default_branch="${{ github.event.repository.default_branch }}"
|
||||
git fetch origin "$default_branch" --tags --force
|
||||
target_sha="$(git rev-parse "origin/$default_branch")"
|
||||
|
||||
previous_tag="$(
|
||||
git tag --list 'v0.1.[0-9][0-9][0-9][0-9].[0-9]*.[0-9]*' --sort=-v:refname \
|
||||
| grep -v -x "$TAG_NAME" \
|
||||
| head -n 1 \
|
||||
|| true
|
||||
)"
|
||||
|
||||
range_spec="$target_sha"
|
||||
if [ -n "$previous_tag" ]; then
|
||||
range_spec="${previous_tag}..${target_sha}"
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'target_sha=%s\n' "$target_sha"
|
||||
printf 'previous_tag=%s\n' "$previous_tag"
|
||||
printf 'range_spec=%s\n' "$range_spec"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release notes
|
||||
env:
|
||||
TAG_NAME: ${{ needs.prepare.outputs.tag_name }}
|
||||
RANGE_SPEC: ${{ steps.release_ctx.outputs.range_spec }}
|
||||
PREVIOUS_TAG: ${{ steps.release_ctx.outputs.previous_tag }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
{
|
||||
echo "## Changelog"
|
||||
echo
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
echo "_Changes since ${PREVIOUS_TAG}_"
|
||||
else
|
||||
echo "_Changes up to ${TAG_NAME}_"
|
||||
fi
|
||||
echo
|
||||
git log "$RANGE_SPEC" --no-merges -n 100 --pretty='- %s (%h) — %an'
|
||||
echo
|
||||
} > GENERATED_CHANGELOG.md
|
||||
|
||||
# Summarize commits into prose via GitHub Models (gpt-4o-mini).
|
||||
# Falls back to raw changelog if the API is unavailable.
|
||||
raw_commits="$(git log "$RANGE_SPEC" --no-merges --pretty='%s' | head -40 || true)"
|
||||
if [ -n "$raw_commits" ]; then
|
||||
api_body="$(jq -n --arg commits "$raw_commits" '{
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are writing a Discord release announcement for an open-source SRE CLI tool called opensre. Summarize the git commits into 2-4 short, punchy prose sentences — no bullet points, no headers, no markdown. Write in present tense, active voice. Focus on what users will notice: new features, fixes, performance, integrations. Be specific but concise."
|
||||
},
|
||||
{role: "user", content: ("Commits:\n" + $commits)}
|
||||
],
|
||||
max_tokens: 300,
|
||||
temperature: 0.4
|
||||
}')"
|
||||
curl -sS --max-time 20 \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://models.inference.ai.azure.com/chat/completions" \
|
||||
-d "$api_body" 2>/dev/null \
|
||||
| jq -r '.choices[0].message.content // empty' 2>/dev/null \
|
||||
| tr -d '\r' > DISCORD_NARRATIVE.md || true
|
||||
|
||||
if [ -s DISCORD_NARRATIVE.md ]; then
|
||||
echo "LLM narrative generated ($(wc -c < DISCORD_NARRATIVE.md) bytes)."
|
||||
else
|
||||
echo "LLM summary unavailable; Discord will use raw changelog."
|
||||
rm -f DISCORD_NARRATIVE.md
|
||||
fi
|
||||
fi
|
||||
|
||||
CB='```'
|
||||
{
|
||||
printf '## Install\n\n'
|
||||
printf '### cURL (macOS / Linux)\n\n%sbash\ncurl -fsSL https://install.opensre.com | bash -s -- --release --version %s\n%s\n\n' "$CB" "${TAG_NAME#v}" "$CB"
|
||||
printf '### Homebrew (macOS / Linux)\n\n%sbash\nbrew tap tracer-cloud/tap\nbrew install tracer-cloud/tap/opensre\n%s\n\n' "$CB" "$CB"
|
||||
printf '### PowerShell (Windows)\n\n%spowershell\n$env:OPENSRE_INSTALL_CHANNEL="release"; $env:OPENSRE_VERSION="%s"; irm https://install.opensre.com | iex\n%s\n\n' "$CB" "${TAG_NAME#v}" "$CB"
|
||||
printf '### Python\n\n%sbash\npipx install opensre\n%s\n\n' "$CB" "$CB"
|
||||
} > RELEASE_NOTES.md
|
||||
cat GENERATED_CHANGELOG.md >> RELEASE_NOTES.md
|
||||
|
||||
- name: Create GitHub release
|
||||
id: github_release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ needs.prepare.outputs.tag_name }}
|
||||
TARGET_SHA: ${{ steps.release_ctx.outputs.target_sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Release $TAG_NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="${TAG_NAME#v}"
|
||||
|
||||
# Every release-channel publish is the newest stable build at this
|
||||
# point, so mark it as GitHub's Latest. The title keeps the full
|
||||
# version (e.g. "OpenSRE 0.1.2026.6.26") so the v0.1 line is clear.
|
||||
gh release create "$TAG_NAME" \
|
||||
release-assets/* \
|
||||
--repo "${{ github.repository }}" \
|
||||
--target "$TARGET_SHA" \
|
||||
--title "OpenSRE ${VERSION}" \
|
||||
--notes-file RELEASE_NOTES.md \
|
||||
--latest
|
||||
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Announce on Discord
|
||||
if: steps.github_release.outputs.created == 'true' && !github.event.repository.fork && !github.event.repository.private
|
||||
continue-on-error: true
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL_UPDATE: ${{ secrets.DISCORD_WEBHOOK_URL_UPDATE }}
|
||||
DISCORD_RELEASES_ROLE_ID: ${{ secrets.DISCORD_RELEASES_ROLE_ID }}
|
||||
DISCORD_RELEASE_LOGO_EMOJI: ${{ secrets.DISCORD_RELEASE_LOGO_EMOJI }}
|
||||
DISCORD_RELEASE_LOGO_URL: ${{ secrets.DISCORD_RELEASE_LOGO_URL }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag_name }}
|
||||
RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.prepare.outputs.tag_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${DISCORD_WEBHOOK_URL_UPDATE:-}" ]; then
|
||||
echo "DISCORD_WEBHOOK_URL_UPDATE is not set; skipping Discord announcement."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prefer LLM-generated narrative; fall back to raw changelog.
|
||||
if [ -f DISCORD_NARRATIVE.md ] && [ -s DISCORD_NARRATIVE.md ]; then
|
||||
changelog_file="DISCORD_NARRATIVE.md"
|
||||
elif [ -f GENERATED_CHANGELOG.md ]; then
|
||||
changelog_file="GENERATED_CHANGELOG.md"
|
||||
else
|
||||
echo "No changelog available." > /tmp/discord_fallback.md
|
||||
changelog_file="/tmp/discord_fallback.md"
|
||||
fi
|
||||
|
||||
# Use an external Python script to build the JSON payload.
|
||||
# This avoids jq issues with multiline LLM output containing control characters.
|
||||
payload="$(CHANGELOG_FILE="$changelog_file" python3 .github/scripts/build-discord-payload.py)"
|
||||
|
||||
curl --fail -sS --max-time 30 -X POST -H "Content-Type: application/json" \
|
||||
-d "$payload" "$DISCORD_WEBHOOK_URL_UPDATE"
|
||||
|
||||
- name: Sync Homebrew tap formula
|
||||
if: steps.github_release.outputs.created == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.tag_name }}
|
||||
ASSET_DIR: release-assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${HOMEBREW_TAP_GITHUB_TOKEN:-}" ]; then
|
||||
echo "HOMEBREW_TAP_GITHUB_TOKEN is not set; skipping Homebrew tap sync."
|
||||
exit 0
|
||||
fi
|
||||
export VERSION="${VERSION#v}"
|
||||
bash .github/scripts/sync-homebrew-tap-formula.sh
|
||||
|
||||
publish-main-release:
|
||||
if: always() && needs.prepare.outputs.channel == 'main' && needs.build-binaries.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prepare
|
||||
- build-binaries
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# The rolling `main-build` tag is force-moved to each new main commit
|
||||
# below. Whenever main has advanced past a commit that touches
|
||||
# .github/workflows/**, the default GITHUB_TOKEN (the
|
||||
# github-actions[bot] GitHub App) is refused with "refusing to allow a
|
||||
# GitHub App to create or update workflow ... without `workflows`
|
||||
# permission" — and that scope cannot be granted via the permissions
|
||||
# block. MAIN_BUILD_TAG_TOKEN must be a token that carries workflow
|
||||
# write access (classic PAT with `repo` + `workflow`, a fine-grained
|
||||
# PAT with Contents + Workflows: write, or a GitHub App token). It is
|
||||
# persisted so the tag push below authenticates with it. Falls back to
|
||||
# GITHUB_TOKEN so the rest of the job still runs if the secret is unset
|
||||
# (the tag push will then fail as before until the secret is added).
|
||||
token: ${{ secrets.MAIN_BUILD_TAG_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Download main release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: main-release-*
|
||||
path: main-release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create release notes
|
||||
env:
|
||||
VERSION_NAME: ${{ needs.prepare.outputs.version_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short_sha="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
|
||||
built_at="$(date -u +"%Y-%m-%d %H:%M UTC")"
|
||||
|
||||
CB='```'
|
||||
{
|
||||
printf '## Main build\n\nRolling binary build from `main`.\n\n'
|
||||
printf -- '- Version: `%s`\n- Commit: `%s`\n- Built: %s\n\n' "$VERSION_NAME" "$short_sha" "$built_at"
|
||||
printf '### Install\n\nmacOS / Linux:\n\n%sbash\ncurl -fsSL https://install.opensre.com | bash\n%s\n\n' "$CB" "$CB"
|
||||
printf 'Equivalent explicit main channel:\n\n%sbash\ncurl -fsSL https://install.opensre.com | bash -s -- --main\n%s\n\n' "$CB" "$CB"
|
||||
printf 'Windows:\n\n%spowershell\nirm https://install.opensre.com | iex\n%s\n' "$CB" "$CB"
|
||||
} > MAIN_RELEASE_NOTES.md
|
||||
|
||||
- name: Move main build tag to the latest commit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -f "${{ needs.prepare.outputs.tag_name }}" "$GITHUB_SHA"
|
||||
git push origin "refs/tags/${{ needs.prepare.outputs.tag_name }}" --force
|
||||
|
||||
- name: Publish rolling main release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag_name="${{ needs.prepare.outputs.tag_name }}"
|
||||
|
||||
if gh release view "$tag_name" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
gh release upload "$tag_name" main-release-assets/* --repo "${{ github.repository }}" --clobber
|
||||
gh release edit "$tag_name" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Main" \
|
||||
--notes-file MAIN_RELEASE_NOTES.md
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh release create "$tag_name" \
|
||||
main-release-assets/* \
|
||||
--repo "${{ github.repository }}" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "Main" \
|
||||
--notes-file MAIN_RELEASE_NOTES.md \
|
||||
--prerelease
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Synthetic Deterministic Tests
|
||||
|
||||
# Regression guardrail: run every offline (no-LLM) synthetic test on every
|
||||
# PR and merge so pipeline refactors can't silently break investigations.
|
||||
#
|
||||
# Tests that need a live LLM are excluded via the marker filter below.
|
||||
# Those suites live in interactive-shell-live.yml and hermes-tests.yml instead.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "surfaces/**"
|
||||
- "config/**"
|
||||
- "core/**"
|
||||
- "integrations/**"
|
||||
- "platform/**"
|
||||
- "tools/**"
|
||||
- "tests/synthetic/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "pytest.ini"
|
||||
- ".github/workflows/synthetic-deterministic.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "surfaces/**"
|
||||
- "config/**"
|
||||
- "core/**"
|
||||
- "integrations/**"
|
||||
- "platform/**"
|
||||
- "tools/**"
|
||||
- "tests/synthetic/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "pytest.ini"
|
||||
- ".github/workflows/synthetic-deterministic.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: synthetic-det-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
offline:
|
||||
name: Synthetic offline (deterministic)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Run deterministic synthetic tests
|
||||
# Excludes markers that require a live LLM:
|
||||
# synthetic — full RCA scenario suites (EKS, RDS, Hermes, Dagster …)
|
||||
# axis2 — adversarial scenario suites (also call the LLM)
|
||||
# live_llm — explicit live-credential gate
|
||||
# e2e — requires live infrastructure
|
||||
run: |
|
||||
uv run pytest tests/synthetic \
|
||||
-m "not synthetic and not live_llm and not e2e and not axis2" \
|
||||
-q
|
||||
|
||||
- name: Run hermes_rca offline scenario suite
|
||||
run: uv run python -m tests.synthetic.hermes_rca.run_suite --offline-only
|
||||
@@ -0,0 +1,282 @@
|
||||
name: terraform-bench (plan)
|
||||
|
||||
# Runs terraform fmt + init + validate + plan against tests/benchmarks/cloudopsbench/infra/ on every PR
|
||||
# that touches the bench infra. Posts the plan output as a sticky PR comment so
|
||||
# reviewers can see what would change before approving.
|
||||
#
|
||||
# Never applies — apply is developer-local in v1 (see tests/benchmarks/cloudopsbench/infra/README.md).
|
||||
# When apply-from-CI is desirable later, add a separate workflow that runs on
|
||||
# merge-to-main with a different (higher-privileged) AWS role.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "tests/benchmarks/cloudopsbench/infra/**"
|
||||
- ".github/workflows/terraform-bench.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write # required for AWS OIDC role assumption
|
||||
|
||||
concurrency:
|
||||
group: terraform-bench-${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: terraform plan
|
||||
if: github.repository == 'Tracer-Cloud/opensre' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tests/benchmarks/cloudopsbench/infra
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
# tfplan + plan.txt artifacts are not uploaded; plan output goes into the
|
||||
# PR comment. If you want a downloadable plan binary, add an
|
||||
# actions/upload-artifact step after `terraform show`.
|
||||
TF_IN_AUTOMATION: "true"
|
||||
TF_INPUT: "0"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "1.7.5"
|
||||
# Wrapper MUST stay enabled (default) — it's what populates
|
||||
# `steps.<id>.outputs.stdout` for the `terraform show` step that
|
||||
# feeds the sticky PR comment. Disabling it makes the comment
|
||||
# render with a blank plan body. See greptile review on initial
|
||||
# PR for details.
|
||||
|
||||
- name: Configure AWS credentials (OIDC role assumption)
|
||||
# No long-lived AWS keys. The role is provisioned by tests/benchmarks/cloudopsbench/infra/
|
||||
# Terraform; ARN is hardcoded here because Terraform outputs aren't
|
||||
# available before the workflow can run. Account ID is the
|
||||
# tracer-cloud account.
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/opensre-bench-terraform-plan
|
||||
role-session-name: github-actions-terraform-plan-${{ github.run_id }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: terraform fmt
|
||||
id: fmt
|
||||
run: terraform fmt -check -recursive -no-color
|
||||
continue-on-error: true
|
||||
|
||||
- name: terraform init
|
||||
id: init
|
||||
run: terraform init -no-color
|
||||
|
||||
- name: terraform validate
|
||||
id: validate
|
||||
run: terraform validate -no-color
|
||||
|
||||
- name: terraform plan
|
||||
id: plan
|
||||
run: terraform plan -no-color -input=false -lock=false -out=tfplan
|
||||
continue-on-error: true
|
||||
|
||||
- name: terraform show
|
||||
id: show
|
||||
if: steps.plan.outcome == 'success'
|
||||
run: terraform show -no-color tfplan
|
||||
|
||||
- name: Post plan to PR
|
||||
if: github.event_name == 'pull_request' && always()
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PLAN_STDOUT: ${{ steps.show.outputs.stdout }}
|
||||
FMT_OUTCOME: ${{ steps.fmt.outcome }}
|
||||
INIT_OUTCOME: ${{ steps.init.outcome }}
|
||||
VALIDATE_OUTCOME: ${{ steps.validate.outcome }}
|
||||
PLAN_OUTCOME: ${{ steps.plan.outcome }}
|
||||
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const MARKER = '<!-- terraform-bench-plan-comment -->';
|
||||
|
||||
const fmt = process.env.FMT_OUTCOME;
|
||||
const init = process.env.INIT_OUTCOME;
|
||||
const validate = process.env.VALIDATE_OUTCOME;
|
||||
const plan = process.env.PLAN_OUTCOME;
|
||||
const url = process.env.WORKFLOW_URL;
|
||||
|
||||
const icon = (outcome) => outcome === 'success' ? '✅' :
|
||||
outcome === 'failure' ? '❌' :
|
||||
outcome === 'skipped' ? '⏭️' : '⚠️';
|
||||
|
||||
let planBody = process.env.PLAN_STDOUT || '';
|
||||
// GitHub PR comments cap at 65536 chars. Reserve ~1000 for surrounding text.
|
||||
const MAX = 60000;
|
||||
let truncated = false;
|
||||
if (planBody.length > MAX) {
|
||||
planBody = planBody.slice(-MAX);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const body = [
|
||||
MARKER,
|
||||
'### terraform-bench plan',
|
||||
'',
|
||||
'| step | outcome |',
|
||||
'| --- | --- |',
|
||||
`| fmt | ${icon(fmt)} \`${fmt}\` |`,
|
||||
`| init | ${icon(init)} \`${init}\` |`,
|
||||
`| validate | ${icon(validate)} \`${validate}\` |`,
|
||||
`| plan | ${icon(plan)} \`${plan}\` |`,
|
||||
'',
|
||||
fmt !== 'success' ? '> Run `terraform fmt -recursive` in `tests/benchmarks/cloudopsbench/infra/` to fix formatting.' : '',
|
||||
'',
|
||||
plan === 'success'
|
||||
? `<details><summary>Plan output${truncated ? ' (truncated — see <a href="' + url + '">workflow logs</a> for full output)' : ''}</summary>\n\n\`\`\`hcl\n${planBody}\n\`\`\`\n\n</details>`
|
||||
: `Plan did not run successfully — see [workflow logs](${url}) for details.`,
|
||||
'',
|
||||
`_Updated by [\`terraform-bench.yml\`](${url})._`,
|
||||
].join('\n');
|
||||
|
||||
// Find an existing comment to update, else create a new one.
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(MARKER));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if any step errored
|
||||
if: |
|
||||
steps.fmt.outcome == 'failure' ||
|
||||
steps.init.outcome == 'failure' ||
|
||||
steps.validate.outcome == 'failure' ||
|
||||
steps.plan.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::One or more terraform steps failed. See PR comment + workflow logs."
|
||||
exit 1
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Security + lint scanners — run in parallel with plan. #
|
||||
# #
|
||||
# Suppression policy (intentional skips) is defined per-tool: #
|
||||
# - checkov: tests/benchmarks/cloudopsbench/infra/.checkov.yaml #
|
||||
# - tfsec: tests/benchmarks/cloudopsbench/infra/.tfsec/config.yml #
|
||||
# - tflint: tests/benchmarks/cloudopsbench/infra/.tflint.hcl #
|
||||
# Each suppression includes a justification at the source. Day-one #
|
||||
# findings should be REAL findings — change configs, not the workflow, #
|
||||
# if a rule needs to be skipped. #
|
||||
# #
|
||||
# checkov + tfsec hard-fail (security blocks PRs). tflint soft-fails #
|
||||
# because its rules cover code quality / deprecations, not security — #
|
||||
# we want visibility without blocking. #
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
tflint:
|
||||
name: tflint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tests/benchmarks/cloudopsbench/infra
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup tflint
|
||||
uses: terraform-linters/setup-tflint@v4
|
||||
with:
|
||||
tflint_version: latest
|
||||
|
||||
- name: tflint --init (download plugins)
|
||||
run: tflint --init
|
||||
|
||||
- name: tflint
|
||||
id: tflint
|
||||
run: tflint --recursive --format compact
|
||||
continue-on-error: true
|
||||
|
||||
- name: Surface tflint findings (non-blocking)
|
||||
if: steps.tflint.outcome == 'failure'
|
||||
run: |
|
||||
echo "::warning::tflint reported findings. Code-quality only — does not block PR. Review in workflow logs."
|
||||
|
||||
tfsec:
|
||||
name: tfsec
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# tfsec via Docker, bypassing aquasecurity/tfsec-action.
|
||||
#
|
||||
# Why direct Docker run: the action shells out to api.github.com to
|
||||
# resolve the latest release binary, and the aquasecurity org has an
|
||||
# IP allow list that blocks GitHub Actions runner IPs — making the
|
||||
# action effectively unusable from CI. The aquasec/tfsec image on
|
||||
# Docker Hub has no such restriction.
|
||||
#
|
||||
# Migration path (v2): tfsec is in maintenance mode; rule set lives on
|
||||
# in Trivy. Replace with `aquasec/trivy:latest config /src` when
|
||||
# ready — same .tfsec/config.yml is recognized.
|
||||
#
|
||||
# Suppressions: tests/benchmarks/cloudopsbench/infra/.tfsec/config.yml. tfsec exits 1 on any
|
||||
# finding by default, which fails the step — exactly the hard-fail
|
||||
# behavior we want. Add a justified exclude to the config rather
|
||||
# than disabling the step.
|
||||
- name: tfsec scan (via Docker)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}/tests/benchmarks/cloudopsbench/infra:/src" \
|
||||
aquasec/tfsec:latest \
|
||||
/src \
|
||||
--config-file=/src/.tfsec/config.yml \
|
||||
--no-color
|
||||
|
||||
checkov:
|
||||
name: checkov
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# Suppressions live in tests/benchmarks/cloudopsbench/infra/.checkov.yaml. soft_fail is
|
||||
# FALSE — real findings block the PR. Add a justified skip-check
|
||||
# to the config rather than disabling here.
|
||||
- name: checkov scan
|
||||
uses: bridgecrewio/checkov-action@v12
|
||||
with:
|
||||
directory: tests/benchmarks/cloudopsbench/infra
|
||||
framework: terraform
|
||||
config_file: tests/benchmarks/cloudopsbench/infra/.checkov.yaml
|
||||
quiet: true
|
||||
soft_fail: false
|
||||
# Re-enable SARIF upload when CodeQL alerts are wanted:
|
||||
# output_format: cli,sarif
|
||||
# output_file_path: console,checkov-results.sarif
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Tracer Demo – Prefect ECS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tracer-demo-prefect-ecs:
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
JWT_TOKEN: ${{ secrets.JWT_TOKEN }}
|
||||
TRACER_ORG_ID: ${{ secrets.TRACER_ORG_ID }}
|
||||
TRACER_WEB_APP_URL: ${{ secrets.TRACER_WEB_APP_URL }}
|
||||
TRACER_API_URL: ${{ secrets.TRACER_API_URL }}
|
||||
TRACER_INGEST_TOKEN: ${{ secrets.TRACER_INGEST_TOKEN }}
|
||||
|
||||
AWS_REGION: us-east-1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Install tracer CLI
|
||||
run: |
|
||||
curl -sSL https://install.tracer.cloud | CLI_BRANCH=dev sh -s user_36fbN6K6FwEgJFHsQv1pUByo8K6
|
||||
|
||||
- name: Initialize tracer
|
||||
run: |
|
||||
sudo tracer init --token ${{ secrets.JWT_TOKEN }}
|
||||
|
||||
- name: Export Tracer run id
|
||||
shell: bash
|
||||
run: |
|
||||
RUN_ID=$(tracer info --json | python3 -c "import sys,json; print(json.load(sys.stdin)['run']['id'])")
|
||||
PIPELINE_NAME=$(tracer info --json | python3 -c "import sys,json; print(json.load(sys.stdin)['pipeline']['name'])")
|
||||
ORG_SLUG=$(tracer info --json | python3 -c "import sys,json; print(json.load(sys.stdin)['pipeline']['organization'])")
|
||||
|
||||
echo "TRACER_RUN_ID=$RUN_ID" >> $GITHUB_ENV
|
||||
echo "TRACER_PIPELINE_NAME=$PIPELINE_NAME" >> $GITHUB_ENV
|
||||
echo "TRACER_ORG_SLUG=$ORG_SLUG" >> $GITHUB_ENV
|
||||
|
||||
# Optional but helpful: stable trace id for ingest rows
|
||||
echo "TRACER_TRACE_ID=trace_$RUN_ID" >> $GITHUB_ENV
|
||||
|
||||
echo "Exported TRACER_RUN_ID=$RUN_ID"
|
||||
|
||||
- name: Run Prefect ECS Demo
|
||||
continue-on-error: true
|
||||
run: |
|
||||
uv run python -m tests.e2e.upstream_prefect_ecs_fargate.test_agent_e2e
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Trigger K8s Alert
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
verify:
|
||||
description: "Verify logs in Datadog + post to Slack"
|
||||
type: boolean
|
||||
default: true
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # every Monday 9am UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger-alert:
|
||||
if: github.repository == 'Tracer-Cloud/opensre'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DD_API_KEY: ${{ secrets.DD_API_KEY }}
|
||||
DD_APP_KEY: ${{ secrets.DD_APP_KEY }}
|
||||
DD_SITE: datadoghq.com
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
SLACK_DEVS_ALERTS_CHANNEL_ID: C09S7GDG60J
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install project
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Trigger pipeline failure via centralized config (~10s)
|
||||
id: trigger
|
||||
run: |
|
||||
echo "trigger_epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
set +e
|
||||
uv run python -m tests.e2e.kubernetes.trigger_alert | tee /tmp/trigger.log
|
||||
trigger_exit=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "trigger_completed_epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
if grep -q '"status": "accepted_timeout"' /tmp/trigger.log; then
|
||||
echo "trigger_accepted_timeout=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "trigger_accepted_timeout=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit "$trigger_exit"
|
||||
|
||||
- name: Report trigger result
|
||||
run: |
|
||||
echo "Trigger command completed successfully"
|
||||
|
||||
- name: Verify Datadog + Slack (~5 min)
|
||||
if: ${{ inputs.verify == true || github.event_name == 'schedule' }}
|
||||
run: |
|
||||
POST_TRIGGER_WAIT=""
|
||||
if [ "${{ steps.trigger.outputs.trigger_accepted_timeout }}" = "true" ]; then
|
||||
POST_TRIGGER_WAIT="--post-trigger-wait 90"
|
||||
fi
|
||||
uv run python -m tests.e2e.kubernetes.trigger_alert \
|
||||
--verify-only \
|
||||
--since-epoch ${{ steps.trigger.outputs.trigger_completed_epoch }} \
|
||||
--dd-max-wait 300 \
|
||||
$POST_TRIGGER_WAIT
|
||||
@@ -0,0 +1,186 @@
|
||||
# Environment variables
|
||||
*.env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.backup*
|
||||
.env.bak
|
||||
.env.bench
|
||||
.secrets
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
.stdlib_vendor/
|
||||
# PyInstaller generates this from CLI args during the release build.
|
||||
opensre.spec
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
.venv-devcontainer/
|
||||
virtualenv/
|
||||
tracer_agent/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.log
|
||||
!tests/synthetic/hermes/**/*.log
|
||||
.turn-shard-logs/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# uv (uv.lock is committed)
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# import-linter
|
||||
.import_linter_cache/
|
||||
|
||||
# PyInstaller build cache
|
||||
.pyinstaller-cache/
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# IDE / Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.code-workspace
|
||||
|
||||
# Wrangler local state
|
||||
**/.wrangler/
|
||||
|
||||
# Project specific
|
||||
.antigravitycli/
|
||||
output/
|
||||
# CLI terminal output package (must stay tracked; ``output/`` above ignores new files here).
|
||||
# Both the pre-restructure path and the post-restructure path under ``surfaces/`` are
|
||||
# negated so the package keeps being tracked across the T-1 move (#3299).
|
||||
!interactive_shell/ui/output/
|
||||
!interactive_shell/ui/output/**
|
||||
interactive_shell/ui/output/**/__pycache__/
|
||||
!surfaces/interactive_shell/ui/output/
|
||||
!surfaces/interactive_shell/ui/output/**
|
||||
surfaces/interactive_shell/ui/output/**/__pycache__/
|
||||
.cloudopsbench-results/
|
||||
.bench-results/
|
||||
tests/benchmarks/cloudopsbench/benchmark/
|
||||
tests/benchmarks/cloudopsbench/.cache/
|
||||
|
||||
# Vendorized packages in api_ingester
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/certifi/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/certifi-*/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/charset_normalizer/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/charset_normalizer-*/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/idna/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/idna-*/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/requests/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/requests-*/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/urllib3/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/urllib3-*/
|
||||
tests/test_case_upstream_downstream_pipeline/pipeline_code/api_ingester/bin/
|
||||
|
||||
# CDK build artifacts
|
||||
**/cdk.out/
|
||||
|
||||
CLAUDE_PERSONAL.md
|
||||
|
||||
# Local planning notes (not tracked)
|
||||
plans/
|
||||
|
||||
tasks/
|
||||
app/memories
|
||||
.aider*
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
|
||||
# Node dev tooling
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Synthetic test run observations (generated test output, not source)
|
||||
tests/synthetic/rds_postgres/_observations/
|
||||
|
||||
# Local CI-fix scratch clone (not part of this repo)
|
||||
opensre-ci-fix/
|
||||
|
||||
# Terraform local state + provider cache
|
||||
**/.terraform/
|
||||
**/.terraform.lock.hcl
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfvars
|
||||
crash.log
|
||||
@@ -0,0 +1,19 @@
|
||||
[importlinter]
|
||||
root_packages =
|
||||
surfaces
|
||||
config
|
||||
core
|
||||
gateway
|
||||
integrations
|
||||
platform
|
||||
tools
|
||||
|
||||
[importlinter:contract:config-independent]
|
||||
name = Config is independent
|
||||
type = independence
|
||||
modules =
|
||||
config
|
||||
|
||||
# Full layered contracts (core ⊄ tools, integrations ⊄ cli, …) live in
|
||||
# ``.importlinter.strict`` for local burn-down. Run: ``make check-layers-strict``
|
||||
# Regenerate baselines: ``uv run lint-imports --config .importlinter.strict``
|
||||
@@ -0,0 +1,105 @@
|
||||
# Counterpart to .github/ci/check_direct_imports.py (direct edges only).
|
||||
# Run: make check-layers-strict
|
||||
#
|
||||
# Remove ignore_imports entries as refactors land; link the fixing PR/issue.
|
||||
|
||||
[importlinter]
|
||||
root_packages =
|
||||
surfaces
|
||||
config
|
||||
core
|
||||
gateway
|
||||
integrations
|
||||
platform
|
||||
tools
|
||||
|
||||
[importlinter:contract:opensre-layers]
|
||||
name = OpenSRE package layers (transitive)
|
||||
type = layers
|
||||
layers =
|
||||
surfaces | gateway
|
||||
tools | integrations
|
||||
# ``:`` = siblings may cross-import (import-linter multi-item layers).
|
||||
# ``|`` would forbid core <-> platform, but both packages depend on each other.
|
||||
core : platform
|
||||
config
|
||||
ignore_imports =
|
||||
# config upward imports (T-4 debt — refactor into lower layers)
|
||||
config.config -> core.llm.providers.azure_openai
|
||||
config.llm_auth.credentials -> integrations.llm_cli.registry
|
||||
config.llm_keyring -> platform
|
||||
config.repl_config -> platform.terminal.theme
|
||||
# platform -> integrations (T-4 debt — scheduler / observability)
|
||||
platform.analytics.cli -> integrations.github.identity
|
||||
platform.observability.errors.sentry -> integrations.llm_cli.errors
|
||||
platform.scheduler.credentials -> integrations.catalog
|
||||
platform.scheduler.executor -> integrations.discord.delivery
|
||||
platform.scheduler.executor -> integrations.slack.delivery
|
||||
platform.scheduler.executor -> integrations.telegram.delivery
|
||||
platform.scheduler.executor -> integrations.telegram.formatting
|
||||
# surfaces -> gateway (cli + repl gateway commands)
|
||||
surfaces.cli.commands.gateway -> gateway.daemon
|
||||
surfaces.cli.commands.gateway -> gateway.manager
|
||||
surfaces.interactive_shell.command_registry.gateway_cmds -> gateway.daemon
|
||||
surfaces.interactive_shell.controller -> gateway.web_server
|
||||
# tools.interactive_shell -> surfaces (T-4 debt — issue #3352)
|
||||
tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.runtime.background.runner
|
||||
tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.runtime.investigation_adapter
|
||||
tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.session
|
||||
tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.session
|
||||
tools.interactive_shell.shared.investigation_launch -> surfaces.interactive_shell.session
|
||||
tools.interactive_shell.actions.llm_provider -> surfaces.cli.wizard.config
|
||||
tools.interactive_shell.actions.llm_provider -> surfaces.interactive_shell.command_registry
|
||||
tools.interactive_shell.actions.llm_provider -> surfaces.interactive_shell.ui.execution_confirm
|
||||
tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.runtime.background.runner
|
||||
tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.runtime.investigation_adapter
|
||||
tools.interactive_shell.actions.slash -> surfaces.interactive_shell.command_registry
|
||||
tools.interactive_shell.actions.slash -> surfaces.interactive_shell.command_registry.slash_catalog
|
||||
tools.interactive_shell.actions.slash -> surfaces.interactive_shell.ui
|
||||
tools.interactive_shell.actions.slash -> surfaces.interactive_shell.ui.execution_confirm
|
||||
tools.interactive_shell.actions.slash -> surfaces.interactive_shell.utils.telemetry.turn_outcome
|
||||
tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.command_registry
|
||||
tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.runtime
|
||||
tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.ui.execution_confirm
|
||||
# tools -> integrations (T-4 debt — vendor tools / reporting)
|
||||
tools.community_followup_tool -> integrations.github.client
|
||||
tools.community_followup_tool -> integrations.github.helpers
|
||||
tools.community_followup_tool -> integrations.github.tools.workflow
|
||||
tools.cross_vendor.fix_sentry_issue.context -> integrations.sentry
|
||||
tools.cross_vendor.fix_sentry_issue.context -> integrations.sentry.issue_url
|
||||
tools.cross_vendor.fix_sentry_issue.pr -> integrations.github.client
|
||||
tools.cross_vendor.fix_sentry_issue.pr -> integrations.github.repo_scope
|
||||
tools.cross_vendor.fix_sentry_issue.runner -> integrations.coding_agent
|
||||
tools.cross_vendor.fix_sentry_issue.runner -> integrations.git
|
||||
tools.cross_vendor.fix_sentry_issue.runner -> integrations.github.client
|
||||
tools.cross_vendor.fix_sentry_issue.ship -> integrations.coding_agent
|
||||
tools.cross_vendor.fix_sentry_issue.ship -> integrations.git
|
||||
tools.cross_vendor.fix_sentry_issue.ship -> integrations.github.client
|
||||
tools.git_deploy_timeline_tool -> integrations.github.helpers
|
||||
tools.git_deploy_timeline_tool -> integrations.github.mcp
|
||||
tools.interactive_shell.implementation.claude_code_executor -> integrations.llm_cli.claude_code
|
||||
tools.interactive_shell.implementation.claude_code_executor -> integrations.llm_cli.subprocess_env
|
||||
tools.investigation.reporting.delivery.bootstrap -> integrations.discord.reporting_adapter
|
||||
tools.investigation.reporting.delivery.bootstrap -> integrations.openclaw.reporting_adapter
|
||||
tools.investigation.reporting.delivery.bootstrap -> integrations.slack.reporting_adapter
|
||||
tools.investigation.reporting.delivery.bootstrap -> integrations.telegram.reporting_adapter
|
||||
tools.investigation.reporting.delivery.bootstrap -> integrations.twilio.reporting_adapter
|
||||
tools.investigation.reporting.delivery.bootstrap -> integrations.whatsapp.reporting_adapter
|
||||
tools.investigation.reporting.evaluation -> integrations.opensre.llm_eval_judge
|
||||
tools.investigation.reporting.formatters.report -> integrations.telegram.formatting
|
||||
tools.investigation.reporting.gitlab_writeback -> integrations.gitlab
|
||||
tools.investigation.reporting.upstream_correlation.registry -> integrations.datadog.correlation.registration
|
||||
tools.investigation.state_factory -> integrations.opensre.hf_remote
|
||||
tools.pi_coding_tool -> integrations.pi
|
||||
tools.pi_coding_tool.runner -> integrations.pi
|
||||
tools.pi_coding_tool.validation -> integrations.pi
|
||||
tools.system.python_execution_tool.credentials -> integrations.github.client
|
||||
tools.system.python_execution_tool.credentials -> integrations.github.helpers
|
||||
tools.slack_send_message_tool.delivery -> integrations.catalog
|
||||
tools.slack_send_message_tool.delivery -> integrations.slack.delivery
|
||||
tools.system.watch_dog.runner -> integrations.telegram.alarms
|
||||
tools.system.watch_dog.runner -> integrations.telegram.credentials
|
||||
tools.work_status_report_tool -> integrations.github.client
|
||||
tools.work_status_report_tool -> integrations.github.helpers
|
||||
tools.work_status_report_tool -> integrations.github.tools.work_status
|
||||
tools.work_status_report_tool -> integrations.github.tools.workflow
|
||||
@@ -0,0 +1,15 @@
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.1
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args: [--no-fix]
|
||||
files: ^(config|core|gateway|integrations|platform|surfaces|tools|tests)/
|
||||
- id: ruff-format
|
||||
args: [--check]
|
||||
files: ^(config|core|gateway|integrations|platform|surfaces|tools|tests)/
|
||||
@@ -0,0 +1,6 @@
|
||||
python 3.13.11
|
||||
nodejs 22.12.0
|
||||
pnpm 10.28.0
|
||||
uv 0.11.11
|
||||
ruff 0.15.12
|
||||
mypy 1.20.2
|
||||
@@ -0,0 +1,237 @@
|
||||
## OpenSRE Development Reference
|
||||
|
||||
## Build and Run commands
|
||||
|
||||
- Build `make install` (sets up the project environment via `uv sync` and installs this repo in editable mode)
|
||||
- Run **`uv run opensre …`** from the repo root while developing — preferred approach, uses this checkout even if another `opensre` is on your `PATH`.
|
||||
- Use **`uv run python …`** for any Python commands.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use strict typing, follow DRY principle
|
||||
- One clear purpose per file (separation of concerns)
|
||||
- Do not keep compatibility-only forwarding modules after refactors. Once imports and tests
|
||||
are migrated, remove the old module path in the same change and use one canonical import path.
|
||||
|
||||
Before any push or PR creation follow **[CI.md](CI.md)** — lint, format, typecheck, and test commands all live there.
|
||||
|
||||
When opening a PR, fill out the **[PR template](.github/PULL_REQUEST_TEMPLATE.md)** — it is not optional boilerplate; it has a required AI-usage disclosure section.
|
||||
|
||||
## 1. Repo Map
|
||||
|
||||
| Path | What it does |
|
||||
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `core/` | Investigation orchestration, context assembly, the shared runtime tool-calling loop, and domain logic (state, types, correlation rules). Includes `core/tool_framework/` — the `BaseTool` base class, `@tool` decorator, registered-tool primitives, error telemetry, skill-guidance helpers, and shared payload utilities (`utils/`). |
|
||||
| `surfaces/cli/` | Command-line interface, onboarding wizard, local LLM helpers, and CLI tests support. |
|
||||
| `surfaces/interactive_shell/` | Interactive terminal (REPL) loop, slash commands, chat/help surfaces, action-planning harness, and terminal UI. |
|
||||
| `integrations/` | Per-integration config normalization, verification, clients, helpers, store/catalog logic, the Hermes log pipeline, and per-vendor tool packages under `integrations/<vendor>/tools/`. |
|
||||
| `tools/` | Tool registry, per-tool packages for cross-cutting tools that aren't vendor-specific (e.g. `tools/system/fleet_monitoring/`, `tools/system/watch_dog/`, `tools/system/sre_guidance_tool/`), and the interactive-shell action tools. Framework primitives (decorator, base class, utils) live in `core/tool_framework/`. |
|
||||
| `platform/` | Cross-cutting platform services: guardrails, masking, sandbox, analytics, auth, notifications, observability, harness ports (`platform/harness_ports.py`), and EC2 deployment (`platform/deployment/`). |
|
||||
| `config/` | Shared constants, prompts, and UI theme. |
|
||||
| `tests/` | Unit, integration, synthetic, deployment, e2e, chaos engineering, and support tests. |
|
||||
| `docs/` | User-facing documentation, integration guides, and docs-site assets. |
|
||||
| `.github/` | CI workflows, issue templates, pull request template, and repository automation. |
|
||||
| `Dockerfile` | Optional production container image (FastAPI health app via uvicorn). |
|
||||
| `pyproject.toml` | Python project metadata, dependency configuration, tooling, and package settings. |
|
||||
| `Makefile` | Canonical local automation for install, test, verify, deploy, and cleanup targets. |
|
||||
| `README.md` | Product overview, install, quick start, high-level capabilities, and links to deeper docs. |
|
||||
| `docs/DEVELOPMENT.md` | Contributor workflows: CI parity commands, dev container, benchmark, deployment, telemetry detail. |
|
||||
| `docs/ARCHITECTURE.md` | Package architecture: the four-tier layer table, folder diagram, per-layer responsibilities, allowed cross-layer edges, and cross-layer flows. |
|
||||
| `docs/investigation-pipeline-architecture.md` | Investigation pipeline stages, ReAct loop control flow, and guardrails (tool cap, stagnation breaker, context budget), with diagrams. |
|
||||
| `docs/investigation-tool-calling.md` | Investigation ReAct tool schemas, LLM invoke payloads, and message shapes (all providers). |
|
||||
| `docs/tool-placement-policy.md` | Decision rule for where a tool lives: `integrations/<vendor>/tools/` vs. `tools/system/` vs. `tools/cross_vendor/` vs. `surfaces/shared/`. |
|
||||
| `docs/NAMING.md` | Naming conventions for `core/`: the glossary (State/Snapshot/RunInput/RunResult/Slice/Resources/Budget), the `{domain}_{role}.py` file rule, type naming (`Mixin` suffix, role-named Protocols, no package-name prefix), and anti-patterns. |
|
||||
| `SETUP.md` | Machine setup (all platforms, Windows, MCP/OpenClaw, troubleshooting). |
|
||||
| `CI.md` | Mandatory pre-push checklist: lint, format, typecheck, tests — agents MUST follow before pushing. |
|
||||
| `TESTING.md` | `ReplDriver` reference: API, usage patterns, wait-time guide, and limitations. |
|
||||
| `CONTRIBUTING.md` | Contribution workflow, branch/PR guidance, and quality expectations. |
|
||||
|
||||
Main packages one level deeper:
|
||||
|
||||
- `platform/analytics/` — Analytics event plumbing and install helpers used by the onboarding flow.
|
||||
- `platform/auth/` — JWT and authentication helpers for local and hosted runtime access.
|
||||
- `surfaces/cli/` — Command-line interface, onboarding wizard, local LLM helpers, and CLI tests support.
|
||||
- `surfaces/interactive_shell/` — Interactive terminal (TTY) loop, slash-command surface, chat/help handoff, session runtime, and terminal UI. REPL watchdog slash commands (`/watch`, `/watches`, `/unwatch`): PR demo steps live under **Interactive shell: REPL watchdog demo** in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#interactive-shell-repl-watchdog-demo).
|
||||
- `config/constants/` — Shared prompt and other static constants.
|
||||
- `platform/deployment/aws/` — Shared boto3 client factory, deployment constants (`config.py`), VPC/subnet/SG helpers, EC2/IAM provisioning, ECR build/push, and SSM run-command primitives. Import from here in deployment scripts instead of duplicating.
|
||||
- `platform/deployment/` — EC2 deploy/destroy: `opensre-web` and `opensre-gateway` on one instance. Makefile: `make deploy`.
|
||||
- `platform/guardrails/` — Guardrail rules, evaluation engine, audit helpers, and CLI bindings.
|
||||
- `platform/harness_ports.py` — Harness port layer (integration resolution, tool registry, investigation tools, GitHub repo scope). Real implementations are wired at startup via `integrations/harness_adapters.py` and `tools/harness_adapters.py` through `install_harness_ports()` in `surfaces/interactive_shell/ui/output/boundary.py`. See `core/agent_harness/AGENTS.md` for the import boundary.
|
||||
- `integrations/` — Integration config normalization, verification, selectors, clients, integration-local helpers, store, and catalog logic.
|
||||
- `integrations/hermes/` — Hermes log tailing, incident classification, correlator, sinks, and investigation bridge.
|
||||
- `integrations/llm_cli/` — Subprocess-backed LLM CLIs (e.g. Codex). Extension guide: `integrations/llm_cli/AGENTS.md`.
|
||||
- `platform/masking/` — Masking utilities for redacting or normalizing sensitive content.
|
||||
- `tools/investigation/` — Composite investigation capability, public entrypoints, semantic stages, and reporting.
|
||||
- `core/` — Shared LLM tool-calling loop (execute tools, message shaping, context budget).
|
||||
- `core/llm/` — Hosted LLM provider clients, retry/schema helpers, and investigation tool-calling adapters.
|
||||
- `platform/sandbox/` — Sandboxed execution helpers for controlled runtime actions.
|
||||
- `core/state/` — Shared agent runtime envelope (`AgentState`), chat slice, investigation pipeline slice contracts, `EvidenceEntry`, state-update helpers, and pure defaults.
|
||||
- `tools/` — Tool registry, decorator, base classes, per-tool packages, shared utilities, and registry helpers.
|
||||
- `core/domain/types/` — Shared typed contracts for evidence, retrieval, and tool-related payloads.
|
||||
- `platform/` — Guardrails, masking, sandbox, analytics, auth, and cross-cutting platform services (e.g. `integrations/telegram/*`).
|
||||
- `tools/system/watch_dog/` — Watchdog feature: per-threshold Telegram alarm dispatch with cooldown, sitting on top of `integrations/telegram/*`.
|
||||
- `gateway/webapp.py` — Web-facing health app served by the gateway daemon; the `opensre` CLI is `surfaces/cli/__main__.py`.
|
||||
|
||||
## 2. Entry Points
|
||||
|
||||
### Adding a Tool
|
||||
|
||||
The tool registry auto-discovers modules under `tools/`, so the normal path is to add one module or package there and let discovery pick it up.
|
||||
|
||||
Files to touch:
|
||||
|
||||
- `integrations/<vendor>/tools/<tool_name>_tool/__init__.py` when the tool belongs to an existing vendor integration (most common path).
|
||||
- `tools/system/<ToolName>/__init__.py` or `tools/cross_vendor/<ToolName>/__init__.py` only when the tool is not vendor-specific — see [docs/tool-placement-policy.md](docs/tool-placement-policy.md) for the system vs. cross_vendor decision rule (e.g. `tools/system/sre_guidance_tool/`).
|
||||
- `core/tool_framework/utils/` if the tool needs shared helper code reused across vendors.
|
||||
- `integrations/<name>/client.py` if the tool should reuse a dedicated integration API client instead of inlining requests.
|
||||
- `docs/<tool_name>.mdx` for user-facing usage, parameters, and examples.
|
||||
- `docs/docs.json` — add the page path (without `.mdx`) to the appropriate `pages` array so Mintlify navigation includes it.
|
||||
- `tests/tools/test_<tool_name>.py` for behavior and regression coverage.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Pick the simplest shape that fits the tool. Use a `BaseTool` subclass (from `core.tool_framework.base`) for richer behavior; use `@tool(...)` from `core.tool_framework.tool_decorator` for a lightweight function tool.
|
||||
2. Declare clear metadata: `name`, `description`, `source`, `input_schema`, and any `use_cases`, `requires`, `outputs`, or `retrieval_controls` you need.
|
||||
3. Treat tool packages as production code, not registry placeholders. A tool package may not be an empty or nearly-empty `__init__.py` whose only purpose is discovery. Directionally, non-trivial tools should use focused sibling modules such as `tool.py`, `client.py`/`delivery.py`, `validation.py`, `models.py`, or `results.py`; `__init__.py` should usually be a small registry entrypoint that imports the public tool object.
|
||||
4. Keep separation of concerns. Put reusable transport or integration-specific parsing code in `integrations/<name>/` or shared tool glue in `core/tool_framework/utils/` rather than copying it into the tool body. Split validation, credential/parameter resolution, dispatch/client calls, result normalization, and error handling into focused helpers or sibling files instead of tangling them inside `run()`.
|
||||
5. Return stable, planner-friendly results. Expected failures should produce a structured error shape; external side effects must declare `side_effect_level`, require approval when appropriate, and avoid leaking secrets through `extract_params`, return values, logs, or traceable tool-call kwargs.
|
||||
6. If the tool should appear in both investigation and chat surfaces, set `surfaces=("investigation", "chat")`.
|
||||
7. Add tests that cover schema shape, availability, extraction, success, failure, and the runtime behavior that the planner depends on.
|
||||
8. Before opening or approving the PR, follow [TOOL_INTEGRATION_CHECKLIST.md](TOOL_INTEGRATION_CHECKLIST.md) for tool/integration-specific wiring, payload, docs, and regression checks.
|
||||
|
||||
### Changing the investigation pipeline
|
||||
|
||||
Investigations are coordinated in `tools/investigation/lifecycle.py` and exposed via
|
||||
`tools/investigation/capability.py`. Semantic stages live under
|
||||
`tools/investigation/stages/`; reporting lives under
|
||||
`tools/investigation/reporting/`. See
|
||||
[docs/investigation-pipeline-architecture.md](docs/investigation-pipeline-architecture.md)
|
||||
for the end-to-end stage/loop diagrams before making structural changes.
|
||||
|
||||
Files to touch:
|
||||
|
||||
- `tools/investigation/lifecycle.py` for high-level stage ordering.
|
||||
- `core/state/` for shared agent state and investigation pipeline slice contracts
|
||||
that cross stage boundaries.
|
||||
- `core/domain/` for pure investigation rules (alert source mapping, tool planning,
|
||||
category alignment, correlation scoring).
|
||||
- `core/` for shared LLM runtime helpers (tool loop and LLM invoke error
|
||||
classification).
|
||||
- `core/state/*.py` when adding or renaming persisted investigation fields
|
||||
(update `AgentStateModel` and the matching slice).
|
||||
- `docs/` — update or add a page if the change introduces user-visible behavior or configuration.
|
||||
- `tests/` coverage for the affected CLI, synthetic, or integration paths.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Keep each stage focused on one responsibility.
|
||||
2. Extend state models when new fields cross stage boundaries.
|
||||
3. Update tests that exercise `run_investigation` / streaming entry points.
|
||||
|
||||
### Adding an Integration
|
||||
|
||||
Integration work usually spans config normalization, verification, integration-local clients/helpers, tools, docs, and tests.
|
||||
|
||||
Files to touch:
|
||||
|
||||
- `integrations/<name>/__init__.py` for config builders, validators, selectors, and normalization helpers.
|
||||
- `integrations/<name>/client.py` when the integration needs a dedicated API client.
|
||||
- `integrations/<name>/verifier.py` when the integration needs local verification logic.
|
||||
- `integrations/catalog.py` when the new integration must be resolved into the shared runtime config.
|
||||
- `integrations/verify.py` when the integration needs a local verification path.
|
||||
- `tools/<Name>Tool/` or `tools/<tool_file>.py` for the user-facing tool layer, or
|
||||
`integrations/<name>/tools/` when consolidating a vendor's tools under its integration package.
|
||||
- `docs/<name>.mdx` for user-facing setup, usage, and verification docs.
|
||||
- `docs/docs.json` — add the page path (without `.mdx`) to the appropriate `pages` array so Mintlify navigation includes it.
|
||||
- `tests/integrations/test_<name>.py` for config, verification, and store coverage.
|
||||
- `tests/tools/test_<tool_name>.py` and any relevant `tests/e2e/` or `tests/synthetic/` files if the integration is exercised by tools or scenarios.
|
||||
|
||||
Treat `integrations/` as the canonical user/config and external-client boundary, and `tools/` as the canonical agent-callable boundary. Do not add or import top-level `vendors/` or `services/` packages.
|
||||
|
||||
Examples from the repo:
|
||||
|
||||
- Datadog: `integrations/datadog/` (including `integrations/datadog/tools/` for query tools), `integrations/catalog.py`, and Datadog-related tests under `tests/integrations/datadog/` and `tests/tools/test_datadog_*.py`.
|
||||
- Grafana: `integrations/grafana/` (including `integrations/grafana/tools/` for query tools), `integrations/catalog.py`, `surfaces/cli/wizard/local_grafana_stack/`, and Grafana-related tests under `tests/integrations/grafana/` and `tests/tools/test_grafana_*.py`.
|
||||
- Hermes: `integrations/hermes/`, `tools/HermesLogsTool/`, `tools/HermesSessionEvidenceTool/`, `surfaces/cli/commands/hermes.py`, `tests/hermes/`, and `tests/synthetic/hermes/`.
|
||||
|
||||
Basic steps:
|
||||
|
||||
1. Add the integration config and normalization logic first so the rest of the stack can consume a consistent shape.
|
||||
2. Add or update the integration-local client only when the integration needs direct remote calls.
|
||||
3. Wire the tool layer after the config path is stable.
|
||||
4. Add docs and tests together so the integration is understandable and verifiable.
|
||||
5. Run `make verify-integrations` before treating the integration as complete.
|
||||
6. Before opening or approving the PR, follow [TOOL_INTEGRATION_CHECKLIST.md](TOOL_INTEGRATION_CHECKLIST.md) for integration completeness, investigation wiring, docs, and demo/test requirements.
|
||||
|
||||
### Large multi-surface refactors
|
||||
|
||||
A consolidation refactor collapses behavior that has diverged across
|
||||
multiple surfaces (`interactive_shell/`, `gateway/`, `tools/investigation/`,
|
||||
`core/agent_harness/`, etc.) into one shared class or module — e.g. the
|
||||
`agent_harness` T-2/T-3 series (session management, integration resolution,
|
||||
startup consolidation). These are higher-risk than a normal feature or tool
|
||||
change: they touch several call sites at once and the source issue's file
|
||||
paths tend to be stale by the time work starts.
|
||||
|
||||
Before starting this class of work, follow
|
||||
[REFACTOR_CHECKLIST.md](REFACTOR_CHECKLIST.md) — it covers dependency
|
||||
ordering, re-validating the issue against current repo state, incremental
|
||||
per-surface migration, and the import-boundary tests that must keep
|
||||
enforcing the new pattern.
|
||||
|
||||
## 3. Rules (if X -> do Y)
|
||||
|
||||
- If core agent or pipeline logic changes -> run `make test-cov` and `make typecheck`.
|
||||
- If a change consolidates or re-homes behavior across multiple surfaces (a "refactor" issue, not a localized fix) -> follow [REFACTOR_CHECKLIST.md](REFACTOR_CHECKLIST.md) before writing code and before opening the PR.
|
||||
- If a new feature is shipped (tool, CLI command, pipeline behavior, integration) -> add a `docs/` page or section covering usage, configuration, and examples before the PR is opened.
|
||||
- If a new `docs/` page is added or renamed -> register it in `docs/docs.json` under the correct `pages` array in the same PR (path without `.mdx`, e.g. `messaging/whatsapp` for `docs/messaging/whatsapp.mdx`).
|
||||
- If an existing feature changes behavior, flags, or config shape -> update the relevant `docs/` page in the same PR; docs and code must stay in sync.
|
||||
- When writing or editing a `docs/` page -> write for **users, not contributors**. Open with a command quick-reference table (command | what it does) if the page covers CLI commands. Follow with brief practical examples. Keep internal file formats, JSONL schemas, and implementation details out of user-facing pages — move those to `docs/DEVELOPMENT.md` or a contributor-only reference file if truly needed.
|
||||
- If a tool's API or schema changes -> update docs in `docs/` and update the related unit tests, usually under `tests/tools/`. For investigation LLM tool-calling (any provider), follow [docs/investigation-tool-calling.md](docs/investigation-tool-calling.md).
|
||||
- If adding or materially changing a tool/integration -> follow [TOOL_INTEGRATION_CHECKLIST.md](TOOL_INTEGRATION_CHECKLIST.md) in the same PR.
|
||||
- If an integration changes -> update `tests/integrations/` and verify with `make verify-integrations`.
|
||||
- If adding a new integration -> follow [TOOL_INTEGRATION_CHECKLIST.md](TOOL_INTEGRATION_CHECKLIST.md) before opening the PR for review.
|
||||
- If adding new tests -> place them in `tests/`, never inside the source packages (no inline tests), except gateway tests which intentionally live in `gateway/tests/` per `gateway/AGENTS.md`.
|
||||
- If CI-only tests are added -> mark them with the right pytest marker or place them in the appropriate e2e/synthetic/chaos folder so they do not run in the default local suite.
|
||||
- If investigation branching or loop behavior changes -> update `tools/investigation/lifecycle.py` and the tests for that path.
|
||||
- If adding or changing interactive REPL behavior (slash commands, session management, display output) -> use `ReplDriver` from `tests/utils/repl_driver.py` for live verification alongside unit tests; see [TESTING.md](TESTING.md).
|
||||
- If pushing or creating a PR -> follow the full pre-push checklist in [CI.md](CI.md).
|
||||
|
||||
## 4. Testing
|
||||
|
||||
Test commands, turn-handling rules, CI-only paths: **[CI.md](CI.md)**. Live REPL testing with `ReplDriver`: **[TESTING.md](TESTING.md)**.
|
||||
|
||||
## 5. Footguns (common mistakes to avoid)
|
||||
|
||||
- No planning-stage fail-closed safeguard (v0.1): the interactive-shell action planner never denies a turn with "I couldn't safely decide actions". All terminal actions are read-only, so unmatched/ambiguous/chatty clauses run what they can and fall through to the assistant. Do **not** reintroduce a planner denial, the `mark_unhandled` tool, or the `UNHANDLED:` convention. Rationale and details: `core/agent_harness/AGENTS.md` and `docs/interactive-shell-action-policy.md`. If mutating actions are ever added, gate them at the execution stage (`tools/interactive_shell/shared/execution_policy.py`), not the planner.
|
||||
- Vendored deps: No obvious vendored third-party dependencies are present. Python dependencies are managed in `pyproject.toml`, and the docs site has its own `docs/package.json` and `docs/pnpm-lock.yaml`. Do not vendor new libraries unless there is a strong reason.
|
||||
- Secrets: Never commit `.env` - always use `.env.example` as the template. Use read-only credentials for production integrations.
|
||||
- CI-only tests: Some e2e tests, including Kubernetes, EKS, and chaos engineering paths, require live infrastructure and are excluded from `make test-cov`. Do not expect them to pass locally without that environment.
|
||||
- Legacy graph dev server: removed; use `make dev` for a local uvicorn hint or run investigations via the CLI.
|
||||
- Docker requirement: Several targets, including the Grafana local stack and Chaos Mesh workflows, require a running Docker daemon.
|
||||
- Docs navigation: Adding an `.mdx` file under `docs/` is not enough — Mintlify only shows pages listed in `docs/docs.json`. Forgetting the `pages` entry leaves the doc unreachable from the site sidebar.
|
||||
- Investigation tool schemas: draft-07 JSON Schema (e.g. `"type": ["object", "null"]`) can pass loose checks but fail the LLM API on first invoke because **all** available investigation tools are sent together. Normalize in the provider adapter and extend registry contract tests; see [docs/investigation-tool-calling.md](docs/investigation-tool-calling.md).
|
||||
- External-system code: `integrations/` owns config, clients, verifiers, and integration-local helpers; `tools/` owns every `@tool(...)` function and `BaseTool` class. Do not reintroduce top-level `vendors/` or `services/` packages.
|
||||
- Compatibility shims: Do not leave modules whose only job is to re-export symbols from a new
|
||||
location. Update callers to the canonical module and delete the old path.
|
||||
- Empty or monolithic tool packages: Do not add a `tools/<name>/__init__.py`
|
||||
that exists only to make discovery pass, and do not hide a non-trivial tool
|
||||
implementation entirely in `__init__.py`. Use sibling modules for validation,
|
||||
models, delivery/client calls, result shaping, and error handling whenever the
|
||||
tool is more than a small function. Every tool must meet the implementation
|
||||
and quality standards in the Adding a Tool section and
|
||||
[TOOL_INTEGRATION_CHECKLIST.md](TOOL_INTEGRATION_CHECKLIST.md).
|
||||
- Interactive-shell action selection: do not implement regex/keyword/fuzzy
|
||||
intent routing, literal slash-command shortcuts, or deterministic action
|
||||
bypasses around the action-agent AgentTool path. Engineers have been fired
|
||||
before for implementing this exact shortcut. The runtime's literal-`/slash`
|
||||
detection (`input_policy._literal_slash_command_text`) is terminal UI policy
|
||||
only (spinner/stdin gating), not an execution path.
|
||||
|
||||
## 6. New Integration Checklist
|
||||
|
||||
Follow [TOOL_INTEGRATION_CHECKLIST.md](TOOL_INTEGRATION_CHECKLIST.md) — it is the single definition of done for all tool and integration work.
|
||||
|
||||
## 7. Large Refactor Checklist
|
||||
|
||||
Follow [REFACTOR_CHECKLIST.md](REFACTOR_CHECKLIST.md) — it is the single definition of done for refactors that consolidate or re-home behavior across multiple surfaces (see "Large multi-surface refactors" above).
|
||||
@@ -0,0 +1,155 @@
|
||||
# CI Readiness — Mandatory Push/PR Harness
|
||||
|
||||
This file is the **single source of truth** for local CI readiness before any push or PR.
|
||||
|
||||
## 0) Docs / process-only shortcut
|
||||
|
||||
If your diff is **only** documentation or contributor-process files, you may
|
||||
skip the code-quality and test commands below.
|
||||
|
||||
Examples of files that qualify:
|
||||
|
||||
- `AGENTS.md`
|
||||
- `CI.md`
|
||||
- `CONTRIBUTING.md`
|
||||
- `README.md`
|
||||
- `TESTING.md`
|
||||
- `TOOL_INTEGRATION_CHECKLIST.md`
|
||||
- `docs/**/*.md`
|
||||
- `docs/**/*.mdx`
|
||||
- `docs/docs.json`
|
||||
|
||||
You may use the shortcut only when **all** changed files are non-runtime and
|
||||
non-executable. If the diff touches application code, tests, build tooling,
|
||||
dependency manifests, CI workflows, scripts, or anything with runtime impact,
|
||||
run the normal harness.
|
||||
|
||||
For docs/process-only changes, the minimum required local check is:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
If you are unsure whether the shortcut applies, do **not** use it — run the
|
||||
standard checks below.
|
||||
|
||||
## 1) Mandatory baseline checks (every code change that is not docs/process-only)
|
||||
|
||||
Run all of these first:
|
||||
|
||||
1. Clean working tree
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
- No accidental untracked files
|
||||
- Never commit `.env` or secrets
|
||||
|
||||
2. Lint
|
||||
|
||||
```bash
|
||||
make lint
|
||||
```
|
||||
|
||||
3. Format check
|
||||
|
||||
```bash
|
||||
make format-check
|
||||
```
|
||||
|
||||
If it fails:
|
||||
|
||||
```bash
|
||||
make format && make format-check
|
||||
```
|
||||
|
||||
4. Typecheck
|
||||
|
||||
```bash
|
||||
make typecheck
|
||||
```
|
||||
|
||||
## 2) Mandatory test harness (scope by touched modules)
|
||||
|
||||
**Recommended — run this instead of manually looking up the table below:**
|
||||
|
||||
```bash
|
||||
make test-scope
|
||||
```
|
||||
|
||||
`make test-scope` reads `git diff` against `main`, maps each changed path to
|
||||
its test target(s) using [`.github/ci/test_scope_rules.py`](.github/ci/test_scope_rules.py),
|
||||
and runs the minimal `pytest` invocation. It escalates automatically to
|
||||
`make test-cov` when shared/core code is touched or 3+ app areas change.
|
||||
Pass `ARGS=--dry-run` to preview without running.
|
||||
|
||||
### Manual lookup (reference only)
|
||||
|
||||
If you prefer to pick the command yourself, or need a focused `-k` filter,
|
||||
see the `PathRule` entries in [`.github/ci/test_scope_rules.py`](.github/ci/test_scope_rules.py).
|
||||
Rules with `always_escalate=True` map to `make test-cov`; all others list their
|
||||
`test_targets` tuple. Changed files under `tests/` with no app rule run as-is.
|
||||
|
||||
## 3) Escalation rules (must run full unit CI suite)
|
||||
|
||||
Run `make test-cov` (instead of only targeted tests) when any of these are true:
|
||||
|
||||
- Shared/core code changed (`core/state/`, `core/domain/types/`, `tools/investigation/`, `tools/investigation/stages/`)
|
||||
- 3+ app areas changed in one diff
|
||||
- New files with unclear blast radius
|
||||
- Cross-cutting refactor
|
||||
- You are unsure test scope is sufficient
|
||||
|
||||
```bash
|
||||
make test-cov
|
||||
```
|
||||
|
||||
## 4) Conditional checks
|
||||
|
||||
If integration config, integration wiring, or related tools changed, also run:
|
||||
|
||||
```bash
|
||||
make verify-integrations
|
||||
```
|
||||
|
||||
## 5) Optional extra confidence
|
||||
|
||||
You may run `make check` as a final pass, but it is heavier (`test-full`) than the required harness.
|
||||
|
||||
## 6) Interactive-shell turn tests
|
||||
|
||||
Interactive-shell live turn tests always run with live coverage enabled. Do not use deselection filters like `-k "not live_llm"`. Fix failures by improving planner/tool correctness or updating fixtures only when behavior changes are explicitly approved.
|
||||
|
||||
For fast **local** iteration only, you can narrow the live suite with `--turn-select` (or the `TURN_SELECT` env var) without disabling live coverage:
|
||||
|
||||
- `--turn-select=complex:N` runs the N most complex scenarios (multi-step plans, `runs > 1`, gather contracts, and `@live` integrations score highest).
|
||||
- `--turn-select=sample:N` runs a random N; add `--turn-select-seed` (or `TURN_SELECT_SEED`) for reproducibility.
|
||||
- `N` may be a count (`5`), a fraction (`0.1`), or a percentage (`10%`); a bare `complex`/`sample` defaults to 5%.
|
||||
|
||||
```bash
|
||||
# Most complex five scenarios
|
||||
uv run python -m pytest tests/core/agent/test_turn_scenarios.py --turn-select=complex:5
|
||||
# Random ~5% sample, reproducible
|
||||
TURN_SELECT=sample:5% TURN_SELECT_SEED=7 uv run python -m pytest tests/core/agent/test_turn_scenarios.py
|
||||
```
|
||||
|
||||
This is an iteration aid, not a substitute for full coverage: leave it unset for the pre-push/PR validation run, and never set it in CI (the sharded `turn-live` job runs every scenario).
|
||||
|
||||
In CI, [`.github/workflows/interactive-shell-live.yml`](.github/workflows/interactive-shell-live.yml) runs two jobs on same-repo PRs and post-merge `main` pushes: a no-LLM `turn-checks` gate (deterministic command detection + fixture integrity, `-m "not live_llm"`) and the sharded `turn-live` job (8 shards, live coverage). The no-LLM gate is a fast guardrail, not a substitute for live coverage.
|
||||
|
||||
`@live` gather scenarios **fail** (not skip) in GitHub Actions when integration credentials are missing; locally they may still skip. Natural-language investigation dispatch is **enabled** by default (`INTERACTIVE_SHELL_INVESTIGATION_ENABLED = True`). Investigation dispatch scenarios run in `turn-live`; if the flag is set to `False` for emergency rollback, those scenarios **skip** in live shards and `turn-checks` stays green. Require all `turn-checks` and `turn-live shard *` checks on `main` branch protection.
|
||||
|
||||
## 7) CI-only tests
|
||||
|
||||
Some paths require live infrastructure and are excluded from `make test-cov`:
|
||||
|
||||
- Kubernetes / EKS scenarios (`tests/e2e/`)
|
||||
- Chaos Mesh workflows (`tests/chaos_engineering/`)
|
||||
- Docker-dependent Grafana stack tests
|
||||
|
||||
Mark CI-only tests with the appropriate pytest marker or place them in the correct folder so they do not run locally by default.
|
||||
|
||||
## Precedence
|
||||
|
||||
If readiness instructions conflict across docs, **this file wins** for push/PR checks.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Code of Conduct
|
||||
|
||||
We aim to keep the Tracer project respectful, constructive, and professional.
|
||||
|
||||
Please:
|
||||
|
||||
- be respectful in discussions
|
||||
- focus on technical issues and improvements
|
||||
- provide constructive feedback
|
||||
|
||||
Harassment, discrimination, or personal attacks are not tolerated.
|
||||
|
||||
If you encounter unacceptable behavior, contact:
|
||||
|
||||
support@opensre.com
|
||||
|
||||
Maintainers may remove comments, issues, or contributions that violate these expectations.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Contributing
|
||||
|
||||
Welcome to OpenSRE
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **GitHub:** [https://github.com/Tracer-Cloud/opensre](https://github.com/Tracer-Cloud/opensre)
|
||||
- **Discord:** [https://discord.gg/opensre](https://discord.gg/opensre)
|
||||
- **X/Twitter:** [@open_sre](https://x.com/open_sre)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Looking for a safe first contribution? See [Good First Issues](docs/good-first-issues/README.md).
|
||||
|
||||
Use the path that matches the kind of contribution you want to make:
|
||||
|
||||
1. **Bugs & small fixes** -> Open a PR. If you need to file an issue first, use the [bug report template](https://github.com/Tracer-Cloud/opensre/issues/new?template=bug_report.yml).
|
||||
2. **New features or behavioral changes** -> Start with a [feature request](https://github.com/Tracer-Cloud/opensre/issues/new?template=feature_request.yml) or ask in Discord before coding. Most feature ideas are better shipped as third-party plugins via the plugin SDK.
|
||||
3. **Improvements tied to concrete work** -> Use the [improvement template](https://github.com/Tracer-Cloud/opensre/issues/new?template=improvement.yml) when proposing a focused refactor, optimization, or quality improvement.
|
||||
4. **Refactor-only PRs** -> Do not open one unless a maintainer explicitly asked for it as part of a real fix.
|
||||
5. **Test/CI-only PRs for known `main` failures** -> Do not open one unless the change is required to validate a real fix the maintainers asked for.
|
||||
6. **Questions** -> Use the docs, email [support@opensre.com](mailto:support@opensre.com), or ask in Discord [#contribute](http://discord.gg/opensre). GitHub Issues are for actionable work.
|
||||
7. **Security issues** -> Follow `SECURITY.md`; do not open a public issue.
|
||||
|
||||
### Environment Setup
|
||||
|
||||
See **[SETUP.md](SETUP.md)** for detailed setup instructions including Windows-specific guidance. For benchmark, deployment detail, and telemetry reference, see **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)**.
|
||||
|
||||
**Quick start:**
|
||||
|
||||
1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and clone the repository (see [SETUP.md](SETUP.md) for Windows and alternatives)
|
||||
2. Install dependencies: `make install`
|
||||
3. Run checks: `make lint && make format-check && make typecheck && make test-cov`
|
||||
- When invoking the CLI from your checkout, prefer **`uv run opensre …`** (see `SETUP.md` troubleshooting if another `opensre` shadows `.venv`).
|
||||
4. Build release artifacts when needed: `make build`
|
||||
|
||||
If you prefer VS Code, use the devcontainer at [.devcontainer/devcontainer.json](.devcontainer/devcontainer.json). Details: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#vs-code-dev-container).
|
||||
|
||||
---
|
||||
|
||||
**Contribution flow:**
|
||||
|
||||
1. **Find or create an issue** — Pick an existing one (Path A) or raise a new one (Path B)
|
||||
2. **Request assignment** — Comment on the issue so maintainers know you're working on it
|
||||
3. **Discuss (if needed)** — For features/changes, discuss approach in the issue before coding
|
||||
4. **Fork and branch** — Create a branch for your work: `git checkout -b issue/123-description`
|
||||
5. **Code and test** — Make changes, add tests, ensure all checks pass
|
||||
6. **Submit a PR** — Open a pull request (or draft PR) linked to the issue; use the PR template
|
||||
7. **Review & iterate** — Respond to feedback, make changes as needed
|
||||
8. **Merge** — Maintainer merges once approved
|
||||
|
||||
**Detailed steps:** See the "Development Workflow" section below.
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Create a Branch
|
||||
|
||||
```bash
|
||||
git checkout -b issue/123-short-description
|
||||
```
|
||||
|
||||
Use `issue/` or `fix/` prefix. Branch names should be lowercase with hyphens.
|
||||
|
||||
### 2. Make Changes
|
||||
|
||||
- Keep commits focused and logical
|
||||
- Write clear commit messages: `"Fix: CLI returns error on incomplete commands"`
|
||||
- One concern per commit when possible
|
||||
|
||||
### 2.1 Add a Tool (Fast Path: Single File)
|
||||
|
||||
For simple tools, you do not need a class or `ClassVar` metadata. Add one file under `tools/` and register a function with `@tool`.
|
||||
|
||||
Example (`tools/example_status_tool.py`):
|
||||
|
||||
```python
|
||||
from core.tool_framework.tool_decorator import tool
|
||||
|
||||
|
||||
@tool(source="knowledge")
|
||||
def get_example_status(run_id: str, include_history: bool = False) -> dict[str, object]:
|
||||
"""Return a lightweight status summary for a run."""
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"include_history": include_history,
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `source` is required for function tools.
|
||||
- `name`, `description`, and `input_schema` are inferred by default.
|
||||
- `surfaces` defaults to `("investigation",)`. Pass `surfaces=("investigation", "chat")` to expose the tool in both investigation and chat contexts.
|
||||
- Use the existing package/class style when a tool has complex helper logic, multiple exports, or substantial integration-specific code.
|
||||
|
||||
### 3. Add or Update Tests
|
||||
|
||||
- **Test Location:** New tests should be placed in the `tests/` directory, mirroring the source package area when useful (e.g., tests for `surfaces/cli/` go in `tests/cli/`).
|
||||
- **No Inline Tests:** Avoid adding `*_test.py` files directly inside source packages. We are phasing out existing inline tests to keep the core logic clean.
|
||||
- Bug fixes should include a test that would have caught the bug
|
||||
- New features should have corresponding tests
|
||||
- Aim for >80% code coverage (run `make test-cov` to check)
|
||||
|
||||
#### Tests under `tests/synthetic/` need an explicit `pytest.mark.synthetic` marker
|
||||
|
||||
The synthetic test tree has its own Make target (`make test-synthetic`) and is excluded from `make test-cov`. The two targets use marker filters:
|
||||
|
||||
- `make test-cov` runs `pytest --ignore=tests/synthetic -m "not synthetic"`, so the whole `tests/synthetic/` tree is excluded.
|
||||
- `make test-synthetic` runs `pytest -m synthetic`, so a file without `pytest.mark.synthetic` is collected but skipped.
|
||||
|
||||
If you add a new test file under `tests/synthetic/`, declare the marker at module level so the file runs under `make test-synthetic`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.synthetic
|
||||
```
|
||||
|
||||
Without this marker the new file silently runs in **zero** standard CI configurations. The pattern is already in `tests/synthetic/rds_postgres/test_suite.py`; new files in the same tree should follow it.
|
||||
|
||||
See [#1671](https://github.com/Tracer-Cloud/opensre/issues/1671) for the meta-issue tracking this discoverability gap.
|
||||
|
||||
### 4. Run Local Checks (Required Before PR)
|
||||
|
||||
```bash
|
||||
make lint # ruff: check code style
|
||||
make format-check # ruff: check formatting (read-only)
|
||||
make typecheck # mypy: check type annotations
|
||||
make test-cov # pytest: run tests with coverage report
|
||||
```
|
||||
|
||||
All four must pass. **CI will block merging if any fail.**
|
||||
|
||||
### Run one focused test
|
||||
|
||||
Replace the placeholders with your actual file or test name:
|
||||
|
||||
```bash
|
||||
pytest tests/cli/test_.py # single file
|
||||
pytest tests/cli/test_.py::test_ # single function
|
||||
pytest tests/tools/ -k "test_registry" # tools example
|
||||
pytest tests/synthetic/ -k "test_scenario" # no live infra needed
|
||||
```
|
||||
|
||||
### 5. Open a Pull Request
|
||||
|
||||
Follow the PR template (see below). Link the relevant issue and describe what changed and why.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### How to Write a Good PR Description
|
||||
|
||||
Use the **[PR template](.github/PULL_REQUEST_TEMPLATE.md)** (automatically provided when you open a PR). Key sections:
|
||||
|
||||
- **Issue link:** `Fixes #123` (auto-closes the issue when merged)
|
||||
- **Type of Change:** Select bug fix, feature, breaking change, or docs (helps categorize)
|
||||
- **Description:** What changed and why
|
||||
- **Testing:** How you tested it with specific steps and evidence
|
||||
- **Impact Analysis:** Is it backward compatible? Any breaking changes? Performance impact?
|
||||
|
||||
### PR Checklist Before Submitting
|
||||
|
||||
- Linked to the relevant issue
|
||||
- All local checks pass: `make lint && make format-check && make typecheck && make test-cov`
|
||||
- Added tests for bug fixes or new features
|
||||
- Updated documentation if behavior changed
|
||||
- Code follows project style (see **Code Quality** section below)
|
||||
- Self-reviewed your own code first
|
||||
- Considered edge cases
|
||||
|
||||
### Greptile Code Review
|
||||
|
||||
We use [Greptile](https://greptile.com) for automated code review. Before a PR can be merged it must reach a **5/5 confidence score** with zero unresolved comments.
|
||||
|
||||
**Trigger a review** by posting this comment on your PR:
|
||||
|
||||
```
|
||||
@greptile review
|
||||
```
|
||||
|
||||
Wait 30–60 seconds for the review to appear, then address each comment and re-trigger until you hit 5/5.
|
||||
|
||||
> **Automate the loop** — the [greploop skill](https://skills.sh/greptileai/skills/greploop) handles triggering, waiting, fixing, and re-reviewing automatically until 5/5 is reached.
|
||||
|
||||
### If Your PR Includes Screenshots or Logs
|
||||
|
||||
Provide **before** and **after** examples when:
|
||||
|
||||
- Changing CLI output or error messages
|
||||
- Updating agent behavior
|
||||
- Fixing a bug with visible impact
|
||||
|
||||
### AI-Assisted PRs
|
||||
|
||||
If you used AI tools (Claude, ChatGPT, Copilot, etc.) to generate code, the **[PR template](.github/PULL_REQUEST_TEMPLATE.md)** requires you to confirm:
|
||||
|
||||
- I reviewed **every single line** of AI-generated code (not just skimmed)
|
||||
- I understand the logic and can explain it in my own words
|
||||
- I tested edge cases (what could break?)
|
||||
- I modified output to match project conventions ([Code Quality Standards](#code-quality-standards))
|
||||
- Verified tests pass with the AI-generated code
|
||||
|
||||
This ensures you understand the code, not just copied it. Reviewers will pay extra attention to AI-assisted code.
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
- **Clarity over cleverness:** Code should be easy to understand and maintain
|
||||
- **DRY principle:** Don't repeat yourself; extract common patterns
|
||||
- **Strong typing:** Use type hints for all function parameters and returns
|
||||
- **One responsibility:** Each function/class should do one thing well
|
||||
- **Comments for "why":** Explain non-obvious logic; code already shows the "what"
|
||||
- **Breaking changes:** Call them out explicitly in PR descriptions and docs
|
||||
|
||||
### Style & Formatting
|
||||
|
||||
We use:
|
||||
|
||||
- **ruff** for linting and import sorting
|
||||
- **mypy** for strict type checking
|
||||
- **Black-compatible** formatting (4-space indents)
|
||||
- **pytest** for testing with coverage tracking
|
||||
|
||||
Run these before every commit:
|
||||
|
||||
```bash
|
||||
make lint # Auto-fixes many style issues
|
||||
make format-check # Checks formatting without modifying files
|
||||
make typecheck # Catches type errors
|
||||
make test-cov # Ensures tests pass and coverage is tracked
|
||||
```
|
||||
|
||||
To verify the package can be shipped, run:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Use the **[bug report template](https://github.com/Tracer-Cloud/opensre/issues/new?template=bug_report.yml)** when creating an issue. It guides you to include:
|
||||
|
||||
- **Summary:** One-line description of the bug (specific, not vague)
|
||||
- **Expected behavior:** What should happen
|
||||
- **Actual behavior:** What actually happens (with error message)
|
||||
- **Reproduction steps:** Clear, minimal steps to consistently trigger the bug
|
||||
- **Can you reproduce it consistently?** Every time / Intermittent / Sometimes
|
||||
- **Environment:** OS, Python version, agent version, install method, relevant config
|
||||
- **Error output:** Full error messages and logs (redact secrets like API keys)
|
||||
- **Workarounds:** If you found a way to work around it
|
||||
- **Context:** What were you trying to do? Is this blocking your work?
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
### Expected Behavior
|
||||
`opensre investigate --org myorg` should return investigation results
|
||||
|
||||
### Actual Behavior
|
||||
Command exits silently with no output
|
||||
Error: exit code 0
|
||||
|
||||
### Steps to Reproduce
|
||||
1. Run `opensre investigate --org myorg`
|
||||
2. Observe output
|
||||
|
||||
### Environment
|
||||
- OS: macOS 14.2
|
||||
- Python: 3.11.5
|
||||
- opensre version: v0.2.1
|
||||
```
|
||||
|
||||
## Requesting Features
|
||||
|
||||
Use the **[feature request template](https://github.com/Tracer-Cloud/opensre/issues/new?template=feature_request.yml)** to propose new functionality. It guides you to clarify:
|
||||
|
||||
- **Problem statement:** Why do we need this? (focus on the problem, not solution)
|
||||
- **Proposed solution:** How should it work? (specific and concrete with examples)
|
||||
- **Acceptance criteria:** What needs to be true for this to be "done"?
|
||||
- **Alternative approaches:** Other solutions you considered and why you prefer this one
|
||||
- **Backward compatible?** Yes / No / Breaking changes (describe what changes)
|
||||
- **Impact:** Which modules? New dependencies?
|
||||
|
||||
## Suggesting Improvements
|
||||
|
||||
Use the **[improvement template](https://github.com/Tracer-Cloud/opensre/issues/new?template=improvement.yml)** to propose refactors, optimizations, or quality improvements. It requires:
|
||||
|
||||
- **Current state:** How does it work now? (with code references)
|
||||
- **Desired state:** How should it work instead?
|
||||
- **Why it matters:** Performance? Maintainability? Reliability?
|
||||
- **Scope:** One focused concern per issue (not bundled work)
|
||||
- **Acceptance criteria:** How will we measure success?
|
||||
- **Metrics:** Before and after values (e.g., "15ms → <1ms")
|
||||
|
||||
## Need Help?
|
||||
|
||||
- **Setup issues?** Check this guide first, then open an issue with details
|
||||
- **How do I...?** Check the project docs or ask in a Discussion
|
||||
- **Found a bug?** Open a bug report issue with the template
|
||||
- **Have an idea?** Start a Discussion to gauge interest before opening an issue
|
||||
|
||||
## Licensing
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the project's license (see `LICENSE`).
|
||||
@@ -0,0 +1,154 @@
|
||||
## Deployment
|
||||
|
||||
OpenSRE has two primary deployment paths (both target AWS EC2) and a general hosted
|
||||
runtime option for ASGI-compatible platforms.
|
||||
|
||||
---
|
||||
|
||||
## EC2 Deploy — Docker/ECR (web + gateway)
|
||||
|
||||
Runs `opensre-web` and `opensre-gateway` as Docker containers on a single EC2 instance.
|
||||
The image is built once and pushed to ECR; subsequent redeploys reuse the cached image.
|
||||
|
||||
**Prerequisites:** Docker daemon running locally, AWS credentials with EC2 / ECR / IAM /
|
||||
SSM permissions, region defaults to `us-east-1`.
|
||||
|
||||
Copy [`.env.deploy.example`](.env.deploy.example) and export the required variables:
|
||||
|
||||
| Variable | Required | Used by |
|
||||
| -------- | -------- | ------- |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Yes (or role) | Provisioning |
|
||||
| `TELEGRAM_BOT_TOKEN` | Yes | Gateway container |
|
||||
| `TELEGRAM_ALLOWED_USERS` | Recommended | Gateway pairing gate |
|
||||
| `LLM_PROVIDER` + API key | Yes | Both containers |
|
||||
|
||||
```bash
|
||||
# Step 1 — build and push Docker image to ECR (run once per code change):
|
||||
make build-image
|
||||
|
||||
# Step 2 — launch EC2 instance using the pre-built image (fast, no Docker build):
|
||||
make deploy
|
||||
|
||||
# Tear down the stack (keeps ECR image by default):
|
||||
make destroy
|
||||
|
||||
# Full teardown including ECR repository:
|
||||
OPENSRE_DESTROY_PURGE_ECR=1 make destroy
|
||||
```
|
||||
|
||||
After deploy:
|
||||
|
||||
```bash
|
||||
curl http://<PublicIpAddress>:8000/health
|
||||
```
|
||||
|
||||
Outputs (instance ID, public IP, image URI) are written to
|
||||
`~/.opensre/deployments/opensre-ec2.json`.
|
||||
|
||||
`make deploy` auto-destroys any existing stack before provisioning a fresh one. Set
|
||||
`OPENSRE_DEPLOY_ABORT_IF_EXISTS=1` to fail instead of auto-destroying.
|
||||
|
||||
---
|
||||
|
||||
## Gateway Deploy — AMI + systemd (Telegram gateway only)
|
||||
|
||||
Runs the Telegram gateway directly on EC2 as a systemd service — no Docker or ECR
|
||||
required. The gateway is baked into a custom AMI once; subsequent deploys launch from
|
||||
that AMI in ~2–3 minutes.
|
||||
|
||||
**Prerequisites:** AWS credentials with EC2 / IAM / SSM permissions. No Docker needed.
|
||||
|
||||
```bash
|
||||
# Step 1 — bake a gateway AMI (run once per code change, takes ~5-10 minutes):
|
||||
make bake-gateway
|
||||
|
||||
# Step 2 — launch EC2 instance from the saved AMI (fast):
|
||||
make deploy-gateway
|
||||
|
||||
# Tear down (keeps AMI by default):
|
||||
make destroy-gateway
|
||||
|
||||
# Full teardown including AMI deregistration:
|
||||
OPENSRE_GATEWAY_DESTROY_PURGE_AMI=1 make destroy-gateway
|
||||
```
|
||||
|
||||
Rollback to a previously baked AMI:
|
||||
|
||||
```bash
|
||||
OPENSRE_GATEWAY_AMI_ID=ami-<previous-id> make deploy-gateway
|
||||
```
|
||||
|
||||
Check the running gateway via SSM:
|
||||
|
||||
```bash
|
||||
aws ssm start-session --target <InstanceId>
|
||||
# inside:
|
||||
sudo systemctl status opensre-gateway
|
||||
sudo journalctl -u opensre-gateway -f
|
||||
```
|
||||
|
||||
Outputs are written to `~/.opensre/deployments/opensre-gateway.json`.
|
||||
|
||||
After deploy, the web API is reachable publicly:
|
||||
|
||||
```bash
|
||||
curl http://<PublicIpAddress>:8000/health
|
||||
```
|
||||
|
||||
Restrict the allowed source CIDR with `OPENSRE_WEB_API_INGRESS_CIDR` (default `0.0.0.0/0`).
|
||||
|
||||
### Direct deploy (no pre-baked AMI)
|
||||
|
||||
Installs OpenSRE inline on a fresh EC2 instance via SSM — slower but requires no bake step:
|
||||
|
||||
```bash
|
||||
make deploy-gateway-direct
|
||||
make destroy-gateway-direct
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Docker/ECR (`make deploy`) | Gateway (`make deploy-gateway`) |
|
||||
| - | - | - |
|
||||
| What deploys | web + gateway containers | gateway service only |
|
||||
| Runtime | Docker inside EC2 | systemd on EC2 host |
|
||||
| Shell access | Inside slim container | Full EC2 host |
|
||||
| ECR repository | Required | Not needed |
|
||||
| Update path | `make build-image && make deploy` | `make bake-gateway && make deploy-gateway` |
|
||||
|
||||
---
|
||||
|
||||
## Runtime Environment (Hosted / General)
|
||||
|
||||
Deploy OpenSRE as a standard Python/FastAPI app using the repo `Dockerfile`, Railway,
|
||||
ECS, Vercel, or another ASGI-capable host.
|
||||
|
||||
1. Build and deploy using your hosting provider's normal workflow.
|
||||
2. Set `LLM_PROVIDER` and the matching provider API key:
|
||||
- `ANTHROPIC_API_KEY` when `LLM_PROVIDER=anthropic`
|
||||
- `OPENAI_API_KEY` when `LLM_PROVIDER=openai`
|
||||
- `OPENROUTER_API_KEY` when `LLM_PROVIDER=openrouter`
|
||||
- `GEMINI_API_KEY` when `LLM_PROVIDER=gemini`
|
||||
3. Add `DATABASE_URI` and `REDIS_URI` for hosted layouts that need persistence.
|
||||
4. Add any additional environment variables required by your integrations.
|
||||
|
||||
Minimum environment:
|
||||
|
||||
```bash
|
||||
LLM_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=...
|
||||
```
|
||||
|
||||
The full set of supported provider keys and optional model overrides is documented in
|
||||
[`.env.example`](.env.example).
|
||||
|
||||
### Railway
|
||||
|
||||
Ensure the Railway project has Postgres and Redis services, and that the OpenSRE service
|
||||
has `DATABASE_URI` and `REDIS_URI` set to those connection strings before deploying.
|
||||
|
||||
For telemetry labeling, set `OPENSRE_DEPLOYMENT_METHOD=railway` on the Railway service.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,43 @@
|
||||
# Unified Dockerfile for OpenSRE
|
||||
# Supports two runtime modes via MODE environment variable:
|
||||
# MODE=web - FastAPI health application (default)
|
||||
# MODE=gateway - Telegram two-way messaging gateway
|
||||
#
|
||||
# EC2 deploy (make deploy) runs both as separate containers on one instance.
|
||||
#
|
||||
# Web mode usage:
|
||||
# docker build -t opensre:latest .
|
||||
# docker run -p 8000:8000 --env-file .env opensre:latest
|
||||
# curl http://localhost:8000/health
|
||||
#
|
||||
# Gateway mode usage:
|
||||
# docker build -t opensre-gateway:latest .
|
||||
# docker run -e MODE=gateway --env-file .env opensre-gateway:latest
|
||||
#
|
||||
# Required env vars for gateway mode:
|
||||
# TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS, LLM_PROVIDER, API keys
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
ENV PORT=8000
|
||||
ENV MODE=web
|
||||
|
||||
# Note: EXPOSE and HEALTHCHECK only apply to web mode
|
||||
# Gateway mode uses outbound-only long-polling (no inbound HTTP)
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD if [ "$MODE" = "web" ]; then python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)" || exit 1; else exit 0; fi
|
||||
|
||||
CMD ["sh", "-c", "if [ \"$MODE\" = \"gateway\" ]; then exec opensre gateway start --foreground; else exec uvicorn gateway.webapp:app --host 0.0.0.0 --port ${PORT:-8000}; fi"]
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Tracer Cloud
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,570 @@
|
||||
-include .env
|
||||
export
|
||||
|
||||
.PHONY: install build onboard demo benchmark benchmark-update-readme \
|
||||
alert-template investigate-alert verify-integrations check-docker \
|
||||
grafana-local-up grafana-local-down grafana-local-seed \
|
||||
cloudwatch-demo datadog-demo crashloop-demo prefect-demo \
|
||||
flink-demo upstream-downstream \
|
||||
test-rca test-rca-grafana test-synthetic test-rds-synthetic test-k8s-synthetic \
|
||||
test-cloudopsbench download-cloudopsbench-hf validate-cloudopsbench \
|
||||
simulate-k8s-alert test-k8s-local test-k8s test-k8s-datadog test-k8s-eks \
|
||||
chaos-mesh-up chaos-mesh-down chaos-engineering-apply chaos-engineering-delete \
|
||||
chaos-lab-up chaos-lab-down chaos-experiment-list chaos-experiment-up chaos-experiment-down \
|
||||
deploy-dd-monitors cleanup-dd-monitors deploy-eks destroy-eks \
|
||||
trigger-alert trigger-alert-verify regen-trigger-config \
|
||||
prefect-local-test run dev docs-dev \
|
||||
build-image deploy destroy test-deploy \
|
||||
bake-gateway deploy-gateway destroy-gateway \
|
||||
deploy-gateway-direct destroy-gateway-direct \
|
||||
deploy-lambda deploy-prefect deploy-flink destroy-lambda destroy-prefect destroy-flink \
|
||||
test test-full test-cov test-scope test-cli-smoke test-turn-live test-grafana \
|
||||
rabbitmq-local-up rabbitmq-local-down test-rabbitmq-real \
|
||||
test-openclaw test-openclaw-synthetic \
|
||||
test-hermes test-hermes-synthetic test-hermes-synthetic-only refresh-hermes-tuples \
|
||||
clean lint format-check format typecheck \
|
||||
check-imports check-cycles check-layers check-imports-strict check-layers-strict check help
|
||||
|
||||
|
||||
ifneq ($(wildcard .venv/bin/python),)
|
||||
PYTHON = .venv/bin/python
|
||||
PIP = .venv/bin/python -m pip
|
||||
else ifeq ($(OS),Windows_NT)
|
||||
ifneq ($(wildcard .venv/Scripts/python.exe),)
|
||||
PYTHON = .venv/Scripts/python.exe
|
||||
PIP = .venv/Scripts/python.exe -m pip
|
||||
else
|
||||
PYTHON = python
|
||||
PIP = python -m pip
|
||||
endif
|
||||
else ifneq ($(shell command -v python3 2>/dev/null),)
|
||||
PYTHON = python3
|
||||
PIP = python3 -m pip
|
||||
else
|
||||
PYTHON = python
|
||||
PIP = python -m pip
|
||||
endif
|
||||
|
||||
# PIP_INSTALL_FLAGS = --user --break-system-packages
|
||||
USER_BASE := $(shell $(PYTHON) -m site --user-base)
|
||||
USER_BIN := $(if $(filter Windows_NT,$(OS)),$(USER_BASE)/Scripts,$(USER_BASE)/bin)
|
||||
export PATH := $(if $(wildcard .venv/bin),$(CURDIR)/.venv/bin:,$(if $(wildcard .venv/Scripts),$(CURDIR)/.venv/Scripts:))$(USER_BIN):$(PATH)
|
||||
|
||||
PYTHON_SOURCE_PATHS := config core gateway integrations platform surfaces tools
|
||||
|
||||
# Create venv and install dependencies (requires https://docs.astral.sh/uv/)
|
||||
install:
|
||||
uv sync --frozen --extra dev
|
||||
uv run python -m platform.analytics.install
|
||||
|
||||
build:
|
||||
$(PYTHON) -m build
|
||||
|
||||
# Run the local onboarding flow
|
||||
onboard:
|
||||
opensre onboard
|
||||
|
||||
# Run Prefect ECS demo (default demo) - shows Investigation Trace in RCA
|
||||
demo:
|
||||
$(PYTHON) -m tests.e2e.upstream_prefect_ecs_fargate.test_agent_e2e
|
||||
|
||||
# Run Benchmarking Script based on Synthetic Scenarios
|
||||
benchmark:
|
||||
$(PYTHON) -m tests.benchmarks.toolcall_model_benchmark.benchmark_generator
|
||||
|
||||
# Update README benchmark section from cached results (no LLM calls)
|
||||
benchmark-update-readme:
|
||||
$(PYTHON) -m tests.benchmarks.toolcall_model_benchmark.readme_updater
|
||||
|
||||
alert-template:
|
||||
opensre investigate --print-template $(or $(TEMPLATE),generic)
|
||||
|
||||
investigate-alert:
|
||||
@[ -n "$(ALERT)" ] || { echo "Usage: make investigate-alert ALERT=/path/to/alert.json"; exit 1; }
|
||||
opensre investigate --input "$(ALERT)"
|
||||
|
||||
CLOUDOPSBENCH_HF_DATASET_ID ?= tracer-cloud/cloud-ops-bench-dataset
|
||||
CLOUDOPSBENCH_DATASET_DIR ?= tests/benchmarks/cloudopsbench
|
||||
CLOUDOPSBENCH_BENCHMARK_DIR ?= $(CLOUDOPSBENCH_DATASET_DIR)/benchmark
|
||||
CLOUDOPSBENCH_HF_INCLUDE ?= benchmark/**
|
||||
CLOUDOPSBENCH_LIMIT ?=
|
||||
|
||||
verify-integrations:
|
||||
uv run opensre integrations verify $(if $(SERVICE),$(SERVICE),) $(if $(SLACK_TEST),--send-slack-test,)
|
||||
|
||||
check-docker:
|
||||
@command -v docker >/dev/null 2>&1 || { echo "Docker is required for the live local Grafana stack. Install Docker Desktop or another Docker-compatible runtime, then rerun this target."; exit 1; }
|
||||
@docker info >/dev/null 2>&1 || { echo "Docker is installed, but the Docker daemon is not running. Start Docker Desktop, OrbStack, or Colima, then rerun this target."; exit 1; }
|
||||
|
||||
grafana-local-up: check-docker
|
||||
docker compose -f surfaces/cli/wizard/local_grafana_stack/docker-compose.yml up -d
|
||||
|
||||
grafana-local-down: check-docker
|
||||
docker compose -f surfaces/cli/wizard/local_grafana_stack/docker-compose.yml down
|
||||
|
||||
grafana-local-seed:
|
||||
$(PYTHON) -m surfaces.cli.wizard.grafana_seed
|
||||
|
||||
# Run CloudWatch demo
|
||||
cloudwatch-demo:
|
||||
$(PYTHON) -m tests.e2e.cloudwatch_demo.test_aws
|
||||
|
||||
# Run Datadog demo (local kind cluster + real DD monitor + investigation agent)
|
||||
datadog-demo:
|
||||
$(PYTHON) -m tests.e2e.datadog.test_local
|
||||
|
||||
# Run CrashLoopBackOff demo
|
||||
crashloop-demo:
|
||||
$(PYTHON) -m tests.e2e.crashloop.test_local
|
||||
|
||||
# Run Prefect ECS Fargate E2E test (alias for demo)
|
||||
prefect-demo:
|
||||
$(PYTHON) -m tests.e2e.upstream_prefect_ecs_fargate.test_agent_e2e
|
||||
|
||||
# Run RCA tests from markdown alert files in tests/e2e/rca/ (pass FILE= to run one)
|
||||
test-rca:
|
||||
$(PYTHON) -m tests.e2e.rca.run_rca_test $(FILE)
|
||||
|
||||
# Run synthetic tests via pytest markers (fixture-based, no live infra required)
|
||||
test-synthetic:
|
||||
$(PYTHON) -m pytest -m synthetic -v tests/synthetic/
|
||||
|
||||
# Run synthetic RDS PostgreSQL RCA benchmark suite via the CLI runner (supports --json, --scenario)
|
||||
test-rds-synthetic:
|
||||
$(PYTHON) -m tests.synthetic.rds_postgres.run_suite $(if $(SCENARIO),--scenario $(SCENARIO),)
|
||||
|
||||
# Run synthetic Kubernetes RCA benchmark suite via the CLI runner (supports --json, --scenario, --mock-backends)
|
||||
test-k8s-synthetic:
|
||||
$(PYTHON) -m tests.synthetic.eks.run_suite $(if $(SCENARIO),--scenario $(SCENARIO),)
|
||||
|
||||
# Run Cloud-OpsBench RCA benchmark suite via the OpenSRE runner
|
||||
test-cloudopsbench:
|
||||
$(PYTHON) -m tests.benchmarks.cloudopsbench.run_suite --benchmark-dir "$(CLOUDOPSBENCH_BENCHMARK_DIR)" $(if $(SYSTEM),--system $(SYSTEM),) $(if $(FAULT),--fault-category $(FAULT),) $(if $(CASE),--case $(CASE),) $(if $(CLOUDOPSBENCH_LIMIT),--limit $(CLOUDOPSBENCH_LIMIT),$(if $(LIMIT),--limit $(LIMIT),))
|
||||
|
||||
# Download Cloud-OpsBench benchmark data from Hugging Face.
|
||||
download-cloudopsbench-hf:
|
||||
@command -v hf >/dev/null 2>&1 || { echo "Install the Hugging Face CLI with: pip install 'huggingface_hub[cli]'"; exit 1; }
|
||||
hf download "$(CLOUDOPSBENCH_HF_DATASET_ID)" --repo-type dataset --local-dir "$(CLOUDOPSBENCH_DATASET_DIR)" --include "$(CLOUDOPSBENCH_HF_INCLUDE)"
|
||||
|
||||
validate-cloudopsbench:
|
||||
$(PYTHON) -m tests.benchmarks.cloudopsbench.run_suite --benchmark-dir "$(CLOUDOPSBENCH_BENCHMARK_DIR)" --validate-only
|
||||
|
||||
# Boot local Grafana+Loki, seed deterministic test logs, then run the RCA pipeline
|
||||
# Requires GRAFANA_INSTANCE_URL + GRAFANA_READ_TOKEN in .env (see .env.example for local defaults)
|
||||
test-rca-grafana: grafana-local-up grafana-local-seed
|
||||
$(PYTHON) -m tests.e2e.rca.run_rca_test grafana_pipeline_failure
|
||||
|
||||
# Run Kubernetes local alert simulation against the in-process investigation API
|
||||
simulate-k8s-alert:
|
||||
$(PYTHON) -m pytest tests/e2e/kubernetes_local_alert_simulation/test_simulation.py -s; \
|
||||
EXIT=$$?; exit $$EXIT
|
||||
|
||||
# Run Kubernetes local test (kind)
|
||||
test-k8s-local:
|
||||
$(PYTHON) -m tests.e2e.kubernetes.test_local --both
|
||||
|
||||
# Run Kubernetes test (matches CI)
|
||||
test-k8s:
|
||||
$(PYTHON) -m tests.e2e.kubernetes.test_local
|
||||
|
||||
# Run Kubernetes + Datadog test (kind + DD Agent)
|
||||
test-k8s-datadog:
|
||||
$(PYTHON) -m tests.e2e.kubernetes.test_datadog
|
||||
|
||||
# Chaos Mesh on the kube context (default: kind-tracer-k8s-test). Override: make chaos-mesh-up KUBECTL_CONTEXT=...
|
||||
# CHAOS_MESH_RUNTIME=containerd matches kind; use docker only on older clusters.
|
||||
CHAOS_MESH_NS ?= chaos-mesh
|
||||
KUBECTL_CONTEXT ?= kind-tracer-k8s-test
|
||||
CHAOS_MESH_RUNTIME ?= containerd
|
||||
HELM_KUBE := $(if $(KUBECTL_CONTEXT),--kube-context $(KUBECTL_CONTEXT),)
|
||||
KUBECTL_FLAGS := $(if $(KUBECTL_CONTEXT),--context=$(KUBECTL_CONTEXT),)
|
||||
|
||||
chaos-mesh-up:
|
||||
@helm repo list 2>/dev/null | grep -q '^chaos-mesh' || helm repo add chaos-mesh https://charts.chaos-mesh.org
|
||||
helm repo update
|
||||
kubectl create namespace $(CHAOS_MESH_NS) --dry-run=client -o yaml | kubectl apply -f - $(KUBECTL_FLAGS)
|
||||
helm upgrade --install chaos-mesh chaos-mesh/chaos-mesh -n $(CHAOS_MESH_NS) \
|
||||
--set chaosDaemon.runtime=$(CHAOS_MESH_RUNTIME) \
|
||||
$(HELM_KUBE)
|
||||
|
||||
chaos-mesh-down:
|
||||
-helm uninstall chaos-mesh -n $(CHAOS_MESH_NS) $(HELM_KUBE)
|
||||
-kubectl delete namespace $(CHAOS_MESH_NS) $(KUBECTL_FLAGS)
|
||||
|
||||
# Apply chaos-engineering manifests on KUBECTL_CONTEXT (nginx target, CrashLoop deployment, PodChaos).
|
||||
# Requires Chaos Mesh CRDs for pod-kill-demo.yaml (run make chaos-mesh-up first).
|
||||
chaos-engineering-apply:
|
||||
kubectl apply -f tests/chaos_engineering/chaos-demo.yaml $(KUBECTL_FLAGS)
|
||||
kubectl apply -f tests/chaos_engineering/experiments/crashloop/crashloop-demo.yaml $(KUBECTL_FLAGS)
|
||||
kubectl apply -f tests/chaos_engineering/pod-kill-demo.yaml $(KUBECTL_FLAGS)
|
||||
|
||||
chaos-engineering-delete:
|
||||
-kubectl delete -f tests/chaos_engineering/pod-kill-demo.yaml $(KUBECTL_FLAGS)
|
||||
-kubectl delete -f tests/chaos_engineering/experiments/crashloop/crashloop-demo.yaml $(KUBECTL_FLAGS)
|
||||
-kubectl delete -f tests/chaos_engineering/chaos-demo.yaml $(KUBECTL_FLAGS)
|
||||
|
||||
# Full chaos lab: kind + Datadog + Chaos Mesh + baseline workloads (same defaults as README).
|
||||
# Optional flags: CHAOS_LAB_FLAGS='--skip-kind' '--skip-datadog' '--no-wait-datadog' etc.
|
||||
chaos-lab-up:
|
||||
$(PYTHON) -m tests.chaos_engineering lab up $(CHAOS_LAB_FLAGS)
|
||||
|
||||
# Tear down lab (baseline, Chaos Mesh, Datadog namespace, kind cluster). Optional: CHAOS_LAB_DOWN_FLAGS='--keep-kind' '--keep-datadog'
|
||||
chaos-lab-down:
|
||||
$(PYTHON) -m tests.chaos_engineering lab down $(CHAOS_LAB_DOWN_FLAGS)
|
||||
|
||||
chaos-experiment-list:
|
||||
$(PYTHON) -m tests.chaos_engineering experiment list
|
||||
|
||||
# Apply experiments/<EXPERIMENT>/ (*-demo.yaml then *-chaos.yaml). Example: make chaos-experiment-up EXPERIMENT=pod-failure
|
||||
chaos-experiment-up:
|
||||
@test -n "$(EXPERIMENT)" || (echo "Set EXPERIMENT=name (see: make chaos-experiment-list)" && false)
|
||||
$(PYTHON) -m tests.chaos_engineering experiment apply $(EXPERIMENT)
|
||||
|
||||
chaos-experiment-down:
|
||||
@test -n "$(EXPERIMENT)" || (echo "Set EXPERIMENT=name (see: make chaos-experiment-list)" && false)
|
||||
$(PYTHON) -m tests.chaos_engineering experiment delete $(EXPERIMENT)
|
||||
|
||||
# Deploy Datadog monitors (requires DD_API_KEY + DD_APP_KEY)
|
||||
deploy-dd-monitors:
|
||||
$(PYTHON) -c "from tests.e2e.kubernetes.test_datadog import deploy_monitors; deploy_monitors()"
|
||||
|
||||
# Remove Datadog monitors created by tracer tests
|
||||
cleanup-dd-monitors:
|
||||
$(PYTHON) -c "from tests.e2e.kubernetes.test_datadog import cleanup_monitors; cleanup_monitors()"
|
||||
|
||||
# Deploy EKS cluster + ECR image for Kubernetes tests
|
||||
deploy-eks:
|
||||
$(PYTHON) -c "from tests.e2e.kubernetes.infrastructure_sdk.eks import deploy_eks_stack; deploy_eks_stack()"
|
||||
|
||||
# Destroy EKS cluster and all associated resources
|
||||
destroy-eks:
|
||||
$(PYTHON) -c "from tests.e2e.kubernetes.infrastructure_sdk.eks import destroy_eks_stack; destroy_eks_stack()"
|
||||
|
||||
# Run Kubernetes + Datadog test on EKS
|
||||
test-k8s-eks:
|
||||
$(PYTHON) -m tests.e2e.kubernetes.test_eks
|
||||
|
||||
# Fast: trigger a K8s alert in ~15s (fire-and-forget)
|
||||
trigger-alert:
|
||||
$(PYTHON) -m tests.e2e.kubernetes.trigger_alert
|
||||
|
||||
# Recreate centralized trigger API config JSON from AWS
|
||||
regen-trigger-config:
|
||||
$(PYTHON) -m tests.e2e.kubernetes.trigger_alert --regen-config
|
||||
|
||||
# Fast trigger + wait for Slack confirmation
|
||||
trigger-alert-verify:
|
||||
$(PYTHON) -m tests.e2e.kubernetes.trigger_alert --verify
|
||||
|
||||
# Run Prefect ECS local test
|
||||
prefect-local-test:
|
||||
$(PYTHON) -m tests.e2e.upstream_prefect_ecs_fargate.test_local $(if $(CLOUD),--cloud,)
|
||||
|
||||
# Run upstream/downstream pipeline E2E test
|
||||
upstream-downstream:
|
||||
$(PYTHON) -m tests.e2e.upstream_lambda.test_agent_e2e
|
||||
|
||||
# Run Apache Flink ECS E2E test
|
||||
flink-demo:
|
||||
$(PYTHON) -m tests.e2e.upstream_apache_flink_ecs.test_agent_e2e
|
||||
|
||||
# Run the generic CLI (reads from stdin or --input)
|
||||
run:
|
||||
opensre investigate
|
||||
|
||||
dev:
|
||||
@echo "Run the health app with: uv run uvicorn gateway.webapp:app --reload --host 0.0.0.0 --port 8000"
|
||||
|
||||
docs-dev:
|
||||
cd docs && mint dev
|
||||
|
||||
|
||||
# Deploy all test case infrastructure in parallel (SDK - fast!)
|
||||
# EC2 deploy (web + gateway containers on one instance)
|
||||
# Step 1 — build once per code change, saves URI locally for reuse:
|
||||
build-image:
|
||||
$(PYTHON) -m platform.deployment.ecr_deploy.lifecycle build-image
|
||||
|
||||
# Step 2 — launch instance using the pre-built image (fast, no Docker build):
|
||||
deploy:
|
||||
$(PYTHON) -m platform.deployment.ecr_deploy.lifecycle deploy
|
||||
|
||||
destroy:
|
||||
$(PYTHON) -m platform.deployment.ecr_deploy.lifecycle destroy
|
||||
|
||||
test-deploy:
|
||||
$(PYTHON) -m pytest tests/deployment/ec2/ -v -s
|
||||
|
||||
# Gateway deploy (Telegram gateway only, no Docker/ECR)
|
||||
# Step 1 — bake once per code change (launches temp EC2, installs opensre, snapshots AMI):
|
||||
bake-gateway:
|
||||
$(PYTHON) -m platform.deployment.gateway.lifecycle bake-ami
|
||||
|
||||
# Step 2 — launch gateway instance from pre-baked AMI (fast):
|
||||
deploy-gateway:
|
||||
$(PYTHON) -m platform.deployment.gateway.lifecycle deploy
|
||||
|
||||
destroy-gateway:
|
||||
$(PYTHON) -m platform.deployment.gateway.lifecycle destroy
|
||||
|
||||
# Gateway direct deploy (no pre-baked AMI — installs inline via SSM)
|
||||
deploy-gateway-direct:
|
||||
$(PYTHON) -m platform.deployment.gateway.lifecycle deploy-direct
|
||||
|
||||
destroy-gateway-direct:
|
||||
$(PYTHON) -m platform.deployment.gateway.lifecycle destroy-direct
|
||||
|
||||
# Deploy Lambda test case
|
||||
deploy-lambda:
|
||||
@echo "Deploying Lambda stack..."
|
||||
$(PYTHON) -m tests.e2e.upstream_lambda.infrastructure_sdk.deploy
|
||||
|
||||
# Deploy Prefect ECS test case
|
||||
deploy-prefect:
|
||||
@echo "Deploying Prefect ECS stack..."
|
||||
$(PYTHON) -m tests.e2e.upstream_prefect_ecs_fargate.infrastructure_sdk.deploy
|
||||
|
||||
# Deploy Flink ECS test case
|
||||
deploy-flink:
|
||||
@echo "Deploying Flink ECS stack..."
|
||||
$(PYTHON) -m tests.e2e.upstream_apache_flink_ecs.infrastructure_sdk.deploy
|
||||
|
||||
# Destroy Lambda test case
|
||||
destroy-lambda:
|
||||
@echo "Destroying Lambda stack..."
|
||||
$(PYTHON) -m tests.e2e.upstream_lambda.infrastructure_sdk.destroy
|
||||
|
||||
# Destroy Prefect ECS test case
|
||||
destroy-prefect:
|
||||
@echo "Destroying Prefect ECS stack..."
|
||||
$(PYTHON) -m tests.e2e.upstream_prefect_ecs_fargate.infrastructure_sdk.destroy
|
||||
|
||||
# Destroy Flink ECS test case
|
||||
destroy-flink:
|
||||
@echo "Destroying Flink ECS stack..."
|
||||
$(PYTHON) -m tests.e2e.upstream_apache_flink_ecs.infrastructure_sdk.destroy
|
||||
|
||||
# Run fast tests + Prefect cloud E2E
|
||||
test:
|
||||
$(PYTHON) -m pytest -v surfaces/cli tests/utils
|
||||
$(PYTHON) -m tests.e2e.upstream_prefect_ecs_fargate.test_agent_e2e
|
||||
|
||||
# Run full test suite (CI/CD)
|
||||
test-full:
|
||||
$(PYTHON) -m pytest -v
|
||||
|
||||
# Run tests with coverage (parallel via pytest-xdist).
|
||||
# Keep tests/synthetic excluded here to match GitHub CI; marker filtering alone is
|
||||
# not enough because some synthetic tests are collected without the synthetic mark.
|
||||
test-cov:
|
||||
$(PYTHON) -m pytest -n auto -v $(addprefix --cov=,$(PYTHON_SOURCE_PATHS)) --cov-report=term-missing --ignore=tests/e2e/kubernetes_local_alert_simulation --ignore=tests/synthetic -m "not synthetic"
|
||||
|
||||
# Run only the tests relevant to files changed on this branch (local use only).
|
||||
# Pass ARGS=--dry-run to preview the command without executing it.
|
||||
test-scope:
|
||||
$(PYTHON) .github/ci/run_test_scope.py --base main $(ARGS)
|
||||
|
||||
# Run the CLI smoke suite against the installed opensre entrypoint.
|
||||
test-cli-smoke:
|
||||
$(PYTHON) -m pytest -v tests/cli/test_smoke.py
|
||||
|
||||
# Run the live-LLM turn scenario suite sharded across local processes, mirroring
|
||||
# the CI turn-live job. The suite is IO-bound on LLM calls, so running all shards
|
||||
# concurrently collapses wall time to ~one shard. Override shard count/subset:
|
||||
# make test-turn-live ARGS="--shards 4"
|
||||
# make test-turn-live ARGS="--indexes 0,3"
|
||||
test-turn-live:
|
||||
$(PYTHON) .github/ci/run_live_turn_shards.py $(ARGS)
|
||||
|
||||
# Run Grafana integration tests
|
||||
test-grafana:
|
||||
@echo "Running Grafana integration tests..."
|
||||
$(PYTHON) -m pytest tests/e2e/grafana_validation/test_grafana_cloud_queries.py -v
|
||||
|
||||
# Spin up the local RabbitMQ stack (broker + publisher + slow consumer), wait
|
||||
# for a backlog to accumulate, then exercise the read-only diagnostic tools
|
||||
# against the real broker. Used for the screen-video demo; NOT part of CI.
|
||||
RABBITMQ_COMPOSE = tests/e2e/rabbitmq/docker-compose.yml
|
||||
|
||||
rabbitmq-local-up:
|
||||
@echo "Starting local RabbitMQ stack (broker + publisher + slow consumer)..."
|
||||
docker compose -f $(RABBITMQ_COMPOSE) up -d
|
||||
@echo "Waiting for broker to become healthy..."
|
||||
@until docker compose -f $(RABBITMQ_COMPOSE) ps rabbitmq | grep -q "(healthy)"; do sleep 2; done
|
||||
@echo "Broker healthy. Letting backlog build for 20s..."
|
||||
@sleep 20
|
||||
@echo "Ready."
|
||||
|
||||
rabbitmq-local-down:
|
||||
docker compose -f $(RABBITMQ_COMPOSE) down -v
|
||||
|
||||
# Run OpenClaw integration + tool E2E tests (mocked transport, no live OpenClaw needed)
|
||||
test-openclaw:
|
||||
$(PYTHON) -m pytest tests/e2e/openclaw/ tests/integrations/openclaw/test_integration.py tests/tools/test_openclaw_mcp_tool.py tests/utils/test_openclaw_delivery.py -v
|
||||
|
||||
# Run synthetic OpenClaw investigation scenarios (FixtureOpenClawBackend, no live OpenClaw)
|
||||
test-openclaw-synthetic:
|
||||
$(PYTHON) -m tests.synthetic.openclaw.run_suite
|
||||
|
||||
# Run Hermes incident-identification suites: Hermes RCA synthetic tests + Hermes e2e.
|
||||
test-hermes:
|
||||
$(PYTHON) -m pytest tests/synthetic/hermes_rca tests/e2e/hermes -v
|
||||
|
||||
# Deterministic/no-key Hermes RCA synthetic checks only.
|
||||
test-hermes-synthetic:
|
||||
$(PYTHON) -m pytest tests/synthetic/hermes_rca -v
|
||||
|
||||
# Offline-only Hermes synthetic runner (scenario harness path).
|
||||
test-hermes-synthetic-only:
|
||||
$(PYTHON) -m tests.synthetic.hermes_rca.run_suite --offline-only
|
||||
|
||||
# Regenerate Hermes adapter tuple catalog.
|
||||
refresh-hermes-tuples:
|
||||
$(PYTHON) -m tests.synthetic.hermes_rca.refresh_adapter_tuples
|
||||
|
||||
# Run the RabbitMQ integration + tool tests, then invoke the verify command
|
||||
# against the live broker. Requires the rabbitmq-local-up stack to be running.
|
||||
test-rabbitmq-real:
|
||||
@echo "Running mocked RabbitMQ unit + e2e tests..."
|
||||
$(PYTHON) -m pytest tests/integrations/test_rabbitmq.py tests/tools/test_rabbitmq_*.py tests/e2e/rabbitmq/ -v
|
||||
@echo ""
|
||||
@echo "Verifying against the live broker (requires \`make rabbitmq-local-up\`)..."
|
||||
RABBITMQ_HOST=127.0.0.1 \
|
||||
RABBITMQ_USERNAME=sre_admin \
|
||||
RABBITMQ_PASSWORD=sre_password \
|
||||
RABBITMQ_VHOST=/orders \
|
||||
$(PYTHON) -c "from integrations.rabbitmq import rabbitmq_config_from_env, validate_rabbitmq_config, get_queue_backlog, get_broker_overview; \
|
||||
cfg = rabbitmq_config_from_env(); \
|
||||
print('validate:', validate_rabbitmq_config(cfg)); \
|
||||
print('overview:', get_broker_overview(cfg)); \
|
||||
print('backlog:', get_queue_backlog(cfg, max_results=5))"
|
||||
|
||||
# Clean up
|
||||
clean:
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -maxdepth 1 \( -name '.coverage' -o -name '.coverage.*' \) -delete 2>/dev/null || true
|
||||
rm -rf htmlcov/ 2>/dev/null || true
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
$(PYTHON) -m ruff check $(PYTHON_SOURCE_PATHS) tests/
|
||||
|
||||
# Check formatting (read-only; CI uses this)
|
||||
format-check:
|
||||
$(PYTHON) -m ruff format --check $(PYTHON_SOURCE_PATHS) tests/
|
||||
|
||||
# Format code
|
||||
format:
|
||||
$(PYTHON) -m ruff format $(PYTHON_SOURCE_PATHS) tests/
|
||||
|
||||
# Type check
|
||||
typecheck:
|
||||
$(PYTHON) -m mypy $(PYTHON_SOURCE_PATHS)
|
||||
|
||||
# Import graph: cycles + layering + forbidden direct edges (one command).
|
||||
check-imports:
|
||||
$(PYTHON) .github/ci/check_imports.py
|
||||
|
||||
# Deprecated aliases — use ``check-imports`` instead.
|
||||
check-cycles check-layers: check-imports
|
||||
|
||||
# Optional: full transitive layer contracts (when .importlinter.strict exists).
|
||||
check-imports-strict:
|
||||
$(PYTHON) .github/ci/check_imports.py --strict
|
||||
|
||||
check-layers-strict: check-imports-strict
|
||||
|
||||
# Run all checks (lint + format read-only check + types + imports + full tests; mirrors CI quality gates)
|
||||
check: lint format-check typecheck check-imports test-full
|
||||
|
||||
# Show help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo ""
|
||||
@echo " EC2 DEPLOY (Docker/ECR — web + gateway)"
|
||||
@echo " make build-image - Build and push Docker image to ECR (run once per code change)"
|
||||
@echo " make deploy - Launch EC2 instance using pre-built image (fast, no Docker build)"
|
||||
@echo " make destroy - Terminate EC2 instance and clean up (keeps ECR image; OPENSRE_DESTROY_PURGE_ECR=1 to also delete it)"
|
||||
@echo " make test-deploy - Run EC2 deployment e2e tests"
|
||||
@echo ""
|
||||
@echo " GATEWAY DEPLOY (systemd, no Docker — gateway only)"
|
||||
@echo " make bake-gateway - Bake a gateway AMI (run once per code change; saves AMI id locally)"
|
||||
@echo " make deploy-gateway - Launch gateway EC2 instance from pre-baked AMI (fast)"
|
||||
@echo " make destroy-gateway - Terminate gateway instance and clean up (set OPENSRE_GATEWAY_DESTROY_PURGE_AMI=1 to also deregister AMI)"
|
||||
@echo ""
|
||||
@echo " E2E TEST INFRA (AWS SDK)"
|
||||
@echo " make deploy-lambda - Deploy Lambda stack (~50s)"
|
||||
@echo " make deploy-prefect - Deploy Prefect ECS stack (~55s)"
|
||||
@echo " make deploy-flink - Deploy Flink ECS stack (~90s)"
|
||||
@echo " make destroy-lambda - Destroy Lambda stack"
|
||||
@echo " make destroy-prefect - Destroy Prefect ECS stack"
|
||||
@echo " make destroy-flink - Destroy Flink ECS stack"
|
||||
@echo ""
|
||||
@echo " DEMOS"
|
||||
@echo " make demo - Run Prefect ECS E2E test (default, shows Investigation Trace)"
|
||||
@echo " make grafana-local-up - Start the local Grafana + Loki stack"
|
||||
@echo " make grafana-local-seed - Seed failure logs into the local Loki instance"
|
||||
@echo " make alert-template TEMPLATE=datadog - Print a starter alert JSON template"
|
||||
@echo " make investigate-alert ALERT=/path/to/alert.json - Run RCA against your own alert payload"
|
||||
@echo " make verify-integrations - Check local store + .env integrations before running RCA"
|
||||
@echo " make prefect-demo - Run Prefect ECS Fargate E2E test (alias for demo)"
|
||||
@echo " make prefect-local-test - Run Prefect ECS local test (CLOUD=1 for ECS)"
|
||||
@echo " make flink-demo - Run Apache Flink ECS E2E test"
|
||||
@echo " make cloudwatch-demo - Run CloudWatch demo"
|
||||
@echo " make datadog-demo - Run Datadog demo (local kind cluster + DD monitor + agent)"
|
||||
@echo " make crashloop-demo - Run CrashLoopBackOff/OOMKill demo (no k8s needed, DD + Slack)"
|
||||
@echo " make upstream-downstream - Run upstream/downstream Lambda E2E test"
|
||||
@echo ""
|
||||
@echo " KUBERNETES"
|
||||
@echo " make test-k8s-local - Run Kubernetes local test (kind)"
|
||||
@echo " make test-k8s - Run Kubernetes test (matches CI)"
|
||||
@echo " make test-k8s-datadog - Run Kubernetes + Datadog test"
|
||||
@echo " make chaos-mesh-up - Install Chaos Mesh (Helm; default context kind-tracer-k8s-test)"
|
||||
@echo " make chaos-mesh-down - Uninstall Chaos Mesh + namespace"
|
||||
@echo " make chaos-engineering-apply - Apply chaos-demo + crashloop + PodChaos (same context)"
|
||||
@echo " make chaos-engineering-delete - Remove those workloads (PodChaos first)"
|
||||
@echo " make chaos-lab-up / chaos-lab-down - Full lab (kind+DD+mesh+baseline; runs python -m tests.chaos_engineering)"
|
||||
@echo " make chaos-experiment-list / chaos-experiment-up EXPERIMENT=... - Per-experiment apply"
|
||||
@echo " make deploy-dd-monitors - Deploy Datadog monitors (DD_API_KEY + DD_APP_KEY)"
|
||||
@echo " make cleanup-dd-monitors - Remove Datadog test monitors"
|
||||
@echo " make deploy-eks - Deploy EKS cluster + ECR image"
|
||||
@echo " make destroy-eks - Destroy EKS cluster and resources"
|
||||
@echo " make test-k8s-eks - Run Kubernetes + Datadog test on EKS"
|
||||
@echo ""
|
||||
@echo " LOCAL DEVELOPMENT"
|
||||
@echo " make install - Install dependencies"
|
||||
@echo " make onboard - Run the OpenSRE onboarding flow"
|
||||
@echo " make docs-dev - Start the local documentation preview (requires mint CLI)"
|
||||
@echo ""
|
||||
@echo " CLI (tab-completable, run 'opensre -h' for full help)"
|
||||
@echo " opensre onboard - Interactive setup wizard"
|
||||
@echo " opensre investigate -i alert.json - Run RCA on an alert payload"
|
||||
@echo " opensre integrations list - Show configured integrations"
|
||||
@echo " opensre integrations verify - Verify connectivity"
|
||||
@echo ""
|
||||
@echo " TESTING & QUALITY"
|
||||
@echo " make test - Run fast unit tests + Prefect cloud E2E"
|
||||
@echo " make test-full - Run full test suite (CI/CD)"
|
||||
@echo " make test-cov - Run tests with coverage"
|
||||
@echo " make test-cli-smoke - Run end-to-end CLI smoke tests"
|
||||
@echo " make test-grafana - Run Grafana integration tests"
|
||||
@echo " make test-rca - Run all RCA markdown alert tests in tests/e2e/rca/"
|
||||
@echo " make test-rca FILE=pipeline_error_in_logs - Run a single RCA alert test"
|
||||
@echo " make test-rds-synthetic - Run the synthetic RDS PostgreSQL RCA suite"
|
||||
@echo " make test-openclaw - Run OpenClaw integration + e2e tests (skips when openclaw CLI absent)"
|
||||
@echo " make test-hermes - Run Hermes synthetic + e2e suites"
|
||||
@echo " make test-hermes-synthetic - Run Hermes RCA synthetic suite only (no-key deterministic path)"
|
||||
@echo " make download-cloudopsbench-hf - Download Cloud-OpsBench from Hugging Face"
|
||||
@echo " make test-cloudopsbench - Run the Cloud-OpsBench synthetic RCA suite"
|
||||
@echo " make clean - Clean up cache files"
|
||||
@echo " make lint - Lint code with ruff"
|
||||
@echo " make format-check - Check formatting with ruff (read-only)"
|
||||
@echo " make format - Format code with ruff"
|
||||
@echo " make typecheck - Type check with mypy"
|
||||
@echo " make check-imports - Import cycles, layers, and direct-edge checks"
|
||||
@echo " make check-layers-strict - Full transitive layer contracts (.importlinter.strict)"
|
||||
@echo " make check - Run all checks"
|
||||
@echo " make benchmark - Run benchmark report generation"
|
||||
@echo " make benchmark-update-readme - Update README from cached benchmark results"
|
||||
@@ -0,0 +1,308 @@
|
||||
<div align="center">
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/logo/opensre-logo-white.svg" alt="OpenSRE" width="360" />
|
||||
</p>
|
||||
|
||||
<h1>OpenSRE v0.1: Build Your Own AI SRE Agents</h1>
|
||||
|
||||
<p>The open-source framework for AI SRE agents, and the training and evaluation environment they need to improve. Connect the 60+ tools you already run, define your own workflows, and investigate incidents on your own infrastructure.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Tracer-Cloud/opensre/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/Tracer-Cloud/opensre/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/Tracer-Cloud/opensre/releases"><img src="https://img.shields.io/badge/status-public%20alpha-orange?style=for-the-badge" alt="Project status: public alpha"></a>
|
||||
<a href="https://github.com/Tracer-Cloud/opensre/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=for-the-badge" alt="Apache 2.0 License"></a>
|
||||
<a href="https://discord.gg/7NTpevXf7w"><img src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://greptile.com"><img src="https://img.shields.io/badge/Sponsored%20by-Greptile-27E99F?style=for-the-badge&labelColor=3D3B4F&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMzY3IiBoZWlnaHQ9IjQyMCIgdmlld0JveD0iMCAwIDM2NyA0MjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI%2BCjxwYXRoIGQ9Ik0yNDAuMjY5IDQ5LjgxNTRMMTY2LjgwNCAxMTUuOTYzTDExNS45NjYgMTU5LjQ0TDE4MS4zMzUgMjIwLjU4NUwyNDkuNzg0IDE2Mi4wNDhMMTk2Ljc4IDExMi40N0wyNTMuMDY4IDYxLjc4ODFMMzYyLjYwNSAxNjQuMjQ2TDE3OC43MzkgMzIxLjQ4OUwzLjE0NTAyIDE1Ny4yNDJMMTg3LjAxMSAwTDI0MC4yNjkgNDkuODE1NFoiIGZpbGw9IiNGRkZGRkYiLz4KPHJlY3Qgd2lkdGg9IjIzNi40NTMiIGhlaWdodD0iODMuNDU2NiIgdHJhbnNmb3JtPSJtYXRyaXgoMC43NTQ3MSAtMC42NTYwNTkgMCAxIDE4OC4wMTcgMzM2LjU0NCkiIGZpbGw9IiNGRkZGRkYiLz4KPHJlY3Qgd2lkdGg9IjIzNi40NTMiIGhlaWdodD0iODMuNDU2NiIgdHJhbnNmb3JtPSJtYXRyaXgoMC43MzEzNTQgMC42ODE5OTggMCAxIDAgMTc0Ljk2MikiIGZpbGw9IiNGRkZGRkYiLz4KPC9zdmc%2BCg%3D%3D" alt="Sponsored by Greptile"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25889" target="_blank">
|
||||
<img
|
||||
src="https://trendshift.io/api/badge/repositories/25889"
|
||||
alt="Tracer-Cloud%2Fopensre | Trendshift"
|
||||
style="height: 30px; width: auto;"
|
||||
height="30"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>
|
||||
<a href="https://www.opensre.com/docs/quickstart">Quickstart</a> ·
|
||||
<a href="https://www.opensre.com/docs">Docs</a> ·
|
||||
<a href="https://opensre.com/docs/faq">FAQ</a> ·
|
||||
<a href="https://trust.tracer.cloud/">Security</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> 🚧 Public Alpha: Core workflows are usable for early exploration, though not yet fully stable. The project is in active development, and APIs and integrations may evolve
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why OpenSRE?](#why-opensre)
|
||||
- [Install](#install)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Deployment](#deployment)
|
||||
- [How OpenSRE Works](#how-opensre-works)
|
||||
- [Benchmark](#benchmark)
|
||||
- [Capabilities & integrations](#capabilities--integrations)
|
||||
- [Contributing & development](#contributing--development)
|
||||
- [Security](#security)
|
||||
- [Telemetry](#telemetry)
|
||||
- [License](#license)
|
||||
- [Citations](#citations)
|
||||
|
||||
---
|
||||
|
||||
## Why OpenSRE?
|
||||
|
||||
When something breaks in production, the evidence is scattered across logs, metrics, traces, runbooks, and Slack threads. OpenSRE is an open-source framework for AI SRE agents that resolve production incidents, built to run on your own infrastructure.
|
||||
|
||||
We do that because SWE-bench<sup>1</sup> gave coding agents scalable training data and clear feedback. Production incident response still lacks an equivalent.
|
||||
|
||||
Distributed failures are slower, noisier, and harder to simulate and evaluate than local code tasks, which is why AI SRE, and AI for production debugging more broadly, remains unsolved.
|
||||
|
||||
OpenSRE is building _that_ missing layer:
|
||||
|
||||
> an open reinforcement learning environment for agentic infrastructure incident response, with end-to-end tests and synthetic incident simulations for realistic production failures
|
||||
|
||||
We do that by:
|
||||
|
||||
- building easy-to-deploy, customizable AI SRE agents for production incident investigation and response
|
||||
- running scored synthetic RCA suites that check root-cause accuracy, required evidence, and adversarial red herrings [(tests/synthetic)](tests/synthetic/rds_postgres)
|
||||
- running real-world end-to-end tests across cloud-backed scenarios including Kubernetes, EC2, CloudWatch, Lambda, ECS Fargate, and Flink [(tests/e2e)](tests/e2e)
|
||||
- keeping semantic test-catalog naming so e2e vs synthetic and local vs cloud boundaries stay obvious [(tests/README.md)](tests/README.md)
|
||||
|
||||
Our mission is to build AI SRE agents on top of this, scale it to thousands of realistic infrastructure failure scenarios, and establish OpenSRE as the benchmark and training ground for AI SRE.
|
||||
|
||||
<sup>1</sup> https://arxiv.org/abs/2310.06770
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
The root installer URL auto-detects Unix shell vs PowerShell and installs the latest build from `main`. OpenSRE moves quickly, so `main` is the latest stable version for normal installs.
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.opensre.com | bash
|
||||
```
|
||||
|
||||
The macOS/Linux installer does not require sudo. If no writable bin directory is already on `PATH`, it installs to `~/.local/bin` and prints the shell command to apply the PATH update.
|
||||
|
||||
Equivalent explicit main-channel form:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.opensre.com | bash -s -- --main
|
||||
```
|
||||
|
||||
Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap tracer-cloud/tap
|
||||
brew install tracer-cloud/tap/opensre
|
||||
```
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://install.opensre.com | iex
|
||||
```
|
||||
|
||||
<!--
|
||||
```bash
|
||||
pipx install opensre
|
||||
``` -->
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Configure once, then pick how you want to run investigations:
|
||||
|
||||
```bash
|
||||
opensre onboard
|
||||
```
|
||||
|
||||
**Interactive shell** — with no subcommand, `opensre` starts a REPL (TTY required). Describe incidents in plain language, stream investigations, and use slash commands for session control (`/help`, `/status`, `/cost`, `/sessions`, `/resume`, `/compact`, `/new`, `/exit`), integrations (`/integrations list`, `/integrations verify`), local agent fleet monitoring (`/agents`), and reasoning depth (`/effort` for **OpenAI** and **Codex** — `low` through `max`). Ctrl+C cancels an in-flight investigation without losing session state. See **[interactive shell commands](https://www.opensre.com/docs/interactive-shell-commands)** for the full reference.
|
||||
|
||||
```bash
|
||||
opensre
|
||||
```
|
||||
|
||||
**One-shot investigation** — run the agent once against an alert file:
|
||||
|
||||
```bash
|
||||
opensre investigate -i tests/e2e/kubernetes/fixtures/datadog_k8s_alert.json
|
||||
```
|
||||
|
||||
**Remote runtime investigation** — investigate a deployed service by name (live health, logs, and deployment status):
|
||||
|
||||
```bash
|
||||
opensre investigate --service api-backend
|
||||
```
|
||||
|
||||
**Hermes log watch** — tail a Hermes `errors.log`, classify incidents, and optionally alert on Telegram:
|
||||
|
||||
```bash
|
||||
opensre hermes watch
|
||||
```
|
||||
|
||||
Other useful commands:
|
||||
|
||||
```bash
|
||||
opensre integrations setup
|
||||
opensre agents scan
|
||||
opensre update
|
||||
opensre uninstall # remove opensre and all local data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Two primary AWS EC2 paths and a general hosted option:
|
||||
|
||||
- **EC2 (Docker/ECR):** `make build-image` then `make deploy` — runs `opensre-web` and `opensre-gateway` containers on one instance.
|
||||
- **Gateway (AMI + systemd):** `make bake-gateway` then `make deploy-gateway` — Telegram gateway only, no Docker, baked into a custom AMI.
|
||||
- **Hosted (Railway / ECS / Vercel):** deploy with the repo `Dockerfile`; set `LLM_PROVIDER` and the matching API key (see [`.env.example`](.env.example)), plus `DATABASE_URI` and `REDIS_URI` if persistence is needed.
|
||||
|
||||
**[Full deployment steps and prerequisites → DEPLOYMENT.md](DEPLOYMENT.md)**
|
||||
|
||||
---
|
||||
|
||||
## How OpenSRE Works
|
||||
|
||||
<img
|
||||
src="https://github.com/user-attachments/assets/936ab1f2-9bda-438d-9897-e8e9cd98e335"
|
||||
width="1064"
|
||||
height="568"
|
||||
alt="opensre-how-it-works-github"
|
||||
/>
|
||||
|
||||
When an alert fires, OpenSRE automatically:
|
||||
|
||||
1. **Fetches** the alert context and correlated logs, metrics, traces, and recent deploys
|
||||
2. **Masks** sensitive identifiers (optional) before external LLM calls
|
||||
3. **Reasons** across your connected systems to test hypotheses in a tool-calling loop
|
||||
4. **Generates** a structured investigation report with probable root cause and linked evidence
|
||||
5. **Suggests** next steps and, optionally, executes remediation actions
|
||||
6. **Posts** a summary directly to Slack, PagerDuty, or Telegram — no context switching needed
|
||||
|
||||
For the current code-level agent architecture after removing the old graph and chain
|
||||
framework layers, see [AGENTS.md](AGENTS.md).
|
||||
|
||||
---
|
||||
|
||||
## Benchmark
|
||||
|
||||
Regenerate numbers with **`make benchmark`**; refresh this table from cached results via **`make benchmark-update-readme`**. See **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#benchmark)** for details.
|
||||
|
||||
<!-- BENCHMARK-START -->
|
||||
|
||||
_No benchmark results yet._
|
||||
|
||||
<!-- BENCHMARK-END -->
|
||||
|
||||
---
|
||||
|
||||
## Capabilities & integrations
|
||||
|
||||
| | |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| 🔍 **Structured incident investigation** | Correlated root-cause analysis across logs, metrics, traces, deploys, and config |
|
||||
| 📋 **Runbook-aware reasoning** | OpenSRE reads your runbooks and applies them automatically |
|
||||
| 🔗 **Evidence-backed root cause** | Every conclusion is linked to the data behind it |
|
||||
| 🛡️ **Reversible identifier masking** | Redact pods, clusters, and account IDs before external LLM calls; restore in output |
|
||||
| 📊 **Session cost & history** | Per-session token tracking (`/cost`) and resumable REPL sessions (`/sessions`) |
|
||||
| 👥 **Local agent fleet** | Monitor Claude Code, Cursor, Codex, and other coding agents on your machine |
|
||||
| 🌐 **Remote runtime RCA** | Investigate deployed services by name with live health probes and recent logs |
|
||||
| 📡 **Hermes log watch** | Tail Hermes error logs, classify incidents, and deliver Telegram alerts |
|
||||
| 🤖 **Full LLM flexibility** | Bring your own model — Anthropic, OpenAI, Codex, Ollama, Gemini, OpenRouter, NVIDIA NIM, Bedrock |
|
||||
|
||||
OpenSRE connects to **60+** tools across LLMs, observability, cloud infrastructure, data platforms, incident management, and MCP. The full matrix (with roadmap links) lives in the **[product docs](https://www.opensre.com/docs)**; a detailed catalog is also maintained in-repo as the project grows.
|
||||
|
||||
---
|
||||
|
||||
## Integrations
|
||||
|
||||
OpenSRE connects to 60+ tools and services across the modern cloud stack, from LLM providers and observability platforms to infrastructure, databases, and incident management.
|
||||
|
||||
| Category | Integrations | Roadmap |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **AI / LLM Providers** | Anthropic · OpenAI · OpenAI Codex · Ollama · Google Gemini · OpenRouter · NVIDIA NIM · Bedrock | |
|
||||
| **Observability** | <img src="docs/assets/icons/grafana.webp" width="16"> Grafana (Loki · Mimir · Tempo · annotations) · <img src="docs/assets/icons/datadog.svg" width="16"> Datadog · Honeycomb · Coralogix · <img src="docs/assets/icons/cloudwatch.png" width="16"> CloudWatch · <img src="docs/assets/icons/sentry.png" width="16"> Sentry · Elasticsearch · Better Stack · Splunk · Victoria Logs · SignOz · OpenObserve · OpenSearch · Azure Monitor · Hermes | [New Relic](https://github.com/Tracer-Cloud/opensre/issues/139) |
|
||||
| **Infrastructure** | <img src="docs/assets/icons/kubernetes.png" width="16"> Kubernetes · <img src="docs/assets/icons/aws.png" width="16"> AWS (S3 · Lambda · EKS · EC2 · CloudTrail · Bedrock) · <img src="docs/assets/icons/gcp.jpg" width="16"> GCP · <img src="docs/assets/icons/azure.png" width="16"> Azure · ArgoCD · Helm · Jenkins | |
|
||||
| **Database** | MongoDB · ClickHouse · PostgreSQL · MySQL · MariaDB · MongoDB Atlas · Azure SQL · Snowflake · Redis · RDS · Supabase | |
|
||||
| **Data Platform** | Apache Airflow · Apache Kafka · Apache Spark · Prefect · RabbitMQ · Dagster | |
|
||||
| **Dev Tools** | <img src="docs/assets/icons/github.webp" width="16"> GitHub · GitHub MCP · Bitbucket · GitLab | |
|
||||
| **Incident Management** | <img src="docs/assets/icons/pagerduty.png" width="16"> PagerDuty · Opsgenie · Jira · Alertmanager · incident.io | [Trello](https://github.com/Tracer-Cloud/opensre/issues/361) · [ServiceNow](https://github.com/Tracer-Cloud/opensre/issues/314) · [Linear](https://github.com/Tracer-Cloud/opensre/issues/124) |
|
||||
| **Communication** | <img src="docs/assets/icons/slack.png" width="16"> Slack · Google Docs · Discord · Telegram · WhatsApp | [Notion](https://github.com/Tracer-Cloud/opensre/issues/286) · [Teams](https://github.com/Tracer-Cloud/opensre/issues/138) · [Confluence](https://github.com/Tracer-Cloud/opensre/issues/313) |
|
||||
| **Agent Deployment** | <img src="docs/assets/icons/vercel.png" width="16"> Vercel · <img src="docs/assets/icons/aws.png" width="16"> EC2 · <img src="docs/assets/icons/aws.png" width="16"> ECS · Railway | |
|
||||
| **Protocols** | <img src="docs/assets/icons/mcp.svg" width="16"> MCP · <img src="docs/assets/icons/acp.png" width="16"> ACP · <img src="docs/assets/icons/openclaw.jpg" width="16"> OpenClaw | |
|
||||
|
||||
OpenSRE is community-built. Looking for a safe first contribution? Browse [`good first issue`](https://github.com/Tracer-Cloud/opensre/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tickets or see the [Good First Issues guide](docs/good-first-issues/README.md). See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full workflow.
|
||||
|
||||
**Local environment:** **[SETUP.md](SETUP.md)** (all platforms, Windows, MCP/OpenClaw).
|
||||
|
||||
**Developing in this repo:** **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)** (install from source, CI parity checks, dev container, benchmark, deployment detail, telemetry reference).
|
||||
|
||||
<p>
|
||||
<a href="https://discord.gg/7NTpevXf7w">
|
||||
<img src="https://img.shields.io/badge/Join%20our%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.star-history.com/?type=date&repos=Tracer-Cloud%2Fopensre">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Tracer-Cloud/opensre&type=date&theme=dark&legend=top-left&sealed_token=LHlhQArnQVcZZDuHHjU19dJHIIzQx9WzB2xacqhOnA8REEAfcVO94FgGmjAMsR8iiPA3ELR-RmF_t2rtnLLj6ieZt6S4PGbFZ5Ev1HgIrg8KJNkJDYQob_BcV8MtWc2wQnahPyKX6B9PgqvoQxI7E6B6SkW7gXQEq1xLLGDZ2JHvv5b3kr7SYnUpq98y" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Tracer-Cloud/opensre&type=date&legend=top-left&sealed_token=LHlhQArnQVcZZDuHHjU19dJHIIzQx9WzB2xacqhOnA8REEAfcVO94FgGmjAMsR8iiPA3ELR-RmF_t2rtnLLj6ieZt6S4PGbFZ5Ev1HgIrg8KJNkJDYQob_BcV8MtWc2wQnahPyKX6B9PgqvoQxI7E6B6SkW7gXQEq1xLLGDZ2JHvv5b3kr7SYnUpq98y" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Tracer-Cloud/opensre&type=date&legend=top-left&sealed_token=LHlhQArnQVcZZDuHHjU19dJHIIzQx9WzB2xacqhOnA8REEAfcVO94FgGmjAMsR8iiPA3ELR-RmF_t2rtnLLj6ieZt6S4PGbFZ5Ev1HgIrg8KJNkJDYQob_BcV8MtWc2wQnahPyKX6B9PgqvoQxI7E6B6SkW7gXQEq1xLLGDZ2JHvv5b3kr7SYnUpq98y" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Thanks goes to these amazing people:
|
||||
|
||||
<!-- readme: contributors -start -->
|
||||
<a href="https://github.com/Tracer-Cloud/opensre/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Tracer-Cloud/opensre&max=200" alt="Contributors" />
|
||||
</a>
|
||||
<!-- readme: contributors -end -->
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
OpenSRE is designed with production environments in mind: structured and auditable LLM prompts, local transcript handling by default, and no silent bulk export of raw logs. See **[SECURITY.md](SECURITY.md)** for responsible disclosure.
|
||||
|
||||
---
|
||||
|
||||
## Telemetry
|
||||
|
||||
PostHog (product analytics) and Sentry (errors) are **opt-out**. Quick disable:
|
||||
|
||||
```bash
|
||||
export OPENSRE_NO_TELEMETRY=1
|
||||
```
|
||||
|
||||
**[Full matrix, DSN override, and local event logging → docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#telemetry-and-privacy)**
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 — see [LICENSE](LICENSE).
|
||||
|
||||
## Citations
|
||||
|
||||
<sup>1</sup> https://arxiv.org/abs/2310.06770
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`Tracer-Cloud/opensre`
|
||||
- 原始仓库:https://github.com/Tracer-Cloud/opensre
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,39 @@
|
||||
# Security Policy
|
||||
|
||||
OpenSRE is designed for security-sensitive compute environments. We prioritize transparency, least privilege, and minimizing data exposure.
|
||||
|
||||
## Trust Center
|
||||
|
||||
For our security posture, architecture overview, compliance information, and subprocessors, see:
|
||||
|
||||
https://trust.tracer.cloud/
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you’ve found a security vulnerability in Tracer, please report it privately.
|
||||
|
||||
**Please do not open a public GitHub issue** for security vulnerabilities.
|
||||
|
||||
Email: support@opensre.com
|
||||
|
||||
### What to include
|
||||
|
||||
To help us triage quickly, please include:
|
||||
|
||||
- a clear description of the issue
|
||||
- affected component(s) and version(s)
|
||||
- steps to reproduce (or a minimal proof-of-concept)
|
||||
- observed / expected behavior
|
||||
- impact assessment (what an attacker could achieve)
|
||||
|
||||
### Coordinated disclosure
|
||||
|
||||
Please avoid public disclosure until we’ve had a reasonable opportunity to investigate and remediate. We’ll coordinate timelines with you when appropriate.
|
||||
|
||||
## Non-security Support
|
||||
|
||||
If you need product help or documentation, use:
|
||||
|
||||
- Docs: https://opensre.com/docs
|
||||
- Book a demo: https://opensre.com
|
||||
- Email: support@opensre.com
|
||||
@@ -0,0 +1,213 @@
|
||||
# Development environment setup
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.12+** — required by [`pyproject.toml`](pyproject.toml) (`requires-python = ">=3.12"`). CI workflows use **Python 3.13** (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)). [`.tool-versions`](.tool-versions) pins Python **3.13**, **uv**, **ruff**, and **mypy** (versions aligned with [`uv.lock`](uv.lock) where applicable) plus Node/pnpm for mise/asdf-style managers — optional; normal flows install **ruff** and **mypy** into `.venv` via **`make install`** / **`uv sync`**.
|
||||
- Git
|
||||
- **[uv](https://docs.astral.sh/uv/getting-started/installation/)** — required for `make install` (locked deps from `uv.lock`)
|
||||
- **Make** — standard on macOS/Linux; Windows options below
|
||||
|
||||
## Quick setup (all platforms)
|
||||
|
||||
1. Fork and clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/opensre.git
|
||||
cd opensre
|
||||
```
|
||||
|
||||
2. Install uv if needed:
|
||||
|
||||
- **macOS/Linux:** `curl -LsSf https://astral.sh/uv/install.sh | sh` (or the [uv install guide](https://docs.astral.sh/uv/getting-started/installation/))
|
||||
- **Windows (PowerShell):** `irm https://astral.sh/uv/install.ps1 | iex`
|
||||
Or: `winget install --id astral-sh.uv -e`
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
Without Make (equivalent to `make install`):
|
||||
|
||||
```bash
|
||||
uv sync --frozen --extra dev
|
||||
uv run python -m platform.analytics.install
|
||||
```
|
||||
|
||||
4. Verify:
|
||||
|
||||
```bash
|
||||
make lint && make format-check && make typecheck && make test-cov
|
||||
```
|
||||
|
||||
`format-check` is what CI enforces for formatting; include it before opening a PR.
|
||||
|
||||
---
|
||||
|
||||
## VS Code dev container
|
||||
|
||||
1. Install the **Dev Containers** extension in VS Code.
|
||||
2. Start Docker Desktop, OrbStack, Colima, or another Docker-compatible runtime on the host.
|
||||
3. Open the repository and run **Dev Containers: Reopen in Container**.
|
||||
|
||||
The image is built from [`.devcontainer/Dockerfile`](.devcontainer/Dockerfile) (**Python 3.13**). **`postCreateCommand`** creates `.venv-devcontainer` and runs **`pip install -e '.[dev]'`** (not `uv`). The interpreter VS Code uses is `.venv-devcontainer/bin/python`.
|
||||
|
||||
On the host, most contributors use **`make install`** + **`uv run`** instead; both approaches are valid.
|
||||
|
||||
---
|
||||
|
||||
## Windows-specific setup
|
||||
|
||||
Windows does not ship **make**. Pick one path below.
|
||||
|
||||
### Option A: Chocolatey (recommended)
|
||||
|
||||
1. Open PowerShell **as Administrator**.
|
||||
2. Install Chocolatey (review the script first):
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
```
|
||||
|
||||
3. Install make:
|
||||
|
||||
```powershell
|
||||
choco install make
|
||||
```
|
||||
|
||||
4. Restart the terminal and verify: `make --version`.
|
||||
|
||||
### Option B: winget
|
||||
|
||||
```powershell
|
||||
winget install GnuWin32.Make
|
||||
```
|
||||
|
||||
Restart the terminal, then `make --version`.
|
||||
|
||||
### Option C: No Make
|
||||
|
||||
Run equivalents from the repo root (same shell where `uv` is on `PATH`). Prefer **`make test-cov`** when possible — the full pytest line is in the [`Makefile`](Makefile) under the `test-cov` target (`pytest -n auto`, coverage, and ignores).
|
||||
|
||||
```bash
|
||||
uv sync --frozen --extra dev
|
||||
uv run python -m platform.analytics.install
|
||||
|
||||
uv run ruff check config core gateway integrations platform surfaces tools tests/
|
||||
uv run ruff format --check config core gateway integrations platform surfaces tools tests/
|
||||
uv run mypy config core gateway integrations platform surfaces tools
|
||||
|
||||
uv run pytest -n auto -v \
|
||||
--cov=config --cov=core --cov=gateway --cov=integrations \
|
||||
--cov=platform --cov=surfaces --cov=tools --cov-report=term-missing \
|
||||
--ignore=tests/e2e/kubernetes_local_alert_simulation \
|
||||
--ignore=tests/synthetic \
|
||||
-m "not synthetic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Commands not using the project environment
|
||||
|
||||
- Prefer **`uv run <command>`** from the repo root.
|
||||
- Refresh deps: **`uv sync --frozen --extra dev`**.
|
||||
|
||||
### Command not found: python
|
||||
|
||||
- Install Python **3.12+** and ensure it is on `PATH` (`python --version`).
|
||||
|
||||
### Command not found: uv
|
||||
|
||||
- Install uv (links above), then restart the terminal.
|
||||
|
||||
### `make install` / `uv sync` fails
|
||||
|
||||
- Run commands from the repository root; ensure **`uv.lock`** is present.
|
||||
- Upgrade uv: **`uv self update`**.
|
||||
- If the lockfile does not match **`pyproject.toml`**, run **`uv lock`** locally and commit the updated lockfile (or open a PR).
|
||||
|
||||
### make: command not found (Windows)
|
||||
|
||||
- Install make (above) or use Option C.
|
||||
|
||||
### Import errors when running code
|
||||
|
||||
- Use **`uv run`** from the repo root.
|
||||
- Re-run **`uv sync --frozen --extra dev`**.
|
||||
|
||||
### `opensre` does not pick up local code edits
|
||||
|
||||
`make install` installs this repo in **editable** mode into `.venv`, but another **`opensre`** may appear earlier on **`PATH`** (installer binary, version manager, `~/.local/bin`, etc.).
|
||||
|
||||
1. Prefer **`uv run opensre …`** from the repository root.
|
||||
2. Or prepend the venv: `export PATH="$(pwd)/.venv/bin:$PATH"` (macOS/Linux), then **`hash -r`** / new shell, and confirm **`which opensre`** points at **`<repo>/.venv/bin/opensre`**.
|
||||
|
||||
---
|
||||
|
||||
## Verify your setup
|
||||
|
||||
```bash
|
||||
make lint && make format-check && make typecheck && make test-cov
|
||||
```
|
||||
|
||||
If those pass, you are ready to develop. Contribution flow: **[CONTRIBUTING.md](CONTRIBUTING.md)**. Deeper contributor topics (benchmark, deployment, telemetry detail): **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Connecting OpenClaw
|
||||
|
||||
OpenSRE no longer exposes a separate `opensre-mcp` server. Instead, OpenSRE connects to the OpenClaw bridge directly to read recent conversation context and write RCA findings back into OpenClaw.
|
||||
|
||||
### 1. Configure observability
|
||||
|
||||
Run the full wizard once (**recommended**):
|
||||
|
||||
```bash
|
||||
uv run opensre onboard
|
||||
```
|
||||
|
||||
To add or reconfigure a **single** integration non-interactively:
|
||||
|
||||
```bash
|
||||
uv run opensre integrations setup <service>
|
||||
```
|
||||
|
||||
### 2. Configure the OpenClaw bridge
|
||||
|
||||
Use the wizard or the direct setup flow:
|
||||
|
||||
```bash
|
||||
uv run opensre integrations setup openclaw
|
||||
uv run opensre integrations verify openclaw
|
||||
```
|
||||
|
||||
Recommended local settings:
|
||||
|
||||
```bash
|
||||
OPENCLAW_MCP_MODE=stdio
|
||||
OPENCLAW_MCP_COMMAND=openclaw
|
||||
OPENCLAW_MCP_ARGS="mcp serve"
|
||||
```
|
||||
|
||||
### 3. Run a test
|
||||
|
||||
```bash
|
||||
uv run opensre investigate -i tests/fixtures/openclaw_test_alert.json
|
||||
```
|
||||
|
||||
### 4. Optional: OpenSRE calls OpenClaw during RCA
|
||||
|
||||
```bash
|
||||
export OPENCLAW_MCP_MODE=stdio
|
||||
export OPENCLAW_MCP_COMMAND=openclaw
|
||||
export OPENCLAW_MCP_ARGS="mcp serve"
|
||||
```
|
||||
|
||||
Keep the OpenClaw gateway running while you investigate, then verify:
|
||||
|
||||
```bash
|
||||
opensre integrations verify openclaw
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Configuration package for OpenSRE constants and runtime config helpers."""
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Global application configuration.
|
||||
|
||||
Clerk JWT configuration for both development and production environments.
|
||||
These are public endpoints and issuer URLs, not secrets.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from difflib import get_close_matches
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, ValidationError, field_validator, model_validator
|
||||
|
||||
from config.llm_auth.auth_method import (
|
||||
LLM_AUTH_METHOD_ENV,
|
||||
effective_llm_provider,
|
||||
get_configured_llm_auth_method,
|
||||
)
|
||||
from config.llm_auth.credentials import status as credential_status
|
||||
from config.llm_auth.provider_catalog import (
|
||||
API_KEY_PROVIDER_ENVS,
|
||||
SUPPORTED_PROVIDER_VALUES,
|
||||
)
|
||||
from config.local_env import bootstrap_opensre_env
|
||||
from config.strict_config import StrictConfigModel
|
||||
|
||||
|
||||
class LLMModelConfig(StrictConfigModel):
|
||||
"""Configuration for an LLM provider's model variants.
|
||||
|
||||
Three tiers, ordered by capability/cost:
|
||||
- ``reasoning_model`` — highest-capability model used for root-cause
|
||||
diagnosis and other deep-reasoning steps (e.g. Claude Opus, GPT-5).
|
||||
- ``classification_model`` — mid-tier model for tasks that need more
|
||||
reasoning than a fast toolcall model but don't justify reasoning cost
|
||||
(e.g. interactive-shell intent classification). Sonnet for Anthropic.
|
||||
- ``toolcall_model`` — lightweight, low-latency model for simple tool
|
||||
selection / action planning (e.g. Claude Haiku, GPT-5 mini).
|
||||
"""
|
||||
|
||||
reasoning_model: str
|
||||
classification_model: str
|
||||
toolcall_model: str
|
||||
max_tokens: int
|
||||
|
||||
|
||||
class Environment(Enum):
|
||||
"""Application environment."""
|
||||
|
||||
DEVELOPMENT = "development"
|
||||
PRODUCTION = "production"
|
||||
|
||||
|
||||
class ClerkConfig(StrictConfigModel):
|
||||
"""Clerk JWT configuration for a specific environment."""
|
||||
|
||||
jwks_url: str
|
||||
issuer: str
|
||||
|
||||
|
||||
CLERK_CONFIG_DEV = ClerkConfig(
|
||||
jwks_url="https://superb-jackal-75.clerk.accounts.dev/.well-known/jwks.json",
|
||||
issuer="https://superb-jackal-75.clerk.accounts.dev",
|
||||
)
|
||||
|
||||
CLERK_CONFIG_PROD = ClerkConfig(
|
||||
jwks_url="https://clerk.tracer.cloud/.well-known/jwks.json",
|
||||
issuer="https://clerk.tracer.cloud",
|
||||
)
|
||||
|
||||
|
||||
def get_environment() -> Environment:
|
||||
"""Get current environment from ENV variable.
|
||||
|
||||
Returns:
|
||||
Environment enum value based on ENV variable.
|
||||
Defaults to DEVELOPMENT if not set or unrecognized.
|
||||
"""
|
||||
env_value = os.getenv("ENV", "development").lower()
|
||||
if env_value in ("production", "prod"):
|
||||
return Environment.PRODUCTION
|
||||
return Environment.DEVELOPMENT
|
||||
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ALGORITHM = "RS256"
|
||||
JWKS_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
# LLM Model Constants
|
||||
DEFAULT_MAX_TOKENS = 4096
|
||||
|
||||
# Anthropic model constants
|
||||
ANTHROPIC_REASONING_MODEL = "claude-opus-4-7"
|
||||
ANTHROPIC_CLASSIFICATION_MODEL = "claude-sonnet-4-6"
|
||||
ANTHROPIC_TOOLCALL_MODEL = "claude-haiku-4-5-20251001"
|
||||
|
||||
# OpenAI model constants
|
||||
# Default to GPT-5.4 mini for both reasoning and toolcall paths; override via
|
||||
# OPENAI_REASONING_MODEL / OPENAI_TOOLCALL_MODEL when needed.
|
||||
OPENAI_REASONING_MODEL = "gpt-5.4-mini"
|
||||
# Mid-tier mirrors the toolcall (mini) model by default — OpenAI's mini sits
|
||||
# between full and nano, which matches the "Sonnet-equivalent" classification
|
||||
# tier well enough; override via OPENAI_CLASSIFICATION_MODEL when needed.
|
||||
OPENAI_CLASSIFICATION_MODEL = "gpt-5.4-mini"
|
||||
OPENAI_TOOLCALL_MODEL = "gpt-5.4-mini"
|
||||
|
||||
# OpenRouter model constants
|
||||
OPENROUTER_REASONING_MODEL = "openrouter/auto"
|
||||
OPENROUTER_CLASSIFICATION_MODEL = "openrouter/auto"
|
||||
OPENROUTER_TOOLCALL_MODEL = "openrouter/auto"
|
||||
|
||||
# DeepSeek model constants
|
||||
DEEPSEEK_REASONING_MODEL = "deepseek-v4-pro"
|
||||
DEEPSEEK_CLASSIFICATION_MODEL = "deepseek-v4-flash"
|
||||
DEEPSEEK_TOOLCALL_MODEL = "deepseek-v4-flash"
|
||||
|
||||
# Gemini model constants (Google AI preview IDs; OpenAI-compatible endpoint)
|
||||
# UNVERIFIED PLACEHOLDER — gemini-3.1-pro-preview / gemini-3.1-flash-lite-preview are
|
||||
# forward-looking IDs that may not yet exist. Override via GEMINI_REASONING_MODEL env var.
|
||||
GEMINI_REASONING_MODEL = "gemini-3.1-pro-preview"
|
||||
GEMINI_CLASSIFICATION_MODEL = "gemini-3-flash-preview"
|
||||
GEMINI_TOOLCALL_MODEL = "gemini-3.1-flash-lite-preview"
|
||||
|
||||
# NVIDIA NIM model constants
|
||||
# Verified safe defaults from the NVIDIA API Catalog (build.nvidia.com).
|
||||
# Override via NVIDIA_REASONING_MODEL, NVIDIA_TOOLCALL_MODEL, or NVIDIA_MODEL env vars.
|
||||
NVIDIA_REASONING_MODEL = "meta/llama-3.1-405b-instruct"
|
||||
NVIDIA_CLASSIFICATION_MODEL = "meta/llama-3.1-70b-instruct"
|
||||
NVIDIA_TOOLCALL_MODEL = "meta/llama-3.1-8b-instruct"
|
||||
|
||||
# MiniMax model constants
|
||||
MINIMAX_REASONING_MODEL = "MiniMax-M3"
|
||||
MINIMAX_CLASSIFICATION_MODEL = "MiniMax-M2.7-highspeed"
|
||||
MINIMAX_TOOLCALL_MODEL = "MiniMax-M2.7-highspeed"
|
||||
|
||||
# Groq model constants
|
||||
GROQ_REASONING_MODEL = "llama-3.3-70b-versatile"
|
||||
GROQ_CLASSIFICATION_MODEL = "llama-3.3-70b-versatile"
|
||||
GROQ_TOOLCALL_MODEL = "llama-3.1-8b-instant"
|
||||
|
||||
# Azure OpenAI deployment-name defaults (must match your Azure deployment names).
|
||||
AZURE_OPENAI_REASONING_MODEL = "gpt-5.4-mini"
|
||||
AZURE_OPENAI_CLASSIFICATION_MODEL = "gpt-5.4-mini"
|
||||
AZURE_OPENAI_TOOLCALL_MODEL = "gpt-5.4-mini"
|
||||
DEFAULT_AZURE_OPENAI_API_VERSION = "2024-10-21"
|
||||
|
||||
# Base URLs for OpenAI-compatible providers
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
DEEPSEEK_BASE_URL = "https://api.deepseek.com" # no /v1 — DeepSeek serves the OpenAI-compatible API at the root path
|
||||
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||
NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
||||
MINIMAX_BASE_URL = "https://api.minimax.io/v1"
|
||||
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
||||
|
||||
# Amazon Bedrock model constants (US cross-region inference profile IDs)
|
||||
BEDROCK_REASONING_MODEL = "us.anthropic.claude-sonnet-4-6"
|
||||
BEDROCK_CLASSIFICATION_MODEL = "us.anthropic.claude-sonnet-4-6"
|
||||
BEDROCK_TOOLCALL_MODEL = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
|
||||
# Ollama local model constants
|
||||
DEFAULT_OLLAMA_MODEL = "llama3.2"
|
||||
DEFAULT_OLLAMA_HOST = "http://localhost:11434"
|
||||
|
||||
LLMProvider = Literal[
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"deepseek",
|
||||
"gemini",
|
||||
"nvidia",
|
||||
"ollama",
|
||||
"bedrock",
|
||||
"minimax",
|
||||
"groq",
|
||||
"azure-openai",
|
||||
"codex",
|
||||
"cursor",
|
||||
"claude-code",
|
||||
"gemini-cli",
|
||||
"antigravity-cli",
|
||||
"opencode",
|
||||
"kimi",
|
||||
"copilot",
|
||||
"grok-cli",
|
||||
"pi",
|
||||
]
|
||||
|
||||
LLM_PROVIDER_API_KEY_ENVS = API_KEY_PROVIDER_ENVS
|
||||
|
||||
# Runtime identifiers for ``LLMProvider`` members. Branch on these instead of
|
||||
# bare string literals when routing on the active provider.
|
||||
PROVIDER_ANTHROPIC: LLMProvider = "anthropic"
|
||||
PROVIDER_OPENAI: LLMProvider = "openai"
|
||||
PROVIDER_BEDROCK: LLMProvider = "bedrock"
|
||||
PROVIDER_OLLAMA: LLMProvider = "ollama"
|
||||
|
||||
|
||||
def get_configured_llm_provider() -> str:
|
||||
"""Return the active LLM provider from env/project .env."""
|
||||
bootstrap_opensre_env(override=False)
|
||||
return os.getenv("LLM_PROVIDER", "anthropic").strip().lower() or "anthropic"
|
||||
|
||||
|
||||
def get_llm_provider_api_key_env(provider: str | None = None) -> str | None:
|
||||
"""Return the API-key env var required by an LLM provider, if any."""
|
||||
provider_name = (provider or get_configured_llm_provider()).strip().lower()
|
||||
auth_method = get_configured_llm_auth_method(provider_name)
|
||||
if effective_llm_provider(provider_name, auth_method) != provider_name:
|
||||
return None
|
||||
return LLM_PROVIDER_API_KEY_ENVS.get(provider_name)
|
||||
|
||||
|
||||
def get_llm_provider_api_key(provider: str | None = None) -> tuple[str | None, str]:
|
||||
"""Return an env API key only; Keychain reads are request-scoped now."""
|
||||
env_var = get_llm_provider_api_key_env(provider)
|
||||
if env_var is None:
|
||||
return None, ""
|
||||
return env_var, os.getenv(env_var, "").strip()
|
||||
|
||||
|
||||
def _llm_api_key_payload(provider: str) -> dict[str, str]:
|
||||
"""Return no secrets; runtime resolves credentials request-time."""
|
||||
_ = provider
|
||||
return {}
|
||||
|
||||
|
||||
def _llm_settings_env_payload(provider: str) -> dict[str, object]:
|
||||
"""Build the raw env-backed payload used to validate LLM settings."""
|
||||
return {
|
||||
"provider": provider,
|
||||
**_llm_api_key_payload(provider),
|
||||
"anthropic_reasoning_model": os.getenv(
|
||||
"ANTHROPIC_REASONING_MODEL", ANTHROPIC_REASONING_MODEL
|
||||
).strip()
|
||||
or ANTHROPIC_REASONING_MODEL,
|
||||
"anthropic_classification_model": os.getenv(
|
||||
"ANTHROPIC_CLASSIFICATION_MODEL", ANTHROPIC_CLASSIFICATION_MODEL
|
||||
).strip()
|
||||
or ANTHROPIC_CLASSIFICATION_MODEL,
|
||||
"anthropic_toolcall_model": os.getenv(
|
||||
"ANTHROPIC_TOOLCALL_MODEL", ANTHROPIC_TOOLCALL_MODEL
|
||||
).strip()
|
||||
or ANTHROPIC_TOOLCALL_MODEL,
|
||||
"openai_reasoning_model": os.getenv(
|
||||
"OPENAI_REASONING_MODEL", OPENAI_REASONING_MODEL
|
||||
).strip()
|
||||
or OPENAI_REASONING_MODEL,
|
||||
"openai_classification_model": os.getenv(
|
||||
"OPENAI_CLASSIFICATION_MODEL", OPENAI_CLASSIFICATION_MODEL
|
||||
).strip()
|
||||
or OPENAI_CLASSIFICATION_MODEL,
|
||||
"openai_toolcall_model": os.getenv("OPENAI_TOOLCALL_MODEL", OPENAI_TOOLCALL_MODEL).strip()
|
||||
or OPENAI_TOOLCALL_MODEL,
|
||||
"openrouter_reasoning_model": os.getenv(
|
||||
"OPENROUTER_REASONING_MODEL",
|
||||
os.getenv("OPENROUTER_MODEL", OPENROUTER_REASONING_MODEL),
|
||||
).strip()
|
||||
or OPENROUTER_REASONING_MODEL,
|
||||
"openrouter_classification_model": os.getenv(
|
||||
"OPENROUTER_CLASSIFICATION_MODEL",
|
||||
os.getenv("OPENROUTER_MODEL", OPENROUTER_CLASSIFICATION_MODEL),
|
||||
).strip()
|
||||
or OPENROUTER_CLASSIFICATION_MODEL,
|
||||
"openrouter_toolcall_model": os.getenv(
|
||||
"OPENROUTER_TOOLCALL_MODEL",
|
||||
os.getenv("OPENROUTER_MODEL", OPENROUTER_TOOLCALL_MODEL),
|
||||
).strip()
|
||||
or OPENROUTER_TOOLCALL_MODEL,
|
||||
"deepseek_reasoning_model": os.getenv(
|
||||
"DEEPSEEK_REASONING_MODEL",
|
||||
os.getenv("DEEPSEEK_MODEL", DEEPSEEK_REASONING_MODEL),
|
||||
).strip()
|
||||
or DEEPSEEK_REASONING_MODEL,
|
||||
"deepseek_classification_model": os.getenv(
|
||||
"DEEPSEEK_CLASSIFICATION_MODEL",
|
||||
os.getenv("DEEPSEEK_MODEL", DEEPSEEK_CLASSIFICATION_MODEL),
|
||||
).strip()
|
||||
or DEEPSEEK_CLASSIFICATION_MODEL,
|
||||
"deepseek_toolcall_model": os.getenv(
|
||||
"DEEPSEEK_TOOLCALL_MODEL",
|
||||
os.getenv("DEEPSEEK_MODEL", DEEPSEEK_TOOLCALL_MODEL),
|
||||
).strip()
|
||||
or DEEPSEEK_TOOLCALL_MODEL,
|
||||
"gemini_reasoning_model": os.getenv(
|
||||
"GEMINI_REASONING_MODEL",
|
||||
os.getenv("GEMINI_MODEL", GEMINI_REASONING_MODEL),
|
||||
).strip()
|
||||
or GEMINI_REASONING_MODEL,
|
||||
"gemini_classification_model": os.getenv(
|
||||
"GEMINI_CLASSIFICATION_MODEL",
|
||||
os.getenv("GEMINI_MODEL", GEMINI_CLASSIFICATION_MODEL),
|
||||
).strip()
|
||||
or GEMINI_CLASSIFICATION_MODEL,
|
||||
"gemini_toolcall_model": os.getenv(
|
||||
"GEMINI_TOOLCALL_MODEL",
|
||||
os.getenv("GEMINI_MODEL", GEMINI_TOOLCALL_MODEL),
|
||||
).strip()
|
||||
or GEMINI_TOOLCALL_MODEL,
|
||||
"nvidia_reasoning_model": os.getenv(
|
||||
"NVIDIA_REASONING_MODEL",
|
||||
os.getenv("NVIDIA_MODEL", NVIDIA_REASONING_MODEL),
|
||||
).strip()
|
||||
or NVIDIA_REASONING_MODEL,
|
||||
"nvidia_classification_model": os.getenv(
|
||||
"NVIDIA_CLASSIFICATION_MODEL",
|
||||
os.getenv("NVIDIA_MODEL", NVIDIA_CLASSIFICATION_MODEL),
|
||||
).strip()
|
||||
or NVIDIA_CLASSIFICATION_MODEL,
|
||||
"nvidia_toolcall_model": os.getenv(
|
||||
"NVIDIA_TOOLCALL_MODEL",
|
||||
os.getenv("NVIDIA_MODEL", NVIDIA_TOOLCALL_MODEL),
|
||||
).strip()
|
||||
or NVIDIA_TOOLCALL_MODEL,
|
||||
"minimax_reasoning_model": os.getenv(
|
||||
"MINIMAX_REASONING_MODEL",
|
||||
os.getenv("MINIMAX_MODEL", MINIMAX_REASONING_MODEL),
|
||||
).strip()
|
||||
or MINIMAX_REASONING_MODEL,
|
||||
"minimax_classification_model": os.getenv(
|
||||
"MINIMAX_CLASSIFICATION_MODEL",
|
||||
os.getenv("MINIMAX_MODEL", MINIMAX_CLASSIFICATION_MODEL),
|
||||
).strip()
|
||||
or MINIMAX_CLASSIFICATION_MODEL,
|
||||
"minimax_toolcall_model": os.getenv(
|
||||
"MINIMAX_TOOLCALL_MODEL",
|
||||
os.getenv("MINIMAX_MODEL", MINIMAX_TOOLCALL_MODEL),
|
||||
).strip()
|
||||
or MINIMAX_TOOLCALL_MODEL,
|
||||
"groq_reasoning_model": os.getenv(
|
||||
"GROQ_REASONING_MODEL",
|
||||
os.getenv("GROQ_MODEL", GROQ_REASONING_MODEL),
|
||||
).strip()
|
||||
or GROQ_REASONING_MODEL,
|
||||
"groq_classification_model": os.getenv(
|
||||
"GROQ_CLASSIFICATION_MODEL",
|
||||
os.getenv("GROQ_MODEL", GROQ_CLASSIFICATION_MODEL),
|
||||
).strip()
|
||||
or GROQ_CLASSIFICATION_MODEL,
|
||||
"groq_toolcall_model": os.getenv(
|
||||
"GROQ_TOOLCALL_MODEL",
|
||||
os.getenv("GROQ_MODEL", GROQ_TOOLCALL_MODEL),
|
||||
).strip()
|
||||
or GROQ_TOOLCALL_MODEL,
|
||||
"azure_openai_base_url": os.getenv("AZURE_OPENAI_BASE_URL", "").strip(),
|
||||
"azure_openai_api_version": os.getenv(
|
||||
"AZURE_OPENAI_API_VERSION", DEFAULT_AZURE_OPENAI_API_VERSION
|
||||
).strip()
|
||||
or DEFAULT_AZURE_OPENAI_API_VERSION,
|
||||
"azure_openai_reasoning_model": os.getenv(
|
||||
"AZURE_OPENAI_REASONING_MODEL",
|
||||
os.getenv("AZURE_OPENAI_MODEL", AZURE_OPENAI_REASONING_MODEL),
|
||||
).strip()
|
||||
or AZURE_OPENAI_REASONING_MODEL,
|
||||
"azure_openai_classification_model": os.getenv(
|
||||
"AZURE_OPENAI_CLASSIFICATION_MODEL",
|
||||
os.getenv("AZURE_OPENAI_MODEL", AZURE_OPENAI_CLASSIFICATION_MODEL),
|
||||
).strip()
|
||||
or AZURE_OPENAI_CLASSIFICATION_MODEL,
|
||||
"azure_openai_toolcall_model": os.getenv(
|
||||
"AZURE_OPENAI_TOOLCALL_MODEL",
|
||||
os.getenv("AZURE_OPENAI_MODEL", AZURE_OPENAI_TOOLCALL_MODEL),
|
||||
).strip()
|
||||
or AZURE_OPENAI_TOOLCALL_MODEL,
|
||||
"bedrock_reasoning_model": os.getenv(
|
||||
"BEDROCK_REASONING_MODEL", BEDROCK_REASONING_MODEL
|
||||
).strip()
|
||||
or BEDROCK_REASONING_MODEL,
|
||||
"bedrock_classification_model": os.getenv(
|
||||
"BEDROCK_CLASSIFICATION_MODEL", BEDROCK_CLASSIFICATION_MODEL
|
||||
).strip()
|
||||
or BEDROCK_CLASSIFICATION_MODEL,
|
||||
"bedrock_toolcall_model": os.getenv(
|
||||
"BEDROCK_TOOLCALL_MODEL", BEDROCK_TOOLCALL_MODEL
|
||||
).strip()
|
||||
or BEDROCK_TOOLCALL_MODEL,
|
||||
"ollama_model": os.getenv("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL).strip()
|
||||
or DEFAULT_OLLAMA_MODEL,
|
||||
"ollama_host": os.getenv("OLLAMA_HOST", DEFAULT_OLLAMA_HOST).strip() or DEFAULT_OLLAMA_HOST,
|
||||
"max_tokens": os.getenv("LLM_MAX_TOKENS", str(DEFAULT_MAX_TOKENS)),
|
||||
}
|
||||
|
||||
|
||||
class LLMSettings(StrictConfigModel):
|
||||
"""Strict runtime configuration for selecting and authenticating an LLM provider."""
|
||||
|
||||
provider: LLMProvider = "anthropic"
|
||||
anthropic_api_key: str = ""
|
||||
openai_api_key: str = ""
|
||||
openrouter_api_key: str = ""
|
||||
deepseek_api_key: str = ""
|
||||
gemini_api_key: str = ""
|
||||
nvidia_api_key: str = ""
|
||||
minimax_api_key: str = ""
|
||||
groq_api_key: str = ""
|
||||
azure_openai_api_key: str = ""
|
||||
azure_openai_base_url: str = ""
|
||||
azure_openai_api_version: str = DEFAULT_AZURE_OPENAI_API_VERSION
|
||||
ollama_model: str = DEFAULT_OLLAMA_MODEL
|
||||
ollama_host: str = DEFAULT_OLLAMA_HOST
|
||||
anthropic_reasoning_model: str = ANTHROPIC_REASONING_MODEL
|
||||
anthropic_classification_model: str = ANTHROPIC_CLASSIFICATION_MODEL
|
||||
anthropic_toolcall_model: str = ANTHROPIC_TOOLCALL_MODEL
|
||||
openai_reasoning_model: str = OPENAI_REASONING_MODEL
|
||||
openai_classification_model: str = OPENAI_CLASSIFICATION_MODEL
|
||||
openai_toolcall_model: str = OPENAI_TOOLCALL_MODEL
|
||||
openrouter_reasoning_model: str = OPENROUTER_REASONING_MODEL
|
||||
openrouter_classification_model: str = OPENROUTER_CLASSIFICATION_MODEL
|
||||
openrouter_toolcall_model: str = OPENROUTER_TOOLCALL_MODEL
|
||||
deepseek_reasoning_model: str = DEEPSEEK_REASONING_MODEL
|
||||
deepseek_classification_model: str = DEEPSEEK_CLASSIFICATION_MODEL
|
||||
deepseek_toolcall_model: str = DEEPSEEK_TOOLCALL_MODEL
|
||||
gemini_reasoning_model: str = GEMINI_REASONING_MODEL
|
||||
gemini_classification_model: str = GEMINI_CLASSIFICATION_MODEL
|
||||
gemini_toolcall_model: str = GEMINI_TOOLCALL_MODEL
|
||||
nvidia_reasoning_model: str = NVIDIA_REASONING_MODEL
|
||||
nvidia_classification_model: str = NVIDIA_CLASSIFICATION_MODEL
|
||||
nvidia_toolcall_model: str = NVIDIA_TOOLCALL_MODEL
|
||||
minimax_reasoning_model: str = MINIMAX_REASONING_MODEL
|
||||
minimax_classification_model: str = MINIMAX_CLASSIFICATION_MODEL
|
||||
minimax_toolcall_model: str = MINIMAX_TOOLCALL_MODEL
|
||||
groq_reasoning_model: str = GROQ_REASONING_MODEL
|
||||
groq_classification_model: str = GROQ_CLASSIFICATION_MODEL
|
||||
groq_toolcall_model: str = GROQ_TOOLCALL_MODEL
|
||||
azure_openai_reasoning_model: str = AZURE_OPENAI_REASONING_MODEL
|
||||
azure_openai_classification_model: str = AZURE_OPENAI_CLASSIFICATION_MODEL
|
||||
azure_openai_toolcall_model: str = AZURE_OPENAI_TOOLCALL_MODEL
|
||||
bedrock_reasoning_model: str = BEDROCK_REASONING_MODEL
|
||||
bedrock_classification_model: str = BEDROCK_CLASSIFICATION_MODEL
|
||||
bedrock_toolcall_model: str = BEDROCK_TOOLCALL_MODEL
|
||||
max_tokens: int = Field(default=DEFAULT_MAX_TOKENS, gt=0)
|
||||
|
||||
@field_validator("ollama_host", mode="before")
|
||||
@classmethod
|
||||
def _normalize_ollama_host(cls, value: object) -> str:
|
||||
host = str(value or DEFAULT_OLLAMA_HOST).strip() or DEFAULT_OLLAMA_HOST
|
||||
if not host.startswith(("http://", "https://")):
|
||||
host = f"http://{host}"
|
||||
return host
|
||||
|
||||
@field_validator("azure_openai_base_url", mode="before")
|
||||
@classmethod
|
||||
def _normalize_azure_openai_base_url(cls, value: object) -> str:
|
||||
from core.llm.providers.azure_openai import normalize_azure_openai_base_url
|
||||
|
||||
return normalize_azure_openai_base_url(str(value or ""))
|
||||
|
||||
@field_validator("provider", mode="before")
|
||||
@classmethod
|
||||
def _normalize_provider(cls, value: object) -> str:
|
||||
provider = str(value or "anthropic").strip().lower() or "anthropic"
|
||||
valid_providers = SUPPORTED_PROVIDER_VALUES
|
||||
if provider in valid_providers:
|
||||
return provider
|
||||
suggestion = get_close_matches(provider, valid_providers, n=1)
|
||||
if suggestion:
|
||||
raise ValueError(
|
||||
f"Unsupported LLM provider '{provider}'. Did you mean '{suggestion[0]}'?"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Unsupported LLM provider '{provider}'. Expected one of: {', '.join(valid_providers)}."
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_api_key_for_selected_provider(self) -> "LLMSettings":
|
||||
if self.provider == "azure-openai" and not self.azure_openai_base_url:
|
||||
raise ValueError(
|
||||
"LLM provider 'azure-openai' requires AZURE_OPENAI_BASE_URL to be set."
|
||||
)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "LLMSettings":
|
||||
"""Build validated LLM settings from environment variables."""
|
||||
bootstrap_opensre_env(override=False)
|
||||
return cls.model_validate(_llm_settings_env_payload(get_configured_llm_provider()))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMResolution:
|
||||
"""Outcome of resolving LLM settings for the configured provider."""
|
||||
|
||||
settings: LLMSettings
|
||||
configured_provider: str
|
||||
resolved_provider: str
|
||||
attempted_providers: tuple[str, ...]
|
||||
missing_key_env: str | None
|
||||
|
||||
@property
|
||||
def fell_back(self) -> bool:
|
||||
"""True when the active provider differs from the configured one."""
|
||||
return self.resolved_provider != self.configured_provider
|
||||
|
||||
def summary(self) -> str:
|
||||
"""One-line, user-facing description of the active provider decision."""
|
||||
return f"Using configured LLM provider '{self.resolved_provider}'."
|
||||
|
||||
|
||||
def resolve_llm_settings_verbose(
|
||||
fallback_providers: Sequence[str] = (),
|
||||
) -> LLMResolution:
|
||||
"""Resolve LLM settings without implicit provider fallback."""
|
||||
bootstrap_opensre_env(override=False)
|
||||
_ = fallback_providers
|
||||
configured_provider = get_configured_llm_provider()
|
||||
settings = LLMSettings.model_validate(_llm_settings_env_payload(configured_provider))
|
||||
return LLMResolution(
|
||||
settings=settings,
|
||||
configured_provider=configured_provider,
|
||||
resolved_provider=settings.provider,
|
||||
attempted_providers=(configured_provider,),
|
||||
missing_key_env=None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_llm_settings(
|
||||
fallback_providers: Sequence[str] = (),
|
||||
) -> LLMSettings:
|
||||
"""Resolve LLM settings for the configured provider only."""
|
||||
return resolve_llm_settings_verbose(fallback_providers).settings
|
||||
|
||||
|
||||
def describe_llm_resolution(
|
||||
fallback_providers: Sequence[str] = (),
|
||||
) -> str:
|
||||
"""Return a human-readable LLM provider resolution report for diagnostics.
|
||||
|
||||
Safe to call even when no provider has usable credentials: instead of
|
||||
raising it reports the missing-credentials condition. Intended for
|
||||
``/status``, doctor commands, and CI diagnostics so operators no longer need
|
||||
ad-hoc inline probes to see which provider is actually in use.
|
||||
"""
|
||||
try:
|
||||
resolution = resolve_llm_settings_verbose(fallback_providers)
|
||||
except ValidationError as exc:
|
||||
configured = get_configured_llm_provider()
|
||||
env_var = get_llm_provider_api_key_env(configured)
|
||||
detail = exc.errors()[0].get("msg", str(exc)) if exc.errors() else str(exc)
|
||||
lines = [
|
||||
f"configured provider : {configured}",
|
||||
"resolved provider : <none — no usable provider credentials>",
|
||||
]
|
||||
if env_var:
|
||||
lines.append(f"required key : {env_var}")
|
||||
lines.append(f"detail : {detail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines = [
|
||||
f"configured provider : {resolution.configured_provider}",
|
||||
f"resolved provider : {resolution.resolved_provider}",
|
||||
f"auth method : {get_configured_llm_auth_method(resolution.resolved_provider)}",
|
||||
"fell back : no",
|
||||
f"providers attempted : {', '.join(resolution.attempted_providers)}",
|
||||
]
|
||||
auth_provider = effective_llm_provider(
|
||||
resolution.resolved_provider,
|
||||
get_configured_llm_auth_method(resolution.resolved_provider),
|
||||
)
|
||||
auth_status = credential_status(auth_provider)
|
||||
lines.append(f"credential status : {auth_status.source} ({auth_status.detail})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def llm_provider_error_context(
|
||||
fallback_providers: Sequence[str] = (),
|
||||
) -> str:
|
||||
"""Return a short bracketed provider context for prefixing error messages.
|
||||
|
||||
Never raises — diagnostics must not mask the original error. Returns an
|
||||
empty string when resolution itself fails so callers can fall back to the
|
||||
raw provider error untouched.
|
||||
"""
|
||||
try:
|
||||
resolution = resolve_llm_settings_verbose(fallback_providers)
|
||||
except Exception:
|
||||
return ""
|
||||
return f"[LLM provider: {resolution.resolved_provider}]"
|
||||
|
||||
|
||||
def has_credentials_for_active_llm_provider() -> bool:
|
||||
"""Return prompt-safe auth availability for the configured LLM provider."""
|
||||
settings = resolve_llm_settings()
|
||||
auth_status = credential_status(
|
||||
effective_llm_provider(settings.provider, os.getenv(LLM_AUTH_METHOD_ENV))
|
||||
)
|
||||
return auth_status.configured and not auth_status.stale
|
||||
|
||||
|
||||
# LLM Provider Configs
|
||||
ANTHROPIC_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=ANTHROPIC_REASONING_MODEL,
|
||||
classification_model=ANTHROPIC_CLASSIFICATION_MODEL,
|
||||
toolcall_model=ANTHROPIC_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
OPENAI_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=OPENAI_REASONING_MODEL,
|
||||
classification_model=OPENAI_CLASSIFICATION_MODEL,
|
||||
toolcall_model=OPENAI_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
OPENROUTER_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=OPENROUTER_REASONING_MODEL,
|
||||
classification_model=OPENROUTER_CLASSIFICATION_MODEL,
|
||||
toolcall_model=OPENROUTER_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
DEEPSEEK_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=DEEPSEEK_REASONING_MODEL,
|
||||
classification_model=DEEPSEEK_CLASSIFICATION_MODEL,
|
||||
toolcall_model=DEEPSEEK_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
GROQ_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=GROQ_REASONING_MODEL,
|
||||
classification_model=GROQ_CLASSIFICATION_MODEL,
|
||||
toolcall_model=GROQ_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
AZURE_OPENAI_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=AZURE_OPENAI_REASONING_MODEL,
|
||||
classification_model=AZURE_OPENAI_CLASSIFICATION_MODEL,
|
||||
toolcall_model=AZURE_OPENAI_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
GEMINI_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=GEMINI_REASONING_MODEL,
|
||||
classification_model=GEMINI_CLASSIFICATION_MODEL,
|
||||
toolcall_model=GEMINI_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
NVIDIA_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=NVIDIA_REASONING_MODEL,
|
||||
classification_model=NVIDIA_CLASSIFICATION_MODEL,
|
||||
toolcall_model=NVIDIA_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
MINIMAX_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=MINIMAX_REASONING_MODEL,
|
||||
classification_model=MINIMAX_CLASSIFICATION_MODEL,
|
||||
toolcall_model=MINIMAX_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
BEDROCK_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=BEDROCK_REASONING_MODEL,
|
||||
classification_model=BEDROCK_CLASSIFICATION_MODEL,
|
||||
toolcall_model=BEDROCK_TOOLCALL_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
OLLAMA_LLM_CONFIG = LLMModelConfig(
|
||||
reasoning_model=DEFAULT_OLLAMA_MODEL,
|
||||
classification_model=DEFAULT_OLLAMA_MODEL,
|
||||
toolcall_model=DEFAULT_OLLAMA_MODEL,
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
# Tracer API Configuration
|
||||
TRACER_BASE_URL_DEV = "https://staging.tracer.cloud"
|
||||
TRACER_BASE_URL_PROD = "https://app.tracer.cloud"
|
||||
SLACK_CHANNEL = "tracer-rca-report-alerts"
|
||||
|
||||
|
||||
def get_tracer_base_url() -> str:
|
||||
"""Get Tracer base URL for current environment."""
|
||||
return (
|
||||
TRACER_BASE_URL_PROD if get_environment() == Environment.PRODUCTION else TRACER_BASE_URL_DEV
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Application-wide constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from config.constants.investigation import MAX_INVESTIGATION_LOOPS
|
||||
from config.constants.paths import (
|
||||
INTEGRATIONS_STORE_PATH,
|
||||
OPENSRE_HOME_DIR,
|
||||
OPENSRE_TMP_DIR,
|
||||
ensure_opensre_tmp_dir,
|
||||
get_store_path,
|
||||
)
|
||||
from config.constants.platform import IS_WINDOWS
|
||||
from config.constants.posthog import (
|
||||
DEFAULT_POSTHOG_BOUNCE_THRESHOLD,
|
||||
DEFAULT_POSTHOG_BOUNCE_WINDOW,
|
||||
DEFAULT_POSTHOG_TIMEOUT_SECONDS,
|
||||
DEFAULT_POSTHOG_URL,
|
||||
POSTHOG_CAPTURE_API_KEY,
|
||||
POSTHOG_HOST,
|
||||
)
|
||||
from config.constants.sentry import (
|
||||
SENTRY_DSN,
|
||||
SENTRY_ERROR_SAMPLE_RATE,
|
||||
SENTRY_IN_APP_INCLUDE,
|
||||
SENTRY_MAX_BREADCRUMBS,
|
||||
SENTRY_TRACES_SAMPLE_RATE,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_POSTHOG_BOUNCE_THRESHOLD",
|
||||
"DEFAULT_POSTHOG_BOUNCE_WINDOW",
|
||||
"DEFAULT_POSTHOG_TIMEOUT_SECONDS",
|
||||
"DEFAULT_POSTHOG_URL",
|
||||
"INTEGRATIONS_STORE_PATH",
|
||||
"IS_WINDOWS",
|
||||
"MAX_INVESTIGATION_LOOPS",
|
||||
"OPENSRE_HOME_DIR",
|
||||
"OPENSRE_TMP_DIR",
|
||||
"POSTHOG_CAPTURE_API_KEY",
|
||||
"POSTHOG_HOST",
|
||||
"SENTRY_DSN",
|
||||
"SENTRY_ERROR_SAMPLE_RATE",
|
||||
"SENTRY_IN_APP_INCLUDE",
|
||||
"SENTRY_MAX_BREADCRUMBS",
|
||||
"SENTRY_TRACES_SAMPLE_RATE",
|
||||
"ensure_opensre_tmp_dir",
|
||||
"get_store_path",
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Constants shared between orchestration routing and investigation stages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
MAX_INVESTIGATION_LOOPS = 20
|
||||
|
||||
# Approval tokens auto-expire after this many seconds (5 minutes).
|
||||
DEFAULT_APPROVAL_EXPIRY_SECONDS: Final[int] = 300
|
||||
|
||||
__all__ = ["DEFAULT_APPROVAL_EXPIRY_SECONDS", "MAX_INVESTIGATION_LOOPS"]
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
PROJECT_ROOT = REPO_ROOT
|
||||
|
||||
SYNTHETIC_SCENARIOS_DIR = REPO_ROOT / "tests" / "synthetic" / "rds_postgres"
|
||||
|
||||
OPENSRE_HOME_DIR = Path.home() / ".opensre"
|
||||
INTEGRATIONS_STORE_PATH = OPENSRE_HOME_DIR / "integrations.json"
|
||||
OPENSRE_TMP_DIR = Path(tempfile.gettempdir()) / "opensre"
|
||||
|
||||
|
||||
def get_store_path() -> Path:
|
||||
override = os.getenv("OPENSRE_WIZARD_STORE_PATH", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return OPENSRE_HOME_DIR / "opensre.json"
|
||||
|
||||
|
||||
def ensure_opensre_tmp_dir() -> Path:
|
||||
OPENSRE_TMP_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with contextlib.suppress(OSError):
|
||||
OPENSRE_TMP_DIR.chmod(0o700)
|
||||
return OPENSRE_TMP_DIR
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INTEGRATIONS_STORE_PATH",
|
||||
"OPENSRE_HOME_DIR",
|
||||
"OPENSRE_TMP_DIR",
|
||||
"PROJECT_ROOT",
|
||||
"REPO_ROOT",
|
||||
"SYNTHETIC_SCENARIOS_DIR",
|
||||
"ensure_opensre_tmp_dir",
|
||||
"get_store_path",
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Host platform flags shared across the application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
IS_WINDOWS: bool = os.name == "nt"
|
||||
|
||||
__all__ = [
|
||||
"IS_WINDOWS",
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Shared PostHog constants used across analytics and integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
POSTHOG_HOST: Final[str] = "https://us.i.posthog.com"
|
||||
POSTHOG_CAPTURE_API_KEY: Final[str] = "phc_zutpVhmQw7oUmMkbawKNdYCKQWjpfASATtf5ywB75W2"
|
||||
|
||||
DEFAULT_POSTHOG_URL: Final[str] = POSTHOG_HOST
|
||||
DEFAULT_POSTHOG_TIMEOUT_SECONDS: Final[float] = 15.0
|
||||
DEFAULT_POSTHOG_BOUNCE_THRESHOLD: Final[float] = 0.6
|
||||
DEFAULT_POSTHOG_BOUNCE_WINDOW: Final[str] = "24h"
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Shared prompt-string constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Prefilled into the next prompt after a background synthetic test exits non-zero,
|
||||
# so the user can ask the CLI assistant for a quick RCA explanation. Both the core
|
||||
# prompt builders and the shell reference it, so it lives in config (the lowest layer).
|
||||
SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST = "why did it fail?"
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Sentry constants for OpenSRE runtime error monitoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
SENTRY_DSN: Final[str] = (
|
||||
"https://06d6b2b739eb2267864d12c6cad34e70"
|
||||
"@o4509281671380992.ingest.us.sentry.io/4511150863482880"
|
||||
)
|
||||
SENTRY_ERROR_SAMPLE_RATE: Final[float] = 1.0
|
||||
SENTRY_TRACES_SAMPLE_RATE: Final[float] = 1.0
|
||||
SENTRY_MAX_BREADCRUMBS: Final[int] = 100
|
||||
SENTRY_IN_APP_INCLUDE: Final[tuple[str, ...]] = ("app",)
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
DEFAULT_INSTANCE_URL = "https://tracerbio.grafana.net"
|
||||
DEFAULT_LOKI_UID = "grafanacloud-logs"
|
||||
DEFAULT_TEMPO_UID = "grafanacloud-traces"
|
||||
DEFAULT_MIMIR_UID = "grafanacloud-prom"
|
||||
|
||||
|
||||
def _get_env(key: str, default: str = "") -> str:
|
||||
return os.getenv(key, default)
|
||||
|
||||
|
||||
def get_env(key: str, default: str = "") -> str:
|
||||
load_env()
|
||||
return _get_env(key, default)
|
||||
|
||||
|
||||
def load_env(env_path: Path | str | None = None, *, override: bool = False) -> None:
|
||||
if os.getenv("GRAFANA_CONFIG_SKIP_ENV_FILE") == "1":
|
||||
return
|
||||
if env_path is None:
|
||||
env_path = Path.cwd() / ".env"
|
||||
path = Path(env_path)
|
||||
if not path.exists():
|
||||
return
|
||||
for line in path.read_text().splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or stripped.startswith(";"):
|
||||
continue
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and (override or key not in os.environ):
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def get_account_read_token(account_id: str) -> str:
|
||||
if account_id == "tracerbio":
|
||||
return get_grafana_read_token()
|
||||
return get_env(f"GRAFANA_{account_id.upper()}_READ_TOKEN", "")
|
||||
|
||||
|
||||
def get_account_instance_url(account_id: str) -> str:
|
||||
if account_id == "tracerbio":
|
||||
return get_grafana_instance_url()
|
||||
return get_env(f"GRAFANA_{account_id.upper()}_INSTANCE_URL", "")
|
||||
|
||||
|
||||
def get_account_datasource_uids(account_id: str) -> tuple[str, str, str]:
|
||||
load_env()
|
||||
if account_id == "tracerbio":
|
||||
return get_datasource_uids()
|
||||
prefix = f"GRAFANA_{account_id.upper()}"
|
||||
loki_uid = _get_env(f"{prefix}_LOKI_DATASOURCE_UID", DEFAULT_LOKI_UID)
|
||||
tempo_uid = _get_env(f"{prefix}_TEMPO_DATASOURCE_UID", DEFAULT_TEMPO_UID)
|
||||
mimir_uid = _get_env(f"{prefix}_MIMIR_DATASOURCE_UID", DEFAULT_MIMIR_UID)
|
||||
return loki_uid, tempo_uid, mimir_uid
|
||||
|
||||
|
||||
def list_account_ids() -> list[str]:
|
||||
load_env()
|
||||
accounts = {"tracerbio"}
|
||||
for key in os.environ:
|
||||
if key.startswith("GRAFANA_") and key.endswith("_READ_TOKEN"):
|
||||
account_id = key[len("GRAFANA_") : -len("_READ_TOKEN")].lower()
|
||||
if account_id:
|
||||
accounts.add(account_id)
|
||||
return sorted(accounts)
|
||||
|
||||
|
||||
def get_grafana_read_token() -> str:
|
||||
return get_env("GRAFANA_READ_TOKEN", "")
|
||||
|
||||
|
||||
def get_grafana_instance_url() -> str:
|
||||
return get_env("GRAFANA_INSTANCE_URL", DEFAULT_INSTANCE_URL)
|
||||
|
||||
|
||||
def get_datasource_uids() -> tuple[str, str, str]:
|
||||
load_env()
|
||||
loki_uid = _get_env("GRAFANA_LOKI_DATASOURCE_UID", DEFAULT_LOKI_UID)
|
||||
tempo_uid = _get_env("GRAFANA_TEMPO_DATASOURCE_UID", DEFAULT_TEMPO_UID)
|
||||
mimir_uid = _get_env("GRAFANA_MIMIR_DATASOURCE_UID", DEFAULT_MIMIR_UID)
|
||||
return loki_uid, tempo_uid, mimir_uid
|
||||
|
||||
|
||||
def get_otlp_endpoint() -> str:
|
||||
return get_env("GCLOUD_OTLP_ENDPOINT", "")
|
||||
|
||||
|
||||
def get_otlp_auth_header() -> str:
|
||||
return get_env("GCLOUD_OTLP_AUTH_HEADER", "")
|
||||
|
||||
|
||||
def get_otel_protocol() -> str:
|
||||
return get_env("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
|
||||
|
||||
|
||||
def get_effective_otlp_endpoint() -> str:
|
||||
load_env()
|
||||
return _get_env("OTEL_EXPORTER_OTLP_ENDPOINT") or _get_env("GCLOUD_OTLP_ENDPOINT", "")
|
||||
|
||||
|
||||
def get_otel_exporter_otlp_protocol(default: str = "grpc") -> str:
|
||||
return get_env("OTEL_EXPORTER_OTLP_PROTOCOL", default)
|
||||
|
||||
|
||||
def get_otel_exporter_otlp_metrics_protocol(default: str = "grpc") -> str:
|
||||
load_env()
|
||||
return _get_env(
|
||||
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
|
||||
_get_env("OTEL_EXPORTER_OTLP_PROTOCOL", default),
|
||||
)
|
||||
|
||||
|
||||
def get_otel_exporter_otlp_endpoint(default: str = "") -> str:
|
||||
return get_env("OTEL_EXPORTER_OTLP_ENDPOINT", default)
|
||||
|
||||
|
||||
def get_otel_exporter_otlp_metrics_endpoint(default: str = "") -> str:
|
||||
return get_env("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", default)
|
||||
|
||||
|
||||
def get_otel_exporter_otlp_headers(default: str = "") -> str:
|
||||
return get_env("OTEL_EXPORTER_OTLP_HEADERS", default)
|
||||
|
||||
|
||||
def get_aws_lambda_function_name(default: str = "") -> str:
|
||||
return get_env("AWS_LAMBDA_FUNCTION_NAME", default)
|
||||
|
||||
|
||||
def parse_otel_headers(headers_str: str | None = None) -> dict[str, str]:
|
||||
headers_raw = headers_str if headers_str is not None else get_otel_exporter_otlp_headers()
|
||||
headers: dict[str, str] = {}
|
||||
if headers_raw:
|
||||
for pair in headers_raw.split(","):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
headers[key.strip()] = value.strip()
|
||||
return headers
|
||||
|
||||
|
||||
def get_hosted_logs_id() -> str:
|
||||
return get_env("GCLOUD_HOSTED_LOGS_ID", "")
|
||||
|
||||
|
||||
def get_hosted_logs_url() -> str:
|
||||
return get_env("GCLOUD_HOSTED_LOGS_URL", "")
|
||||
|
||||
|
||||
def get_hosted_metrics_id() -> str:
|
||||
return get_env("GCLOUD_HOSTED_METRICS_ID", "")
|
||||
|
||||
|
||||
def get_hosted_metrics_url() -> str:
|
||||
return get_env("GCLOUD_HOSTED_METRICS_URL", "")
|
||||
|
||||
|
||||
def get_hosted_traces_id() -> str:
|
||||
return get_env("GCLOUD_HOSTED_TRACES_ID", "")
|
||||
|
||||
|
||||
def get_hosted_traces_url() -> str:
|
||||
load_env()
|
||||
traces_url = _get_env("GCLOUD_HOSTED_TRACES_URL_TEMPO") or _get_env(
|
||||
"GCLOUD_HOSTED_TRACES_URL", ""
|
||||
)
|
||||
return traces_url
|
||||
|
||||
|
||||
def get_rw_api_key() -> str:
|
||||
return get_env("GCLOUD_RW_API_KEY", "")
|
||||
|
||||
|
||||
def _is_grafana_hostname(endpoint: str) -> bool:
|
||||
hostname = urlparse(endpoint).hostname or ""
|
||||
return (
|
||||
hostname == "grafana.net"
|
||||
or hostname.endswith(".grafana.net")
|
||||
or hostname == "grafana.com"
|
||||
or hostname.endswith(".grafana.com")
|
||||
)
|
||||
|
||||
|
||||
def is_grafana_otlp_endpoint(value: str | None = None) -> bool:
|
||||
endpoint = value if value is not None else get_effective_otlp_endpoint()
|
||||
return _is_grafana_hostname(endpoint)
|
||||
|
||||
|
||||
def configure_grafana_cloud(env_file: Path | str | None = None) -> None:
|
||||
"""
|
||||
Configure OTLP to send telemetry to Grafana Cloud.
|
||||
|
||||
Args:
|
||||
env_file: Optional path to .env file containing Grafana Cloud credentials.
|
||||
If provided, loads environment variables from this file.
|
||||
|
||||
Raises:
|
||||
ValueError: If GCLOUD_OTLP_ENDPOINT is not set after loading .env.
|
||||
|
||||
Environment variables used:
|
||||
- GCLOUD_OTLP_ENDPOINT: Grafana Cloud OTLP endpoint (required)
|
||||
- GCLOUD_OTLP_AUTH_HEADER: Authorization header value (optional but recommended)
|
||||
"""
|
||||
load_env(env_file)
|
||||
|
||||
endpoint = get_otlp_endpoint()
|
||||
auth_header = get_otlp_auth_header()
|
||||
|
||||
if not endpoint:
|
||||
raise ValueError("GCLOUD_OTLP_ENDPOINT not set in environment")
|
||||
|
||||
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint
|
||||
os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"
|
||||
os.environ["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] = "cumulative"
|
||||
if auth_header:
|
||||
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization={auth_header}"
|
||||
|
||||
|
||||
def apply_otel_env_defaults() -> None:
|
||||
"""Apply OpenTelemetry environment defaults, preferring Grafana Cloud config if available."""
|
||||
load_env()
|
||||
gcloud_endpoint = get_otlp_endpoint()
|
||||
|
||||
if not get_otel_exporter_otlp_endpoint() and gcloud_endpoint:
|
||||
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = gcloud_endpoint
|
||||
|
||||
gcloud_auth = get_otlp_auth_header()
|
||||
if not get_otel_exporter_otlp_headers() and gcloud_auth:
|
||||
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization={gcloud_auth}"
|
||||
|
||||
if not get_otel_exporter_otlp_protocol(default="") and gcloud_endpoint:
|
||||
os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"
|
||||
|
||||
|
||||
def validate_grafana_cloud_config() -> bool:
|
||||
"""Validate that Grafana Cloud configuration is present when using cloud endpoints."""
|
||||
endpoint = get_effective_otlp_endpoint()
|
||||
if _is_grafana_hostname(endpoint):
|
||||
required_values = {
|
||||
"GCLOUD_HOSTED_METRICS_ID": get_hosted_metrics_id(),
|
||||
"GCLOUD_HOSTED_METRICS_URL": get_hosted_metrics_url(),
|
||||
"GCLOUD_HOSTED_LOGS_ID": get_hosted_logs_id(),
|
||||
"GCLOUD_HOSTED_LOGS_URL": get_hosted_logs_url(),
|
||||
"GCLOUD_RW_API_KEY": get_rw_api_key(),
|
||||
"GCLOUD_OTLP_ENDPOINT": get_otlp_endpoint(),
|
||||
"GCLOUD_OTLP_AUTH_HEADER": get_otlp_auth_header(),
|
||||
}
|
||||
missing = [key for key, value in required_values.items() if not value]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Grafana Cloud endpoint detected but missing env vars: {', '.join(missing)}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def build_resource(service_name: str, extra_attributes: dict[str, Any] | None) -> Resource:
|
||||
attributes: dict[str, Any] = {"service.name": service_name}
|
||||
if extra_attributes:
|
||||
attributes.update(extra_attributes)
|
||||
return Resource.create(attributes)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Config-owned storage helpers for LLM provider auth metadata."""
|
||||
|
||||
from config.llm_auth.auth_method import (
|
||||
API_KEY_AUTH_METHOD,
|
||||
LLM_AUTH_METHOD_ENV,
|
||||
OAUTH_AUTH_METHOD,
|
||||
OAUTH_BACKEND_PROVIDER_BY_PROVIDER,
|
||||
OAUTH_PROVIDER_BY_BACKEND_PROVIDER,
|
||||
canonical_llm_provider,
|
||||
effective_llm_provider,
|
||||
get_configured_llm_auth_method,
|
||||
normalize_llm_auth_method,
|
||||
supports_oauth_auth_method,
|
||||
)
|
||||
from config.llm_auth.credentials import (
|
||||
CredentialResolution,
|
||||
CredentialSource,
|
||||
CredentialStatus,
|
||||
MissingLLMCredentialError,
|
||||
delete,
|
||||
has_api_key_env_status,
|
||||
require_for_request,
|
||||
resolve_api_key_env_for_request,
|
||||
resolve_for_request,
|
||||
save_api_key,
|
||||
source_for_api_key_env,
|
||||
status,
|
||||
verify,
|
||||
)
|
||||
from config.llm_auth.provider_catalog import (
|
||||
API_KEY_PROVIDER_ENVS,
|
||||
KEYLESS_PROVIDER_VALUES,
|
||||
PROVIDER_SPECS,
|
||||
SUPPORTED_PROVIDER_VALUES,
|
||||
ProviderSpec,
|
||||
provider_spec,
|
||||
)
|
||||
from config.llm_auth.records import (
|
||||
delete_provider_auth_record,
|
||||
provider_auth_record_name,
|
||||
resolve_provider_auth_record,
|
||||
save_provider_auth_record,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"API_KEY_PROVIDER_ENVS",
|
||||
"API_KEY_AUTH_METHOD",
|
||||
"CredentialResolution",
|
||||
"CredentialSource",
|
||||
"CredentialStatus",
|
||||
"KEYLESS_PROVIDER_VALUES",
|
||||
"LLM_AUTH_METHOD_ENV",
|
||||
"MissingLLMCredentialError",
|
||||
"OAUTH_AUTH_METHOD",
|
||||
"OAUTH_BACKEND_PROVIDER_BY_PROVIDER",
|
||||
"OAUTH_PROVIDER_BY_BACKEND_PROVIDER",
|
||||
"PROVIDER_SPECS",
|
||||
"ProviderSpec",
|
||||
"SUPPORTED_PROVIDER_VALUES",
|
||||
"canonical_llm_provider",
|
||||
"delete",
|
||||
"delete_provider_auth_record",
|
||||
"effective_llm_provider",
|
||||
"get_configured_llm_auth_method",
|
||||
"has_api_key_env_status",
|
||||
"normalize_llm_auth_method",
|
||||
"provider_auth_record_name",
|
||||
"provider_spec",
|
||||
"require_for_request",
|
||||
"resolve_provider_auth_record",
|
||||
"resolve_api_key_env_for_request",
|
||||
"resolve_for_request",
|
||||
"save_api_key",
|
||||
"save_provider_auth_record",
|
||||
"source_for_api_key_env",
|
||||
"status",
|
||||
"supports_oauth_auth_method",
|
||||
"verify",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""LLM provider auth-method selection.
|
||||
|
||||
The public provider remains the vendor name (for example ``openai``), while
|
||||
some auth methods use a provider-specific runtime backend under the hood.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
LLM_AUTH_METHOD_ENV = "LLM_AUTH_METHOD"
|
||||
LLMAuthMethod = Literal["api_key", "oauth"]
|
||||
|
||||
API_KEY_AUTH_METHOD: LLMAuthMethod = "api_key"
|
||||
OAUTH_AUTH_METHOD: LLMAuthMethod = "oauth"
|
||||
|
||||
OAUTH_BACKEND_PROVIDER_BY_PROVIDER: dict[str, str] = {
|
||||
"openai": "codex",
|
||||
"anthropic": "claude-code",
|
||||
}
|
||||
OAUTH_PROVIDER_BY_BACKEND_PROVIDER: dict[str, str] = {
|
||||
backend: provider for provider, backend in OAUTH_BACKEND_PROVIDER_BY_PROVIDER.items()
|
||||
}
|
||||
|
||||
|
||||
def normalize_llm_auth_method(value: str | None) -> LLMAuthMethod:
|
||||
"""Return a supported auth method, defaulting to API-key auth."""
|
||||
normalized = (value or "").strip().lower()
|
||||
if normalized == OAUTH_AUTH_METHOD:
|
||||
return OAUTH_AUTH_METHOD
|
||||
return API_KEY_AUTH_METHOD
|
||||
|
||||
|
||||
def supports_oauth_auth_method(provider: str) -> bool:
|
||||
"""Whether onboarding exposes OAuth for this public provider."""
|
||||
return provider.strip().lower() in OAUTH_BACKEND_PROVIDER_BY_PROVIDER
|
||||
|
||||
|
||||
def canonical_llm_provider(provider: str) -> str:
|
||||
"""Map legacy OAuth backend provider values to their public provider."""
|
||||
normalized_provider = provider.strip().lower()
|
||||
return OAUTH_PROVIDER_BY_BACKEND_PROVIDER.get(normalized_provider, normalized_provider)
|
||||
|
||||
|
||||
def get_configured_llm_auth_method(provider: str | None = None) -> LLMAuthMethod:
|
||||
"""Return the active auth method from env, with legacy CLI compatibility."""
|
||||
normalized_provider = (provider or os.getenv("LLM_PROVIDER") or "").strip().lower()
|
||||
if normalized_provider in OAUTH_PROVIDER_BY_BACKEND_PROVIDER:
|
||||
return OAUTH_AUTH_METHOD
|
||||
return normalize_llm_auth_method(os.getenv(LLM_AUTH_METHOD_ENV))
|
||||
|
||||
|
||||
def effective_llm_provider(provider: str, auth_method: str | None = None) -> str:
|
||||
"""Map a public provider/auth pair to the runtime provider implementation."""
|
||||
normalized_provider = provider.strip().lower()
|
||||
method = normalize_llm_auth_method(auth_method)
|
||||
if method == OAUTH_AUTH_METHOD:
|
||||
return OAUTH_BACKEND_PROVIDER_BY_PROVIDER.get(normalized_provider, normalized_provider)
|
||||
return normalized_provider
|
||||
|
||||
|
||||
__all__ = [
|
||||
"API_KEY_AUTH_METHOD",
|
||||
"LLM_AUTH_METHOD_ENV",
|
||||
"LLMAuthMethod",
|
||||
"OAUTH_AUTH_METHOD",
|
||||
"OAUTH_BACKEND_PROVIDER_BY_PROVIDER",
|
||||
"OAUTH_PROVIDER_BY_BACKEND_PROVIDER",
|
||||
"canonical_llm_provider",
|
||||
"effective_llm_provider",
|
||||
"get_configured_llm_auth_method",
|
||||
"normalize_llm_auth_method",
|
||||
"supports_oauth_auth_method",
|
||||
]
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Prompt-safe LLM auth status and request-scoped credential resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from config.llm_auth.provider_catalog import (
|
||||
API_KEY_PROVIDER_ENVS,
|
||||
ProviderSpec,
|
||||
provider_spec,
|
||||
require_provider_spec,
|
||||
)
|
||||
from config.llm_auth.records import (
|
||||
delete_provider_auth_record,
|
||||
resolve_provider_auth_record,
|
||||
save_provider_auth_record,
|
||||
save_provider_auth_record_values,
|
||||
)
|
||||
|
||||
CredentialSource = Literal[
|
||||
"env",
|
||||
"keyring",
|
||||
"metadata",
|
||||
"cli",
|
||||
"ambient",
|
||||
"local",
|
||||
"none",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
|
||||
class MissingLLMCredentialError(RuntimeError):
|
||||
"""Raised when a selected provider lacks request-time credentials."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredentialStatus:
|
||||
"""Prompt-safe status for a provider auth path."""
|
||||
|
||||
provider: str
|
||||
configured: bool
|
||||
source: CredentialSource
|
||||
verified: bool
|
||||
stale: bool
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredentialResolution:
|
||||
"""Request-time credential resolution for one provider."""
|
||||
|
||||
provider: str
|
||||
api_key: str
|
||||
source: CredentialSource
|
||||
detail: str
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return bool(self.api_key) or self.source in {"cli", "ambient", "local"}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
redacted = "<set>" if self.api_key else "<empty>"
|
||||
return (
|
||||
"CredentialResolution("
|
||||
f"provider={self.provider!r}, api_key={redacted}, "
|
||||
f"source={self.source!r}, detail={self.detail!r})"
|
||||
)
|
||||
|
||||
|
||||
def _bool_record_value(record: dict[str, str], key: str, default: bool) -> bool:
|
||||
raw = record.get(key)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _normalize_source(raw: str | None, *, fallback: CredentialSource) -> CredentialSource:
|
||||
value = (raw or "").strip().lower()
|
||||
allowed = {"env", "keyring", "metadata", "cli", "ambient", "local", "none", "unknown"}
|
||||
return value if value in allowed else fallback # type: ignore[return-value]
|
||||
|
||||
|
||||
def _env_value(env_var: str) -> str:
|
||||
return os.getenv(env_var, "").strip()
|
||||
|
||||
|
||||
def _source_status(provider: str, source: CredentialSource, detail: str) -> CredentialStatus:
|
||||
return CredentialStatus(
|
||||
provider=provider,
|
||||
configured=source not in {"none", "unknown"},
|
||||
source=source,
|
||||
verified=source not in {"metadata", "unknown"},
|
||||
stale=False,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _record_status(spec: ProviderSpec, record: dict[str, str]) -> CredentialStatus:
|
||||
source = _normalize_source(record.get("source"), fallback="metadata")
|
||||
stale = _bool_record_value(record, "stale", False)
|
||||
verified = _bool_record_value(record, "verified", not stale)
|
||||
detail = record.get("detail") or (
|
||||
f"{spec.api_key_env} was previously saved; run `opensre auth verify {spec.value}` "
|
||||
"to confirm it is still available."
|
||||
)
|
||||
return CredentialStatus(
|
||||
provider=spec.value,
|
||||
configured=True,
|
||||
source=source if source != "keyring" else "metadata",
|
||||
verified=verified and not stale,
|
||||
stale=stale,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def status(provider: str) -> CredentialStatus:
|
||||
"""Return prompt-safe provider auth status.
|
||||
|
||||
This function must not read Keychain secrets. It may inspect environment,
|
||||
non-secret metadata, CLI adapter probes, and ambient/local config markers.
|
||||
"""
|
||||
spec = provider_spec(provider)
|
||||
if spec is None:
|
||||
return CredentialStatus(
|
||||
provider=provider.strip().lower(),
|
||||
configured=False,
|
||||
source="unknown",
|
||||
verified=False,
|
||||
stale=False,
|
||||
detail=f"Unsupported LLM provider: {provider}",
|
||||
)
|
||||
|
||||
if spec.credential_kind == "api_key":
|
||||
if spec.api_key_env and _env_value(spec.api_key_env):
|
||||
return _source_status(
|
||||
spec.value,
|
||||
"env",
|
||||
f"{spec.api_key_env} is set in the environment.",
|
||||
)
|
||||
record = resolve_provider_auth_record(spec.value)
|
||||
if record:
|
||||
return _record_status(spec, record)
|
||||
return CredentialStatus(
|
||||
provider=spec.value,
|
||||
configured=False,
|
||||
source="none",
|
||||
verified=False,
|
||||
stale=False,
|
||||
detail=f"{spec.api_key_env} is not configured.",
|
||||
)
|
||||
|
||||
if spec.credential_kind == "cli":
|
||||
record = resolve_provider_auth_record(spec.value)
|
||||
record_source = _normalize_source(record.get("source"), fallback="metadata")
|
||||
stale = _bool_record_value(record, "stale", False)
|
||||
verified = _bool_record_value(record, "verified", False)
|
||||
if record and verified and not stale:
|
||||
return CredentialStatus(
|
||||
provider=spec.value,
|
||||
configured=True,
|
||||
source="cli" if record_source in {"metadata", "unknown", "none"} else record_source,
|
||||
verified=True,
|
||||
stale=False,
|
||||
detail=record.get("detail") or f"{spec.label} auth metadata is present.",
|
||||
)
|
||||
try:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration(spec.value)
|
||||
if reg is None:
|
||||
return CredentialStatus(
|
||||
spec.value,
|
||||
False,
|
||||
"none",
|
||||
False,
|
||||
False,
|
||||
"No CLI adapter registered.",
|
||||
)
|
||||
probe = reg.adapter_factory().detect()
|
||||
except Exception as exc:
|
||||
return CredentialStatus(
|
||||
spec.value,
|
||||
False,
|
||||
"unknown",
|
||||
False,
|
||||
False,
|
||||
f"CLI auth status could not be checked: {exc}",
|
||||
)
|
||||
configured = probe.installed and probe.logged_in is True
|
||||
source: CredentialSource = "cli" if configured else "none"
|
||||
return CredentialStatus(
|
||||
provider=spec.value,
|
||||
configured=configured,
|
||||
source=source,
|
||||
verified=configured,
|
||||
stale=False,
|
||||
detail=probe.detail,
|
||||
)
|
||||
|
||||
if spec.credential_kind == "ambient":
|
||||
region = os.getenv("AWS_REGION", "").strip() or os.getenv("AWS_DEFAULT_REGION", "").strip()
|
||||
return CredentialStatus(
|
||||
provider=spec.value,
|
||||
configured=bool(region),
|
||||
source="ambient" if region else "none",
|
||||
verified=bool(region),
|
||||
stale=False,
|
||||
detail=(
|
||||
f"AWS region is configured ({region}); Bedrock uses the AWS credential chain."
|
||||
if region
|
||||
else "AWS_REGION or AWS_DEFAULT_REGION is not set."
|
||||
),
|
||||
)
|
||||
|
||||
if spec.credential_kind == "local":
|
||||
host = os.getenv("OLLAMA_HOST", "").strip() or "http://localhost:11434"
|
||||
return CredentialStatus(
|
||||
provider=spec.value,
|
||||
configured=True,
|
||||
source="local",
|
||||
verified=True,
|
||||
stale=False,
|
||||
detail=f"Ollama host: {host}.",
|
||||
)
|
||||
|
||||
return CredentialStatus(spec.value, False, "unknown", False, False, "Unknown auth kind.")
|
||||
|
||||
|
||||
def _mark_stale(spec: ProviderSpec, detail: str) -> None:
|
||||
record = resolve_provider_auth_record(spec.value)
|
||||
if not record:
|
||||
return
|
||||
save_provider_auth_record_values(
|
||||
spec.value,
|
||||
{
|
||||
**record,
|
||||
"source": record.get("source") or "metadata",
|
||||
"detail": detail,
|
||||
"verified": "false",
|
||||
"stale": "true",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def resolve_for_request(provider: str) -> CredentialResolution:
|
||||
"""Resolve request-time auth for exactly one selected provider."""
|
||||
spec = require_provider_spec(provider)
|
||||
if spec.credential_kind == "api_key":
|
||||
env_value = _env_value(spec.api_key_env)
|
||||
if env_value:
|
||||
return CredentialResolution(
|
||||
provider=spec.value,
|
||||
api_key=env_value,
|
||||
source="env",
|
||||
detail=f"{spec.api_key_env} resolved from environment.",
|
||||
)
|
||||
|
||||
import keyring.errors
|
||||
|
||||
from config.llm_keyring import keyring_is_disabled, read_keychain_secret
|
||||
|
||||
if not keyring_is_disabled():
|
||||
try:
|
||||
key = read_keychain_secret(spec.api_key_env)
|
||||
except keyring.errors.KeyringError as exc:
|
||||
# The backend itself couldn't be reached (e.g. no D-Bus/Secret
|
||||
# Service session in this process) — this is not evidence the
|
||||
# credential is missing, so leave any previously-verified
|
||||
# metadata untouched instead of marking it stale.
|
||||
return CredentialResolution(
|
||||
provider=spec.value,
|
||||
api_key="",
|
||||
source="unknown",
|
||||
detail=(
|
||||
f"Could not reach the system keychain to check {spec.api_key_env}: "
|
||||
f"{exc}. Retry once the keychain is reachable."
|
||||
),
|
||||
)
|
||||
if key:
|
||||
save_provider_auth_record(
|
||||
provider=spec.value,
|
||||
auth_name=spec.value,
|
||||
kind="api_key",
|
||||
source="keyring",
|
||||
detail=f"{spec.api_key_env} stored in the system keychain.",
|
||||
verified=True,
|
||||
stale=False,
|
||||
env_var=spec.api_key_env,
|
||||
)
|
||||
return CredentialResolution(
|
||||
provider=spec.value,
|
||||
api_key=key,
|
||||
source="keyring",
|
||||
detail=f"{spec.api_key_env} resolved from secure local storage.",
|
||||
)
|
||||
|
||||
detail = (
|
||||
f"Missing credential for LLM provider '{spec.value}'. Set {spec.api_key_env} "
|
||||
f"or run `opensre auth login {spec.value}`."
|
||||
)
|
||||
_mark_stale(spec, detail)
|
||||
return CredentialResolution(spec.value, "", "none", detail)
|
||||
|
||||
if spec.credential_kind == "cli":
|
||||
return CredentialResolution(
|
||||
provider=spec.value,
|
||||
api_key="",
|
||||
source="cli",
|
||||
detail=f"{spec.label} uses vendor CLI authentication.",
|
||||
)
|
||||
if spec.credential_kind == "ambient":
|
||||
return CredentialResolution(
|
||||
provider=spec.value,
|
||||
api_key="",
|
||||
source="ambient",
|
||||
detail=f"{spec.label} uses ambient credentials.",
|
||||
)
|
||||
if spec.credential_kind == "local":
|
||||
return CredentialResolution(
|
||||
provider=spec.value,
|
||||
api_key="",
|
||||
source="local",
|
||||
detail=f"{spec.label} uses local runtime configuration.",
|
||||
)
|
||||
return CredentialResolution(spec.value, "", "unknown", "Unsupported provider auth kind.")
|
||||
|
||||
|
||||
def require_for_request(provider: str) -> CredentialResolution:
|
||||
"""Resolve request-time auth or raise an actionable error."""
|
||||
resolution = resolve_for_request(provider)
|
||||
if not resolution.ok:
|
||||
raise MissingLLMCredentialError(resolution.detail)
|
||||
return resolution
|
||||
|
||||
|
||||
def resolve_api_key_env_for_request(env_var: str) -> str:
|
||||
"""Resolve one API-key env var through the request-time provider boundary."""
|
||||
normalized = env_var.strip()
|
||||
for provider, provider_env in API_KEY_PROVIDER_ENVS.items():
|
||||
if provider_env == normalized:
|
||||
return resolve_for_request(provider).api_key
|
||||
from config.llm_keyring import resolve_llm_api_key
|
||||
|
||||
return resolve_llm_api_key(normalized)
|
||||
|
||||
|
||||
def save_api_key(provider: str, value: str, *, detail: str | None = None) -> None:
|
||||
"""Store an OpenSRE-managed API key and refresh prompt-safe metadata."""
|
||||
spec = require_provider_spec(provider)
|
||||
if not spec.uses_open_sre_api_key:
|
||||
raise ValueError(f"{spec.value} does not use an OpenSRE-managed API key")
|
||||
from config.llm_keyring import save_llm_api_key
|
||||
|
||||
save_llm_api_key(spec.api_key_env, value)
|
||||
save_provider_auth_record(
|
||||
provider=spec.value,
|
||||
auth_name=spec.value,
|
||||
kind="api_key",
|
||||
source="keyring",
|
||||
detail=detail or f"{spec.api_key_env} stored in the system keychain.",
|
||||
verified=True,
|
||||
stale=False,
|
||||
env_var=spec.api_key_env,
|
||||
)
|
||||
|
||||
|
||||
def delete(provider: str) -> None:
|
||||
"""Delete OpenSRE-managed provider auth metadata and API key when applicable."""
|
||||
spec = require_provider_spec(provider)
|
||||
if spec.uses_open_sre_api_key:
|
||||
from config.llm_keyring import delete_llm_api_key
|
||||
|
||||
delete_llm_api_key(spec.api_key_env)
|
||||
delete_provider_auth_record(spec.value)
|
||||
|
||||
|
||||
def verify(provider: str) -> CredentialStatus:
|
||||
"""Intentionally check request-time credentials and update metadata."""
|
||||
resolution = resolve_for_request(provider)
|
||||
if resolution.ok:
|
||||
return CredentialStatus(
|
||||
provider=resolution.provider,
|
||||
configured=True,
|
||||
source=resolution.source,
|
||||
verified=True,
|
||||
stale=False,
|
||||
detail=resolution.detail,
|
||||
)
|
||||
return status(provider)
|
||||
|
||||
|
||||
def source_for_api_key_env(env_var: str) -> CredentialSource:
|
||||
"""Prompt-safe source lookup for legacy env-var-based callers."""
|
||||
normalized = env_var.strip()
|
||||
if _env_value(normalized):
|
||||
return "env"
|
||||
for provider, provider_env in API_KEY_PROVIDER_ENVS.items():
|
||||
if provider_env == normalized:
|
||||
provider_status = status(provider)
|
||||
return provider_status.source if provider_status.configured else "none"
|
||||
return "none"
|
||||
|
||||
|
||||
def has_api_key_env_status(env_var: str) -> bool:
|
||||
"""Prompt-safe availability check for legacy env-var-based callers."""
|
||||
return source_for_api_key_env(env_var) != "none"
|
||||
|
||||
|
||||
def llm_api_key_source(env_var: str) -> str:
|
||||
"""Return where an LLM credential resolves from: ``env``, ``keyring``, or ``none``."""
|
||||
import keyring
|
||||
import keyring.errors
|
||||
|
||||
from config.llm_keyring import (
|
||||
_KEYRING_SERVICE,
|
||||
keyring_is_disabled,
|
||||
macos_keychain_item_exists,
|
||||
)
|
||||
|
||||
prompt_safe_source = source_for_api_key_env(env_var)
|
||||
if prompt_safe_source != "none":
|
||||
return prompt_safe_source
|
||||
if env_var.strip() in set(API_KEY_PROVIDER_ENVS.values()):
|
||||
return "none"
|
||||
if os.getenv(env_var, "").strip():
|
||||
return "env"
|
||||
if keyring_is_disabled():
|
||||
return "none"
|
||||
item_exists = macos_keychain_item_exists(env_var)
|
||||
if item_exists is not None:
|
||||
return "keyring" if item_exists else "none"
|
||||
try:
|
||||
if (keyring.get_password(_KEYRING_SERVICE, env_var) or "").strip():
|
||||
return "keyring"
|
||||
except keyring.errors.KeyringError:
|
||||
return "none"
|
||||
return "none"
|
||||
|
||||
|
||||
def has_llm_api_key(env_var: str) -> bool:
|
||||
"""Return True when an API key is available from env or secure local storage."""
|
||||
from config.llm_keyring import (
|
||||
keyring_is_disabled,
|
||||
macos_keychain_item_exists,
|
||||
resolve_llm_api_key,
|
||||
)
|
||||
|
||||
if has_api_key_env_status(env_var):
|
||||
return True
|
||||
if env_var.strip() in set(API_KEY_PROVIDER_ENVS.values()):
|
||||
return False
|
||||
if os.getenv(env_var, "").strip():
|
||||
return True
|
||||
if keyring_is_disabled():
|
||||
return False
|
||||
item_exists = macos_keychain_item_exists(env_var)
|
||||
if item_exists is not None:
|
||||
return item_exists
|
||||
return bool(resolve_llm_api_key(env_var))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CredentialResolution",
|
||||
"CredentialSource",
|
||||
"CredentialStatus",
|
||||
"MissingLLMCredentialError",
|
||||
"delete",
|
||||
"has_api_key_env_status",
|
||||
"has_llm_api_key",
|
||||
"llm_api_key_source",
|
||||
"require_for_request",
|
||||
"resolve_api_key_env_for_request",
|
||||
"resolve_for_request",
|
||||
"save_api_key",
|
||||
"source_for_api_key_env",
|
||||
"status",
|
||||
"verify",
|
||||
]
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Canonical LLM provider auth and model-selection metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
CredentialKind = Literal["api_key", "cli", "ambient", "local"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderSpec:
|
||||
"""Provider facts shared by config, auth, wizard, and runtime checks."""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
credential_kind: CredentialKind
|
||||
api_key_env: str = ""
|
||||
model_env: str = ""
|
||||
legacy_model_env: str | None = None
|
||||
toolcall_model_env: str | None = None
|
||||
classification_model_env: str | None = None
|
||||
cli_model_env: str | None = None
|
||||
endpoint_env: str = ""
|
||||
api_version_env: str = ""
|
||||
allow_custom_models: bool = False
|
||||
|
||||
@property
|
||||
def uses_open_sre_api_key(self) -> bool:
|
||||
return self.credential_kind == "api_key" and bool(self.api_key_env)
|
||||
|
||||
|
||||
PROVIDER_SPECS: tuple[ProviderSpec, ...] = (
|
||||
ProviderSpec(
|
||||
value="anthropic",
|
||||
label="Anthropic API key",
|
||||
credential_kind="api_key",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
model_env="ANTHROPIC_REASONING_MODEL",
|
||||
legacy_model_env="ANTHROPIC_MODEL",
|
||||
toolcall_model_env="ANTHROPIC_TOOLCALL_MODEL",
|
||||
classification_model_env="ANTHROPIC_CLASSIFICATION_MODEL",
|
||||
),
|
||||
ProviderSpec(
|
||||
value="openai",
|
||||
label="OpenAI API key",
|
||||
credential_kind="api_key",
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
model_env="OPENAI_REASONING_MODEL",
|
||||
legacy_model_env="OPENAI_MODEL",
|
||||
toolcall_model_env="OPENAI_TOOLCALL_MODEL",
|
||||
classification_model_env="OPENAI_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="openrouter",
|
||||
label="OpenRouter",
|
||||
credential_kind="api_key",
|
||||
api_key_env="OPENROUTER_API_KEY",
|
||||
model_env="OPENROUTER_REASONING_MODEL",
|
||||
legacy_model_env="OPENROUTER_MODEL",
|
||||
toolcall_model_env="OPENROUTER_TOOLCALL_MODEL",
|
||||
classification_model_env="OPENROUTER_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="deepseek",
|
||||
label="DeepSeek",
|
||||
credential_kind="api_key",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
model_env="DEEPSEEK_REASONING_MODEL",
|
||||
legacy_model_env="DEEPSEEK_MODEL",
|
||||
toolcall_model_env="DEEPSEEK_TOOLCALL_MODEL",
|
||||
classification_model_env="DEEPSEEK_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="gemini",
|
||||
label="Google Gemini API key",
|
||||
credential_kind="api_key",
|
||||
api_key_env="GEMINI_API_KEY",
|
||||
model_env="GEMINI_REASONING_MODEL",
|
||||
legacy_model_env="GEMINI_MODEL",
|
||||
toolcall_model_env="GEMINI_TOOLCALL_MODEL",
|
||||
classification_model_env="GEMINI_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="nvidia",
|
||||
label="NVIDIA NIM",
|
||||
credential_kind="api_key",
|
||||
api_key_env="NVIDIA_API_KEY",
|
||||
model_env="NVIDIA_REASONING_MODEL",
|
||||
legacy_model_env="NVIDIA_MODEL",
|
||||
toolcall_model_env="NVIDIA_TOOLCALL_MODEL",
|
||||
classification_model_env="NVIDIA_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="minimax",
|
||||
label="MiniMax",
|
||||
credential_kind="api_key",
|
||||
api_key_env="MINIMAX_API_KEY",
|
||||
model_env="MINIMAX_REASONING_MODEL",
|
||||
legacy_model_env="MINIMAX_MODEL",
|
||||
toolcall_model_env="MINIMAX_TOOLCALL_MODEL",
|
||||
classification_model_env="MINIMAX_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="groq",
|
||||
label="Groq API key",
|
||||
credential_kind="api_key",
|
||||
api_key_env="GROQ_API_KEY",
|
||||
model_env="GROQ_REASONING_MODEL",
|
||||
legacy_model_env="GROQ_MODEL",
|
||||
toolcall_model_env="GROQ_TOOLCALL_MODEL",
|
||||
classification_model_env="GROQ_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="azure-openai",
|
||||
label="Azure OpenAI",
|
||||
credential_kind="api_key",
|
||||
api_key_env="AZURE_OPENAI_API_KEY",
|
||||
model_env="AZURE_OPENAI_REASONING_MODEL",
|
||||
legacy_model_env="AZURE_OPENAI_MODEL",
|
||||
toolcall_model_env="AZURE_OPENAI_TOOLCALL_MODEL",
|
||||
classification_model_env="AZURE_OPENAI_CLASSIFICATION_MODEL",
|
||||
endpoint_env="AZURE_OPENAI_BASE_URL",
|
||||
api_version_env="AZURE_OPENAI_API_VERSION",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="bedrock",
|
||||
label="Amazon Bedrock (IAM auth)",
|
||||
credential_kind="ambient",
|
||||
model_env="BEDROCK_REASONING_MODEL",
|
||||
toolcall_model_env="BEDROCK_TOOLCALL_MODEL",
|
||||
classification_model_env="BEDROCK_CLASSIFICATION_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="ollama",
|
||||
label="Ollama (local)",
|
||||
credential_kind="local",
|
||||
api_key_env="OLLAMA_HOST",
|
||||
model_env="OLLAMA_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="codex",
|
||||
label="OpenAI Codex CLI",
|
||||
credential_kind="cli",
|
||||
model_env="CODEX_MODEL",
|
||||
cli_model_env="CODEX_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="cursor",
|
||||
label="Cursor Agent CLI",
|
||||
credential_kind="cli",
|
||||
model_env="CURSOR_MODEL",
|
||||
cli_model_env="CURSOR_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="claude-code",
|
||||
label="Anthropic Claude Code CLI",
|
||||
credential_kind="cli",
|
||||
model_env="CLAUDE_CODE_MODEL",
|
||||
cli_model_env="CLAUDE_CODE_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="gemini-cli",
|
||||
label="Google Gemini CLI",
|
||||
credential_kind="cli",
|
||||
model_env="GEMINI_CLI_MODEL",
|
||||
cli_model_env="GEMINI_CLI_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="antigravity-cli",
|
||||
label="Google Antigravity CLI",
|
||||
credential_kind="cli",
|
||||
model_env="ANTIGRAVITY_CLI_MODEL",
|
||||
cli_model_env="ANTIGRAVITY_CLI_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="opencode",
|
||||
label="OpenCode CLI",
|
||||
credential_kind="cli",
|
||||
model_env="OPENCODE_MODEL",
|
||||
cli_model_env="OPENCODE_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="kimi",
|
||||
label="Kimi Code CLI",
|
||||
credential_kind="cli",
|
||||
model_env="KIMI_MODEL",
|
||||
cli_model_env="KIMI_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="copilot",
|
||||
label="GitHub Copilot CLI",
|
||||
credential_kind="cli",
|
||||
model_env="COPILOT_MODEL",
|
||||
cli_model_env="COPILOT_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="grok-cli",
|
||||
label="xAI Grok Build CLI",
|
||||
credential_kind="cli",
|
||||
model_env="GROK_CLI_MODEL",
|
||||
cli_model_env="GROK_CLI_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
ProviderSpec(
|
||||
value="pi",
|
||||
label="Pi CLI (pi.dev, BYOK multi-provider)",
|
||||
credential_kind="cli",
|
||||
model_env="PI_MODEL",
|
||||
cli_model_env="PI_MODEL",
|
||||
allow_custom_models=True,
|
||||
),
|
||||
)
|
||||
|
||||
PROVIDER_BY_VALUE: dict[str, ProviderSpec] = {spec.value: spec for spec in PROVIDER_SPECS}
|
||||
SUPPORTED_PROVIDER_VALUES: tuple[str, ...] = tuple(spec.value for spec in PROVIDER_SPECS)
|
||||
API_KEY_PROVIDER_ENVS: dict[str, str] = {
|
||||
spec.value: spec.api_key_env for spec in PROVIDER_SPECS if spec.uses_open_sre_api_key
|
||||
}
|
||||
KEYLESS_PROVIDER_VALUES: frozenset[str] = frozenset(
|
||||
spec.value for spec in PROVIDER_SPECS if not spec.uses_open_sre_api_key
|
||||
)
|
||||
|
||||
|
||||
def provider_spec(provider: str) -> ProviderSpec | None:
|
||||
"""Return the provider spec for *provider*, if supported."""
|
||||
return PROVIDER_BY_VALUE.get(provider.strip().lower())
|
||||
|
||||
|
||||
def require_provider_spec(provider: str) -> ProviderSpec:
|
||||
"""Return the provider spec or raise ``KeyError`` for unsupported providers."""
|
||||
spec = provider_spec(provider)
|
||||
if spec is None:
|
||||
raise KeyError(provider)
|
||||
return spec
|
||||
|
||||
|
||||
__all__ = [
|
||||
"API_KEY_PROVIDER_ENVS",
|
||||
"CredentialKind",
|
||||
"KEYLESS_PROVIDER_VALUES",
|
||||
"PROVIDER_BY_VALUE",
|
||||
"PROVIDER_SPECS",
|
||||
"ProviderSpec",
|
||||
"SUPPORTED_PROVIDER_VALUES",
|
||||
"provider_spec",
|
||||
"require_provider_spec",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Metadata-file-backed LLM provider auth records.
|
||||
|
||||
The records in this file are intentionally non-secret. They exist so status
|
||||
commands can report local auth state without touching Keychain secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from filelock import FileLock
|
||||
|
||||
from config.constants import OPENSRE_HOME_DIR
|
||||
|
||||
_VERSION = 1
|
||||
_AUTH_RECORD_PREFIX = "provider-auth:"
|
||||
_LOCK_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
def _auth_metadata_path() -> Path:
|
||||
override = os.getenv("OPENSRE_LLM_AUTH_METADATA_PATH", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return OPENSRE_HOME_DIR / "llm-auth.json"
|
||||
|
||||
|
||||
def _lock_path(path: Path) -> Path:
|
||||
return path.with_suffix(path.suffix + ".lock")
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def _ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with suppress(OSError):
|
||||
path.parent.chmod(0o700)
|
||||
|
||||
|
||||
def _empty_store() -> dict[str, object]:
|
||||
return {"version": _VERSION, "providers": {}}
|
||||
|
||||
|
||||
def _load_unlocked(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
return _empty_store()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return _empty_store()
|
||||
if not isinstance(data, dict):
|
||||
return _empty_store()
|
||||
providers = data.get("providers")
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
return {"version": _VERSION, "providers": providers}
|
||||
|
||||
|
||||
def _write_unlocked(path: Path, data: Mapping[str, object]) -> None:
|
||||
_ensure_parent(path)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(path.parent),
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
os.chmod(tmp_name, 0o600)
|
||||
os.replace(tmp_name, path)
|
||||
with suppress(OSError):
|
||||
path.chmod(0o600)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
os.unlink(tmp_name)
|
||||
|
||||
|
||||
def provider_auth_record_name(provider: str) -> str:
|
||||
"""Return the stable metadata record name for one LLM provider."""
|
||||
normalized = provider.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("provider must not be empty")
|
||||
return f"{_AUTH_RECORD_PREFIX}{normalized}"
|
||||
|
||||
|
||||
def _normalize_record(values: Mapping[str, object]) -> dict[str, str]:
|
||||
return {
|
||||
str(key).strip(): str(value).strip()
|
||||
for key, value in values.items()
|
||||
if str(key).strip() and str(value).strip()
|
||||
}
|
||||
|
||||
|
||||
def save_provider_auth_record(
|
||||
*,
|
||||
provider: str,
|
||||
auth_name: str,
|
||||
kind: str,
|
||||
source: str,
|
||||
detail: str,
|
||||
verified: bool = True,
|
||||
stale: bool = False,
|
||||
env_var: str = "",
|
||||
) -> None:
|
||||
"""Persist non-token auth metadata for a provider."""
|
||||
provider_value = provider.strip().lower()
|
||||
path = _auth_metadata_path()
|
||||
_ensure_parent(path)
|
||||
with FileLock(str(_lock_path(path)), timeout=_LOCK_TIMEOUT_SECONDS):
|
||||
data = _load_unlocked(path)
|
||||
providers = data.setdefault("providers", {})
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
data["providers"] = providers
|
||||
providers[provider_value] = _normalize_record(
|
||||
{
|
||||
"provider": provider_value,
|
||||
"auth_name": auth_name,
|
||||
"kind": kind,
|
||||
"source": source,
|
||||
"detail": detail,
|
||||
"env_var": env_var,
|
||||
"verified": str(bool(verified)).lower(),
|
||||
"stale": str(bool(stale)).lower(),
|
||||
"updated_at": _utc_now(),
|
||||
}
|
||||
)
|
||||
_write_unlocked(path, data)
|
||||
|
||||
|
||||
def save_provider_auth_record_values(provider: str, values: Mapping[str, object]) -> None:
|
||||
"""Persist an already-shaped non-secret provider auth record."""
|
||||
provider_value = provider.strip().lower()
|
||||
path = _auth_metadata_path()
|
||||
_ensure_parent(path)
|
||||
with FileLock(str(_lock_path(path)), timeout=_LOCK_TIMEOUT_SECONDS):
|
||||
data = _load_unlocked(path)
|
||||
providers = data.setdefault("providers", {})
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
data["providers"] = providers
|
||||
record = _normalize_record({"provider": provider_value, **dict(values)})
|
||||
record.setdefault("updated_at", _utc_now())
|
||||
providers[provider_value] = record
|
||||
_write_unlocked(path, data)
|
||||
|
||||
|
||||
def resolve_provider_auth_record(provider: str) -> dict[str, str]:
|
||||
"""Resolve non-token auth metadata for a provider without reading secrets."""
|
||||
provider_value = provider.strip().lower()
|
||||
path = _auth_metadata_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
with FileLock(str(_lock_path(path)), timeout=_LOCK_TIMEOUT_SECONDS):
|
||||
data = _load_unlocked(path)
|
||||
providers = data.get("providers")
|
||||
if not isinstance(providers, dict):
|
||||
return {}
|
||||
record = providers.get(provider_value)
|
||||
if not isinstance(record, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): str(value)
|
||||
for key, value in record.items()
|
||||
if isinstance(key, str) and isinstance(value, str)
|
||||
}
|
||||
|
||||
|
||||
def delete_provider_auth_record(provider: str) -> None:
|
||||
"""Delete provider auth metadata."""
|
||||
provider_value = provider.strip().lower()
|
||||
path = _auth_metadata_path()
|
||||
if not path.exists():
|
||||
return
|
||||
_ensure_parent(path)
|
||||
with FileLock(str(_lock_path(path)), timeout=_LOCK_TIMEOUT_SECONDS):
|
||||
data = _load_unlocked(path)
|
||||
providers = data.get("providers")
|
||||
if isinstance(providers, dict):
|
||||
providers.pop(provider_value, None)
|
||||
_write_unlocked(path, data)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"delete_provider_auth_record",
|
||||
"provider_auth_record_name",
|
||||
"resolve_provider_auth_record",
|
||||
"save_provider_auth_record",
|
||||
"save_provider_auth_record_values",
|
||||
]
|
||||