diff --git a/.gitignore b/.gitignore index 0a378bf..afe2877 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ STRATEGY.md # Local hygiene denylist — the list itself is the leak .skill-denylist +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 7821f37..ab612a2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ MCP gives your agent access. Skills give it judgment. | [`go-agent-install`](skills/go-agent-install/SKILL.md) | Instrument a Go service with Last9 go-agent: detect the stack, wire chi + `database/sql` tracing, promote opt-in body capture, and verify spans land — without double-instrumenting | | [`last9-logs`](skills/last9-logs/SKILL.md) | Log investigation: scope to a service first, attribute filters over body search, aggregate before drilling into raw lines | | [`last9-traces`](skills/last9-traces/SKILL.md) | Trace investigation: a five-question interview that lands on the right tool call, plus a `tracejson` syntax reference card | +| [`last9-api`](skills/last9-api/SKILL.md) | Call the Last9 REST API from scripts and CI without MCP: a stdlib Python helper handles refresh-token exchange and the `X-LAST9-API-TOKEN` header; references cover logs, traces, change events, and Alertmanager migration | | [`last9-cloudwatch`](skills/last9-cloudwatch/SKILL.md) | CloudWatch investigation across Billing, RDS/Aurora, ElastiCache, MSK, DynamoDB, EC2, SQS, DMS, KMS, and S3; focused family references share discovery, statistic, and evidence rules | ## Installation diff --git a/plugins/opencode-last9/README.md b/plugins/opencode-last9/README.md index 65b35eb..b802678 100644 --- a/plugins/opencode-last9/README.md +++ b/plugins/opencode-last9/README.md @@ -36,7 +36,7 @@ export LAST9_ORG_SLUG="" ## What it registers - **`last9` MCP server** — a remote MCP endpoint at `https://app.last9.io/api/v4/organizations//mcp`. OpenCode handles OAuth automatically (dynamic client registration + browser flow). -- **Last9 skills** — the canonical skills from [`last9/ai-toolkit`](https://github.com/last9/ai-toolkit) (`last9-logs`, `last9-traces`, `last9-cloudwatch`, `go-agent-install`), loaded via `skills.paths`. +- **Last9 skills** — the canonical skills from [`last9/ai-toolkit`](https://github.com/last9/ai-toolkit) (`last9-logs`, `last9-traces`, `last9-api`, `last9-cloudwatch`, `go-agent-install`), loaded via `skills.paths`. ## Configuration diff --git a/scripts/check-skill-pack-selftest.sh b/scripts/check-skill-pack-selftest.sh index 8a5ed74..197173c 100755 --- a/scripts/check-skill-pack-selftest.sh +++ b/scripts/check-skill-pack-selftest.sh @@ -150,6 +150,63 @@ printf 'orphan\n' > "$FIX/skills/orphan/references/family.md" commit_fault expect_fail "reference without entrypoint" +setup_fixture script-happy +mkdir -p "$FIX/skills/last9-logs/scripts" +printf 'print(1)\n' > "$FIX/skills/last9-logs/scripts/helper.py" +printf 'print(2)\n' > "$FIX/skills/last9-logs/scripts/test_helper.py" +commit_fault +if ! run_full sh "$FIX/scripts/check-skill-pack.sh" >/dev/null 2>&1; then + echo "selftest FAILED: tracked flat Python script expected exit 0" >&2 + exit 1 +fi + +setup_fixture script-wrong-ext +mkdir -p "$FIX/skills/last9-logs/scripts" +printf 'x\n' > "$FIX/skills/last9-logs/scripts/helper.js" +commit_fault +expect_fail "script with wrong extension" + +setup_fixture script-nested +mkdir -p "$FIX/skills/last9-logs/scripts/sub" +printf 'x\n' > "$FIX/skills/last9-logs/scripts/sub/helper.py" +commit_fault +expect_fail "nested script" + +setup_fixture script-uppercase-or-dash +mkdir -p "$FIX/skills/last9-logs/scripts" +printf 'x\n' > "$FIX/skills/last9-logs/scripts/Helper-Run.py" +commit_fault +expect_fail "script with uppercase or dash" + +setup_fixture script-symlink +mkdir -p "$FIX/skills/last9-logs/scripts" +printf 'outside skill\n' > "$FIX/outside.py" +ln -s ../../../outside.py "$FIX/skills/last9-logs/scripts/helper.py" +commit_fault +expect_fail "tracked script symlink" + +setup_fixture script-parent-symlink +mkdir -p "$FIX/skills/last9-logs/scripts" +printf 'print(1)\n' > "$FIX/skills/last9-logs/scripts/helper.py" +commit_fault +mv "$FIX/skills/last9-logs/scripts" "$FIX/outside-scripts" +ln -s ../../outside-scripts "$FIX/skills/last9-logs/scripts" +expect_fail "working-tree script parent symlink" + +setup_fixture script-missing-from-tarball +mkdir -p "$FIX/skills/last9-logs/scripts" +printf 'print(1)\n' > "$FIX/skills/last9-logs/scripts/helper.py" +commit_fault +jq '.scripts.prepack += " && rm skills/last9-logs/scripts/helper.py"' "$FIX/plugins/opencode-last9/package.json" > "$FIX/package.tmp" +mv "$FIX/package.tmp" "$FIX/plugins/opencode-last9/package.json" +expect_fail "missing packaged script" + +setup_fixture script-without-entrypoint +mkdir -p "$FIX/skills/orphan/scripts" +printf 'print(1)\n' > "$FIX/skills/orphan/scripts/helper.py" +commit_fault +expect_fail "script without entrypoint" + # The gate must never delete or inspect another invocation's archive. setup_fixture archive-isolation ARCHIVE_TMP="$SANDBOX/archive-temp" diff --git a/scripts/check-skill-pack.sh b/scripts/check-skill-pack.sh index 91450a0..30043f1 100755 --- a/scripts/check-skill-pack.sh +++ b/scripts/check-skill-pack.sh @@ -20,10 +20,12 @@ if [ -n "$(git ls-files 'plugins/*/skills/*')" ]; then exit 1 fi -# Canonical payload is deliberately narrow: one entrypoint plus optional direct -# Markdown references. Reject links before prepack can follow them outside skills/. +# Canonical payload is deliberately narrow: one entrypoint, direct Markdown +# references, and optional flat scripts/*.py helpers (no nesting, no other +# extensions). Reject links before prepack can follow them outside skills/. +PAYLOAD_RE='^skills/[a-z0-9-]+/(SKILL\.md|references/[a-z0-9-]+\.md|scripts/[a-z0-9_]+\.py)$' for payload in $(git ls-files 'skills/**'); do - if ! printf '%s\n' "$payload" | grep -Eq '^skills/[a-z0-9-]+/(SKILL\.md|references/[a-z0-9-]+\.md)$'; then + if ! printf '%s\n' "$payload" | grep -Eq "$PAYLOAD_RE"; then echo "::error::unsupported canonical skill payload: $payload" >&2 exit 1 fi @@ -32,7 +34,7 @@ for payload in $(git ls-files 'skills/**'); do echo "::error::skill reference has no tracked entrypoint: $payload" >&2 exit 1 fi - if [ -L skills ] || [ -L "$skill_dir" ] || [ -L "$skill_dir/references" ] || [ -L "$payload" ] || [ ! -f "$payload" ]; then + if [ -L skills ] || [ -L "$skill_dir" ] || [ -L "$skill_dir/references" ] || [ -L "$skill_dir/scripts" ] || [ -L "$payload" ] || [ ! -f "$payload" ]; then echo "::error::skill payload must be a regular file without symlink parents: $payload" >&2 exit 1 fi @@ -135,7 +137,7 @@ for payload in $(git ls-files 'skills/**'); do done for packed in $(grep -E '^package/skills/' "$tgz.list" || true); do payload="${packed#package/}" - if ! printf '%s\n' "$payload" | grep -Eq '^skills/[a-z0-9-]+/(SKILL\.md|references/[a-z0-9-]+\.md)$' || ! git ls-files --error-unmatch -- "$payload" >/dev/null 2>&1; then + if ! printf '%s\n' "$payload" | grep -Eq "$PAYLOAD_RE" || ! git ls-files --error-unmatch -- "$payload" >/dev/null 2>&1; then echo "::error::opencode tarball ships unexpected or untracked skills payload: $packed" >&2 missing=1 fi diff --git a/skills/last9-api/SKILL.md b/skills/last9-api/SKILL.md new file mode 100644 index 0000000..e41efa9 --- /dev/null +++ b/skills/last9-api/SKILL.md @@ -0,0 +1,78 @@ +--- +name: last9-api +description: Call the Last9 REST API from the command line with a stdlib Python helper that handles refresh-token exchange, access-token caching, and the X-LAST9-API-TOKEN header. Use when calling the Last9 REST API from scripts, CI, or an agent without the MCP server — exchanging refresh tokens for access tokens, sending change events, querying logs or traces over HTTP, migrating Alertmanager rules ("last9 api", "access token", "refresh token", "change events", "X-LAST9-API-TOKEN"). +compatibility: Requires python3; network access to app.last9.io +metadata: + author: last9 +--- + +# Last9 REST API + +`/scripts/last9.py` is a single-file, stdlib-only Python 3.8+ CLI. It stores the refresh token, exchanges it for short-lived access tokens (docs say 24h; 72h observed, see references/auth.md), caches them, sets the auth header, and retries once if a token has expired. + +## REST vs MCP + +- If Last9 MCP tools are connected, prefer them for interactive investigation. See the `last9-logs` and `last9-traces` skills. +- Use this skill for automation, CI, change events, Alertmanager migration, or any session without the MCP server. + +## Setup + +The user runs login themselves. Never ask the user to paste a token into chat. + +1. Check first: `python3 /scripts/last9.py status`. Exit 0 means already logged in. +2. If not logged in, tell the user to run this in their own session (the `!` prefix runs it there): + + ```text + ! python3 /scripts/last9.py login --region + ``` + + It opens the API Access page, prompts for the refresh token with hidden input, validates it by exchanging it, and saves it to `~/.last9/credentials` (mode 0600). Only Admins can create refresh tokens; Editors must ask an Admin. `--region` is saved with the profile and added automatically to logs/traces calls. Add `--no-browser` to skip opening the browser. +3. In CI, set `LAST9_REFRESH_TOKEN` from a secret store instead. It wins over saved profiles and is never written to disk. +4. Run `status` again to confirm org, host, scopes, and expiry. It prints no secrets. + +**Finding your region:** use the region the org's data lives in (for example `ap-south-1`). If unsure, check the Last9 UI or org settings, or ask the user. Never guess in a loop. `LAST9_REGION` overrides the saved region. + +Config lives in `~/.last9` (override with `LAST9_CONFIG_DIR`). `logout` removes a profile. + +## Hard rules + +- Always call the API through `last9.py api`. It sets `X-LAST9-API-TOKEN: Bearer ` and refreshes expired tokens. Do not hand-build the header. +- Never print, echo, or log a token or the refresh token into the transcript. Use `last9.py token` only when piping into another tool (`$(...)`). +- Pick the profile by scope and org: `--profile ` (or `LAST9_PROFILE`). Keep a read-only profile for queries and a separate write profile for change events or migrations. One profile per org. +- `api` sends the token only over `https://` and only to the token's own host. It refuses plaintext `http://` URLs and any other host, and does not follow redirects. +- When comparing query results, pin absolute `start`/`end` values. Two "last N minutes" queries issued seconds apart already diverge. + +## Using `api` + +```bash +python3 /scripts/last9.py api METHOD PATH [-d DATA] [-q k=v ...] [-H 'K: V' ...] [-i] +``` + +- `PATH` without a leading `/api/` is relative to `https:///api/v4/organizations//`. A path starting `/api/` is used on the token's host (for example `/api/v4/oauth/...`). +- `-q k=v` is repeatable and URL-encoded. `-d` takes a literal string, `@file`, or `-` for stdin and sets `Content-Type: application/json`. +- 2xx: body to stdout, exit 0. Otherwise: body to stdout, `HTTP ` to stderr, exit 1. `-i` prints the status on success too. + +## Task to reference + +| Task | Reference | +| ---- | --------- | +| Roles, scopes, token expiry, revocation, header rules, raw curl | [references/auth.md](references/auth.md) | +| Query, filter, and discover labels in logs | [references/logs.md](references/logs.md) | +| Query traces, search by duration, fetch a trace, tags | [references/traces.md](references/traces.md) | +| Send deploy/config change events from CI or scripts | [references/change-events.md](references/change-events.md) | +| Convert Prometheus Alertmanager rules to Last9 config | [references/alertmanager-migration.md](references/alertmanager-migration.md) | + +## Errors + +| Symptom | Meaning | Fix | +| ------- | ------- | --- | +| `400 {"error":"invalid access token"}` | Header value missing the `Bearer ` prefix | Use `last9.py api`; never send the raw token | +| `401` on an API call | Wrong header name (`Authorization`), or invalid or expired token | `api` retries once with a fresh token. If it still fails, run `status` and re-login | +| `{"error":"Authorization token is expired"}` | Access token past its `expires_at` | Handled automatically by `api` (forced refresh and one retry) | +| "refresh token invalid, expired, or revoked" | Exchange returned 400, 401, or 403 | The user generates a new refresh token at https://app.last9.io/settings/api-access and re-runs login | +| "not logged in" | No `LAST9_REFRESH_TOKEN` and no saved profile | Have the user run login (see Setup) | +| `400 region query parameter is required` | Logs/traces call without a region | Pass `-q region=`, set `LAST9_REGION`, or `login --region ` | +| `500 ERR_S3_CONFIG_MISSING` or `502` "Maintenance Mode" HTML on logs/traces | Wrong region for this org | Use the org's real region; do not retry other regions blindly | +| `400 invalid refresh token: ...` from an API call | Misleading wording; the access token is bad | Delete `~/.last9/cache/.json` or re-login | +| `403` | Token scope too low for the operation | Use a profile whose refresh token has the needed scope (read, write, delete) | +| "refusing to send token to foreign host" or "over plaintext HTTP" | Full URL on another host, or using `http://` | Use a relative path, or an `https://` URL on the token's host | diff --git a/skills/last9-api/references/alertmanager-migration.md b/skills/last9-api/references/alertmanager-migration.md new file mode 100644 index 0000000..e4d0f47 --- /dev/null +++ b/skills/last9-api/references/alertmanager-migration.md @@ -0,0 +1,47 @@ +# Alertmanager migration + +Source: Last9 "Prometheus alertmanager Migrations" documentation. + +Converts a Prometheus Alertmanager-compatible alert rules YAML into Last9 alert configuration. + +```text +POST /entities/migrate/alertmanager (org base) +``` + +- Request body: the Alertmanager YAML, sent as-is. +- Response: YAML compatible with Last9. + +## Recipe + +```bash +python3 /scripts/last9.py api POST entities/migrate/alertmanager \ + -d @sample.yaml -H 'Content-Type: application/yaml' > last9-alerts.yaml +``` + +`-d` sets `Content-Type: application/json` by default; the doc's curl uses `--data-binary` with no content type, so override it with `-H` if the server rejects the default. This is unverified. + +## Response shape (trimmed) + +```yaml +entities: + - name: payment service + type: alert-manager + external_ref: payment service-alert-manager-alert-manager + entity_class: alert-manager + indicators: + - name: "EXPR: HighRequestLatency - breach" + query: job:request_latency_seconds:mean5m{service="payment"} > 0.5 + alert_rules: + - name: High request latency + indicator: "EXPR: HighRequestLatency - breach" + total_minutes: 10 + bad_minutes: 10 + greater_than: 0 +``` + +Each Alertmanager rule yields an indicator plus an alert rule keyed to it. + +## Notes + +- The doc writes the path as `/v4/organizations/{org_slug}/entities/migrate/alertmanager` on `app.last9.io` (no `/api` prefix, unlike every other endpoint). The recipe uses the org-relative form like the other references; this is unverified. +- Review the converted YAML before applying it; the doc does not describe how unsupported rule features are handled. diff --git a/skills/last9-api/references/auth.md b/skills/last9-api/references/auth.md new file mode 100644 index 0000000..69501c0 --- /dev/null +++ b/skills/last9-api/references/auth.md @@ -0,0 +1,99 @@ +# Authentication + +Source: Last9 "Getting started with API" documentation. + +## Roles + +- Admins can generate and revoke refresh tokens, and exchange them for access tokens. +- Editors can exchange existing refresh tokens for access tokens but cannot generate refresh tokens. They must ask an Admin. +- Viewers cannot access the API Access page. + +Tokens are managed at https://app.last9.io/settings/api-access. The Refresh Token tab is Admin-only. Refresh tokens are shown only once at creation. + +## Token model + +- A refresh token is created by an Admin with a name and a scope: read, write, or delete. +- Exchange it for a short-lived access token. Access tokens expire after 24 hours. +- Scopes: read tokens only read state; write tokens create or modify data; delete tokens can remove data irrevocably, so use them sparingly. +- Revoking a refresh token invalidates it immediately, and access tokens generated from it are rejected. +- Token creation and revocation appear in Settings > Audit Trail. + +## Observed behavior (verified live) + +- Access token lifetime was observed at 72h although the docs say 24h. The CLI uses `expires_at`, so never hardcode a lifetime. +- Refresh tokens are not rotated on exchange: the response returns the same refresh token. +- A malformed or garbage access token returns HTTP 400 with `invalid refresh token: ...`. The wording is misleading; it means the access token is bad. + +## Base URL + +```text +https://{domain}/api/{version}/organizations/{org}/{endpoint} +``` + +`last9.py` derives the host and `{org}` from the refresh token's claims, so you rarely type either. + +## Exchange endpoint + +```text +POST https://app.last9.io/api/v4/oauth/access_token +``` + +The OAuth endpoint does not include the organization in the URL. Body: + +```json +{ "refresh_token": "" } +``` + +Response (trimmed): + +```json +{ + "access_token": "", + "expires_at": 1587412870, + "issued_at": 1587240070, + "refresh_token": "", + "type": "Bearer", + "scopes": ["read", "write", "delete"] +} +``` + +`last9.py` does this for you and caches the result. It keeps your original refresh token and does not store the one returned. + +## Header rules + +The token goes in `X-LAST9-API-TOKEN`, prefixed with `Bearer ` (with a trailing space). + +| Example | Result | +| ------- | ------ | +| `X-LAST9-API-TOKEN: Bearer ` | correct | +| `X-LAST9-API-TOKEN: ` | 400 `{"error":"invalid access token"}` (missing Bearer prefix) | +| `Authorization: Bearer ` | 401 (wrong header) | + +An expired access token returns `{"error": "Authorization token is expired"}`. + +## Recipes + +Check identity (no secrets printed): + +```bash +python3 /scripts/last9.py status +``` + +Second org or a write-scoped token under its own profile: + +```bash +! python3 /scripts/last9.py --profile writer login +python3 /scripts/last9.py --profile writer status +``` + +CI: export `LAST9_REFRESH_TOKEN` from a secret, then call `api` as usual. + +Raw curl, for environments without Python only (placeholders, not real values): + +```bash +ACCESS=$(curl -s -X POST https://app.last9.io/api/v4/oauth/access_token \ + -H 'Content-Type: application/json' \ + -d '{"refresh_token":""}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +curl -H "X-LAST9-API-TOKEN: Bearer $ACCESS" \ + 'https://app.last9.io/api/v4/organizations//logs/api/v1/labels?start=&end=' +``` diff --git a/skills/last9-api/references/change-events.md b/skills/last9-api/references/change-events.md new file mode 100644 index 0000000..220a4f1 --- /dev/null +++ b/skills/last9-api/references/change-events.md @@ -0,0 +1,55 @@ +# Change events + +Source: Last9 "Change Events" and GitHub Actions integration documentation. Needs a **write**-scope token (use a dedicated profile). + +## Send an event + +```text +PUT /change_events (org base) +``` + +| Field | Required | Description | +| ----- | -------- | ----------- | +| `event_name` | yes | Event identifier; becomes a label on the metric | +| `timestamp` | no | ISO8601; defaults to now | +| `event_state` | no | `start` or `stop`; defaults to `start` | +| `attributes` | no | Key-value pairs; become labels | +| `value` | no | Sample value; defaults to `1` for start, `2` for stop | +| `data_source_name` | no | Cluster to store events in; defaults to the cluster designated for change events | + +Success: HTTP 200, `{"message":"success"}`. + +Last9 converts each event into the metric `last9_change_events`. Only `attributes` become labels; the API adds `event_name` and `event_state` itself. + +## Attributes that matter + +- Set `service_name` to your APM service name **exactly** (case included) or no chart markers appear. Events are still stored and queryable. `service` is accepted as an alias. +- Set the environment with `deployment_environment` or `env` (first non-empty, in that order). Discover scopes markers by it. +- Store events in the same cluster as the metrics you want to correlate with. +- `event_name` also colours the marker by substring (case-insensitive, first match wins): feature flag (`flag`, `launchdarkly`, `toggle`, `experiment`), deploy (`deploy`, `release`, `rollout`, `rollback`, `build`, `version`), infrastructure (`scal`, `restart`, `reboot`, `config`, `terraform`, `migration`, `maintenance`, `infra`, `node`, `cluster`), otherwise neutral. +- Naming examples: `deployment_start` / `deployment_complete`, `config_update_redis`, `feature_flag_toggle`, `db_migration_start` / `db_migration_complete`. + +## Recipes + +Deploy start and stop from CI: + +```bash +L9="python3 /scripts/last9.py --profile writer api" +$L9 PUT change_events -d "{\"event_name\":\"deployment\",\"event_state\":\"start\",\"attributes\":{\"service_name\":\"$SERVICE_NAME\",\"deployment_environment\":\"$DEPLOY_ENV\",\"version\":\"$GIT_SHA\",\"team\":\"$TEAM\"}}" +# ... deploy ... +$L9 PUT change_events -d "{\"event_name\":\"deployment\",\"event_state\":\"stop\",\"attributes\":{\"service_name\":\"$SERVICE_NAME\",\"deployment_environment\":\"$DEPLOY_ENV\",\"version\":\"$GIT_SHA\"}}" +``` + +For a payload file: `... api PUT change_events -d @event.json`. + +Verify in PromQL (Metrics Explorer): `last9_change_events{event_name="deployment", deployment_environment="production"}`. + +GitHub Actions: the documented Last9 Deployment Marker action sends events without custom steps. Its inputs include `env` (required; must match APM `deployment_environment`), `service_name` (defaults to repo name), `event_state` (`start`, `stop`, or `both`; default `stop`), `event_name` (default `deployment`), `custom_attributes` (JSON string). + +## Gotchas + +- Each call writes exactly one sample; there is no periodic refresh. For "stuck in a state" alerts, push the `start` event once in real time (re-pushing resets elapsed time) and size the PromQL lookback larger than the longest stuck time you want to catch (about 3-4x the threshold). +- Backdated timestamps past the database's backfill limit are rewritten to the ingestion time. Send events in real time. +- If a `stop` never arrives, such an alert keeps firing until one is pushed. +- Markers on dashboards need a **Label** variable targeting `service` or `service_name`; toggle shows 0 otherwise. +- Any valid ISO8601 timestamp is accepted (no 18-24h cap). diff --git a/skills/last9-api/references/logs.md b/skills/last9-api/references/logs.md new file mode 100644 index 0000000..7ec67e3 --- /dev/null +++ b/skills/last9-api/references/logs.md @@ -0,0 +1,89 @@ +# Logs query API + +Source: Last9 "Logs Query API" documentation. This API queries logs; it does not ingest them. + +Base: `/logs/...` relative to the org base (use with `last9.py api`). Timestamps are Unix **nanoseconds**. + +## Query logs + +```text +POST /logs/api/v2/query_range/json +``` + +| Param | Type | Required | Description | +| ----- | ---- | -------- | ----------- | +| `start` | int | yes | Start, Unix ns | +| `end` | int | yes | End, Unix ns | +| `limit` | int | no | Max logs | +| `direction` | string | no | `forward` or `backward` | +| `step` | string | no | Time step for aggregations | +| `offset` | int | no | Pagination offset | +| `region` | string | **yes** | Cloud region the org's data lives in (for example `ap-south-1`). The public docs say optional; the API returns 400 `region query parameter is required` without it. | +| `index` | string | no | `physical_index:` or `rehydration_index:` | + +Body is a JSON pipeline: + +```json +{ "pipeline": [ { "type": "filter", "query": { "$and": [ { "$eq": ["service", "api-gateway"] } ] } } ] } +``` + +Response (trimmed): + +```json +{ "status": "success", + "data": { "resultType": "streams", + "result": [ { "stream": { "service": "api-gateway", "level": "error" }, + "values": [ ["1743505000000000000", "Connection timeout after 30s"] ] } ], + "stats": { "summary": { "totalLinesProcessed": 1000, "execTime": 0.25 } } } } +``` + +No matches: `"result": null`. + +## Discover labels and values + +```text +GET /logs/api/v1/labels params: start, end (required, ns) +GET /logs/api/v1/label/{labelName}/values params: start, end (required, ns) +``` + +Both also require `region`, and accept seconds as well as ns (verified). Responses: `{"status":"success","data":["service","level",...]}`. + +## Pipeline syntax + +Stages (`type`): `filter`, `where` (adds conditions, OR/NOT logic), `parse`. + +```json +{ "type": "parse", "parser": "json", "field": "body", "labels": { "user_id": null, "request_id": null } } +``` + +Operators (each takes a 2-element array `["field", "value"]`): `$eq`, `$neq`, `$contains`, `$notcontains`, `$regex`, `$notregex`, `$gt`, `$lt`, `$gte`, `$lte`. Numeric comparisons take string values (`["status_code", "400"]`). Combine with `$and`, `$or`, `$not` (`{"$not": [{"$and": [...]}]}`); logical operators nest. + +## Recipes + +`last9.py api` adds `region=` to every `logs/` path automatically, from `LAST9_REGION` or the profile region saved by `login --region `; it exits 1 without calling the API if neither is set. The recipes below rely on that; with raw curl add `region=` yourself. A wrong region returns 500 `{"error":"ERR_S3_CONFIG_MISSING"}` or a 502 "Maintenance Mode" HTML page. + +Time helper (ns): `$(( $(date +%s) * 1000000000 ))`. + +```bash +S=$(( ($(date +%s) - 3600) * 1000000000 )); E=$(( $(date +%s) * 1000000000 )) +python3 /scripts/last9.py api POST logs/api/v2/query_range/json \ + -q start=$S -q end=$E -q limit=100 -q direction=backward \ + -d '{"pipeline":[{"type":"filter","query":{"$and":[{"$eq":["service","payment-service"]},{"$eq":["level","error"]}]}}]}' +``` + +Discover labels, then values for one label: + +```bash +python3 /scripts/last9.py api GET logs/api/v1/labels -q start=$S -q end=$E +python3 /scripts/last9.py api GET logs/api/v1/label/service/values -q start=$S -q end=$E +``` + +Text search plus OR with a `where` stage: put a `filter` stage for the service, then `{"type":"where","query":{"$or":[{"$eq":["level","error"]},{"$eq":["level","fatal"]}]}}`. + +## Gotchas + +- Timestamps are nanoseconds (traces use seconds). +- Field names are case-sensitive; discover them via the labels endpoint. +- Empty result: check field names, time range, data retention, pipeline JSON. +- Pipeline errors: each operator needs exactly 2 array elements; stage `type` must be `filter`, `where`, or `parse`. +- Errors look like `{"error":{"code":"...","message":"..."}}`. 400 bad query, 401 expired token, 403 insufficient permissions. diff --git a/skills/last9-api/references/traces.md b/skills/last9-api/references/traces.md new file mode 100644 index 0000000..0cd8910 --- /dev/null +++ b/skills/last9-api/references/traces.md @@ -0,0 +1,82 @@ +# Traces query API + +Source: Last9 "Traces Query API" documentation. This API queries traces; it does not ingest them. + +Paths are relative to the org base. Timestamps are Unix **seconds**. **`region` is required** on the query endpoints (for example `ap-south-1`); omitting it returns 400 `region query parameter is required`. `last9.py api` adds it automatically to `cat/` paths from `LAST9_REGION` or the profile region (`login --region `); an explicit `-q region=` wins. A wrong region gives 500 `ERR_S3_CONFIG_MISSING` or a 502 "Maintenance Mode" page. + +## Endpoints + +| Method | Path | Purpose | +| ------ | ---- | ------- | +| POST | `/cat/api/traces/v2/query_range/json` | Query spans or traces with a pipeline | +| POST | `/cat/api/traces/v2/search/json` | Search traces with span sets; duration filters | +| GET | `/cat/api/traces/{traceID}` | All spans of one trace (32-char hex ID) | +| POST | `/cat/api/traces/v2/heatmap/json` | Duration heatmap | +| POST | `/cat/api/traces/v2/series/json` | Available tags | +| POST | `/cat/api/traces/v2/label/json/{tagName}/values` | Values for a tag | +| GET/POST/PUT/DELETE | `/traces/searches`, `/traces/searches/{id}` | Saved searches | +| GET | `/traces/recent-searches` | Recent searches (retained 7 days) | + +## Query params + +Common: `region` (required), `start` (required, s), `end` (required, s). + +- query_range: `limit`, `order` (`asc`/`desc`), `direction`, `mode` (`span` or `trace`). +- search: `limit`, `span_limit` (spans per trace), `order`, `mode`, `minDuration` (for example `100ms`, `1s`), `maxDuration`. +- get trace: `limit` (default 1000 spans). +- heatmap: also `time_resolution_sec` (required, x-axis) and `buckets` (required, y-axis). +- saved/recent searches: optional `query_source` = `manual` or `nlp`. + +Body for query/search/heatmap: `{"pipeline":[ ... ]}` (use `{"pipeline": []}` for none). Series and tag-values endpoints are POST with body `{}`. + +Saved search create body: `name` (required), `pipeline` (required), `mode` (required, `span`/`trace`), `shared` (optional bool, default false). + +## Pipeline + +```json +{ "type": "filter", "query": { "$and": [ { "$eq": ["service.name", "api-gateway"] }, { "$gte": ["http.status_code", "500"] } ] } } +``` + +Operators (2-element arrays): `$eq`, `$ieq`, `$neq`, `$ineq`, `$gt`, `$gte`, `$lt`, `$lte`, `$contains`, `$icontains`, `$notcontains`, `$regex`, `$iregex`, `$notregex`. Logical: `$and`, `$or`, `$not`, nestable. `i` variants are case-insensitive; the rest are case-sensitive. + +Field prefixes: `resource.` (resource attributes), `span.` (span attributes), none (auto-resolved, for example `service.name`). + +Modes: `span` queries individual spans; `trace` queries complete traces. + +## Response shapes (trimmed) + +query_range: `{"status":"success","data":{"resultType":"stream","result":[{"Timestamp","TraceId","SpanId","ParentSpanId","SpanName","SpanKind","ServiceName","ResourceAttributes":{},"SpanAttributes":{},"Duration":25748892,"StatusCode","StatusMessage"}]}}`. Empty: `"result": []`. + +search: result items have `traceID`, `rootServiceName`, `rootTraceName`, `startTimeUnixNano`, `endTimeUnixNano`, `durationMs`, `spanSets`. + +get trace: `{"traces":[ ...span objects... ]}`. Tags: `{"status":"success","data":[{"service.name":"...","http.method":"..."}]}`. + +Span kinds: `SPAN_KIND_UNSPECIFIED|INTERNAL|SERVER|CLIENT|PRODUCER|CONSUMER`. Status: `STATUS_CODE_UNSET|OK|ERROR`. + +## Recipes + +```bash +S=$(( $(date +%s) - 3600 )); E=$(date +%s) +# 5xx traces for a service +python3 /scripts/last9.py api POST cat/api/traces/v2/query_range/json \ + -q region= -q start=$S -q end=$E -q limit=100 -q mode=trace \ + -d '{"pipeline":[{"type":"filter","query":{"$and":[{"$eq":["service.name","api-gateway"]},{"$gte":["http.status_code","500"]}]}}]}' + +# Slow traces (1s to 10s) +python3 /scripts/last9.py api POST cat/api/traces/v2/search/json \ + -q region= -q start=$S -q end=$E -q limit=50 -q minDuration=1s -q maxDuration=10s -d '{"pipeline":[]}' + +# One trace +python3 /scripts/last9.py api GET cat/api/traces/ -q region= -q start=$S -q end=$E + +# Discover tag values +python3 /scripts/last9.py api POST cat/api/traces/v2/label/json/service.name/values \ + -q region= -q start=$S -q end=$E -d '{}' +``` + +## Gotchas + +- Seconds here, nanoseconds in the logs API. +- Empty results: check field names (use the tags endpoint), time range, retention, pipeline JSON, and `mode`. +- Documented errors: 400 missing `region`; 401 `Authorization token is expired`; 403 forbidden; 404 not found; 500 `ERR_S3_CONFIG_MISSING` (OTLP configuration not found for org). +- Check the `{org}` slug matches your token's org if you get authorization errors. diff --git a/skills/last9-api/scripts/last9.py b/skills/last9-api/scripts/last9.py new file mode 100755 index 0000000..6567262 --- /dev/null +++ b/skills/last9-api/scripts/last9.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Last9 REST API helper: login once, then `api` calls with auto-refreshed tokens. + +Stdlib only. Never prints tokens (except `token`, which is meant for $(...)). +""" +import argparse +import base64 +import getpass +import hashlib +import json +import os +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from datetime import datetime, timezone + +API_ACCESS_URL = "https://app.last9.io/settings/api-access" +REFRESH_MARGIN = 3600 # re-exchange when the cached access token has <= 1h left +TIMEOUT = 60 + + +def die(msg): + sys.stderr.write(msg + "\n") + sys.exit(1) + + +def config_dir(): + return os.environ.get("LAST9_CONFIG_DIR") or os.path.expanduser("~/.last9") + + +def write_private(path, obj): + """Atomic write, file 0600, parent dir 0700.""" + d = os.path.dirname(path) + os.makedirs(d, mode=0o700, exist_ok=True) + os.chmod(d, 0o700) + fd, tmp = tempfile.mkstemp(dir=d, prefix=".tmp-") # created 0600 + with os.fdopen(fd, "w") as f: + json.dump(obj, f) + os.replace(tmp, path) + + +def read_json(path, default): + try: + with open(path) as f: + return json.load(f) + except (OSError, ValueError): + return default + + +def creds_path(): + return os.path.join(config_dir(), "credentials") + + +def load_creds(): + return read_json(creds_path(), {"profiles": {}}) + + +def decode_claims(token): + try: + seg = token.split(".")[1] + return json.loads(base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))) + except Exception: + die("refresh token is not a valid JWT") + + +def host_org(claims): + aud = claims.get("aud") + aud = aud[0] if isinstance(aud, list) and aud else aud + org = claims.get("organization_slug") + if not aud or not org: + die("refresh token is missing the 'aud' or 'organization_slug' claim") + host = aud.split("://", 1)[-1].split("/", 1)[0] + return host, org + + +class Ctx: + """Resolved identity for one profile (or the env token).""" + + def __init__(self, profile, refresh=None): + env = os.environ.get("LAST9_REFRESH_TOKEN", "").strip() + if refresh: # explicit token from `login` + self.refresh, self.source, self.cache_key = refresh, "file", profile + elif env: + self.refresh, self.source = env, "env" + self.cache_key = "env-" + hashlib.sha256(env.encode()).hexdigest()[:12] + else: + self.refresh = load_creds().get("profiles", {}).get(profile, {}).get("refresh_token") + self.source, self.cache_key = "file", profile + if not self.refresh: + die("not logged in — run: python3 %s login" % os.path.abspath(__file__)) + self.profile = profile + saved = load_creds().get("profiles", {}).get(profile, {}).get("region") + self.region = os.environ.get("LAST9_REGION", "").strip() or saved + self.claims = decode_claims(self.refresh) + self.host, self.org = host_org(self.claims) + + @property + def cache_file(self): + return os.path.join(config_dir(), "cache", self.cache_key + ".json") + + +def exchange(host, refresh): + req = urllib.request.Request( + "https://%s/api/v4/oauth/access_token" % host, + data=json.dumps({"refresh_token": refresh}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + if e.code in (400, 401, 403): + die("refresh token invalid, expired, or revoked — generate a new one at " + API_ACCESS_URL) + die("token exchange failed: HTTP %d" % e.code) + except urllib.error.URLError as e: + die("token exchange failed: %s" % e.reason) + + +def save_cache(ctx, resp): + entry = {"access_token": resp["access_token"], "expires_at": resp["expires_at"]} + try: + write_private(ctx.cache_file, entry) + except OSError as e: # read-only home / sandbox: still usable, just re-exchanges per call + sys.stderr.write("warning: could not cache access token (%s)\n" % (e.strerror or e)) + return entry + + +def access_token(ctx, force=False): + if not force: + c = read_json(ctx.cache_file, {}) + if c.get("access_token") and c.get("expires_at", 0) - time.time() > REFRESH_MARGIN: + return c["access_token"] + return save_cache(ctx, exchange(ctx.host, ctx.refresh))["access_token"] + + +def iso(ts): + return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def info_lines(ctx): + c = ctx.claims + return [ + "profile: %s" % ctx.profile, + "region: %s" % (ctx.region or "not set"), + "org: %s" % ctx.org, + "host: %s" % ctx.host, + "scopes: %s" % ", ".join(c.get("scopes") or []), + "refresh token expires: %s" % (iso(c["exp"]) if "exp" in c else "unknown"), + ] + + +# ---- subcommands ---- + +def cmd_login(a): + if a.with_token: + token = sys.stdin.read().strip() + else: + print("Create a refresh token at %s (Admins create refresh tokens; " + "Editors must ask an Admin)." % API_ACCESS_URL) + if not a.no_browser: + webbrowser.open(API_ACCESS_URL) + token = getpass.getpass("Paste refresh token (input hidden): ").strip() + if not token: + die("no token provided") + claims = decode_claims(token) + host, _ = host_org(claims) + resp = exchange(host, token) # validate before saving anything + creds = load_creds() + old = creds.get("profiles", {}).get(a.profile, {}) + entry = {"refresh_token": token} + if a.region or old.get("region"): # keep the saved region unless a new one is given + entry["region"] = a.region or old["region"] + creds.setdefault("profiles", {})[a.profile] = entry + write_private(creds_path(), creds) + ctx = Ctx(a.profile, refresh=token) + save_cache(ctx, resp) + print("\n".join(["logged in"] + info_lines(ctx))) + + +def cmd_token(a): + print(access_token(Ctx(a.profile))) + + +def cmd_status(a): + ctx = Ctx(a.profile) + cached = read_json(ctx.cache_file, {}) + exp = cached.get("expires_at") + lines = info_lines(ctx) + lines.insert(1, "source: %s" % ctx.source) + lines.append("email: %s" % ctx.claims.get("email", "unknown")) + lines.append("access token expires: %s" % (iso(exp) if exp else "none")) + print("\n".join(lines)) + + +def cmd_logout(a): + creds = load_creds() + removed = creds.get("profiles", {}).pop(a.profile, None) + if removed: + write_private(creds_path(), creds) + try: + os.remove(os.path.join(config_dir(), "cache", a.profile + ".json")) + except OSError: + pass + print("logged out profile %r" % a.profile if removed else "profile %r not found" % a.profile) + if os.environ.get("LAST9_REFRESH_TOKEN"): + print("note: LAST9_REFRESH_TOKEN is set and still in effect") + + +def resolve_url(ctx, path, queries): + if path.startswith(("http://", "https://")): + parts = urllib.parse.urlsplit(path) + if parts.scheme != "https": + die("refusing to send token over plaintext HTTP — use https://") + if parts.hostname != ctx.host: + die("refusing to send token to foreign host (expected %s)" % ctx.host) + url = path + elif path.startswith("/api/"): + url = "https://%s%s" % (ctx.host, path) + else: + url = "https://%s/api/v4/organizations/%s/%s" % (ctx.host, ctx.org, path.lstrip("/")) + if queries: + qs = urllib.parse.urlencode([(k, v) for k, _, v in (q.partition("=") for q in queries)]) + url += ("&" if "?" in url else "?") + qs + # logs/ and cat/ endpoints 400 without region (live-verified) + sp = urllib.parse.urlsplit(url) + rel = sp.path[len("/api/v4/organizations/%s/" % ctx.org):] \ + if sp.path.startswith("/api/v4/organizations/%s/" % ctx.org) else "" + if rel.startswith(("logs/", "cat/")) and "region" not in urllib.parse.parse_qs(sp.query): + if not ctx.region: + die("region required for logs/traces endpoints \u2014 pass -q region=, " + "set LAST9_REGION, or run: login --region ") + url += ("&" if sp.query else "?") + urllib.parse.urlencode({"region": ctx.region}) + return url + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + # Don't follow redirects: urllib would forward the token header to the new host. + def redirect_request(self, *args, **kwargs): + return None + + +def send(method, url, token, data, headers): + h = {"X-LAST9-API-TOKEN": "Bearer " + token} + if data is not None: + h["Content-Type"] = "application/json" + h.update(headers) + req = urllib.request.Request(url, data=data, headers=h, method=method) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + except urllib.error.URLError as e: + die("request failed: %s" % e.reason) + + +def cmd_api(a): + ctx = Ctx(a.profile) + url = resolve_url(ctx, a.path, a.q) + data = None + if a.d is not None: + raw = sys.stdin.buffer.read() if a.d == "-" else None + if a.d.startswith("@"): + with open(a.d[1:], "rb") as f: + raw = f.read() + data = raw if raw is not None else a.d.encode() + headers = dict(h.split(":", 1) for h in a.H) + headers = {k.strip(): v.strip() for k, v in headers.items()} + urllib.request.install_opener(urllib.request.build_opener(NoRedirect)) + method = a.method.upper() + code, body = send(method, url, access_token(ctx), data, headers) + if code == 401 or (400 <= code < 500 and b"expired" in body.lower()): + code, body = send(method, url, access_token(ctx, force=True), data, headers) # retry once + sys.stdout.flush() + sys.stdout.buffer.write(body) + ok = 200 <= code < 300 + if a.i or not ok: + sys.stderr.write("HTTP %d\n" % code) + sys.exit(0 if ok else 1) + + +def main(argv=None): + p = argparse.ArgumentParser(description="Last9 REST API helper") + p.add_argument("--profile", default=os.environ.get("LAST9_PROFILE") or "default") + sub = p.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("login", help="save a refresh token (validated by exchange)") + s.add_argument("--with-token", action="store_true", help="read token from stdin") + s.add_argument("--no-browser", action="store_true") + s.add_argument("--region", help="default region for logs/traces endpoints (e.g. ap-south-1)") + s.set_defaults(fn=cmd_login) + sub.add_parser("token", help="print a valid access token").set_defaults(fn=cmd_token) + sub.add_parser("status", help="show identity, no secrets").set_defaults(fn=cmd_status) + sub.add_parser("logout", help="forget the profile").set_defaults(fn=cmd_logout) + s = sub.add_parser("api", help="authenticated request") + s.add_argument("method") + s.add_argument("path", help="org-relative path, /api/... path, or full URL on your host") + s.add_argument("-d", metavar="DATA", help="body: literal, @file, or - for stdin") + s.add_argument("-q", action="append", default=[], metavar="K=V", help="query param (repeatable)") + s.add_argument("-H", action="append", default=[], metavar="'K: V'", help="extra header (repeatable)") + s.add_argument("-i", action="store_true", help="always print HTTP status to stderr") + s.set_defaults(fn=cmd_api) + a = p.parse_args(argv) + if "/" in a.profile or a.profile.startswith("."): + die("invalid profile name") + a.fn(a) + + +if __name__ == "__main__": + main() diff --git a/skills/last9-api/scripts/test_last9.py b/skills/last9-api/scripts/test_last9.py new file mode 100644 index 0000000..89c4a43 --- /dev/null +++ b/skills/last9-api/scripts/test_last9.py @@ -0,0 +1,289 @@ +import base64 +import importlib.util +import io +import json +import os +import stat +import sys +import tempfile +import time +import unittest +import urllib.error +from unittest import mock + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location("last9", os.path.join(HERE, "last9.py")) +last9 = importlib.util.module_from_spec(spec) +spec.loader.exec_module(last9) + + +def jwt(**claims): + b = lambda d: base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode() + base = {"aud": ["app.last9.io"], "organization_slug": "acme", "scopes": ["read"], + "exp": 2000000000, "email": "a@b.c"} + base.update(claims) + return "%s.%s.sig" % (b({"alg": "none"}), b(base)) + + +class FakeResp(io.BytesIO): + def __init__(self, body, status=200): + super().__init__(body if isinstance(body, bytes) else body.encode()) + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + +def http_error(code, body=b""): + return urllib.error.HTTPError("u", code, "x", {}, io.BytesIO(body)) + + +def token_resp(tok="ACCESS", ttl=86400): + return FakeResp(json.dumps({"access_token": tok, "expires_at": int(time.time()) + ttl})) + + +class Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.cfg = os.path.join(self.tmp, "cfg") + patcher = mock.patch.dict(os.environ, {"LAST9_CONFIG_DIR": self.cfg}, clear=False) + patcher.start() + self.addCleanup(patcher.stop) + os.environ.pop("LAST9_REFRESH_TOKEN", None) + self.refresh = jwt() + + def login(self, token=None, profile="default"): + last9.write_private(last9.creds_path(), {"profiles": {profile: {"refresh_token": token or self.refresh}}}) + + def run_cli(self, argv, stdin=""): + out, err = io.StringIO(), io.StringIO() + out.buffer = io.BytesIO() + stdin = io.StringIO(stdin) + stdin.buffer = io.BytesIO(stdin.getvalue().encode()) + with mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", err), \ + mock.patch.object(sys, "stdin", stdin): + try: + last9.main(argv) + code = 0 + except SystemExit as e: + code = e.code + return code, out.getvalue() + out.buffer.getvalue().decode(), err.getvalue() + + +class TestClaims(unittest.TestCase): + def test_decode(self): + c = last9.decode_claims(jwt()) + self.assertEqual(c["organization_slug"], "acme") + self.assertEqual(last9.host_org(c), ("app.last9.io", "acme")) + + def test_aud_strip(self): + for aud in ("https://app.last9.io", "app.last9.io/api", "https://app.last9.io/api/"): + self.assertEqual(last9.host_org({"aud": [aud], "organization_slug": "o"})[0], "app.last9.io") + + def test_missing_claim(self): + with self.assertRaises(SystemExit): + with mock.patch.object(sys, "stderr", io.StringIO()): + last9.host_org({"aud": ["h"]}) + + +class TestUrl(Base): + def ctx(self): + self.login() + return last9.Ctx("default") + + def test_relative(self): + self.assertEqual(last9.resolve_url(self.ctx(), "/change_events", []), + "https://app.last9.io/api/v4/organizations/acme/change_events") + + def test_api_path(self): + self.assertEqual(last9.resolve_url(self.ctx(), "/api/v4/x", []), "https://app.last9.io/api/v4/x") + + def test_full_url_same_host(self): + u = "https://app.last9.io/api/v4/x" + self.assertEqual(last9.resolve_url(self.ctx(), u, []), u) + + def test_foreign_host_rejected(self): + with mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(SystemExit): + last9.resolve_url(self.ctx(), "https://evil.example.com/x", []) + + def test_query_appended(self): + c = self.ctx() + self.assertTrue(last9.resolve_url(c, "logs?a=1", ["b=2", "c=x y"]).endswith("logs?a=1&b=2&c=x+y")) + self.assertTrue(last9.resolve_url(c, "logs", ["b=2"]).endswith("logs?b=2")) + + +class TestTokens(Base): + def test_fresh_cache_no_exchange(self): + self.login() + ctx = last9.Ctx("default") + last9.write_private(ctx.cache_file, {"access_token": "CACHED", "expires_at": time.time() + 7200}) + with mock.patch("urllib.request.urlopen") as m: + self.assertEqual(last9.access_token(ctx), "CACHED") + m.assert_not_called() + + def test_stale_cache_exchanges(self): + self.login() + ctx = last9.Ctx("default") + last9.write_private(ctx.cache_file, {"access_token": "OLD", "expires_at": time.time() + 600}) + with mock.patch("urllib.request.urlopen", return_value=token_resp("NEW")) as m: + self.assertEqual(last9.access_token(ctx), "NEW") + m.assert_called_once() + self.assertEqual(last9.read_json(ctx.cache_file, {})["access_token"], "NEW") + + def test_env_wins_and_not_written(self): + self.login(jwt(organization_slug="fromfile")) + os.environ["LAST9_REFRESH_TOKEN"] = jwt(organization_slug="fromenv") + ctx = last9.Ctx("default") + self.assertEqual((ctx.org, ctx.source), ("fromenv", "env")) + self.assertTrue(ctx.cache_key.startswith("env-")) + with mock.patch("urllib.request.urlopen", return_value=token_resp()): + last9.access_token(ctx) + self.assertNotIn("fromenv", open(last9.creds_path()).read()) + self.assertNotIn(os.environ["LAST9_REFRESH_TOKEN"], open(ctx.cache_file).read()) + + def test_unwritable_cache_is_not_fatal(self): + os.environ["LAST9_REFRESH_TOKEN"] = self.refresh + ctx = last9.Ctx("default") + err = io.StringIO() + with mock.patch("urllib.request.urlopen", return_value=token_resp("NEW")), \ + mock.patch.object(last9, "write_private", side_effect=PermissionError(13, "Permission denied")), \ + mock.patch.object(sys, "stderr", err): + self.assertEqual(last9.access_token(ctx), "NEW") + self.assertIn("could not cache access token", err.getvalue()) + self.assertNotIn("NEW", err.getvalue()) + + def test_not_logged_in(self): + with mock.patch.object(sys, "stderr", io.StringIO()) as err, self.assertRaises(SystemExit): + last9.Ctx("default") + self.assertIn("not logged in", err.getvalue()) + + def test_file_modes(self): + self.login() + ctx = last9.Ctx("default") + last9.write_private(ctx.cache_file, {"access_token": "x", "expires_at": 1}) + mode = lambda p: stat.S_IMODE(os.stat(p).st_mode) + self.assertEqual(mode(self.cfg), 0o700) + self.assertEqual(mode(os.path.dirname(ctx.cache_file)), 0o700) + self.assertEqual(mode(last9.creds_path()), 0o600) + self.assertEqual(mode(ctx.cache_file), 0o600) + + +class TestApi(Base): + def test_retry_once_on_401(self): + self.login() + # exchange, 401, re-exchange, 200 + seq = [token_resp("A1"), http_error(401, b"{}"), token_resp("A2"), FakeResp(b'{"ok":1}')] + with mock.patch("urllib.request.urlopen", side_effect=seq) as m: + code, out, err = self.run_cli(["api", "GET", "x"]) + self.assertEqual((code, out), (0, '{"ok":1}')) + self.assertEqual(m.call_count, 4) + self.assertEqual(m.call_args[0][0].get_header("X-last9-api-token"), "Bearer A2") + + def test_no_loop_on_repeated_401(self): + self.login() + seq = [token_resp("A1"), http_error(401, b"nope"), token_resp("A2"), http_error(401, b"nope")] + with mock.patch("urllib.request.urlopen", side_effect=seq) as m: + code, out, err = self.run_cli(["api", "GET", "x"]) + self.assertEqual(code, 1) + self.assertEqual(m.call_count, 4) + self.assertIn("HTTP 401", err) + self.assertEqual(out, "nope") + + def test_expired_body_triggers_retry(self): + self.login() + seq = [token_resp("A1"), http_error(403, b'{"error":"Authorization token is expired"}'), + token_resp("A2"), FakeResp(b"ok")] + with mock.patch("urllib.request.urlopen", side_effect=seq): + self.assertEqual(self.run_cli(["api", "GET", "x"])[0], 0) + + def test_data_sets_content_type_and_no_token_leak(self): + self.login() + with mock.patch("urllib.request.urlopen", side_effect=[token_resp("SECRETACCESS"), FakeResp(b"{}")]) as m: + code, out, err = self.run_cli(["api", "PUT", "change_events", "-d", "-", "-i"], stdin='{"a":1}') + req = m.call_args[0][0] + self.assertEqual(req.data, b'{"a":1}') + self.assertEqual(req.get_header("Content-type"), "application/json") + self.assertIn("HTTP 200", err) + self.assertNotIn("SECRETACCESS", out + err) + + def test_foreign_host_never_sent(self): + self.login() + with mock.patch("urllib.request.urlopen") as m: + code, _, err = self.run_cli(["api", "GET", "https://evil.example.com/x"]) + self.assertEqual(code, 1) + m.assert_not_called() + + +class TestLogin(Base): + def test_exchange_failure_saves_nothing(self): + with mock.patch("urllib.request.urlopen", side_effect=http_error(401)): + code, out, err = self.run_cli(["login", "--with-token"], stdin=self.refresh + "\n") + self.assertEqual(code, 1) + self.assertIn("invalid, expired, or revoked", err) + self.assertFalse(os.path.exists(self.cfg)) + + def test_success_saves_and_hides_token(self): + with mock.patch("urllib.request.urlopen", return_value=token_resp()): + code, out, err = self.run_cli(["login", "--with-token"], stdin=self.refresh) + self.assertEqual(code, 0) + self.assertIn("org: acme", out) + self.assertNotIn(self.refresh, out + err) + self.assertEqual(last9.load_creds()["profiles"]["default"]["refresh_token"], self.refresh) + + def test_status_not_logged_in(self): + code, _, err = self.run_cli(["status"]) + self.assertEqual(code, 1) + self.assertIn("not logged in", err) + + +class TestRegion(Base): + def url(self, path, q=()): + return last9.resolve_url(last9.Ctx("default"), path, list(q)) + + def saved(self, region): + last9.write_private(last9.creds_path(), + {"profiles": {"default": {"refresh_token": self.refresh, "region": region}}}) + + def test_added_for_logs_and_cat(self): + self.saved("ap-south-1") + self.assertTrue(self.url("logs/api/v1/labels", ["start=1"]).endswith("labels?start=1®ion=ap-south-1")) + self.assertTrue(self.url("/cat/api/traces/x").endswith("traces/x?region=ap-south-1")) + self.assertTrue(self.url("/api/v4/organizations/acme/logs/x").endswith("logs/x?region=ap-south-1")) + + def test_not_added_elsewhere(self): + self.saved("ap-south-1") + self.assertNotIn("region", self.url("change_events")) + self.assertNotIn("region", self.url("entities/x")) + + def test_explicit_wins_no_dup(self): + self.saved("ap-south-1") + self.assertEqual(self.url("logs/x", ["region=eu-west-1"]).count("region="), 1) + self.assertEqual(self.url("logs/x?region=eu-west-1").count("region="), 1) + + def test_missing_exits_without_request(self): + self.login() + with mock.patch("urllib.request.urlopen") as m: + code, _, err = self.run_cli(["api", "GET", "logs/api/v1/labels"]) + self.assertEqual(code, 1) + self.assertIn("region required", err) + m.assert_not_called() + + def test_env_beats_saved(self): + self.saved("ap-south-1") + with mock.patch.dict(os.environ, {"LAST9_REGION": "us-east-1"}): + self.assertTrue(self.url("logs/x").endswith("region=us-east-1")) + + def test_login_persists_and_preserves(self): + with mock.patch("urllib.request.urlopen", side_effect=[token_resp(), token_resp()]): + self.run_cli(["login", "--with-token", "--region", "ap-south-1"], stdin=self.refresh) + self.assertEqual(last9.load_creds()["profiles"]["default"]["region"], "ap-south-1") + code, out, _ = self.run_cli(["login", "--with-token"], stdin=self.refresh) + self.assertEqual(last9.load_creds()["profiles"]["default"]["region"], "ap-south-1") + self.assertIn("region: ap-south-1", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/last9-api/scripts/test_transport.py b/skills/last9-api/scripts/test_transport.py new file mode 100644 index 0000000..f0fead1 --- /dev/null +++ b/skills/last9-api/scripts/test_transport.py @@ -0,0 +1,50 @@ +import io +import time +import unittest +import urllib.request +from unittest import mock + +from test_last9 import Base, FakeResp, last9 + + +class TestTransport(Base): + def setUp(self): + super().setUp() + self.login() + self.ctx = last9.Ctx("default") + last9.write_private(self.ctx.cache_file, {"access_token": "SYNTHETIC_ACCESS", "expires_at": time.time() + 7200}) + saved = urllib.request._opener + self.addCleanup(urllib.request.install_opener, saved) + + def test_http_must_not_send_bearer(self): + with mock.patch("urllib.request.urlopen", return_value=FakeResp(b"{}")) as network: + code, _, err = self.run_cli(["api", "GET", "http://app.last9.io/api/v4/organizations/acme/change_events"]) + self.assertEqual(code, 1) + self.assertIn("https", err) + network.assert_not_called() + + def test_https_positive_control(self): + with mock.patch("urllib.request.urlopen", return_value=FakeResp(b"{}")) as network: + code, _, _ = self.run_cli(["api", "GET", "https://app.last9.io/api/v4/organizations/acme/change_events"]) + self.assertEqual(code, 0) + network.assert_called_once() + self.assertEqual(network.call_args.args[0].get_header("X-last9-api-token"), "Bearer SYNTHETIC_ACCESS") + + def test_foreign_authorities_rejected(self): + paths = ["https://foreign.example/x", "https://app.last9.io.foreign.example/x", + "https://app.last9.io@foreign.example/x", "http://foreign.example/x"] + for path in paths: + with self.subTest(path=path), mock.patch("urllib.request.urlopen") as network: + code, _, _ = self.run_cli(["api", "GET", path]) + self.assertEqual(code, 1) + network.assert_not_called() + + def test_redirect_refused(self): + req = urllib.request.Request("https://app.last9.io/x", headers={"X-LAST9-API-TOKEN": "Bearer SYNTHETIC_ACCESS"}) + for code in [301, 302, 303, 307, 308]: + with self.subTest(code=code): + self.assertIsNone(last9.NoRedirect().redirect_request(req, io.BytesIO(), code, "redirect", {}, "https://foreign.example/x")) + + +if __name__ == "__main__": + unittest.main()