diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec3af6a..f109d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,10 @@ jobs: - name: Validate SKILL.md files run: ./scripts/validate-skills.sh + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test skill examples + run: make test-skills diff --git a/Makefile b/Makefile index 70fd0f3..de80542 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test lint clean install help test-e2e docker-up docker-down validate-skills +.PHONY: build test lint clean install help test-e2e docker-up docker-down validate-skills test-skills check # Build variables BINARY_NAME := a6 @@ -81,5 +81,9 @@ docker-down: validate-skills: ./scripts/validate-skills.sh +## test-skills: Validate commands and flags used in skill shell examples +test-skills: + go test ./test/skills -count=1 + ## check: Run all checks (fmt, vet, lint, test) -check: fmt vet lint test +check: fmt vet lint test validate-skills test-skills diff --git a/docs/skills.md b/docs/skills.md index 2467006..840474b 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -4,7 +4,30 @@ This document describes the skill system for the a6 CLI. Skills are structured k ## Overview -Skills are `SKILL.md` files stored in the `skills/` directory. Each skill provides domain-specific instructions, command patterns, and decision guidance for AI agents. The format is compatible with 39+ AI coding agents including Claude Code, OpenCode, Cursor, GitHub Copilot, and Windsurf. +Skills are `SKILL.md` files stored in the `skills/` directory. Each skill provides domain-specific instructions, command patterns, and decision guidance for AI agents. The supported installation examples cover Claude Code, Codex, Cursor, and GitHub Copilot. + +Start with one task-specific skill. Add another only when the task clearly spans +multiple workflows. Do not install the full collection by default: overlapping +persona, recipe, and plugin guidance can make skill routing and updates harder +to review. + +## Install a Skill + +Preview the available skills, then copy one skill into the current project: + +```bash +npx skills add api7/a6 --list +npx skills add api7/a6 --skill a6-plugin-key-auth --agent codex --copy +``` + +Replace `codex` with `claude-code`, `cursor`, or `github-copilot`. Review the +selected `SKILL.md` before use. Installation copies instructions only; it does +not install `a6`, connect to APISIX, or run gateway commands. + +Use a non-production context for a first run. Ask the agent to inspect current +resources, propose an exact change, wait for approval, apply only the approved +change, verify the result, and retain a rollback path. Never put an Admin API +key in a prompt or committed file. ## Directory Structure @@ -108,18 +131,23 @@ The body follows the skill type: ## CI Validation -Every PR that modifies `skills/` is validated by `scripts/validate-skills.sh`. The script checks: +Every PR that modifies `skills/` runs metadata validation and CLI-example +tests. The checks cover: 1. Every `skills/*/SKILL.md` has valid YAML frontmatter 2. Required fields `name` and `description` are present 3. `name` matches the directory name 4. `name` follows kebab-case pattern 5. `description` is non-empty +6. Commands used in shell examples exist in the current a6 CLI +7. Flags used in shell examples are supported by that command or globally +8. Literal output formats and positional argument counts match the command Run locally: ```bash make validate-skills +make test-skills ``` ## Adding a New Skill @@ -127,7 +155,7 @@ make validate-skills 1. Choose the skill type and name following the [taxonomy](#skill-taxonomy) 2. Create the directory: `mkdir skills/` 3. Create `skills//SKILL.md` with frontmatter and body -4. Run validation: `make validate-skills` +4. Run validation: `make validate-skills test-skills` 5. Update this document if adding a new skill type or category ## Skill Roadmap diff --git a/skills/a6-persona-developer/SKILL.md b/skills/a6-persona-developer/SKILL.md index 00edc0d..c8556b9 100644 --- a/skills/a6-persona-developer/SKILL.md +++ b/skills/a6-persona-developer/SKILL.md @@ -10,7 +10,7 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: persona - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" a6_commands: - a6 route create - a6 route update @@ -18,6 +18,7 @@ metadata: - a6 upstream create - a6 service create - a6 consumer create + - a6 credential create - a6 plugin list - a6 plugin get - a6 config sync @@ -49,7 +50,7 @@ go install github.com/api7/a6/cmd/a6@latest a6 context create dev --server http://localhost:9180 --api-key edd1c9f034335f136f87ad84b625c8f1 # Verify connection -a6 health +a6 route list --output table ``` ### 2. Explore available plugins @@ -287,9 +288,6 @@ upstreams: consumers: - username: dev - plugins: - key-auth: - key: dev-key routes: - id: api @@ -303,23 +301,34 @@ EOF a6 config sync -f dev-config.yaml ``` +Create authentication data as a separate credential resource. Save the +following as `dev-credential.yaml`: + +```yaml +id: dev-key-auth +plugins: + key-auth: + key: dev-key +``` + +```bash +a6 credential create --consumer dev -f dev-credential.yaml +``` + ## Debugging ### Trace a request ```bash # See how APISIX routes a specific request -a6 debug trace --uri /api/users --method GET --header "apikey: dev-key" +a6 debug trace api --path /api/users --method GET --header "apikey: dev-key" ``` ### Stream logs ```bash -# Watch APISIX error logs in real-time +# Watch APISIX container logs in real-time a6 debug logs --follow - -# Filter by log level -a6 debug logs --follow --level error ``` ### Inspect a route's full config @@ -350,14 +359,13 @@ a6 route get my-api --output json | jq . a6 config sync -f apisix-config.yaml ``` -### Export for other tools +### Export the current configuration -```bash -# Export to Kubernetes-friendly format -a6 export --format kubernetes > k8s-apisix.yaml +`a6 config dump` does not export Consumer Credential subresources. Keep the +credential files in your secure deployment workflow and restore them separately. -# Export to standalone YAML -a6 export --format standalone > apisix-standalone.yaml +```bash +a6 config dump --output yaml > apisix-backup.yaml ``` ## Decision Framework @@ -369,7 +377,7 @@ a6 export --format standalone > apisix-standalone.yaml | Multiple routes, same config | Create a Service → reference via `service_id` | | Need rate limiting | Choose `limit-count` (fixed) or `limit-req` (smooth) → add to route | | Backend URL changed | `a6 upstream update ` with new nodes | -| Debug 502 errors | `a6 debug trace` → `a6 upstream health` → check backend | +| Debug 502 errors | `a6 debug trace ` → `a6 upstream health` → check backend | | Prepare for production | `a6 config dump` → commit to git → `a6 config validate` in CI | | Test a new plugin | `a6 plugin get ` for schema → add to a test route → verify | diff --git a/skills/a6-persona-operator/SKILL.md b/skills/a6-persona-operator/SKILL.md index 50fdfe2..61cb867 100644 --- a/skills/a6-persona-operator/SKILL.md +++ b/skills/a6-persona-operator/SKILL.md @@ -21,7 +21,7 @@ metadata: - a6 config validate - a6 debug logs - a6 debug trace - - a6 health + - a6 route list - a6 ssl create - a6 global-rule create --- @@ -65,8 +65,8 @@ Always verify the active context before running destructive operations. ### 1. Health check ```bash -# Verify APISIX is reachable and get version -a6 health +# Verify that the APISIX Admin API is reachable +a6 route list --output table # Check all upstream health status a6 upstream list --output json | jq '.[] | {id: .id, name: .name}' @@ -117,14 +117,13 @@ a6 config diff -f new-config.yaml a6 --context staging config sync -f new-config.yaml # 4. Verify staging -a6 --context staging health -a6 --context staging route list +a6 --context staging route list --output table # 5. Apply to production a6 --context prod config sync -f new-config.yaml # 6. Verify production -a6 --context prod health +a6 --context prod route list --output table ``` ### Rollback @@ -147,9 +146,9 @@ a6 route list a6 route get --output json # 2. Trace the request path -a6 debug trace --uri /api/v1/users --method GET +a6 debug trace --path /api/v1/users --method GET -# 3. Stream error logs in real-time +# 3. Stream APISIX container logs in real-time a6 debug logs --follow # 4. Check upstream health @@ -163,10 +162,10 @@ a6 upstream health a6 upstream get --output json # Verify backend is reachable from APISIX -a6 debug trace --uri /failing-endpoint +a6 debug trace --path /failing-endpoint -# Check error logs for connection refused / timeout -a6 debug logs --follow --level error +# Check container logs for connection refused / timeout +a6 debug logs --follow ``` ### Authentication failures (401/403) diff --git a/skills/a6-plugin-basic-auth/SKILL.md b/skills/a6-plugin-basic-auth/SKILL.md index ace9770..44024c8 100644 --- a/skills/a6-plugin-basic-auth/SKILL.md +++ b/skills/a6-plugin-basic-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: basic-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-basic-auth @@ -64,19 +65,18 @@ EOF ### 2. Add basic-auth credential +Save the credential as `credential.yaml`, then create it: + +```yaml +id: cred-alice-basic-auth +plugins: + basic-auth: + username: alice + password: alice-password-123 +``` + ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-basic-auth", - "plugins": { - "basic-auth": { - "username": "alice", - "password": "alice-password-123" - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with basic-auth enabled diff --git a/skills/a6-plugin-consumer-restriction/SKILL.md b/skills/a6-plugin-consumer-restriction/SKILL.md index 882ff1b..58eb0bb 100644 --- a/skills/a6-plugin-consumer-restriction/SKILL.md +++ b/skills/a6-plugin-consumer-restriction/SKILL.md @@ -154,7 +154,7 @@ Only allow consumers in `enterprise` group: ```bash # Create consumer group -a6 consumergroup create -f - <<'EOF' +a6 consumer-group create -f - <<'EOF' { "id": "enterprise", "plugins": { diff --git a/skills/a6-plugin-hmac-auth/SKILL.md b/skills/a6-plugin-hmac-auth/SKILL.md index edc46d6..76c1456 100644 --- a/skills/a6-plugin-hmac-auth/SKILL.md +++ b/skills/a6-plugin-hmac-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: hmac-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-hmac-auth @@ -70,19 +71,18 @@ EOF ### 2. Add hmac-auth credential +Save the credential as `credential.yaml`, then create it: + +```yaml +id: cred-alice-hmac +plugins: + hmac-auth: + key_id: alice-key + secret_key: alice-secret-key-value +``` + ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-hmac", - "plugins": { - "hmac-auth": { - "key_id": "alice-key", - "secret_key": "alice-secret-key-value" - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with hmac-auth enabled diff --git a/skills/a6-plugin-jwt-auth/SKILL.md b/skills/a6-plugin-jwt-auth/SKILL.md index 31a9638..80afa2e 100644 --- a/skills/a6-plugin-jwt-auth/SKILL.md +++ b/skills/a6-plugin-jwt-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: jwt-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-jwt-auth @@ -91,21 +92,20 @@ EOF ### 2. Add jwt-auth credential +Save the credential as `credential.yaml`, then create it: + +```yaml +id: cred-alice-jwt +plugins: + jwt-auth: + key: alice-key + secret: alice-secret-minimum-32-chars-long + algorithm: HS256 + exp: 86400 +``` + ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-jwt", - "plugins": { - "jwt-auth": { - "key": "alice-key", - "secret": "alice-secret-minimum-32-chars-long", - "algorithm": "HS256", - "exp": 86400 - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with jwt-auth @@ -147,25 +147,38 @@ openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem ``` -### 2. Create credential with public key +### 2. Create a consumer ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/bob/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-bob-jwt", - "plugins": { - "jwt-auth": { - "key": "bob-key", - "algorithm": "RS256", - "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN...\n-----END PUBLIC KEY-----" - } - } - }' +a6 consumer create -f - <<'EOF' +{ + "username": "bob" +} +EOF +``` + +### 3. Create a credential with the public key + +Save the following as `bob-rs256-credential.yaml`, replacing the placeholder +with the base64 body between the PEM delimiters in `public.pem`: + +```yaml +id: cred-bob-jwt +plugins: + jwt-auth: + key: bob-key + algorithm: RS256 + public_key: | + -----BEGIN PUBLIC KEY----- + replace-with-the-base64-body-from-public.pem + -----END PUBLIC KEY----- +``` + +```bash +a6 credential create --consumer bob -f bob-rs256-credential.yaml ``` -Sign tokens with `private.pem` externally. APISIX only needs the public key. +Sign tokens with `private.pem` externally; APISIX only needs the public key. ## Common Patterns diff --git a/skills/a6-plugin-key-auth/SKILL.md b/skills/a6-plugin-key-auth/SKILL.md index 20107ab..d082287 100644 --- a/skills/a6-plugin-key-auth/SKILL.md +++ b/skills/a6-plugin-key-auth/SKILL.md @@ -10,13 +10,14 @@ author: Apache APISIX Contributors license: Apache-2.0 metadata: category: plugin - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" plugin_name: key-auth a6_commands: - a6 route create - a6 route update - a6 consumer create - a6 consumer update + - a6 credential create --- # a6-plugin-key-auth @@ -71,20 +72,17 @@ EOF ### 2. Add key-auth credential to the consumer -Use the Admin API (credentials are sub-resources of consumers): +Save the credential as `credential.yaml`: + +```yaml +id: cred-alice-key-auth +plugins: + key-auth: + key: alice-secret-key-001 +``` ```bash -curl "$(a6 context current -o json | jq -r .server)/apisix/admin/consumers/alice/credentials" \ - -X PUT \ - -H "X-API-KEY: $(a6 context current -o json | jq -r .api_key)" \ - -d '{ - "id": "cred-alice-key-auth", - "plugins": { - "key-auth": { - "key": "alice-secret-key-001" - } - } - }' +a6 credential create --consumer alice -f credential.yaml ``` ### 3. Create a route with key-auth enabled diff --git a/skills/a6-plugin-serverless/SKILL.md b/skills/a6-plugin-serverless/SKILL.md index 4286cee..498d549 100644 --- a/skills/a6-plugin-serverless/SKILL.md +++ b/skills/a6-plugin-serverless/SKILL.md @@ -17,7 +17,7 @@ metadata: a6_commands: - a6 route create - a6 route update - - a6 globalrule create + - a6 global-rule create - a6 config sync --- diff --git a/skills/a6-recipe-mtls/SKILL.md b/skills/a6-recipe-mtls/SKILL.md index 387efef..42fefb3 100644 --- a/skills/a6-recipe-mtls/SKILL.md +++ b/skills/a6-recipe-mtls/SKILL.md @@ -235,14 +235,18 @@ EOF ### Using APISIX Secret for certificate management -Store certificates in external secret managers (Vault, AWS, etc.): +Configure APISIX to read certificate material from a supported external secret +manager. This example registers a Vault KV v1 manager; store the certificate +values separately in Vault and reference them from the SSL resource with +`$secret://vault/mtls-certs//`. ```bash -# Create a secret reference -a6 secret create -f - <<'EOF' +# Configure a Vault secret manager +a6 secret create vault/mtls-certs -f - <<'EOF' { - "id": "vault/mtls-certs", - "uri": "https://vault.example.com/v1/secret/data/mtls" + "uri": "https://vault.example.com", + "prefix": "apisix", + "token": "" } EOF ``` diff --git a/skills/a6-recipe-multi-tenant/SKILL.md b/skills/a6-recipe-multi-tenant/SKILL.md index cef9a84..a5c97d2 100644 --- a/skills/a6-recipe-multi-tenant/SKILL.md +++ b/skills/a6-recipe-multi-tenant/SKILL.md @@ -1,55 +1,67 @@ --- name: a6-recipe-multi-tenant description: >- - Recipe skill for implementing multi-tenant API gateway patterns using the a6 - CLI. Covers tenant isolation via Consumer Groups, host/path/header-based - routing, per-tenant rate limiting, context forwarding with proxy-rewrite, - and declarative config sync workflows for multi-tenant management. + Recipe skill for implementing tenant-aware policies on a shared APISIX + gateway using the a6 CLI. Covers shared policies through Consumer Groups, + host/path/authenticated-consumer routing, per-consumer rate limiting, context + forwarding with proxy-rewrite, and declarative configuration workflows. version: "1.0.0" author: Apache APISIX Contributors license: Apache-2.0 metadata: category: recipe - apisix_version: ">=3.0.0" + apisix_version: ">=3.11.0" a6_commands: - a6 consumer create - a6 consumer-group create + - a6 consumer-group list + - a6 consumer get + - a6 credential create - a6 route create - a6 route update + - a6 upstream create + - a6 config diff - a6 config sync - a6 config dump --- -# a6-recipe-multi-tenant +# Build Tenant-Aware Policies on a Shared Gateway ## Overview -Multi-tenancy in an API gateway means serving multiple isolated tenants (customers, -teams, or business units) through the same gateway instance, each with their own -rate limits, authentication, and routing rules. - -APISIX achieves multi-tenancy through: -1. **Consumer Groups** — group consumers into tenants with shared plugin configs -2. **Host/path/header-based routing** — route requests to tenant-specific upstreams -3. **Per-tenant rate limiting** — enforce quotas per consumer group +APISIX does not provide a Tenant resource or a built-in tenant isolation model. +This recipe combines APISIX capabilities to serve customers, teams, or business +units through one shared gateway with different authentication, routing, and +traffic policies. + +These patterns separate request handling and policy behavior. They do not +isolate Admin API access, configuration storage, or gateway runtime resources. +Use separate APISIX deployments when stronger administrative or runtime +isolation is required. + +This recipe composes: +1. **Consumer Groups** — apply shared plugin configurations to related consumers +2. **Host/path/authenticated-consumer routing** — route requests to + tenant-specific upstreams +3. **Per-consumer rate limiting** — enforce different quotas within policy groups 4. **Proxy-rewrite** — forward tenant context to backends via headers ## When to Use - Multiple customers sharing a single API gateway -- Internal platform serving different teams with isolated quotas -- SaaS application requiring per-tenant rate limits and auth +- Internal platform serving different teams with separate policy and quota settings +- SaaS application requiring tenant-aware routing and authentication - Need to forward tenant identity to backend services -## Approach A: Consumer Groups for Tenant Isolation +## Approach A: Consumer Groups for Shared Tenant Policies -Group consumers by tenant. Each tenant gets shared plugin configuration -(rate limits, transformations) applied via the consumer group. +Group consumers by tenant or service tier. Each group supplies shared plugin +configuration, such as rate limits and transformations, to its consumers. -### 1. Create consumer groups (one per tenant) +### 1. Create consumer groups for tenant policy sets ```bash -# Free tier — 100 requests/day +# Free tier — 100 requests/day per consumer a6 consumer-group create -f - <<'EOF' { "id": "tenant-free", @@ -67,7 +79,7 @@ a6 consumer-group create -f - <<'EOF' } EOF -# Pro tier — 10000 requests/day +# Pro tier — 10000 requests/day per consumer a6 consumer-group create -f - <<'EOF' { "id": "tenant-pro", @@ -179,10 +191,12 @@ a6 route create -f - <<'EOF' EOF ``` -## Approach C: Header-Based Tenant Routing +## Approach C: Authenticated Tenant Routing -Use a custom header (e.g., `X-Tenant-ID`) to route to different upstreams -via `traffic-split`. +Use the authenticated `consumer_name` variable to route to different upstreams +with `traffic-split`. Authentication plugins populate this APISIX variable from +the matched Consumer before `traffic-split` runs, so a client cannot select +another tenant's upstream by spoofing a request header. ```bash a6 route create -f - <<'EOF' @@ -193,13 +207,13 @@ a6 route create -f - <<'EOF' "traffic-split": { "rules": [ { - "match": [{ "vars": [["http_x_tenant_id", "==", "tenant-a"]] }], + "match": [{ "vars": [["consumer_name", "==", "acme-corp"]] }], "weighted_upstreams": [ { "upstream": { "type": "roundrobin", "nodes": { "tenant-a-backend:8080": 1 } }, "weight": 1 } ] }, { - "match": [{ "vars": [["http_x_tenant_id", "==", "tenant-b"]] }], + "match": [{ "vars": [["consumer_name", "==", "startup-xyz"]] }], "weighted_upstreams": [ { "upstream": { "type": "roundrobin", "nodes": { "tenant-b-backend:8080": 1 } }, "weight": 1 } ] @@ -240,9 +254,9 @@ EOF Backend receives `X-Consumer-Name: acme-corp` and `X-Consumer-Group: tenant-pro`. -## Declarative Multi-Tenant Config +## Declarative Tenant-Aware Configuration -Manage all tenants declaratively with `a6 config sync`: +Manage tenant groups, consumers, and routes declaratively with `a6 config sync`: ```yaml # apisix-tenants.yaml @@ -267,14 +281,8 @@ consumer_groups: consumers: - username: acme-corp group_id: tenant-pro - plugins: - key-auth: - key: acme-secret-key - username: startup-xyz group_id: tenant-free - plugins: - key-auth: - key: startup-xyz-key routes: - id: api-v1 @@ -300,8 +308,41 @@ a6 config diff -f apisix-tenants.yaml a6 config sync -f apisix-tenants.yaml ``` +Create each tenant's `key-auth` data as a credential after the consumers +exist. For example, save the following as `acme-credential.yaml`: + +```yaml +id: acme-key-auth +plugins: + key-auth: + key: acme-secret-key +``` + +```bash +a6 credential create --consumer acme-corp -f acme-credential.yaml +``` + +Save the free-tier credential as `startup-credential.yaml`: + +```yaml +id: startup-key-auth +plugins: + key-auth: + key: startup-xyz-key +``` + +```bash +a6 credential create --consumer startup-xyz -f startup-credential.yaml +``` + ## Gotchas +- **Consumer Groups are not isolation boundaries** — they reuse plugin + configurations across consumers. All groups still share the same APISIX + administrative surface, configuration storage, and gateway runtime. +- **Credentials are separate resources** — `a6 config sync` and `a6 config dump` + do not manage Consumer Credential subresources. Store credential files securely + and apply or restore them separately with `a6 credential` commands. - **Consumer group plugins merge** — plugins set on the consumer group are merged with plugins on the individual consumer. The consumer's plugin config takes precedence if both define the same plugin. @@ -309,6 +350,9 @@ a6 config sync -f apisix-tenants.yaml - **Rate limit key** — use `key_type: "var"` with `key: "consumer_name"` to enforce per-consumer limits within a group. Without this, the limit applies globally across all consumers in the group. +- **Tenant routing identity** — match `consumer_name` or `consumer_group_id` + after authentication. Do not route on a client-supplied tenant header because + an authenticated consumer could spoof another tenant's value. - **Variable names in proxy-rewrite** — `$consumer_name` and `$consumer_group_id` are APISIX built-in variables, available only after authentication runs. Ensure the auth plugin (key-auth, jwt-auth, etc.) has higher priority than diff --git a/test/e2e/skills/skill_recipe_multi_tenant_test.go b/test/e2e/skills/skill_recipe_multi_tenant_test.go index 74621fa..30db003 100644 --- a/test/e2e/skills/skill_recipe_multi_tenant_test.go +++ b/test/e2e/skills/skill_recipe_multi_tenant_test.go @@ -36,15 +36,40 @@ func TestSkillRecipeMultiTenant(t *testing.T) { "uri": "/skill-multi-tenant", "plugins": { "key-auth": {}, - "consumer-restriction": { - "whitelist": ["skill-tenant-a", "skill-tenant-b"] - }, "limit-count": { "count": 100, "time_window": 86400, "key_type": "var", "key": "consumer_name" }, + "traffic-split": { + "rules": [ + { + "match": [{"vars": [["consumer_name", "==", "skill-tenant-a"]]}], + "weighted_upstreams": [{ + "upstream": { + "type": "roundrobin", + "pass_host": "rewrite", + "upstream_host": "skill-tenant-a.internal", + "nodes": {"127.0.0.1:8080": 1} + }, + "weight": 1 + }] + }, + { + "match": [{"vars": [["consumer_name", "==", "skill-tenant-b"]]}], + "weighted_upstreams": [{ + "upstream": { + "type": "roundrobin", + "pass_host": "rewrite", + "upstream_host": "skill-tenant-b.internal", + "nodes": {"127.0.0.1:8080": 1} + }, + "weight": 1 + }] + } + ] + }, "proxy-rewrite": { "uri": "/get" } @@ -57,16 +82,20 @@ func TestSkillRecipeMultiTenant(t *testing.T) { stdout, _, err = runA6WithEnv(env, "route", "get", routeID, "--output", "json") require.NoError(t, err) - assert.Contains(t, stdout, `"consumer-restriction"`) + assert.Contains(t, stdout, `"traffic-split"`) assert.Contains(t, stdout, `"limit-count"`) - status, _ := httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", - map[string]string{"apikey": "tenant-a-key"}, 200, 5*time.Second) + status, body := httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", + map[string]string{"apikey": "tenant-a-key", "X-Tenant-ID": "skill-tenant-b"}, 200, 5*time.Second) assert.Equal(t, 200, status) + assert.Contains(t, body, "skill-tenant-a.internal") + assert.NotContains(t, body, "skill-tenant-b.internal") - status, _ = httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", - map[string]string{"apikey": "tenant-b-key"}, 200, 5*time.Second) + status, body = httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", + map[string]string{"apikey": "tenant-b-key", "X-Tenant-ID": "skill-tenant-a"}, 200, 5*time.Second) assert.Equal(t, 200, status) + assert.Contains(t, body, "skill-tenant-b.internal") + assert.NotContains(t, body, "skill-tenant-a.internal") status, _ = httpGetWithRetry(t, gatewayURL+"/skill-multi-tenant", map[string]string{"apikey": "unknown-key"}, 401, 5*time.Second) diff --git a/test/skills/skills_test.go b/test/skills/skills_test.go new file mode 100644 index 0000000..7c0d25f --- /dev/null +++ b/test/skills/skills_test.go @@ -0,0 +1,698 @@ +package skills + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "unicode" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "gopkg.in/yaml.v3" + + "github.com/api7/a6/internal/config" + cmd "github.com/api7/a6/pkg/cmd" + rootcmd "github.com/api7/a6/pkg/cmd/root" + "github.com/api7/a6/pkg/iostreams" +) + +func locateRepoRoot() (string, error) { + var dir string + var err error + dir, err = os.Getwd() + if err != nil { + return "", err + } + for { + var statErr error + _, statErr = os.Stat(filepath.Join(dir, "go.mod")) + if statErr == nil { + return dir, nil + } + var parent string = filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + dir = parent + } +} + +func buildA6Binary(t *testing.T, root string) string { + t.Helper() + var binary string = filepath.Join(t.TempDir(), "a6") + var cmd *exec.Cmd = exec.Command("go", "build", "-o", binary, "./cmd/a6") + cmd.Dir = root + var output []byte + var err error + output, err = cmd.CombinedOutput() + if err != nil { + t.Fatalf("failed to build a6: %v\n%s", err, output) + } + return binary +} + +func TestSkillCommandsUseSupportedA6CommandsAndFlags(t *testing.T) { + shellFencePattern := regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") + yamlFencePattern := regexp.MustCompile("(?s)```(?:yaml|yml)\\s*\\n(.*?)```") + invocationPattern := regexp.MustCompile(`(?:^|[^A-Za-z0-9_-])(a6)(?:\s|$)`) + workflowExpressionPattern := regexp.MustCompile(`\$\{\{.*?\}\}`) + root, err := locateRepoRoot() + if err != nil { + t.Fatalf("failed to locate repository root: %v", err) + } + binary := buildA6Binary(t, root) + commandTree := newA6CommandTree(t) + rootFlags, valueFlags := rootFlagSets(commandTree) + matches, err := filepath.Glob(filepath.Join(root, "skills", "*", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if len(matches) == 0 { + t.Fatal("expected at least one skill file") + } + rootHelp, err := commandHelp(binary, nil) + if err != nil { + t.Fatal(err) + } + rootCommands := availableCommands(rootHelp) + regressions := []string{ + "a6 route creat", + "a6 route --server https://example.test creat", + "a6 --bogus route list", + "a6 --bogus=value route list", + "a6 route list --Output json", + "a6 route list --output_json json", + "a6 route list -Z", + "a6 route list --output wide", + "a6 route list --output=wide", + "a6 route list -owide", + "a6 route get example --output table", + "a6 route list unexpected", + "a6 credential get", + } + for _, invocation := range regressions { + if err := validateA6Invocation(binary, invocation, commandTree, rootCommands, rootFlags, valueFlags); err == nil { + t.Fatalf("expected invalid invocation %q to fail", invocation) + } + } + for _, invocation := range []string{ + "a6 --output json route list", + "a6 --output=json route list", + "a6 route -o yaml get example", + "a6 route -oyaml get example", + `a6 debug trace api --header "X-Test: --not-a-flag"`, + } { + if err := validateA6Invocation(binary, invocation, commandTree, rootCommands, rootFlags, valueFlags); err != nil { + t.Fatalf("expected valid invocation %q: %v", invocation, err) + } + } + embedded := cliInvocations("CURRENT=$(a6 route get blue-green)", invocationPattern) + if len(embedded) != 1 || !strings.HasPrefix(embedded[0], "a6 route get") { + t.Fatalf("expected embedded a6 invocation, got %q", embedded) + } + quoted := cliInvocations(`a6 debug trace id --header "X-Test: a|b;c&d)" --bogus`, invocationPattern) + if len(quoted) != 1 || !strings.Contains(quoted[0], "--bogus") { + t.Fatalf("expected quoted separators to preserve the complete invocation, got %q", quoted) + } + yamlBlocks, err := skillShellBlocks("```yaml\n- name: Validate\n run: >\n a6 route list\n --unsupported\n```", shellFencePattern, yamlFencePattern) + if err != nil { + t.Fatalf("failed to extract workflow run block: %v", err) + } + yamlCommands := joinedShellLines(yamlBlocks[0]) + if len(yamlCommands) != 1 || yamlCommands[0] != "a6 route list --unsupported" { + t.Fatalf("expected workflow run block, got %q", yamlBlocks) + } + + for _, file := range matches { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + declaredCommands, err := frontmatterA6Commands(string(data)) + if err != nil { + t.Fatalf("%s: failed to parse frontmatter: %v", file, err) + } + for _, declaredCommand := range declaredCommands { + fields := strings.Fields(declaredCommand) + if len(fields) < 2 || fields[0] != "a6" { + t.Fatalf("%s: a6_commands entry %q must start with a6 and include a command", file, declaredCommand) + } + commandArgs, err := commandFields(fields[1:], commandTree, rootFlags, valueFlags) + if err != nil { + t.Fatalf("%s: a6_commands entry %q is invalid: %v", file, declaredCommand, err) + } + path, _, remaining, err := resolveCommand(binary, commandArgs, rootCommands, rootFlags, valueFlags) + if err != nil { + t.Fatalf("%s: a6_commands entry %q is invalid: %v", file, declaredCommand, err) + } + if len(remaining) != 0 || strings.Join(path, " ") != strings.Join(fields[1:], " ") { + t.Fatalf("%s: a6_commands entry %q must contain only a command path", file, declaredCommand) + } + } + blocks, err := skillShellBlocks(string(data), shellFencePattern, yamlFencePattern) + if err != nil { + t.Fatalf("%s: failed to parse fenced YAML: %v", file, err) + } + for _, block := range blocks { + for _, line := range joinedShellLines(block) { + for _, invocation := range cliInvocations(line, invocationPattern) { + invocation = workflowExpressionPattern.ReplaceAllString(invocation, "workflow-expression") + if err := validateA6Invocation(binary, invocation, commandTree, rootCommands, rootFlags, valueFlags); err != nil { + t.Fatalf("%s: command %q is invalid: %v", file, invocation, err) + } + } + } + } + } +} + +func validateA6Invocation(binary, invocation string, root *cobra.Command, rootCommands, rootFlags, valueFlags map[string]bool) error { + fields, err := shellFields(invocation) + if err != nil { + return err + } + if len(fields) < 2 || fields[0] != "a6" { + return nil + } + commandArgs, err := commandFields(fields[1:], root, rootFlags, valueFlags) + if err != nil { + return err + } + path, _, remaining, err := resolveCommand(binary, commandArgs, rootCommands, rootFlags, valueFlags) + if err != nil { + return err + } + return validatePositionalArgs(root, path, remaining) +} + +func frontmatterA6Commands(data string) ([]string, error) { + var lines []string = strings.Split(data, "\n") + if len(lines) < 3 || lines[0] != "---" { + return nil, fmt.Errorf("missing opening frontmatter delimiter") + } + var end int = -1 + var index int + for index = 1; index < len(lines); index++ { + if lines[index] == "---" { + end = index + break + } + } + if end == -1 { + return nil, fmt.Errorf("missing closing frontmatter delimiter") + } + var frontmatter struct { + Metadata struct { + A6Commands []string `yaml:"a6_commands"` + } `yaml:"metadata"` + } + var err error = yaml.Unmarshal([]byte(strings.Join(lines[1:end], "\n")), &frontmatter) + if err != nil { + return nil, err + } + return frontmatter.Metadata.A6Commands, nil +} + +func skillShellBlocks(data string, shellFencePattern *regexp.Regexp, yamlFencePattern *regexp.Regexp) ([]string, error) { + var blocks []string + var match []string + for _, match = range shellFencePattern.FindAllStringSubmatch(data, -1) { + blocks = append(blocks, match[1]) + } + for _, match = range yamlFencePattern.FindAllStringSubmatch(data, -1) { + var runBlocks []string + var err error + runBlocks, err = yamlRunBlocks(match[1]) + if err != nil { + return nil, err + } + blocks = append(blocks, runBlocks...) + } + return blocks, nil +} + +func yamlRunBlocks(block string) ([]string, error) { + var root yaml.Node + var err error = yaml.Unmarshal([]byte(block), &root) + if err != nil { + return nil, err + } + var runBlocks []string + collectYAMLRunBlocks(&root, &runBlocks) + return runBlocks, nil +} + +func collectYAMLRunBlocks(node *yaml.Node, runBlocks *[]string) { + if node.Kind == yaml.MappingNode { + var index int + for index = 0; index+1 < len(node.Content); index += 2 { + var key *yaml.Node = node.Content[index] + var value *yaml.Node = node.Content[index+1] + if key.Value == "run" && value.Kind == yaml.ScalarNode { + *runBlocks = append(*runBlocks, value.Value) + } + collectYAMLRunBlocks(value, runBlocks) + } + return + } + var child *yaml.Node + for _, child = range node.Content { + collectYAMLRunBlocks(child, runBlocks) + } +} + +func commandFields(fields []string, root *cobra.Command, rootFlags, valueFlags map[string]bool) ([]string, error) { + for len(fields) > 0 && strings.HasPrefix(fields[0], "-") { + field := fields[0] + flagName, flag, value, hasInlineValue := rootFlag(root, field) + if flag == nil || !rootFlags[flagName] { + return nil, fmt.Errorf("unsupported root flag %q", flagName) + } + fields = fields[1:] + if valueFlags[flagName] && !hasInlineValue { + if len(fields) == 0 { + return nil, fmt.Errorf("flag %q requires a value", flagName) + } + value = fields[0] + fields = fields[1:] + } + if err := validateKnownFlagValue(flag, value); err != nil { + return nil, err + } + } + return fields, nil +} + +func rootFlag(root *cobra.Command, field string) (string, *pflag.Flag, string, bool) { + if strings.HasPrefix(field, "--") { + nameValue := strings.TrimPrefix(field, "--") + name, value, hasInlineValue := strings.Cut(nameValue, "=") + return "--" + name, lookupFlag(root, name), value, hasInlineValue + } + shorthandValue := strings.TrimPrefix(field, "-") + if shorthandValue == "" { + return field, nil, "", false + } + shorthand := shorthandValue[:1] + value := strings.TrimPrefix(shorthandValue[1:], "=") + return "-" + shorthand, lookupShorthandFlag(root, shorthand), value, len(shorthandValue) > 1 +} + +func rootFlagSets(root *cobra.Command) (map[string]bool, map[string]bool) { + rootFlags := map[string]bool{} + valueFlags := map[string]bool{} + root.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + longName := "--" + flag.Name + rootFlags[longName] = true + if flag.NoOptDefVal == "" { + valueFlags[longName] = true + } + if flag.Shorthand != "" { + shortName := "-" + flag.Shorthand + rootFlags[shortName] = true + if flag.NoOptDefVal == "" { + valueFlags[shortName] = true + } + } + }) + return rootFlags, valueFlags +} + +func commandHelp(binary string, path []string) (string, error) { + var args []string = append(append([]string{}, path...), "--help") + var output []byte + var err error + output, err = exec.Command(binary, args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("a6 %s --help failed: %w\n%s", strings.Join(path, " "), err, output) + } + return string(output), nil +} + +func availableCommands(help string) map[string]bool { + var commands map[string]bool = map[string]bool{} + var inCommands bool + var lines []string = strings.Split(help, "\n") + var line string + for _, line = range lines { + var heading string = strings.TrimSpace(line) + if heading == "Available Commands:" || heading == "Additional Commands:" { + inCommands = true + continue + } + if !inCommands { + continue + } + if heading == "" { + break + } + var fields []string = strings.Fields(line) + if len(fields) > 0 { + commands[fields[0]] = true + } + } + return commands +} + +func resolveCommand(binary string, fields []string, commands, rootFlags, valueFlags map[string]bool) ([]string, string, []string, error) { + if len(fields) == 0 || !commands[fields[0]] { + return nil, "", nil, fmt.Errorf("unsupported a6 command %q", strings.Join(fields, " ")) + } + var path []string = []string{fields[0]} + var help string + var err error + help, err = commandHelp(binary, path) + if err != nil { + return nil, "", nil, err + } + var index int = 1 + for index < len(fields) { + var field string = fields[index] + if strings.ContainsAny(field, "|<>") { + break + } + var subcommands map[string]bool = availableCommands(help) + if len(subcommands) == 0 { + break + } + if strings.HasPrefix(field, "-") { + flag := strings.SplitN(field, "=", 2)[0] + hasInlineValue := strings.Contains(field, "=") + if strings.HasPrefix(flag, "-") && !strings.HasPrefix(flag, "--") && len(flag) > 2 { + flag = flag[:2] + hasInlineValue = true + } + if !rootFlags[flag] { + return path, help, nil, fmt.Errorf("unsupported interspersed flag %q before a6 subcommand", flag) + } + index++ + if valueFlags[flag] && !hasInlineValue { + if index >= len(fields) { + return path, help, nil, fmt.Errorf("flag %q requires a value", flag) + } + index++ + } + continue + } + if !subcommands[field] { + return path, help, nil, fmt.Errorf("unsupported nested command %q after %q", field, strings.Join(path, " ")) + } + path = append(path, field) + help, err = commandHelp(binary, path) + if err != nil { + return nil, "", nil, err + } + index++ + } + return path, help, fields[index:], nil +} + +func newA6CommandTree(t *testing.T) *cobra.Command { + t.Helper() + ios, _, _, _ := iostreams.Test() + cfg := config.NewFileConfigWithPath(filepath.Join(t.TempDir(), "config.yaml")) + factory := &cmd.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return http.DefaultClient, nil + }, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + return rootcmd.NewCmdRoot(factory) +} + +func validatePositionalArgs(root *cobra.Command, path, fields []string) error { + command, remainingPath, err := root.Find(path) + if err != nil { + return err + } + if len(remainingPath) != 0 { + return fmt.Errorf("failed to resolve command path %q", strings.Join(path, " ")) + } + args, err := positionalArgs(command, fields) + if err != nil { + return err + } + return command.ValidateArgs(args) +} + +func positionalArgs(command *cobra.Command, fields []string) ([]string, error) { + var args []string + for index := 0; index < len(fields); index++ { + field := fields[index] + if strings.ContainsAny(field, "|<>") { + break + } + if field == "--" { + for _, arg := range fields[index+1:] { + if strings.ContainsAny(arg, "|<>") { + break + } + args = append(args, arg) + } + break + } + if strings.HasPrefix(field, "--") { + nameValue := strings.TrimPrefix(field, "--") + name, value, hasInlineValue := strings.Cut(nameValue, "=") + flag := lookupFlag(command, name) + if flag == nil { + return nil, fmt.Errorf("unsupported flag %q", field) + } + if !hasInlineValue && flag.NoOptDefVal == "" { + index++ + if index >= len(fields) { + return nil, fmt.Errorf("flag %q requires a value", field) + } + value = fields[index] + } + if err := validateKnownFlagValue(flag, value); err != nil { + return nil, err + } + continue + } + if strings.HasPrefix(field, "-") && field != "-" { + shorthandValue := strings.TrimPrefix(field, "-") + shorthand := shorthandValue[:1] + flag := lookupShorthandFlag(command, shorthand) + if flag == nil { + return nil, fmt.Errorf("unsupported shorthand flag %q", field) + } + hasInlineValue := len(shorthandValue) > 1 + value := strings.TrimPrefix(shorthandValue[1:], "=") + if !hasInlineValue && flag.NoOptDefVal == "" { + index++ + if index >= len(fields) { + return nil, fmt.Errorf("flag %q requires a value", field) + } + value = fields[index] + } + if err := validateKnownFlagValue(flag, value); err != nil { + return nil, err + } + continue + } + args = append(args, field) + } + return args, nil +} + +func validateKnownFlagValue(flag *pflag.Flag, value string) error { + if flag == nil || flag.Name != "output" || value == "" || value == "workflow-expression" || strings.HasPrefix(value, "$") || strings.HasPrefix(value, "<") { + return nil + } + _, formats, ok := strings.Cut(flag.Usage, ":") + if !ok { + return nil + } + for _, format := range strings.Split(formats, ",") { + if value == strings.TrimSpace(format) { + return nil + } + } + return fmt.Errorf("unsupported output format %q", value) +} + +func lookupFlag(command *cobra.Command, name string) *pflag.Flag { + if flag := command.Flags().Lookup(name); flag != nil { + return flag + } + if flag := command.InheritedFlags().Lookup(name); flag != nil { + return flag + } + return command.Root().PersistentFlags().Lookup(name) +} + +func lookupShorthandFlag(command *cobra.Command, shorthand string) *pflag.Flag { + if flag := command.Flags().ShorthandLookup(shorthand); flag != nil { + return flag + } + if flag := command.InheritedFlags().ShorthandLookup(shorthand); flag != nil { + return flag + } + return command.Root().PersistentFlags().ShorthandLookup(shorthand) +} + +func shellFields(line string) ([]string, error) { + var fields []string + var current strings.Builder + var quote rune + var escaped bool + var started bool + for _, char := range line { + if escaped { + current.WriteRune(char) + escaped = false + started = true + continue + } + if quote != 0 { + if char == quote { + quote = 0 + continue + } + if quote == '"' && char == '\\' { + escaped = true + continue + } + current.WriteRune(char) + started = true + continue + } + switch { + case char == '\\': + escaped = true + started = true + case char == '\'' || char == '"': + quote = char + started = true + case unicode.IsSpace(char): + if started { + fields = append(fields, current.String()) + current.Reset() + started = false + } + default: + current.WriteRune(char) + started = true + } + } + if escaped { + return nil, fmt.Errorf("unfinished escape") + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quote %q", string(quote)) + } + if started { + fields = append(fields, current.String()) + } + return fields, nil +} + +func cliInvocations(line string, invocationPattern *regexp.Regexp) []string { + var invocations []string + var matches [][]int = invocationPattern.FindAllStringSubmatchIndex(line, -1) + var match []int + for _, match = range matches { + if len(match) >= 4 { + var start int = match[2] + var end int = shellInvocationEnd(line, match[3]) + invocations = append(invocations, strings.TrimSpace(line[start:end])) + } + } + return invocations +} + +func shellInvocationEnd(line string, start int) int { + var quote byte + var escaped bool + var substitutionDepth int + var index int + for index = start; index < len(line); index++ { + var current byte = line[index] + if escaped { + escaped = false + continue + } + if quote != '\'' && current == '\\' { + escaped = true + continue + } + if quote == '\'' { + if current == '\'' { + quote = 0 + } + continue + } + if quote == '"' { + if current == '"' { + quote = 0 + continue + } + if current == '$' && index+1 < len(line) && line[index+1] == '(' { + substitutionDepth++ + index++ + continue + } + if current == ')' && substitutionDepth > 0 { + substitutionDepth-- + } + continue + } + if quote == '`' { + if current == '`' { + quote = 0 + } + continue + } + + switch current { + case '\'', '"', '`': + quote = current + case '$': + if index+1 < len(line) && line[index+1] == '(' { + substitutionDepth++ + index++ + } + case ')': + if substitutionDepth == 0 { + return index + } + substitutionDepth-- + case '|', ';', '&': + if substitutionDepth == 0 { + return index + } + } + } + return len(line) +} + +func joinedShellLines(block string) []string { + var commands []string + var current string + var raw string + for _, raw = range strings.Split(block, "\n") { + var line string = strings.TrimSpace(raw) + if current == "" && (line == "" || strings.HasPrefix(line, "#")) { + continue + } + current += " " + strings.TrimSuffix(line, "\\") + if strings.HasSuffix(line, "\\") { + continue + } + commands = append(commands, strings.TrimSpace(current)) + current = "" + } + if strings.TrimSpace(current) != "" { + commands = append(commands, strings.TrimSpace(current)) + } + return commands +}