From 6c4c867f9e9c3cd39b15d94bd3f8d87b508af54d Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Mon, 11 May 2026 16:28:53 +0800 Subject: [PATCH 1/3] remove unsupported API7 resources --- AGENTS.md | 10 +- PRD.md | 16 +- README.md | 7 +- docs/adr/001-tech-stack.md | 4 +- docs/api7ee-api-spec.md | 14 +- docs/documentation-maintenance.md | 5 +- docs/roadmap.md | 4 +- docs/skills.md | 10 +- docs/user-guide/bulk-operations.md | 2 +- docs/user-guide/consumer.md | 1 - docs/user-guide/declarative-config.md | 5 +- docs/user-guide/proto.md | 4 +- docs/user-guide/route.md | 4 +- docs/user-guide/service-template.md | 125 ---------- docs/user-guide/service.md | 11 +- docs/user-guide/stream-route.md | 11 +- docs/user-guide/upstream.md | 6 +- pkg/api/types_service_template.go | 16 -- pkg/api/types_stream_route.go | 1 + pkg/cmd/config/configutil/configutil.go | 34 +-- pkg/cmd/config/diff/diff_test.go | 2 - pkg/cmd/config/dump/dump_test.go | 2 - pkg/cmd/config/sync/sync.go | 8 - pkg/cmd/config/sync/sync_test.go | 7 +- pkg/cmd/config/validate/validate.go | 24 +- pkg/cmd/config/validate/validate_test.go | 76 +++++- pkg/cmd/consumer-group/consumer_group.go | 30 --- pkg/cmd/consumer-group/create/create.go | 141 ----------- pkg/cmd/consumer-group/create/create_test.go | 85 ------- pkg/cmd/consumer-group/delete/delete.go | 83 ------ pkg/cmd/consumer-group/delete/delete_test.go | 78 ------ pkg/cmd/consumer-group/export/export.go | 147 ----------- pkg/cmd/consumer-group/export/export_test.go | 78 ------ pkg/cmd/consumer-group/get/get.go | 85 ------- pkg/cmd/consumer-group/get/get_test.go | 101 -------- pkg/cmd/consumer-group/list/list.go | 102 -------- pkg/cmd/consumer-group/list/list_test.go | 112 --------- pkg/cmd/consumer-group/update/update.go | 130 ---------- pkg/cmd/consumer-group/update/update_test.go | 85 ------- pkg/cmd/root/root.go | 8 +- pkg/cmd/route/create/create.go | 36 +-- pkg/cmd/route/create/create_test.go | 21 +- pkg/cmd/route/update/update.go | 5 - pkg/cmd/service-template/create/create.go | 141 ----------- pkg/cmd/service-template/delete/delete.go | 73 ------ pkg/cmd/service-template/get/get.go | 69 ----- pkg/cmd/service-template/list/list.go | 92 ------- pkg/cmd/service-template/publish/publish.go | 108 -------- pkg/cmd/service-template/service_template.go | 30 --- pkg/cmd/service-template/update/update.go | 127 ---------- pkg/cmd/service/create/create.go | 15 +- pkg/cmd/service/create/create_test.go | 1 - pkg/cmd/service/update/update.go | 13 +- pkg/cmd/service/update/update_test.go | 1 - pkg/cmd/stream-route/create/create.go | 10 +- pkg/cmd/stream-route/create/create_test.go | 16 +- pkg/cmd/stream-route/update/update.go | 10 +- pkg/cmd/stream-route/update/update_test.go | 30 +-- pkg/cmd/upstream/create/create.go | 169 ------------- pkg/cmd/upstream/create/create_test.go | 57 ----- pkg/cmd/upstream/delete/delete.go | 84 ------- pkg/cmd/upstream/delete/delete_test.go | 55 ---- pkg/cmd/upstream/export/export.go | 147 ----------- pkg/cmd/upstream/export/export_test.go | 78 ------ pkg/cmd/upstream/get/get.go | 90 ------- pkg/cmd/upstream/get/get_test.go | 55 ---- pkg/cmd/upstream/list/list.go | 123 --------- pkg/cmd/upstream/list/list_test.go | 55 ---- pkg/cmd/upstream/update/update.go | 163 ------------ pkg/cmd/upstream/update/update_test.go | 57 ----- pkg/cmd/upstream/upstream.go | 30 --- skills/a7-persona-developer/SKILL.md | 51 ++-- .../a7-plugin-ai-content-moderation/SKILL.md | 10 +- skills/a7-plugin-ai-prompt-decorator/SKILL.md | 10 +- skills/a7-plugin-ai-prompt-template/SKILL.md | 10 +- skills/a7-plugin-ai-proxy/SKILL.md | 12 +- skills/a7-plugin-basic-auth/SKILL.md | 16 +- .../a7-plugin-consumer-restriction/SKILL.md | 64 +++-- skills/a7-plugin-cors/SKILL.md | 20 +- skills/a7-plugin-datadog/SKILL.md | 11 +- skills/a7-plugin-ext-plugin/SKILL.md | 13 +- skills/a7-plugin-fault-injection/SKILL.md | 21 +- skills/a7-plugin-grpc-transcode/SKILL.md | 18 +- skills/a7-plugin-hmac-auth/SKILL.md | 16 +- skills/a7-plugin-http-logger/SKILL.md | 11 +- skills/a7-plugin-ip-restriction/SKILL.md | 20 +- skills/a7-plugin-jwt-auth/SKILL.md | 16 +- skills/a7-plugin-kafka-logger/SKILL.md | 11 +- skills/a7-plugin-key-auth/SKILL.md | 16 +- skills/a7-plugin-limit-count/SKILL.md | 16 +- skills/a7-plugin-limit-req/SKILL.md | 20 +- skills/a7-plugin-openid-connect/SKILL.md | 20 +- skills/a7-plugin-prometheus/SKILL.md | 11 +- skills/a7-plugin-proxy-rewrite/SKILL.md | 24 +- skills/a7-plugin-response-rewrite/SKILL.md | 24 +- skills/a7-plugin-serverless/SKILL.md | 23 +- skills/a7-plugin-skywalking/SKILL.md | 16 +- skills/a7-plugin-traffic-split/SKILL.md | 66 +++-- skills/a7-plugin-wolf-rbac/SKILL.md | 14 +- skills/a7-plugin-zipkin/SKILL.md | 16 +- skills/a7-recipe-api-versioning/SKILL.md | 4 +- skills/a7-recipe-blue-green/SKILL.md | 2 +- skills/a7-recipe-canary/SKILL.md | 12 +- skills/a7-recipe-multi-tenant/SKILL.md | 67 ++--- skills/a7-shared/SKILL.md | 13 +- test/e2e/completion_version_test.go | 11 +- test/e2e/config_test.go | 75 +++++- test/e2e/local_stability_ginkgo_test.go | 2 +- test/e2e/service_template_test.go | 236 ------------------ test/e2e/skills/skills_test.go | 6 + 110 files changed, 610 insertions(+), 4049 deletions(-) delete mode 100644 docs/user-guide/service-template.md delete mode 100644 pkg/api/types_service_template.go delete mode 100644 pkg/cmd/consumer-group/consumer_group.go delete mode 100644 pkg/cmd/consumer-group/create/create.go delete mode 100644 pkg/cmd/consumer-group/create/create_test.go delete mode 100644 pkg/cmd/consumer-group/delete/delete.go delete mode 100644 pkg/cmd/consumer-group/delete/delete_test.go delete mode 100644 pkg/cmd/consumer-group/export/export.go delete mode 100644 pkg/cmd/consumer-group/export/export_test.go delete mode 100644 pkg/cmd/consumer-group/get/get.go delete mode 100644 pkg/cmd/consumer-group/get/get_test.go delete mode 100644 pkg/cmd/consumer-group/list/list.go delete mode 100644 pkg/cmd/consumer-group/list/list_test.go delete mode 100644 pkg/cmd/consumer-group/update/update.go delete mode 100644 pkg/cmd/consumer-group/update/update_test.go delete mode 100644 pkg/cmd/service-template/create/create.go delete mode 100644 pkg/cmd/service-template/delete/delete.go delete mode 100644 pkg/cmd/service-template/get/get.go delete mode 100644 pkg/cmd/service-template/list/list.go delete mode 100644 pkg/cmd/service-template/publish/publish.go delete mode 100644 pkg/cmd/service-template/service_template.go delete mode 100644 pkg/cmd/service-template/update/update.go delete mode 100644 pkg/cmd/upstream/create/create.go delete mode 100644 pkg/cmd/upstream/create/create_test.go delete mode 100644 pkg/cmd/upstream/delete/delete.go delete mode 100644 pkg/cmd/upstream/delete/delete_test.go delete mode 100644 pkg/cmd/upstream/export/export.go delete mode 100644 pkg/cmd/upstream/export/export_test.go delete mode 100644 pkg/cmd/upstream/get/get.go delete mode 100644 pkg/cmd/upstream/get/get_test.go delete mode 100644 pkg/cmd/upstream/list/list.go delete mode 100644 pkg/cmd/upstream/list/list_test.go delete mode 100644 pkg/cmd/upstream/update/update.go delete mode 100644 pkg/cmd/upstream/update/update_test.go delete mode 100644 pkg/cmd/upstream/upstream.go delete mode 100644 test/e2e/service_template_test.go diff --git a/AGENTS.md b/AGENTS.md index 6f0ef22..a45afba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,6 @@ a7/ │ ├── root/root.go # Root command registration │ ├── factory.go # Factory DI container │ ├── route/ # Published route commands -│ ├── upstream/ # ⚠️ NOT EXPOSED in API7 EE — upstreams are inline-only (code kept for potential future support) │ ├── service/ # Published service commands │ ├── consumer/ # Consumer commands │ ├── ssl/ # SSL certificate commands @@ -45,7 +44,6 @@ a7/ │ ├── secret-provider/ # Secret provider commands │ ├── plugin-metadata/ # Plugin metadata commands │ ├── credential/ # Consumer credential commands -│ ├── service-template/ # Service template commands (EE) │ ├── route-template/ # Route template commands (EE) │ ├── gateway-group/ # Gateway group commands (EE) │ ├── service-registry/ # Service registry commands (EE) @@ -70,7 +68,6 @@ a7/ │ ├── types_consumer.go # Consumer types │ ├── types_ssl.go # SSL types │ ├── types_gateway_group.go # Gateway group types (EE) -│ ├── types_service_template.go # Service template types (EE) │ ├── types_token.go # Token types (EE) │ └── ... # One types file per resource ├── pkg/iostreams/ # I/O abstraction (TTY detection) @@ -100,19 +97,18 @@ a7/ 6. **Dual API Prefix**: Control-plane resources use `/api/*`, runtime resources use `/apisix/admin/*`. The client handles prefix selection transparently. ### API7 EE vs APISIX Differences (Key) -- API7 EE uses `/api/services/template` for design-time services, `/apisix/admin/services` for published (runtime) services. +- API7 EE uses `/apisix/admin/services` for runtime services. Upstreams are modeled inline on services and routes. - Gateway groups scope all operations. Many endpoints require `gateway_group_id` as a query parameter. - Auth tokens use `a7ee` prefix (access token). - PATCH endpoints use JSON Patch (RFC 6902) arrays, not merge-patch. -- Enterprise-specific resources: gateway groups, service templates, RBAC (users/roles/policies), developer portal, audit logs, custom plugins, service registries, tokens. +- Enterprise-specific resources: gateway groups, RBAC (users/roles/policies), developer portal, audit logs, custom plugins, service registries, tokens. ### Resources NOT Exposed in API7 EE The following APISIX resources do **not** have REST API endpoints in API7 Enterprise Edition: - **Standalone Upstream** (`/apisix/admin/upstreams`): Returns "resource not found". Upstreams exist only inline within services and routes. - **Consumer Group** (`/apisix/admin/consumer_groups`): No endpoint exposed. - **Plugin Config** (`/apisix/admin/plugin_configs`): No endpoint exposed. - -The `pkg/cmd/upstream/` code directory is kept for potential future support, but these commands are non-functional against API7 EE. +- **Service Template** (`/api/services/template`): Removed from current a7 support; use runtime services directly. ### How to Add a New Command 1. Read `PRD.md` for the resource spec and `docs/api7ee-api-spec.md` for the API. diff --git a/PRD.md b/PRD.md index 8893c7c..c050eae 100644 --- a/PRD.md +++ b/PRD.md @@ -92,8 +92,8 @@ a7 [args] [flags] ### Resource Commands — Control Plane (`/api/*`) #### Service Templates (design-time) -- `a7 service-template list|get|create|update|patch|delete` -- `a7 service-template publish --gateway-group ` (publish to gateway group) +Service templates are not part of current a7 support. Manage runtime services +directly and define upstreams inline on services or routes. #### Gateway Groups - `a7 gateway-group list|get|create|update|delete` @@ -128,7 +128,7 @@ All runtime commands require `--gateway-group ` (or default from context). - `a7 service list|get|create|update|delete|export --gateway-group ` #### Upstreams -> **⚠️ NOT SUPPORTED**: Standalone upstreams are not exposed via the API7 EE Admin API. Upstreams exist only as inline objects within services and routes. The `a7 upstream` commands exist for APISIX compatibility but will not work against API7 EE. +> **⚠️ NOT SUPPORTED**: Standalone upstreams are not exposed via the API7 EE Admin API. Upstreams exist only as inline objects within services and routes. #### Consumers - `a7 consumer list|get|create|update|delete|export --gateway-group ` @@ -137,7 +137,7 @@ All runtime commands require `--gateway-group ` (or default from context). - `a7 credential list|get|create|update|delete --consumer --gateway-group ` #### Consumer Groups -> **⚠️ NOT SUPPORTED**: Consumer groups are not exposed via the API7 EE Admin API. The `a7 consumer-group` commands exist for APISIX compatibility but will not work against API7 EE. +> **⚠️ NOT SUPPORTED**: Consumer groups are not exposed via the API7 EE Admin API, so a7 does not expose consumer group commands. #### SSL Certificates - `a7 ssl list|get|create|update|delete|export --gateway-group ` @@ -207,7 +207,7 @@ All runtime commands require `--gateway-group ` (or default from context). ### Phase 3 — CLI Usability ✅ COMPLETE 1. ✅ `-f/--file` flag: file-based create/update for all resource commands. -2. ✅ `export` subcommand for all applicable resources (route, service, upstream, consumer, consumer-group, ssl, global-rule, stream-route, plugin-config, proto). +2. ✅ `export` subcommand for all applicable resources (route, service, consumer, ssl, global-rule, stream-route, plugin-config, proto). 3. ✅ `--force` flag for delete commands (skip confirmation). 4. ✅ `--label` flag for list/export commands (label-based filtering). 5. 🔲 `--dry-run` flag for create/update commands. @@ -229,7 +229,7 @@ All runtime commands require `--gateway-group ` (or default from context). 6. ✅ `docs/documentation-maintenance.md` — Doc update rules and templates. 7. ✅ `docs/roadmap.md` — Per-PR development plan for Phases 5-9. 8. ✅ `docs/api7ee-api-spec.md` — API7 EE Admin API reference (16 resources, dual-API). -9. ✅ `docs/user-guide/` — 21 per-resource user guides (getting-started, configuration, route, service, upstream, consumer, ssl, plugin, global-rule, stream-route, plugin-config, plugin-metadata, consumer-group, credential, secret, proto, declarative-config, gateway-group, service-template, debug, bulk-operations). +9. ✅ `docs/user-guide/` — per-resource user guides (getting-started, configuration, route, service, upstream, consumer, ssl, plugin, global-rule, stream-route, plugin-config, plugin-metadata, credential, secret, proto, declarative-config, gateway-group, debug, bulk-operations). ### Phase 6 — AI Agent Skills ✅ COMPLETE Port and adapt 40 SKILL.md files from a6, organized by category: @@ -255,7 +255,7 @@ Port and adapt 40 SKILL.md files from a6, organized by category: ### Phase 8 — End-to-End Tests ✅ COMPLETE 1. ✅ `test/e2e/docker-compose.yml` — Docker Compose for API7 EE (Dashboard + DP Manager + Gateway + PostgreSQL). 2. ✅ `test/e2e/setup_test.go` — TestMain, binary build, admin/control API helpers, shared test utilities. -3. ✅ Per-resource E2E tests (22 test files): route, service, upstream, consumer, ssl, plugin, global-rule, stream-route, plugin-config, plugin-metadata, consumer-group, credential, secret, proto, context, gateway-group, service-template. +3. ✅ Per-resource E2E tests: route, service, consumer, ssl, plugin, global-rule, stream-route, plugin-config, plugin-metadata, credential, secret, proto, context, gateway-group. 4. ✅ Declarative config E2E tests: dump, diff, sync, validate (config_test.go + config_sync_test.go). 5. ✅ Export and label E2E tests (integrated into resource test files). 6. ✅ Debug E2E tests: trace (JSON/method/headers/host/path) + logs (file mode). @@ -317,7 +317,7 @@ The following table tracks feature parity between a7 and [a6](https://github.com | Feature | a6 | a7 | Notes | |---------|----|----|-------| -| Resource CRUD (14 types) | ✅ | ✅ (13 functional in API7 EE) | a7 adds gateway-group, service-template; 3 APISIX resources (upstream, consumer-group, plugin-config) not exposed in API7 EE | +| Resource CRUD | ✅ | ✅ (API7 EE supported resources) | a7 adds gateway-group; standalone upstreams, consumer groups, service templates, and plugin configs are not exposed in current API7 EE | | Context management | ✅ | ✅ | | | Shell completions | ✅ | ✅ | | | JSON/YAML/table output | ✅ | ✅ | | diff --git a/README.md b/README.md index d30b679..dd96bf7 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ > **⚠️ This project is under active development and is NOT production-ready.** APIs, commands, and output formats may change without notice. -`a7` is a command-line tool for managing [API7 Enterprise Edition](https://api7.ai) API Gateway from your terminal. It wraps both the API7 EE control-plane API (`/api/*`) and the APISIX admin API (`/apisix/admin/*`) to provide convenient, scriptable access to gateway groups, routes, services, upstreams, consumers, SSL certificates, plugins, and more. +`a7` is a command-line tool for managing [API7 Enterprise Edition](https://api7.ai) API Gateway from your terminal. It wraps both the API7 EE control-plane API (`/api/*`) and the APISIX admin API (`/apisix/admin/*`) to provide convenient, scriptable access to gateway groups, routes, services, consumers, SSL certificates, plugins, and more. ## Features -- **Resource CRUD** — Create, list, get, update, and delete 13 API7 EE resource types: - - **Control Plane**: Gateway Group, Service Template +- **Resource CRUD** — Create, list, get, update, and delete current API7 EE resource types: + - **Control Plane**: Gateway Group - **Runtime**: Route, Service, Consumer, SSL Certificate, Plugin, Global Rule, Stream Route, Plugin Metadata, Credential, Secret, Proto - **Context management** — Switch between multiple API7 EE instances (`a7 context create`, `a7 context use`, `a7 context list`) - **Gateway group scoping** — All runtime operations are scoped to a gateway group via `--gateway-group` flag or context config @@ -131,7 +131,6 @@ a7 service delete -g default --force | Command | Alias | Actions | Description | |---------|-------|---------|-------------| | `a7 gateway-group` | `gg` | list, get, create, update, delete | Manage gateway groups | -| `a7 service-template` | `st` | list, get, create, update, delete, publish | Manage service templates | ### Runtime Resources diff --git a/docs/adr/001-tech-stack.md b/docs/adr/001-tech-stack.md index 8182ba7..6d4bf3f 100644 --- a/docs/adr/001-tech-stack.md +++ b/docs/adr/001-tech-stack.md @@ -42,8 +42,6 @@ a7/ │ │ │ ├── delete/delete.go │ │ │ └── shared/display.go # Shared display logic for route resources │ │ ├── gateway-group/ # Gateway group commands (control-plane) -│ │ ├── service-template/ # Service template commands (control-plane) -│ │ ├── upstream/ # Same pattern as route/ │ │ ├── service/ │ │ ├── consumer/ │ │ ├── ssl/ @@ -151,4 +149,4 @@ Each command is structured into four distinct parts: - **Opinionated Frameworks**: Cobra and Viper impose specific patterns on flag and config handling. - **Initial Setup**: The Factory pattern requires more boilerplate initially, but it significantly simplifies testing. - **Modularity**: The directory-per-command approach keeps the codebase organized as it grows to support the 16+ enterprise resource types. -- **Enterprise Complexity**: Handling dual API prefixes and gateway group scoping adds internal complexity but provides a seamless user experience. \ No newline at end of file +- **Enterprise Complexity**: Handling dual API prefixes and gateway group scoping adds internal complexity but provides a seamless user experience. diff --git a/docs/api7ee-api-spec.md b/docs/api7ee-api-spec.md index 0409438..f302b41 100644 --- a/docs/api7ee-api-spec.md +++ b/docs/api7ee-api-spec.md @@ -76,9 +76,9 @@ API7 EE uses **JSON Patch** (RFC 6902) for `PATCH` operations. The request body Manage logical groupings of gateway instances. - **Methods**: `GET` (list), `GET /:id`, `POST` (create), `PUT /:id` (update), `DELETE /:id` -### 2. Service Template (/api/services/template) -Design-time service definitions used to generate runtime services. -- **Methods**: `GET` (list), `GET /:id`, `POST`, `PUT /:id`, `DELETE /:id` +### 2. Service Template +> **Removed from current a7 support.** Current API7 EE workflows should manage +> runtime services directly and define upstreams inline on services. ## Runtime Resources (/apisix/admin) @@ -86,14 +86,14 @@ All runtime resources require `gateway_group_id` query parameter. ### 1. Route (/apisix/admin/routes) - **Methods**: `GET` (list), `GET /:id`, `POST` (create), `PUT /:id` (create/update), `PATCH /:id`, `DELETE /:id` -- **Fields**: `id`, `name`, `uris`, `methods`, `host`, `hosts`, `plugins`, `upstream_id`, `service_id`, `status`. +- **Fields**: `id`, `name`, `paths`, `methods`, `host`, `hosts`, `plugins`, `service_id`, `status`. ### 2. Upstream (/apisix/admin/upstreams) > **⚠️ NOT EXPOSED in API7 EE.** Standalone upstreams do not have REST API endpoints. Upstreams exist only as inline objects within services and routes. This endpoint returns "resource not found". ### 3. Service (/apisix/admin/services) - **Methods**: `GET`, `GET /:id`, `POST`, `PUT /:id`, `PATCH /:id`, `DELETE /:id` -- **Fields**: `id`, `name`, `upstream_id`, `plugins`. +- **Fields**: `id`, `name`, `upstream`, `plugins`. ### 4. Consumer (/apisix/admin/consumers) - **Methods**: `GET`, `GET /:username`, `PUT` (idempotent create/update), `DELETE /:username` @@ -137,7 +137,7 @@ All runtime resources require `gateway_group_id` query parameter. | Resource | Prefix | Identifier | Scope | Status | |----------|--------|------------|-------|--------| | Gateway Group | `/api` | `id` | Global | ✅ | -| Service Template | `/api` | `id` | Global | ✅ | +| Service Template | `/api` | `id` | Global | ⚠️ Removed from a7 | | Route | `/apisix/admin` | `id` | Gateway Group | ✅ | | Upstream | `/apisix/admin` | `id` | Gateway Group | ⚠️ Not exposed | | Service | `/apisix/admin` | `id` | Gateway Group | ✅ | @@ -151,4 +151,4 @@ All runtime resources require `gateway_group_id` query parameter. | Credential | `/apisix/admin` | `id` | Consumer | ✅ | | Secret | `/apisix/admin` | `manager/id`| Gateway Group | ✅ | | Proto | `/apisix/admin` | `id` | Gateway Group | ✅ | -| Plugin | `/apisix/admin` | `name` | Gateway Group | ✅ | \ No newline at end of file +| Plugin | `/apisix/admin` | `name` | Gateway Group | ✅ | diff --git a/docs/documentation-maintenance.md b/docs/documentation-maintenance.md index d0736be..d31ddac 100644 --- a/docs/documentation-maintenance.md +++ b/docs/documentation-maintenance.md @@ -19,9 +19,8 @@ docs/ ├── getting-started.md # Installation + first commands ├── configuration.md # Context management, config file format ├── gateway-group.md # Gateway group commands (EE) - ├── service-template.md # Service template commands (EE) ├── route.md # Route command reference - ├── upstream.md # ⚠️ Deprecated — upstreams are inline-only in API7 EE + ├── upstream.md # Inline upstream configuration guide ├── service.md # Service command reference ├── consumer.md # Consumer command reference ├── ssl.md # SSL command reference @@ -121,4 +120,4 @@ Before approving any PR, verify: ### Who Updates Documentation - AI coding agents: MUST update docs as part of every feature PR - Human reviewers: MUST check docs checklist before approving -- If docs are missing from a code PR, the PR is NOT ready for merge \ No newline at end of file +- If docs are missing from a code PR, the PR is NOT ready for merge diff --git a/docs/roadmap.md b/docs/roadmap.md index 885dfe7..14296f9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,8 +30,8 @@ This document defines the per-PR development plan for the a7 CLI (API7 Enterpris **Goal**: Create 40 `SKILL.md` files enabling AI agents to operate API7 EE. ### PR-29: AI Gateway & Enterprise Skills -- **Skills**: `a7-plugin-ai-proxy`, `a7-plugin-ai-prompt-template`, `a7-plugin-ai-rag`, `a7-recipe-gateway-group`, `a7-recipe-service-template` -- **Focus**: Configuring LLM providers and enterprise-only design-time resources. +- **Skills**: `a7-plugin-ai-proxy`, `a7-plugin-ai-prompt-template`, `a7-plugin-ai-rag`, `a7-recipe-gateway-group` +- **Focus**: Configuring LLM providers and supported API7 EE runtime resources. ### PR-30: Security & Auth Skills - **Skills**: `a7-plugin-key-auth`, `a7-plugin-openid-connect`, `a7-plugin-wolf-rbac`, `a7-recipe-rbac-setup` diff --git a/docs/skills.md b/docs/skills.md index 0b23861..45f3836 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -47,7 +47,7 @@ Every skill file has two parts: YAML frontmatter and Markdown body. --- name: a7-plugin-ai-proxy description: >- - Skill for configuring AI Proxy plugin on API7 EE routes and service templates. + Skill for configuring AI Proxy plugin on API7 EE routes and services. Covers LLM provider configuration, model selection, and endpoint routing. version: "1.0.0" author: API7.ai Contributors @@ -58,7 +58,7 @@ metadata: plugin_name: ai-proxy a7_commands: - a7 route create - - a7 service-template create + - a7 service create - a7 plugin list --- ``` @@ -88,11 +88,11 @@ The body content depends on the skill type: - Configuration schema reference - **Gateway Group Scoping**: How to enable per gateway group - Example: Enabling on a Route -- Example: Enabling on a Service Template +- Example: Enabling on a Service - Enterprise-only features and limitations **Recipe skills** (EE specific): -- Enterprise workflow goal (e.g., "Create a cross-group service template") +- Enterprise workflow goal (e.g., "Create service-backed routes across gateway groups") - Prerequisites (e.g., "Existing gateway groups") - Step-by-step instructions with `a7` commands - Verification using `a7` list/get commands @@ -101,7 +101,7 @@ The body content depends on the skill type: **Persona skills**: - Role description (Platform Engineer, API Architect, App Developer) - Common enterprise workflows -- Decision trees for resource selection (e.g., "Route vs Service Template") +- Decision trees for resource selection (e.g., "Route vs Service") - Which other skills to load for specific tasks ## CI Validation diff --git a/docs/user-guide/bulk-operations.md b/docs/user-guide/bulk-operations.md index ec4bf05..f6599d7 100644 --- a/docs/user-guide/bulk-operations.md +++ b/docs/user-guide/bulk-operations.md @@ -34,7 +34,7 @@ Each resource will have an `export` command to dump multiple resources to a file ```bash # Planned -a7 upstream export -g default +a7 service export -g default ``` #### Export by Label diff --git a/docs/user-guide/consumer.md b/docs/user-guide/consumer.md index 87e270f..7617f89 100644 --- a/docs/user-guide/consumer.md +++ b/docs/user-guide/consumer.md @@ -138,7 +138,6 @@ Key fields in the consumer configuration (sent to `/apisix/admin/consumers`): | `username` | string | Unique identifier for the consumer | | `desc` | string | Human-readable description | | `plugins` | object | Plugin configurations (e.g., key-auth, jwt-auth) | -| `group_id` | string | Reference to a consumer group | | `labels` | object | Key-value labels for the consumer | ## Examples diff --git a/docs/user-guide/declarative-config.md b/docs/user-guide/declarative-config.md index 040e6f4..85e37fb 100644 --- a/docs/user-guide/declarative-config.md +++ b/docs/user-guide/declarative-config.md @@ -19,12 +19,9 @@ A7 supports the following resource types in its declarative configuration: - `protos` - `secrets` - `plugin_metadata` -- `gateway_groups` (Control Plane resource) -- `service_templates` (Control Plane resource) - `credentials` (Nested under consumers) -- `canary_release` -> **Note:** `upstreams`, `plugin_configs`, and `consumer_groups` are not exposed via the API7 EE Admin API and are excluded from declarative config operations. +> **Note:** `upstreams`, `consumer_groups`, and `service_templates` are not supported as top-level API7 EE declarative resources. Define upstreams inline under `services` instead. ### Structure with Gateway Groups diff --git a/docs/user-guide/proto.md b/docs/user-guide/proto.md index 3931a0d..4788264 100644 --- a/docs/user-guide/proto.md +++ b/docs/user-guide/proto.md @@ -147,9 +147,7 @@ Proto resources are commonly referenced by the `grpc-transcode` plugin. Once def }, "upstream": { "scheme": "grpc", - "nodes": { - "127.0.0.1:50051": 1 - } + "nodes": [{"host": "127.0.0.1", "port": 50051, "weight": 1}] } } ``` diff --git a/docs/user-guide/route.md b/docs/user-guide/route.md index 25233da..d8f636f 100644 --- a/docs/user-guide/route.md +++ b/docs/user-guide/route.md @@ -151,9 +151,7 @@ Key fields in the route configuration (sent to `/apisix/admin/routes`): | `paths` | array | Path patterns to match | | `uri` | string | Legacy URI pattern accepted by some APISIX-compatible payloads | | `methods` | array | HTTP methods allowed (e.g., ["GET", "POST"]) | -| `upstream` | object | Inline upstream configuration | -| `service_id` | string | Reference to a service that owns the upstream configuration | -| `upstream_id` | string | Reference to a standalone upstream; distinct from `service_id` and still supported by the CLI/API | +| `service_id` | string | Required by current API7 EE; reference to the service that owns the upstream configuration | | `status` | integer | Route status (1 for enabled, 0 for disabled) | | `plugins` | object | Plugin configurations for the route | | `labels` | object | Key-value pairs for filtering and organization | diff --git a/docs/user-guide/service-template.md b/docs/user-guide/service-template.md deleted file mode 100644 index 3708c30..0000000 --- a/docs/user-guide/service-template.md +++ /dev/null @@ -1,125 +0,0 @@ -# Service Template - -The `a7 service-template` command manages API7 Enterprise Edition (EE) service templates. Service templates are design-time service definitions stored on the Control Plane. They can be "published" to one or more gateway groups to create runtime services. - -## Design-time vs Runtime - -- **Service Template**: A Control Plane resource (`/api/services/template`) where you define the service's structure, plugins, and configuration. -- **Service**: A runtime resource (`/apisix/admin/services`) that exists within a specific gateway group and actually handles traffic. - -## Commands - -### `a7 service-template list` - -Lists all service templates on the Control Plane. - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--output` | `-o` | `table` | Output format (`table`, `json`, `yaml`) | - -**Examples:** - -```bash -a7 service-template list -``` - -### `a7 service-template get` - -Gets detailed information about a specific service template. - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--output` | `-o` | `yaml` | Output format (`json`, `yaml`) | - -**Examples:** - -```bash -a7 service-template get st-123 -``` - -### `a7 service-template create` - -Creates a new service template from a file or flags. - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--name` | `-n` | | Template name (required) | -| `--file` | `-f` | | Path to configuration file | - -**Examples:** - -```bash -a7 service-template create --name "User API" -f user-service.yaml -``` - -### `a7 service-template update` - -Updates an existing service template. - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--file` | `-f` | | Path to configuration file | - -**Examples:** - -```bash -a7 service-template update st-123 -f updated-user-service.json -``` - -### `a7 service-template delete` - -Deletes a service template. - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--force` | | `false` | Skip confirmation prompt | - -**Examples:** - -```bash -a7 service-template delete st-123 --force -``` - -### `a7 service-template publish` - -Publishes a service template to one or more gateway groups. This operation creates or updates corresponding runtime services in the targeted groups. - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--gateway-group-id` | | | ID of the target gateway group (can be repeated) | - -**Examples:** - -Publish to a single gateway group: -```bash -a7 service-template publish st-123 --gateway-group-id default -``` - -Publish to multiple gateway groups: -```bash -a7 service-template publish st-123 --gateway-group-id dev --gateway-group-id staging -``` - -## Example Configuration - -```yaml -id: st-123 -name: "Order API" -desc: "Template for the ordering microservice" -upstream: - type: roundrobin - nodes: - "orders.internal:8080": 1 -plugins: - proxy-rewrite: - uri: "/v1/orders" -``` - -## Relationship with Routes - -When you create a route, you can reference a published service (which may have originated from a service template): - -```yaml -# In a route config -service_id: "order-service-runtime-id" -``` diff --git a/docs/user-guide/service.md b/docs/user-guide/service.md index fccc94f..d6b28ca 100644 --- a/docs/user-guide/service.md +++ b/docs/user-guide/service.md @@ -2,7 +2,7 @@ The `a7 service` command allows you to manage API7 Enterprise Edition (API7 EE) runtime services. You can list, create, update, get, and delete services within a specific gateway group using the CLI. -> **Note:** This command manages **runtime services** via the `/apisix/admin/services` endpoint. These are different from **service templates** which are used for control-plane design-time configurations. +> **Note:** This command manages API7 EE runtime services via the `/apisix/admin/services` endpoint. Current `a7` workflows define upstreams inline on services. > > The `--gateway-group` (or `-g`) flag is required for all service commands if not specified in your current context. @@ -73,9 +73,7 @@ a7 service create -g default -f service.json "name": "example-service", "upstream": { "type": "roundrobin", - "nodes": { - "httpbin.org:80": 1 - } + "nodes": [{"host": "httpbin.org", "port": 80, "weight": 1}] } } ``` @@ -142,7 +140,6 @@ Key fields in the service configuration (sent to `/apisix/admin/services`): | `name` | string | Human-readable name for the service | | `desc` | string | Description of the service | | `upstream` | object | Inline upstream configuration | -| `upstream_id` | string | Reference to an existing upstream ID | | `status` | integer | Service status (1 for enabled, 0 for disabled) | | `plugins` | object | Plugin configurations for the service | | `hosts` | array | List of hostnames the service handles | @@ -157,9 +154,7 @@ Key fields in the service configuration (sent to `/apisix/admin/services`): "name": "protected-service", "upstream": { "type": "roundrobin", - "nodes": { - "127.0.0.1:8080": 1 - } + "nodes": [{"host": "127.0.0.1", "port": 8080, "weight": 1}] }, "plugins": { "limit-count": { diff --git a/docs/user-guide/stream-route.md b/docs/user-guide/stream-route.md index 5f45735..2dfbf8d 100644 --- a/docs/user-guide/stream-route.md +++ b/docs/user-guide/stream-route.md @@ -65,9 +65,7 @@ a7 stream-route create -g default -f stream-route.json "server_port": 9100, "upstream": { "type": "roundrobin", - "nodes": { - "127.0.0.1:8080": 1 - } + "nodes": [{"host": "127.0.0.1", "port": 8080, "weight": 1}] } } ``` @@ -137,8 +135,7 @@ Key fields in the stream route configuration (sent to `/apisix/admin/stream_rout | `server_addr` | string | Match destination server address | | `server_port` | integer | Match destination server port | | `sni` | string | Match TLS SNI | -| `upstream` | object | Inline upstream configuration | -| `upstream_id` | string | Reference to an existing upstream ID | +| `service_id` | string | Required by current API7 EE; reference to the service that owns the upstream configuration | | `plugins` | object | Plugin configurations | | `labels` | object | Key-value pairs for filtering and organization | @@ -153,9 +150,7 @@ Key fields in the stream route configuration (sent to `/apisix/admin/stream_rout "server_port": 9443, "upstream": { "type": "roundrobin", - "nodes": { - "127.0.0.1:9000": 1 - } + "nodes": [{"host": "127.0.0.1", "port": 9000, "weight": 1}] } } ``` diff --git a/docs/user-guide/upstream.md b/docs/user-guide/upstream.md index 075f21a..c79ee15 100644 --- a/docs/user-guide/upstream.md +++ b/docs/user-guide/upstream.md @@ -1,4 +1,4 @@ -# Upstream Management +# Inline Upstream Configuration > **⚠️ Standalone upstreams are NOT exposed via the API7 Enterprise Edition Admin API.** In API7 EE, upstreams exist only as inline objects within services and routes. The `/apisix/admin/upstreams` endpoint returns "resource not found". @@ -41,9 +41,9 @@ upstream: a7 route create -g default -f route-with-upstream.yaml ``` -## CLI Commands (Not Functional in API7 EE) +## CLI Model -The `a7 upstream` commands exist in the CLI for APISIX compatibility but will return errors when used against an API7 EE instance. Use `a7 service` or `a7 route` with inline upstream configurations instead. +`a7` does not expose an `upstream` command group for current API7 EE. Use `a7 service` or `a7 route` with inline upstream configurations instead. ## See Also diff --git a/pkg/api/types_service_template.go b/pkg/api/types_service_template.go deleted file mode 100644 index 6b7fdde..0000000 --- a/pkg/api/types_service_template.go +++ /dev/null @@ -1,16 +0,0 @@ -package api - -// ServiceTemplate represents an API7 EE Service Template (design-time service). -type ServiceTemplate struct { - ID string `json:"id,omitempty" yaml:"id,omitempty"` - Name string `json:"name" yaml:"name"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` - Upstream map[string]interface{} `json:"upstream,omitempty" yaml:"upstream,omitempty"` - Plugins map[string]interface{} `json:"plugins,omitempty" yaml:"plugins,omitempty"` - Hosts []string `json:"hosts,omitempty" yaml:"hosts,omitempty"` - PathPrefix string `json:"path_prefix,omitempty" yaml:"path_prefix,omitempty"` - Status int `json:"status,omitempty" yaml:"status,omitempty"` - CreatedAt int64 `json:"created_at,omitempty" yaml:"created_at,omitempty"` - UpdatedAt int64 `json:"updated_at,omitempty" yaml:"updated_at,omitempty"` -} diff --git a/pkg/api/types_stream_route.go b/pkg/api/types_stream_route.go index 051144c..a9727b2 100644 --- a/pkg/api/types_stream_route.go +++ b/pkg/api/types_stream_route.go @@ -8,6 +8,7 @@ type StreamRoute struct { ServerAddr string `json:"server_addr,omitempty" yaml:"server_addr,omitempty"` ServerPort int `json:"server_port,omitempty" yaml:"server_port,omitempty"` SNI string `json:"sni,omitempty" yaml:"sni,omitempty"` + ServiceID string `json:"service_id,omitempty" yaml:"service_id,omitempty"` UpstreamID string `json:"upstream_id,omitempty" yaml:"upstream_id,omitempty"` Upstream map[string]interface{} `json:"upstream,omitempty" yaml:"upstream,omitempty"` Plugins map[string]interface{} `json:"plugins,omitempty" yaml:"plugins,omitempty"` diff --git a/pkg/cmd/config/configutil/configutil.go b/pkg/cmd/config/configutil/configutil.go index a8dcf0d..22efa39 100644 --- a/pkg/cmd/config/configutil/configutil.go +++ b/pkg/cmd/config/configutil/configutil.go @@ -37,12 +37,10 @@ func (d ResourceDiff) HasDifferences() bool { type DiffResult struct { Routes ResourceDiff `json:"routes"` Services ResourceDiff `json:"services"` - Upstreams ResourceDiff `json:"upstreams"` Consumers ResourceDiff `json:"consumers"` SSL ResourceDiff `json:"ssl"` GlobalRules ResourceDiff `json:"global_rules"` PluginConfigs ResourceDiff `json:"plugin_configs"` - ConsumerGroups ResourceDiff `json:"consumer_groups"` StreamRoutes ResourceDiff `json:"stream_routes"` Protos ResourceDiff `json:"protos"` Secrets ResourceDiff `json:"secrets"` @@ -73,10 +71,8 @@ func (r *DiffResult) Sections() []DiffSection { return nil } return []DiffSection{ - {Name: "upstreams", Diff: r.Upstreams}, {Name: "services", Diff: r.Services}, {Name: "consumers", Diff: r.Consumers}, - {Name: "consumer_groups", Diff: r.ConsumerGroups}, {Name: "plugin_configs", Diff: r.PluginConfigs}, {Name: "ssl", Diff: r.SSL}, {Name: "global_rules", Diff: r.GlobalRules}, @@ -128,10 +124,6 @@ func FetchRemoteConfig(client *api.Client, gatewayGroup string) (*api.ConfigFile if err != nil { return nil, err } - upstreams, err := fetchPaginated[api.Upstream](client, "/apisix/admin/upstreams", query) - if err != nil { - return nil, err - } consumers, err := fetchPaginated[api.Consumer](client, "/apisix/admin/consumers", query) if err != nil { return nil, err @@ -148,10 +140,6 @@ func FetchRemoteConfig(client *api.Client, gatewayGroup string) (*api.ConfigFile if err != nil { return nil, err } - consumerGroups, err := fetchPaginated[api.ConsumerGroup](client, "/apisix/admin/consumer_groups", query) - if err != nil { - return nil, err - } streamRoutes, err := fetchPaginated[api.StreamRoute](client, "/apisix/admin/stream_routes", query) if err != nil { return nil, err @@ -174,12 +162,10 @@ func FetchRemoteConfig(client *api.Client, gatewayGroup string) (*api.ConfigFile Version: "1", Routes: stripTimestampsFromSlice(routes), Services: stripTimestampsFromSlice(services), - Upstreams: stripTimestampsFromSlice(upstreams), Consumers: stripTimestampsFromSlice(consumers), SSL: stripTimestampsFromSlice(ssl), GlobalRules: stripTimestampsFromSlice(globalRules), PluginConfigs: stripTimestampsFromSlice(pluginConfigs), - ConsumerGroups: stripTimestampsFromSlice(consumerGroups), StreamRoutes: stripTimestampsFromSlice(streamRoutes), Protos: stripTimestampsFromSlice(protos), Secrets: stripTimestampsFromSlice(secrets), @@ -200,12 +186,10 @@ func ComputeDiff(local, remote api.ConfigFile) (*DiffResult, error) { specs := []diffSpec{ {local.Routes, remote.Routes, "id", "routes"}, {local.Services, remote.Services, "id", "services"}, - {local.Upstreams, remote.Upstreams, "id", "upstreams"}, {local.Consumers, remote.Consumers, "username", "consumers"}, {local.SSL, remote.SSL, "id", "ssl"}, {local.GlobalRules, remote.GlobalRules, "id", "global_rules"}, {local.PluginConfigs, remote.PluginConfigs, "id", "plugin_configs"}, - {local.ConsumerGroups, remote.ConsumerGroups, "id", "consumer_groups"}, {local.StreamRoutes, remote.StreamRoutes, "id", "stream_routes"}, {local.Protos, remote.Protos, "id", "protos"}, {local.Secrets, remote.Secrets, "id", "secrets"}, @@ -232,16 +216,14 @@ func ComputeDiff(local, remote api.ConfigFile) (*DiffResult, error) { return &DiffResult{ Routes: diffs[0], Services: diffs[1], - Upstreams: diffs[2], - Consumers: diffs[3], - SSL: diffs[4], - GlobalRules: diffs[5], - PluginConfigs: diffs[6], - ConsumerGroups: diffs[7], - StreamRoutes: diffs[8], - Protos: diffs[9], - Secrets: diffs[10], - PluginMetadata: diffs[11], + Consumers: diffs[2], + SSL: diffs[3], + GlobalRules: diffs[4], + PluginConfigs: diffs[5], + StreamRoutes: diffs[6], + Protos: diffs[7], + Secrets: diffs[8], + PluginMetadata: diffs[9], }, nil } diff --git a/pkg/cmd/config/diff/diff_test.go b/pkg/cmd/config/diff/diff_test.go index 5886749..8e37566 100644 --- a/pkg/cmd/config/diff/diff_test.go +++ b/pkg/cmd/config/diff/diff_test.go @@ -37,12 +37,10 @@ func (m *mockConfig) Save() error { return n func registerEmptyResources(reg *httpmock.Registry, skip map[string]bool) { resources := []string{ "/apisix/admin/services", - "/apisix/admin/upstreams", "/apisix/admin/consumers", "/apisix/admin/ssls", "/apisix/admin/global_rules", "/apisix/admin/plugin_configs", - "/apisix/admin/consumer_groups", "/apisix/admin/stream_routes", "/apisix/admin/protos", "/apisix/admin/secret_providers", diff --git a/pkg/cmd/config/dump/dump_test.go b/pkg/cmd/config/dump/dump_test.go index 3d6791d..f3af736 100644 --- a/pkg/cmd/config/dump/dump_test.go +++ b/pkg/cmd/config/dump/dump_test.go @@ -42,12 +42,10 @@ func (m *mockConfig) Save() error { return n func registerEmptyResources(reg *httpmock.Registry, skip map[string]bool) { resources := []string{ "/apisix/admin/services", - "/apisix/admin/upstreams", "/apisix/admin/consumers", "/apisix/admin/ssls", "/apisix/admin/global_rules", "/apisix/admin/plugin_configs", - "/apisix/admin/consumer_groups", "/apisix/admin/stream_routes", "/apisix/admin/protos", "/apisix/admin/secret_providers", diff --git a/pkg/cmd/config/sync/sync.go b/pkg/cmd/config/sync/sync.go index e62209d..71145df 100644 --- a/pkg/cmd/config/sync/sync.go +++ b/pkg/cmd/config/sync/sync.go @@ -206,8 +206,6 @@ func putPathAndBody(resourceType, key string, payload map[string]interface{}) (s return fmt.Sprintf("/apisix/admin/routes/%s", key), payload, nil case "services": return fmt.Sprintf("/apisix/admin/services/%s", key), payload, nil - case "upstreams": - return fmt.Sprintf("/apisix/admin/upstreams/%s", key), payload, nil case "consumers": return "/apisix/admin/consumers", payload, nil case "ssl": @@ -216,8 +214,6 @@ func putPathAndBody(resourceType, key string, payload map[string]interface{}) (s return fmt.Sprintf("/apisix/admin/global_rules/%s", key), payload, nil case "plugin_configs": return fmt.Sprintf("/apisix/admin/plugin_configs/%s", key), payload, nil - case "consumer_groups": - return fmt.Sprintf("/apisix/admin/consumer_groups/%s", key), payload, nil case "stream_routes": return fmt.Sprintf("/apisix/admin/stream_routes/%s", key), payload, nil case "protos": @@ -238,8 +234,6 @@ func deletePath(resourceType, key string) (string, error) { return fmt.Sprintf("/apisix/admin/routes/%s", key), nil case "services": return fmt.Sprintf("/apisix/admin/services/%s", key), nil - case "upstreams": - return fmt.Sprintf("/apisix/admin/upstreams/%s", key), nil case "consumers": return fmt.Sprintf("/apisix/admin/consumers/%s", key), nil case "ssl": @@ -248,8 +242,6 @@ func deletePath(resourceType, key string) (string, error) { return fmt.Sprintf("/apisix/admin/global_rules/%s", key), nil case "plugin_configs": return fmt.Sprintf("/apisix/admin/plugin_configs/%s", key), nil - case "consumer_groups": - return fmt.Sprintf("/apisix/admin/consumer_groups/%s", key), nil case "stream_routes": return fmt.Sprintf("/apisix/admin/stream_routes/%s", key), nil case "protos": diff --git a/pkg/cmd/config/sync/sync_test.go b/pkg/cmd/config/sync/sync_test.go index 1c3a6bc..b91aaa1 100644 --- a/pkg/cmd/config/sync/sync_test.go +++ b/pkg/cmd/config/sync/sync_test.go @@ -36,12 +36,10 @@ func (m *mockConfig) Save() error { return n func registerEmptyResources(reg *httpmock.Registry, skip map[string]bool) { resources := []string{ "/apisix/admin/services", - "/apisix/admin/upstreams", "/apisix/admin/consumers", "/apisix/admin/ssls", "/apisix/admin/global_rules", "/apisix/admin/plugin_configs", - "/apisix/admin/consumer_groups", "/apisix/admin/stream_routes", "/apisix/admin/protos", "/apisix/admin/secret_providers", @@ -84,6 +82,7 @@ version: "1" routes: - id: r1 uri: /sync + service_id: svc-1 `) ios, _, stdout, _ := iostreams.Test() @@ -106,7 +105,7 @@ func TestConfigSync_UpdatesExistingResources(t *testing.T) { }`)) reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{ "total":1, - "list":[{"id":"r1","uri":"/old","name":"old"}] + "list":[{"id":"r1","uri":"/old","name":"old","service_id":"svc-1"}] }`)) reg.Register(http.MethodPut, "/apisix/admin/routes/r1", httpmock.JSONResponse(`{"id":"r1"}`)) @@ -119,6 +118,7 @@ routes: - id: r1 uri: /new name: new + service_id: svc-1 `) ios, _, stdout, _ := iostreams.Test() @@ -170,6 +170,7 @@ version: "1" routes: - id: r1 uri: /sync + service_id: svc-1 `) ios, _, stdout, _ := iostreams.Test() diff --git a/pkg/cmd/config/validate/validate.go b/pkg/cmd/config/validate/validate.go index 7fedf7a..5a8becc 100644 --- a/pkg/cmd/config/validate/validate.go +++ b/pkg/cmd/config/validate/validate.go @@ -97,11 +97,21 @@ func ValidateConfigFile(cfg api.ConfigFile) []string { errs = append(errs, "version must be \"1\"") } + if len(cfg.Upstreams) > 0 { + errs = append(errs, "upstreams are not supported as top-level API7 EE resources; define upstream inline on services instead") + } + if len(cfg.ConsumerGroups) > 0 { + errs = append(errs, "consumer_groups are not supported by current API7 EE") + } + seenRouteIDs := map[string]struct{}{} for i, r := range cfg.Routes { if !hasRouteURI(r) { errs = append(errs, fmt.Sprintf("routes[%d]: either uri or uris is required", i)) } + if strings.TrimSpace(r.ServiceID) == "" { + errs = append(errs, fmt.Sprintf("routes[%d]: service_id is required by current API7 EE", i)) + } if r.ID != "" { if err := checkID(r.ID, "routes", i); err != "" { errs = append(errs, err) @@ -126,19 +136,6 @@ func ValidateConfigFile(cfg api.ConfigFile) []string { } } - seenUpstreamIDs := map[string]struct{}{} - for i, item := range cfg.Upstreams { - if item.ID != "" { - if err := checkID(item.ID, "upstreams", i); err != "" { - errs = append(errs, err) - } else if _, ok := seenUpstreamIDs[item.ID]; ok { - errs = append(errs, fmt.Sprintf("upstreams[%d]: duplicate id %q", i, item.ID)) - } else { - seenUpstreamIDs[item.ID] = struct{}{} - } - } - } - seenConsumerUsernames := map[string]struct{}{} for i, c := range cfg.Consumers { if strings.TrimSpace(c.Username) == "" { @@ -158,7 +155,6 @@ func ValidateConfigFile(cfg api.ConfigFile) []string { errs = append(errs, checkDuplicateIDs(cfg.SSL, func(s api.SSL) string { return s.ID }, "ssl")...) errs = append(errs, checkDuplicateIDs(cfg.GlobalRules, func(g api.GlobalRule) string { return g.ID }, "global_rules")...) errs = append(errs, checkDuplicateIDs(cfg.PluginConfigs, func(p api.PluginConfig) string { return p.ID }, "plugin_configs")...) - errs = append(errs, checkDuplicateIDs(cfg.ConsumerGroups, func(c api.ConsumerGroup) string { return c.ID }, "consumer_groups")...) errs = append(errs, checkDuplicateIDs(cfg.StreamRoutes, func(s api.StreamRoute) string { return s.ID }, "stream_routes")...) errs = append(errs, checkDuplicateIDs(cfg.Protos, func(p api.Proto) string { return p.ID }, "protos")...) diff --git a/pkg/cmd/config/validate/validate_test.go b/pkg/cmd/config/validate/validate_test.go index 351d38c..1d36f30 100644 --- a/pkg/cmd/config/validate/validate_test.go +++ b/pkg/cmd/config/validate/validate_test.go @@ -52,6 +52,16 @@ version: "1" routes: - id: "route-1" uri: /hello + service_id: service-1 +services: + - id: service-1 + name: service-1 + upstream: + type: roundrobin + nodes: + - host: 127.0.0.1 + port: 8080 + weight: 1 consumers: - username: jack `), 0o644) @@ -71,7 +81,8 @@ func TestConfigValidate_ValidJSON(t *testing.T) { filePath := filepath.Join(t.TempDir(), "config.json") err := os.WriteFile(filePath, []byte(`{ "version": "1", - "routes": [{"id": "route-1", "uri": "/hello"}], + "routes": [{"id": "route-1", "uri": "/hello", "service_id": "service-1"}], + "services": [{"id": "service-1", "name": "service-1", "upstream": {"type": "roundrobin", "nodes": [{"host": "127.0.0.1", "port": 8080, "weight": 1}]}}], "consumers": [{"username": "jack"}] }`), 0o644) require.NoError(t, err) @@ -129,8 +140,10 @@ version: "1" routes: - id: "route-1" uri: /hello + service_id: service-1 - id: "route-1" uri: /hello2 + service_id: service-1 `), 0o644) require.NoError(t, err) @@ -150,6 +163,7 @@ func TestConfigValidate_MissingRouteURI(t *testing.T) { version: "1" routes: - id: "route-1" + service_id: service-1 `), 0o644) require.NoError(t, err) @@ -161,6 +175,26 @@ routes: assert.Contains(t, err.Error(), "either uri or uris is required") } +func TestConfigValidate_MissingRouteServiceID(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + filePath := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(filePath, []byte(` +version: "1" +routes: + - id: "route-1" + uri: /hello +`), 0o644) + require.NoError(t, err) + + c := NewCmdValidate(factoryWithIO(ios)) + c.SetArgs([]string{"-f", filePath}) + err = c.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "service_id is required by current API7 EE") +} + func TestConfigValidate_MissingConsumerUsername(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -182,6 +216,46 @@ consumers: assert.Contains(t, err.Error(), "username is required") } +func TestConfigValidate_RejectsUnsupportedTopLevelUpstreams(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + filePath := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(filePath, []byte(` +version: "1" +upstreams: + - id: backend + nodes: + 127.0.0.1:8080: 1 +`), 0o644) + require.NoError(t, err) + + c := NewCmdValidate(factoryWithIO(ios)) + c.SetArgs([]string{"-f", filePath}) + err = c.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "upstreams are not supported as top-level API7 EE resources") +} + +func TestConfigValidate_RejectsUnsupportedConsumerGroups(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + filePath := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(filePath, []byte(` +version: "1" +consumer_groups: + - id: tenants +`), 0o644) + require.NoError(t, err) + + c := NewCmdValidate(factoryWithIO(ios)) + c.SetArgs([]string{"-f", filePath}) + err = c.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "consumer_groups are not supported by current API7 EE") +} + func TestConfigValidate_MissingFileFlag(t *testing.T) { ios, _, _, _ := iostreams.Test() diff --git a/pkg/cmd/consumer-group/consumer_group.go b/pkg/cmd/consumer-group/consumer_group.go deleted file mode 100644 index 3d2fa37..0000000 --- a/pkg/cmd/consumer-group/consumer_group.go +++ /dev/null @@ -1,30 +0,0 @@ -package consumergroup - -import ( - "github.com/spf13/cobra" - - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmd/consumer-group/create" - del "github.com/api7/a7/pkg/cmd/consumer-group/delete" - "github.com/api7/a7/pkg/cmd/consumer-group/export" - "github.com/api7/a7/pkg/cmd/consumer-group/get" - "github.com/api7/a7/pkg/cmd/consumer-group/list" - "github.com/api7/a7/pkg/cmd/consumer-group/update" -) - -func NewCmd(f *cmd.Factory) *cobra.Command { - c := &cobra.Command{ - Use: "consumer-group", - Short: "Manage consumer groups", - Aliases: []string{"cg"}, - } - - c.AddCommand(list.NewCmd(f)) - c.AddCommand(get.NewCmd(f)) - c.AddCommand(create.NewCmd(f)) - c.AddCommand(update.NewCmd(f)) - c.AddCommand(del.NewCmd(f)) - c.AddCommand(export.NewCmd(f)) - - return c -} diff --git a/pkg/cmd/consumer-group/create/create.go b/pkg/cmd/consumer-group/create/create.go deleted file mode 100644 index e7ddedc..0000000 --- a/pkg/cmd/consumer-group/create/create.go +++ /dev/null @@ -1,141 +0,0 @@ -package create - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - GatewayGroup string - File string - - ID string - Desc string - PluginsJSON string - Labels []string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "create", - Short: "Create a consumer group", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - - c.Flags().StringVar(&opts.ID, "id", "", "Consumer group ID (optional)") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") - c.Flags().StringVar(&opts.Desc, "desc", "", "Consumer group description") - c.Flags().StringVar(&opts.PluginsJSON, "plugins-json", "", "Plugins JSON string") - c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - if opts.File != "" { - payload, err := cmdutil.ReadResourceFile(opts.File, opts.IO.In) - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - var body []byte - if id, ok := payload["id"]; ok { - body, err = client.Put(fmt.Sprintf("/apisix/admin/consumer_groups/%v?gateway_group_id=%s", id, ggID), payload) - } else { - body, err = client.Post("/apisix/admin/consumer_groups?gateway_group_id="+ggID, payload) - } - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - pl := make(map[string]interface{}) - if opts.PluginsJSON != "" { - if err := json.Unmarshal([]byte(opts.PluginsJSON), &pl); err != nil { - return fmt.Errorf("invalid --plugins-json: %w", err) - } - } - - labels := make(map[string]string) - for _, label := range opts.Labels { - parts := strings.SplitN(label, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return fmt.Errorf("invalid label %q, expected key=value", label) - } - labels[parts[0]] = parts[1] - } - - bodyReq := api.ConsumerGroup{ID: opts.ID, Desc: opts.Desc} - if len(pl) > 0 { - bodyReq.Plugins = pl - } - if len(labels) > 0 { - bodyReq.Labels = labels - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Post("/apisix/admin/consumer_groups?gateway_group_id="+ggID, bodyReq) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var created api.ConsumerGroup - if err := json.Unmarshal(body, &created); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(created) -} diff --git a/pkg/cmd/consumer-group/create/create_test.go b/pkg/cmd/consumer-group/create/create_test.go deleted file mode 100644 index e801488..0000000 --- a/pkg/cmd/consumer-group/create/create_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package create - -import ( - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestCreateConsumerGroup_JSONOutput(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodPost, "/apisix/admin/consumer_groups", httpmock.JSONResponse(`{"id":"cg1","desc":"group-1","plugins":{"key-auth":{}},"labels":{"env":"dev"}}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", ID: "cg1", Desc: "group-1", PluginsJSON: `{"key-auth":{}}`, Labels: []string{"env=dev"}} - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - - var item api.ConsumerGroup - if err := json.Unmarshal(out.Bytes(), &item); err != nil { - t.Fatalf("failed to parse output: %v", err) - } - if item.ID != "cg1" || item.Desc != "group-1" { - t.Fatalf("unexpected output: %+v", item) - } - - registry.Verify(t) -} - -func TestCreateConsumerGroup_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local"}, nil - }} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected missing gateway group error, got: %v", err) - } -} - -func TestCreateConsumerGroup_APIError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodPost, "/apisix/admin/consumer_groups", httpmock.StringResponse(http.StatusInternalServerError, `{"message":"boom"}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "API error") { - t.Fatalf("expected api error, got: %v", err) - } - - registry.Verify(t) -} diff --git a/pkg/cmd/consumer-group/delete/delete.go b/pkg/cmd/consumer-group/delete/delete.go deleted file mode 100644 index 665cf18..0000000 --- a/pkg/cmd/consumer-group/delete/delete.go +++ /dev/null @@ -1,83 +0,0 @@ -package delete - -import ( - "bufio" - "fmt" - "net/http" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - GatewayGroup string - ID string - Force bool -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "delete ", - Short: "Delete a consumer group", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.ID = args[0] - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - c.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - if !opts.Force && opts.IO.IsStdinTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Delete consumer group %q? (y/N): ", opts.ID) - reader := bufio.NewReader(opts.IO.In) - answer, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read confirmation: %w", err) - } - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer != "y" && answer != "yes" { - fmt.Fprintln(opts.IO.ErrOut, "Aborted.") - return nil - } - } - if _, err := client.Delete("/apisix/admin/consumer_groups/"+opts.ID, map[string]string{"gateway_group_id": ggID}); err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - _, err = fmt.Fprintf(opts.IO.Out, "Consumer group %q deleted.\n", opts.ID) - return err -} diff --git a/pkg/cmd/consumer-group/delete/delete_test.go b/pkg/cmd/consumer-group/delete/delete_test.go deleted file mode 100644 index 53f16d5..0000000 --- a/pkg/cmd/consumer-group/delete/delete_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package delete - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestDeleteConsumerGroup_Success(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodDelete, "/apisix/admin/consumer_groups/cg1", httpmock.StringResponse(http.StatusNoContent, "")) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", ID: "cg1"} - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(out.String(), `Consumer group "cg1" deleted.`) { - t.Fatalf("unexpected output: %s", out.String()) - } - - registry.Verify(t) -} - -func TestDeleteConsumerGroup_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local"}, nil - }, ID: "cg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected missing gateway group error, got: %v", err) - } -} - -func TestDeleteConsumerGroup_APIError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodDelete, "/apisix/admin/consumer_groups/cg1", httpmock.StringResponse(http.StatusInternalServerError, `{"message":"boom"}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", ID: "cg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "API error") { - t.Fatalf("expected api error, got: %v", err) - } - - registry.Verify(t) -} diff --git a/pkg/cmd/consumer-group/export/export.go b/pkg/cmd/consumer-group/export/export.go deleted file mode 100644 index e24f92f..0000000 --- a/pkg/cmd/consumer-group/export/export.go +++ /dev/null @@ -1,147 +0,0 @@ -package export - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - GatewayGroup string - Label string - Output string - File string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "export", - Short: "Export consumer groups as JSON or YAML", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - c.Flags().StringVar(&opts.Label, "label", "", "Filter by label (key=value)") - c.Flags().StringVarP(&opts.Output, "output", "o", "yaml", "Output format: json, yaml") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Write output to file") - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - items, err := fetchAll(client, ggID, opts.Label) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - if len(items) == 0 { - fmt.Fprintln(opts.IO.ErrOut, "No consumer groups found.") - return nil - } - - format := opts.Output - if format == "" { - format = "yaml" - } - - var out io.Writer = opts.IO.Out - if opts.File != "" { - f, err := os.Create(opts.File) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer f.Close() - out = f - } - - return cmdutil.NewExporter(format, out).Write(stripTimestamps(items)) -} - -func fetchAll(client *api.Client, ggID, label string) ([]api.ConsumerGroup, error) { - page := 1 - pageSize := 100 - var all []api.ConsumerGroup - labelKey, labelValue := cmdutil.ParseLabel(label) - - for { - query := map[string]string{ - "gateway_group_id": ggID, - "page": fmt.Sprintf("%d", page), - "page_size": fmt.Sprintf("%d", pageSize), - } - if labelKey != "" { - query["label"] = labelKey - } - - body, err := client.Get("/apisix/admin/consumer_groups", query) - if err != nil { - return nil, err - } - - var resp api.ListResponse[api.ConsumerGroup] - if err := json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - for _, item := range resp.List { - if labelValue != "" && (item.Labels == nil || item.Labels[labelKey] != labelValue) { - continue - } - all = append(all, item) - } - - if len(resp.List) == 0 || page*pageSize >= resp.Total { - break - } - page++ - } - - return all, nil -} - -func stripTimestamps(items []api.ConsumerGroup) []map[string]interface{} { - out := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - var m map[string]interface{} - b, _ := json.Marshal(item) - _ = json.Unmarshal(b, &m) - delete(m, "create_time") - delete(m, "update_time") - out = append(out, m) - } - return out -} diff --git a/pkg/cmd/consumer-group/export/export_test.go b/pkg/cmd/consumer-group/export/export_test.go deleted file mode 100644 index 6abd774..0000000 --- a/pkg/cmd/consumer-group/export/export_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package export - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestExport_Success(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups", httpmock.JSONResponse(`{"total":1,"list":[{"id":"cg1","desc":"demo"}]}`)) - - opts := &Options{ - IO: ios, - Client: func() (*http.Client, error) { return registry.GetClient(), nil }, - Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, - GatewayGroup: "gg1", - Output: "json", - } - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - - if !strings.Contains(out.String(), "cg1") { - t.Fatalf("expected consumer group in output, got: %s", out.String()) - } - registry.Verify(t) -} - -func TestExport_Empty(t *testing.T) { - ios, _, _, errBuf := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups", httpmock.JSONResponse(`{"total":0,"list":[]}`)) - - opts := &Options{ - IO: ios, - Client: func() (*http.Client, error) { return registry.GetClient(), nil }, - Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, - GatewayGroup: "gg1", - Output: "json", - } - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(errBuf.String(), "No consumer groups found") { - t.Fatalf("expected no consumer groups message, got: %s", errBuf.String()) - } -} diff --git a/pkg/cmd/consumer-group/get/get.go b/pkg/cmd/consumer-group/get/get.go deleted file mode 100644 index ce8acfc..0000000 --- a/pkg/cmd/consumer-group/get/get.go +++ /dev/null @@ -1,85 +0,0 @@ -package get - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" - "github.com/api7/a7/pkg/tableprinter" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - GatewayGroup string - ID string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "get ", - Short: "Get a consumer group", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.ID = args[0] - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Get("/apisix/admin/consumer_groups/"+opts.ID, map[string]string{"gateway_group_id": ggID}) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var item api.ConsumerGroup - if err := json.Unmarshal(body, &item); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - if opts.Output != "" { - return cmdutil.NewExporter(opts.Output, opts.IO.Out).Write(item) - } - - tp := tableprinter.New(opts.IO.Out) - tp.SetHeaders("FIELD", "VALUE") - tp.AddRow("id", item.ID) - tp.AddRow("desc", item.Desc) - tp.AddRow("plugins", fmt.Sprintf("%d", len(item.Plugins))) - - return tp.Render() -} diff --git a/pkg/cmd/consumer-group/get/get_test.go b/pkg/cmd/consumer-group/get/get_test.go deleted file mode 100644 index 4b66548..0000000 --- a/pkg/cmd/consumer-group/get/get_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package get - -import ( - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestGetConsumerGroup_Table(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups/cg1", httpmock.JSONResponse(`{"id":"cg1","desc":"group-1","plugins":{"a":{},"b":{}}}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, ID: "cg1", GatewayGroup: "gg1"} - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - output := out.String() - if !strings.Contains(output, "FIELD") || !strings.Contains(output, "VALUE") || !strings.Contains(output, "group-1") || !strings.Contains(output, "2") { - t.Fatalf("unexpected table output: %s", output) - } - registry.Verify(t) -} - -func TestGetConsumerGroup_JSON(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups/cg1", httpmock.JSONResponse(`{"id":"cg1","desc":"group-1"}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, Output: "json", ID: "cg1", GatewayGroup: "gg1"} - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - var item api.ConsumerGroup - if err := json.Unmarshal(out.Bytes(), &item); err != nil { - t.Fatalf("failed to parse json output: %v", err) - } - if item.ID != "cg1" { - t.Fatalf("unexpected output: %+v", item) - } - registry.Verify(t) -} - -func TestGetConsumerGroup_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local"}, nil - }, ID: "cg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected missing gateway group error, got: %v", err) - } -} - -func TestGetConsumerGroup_APIError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups/cg1", httpmock.StringResponse(http.StatusInternalServerError, `{"message":"boom"}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, ID: "cg1", GatewayGroup: "gg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "API error") { - t.Fatalf("expected api error, got: %v", err) - } - registry.Verify(t) -} diff --git a/pkg/cmd/consumer-group/list/list.go b/pkg/cmd/consumer-group/list/list.go deleted file mode 100644 index 66ce713..0000000 --- a/pkg/cmd/consumer-group/list/list.go +++ /dev/null @@ -1,102 +0,0 @@ -package list - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" - "github.com/api7/a7/pkg/tableprinter" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - GatewayGroup string - Label string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "list", - Short: "List consumer groups", - Aliases: []string{"ls"}, - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - opts.Label, _ = c.Flags().GetString("label") - return actionRun(opts) - }, - } - c.Flags().StringVar(&opts.Label, "label", "", "Filter by label (key=value)") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - query := map[string]string{"gateway_group_id": ggID} - labelKey, labelValue := cmdutil.ParseLabel(opts.Label) - if labelKey != "" { - query["label"] = labelKey - } - body, err := client.Get("/apisix/admin/consumer_groups", query) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var resp api.ListResponse[api.ConsumerGroup] - if err := json.Unmarshal(body, &resp); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - if labelValue != "" { - filtered := make([]api.ConsumerGroup, 0) - for _, item := range resp.List { - if item.Labels != nil && item.Labels[labelKey] == labelValue { - filtered = append(filtered, item) - } - } - resp.List = filtered - } - - if opts.Output != "" { - return cmdutil.NewExporter(opts.Output, opts.IO.Out).Write(resp.List) - } - - tp := tableprinter.New(opts.IO.Out) - tp.SetHeaders("ID", "DESCRIPTION", "PLUGINS") - for _, item := range resp.List { - tp.AddRow(item.ID, item.Desc, fmt.Sprintf("%d", len(item.Plugins))) - } - - return tp.Render() -} diff --git a/pkg/cmd/consumer-group/list/list_test.go b/pkg/cmd/consumer-group/list/list_test.go deleted file mode 100644 index ab28cb1..0000000 --- a/pkg/cmd/consumer-group/list/list_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package list - -import ( - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestListConsumerGroups_Table(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups", httpmock.JSONResponse(`{"total":2,"list":[{"id":"cg1","desc":"first","plugins":{"key-auth":{} }},{"id":"cg2","desc":"second","plugins":{}}]}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1"} - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - - output := out.String() - if !strings.Contains(output, "ID") || !strings.Contains(output, "DESCRIPTION") || !strings.Contains(output, "PLUGINS") { - t.Fatalf("table headers missing: %s", output) - } - if !strings.Contains(output, "cg1") || !strings.Contains(output, "first") || !strings.Contains(output, "1") { - t.Fatalf("first row missing: %s", output) - } - if !strings.Contains(output, "cg2") || !strings.Contains(output, "second") || !strings.Contains(output, "0") { - t.Fatalf("second row missing: %s", output) - } - - registry.Verify(t) -} - -func TestListConsumerGroups_JSON(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups", httpmock.JSONResponse(`{"total":1,"list":[{"id":"cg1","desc":"first"}]}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, Output: "json", GatewayGroup: "gg1"} - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - - var items []api.ConsumerGroup - if err := json.Unmarshal(out.Bytes(), &items); err != nil { - t.Fatalf("failed to parse json output: %v", err) - } - if len(items) != 1 || items[0].ID != "cg1" { - t.Fatalf("unexpected output: %+v", items) - } - - registry.Verify(t) -} - -func TestListConsumerGroups_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local"}, nil - }} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected missing gateway group error, got: %v", err) - } -} - -func TestListConsumerGroups_APIError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/consumer_groups", httpmock.StringResponse(http.StatusInternalServerError, `{"message":"boom"}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "API error") { - t.Fatalf("expected api error, got: %v", err) - } - - registry.Verify(t) -} diff --git a/pkg/cmd/consumer-group/update/update.go b/pkg/cmd/consumer-group/update/update.go deleted file mode 100644 index 8b27a2a..0000000 --- a/pkg/cmd/consumer-group/update/update.go +++ /dev/null @@ -1,130 +0,0 @@ -package update - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - File string - GatewayGroup string - ID string - - Desc string - PluginsJSON string - Labels []string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "update ", - Short: "Update a consumer group", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.ID = args[0] - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - - c.Flags().StringVar(&opts.Desc, "desc", "", "Consumer group description") - c.Flags().StringVar(&opts.PluginsJSON, "plugins-json", "", "Plugins JSON string") - c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - if opts.File != "" { - payload, err := cmdutil.ReadResourceFile(opts.File, opts.IO.In) - if err != nil { - return err - } - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Put("/apisix/admin/consumer_groups/"+opts.ID+"?gateway_group_id="+ggID, payload) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) - } - - pl := make(map[string]interface{}) - if opts.PluginsJSON != "" { - if err := json.Unmarshal([]byte(opts.PluginsJSON), &pl); err != nil { - return fmt.Errorf("invalid --plugins-json: %w", err) - } - } - - labels := make(map[string]string) - for _, label := range opts.Labels { - parts := strings.SplitN(label, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return fmt.Errorf("invalid label %q, expected key=value", label) - } - labels[parts[0]] = parts[1] - } - - bodyReq := api.ConsumerGroup{ID: opts.ID, Desc: opts.Desc} - if len(pl) > 0 { - bodyReq.Plugins = pl - } - if len(labels) > 0 { - bodyReq.Labels = labels - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Put("/apisix/admin/consumer_groups/"+opts.ID+"?gateway_group_id="+ggID, bodyReq) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var updated api.ConsumerGroup - if err := json.Unmarshal(body, &updated); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(updated) -} diff --git a/pkg/cmd/consumer-group/update/update_test.go b/pkg/cmd/consumer-group/update/update_test.go deleted file mode 100644 index d47c84a..0000000 --- a/pkg/cmd/consumer-group/update/update_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package update - -import ( - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestUpdateConsumerGroup_JSONOutput(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodPut, "/apisix/admin/consumer_groups/cg1", httpmock.JSONResponse(`{"id":"cg1","desc":"updated"}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", ID: "cg1", Desc: "updated"} - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - - var item api.ConsumerGroup - if err := json.Unmarshal(out.Bytes(), &item); err != nil { - t.Fatalf("failed to parse output: %v", err) - } - if item.ID != "cg1" || item.Desc != "updated" { - t.Fatalf("unexpected output: %+v", item) - } - - registry.Verify(t) -} - -func TestUpdateConsumerGroup_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local"}, nil - }, ID: "cg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected missing gateway group error, got: %v", err) - } -} - -func TestUpdateConsumerGroup_APIError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodPut, "/apisix/admin/consumer_groups/cg1", httpmock.StringResponse(http.StatusInternalServerError, `{"message":"boom"}`)) - - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", ID: "cg1"} - - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "API error") { - t.Fatalf("expected api error, got: %v", err) - } - - registry.Verify(t) -} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index a636f53..3677775 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -10,7 +10,6 @@ import ( "github.com/api7/a7/pkg/cmd/completion" configcmd "github.com/api7/a7/pkg/cmd/config" "github.com/api7/a7/pkg/cmd/consumer" - consumergroup "github.com/api7/a7/pkg/cmd/consumer-group" "github.com/api7/a7/pkg/cmd/context" "github.com/api7/a7/pkg/cmd/credential" "github.com/api7/a7/pkg/cmd/debug" @@ -23,11 +22,9 @@ import ( "github.com/api7/a7/pkg/cmd/route" "github.com/api7/a7/pkg/cmd/secret" "github.com/api7/a7/pkg/cmd/service" - servicetemplate "github.com/api7/a7/pkg/cmd/service-template" "github.com/api7/a7/pkg/cmd/ssl" streamroute "github.com/api7/a7/pkg/cmd/stream-route" "github.com/api7/a7/pkg/cmd/update" - "github.com/api7/a7/pkg/cmd/upstream" "github.com/api7/a7/pkg/cmd/version" ) @@ -36,7 +33,7 @@ func NewCmd(f *cmd.Factory, cfg *config.FileConfig) *cobra.Command { c := &cobra.Command{ Use: "a7", Short: "CLI for API7 Enterprise Edition", - Long: "a7 is a command-line interface for the API7 Enterprise Edition API Gateway.\nManage gateway groups, services, routes, upstreams, consumers, SSL, and more.", + Long: "a7 is a command-line interface for the API7 Enterprise Edition API Gateway.\nManage gateway groups, services, routes, consumers, SSL, and more.", SilenceUsage: true, SilenceErrors: true, } @@ -84,9 +81,7 @@ func NewCmd(f *cmd.Factory, cfg *config.FileConfig) *cobra.Command { // Register resource commands. c.AddCommand(gatewaygroup.NewCmd(f)) - c.AddCommand(servicetemplate.NewCmd(f)) c.AddCommand(route.NewCmd(f)) - c.AddCommand(upstream.NewCmd(f)) c.AddCommand(consumer.NewCmd(f)) c.AddCommand(ssl.NewCmd(f)) c.AddCommand(plugin.NewCmd(f)) @@ -95,7 +90,6 @@ func NewCmd(f *cmd.Factory, cfg *config.FileConfig) *cobra.Command { c.AddCommand(streamroute.NewCmd(f)) c.AddCommand(pluginconfig.NewCmd(f)) c.AddCommand(pluginmetadata.NewCmd(f)) - c.AddCommand(consumergroup.NewCmd(f)) c.AddCommand(credential.NewCmd(f)) c.AddCommand(secret.NewCmd(f)) c.AddCommand(proto.NewCmd(f)) diff --git a/pkg/cmd/route/create/create.go b/pkg/cmd/route/create/create.go index 90e0953..501de34 100644 --- a/pkg/cmd/route/create/create.go +++ b/pkg/cmd/route/create/create.go @@ -23,15 +23,14 @@ type Options struct { GatewayGroup string File string - Name string - URI string - Paths []string - Methods []string - Host string - ServiceID string - UpstreamID string - Labels []string - Status int + Name string + URI string + Paths []string + Methods []string + Host string + ServiceID string + Labels []string + Status int } func NewCmd(f *cmd.Factory) *cobra.Command { @@ -54,7 +53,6 @@ func NewCmd(f *cmd.Factory) *cobra.Command { c.Flags().StringSliceVar(&opts.Methods, "methods", nil, "Allowed HTTP methods") c.Flags().StringVar(&opts.Host, "host", "", "Route host") c.Flags().StringVar(&opts.ServiceID, "service-id", "", "Bound service ID") - c.Flags().StringVar(&opts.UpstreamID, "upstream-id", "", "Bound upstream ID") c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") c.Flags().IntVar(&opts.Status, "status", 1, "Route status") @@ -109,6 +107,9 @@ func actionRun(opts *Options) error { if opts.Name == "" { return fmt.Errorf("--name is required for flag-based route creation") } + if opts.ServiceID == "" { + return fmt.Errorf("--service-id is required for current API7 EE") + } // Convert --uri to Paths for API7 EE compatibility. paths := opts.Paths @@ -131,14 +132,13 @@ func actionRun(opts *Options) error { } bodyReq := api.Route{ - Name: opts.Name, - Paths: paths, - Methods: opts.Methods, - Host: opts.Host, - ServiceID: opts.ServiceID, - UpstreamID: opts.UpstreamID, - Labels: labels, - Status: opts.Status, + Name: opts.Name, + Paths: paths, + Methods: opts.Methods, + Host: opts.Host, + ServiceID: opts.ServiceID, + Labels: labels, + Status: opts.Status, } client := api.NewClient(httpClient, cfg.BaseURL()) diff --git a/pkg/cmd/route/create/create_test.go b/pkg/cmd/route/create/create_test.go index 3edafd6..70421fe 100644 --- a/pkg/cmd/route/create/create_test.go +++ b/pkg/cmd/route/create/create_test.go @@ -34,7 +34,7 @@ func (m *mockConfig) Save() error { return n func TestCreateRoute_Success(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} - registry.Register(http.MethodPost, "/apisix/admin/routes", httpmock.JSONResponse(`{"id":"r1","name":"demo","uri":"/demo"}`)) + registry.Register(http.MethodPost, "/apisix/admin/routes", httpmock.JSONResponse(`{"id":"r1","name":"demo","uri":"/demo","service_id":"svc1"}`)) opts := &Options{ IO: ios, @@ -45,6 +45,7 @@ func TestCreateRoute_Success(t *testing.T) { GatewayGroup: "gg1", URI: "/demo", Name: "demo", + ServiceID: "svc1", } if err := actionRun(opts); err != nil { @@ -71,6 +72,24 @@ func TestCreateRoute_MissingURI(t *testing.T) { } } +func TestCreateRoute_MissingServiceID(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &Options{ + IO: ios, + Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, + Config: func() (config.Config, error) { + return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil + }, + GatewayGroup: "gg1", + URI: "/demo", + Name: "demo", + } + err := actionRun(opts) + if err == nil || !strings.Contains(err.Error(), "--service-id is required") { + t.Fatalf("expected service-id required error, got: %v", err) + } +} + func TestCreateRoute_FromFile(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} diff --git a/pkg/cmd/route/update/update.go b/pkg/cmd/route/update/update.go index a0f2431..307b782 100644 --- a/pkg/cmd/route/update/update.go +++ b/pkg/cmd/route/update/update.go @@ -29,7 +29,6 @@ type Options struct { Methods []string Host string ServiceID string - UpstreamID string Labels []string Status int Priority int @@ -58,7 +57,6 @@ func NewCmd(f *cmd.Factory) *cobra.Command { c.Flags().StringSliceVar(&opts.Methods, "methods", nil, "Allowed HTTP methods") c.Flags().StringVar(&opts.Host, "host", "", "Route host") c.Flags().StringVar(&opts.ServiceID, "service-id", "", "Bound service ID") - c.Flags().StringVar(&opts.UpstreamID, "upstream-id", "", "Bound upstream ID") c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") c.Flags().IntVar(&opts.Status, "status", 0, "Route status") c.Flags().IntVar(&opts.Priority, "priority", 0, "Route priority") @@ -139,9 +137,6 @@ func actionRun(opts *Options) error { if opts.ServiceID != "" { bodyReq.ServiceID = opts.ServiceID } - if opts.UpstreamID != "" { - bodyReq.UpstreamID = opts.UpstreamID - } if opts.StatusSet { bodyReq.Status = opts.Status } diff --git a/pkg/cmd/service-template/create/create.go b/pkg/cmd/service-template/create/create.go deleted file mode 100644 index c495ef4..0000000 --- a/pkg/cmd/service-template/create/create.go +++ /dev/null @@ -1,141 +0,0 @@ -package create - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - File string - Name string - Description string - Labels []string - Hosts []string - PathPrefix string -} - -type createRequest struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Upstream map[string]interface{} `json:"upstream,omitempty"` - Plugins map[string]interface{} `json:"plugins,omitempty"` - Hosts []string `json:"hosts,omitempty"` - PathPrefix string `json:"path_prefix,omitempty"` -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "create", - Short: "Create a service template", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - return actionRun(opts) - }, - } - - c.Flags().StringVar(&opts.Name, "name", "", "Service template name") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") - c.Flags().StringVar(&opts.Description, "description", "", "Service template description") - c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") - c.Flags().StringSliceVar(&opts.Hosts, "host", nil, "Host to match (repeatable)") - c.Flags().StringVar(&opts.PathPrefix, "path-prefix", "", "Path prefix") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - if opts.File != "" { - payload, err := cmdutil.ReadResourceFile(opts.File, opts.IO.In) - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - var body []byte - if id, ok := payload["id"]; ok { - body, err = client.Put(fmt.Sprintf("/api/services/template/%v", id), payload) - } else { - body, err = client.Post("/api/services/template", payload) - } - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) - } - - if opts.Name == "" { - return fmt.Errorf("required flag(s) \"name\" not set") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - labels := make(map[string]string, len(opts.Labels)) - for _, label := range opts.Labels { - k, v := cmdutil.ParseLabel(label) - if k == "" { - return fmt.Errorf("invalid label: %q", label) - } - labels[k] = v - } - - req := createRequest{ - Name: opts.Name, - Description: opts.Description, - Labels: labels, - Hosts: opts.Hosts, - PathPrefix: opts.PathPrefix, - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Post("/api/services/template", req) - if err != nil { - return err - } - - var item api.ServiceTemplate - if err := json.Unmarshal(body, &item); err != nil { - return fmt.Errorf("failed to parse create response: %w", err) - } - - if opts.Output != "" { - exporter := cmdutil.NewExporter(opts.Output, opts.IO.Out) - return exporter.Write(item) - } - - exporter := cmdutil.NewExporter("json", opts.IO.Out) - return exporter.Write(item) -} diff --git a/pkg/cmd/service-template/delete/delete.go b/pkg/cmd/service-template/delete/delete.go deleted file mode 100644 index 4c63325..0000000 --- a/pkg/cmd/service-template/delete/delete.go +++ /dev/null @@ -1,73 +0,0 @@ -package delete - -import ( - "bufio" - "fmt" - "net/http" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - ID string - Force bool -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "delete ", - Short: "Delete a service template", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.ID = args[0] - return actionRun(opts) - }, - } - c.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt") - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - if !opts.Force && opts.IO.IsStdinTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Delete service template %q? (y/N): ", opts.ID) - reader := bufio.NewReader(opts.IO.In) - answer, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read confirmation: %w", err) - } - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer != "y" && answer != "yes" { - fmt.Fprintln(opts.IO.ErrOut, "Aborted.") - return nil - } - } - if _, err := client.Delete(fmt.Sprintf("/api/services/template/%s", opts.ID), nil); err != nil { - return err - } - - _, err = fmt.Fprintf(opts.IO.Out, "Service template %s deleted\n", opts.ID) - return err -} diff --git a/pkg/cmd/service-template/get/get.go b/pkg/cmd/service-template/get/get.go deleted file mode 100644 index 2a6ccc8..0000000 --- a/pkg/cmd/service-template/get/get.go +++ /dev/null @@ -1,69 +0,0 @@ -package get - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - ID string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "get ", - Short: "Get a service template", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.ID = args[0] - return actionRun(opts) - }, - } - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Get(fmt.Sprintf("/api/services/template/%s", opts.ID), nil) - if err != nil { - return err - } - - var item api.ServiceTemplate - if err := json.Unmarshal(body, &item); err != nil { - return fmt.Errorf("failed to parse get response: %w", err) - } - - if opts.Output != "" { - exporter := cmdutil.NewExporter(opts.Output, opts.IO.Out) - return exporter.Write(item) - } - - exporter := cmdutil.NewExporter("json", opts.IO.Out) - return exporter.Write(item) -} diff --git a/pkg/cmd/service-template/list/list.go b/pkg/cmd/service-template/list/list.go deleted file mode 100644 index 61b6ebc..0000000 --- a/pkg/cmd/service-template/list/list.go +++ /dev/null @@ -1,92 +0,0 @@ -package list - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" - "github.com/api7/a7/pkg/tableprinter" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - Label string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "list", - Short: "List service templates", - Aliases: []string{"ls"}, - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.Label, _ = c.Flags().GetString("label") - return actionRun(opts) - }, - } - c.Flags().StringVar(&opts.Label, "label", "", "Filter by label (key=value)") - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - var query map[string]string - labelKey, labelValue := cmdutil.ParseLabel(opts.Label) - if labelKey != "" { - query = map[string]string{"label": labelKey} - } - body, err := client.Get("/api/services/template", query) - if err != nil { - return err - } - - var resp api.ListResponse[api.ServiceTemplate] - if err := json.Unmarshal(body, &resp); err != nil { - return fmt.Errorf("failed to parse list response: %w", err) - } - - if labelValue != "" { - filtered := make([]api.ServiceTemplate, 0) - for _, item := range resp.List { - if item.Labels != nil && item.Labels[labelKey] == labelValue { - filtered = append(filtered, item) - } - } - resp.List = filtered - } - - if opts.Output != "" { - exporter := cmdutil.NewExporter(opts.Output, opts.IO.Out) - return exporter.Write(resp.List) - } - - tbl := tableprinter.New(opts.IO.Out) - tbl.SetHeaders("ID", "NAME", "DESCRIPTION", "STATUS") - for _, item := range resp.List { - tbl.AddRow(item.ID, item.Name, item.Description, fmt.Sprintf("%d", item.Status)) - } - - return tbl.Render() -} diff --git a/pkg/cmd/service-template/publish/publish.go b/pkg/cmd/service-template/publish/publish.go deleted file mode 100644 index e946f08..0000000 --- a/pkg/cmd/service-template/publish/publish.go +++ /dev/null @@ -1,108 +0,0 @@ -package publish - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - ID string - GatewayGroupID string - Version string -} - -type publishPayload struct { - CreateNewVersion bool `json:"create_new_version"` - GatewayGroupID string `json:"gateway_group_id"` - Services []publishService `json:"services"` -} - -type publishService struct { - ServiceID string `json:"service_id"` - Version string `json:"version"` -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "publish ", - Short: "Publish a service template", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.ID = args[0] - return actionRun(opts) - }, - } - - c.Flags().StringVar(&opts.GatewayGroupID, "gateway-group-id", "", "Gateway group ID to publish to (required)") - c.Flags().StringVar(&opts.Version, "version", "1.0.0", "Version label for the published service") - - return c -} - -func actionRun(opts *Options) error { - if opts.GatewayGroupID == "" { - return fmt.Errorf("required flag(s) \"gateway-group-id\" not set") - } - - cfg, err := opts.Config() - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - payload := publishPayload{ - CreateNewVersion: true, - GatewayGroupID: opts.GatewayGroupID, - Services: []publishService{ - {ServiceID: opts.ID, Version: opts.Version}, - }, - } - - body, err := client.Post("/api/services/publish", payload) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var resp map[string]interface{} - if len(body) > 0 { - if err := json.Unmarshal(body, &resp); err != nil { - return fmt.Errorf("failed to parse publish response: %w", err) - } - } - - if opts.Output != "" { - exporter := cmdutil.NewExporter(opts.Output, opts.IO.Out) - if resp == nil { - return exporter.Write(map[string]string{"message": "published"}) - } - return exporter.Write(resp) - } - - if resp != nil { - exporter := cmdutil.NewExporter("json", opts.IO.Out) - return exporter.Write(resp) - } - - _, err = fmt.Fprintf(opts.IO.Out, "Service template %s published\n", opts.ID) - return err -} diff --git a/pkg/cmd/service-template/service_template.go b/pkg/cmd/service-template/service_template.go deleted file mode 100644 index 443fab6..0000000 --- a/pkg/cmd/service-template/service_template.go +++ /dev/null @@ -1,30 +0,0 @@ -package servicetemplate - -import ( - "github.com/spf13/cobra" - - cmd "github.com/api7/a7/pkg/cmd" - servicetemplatecreate "github.com/api7/a7/pkg/cmd/service-template/create" - servicetemplatedelete "github.com/api7/a7/pkg/cmd/service-template/delete" - servicetemplateget "github.com/api7/a7/pkg/cmd/service-template/get" - servicetemplatelist "github.com/api7/a7/pkg/cmd/service-template/list" - servicetemplatepublish "github.com/api7/a7/pkg/cmd/service-template/publish" - servicetemplateupdate "github.com/api7/a7/pkg/cmd/service-template/update" -) - -func NewCmd(f *cmd.Factory) *cobra.Command { - c := &cobra.Command{ - Use: "service-template", - Short: "Manage service templates", - Aliases: []string{"st"}, - } - - c.AddCommand(servicetemplatelist.NewCmd(f)) - c.AddCommand(servicetemplateget.NewCmd(f)) - c.AddCommand(servicetemplatecreate.NewCmd(f)) - c.AddCommand(servicetemplateupdate.NewCmd(f)) - c.AddCommand(servicetemplatedelete.NewCmd(f)) - c.AddCommand(servicetemplatepublish.NewCmd(f)) - - return c -} diff --git a/pkg/cmd/service-template/update/update.go b/pkg/cmd/service-template/update/update.go deleted file mode 100644 index 37a30f8..0000000 --- a/pkg/cmd/service-template/update/update.go +++ /dev/null @@ -1,127 +0,0 @@ -package update - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - File string - ID string - Name string - Description string - Labels []string - Hosts []string - PathPrefix string -} - -type updateRequest struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Upstream map[string]interface{} `json:"upstream,omitempty"` - Plugins map[string]interface{} `json:"plugins,omitempty"` - Hosts []string `json:"hosts,omitempty"` - PathPrefix string `json:"path_prefix,omitempty"` -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "update ", - Short: "Update a service template", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.ID = args[0] - return actionRun(opts) - }, - } - - c.Flags().StringVar(&opts.Name, "name", "", "Service template name") - c.Flags().StringVar(&opts.Description, "description", "", "Service template description") - c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") - c.Flags().StringSliceVar(&opts.Hosts, "host", nil, "Host to match (repeatable)") - c.Flags().StringVar(&opts.PathPrefix, "path-prefix", "", "Path prefix") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - if opts.File != "" { - payload, err := cmdutil.ReadResourceFile(opts.File, opts.IO.In) - if err != nil { - return err - } - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Put("/api/services/template/"+opts.ID, payload) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) - } - - labels := make(map[string]string, len(opts.Labels)) - for _, label := range opts.Labels { - k, v := cmdutil.ParseLabel(label) - if k == "" { - return fmt.Errorf("invalid label: %q", label) - } - labels[k] = v - } - - req := updateRequest{ - Name: opts.Name, - Description: opts.Description, - Labels: labels, - Hosts: opts.Hosts, - PathPrefix: opts.PathPrefix, - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Put(fmt.Sprintf("/api/services/template/%s", opts.ID), req) - if err != nil { - return err - } - - var item api.ServiceTemplate - if err := json.Unmarshal(body, &item); err != nil { - return fmt.Errorf("failed to parse update response: %w", err) - } - - if opts.Output != "" { - exporter := cmdutil.NewExporter(opts.Output, opts.IO.Out) - return exporter.Write(item) - } - - exporter := cmdutil.NewExporter("json", opts.IO.Out) - return exporter.Write(item) -} diff --git a/pkg/cmd/service/create/create.go b/pkg/cmd/service/create/create.go index ab0b480..895b1c8 100644 --- a/pkg/cmd/service/create/create.go +++ b/pkg/cmd/service/create/create.go @@ -23,11 +23,10 @@ type Options struct { GatewayGroup string File string - Name string - Desc string - UpstreamID string - Labels []string - Host string + Name string + Desc string + Labels []string + Host string } func NewCmd(f *cmd.Factory) *cobra.Command { @@ -46,7 +45,6 @@ func NewCmd(f *cmd.Factory) *cobra.Command { c.Flags().StringVar(&opts.Name, "name", "", "Service name") c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") c.Flags().StringVar(&opts.Desc, "desc", "", "Service description") - c.Flags().StringVar(&opts.UpstreamID, "upstream-id", "", "Bound upstream ID") c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") c.Flags().StringVar(&opts.Host, "host", "", "Service host") return c @@ -112,9 +110,8 @@ func actionRun(opts *Options) error { } bodyReq := api.Service{ - Name: opts.Name, - Desc: opts.Desc, - UpstreamID: opts.UpstreamID, + Name: opts.Name, + Desc: opts.Desc, } if len(labels) > 0 { bodyReq.Labels = labels diff --git a/pkg/cmd/service/create/create_test.go b/pkg/cmd/service/create/create_test.go index 7960dfa..bb2ee11 100644 --- a/pkg/cmd/service/create/create_test.go +++ b/pkg/cmd/service/create/create_test.go @@ -45,7 +45,6 @@ func TestCreateService_Success(t *testing.T) { GatewayGroup: "gg1", Name: "svc-1", Desc: "d1", - UpstreamID: "u1", Host: "example.com", Labels: []string{"k=v"}, } diff --git a/pkg/cmd/service/update/update.go b/pkg/cmd/service/update/update.go index b1eb599..ba79a85 100644 --- a/pkg/cmd/service/update/update.go +++ b/pkg/cmd/service/update/update.go @@ -24,11 +24,10 @@ type Options struct { GatewayGroup string ID string - Name string - Desc string - UpstreamID string - Labels []string - Host string + Name string + Desc string + Labels []string + Host string } func NewCmd(f *cmd.Factory) *cobra.Command { @@ -47,7 +46,6 @@ func NewCmd(f *cmd.Factory) *cobra.Command { c.Flags().StringVar(&opts.Name, "name", "", "Service name") c.Flags().StringVar(&opts.Desc, "desc", "", "Service description") - c.Flags().StringVar(&opts.UpstreamID, "upstream-id", "", "Bound upstream ID") c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") c.Flags().StringVar(&opts.Host, "host", "", "Service host") c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") @@ -115,9 +113,6 @@ func actionRun(opts *Options) error { if opts.Desc != "" { bodyReq.Desc = opts.Desc } - if opts.UpstreamID != "" { - bodyReq.UpstreamID = opts.UpstreamID - } if len(labels) > 0 { bodyReq.Labels = labels } diff --git a/pkg/cmd/service/update/update_test.go b/pkg/cmd/service/update/update_test.go index f155739..dd30114 100644 --- a/pkg/cmd/service/update/update_test.go +++ b/pkg/cmd/service/update/update_test.go @@ -48,7 +48,6 @@ func TestUpdateService_Success(t *testing.T) { ID: "s1", Name: "svc-1-updated", Desc: "d2", - UpstreamID: "u2", Labels: []string{"k=v"}, } diff --git a/pkg/cmd/stream-route/create/create.go b/pkg/cmd/stream-route/create/create.go index 184f4c1..ff0c202 100644 --- a/pkg/cmd/stream-route/create/create.go +++ b/pkg/cmd/stream-route/create/create.go @@ -28,7 +28,7 @@ type Options struct { ServerAddr string ServerPort int SNI string - UpstreamID string + ServiceID string Labels []string } @@ -51,7 +51,7 @@ func NewCmd(f *cmd.Factory) *cobra.Command { c.Flags().StringVar(&opts.ServerAddr, "server-addr", "", "Server address") c.Flags().IntVar(&opts.ServerPort, "server-port", 0, "Server port") c.Flags().StringVar(&opts.SNI, "sni", "", "SNI") - c.Flags().StringVar(&opts.UpstreamID, "upstream-id", "", "Bound upstream ID") + c.Flags().StringVar(&opts.ServiceID, "service-id", "", "Bound service ID") c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") return c @@ -98,8 +98,8 @@ func actionRun(opts *Options) error { } return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) } - if opts.UpstreamID == "" { - return fmt.Errorf("--upstream-id is required") + if opts.ServiceID == "" { + return fmt.Errorf("--service-id is required for current API7 EE") } httpClient, err := opts.Client() @@ -122,7 +122,7 @@ func actionRun(opts *Options) error { ServerAddr: opts.ServerAddr, ServerPort: opts.ServerPort, SNI: opts.SNI, - UpstreamID: opts.UpstreamID, + ServiceID: opts.ServiceID, } if len(labels) > 0 { bodyReq.Labels = labels diff --git a/pkg/cmd/stream-route/create/create_test.go b/pkg/cmd/stream-route/create/create_test.go index b2f18eb..5ab168f 100644 --- a/pkg/cmd/stream-route/create/create_test.go +++ b/pkg/cmd/stream-route/create/create_test.go @@ -34,14 +34,14 @@ func (m *mockConfig) Save() error { return n func TestCreateStreamRoute_Success(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} - registry.Register(http.MethodPost, "/apisix/admin/stream_routes", httpmock.JSONResponse(`{"id":"sr1","desc":"mysql","upstream_id":"u1"}`)) + registry.Register(http.MethodPost, "/apisix/admin/stream_routes", httpmock.JSONResponse(`{"id":"sr1","desc":"mysql","service_id":"svc1"}`)) err := actionRun(&Options{ IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, GatewayGroup: "gg1", Desc: "mysql", - UpstreamID: "u1", + ServiceID: "svc1", Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil }, @@ -71,17 +71,17 @@ func TestCreateStreamRoute_ValidationError(t *testing.T) { return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil }, }) - if err == nil || !strings.Contains(err.Error(), "--upstream-id is required") { - t.Fatalf("expected missing upstream-id error, got: %v", err) + if err == nil || !strings.Contains(err.Error(), "--service-id is required") { + t.Fatalf("expected missing service-id error, got: %v", err) } } func TestCreateStreamRoute_MissingGatewayGroup(t *testing.T) { ios, _, _, _ := iostreams.Test() err := actionRun(&Options{ - IO: ios, - Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, - UpstreamID: "u1", + IO: ios, + Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, + ServiceID: "svc1", Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: ""}, nil }, @@ -100,7 +100,7 @@ func TestCreateStreamRoute_APIError(t *testing.T) { IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, GatewayGroup: "gg1", - UpstreamID: "u1", + ServiceID: "svc1", Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil }, diff --git a/pkg/cmd/stream-route/update/update.go b/pkg/cmd/stream-route/update/update.go index 0007265..d4649bd 100644 --- a/pkg/cmd/stream-route/update/update.go +++ b/pkg/cmd/stream-route/update/update.go @@ -29,7 +29,7 @@ type Options struct { ServerAddr string ServerPort int SNI string - UpstreamID string + ServiceID string Labels []string } @@ -52,7 +52,7 @@ func NewCmd(f *cmd.Factory) *cobra.Command { c.Flags().StringVar(&opts.ServerAddr, "server-addr", "", "Server address") c.Flags().IntVar(&opts.ServerPort, "server-port", 0, "Server port") c.Flags().StringVar(&opts.SNI, "sni", "", "SNI") - c.Flags().StringVar(&opts.UpstreamID, "upstream-id", "", "Bound upstream ID") + c.Flags().StringVar(&opts.ServiceID, "service-id", "", "Bound service ID") c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") @@ -93,10 +93,6 @@ func actionRun(opts *Options) error { } return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) } - if opts.UpstreamID == "" { - return fmt.Errorf("--upstream-id is required") - } - httpClient, err := opts.Client() if err != nil { return err @@ -117,7 +113,7 @@ func actionRun(opts *Options) error { ServerAddr: opts.ServerAddr, ServerPort: opts.ServerPort, SNI: opts.SNI, - UpstreamID: opts.UpstreamID, + ServiceID: opts.ServiceID, } if len(labels) > 0 { bodyReq.Labels = labels diff --git a/pkg/cmd/stream-route/update/update_test.go b/pkg/cmd/stream-route/update/update_test.go index 689ffae..e4baa51 100644 --- a/pkg/cmd/stream-route/update/update_test.go +++ b/pkg/cmd/stream-route/update/update_test.go @@ -34,7 +34,7 @@ func (m *mockConfig) Save() error { return n func TestUpdateStreamRoute_Success(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} - registry.Register(http.MethodPut, "/apisix/admin/stream_routes/sr1", httpmock.JSONResponse(`{"id":"sr1","desc":"mysql-updated","upstream_id":"u2"}`)) + registry.Register(http.MethodPut, "/apisix/admin/stream_routes/sr1", httpmock.JSONResponse(`{"id":"sr1","desc":"mysql-updated","service_id":"svc2"}`)) err := actionRun(&Options{ IO: ios, @@ -42,7 +42,7 @@ func TestUpdateStreamRoute_Success(t *testing.T) { GatewayGroup: "gg1", ID: "sr1", Desc: "mysql-updated", - UpstreamID: "u2", + ServiceID: "svc2", Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil }, @@ -62,29 +62,13 @@ func TestUpdateStreamRoute_Success(t *testing.T) { registry.Verify(t) } -func TestUpdateStreamRoute_ValidationError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - err := actionRun(&Options{ - IO: ios, - Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, - GatewayGroup: "gg1", - ID: "sr1", - Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil - }, - }) - if err == nil || !strings.Contains(err.Error(), "--upstream-id is required") { - t.Fatalf("expected missing upstream-id error, got: %v", err) - } -} - func TestUpdateStreamRoute_MissingGatewayGroup(t *testing.T) { ios, _, _, _ := iostreams.Test() err := actionRun(&Options{ - IO: ios, - Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, - ID: "sr1", - UpstreamID: "u1", + IO: ios, + Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, + ID: "sr1", + ServiceID: "svc1", Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: ""}, nil }, @@ -104,7 +88,7 @@ func TestUpdateStreamRoute_APIError(t *testing.T) { Client: func() (*http.Client, error) { return registry.GetClient(), nil }, GatewayGroup: "gg1", ID: "sr1", - UpstreamID: "u1", + ServiceID: "svc1", Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil }, diff --git a/pkg/cmd/upstream/create/create.go b/pkg/cmd/upstream/create/create.go deleted file mode 100644 index 4a742ec..0000000 --- a/pkg/cmd/upstream/create/create.go +++ /dev/null @@ -1,169 +0,0 @@ -package create - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - GatewayGroup string - File string - - Name string - Type string - Nodes []string - Scheme string - Retries int - PassHost string - UpstreamHost string - Labels []string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config, Type: "roundrobin", Scheme: "http"} - c := &cobra.Command{ - Use: "create", - Short: "Create a runtime upstream", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - - c.Flags().StringVar(&opts.Name, "name", "", "Upstream name") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") - c.Flags().StringVar(&opts.Type, "type", "roundrobin", "Load balancing type") - c.Flags().StringSliceVar(&opts.Nodes, "nodes", nil, "Upstream node, repeatable, format host:port=weight") - c.Flags().StringVar(&opts.Scheme, "scheme", "http", "Upstream scheme") - c.Flags().IntVar(&opts.Retries, "retries", 0, "Retry count") - c.Flags().StringVar(&opts.PassHost, "pass-host", "", "Pass host mode") - c.Flags().StringVar(&opts.UpstreamHost, "upstream-host", "", "Upstream host override") - c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - if opts.File != "" { - payload, err := cmdutil.ReadResourceFile(opts.File, opts.IO.In) - if err != nil { - return err - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - var body []byte - if id, ok := payload["id"]; ok { - body, err = client.Put(fmt.Sprintf("/apisix/admin/upstreams/%v?gateway_group_id=%s", id, ggID), payload) - } else { - body, err = client.Post("/apisix/admin/upstreams?gateway_group_id="+ggID, payload) - } - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - nodes, err := parseNodes(opts.Nodes) - if err != nil { - return err - } - - labels := make(map[string]string) - for _, label := range opts.Labels { - parts := strings.SplitN(label, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return fmt.Errorf("invalid label %q, expected key=value", label) - } - labels[parts[0]] = parts[1] - } - - bodyReq := api.Upstream{ - Name: opts.Name, - Type: opts.Type, - Nodes: nodes, - Scheme: opts.Scheme, - Retries: opts.Retries, - PassHost: opts.PassHost, - UpstreamHost: opts.UpstreamHost, - } - if len(labels) > 0 { - bodyReq.Labels = labels - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Post("/apisix/admin/upstreams?gateway_group_id="+ggID, bodyReq) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var created api.Upstream - if err := json.Unmarshal(body, &created); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - format := opts.Output - if format == "" { - format = "json" - } - exporter := cmdutil.NewExporter(format, opts.IO.Out) - return exporter.Write(created) -} - -func parseNodes(items []string) (map[string]int, error) { - out := make(map[string]int) - for _, item := range items { - parts := strings.SplitN(item, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return nil, fmt.Errorf("invalid node %q, expected host:port=weight", item) - } - weight, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid node %q: invalid weight: %w", item, err) - } - out[parts[0]] = weight - } - return out, nil -} diff --git a/pkg/cmd/upstream/create/create_test.go b/pkg/cmd/upstream/create/create_test.go deleted file mode 100644 index a614161..0000000 --- a/pkg/cmd/upstream/create/create_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package create - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestCreateUpstream_Success(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodPost, "/apisix/admin/upstreams", httpmock.JSONResponse(`{"id":"u1","name":"up1"}`)) - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", Name: "up1", Nodes: []string{"127.0.0.1:80=1"}} - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(out.String(), "u1") { - t.Fatalf("expected created upstream in output: %s", out.String()) - } - registry.Verify(t) -} - -func TestCreateUpstream_InvalidNode(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", Name: "up1", Nodes: []string{"bad"}} - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "invalid node") { - t.Fatalf("expected invalid node error, got: %v", err) - } -} diff --git a/pkg/cmd/upstream/delete/delete.go b/pkg/cmd/upstream/delete/delete.go deleted file mode 100644 index 72f6b07..0000000 --- a/pkg/cmd/upstream/delete/delete.go +++ /dev/null @@ -1,84 +0,0 @@ -package delete - -import ( - "bufio" - "fmt" - "net/http" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - GatewayGroup string - ID string - Force bool -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "delete ", - Short: "Delete a runtime upstream", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.ID = args[0] - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - c.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - query := map[string]string{"gateway_group_id": ggID} - if !opts.Force && opts.IO.IsStdinTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Delete upstream %q? (y/N): ", opts.ID) - reader := bufio.NewReader(opts.IO.In) - answer, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read confirmation: %w", err) - } - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer != "y" && answer != "yes" { - fmt.Fprintln(opts.IO.ErrOut, "Aborted.") - return nil - } - } - if _, err := client.Delete("/apisix/admin/upstreams/"+opts.ID, query); err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - _, err = fmt.Fprintf(opts.IO.Out, "Upstream %q deleted.\n", opts.ID) - return err -} diff --git a/pkg/cmd/upstream/delete/delete_test.go b/pkg/cmd/upstream/delete/delete_test.go deleted file mode 100644 index 0823935..0000000 --- a/pkg/cmd/upstream/delete/delete_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package delete - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestDeleteUpstream_Success(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodDelete, "/apisix/admin/upstreams/u1", httpmock.StringResponse(http.StatusNoContent, "")) - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1", ID: "u1"} - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(out.String(), `Upstream "u1" deleted.`) { - t.Fatalf("unexpected output: %s", out.String()) - } - registry.Verify(t) -} - -func TestDeleteUpstream_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local"}, nil }, ID: "u1"} - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected gateway group error, got: %v", err) - } -} diff --git a/pkg/cmd/upstream/export/export.go b/pkg/cmd/upstream/export/export.go deleted file mode 100644 index dce4119..0000000 --- a/pkg/cmd/upstream/export/export.go +++ /dev/null @@ -1,147 +0,0 @@ -package export - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - GatewayGroup string - Label string - Output string - File string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "export", - Short: "Export upstreams as JSON or YAML", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - c.Flags().StringVar(&opts.Label, "label", "", "Filter by label (key=value)") - c.Flags().StringVarP(&opts.Output, "output", "o", "yaml", "Output format: json, yaml") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Write output to file") - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - items, err := fetchAll(client, ggID, opts.Label) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - if len(items) == 0 { - fmt.Fprintln(opts.IO.ErrOut, "No upstreams found.") - return nil - } - - format := opts.Output - if format == "" { - format = "yaml" - } - - var out io.Writer = opts.IO.Out - if opts.File != "" { - f, err := os.Create(opts.File) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer f.Close() - out = f - } - - return cmdutil.NewExporter(format, out).Write(stripTimestamps(items)) -} - -func fetchAll(client *api.Client, ggID, label string) ([]api.Upstream, error) { - page := 1 - pageSize := 100 - var all []api.Upstream - labelKey, labelValue := cmdutil.ParseLabel(label) - - for { - query := map[string]string{ - "gateway_group_id": ggID, - "page": fmt.Sprintf("%d", page), - "page_size": fmt.Sprintf("%d", pageSize), - } - if labelKey != "" { - query["label"] = labelKey - } - - body, err := client.Get("/apisix/admin/upstreams", query) - if err != nil { - return nil, err - } - - var resp api.ListResponse[api.Upstream] - if err := json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - for _, item := range resp.List { - if labelValue != "" && (item.Labels == nil || item.Labels[labelKey] != labelValue) { - continue - } - all = append(all, item) - } - - if len(resp.List) == 0 || page*pageSize >= resp.Total { - break - } - page++ - } - - return all, nil -} - -func stripTimestamps(items []api.Upstream) []map[string]interface{} { - out := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - var m map[string]interface{} - b, _ := json.Marshal(item) - _ = json.Unmarshal(b, &m) - delete(m, "create_time") - delete(m, "update_time") - out = append(out, m) - } - return out -} diff --git a/pkg/cmd/upstream/export/export_test.go b/pkg/cmd/upstream/export/export_test.go deleted file mode 100644 index a3baca7..0000000 --- a/pkg/cmd/upstream/export/export_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package export - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestExport_Success(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/upstreams", httpmock.JSONResponse(`{"total":1,"list":[{"id":"u1","name":"demo"}]}`)) - - opts := &Options{ - IO: ios, - Client: func() (*http.Client, error) { return registry.GetClient(), nil }, - Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, - GatewayGroup: "gg1", - Output: "json", - } - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - - if !strings.Contains(out.String(), "u1") { - t.Fatalf("expected upstream in output, got: %s", out.String()) - } - registry.Verify(t) -} - -func TestExport_Empty(t *testing.T) { - ios, _, _, errBuf := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/upstreams", httpmock.JSONResponse(`{"total":0,"list":[]}`)) - - opts := &Options{ - IO: ios, - Client: func() (*http.Client, error) { return registry.GetClient(), nil }, - Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, - GatewayGroup: "gg1", - Output: "json", - } - - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(errBuf.String(), "No upstreams found") { - t.Fatalf("expected no upstreams message, got: %s", errBuf.String()) - } -} diff --git a/pkg/cmd/upstream/get/get.go b/pkg/cmd/upstream/get/get.go deleted file mode 100644 index 947e03a..0000000 --- a/pkg/cmd/upstream/get/get.go +++ /dev/null @@ -1,90 +0,0 @@ -package get - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" - "github.com/api7/a7/pkg/tableprinter" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - GatewayGroup string - ID string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "get ", - Short: "Get a runtime upstream", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.ID = args[0] - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - query := map[string]string{"gateway_group_id": ggID} - body, err := client.Get("/apisix/admin/upstreams/"+opts.ID, query) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var item api.Upstream - if err := json.Unmarshal(body, &item); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - if opts.Output != "" { - exporter := cmdutil.NewExporter(opts.Output, opts.IO.Out) - return exporter.Write(item) - } - - tp := tableprinter.New(opts.IO.Out) - tp.SetHeaders("FIELD", "VALUE") - tp.AddRow("id", item.ID) - tp.AddRow("name", item.Name) - tp.AddRow("type", item.Type) - tp.AddRow("scheme", item.Scheme) - tp.AddRow("retries", fmt.Sprintf("%d", item.Retries)) - tp.AddRow("pass_host", item.PassHost) - tp.AddRow("upstream_host", item.UpstreamHost) - - return tp.Render() -} diff --git a/pkg/cmd/upstream/get/get_test.go b/pkg/cmd/upstream/get/get_test.go deleted file mode 100644 index 4d49576..0000000 --- a/pkg/cmd/upstream/get/get_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package get - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestGetUpstream_Table(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/upstreams/u1", httpmock.JSONResponse(`{"id":"u1","name":"up1","type":"roundrobin"}`)) - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, ID: "u1", GatewayGroup: "gg1"} - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(out.String(), "up1") { - t.Fatalf("expected upstream name in output: %s", out.String()) - } - registry.Verify(t) -} - -func TestGetUpstream_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local"}, nil }, ID: "u1"} - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected gateway group error, got: %v", err) - } -} diff --git a/pkg/cmd/upstream/list/list.go b/pkg/cmd/upstream/list/list.go deleted file mode 100644 index feb03c5..0000000 --- a/pkg/cmd/upstream/list/list.go +++ /dev/null @@ -1,123 +0,0 @@ -package list - -import ( - "encoding/json" - "fmt" - "net/http" - "sort" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" - "github.com/api7/a7/pkg/tableprinter" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - GatewayGroup string - Label string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "list", - Short: "List runtime upstreams", - Aliases: []string{"ls"}, - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - opts.Label, _ = c.Flags().GetString("label") - return actionRun(opts) - }, - } - c.Flags().StringVar(&opts.Label, "label", "", "Filter by label (key=value)") - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - query := map[string]string{"gateway_group_id": ggID} - labelKey, labelValue := cmdutil.ParseLabel(opts.Label) - if labelKey != "" { - query["label"] = labelKey - } - body, err := client.Get("/apisix/admin/upstreams", query) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var resp api.ListResponse[api.Upstream] - if err := json.Unmarshal(body, &resp); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - if labelValue != "" { - filtered := make([]api.Upstream, 0) - for _, item := range resp.List { - if item.Labels != nil && item.Labels[labelKey] == labelValue { - filtered = append(filtered, item) - } - } - resp.List = filtered - } - - if opts.Output != "" { - exporter := cmdutil.NewExporter(opts.Output, opts.IO.Out) - return exporter.Write(resp.List) - } - - tp := tableprinter.New(opts.IO.Out) - tp.SetHeaders("ID", "NAME", "TYPE", "NODES") - for _, item := range resp.List { - tp.AddRow(item.ID, item.Name, item.Type, renderNodes(item.Nodes)) - } - - return tp.Render() -} - -func renderNodes(nodes map[string]int) string { - if len(nodes) == 0 { - return "" - } - keys := make([]string, 0, len(nodes)) - for k := range nodes { - keys = append(keys, k) - } - sort.Strings(keys) - - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, k+"="+strconv.Itoa(nodes[k])) - } - - return strings.Join(parts, ", ") -} diff --git a/pkg/cmd/upstream/list/list_test.go b/pkg/cmd/upstream/list/list_test.go deleted file mode 100644 index 1f17846..0000000 --- a/pkg/cmd/upstream/list/list_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package list - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestListUpstream_Table(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodGet, "/apisix/admin/upstreams", httpmock.JSONResponse(`{"total":1,"list":[{"id":"u1","name":"up1","type":"roundrobin","nodes":{"127.0.0.1:80":1}}]}`)) - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, GatewayGroup: "gg1"} - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(out.String(), "u1") { - t.Fatalf("expected upstream id in output: %s", out.String()) - } - registry.Verify(t) -} - -func TestListUpstream_MissingGatewayGroup(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local"}, nil }} - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "gateway group is required") { - t.Fatalf("expected gateway group error, got: %v", err) - } -} diff --git a/pkg/cmd/upstream/update/update.go b/pkg/cmd/upstream/update/update.go deleted file mode 100644 index 59a3d8f..0000000 --- a/pkg/cmd/upstream/update/update.go +++ /dev/null @@ -1,163 +0,0 @@ -package update - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/api" - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmdutil" - "github.com/api7/a7/pkg/iostreams" -) - -type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - File string - GatewayGroup string - ID string - - Name string - Type string - Nodes []string - Scheme string - Retries int - PassHost string - UpstreamHost string - Labels []string -} - -func NewCmd(f *cmd.Factory) *cobra.Command { - opts := &Options{IO: f.IOStreams, Client: f.HttpClient, Config: f.Config} - c := &cobra.Command{ - Use: "update ", - Short: "Update a runtime upstream", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - opts.ID = args[0] - opts.Output, _ = c.Flags().GetString("output") - opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") - return actionRun(opts) - }, - } - - c.Flags().StringVar(&opts.Name, "name", "", "Upstream name") - c.Flags().StringVar(&opts.Type, "type", "", "Load balancing type") - c.Flags().StringSliceVar(&opts.Nodes, "nodes", nil, "Upstream node, repeatable, format host:port=weight") - c.Flags().StringVar(&opts.Scheme, "scheme", "", "Upstream scheme") - c.Flags().IntVar(&opts.Retries, "retries", 0, "Retry count") - c.Flags().StringVar(&opts.PassHost, "pass-host", "", "Pass host mode") - c.Flags().StringVar(&opts.UpstreamHost, "upstream-host", "", "Upstream host override") - c.Flags().StringSliceVar(&opts.Labels, "labels", nil, "Labels in key=value format") - c.Flags().StringVarP(&opts.File, "file", "f", "", "Path to JSON/YAML file with resource definition") - - return c -} - -func actionRun(opts *Options) error { - cfg, err := opts.Config() - if err != nil { - return err - } - - ggID := opts.GatewayGroup - if ggID == "" { - ggID = cfg.GatewayGroup() - } - if ggID == "" { - return fmt.Errorf("gateway group is required; use --gateway-group flag or set a default in context config") - } - - httpClient, err := opts.Client() - if err != nil { - return err - } - - if opts.File != "" { - payload, err := cmdutil.ReadResourceFile(opts.File, opts.IO.In) - if err != nil { - return err - } - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Put("/apisix/admin/upstreams/"+opts.ID+"?gateway_group_id="+ggID, payload) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - format := opts.Output - if format == "" { - format = "json" - } - return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) - } - - nodes, err := parseNodes(opts.Nodes) - if err != nil { - return err - } - - labels := make(map[string]string) - for _, label := range opts.Labels { - parts := strings.SplitN(label, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return fmt.Errorf("invalid label %q, expected key=value", label) - } - labels[parts[0]] = parts[1] - } - - bodyReq := api.Upstream{ - Name: opts.Name, - Type: opts.Type, - Scheme: opts.Scheme, - Retries: opts.Retries, - PassHost: opts.PassHost, - UpstreamHost: opts.UpstreamHost, - } - if len(nodes) > 0 { - bodyReq.Nodes = nodes - } - if len(labels) > 0 { - bodyReq.Labels = labels - } - - client := api.NewClient(httpClient, cfg.BaseURL()) - body, err := client.Put("/apisix/admin/upstreams/"+opts.ID+"?gateway_group_id="+ggID, bodyReq) - if err != nil { - return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) - } - - var updated api.Upstream - if err := json.Unmarshal(body, &updated); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - format := opts.Output - if format == "" { - format = "json" - } - exporter := cmdutil.NewExporter(format, opts.IO.Out) - return exporter.Write(updated) -} - -func parseNodes(items []string) (map[string]int, error) { - out := make(map[string]int) - for _, item := range items { - parts := strings.SplitN(item, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return nil, fmt.Errorf("invalid node %q, expected host:port=weight", item) - } - weight, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid node %q: invalid weight: %w", item, err) - } - out[parts[0]] = weight - } - return out, nil -} diff --git a/pkg/cmd/upstream/update/update_test.go b/pkg/cmd/upstream/update/update_test.go deleted file mode 100644 index e976b1d..0000000 --- a/pkg/cmd/upstream/update/update_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package update - -import ( - "net/http" - "strings" - "testing" - - "github.com/api7/a7/internal/config" - "github.com/api7/a7/pkg/httpmock" - "github.com/api7/a7/pkg/iostreams" -) - -type mockConfig struct { - baseURL string - token string - gatewayGroup string -} - -func (m *mockConfig) BaseURL() string { return m.baseURL } -func (m *mockConfig) Token() string { return m.token } -func (m *mockConfig) GatewayGroup() string { return m.gatewayGroup } -func (m *mockConfig) TLSSkipVerify() bool { return false } -func (m *mockConfig) CACert() string { return "" } -func (m *mockConfig) CurrentContext() string { return "test" } -func (m *mockConfig) Contexts() []config.Context { return nil } -func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } -func (m *mockConfig) AddContext(ctx config.Context) error { return nil } -func (m *mockConfig) RemoveContext(name string) error { return nil } -func (m *mockConfig) SetCurrentContext(name string) error { return nil } -func (m *mockConfig) Save() error { return nil } - -func TestUpdateUpstream_Success(t *testing.T) { - ios, _, out, _ := iostreams.Test() - registry := &httpmock.Registry{} - registry.Register(http.MethodPut, "/apisix/admin/upstreams/u1", httpmock.JSONResponse(`{"id":"u1","name":"up2"}`)) - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, ID: "u1", GatewayGroup: "gg1", Name: "up2", Nodes: []string{"127.0.0.1:80=1"}} - if err := actionRun(opts); err != nil { - t.Fatalf("actionRun failed: %v", err) - } - if !strings.Contains(out.String(), "up2") { - t.Fatalf("expected updated upstream output: %s", out.String()) - } - registry.Verify(t) -} - -func TestUpdateUpstream_InvalidNode(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { - return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, ID: "u1", GatewayGroup: "gg1", Nodes: []string{"bad"}} - err := actionRun(opts) - if err == nil || !strings.Contains(err.Error(), "invalid node") { - t.Fatalf("expected invalid node error, got: %v", err) - } -} diff --git a/pkg/cmd/upstream/upstream.go b/pkg/cmd/upstream/upstream.go deleted file mode 100644 index d23ac95..0000000 --- a/pkg/cmd/upstream/upstream.go +++ /dev/null @@ -1,30 +0,0 @@ -package upstream - -import ( - "github.com/spf13/cobra" - - cmd "github.com/api7/a7/pkg/cmd" - "github.com/api7/a7/pkg/cmd/upstream/create" - del "github.com/api7/a7/pkg/cmd/upstream/delete" - "github.com/api7/a7/pkg/cmd/upstream/export" - "github.com/api7/a7/pkg/cmd/upstream/get" - "github.com/api7/a7/pkg/cmd/upstream/list" - "github.com/api7/a7/pkg/cmd/upstream/update" -) - -func NewCmd(f *cmd.Factory) *cobra.Command { - c := &cobra.Command{ - Use: "upstream", - Short: "Manage runtime upstreams", - Aliases: []string{"us"}, - } - - c.AddCommand(list.NewCmd(f)) - c.AddCommand(get.NewCmd(f)) - c.AddCommand(create.NewCmd(f)) - c.AddCommand(update.NewCmd(f)) - c.AddCommand(del.NewCmd(f)) - c.AddCommand(export.NewCmd(f)) - - return c -} diff --git a/skills/a7-persona-developer/SKILL.md b/skills/a7-persona-developer/SKILL.md index 8d2bc7c..f2ab3b0 100644 --- a/skills/a7-persona-developer/SKILL.md +++ b/skills/a7-persona-developer/SKILL.md @@ -2,8 +2,8 @@ name: a7-persona-developer description: >- Persona skill for API developers building and testing APIs on API7 Enterprise Edition (API7 EE) - using the a7 CLI. Provides decision frameworks for API design, Service Template - lifecycle, route publication, plugin configuration, and local-to-cloud development workflows. + using the a7 CLI. Provides decision frameworks for service-backed API design, + route configuration, plugin configuration, and local-to-cloud development workflows. version: "1.0.0" author: API7.ai Contributors license: Apache-2.0 @@ -11,8 +11,6 @@ metadata: category: persona apisix_version: ">=3.0.0" a7_commands: - - a7 service-template create - - a7 service-template publish - a7 route create - a7 service create - a7 consumer create @@ -27,7 +25,7 @@ metadata: You are an **API Developer** responsible for: - Designing API schemas and configuring routes within a **Gateway Group**. -- Leveraging **Service Templates** to standardize API deployments across environments. +- Defining **Services** with inline upstreams and attaching routes with `service_id`. - Publishing APIs to gateway groups with service-backed routes. - Configuring advanced enterprise plugins (OIDC, Canary, Request/Response Transformation). - Debugging complex request flows using built-in enterprise tracing tools. @@ -36,9 +34,8 @@ You are an **API Developer** responsible for: In API7 EE, developers work within a structured lifecycle: 1. **Gateway Groups**: Your assigned workspace (e.g., `ecommerce-dev`). -2. **Service Templates**: Blueprints for services (e.g., `payment-service-v1`). -3. **Publication**: Promoting a Service Template to a live Gateway Group. -4. **Service-backed Routes**: Routes should reference a service with `service_id` in current API7 EE. +2. **Services**: Runtime service definitions with inline upstreams (e.g., `payment-service-v1`). +3. **Service-backed Routes**: Routes should reference a service with `service_id` in current API7 EE. ## Getting Started @@ -69,18 +66,19 @@ a7 plugin get openid-connect -g my-group --output json ## Building & Publishing Your API -### Step 1: Create a Service Template +### Step 1: Create a Service -Standardize your service configuration before deploying it to a gateway. +Create the service configuration in the target gateway group. ```bash -a7 service-template create -f - <<'EOF' +a7 service create -g staging-group -f - <<'EOF' { - "name": "user-service-template", - "desc": "Template for User Management API", + "id": "user-service", + "name": "user-service", + "desc": "User Management API", "upstream": { "type": "roundrobin", - "nodes": { "user-backend.internal:8080": 1 } + "nodes": [{"host": "user-backend.internal", "port": 8080, "weight": 1}] }, "plugins": { "key-auth": {} @@ -89,14 +87,7 @@ a7 service-template create -f - <<'EOF' EOF ``` -### Step 2: Publish to a Gateway Group - -```bash -# Publish the template to the 'staging-group' -a7 service-template publish user-service-template --group staging-group -``` - -### Step 3: Configure a Route within the Group +### Step 2: Configure a Route within the Group ```bash a7 route create -g staging-group -f - <<'EOF' @@ -104,7 +95,7 @@ a7 route create -g staging-group -f - <<'EOF' "id": "user-v1-get", "uri": "/v1/users/*", "methods": ["GET"], - "service_id": "user-service-template", + "service_id": "user-service", "plugins": { "proxy-rewrite": { "regex_uri": ["^/v1/users/(.*)", "/users/$1"] @@ -178,10 +169,10 @@ Automate your API lifecycle using `a7` in your pipelines. ```yaml # Example GitHub Action Step -- name: Publish Service Template +- name: Sync Service Config run: | - a7 service-template publish ${{ env.SERVICE_NAME }} \ - --group ${{ env.TARGET_GROUP }} \ + a7 config sync -f api7.yaml \ + --gateway-group ${{ env.TARGET_GROUP }} \ --token ${{ secrets.A7_TOKEN }} ``` @@ -189,8 +180,8 @@ Automate your API lifecycle using `a7` in your pipelines. | Situation | Action | Command | |-----------|--------|---------| -| Standardizing multiple APIs | Use a Service Template | `a7 service-template create` | -| Promoting to production | Publish Template to Group | `a7 service-template publish` | +| Standardizing multiple APIs | Use services with inline upstreams and shared plugins | `a7 service create` | +| Promoting to production | Sync service and route config to target group | `a7 config sync` | | Exposing an API path | Create or update a service-backed route | `a7 route create -f route.yaml` | | Backend URI mismatch | Use `proxy-rewrite` | `a7 route update ...` | | Testing Canary version | Use `traffic-split` | `a7 route update ...` | @@ -198,11 +189,11 @@ Automate your API lifecycle using `a7` in your pipelines. ## Best Practices -1. **Templates First**: Always create a **Service Template** for reusable service logic. +1. **Services First**: Put reusable upstream and plugin configuration on services. 2. **Group Scoping**: Always use the `-g` flag to target the correct environment. 3. **Port & Protocol**: Ensure you are connecting to the Dashboard via HTTPS on port `7443`. 4. **Token Security**: Do not hardcode your `--token` in scripts; use environment variables or secrets. 5. **Declarative Sync**: Prefer `a7 config sync` for complex multi-route deployments. -6. **Documentation**: Always provide a description (`--desc`) for routes and templates for colleagues. +6. **Documentation**: Always provide a description (`--desc`) for routes and services for colleagues. 7. **Trace Verbosity**: Use `--verbose` in `debug trace` to inspect plugin input/output headers. 8. **Route Model**: Prefer `service create` plus `route create` with `service_id`; avoid standalone upstream workflows for API7 EE. diff --git a/skills/a7-plugin-ai-content-moderation/SKILL.md b/skills/a7-plugin-ai-content-moderation/SKILL.md index a0382b6..1ad1ddf 100644 --- a/skills/a7-plugin-ai-content-moderation/SKILL.md +++ b/skills/a7-plugin-ai-content-moderation/SKILL.md @@ -17,7 +17,7 @@ metadata: a7_commands: - a7 route create - a7 route update - - a7 service-template create + - a7 service create - a7 config sync --- @@ -40,7 +40,7 @@ Both must be used alongside `ai-proxy` or `ai-proxy-multi`. - Block toxic, hateful, or sexual content before it reaches the LLM - Filter harmful LLM responses before they reach clients (Aliyun only) - Enforce content policies with configurable thresholds -- Apply consistent moderation policies using **Service Templates** +- Apply consistent moderation policies directly on services or routes ## Plugin Execution Order @@ -159,12 +159,12 @@ a7 route create -g default -f - <<'EOF' EOF ``` -## Using Service Templates +## Using Services -You can define standard moderation policies on a **Service Template** to reuse them across multiple services. +You can define standard moderation policies on a service and attach routes to that service. ```bash -a7 service-template create -f - <<'EOF' +a7 service create -g default -f - <<'EOF' { "id": "standard-moderation", "name": "Standard Moderation", diff --git a/skills/a7-plugin-ai-prompt-decorator/SKILL.md b/skills/a7-plugin-ai-prompt-decorator/SKILL.md index fdc34d9..b1f12fb 100644 --- a/skills/a7-plugin-ai-prompt-decorator/SKILL.md +++ b/skills/a7-plugin-ai-prompt-decorator/SKILL.md @@ -14,7 +14,7 @@ metadata: a7_commands: - a7 route create - a7 route update - - a7 service-template create + - a7 service create - a7 config sync --- @@ -36,7 +36,7 @@ without modifying client code. - Append output format instructions (e.g. "respond in JSON") - Add conversation context that clients should not control - Combine with `ai-prompt-template` for structured + decorated prompts -- Apply consistent prompt decorations across services using **Service Templates** +- Apply consistent prompt decorations directly on services or routes ## Plugin Configuration Reference @@ -100,12 +100,12 @@ curl http://127.0.0.1:9080/v1/chat/completions \ }' ``` -## Using Service Templates +## Using Services -You can define standard prompt decorations on a **Service Template** to reuse them across multiple services. +You can define standard prompt decorations on a service and attach routes to that service. ```bash -a7 service-template create -f - <<'EOF' +a7 service create -g default -f - <<'EOF' { "id": "global-ai-safety", "name": "Global AI Safety", diff --git a/skills/a7-plugin-ai-prompt-template/SKILL.md b/skills/a7-plugin-ai-prompt-template/SKILL.md index ef78d3c..928c31c 100644 --- a/skills/a7-plugin-ai-prompt-template/SKILL.md +++ b/skills/a7-plugin-ai-prompt-template/SKILL.md @@ -14,7 +14,7 @@ metadata: a7_commands: - a7 route create - a7 route update - - a7 service-template create + - a7 service create - a7 config sync --- @@ -37,7 +37,7 @@ clients from sending arbitrary system prompts. - Accept user inputs only for specific fields (fill-in-the-blank) - Prevent prompt injection by controlling the system message - Build prompt libraries that clients select by name -- Standardize prompt templates across services using **Service Templates** +- Standardize prompt templates directly on services or routes ## Plugin Configuration Reference @@ -124,12 +124,12 @@ curl http://127.0.0.1:9080/v1/chat/completions \ }' ``` -## Using Service Templates +## Using Services -You can define standard prompt templates on a **Service Template** to reuse them across multiple services. +You can define standard prompt templates on a service and attach routes to that service. ```bash -a7 service-template create -f - <<'EOF' +a7 service create -g default -f - <<'EOF' { "id": "global-ai-prompts", "name": "Global AI Prompts", diff --git a/skills/a7-plugin-ai-proxy/SKILL.md b/skills/a7-plugin-ai-proxy/SKILL.md index 74c7b84..7f3915a 100644 --- a/skills/a7-plugin-ai-proxy/SKILL.md +++ b/skills/a7-plugin-ai-proxy/SKILL.md @@ -4,7 +4,7 @@ description: >- Skill for configuring the API7 Enterprise Edition ai-proxy plugin via the a7 CLI. Covers proxying requests to LLM providers (OpenAI, Azure OpenAI, DeepSeek, Anthropic, Gemini, Vertex AI, and more), authentication per provider, - model configuration, streaming, logging, and service template usage. + model configuration, streaming, logging, and route/service usage. version: "1.0.0" author: API7.ai Contributors license: Apache-2.0 @@ -15,7 +15,7 @@ metadata: a7_commands: - a7 route create - a7 route update - - a7 service-template create + - a7 service create - a7 config sync --- @@ -35,7 +35,7 @@ request; the plugin translates and forwards it to the configured provider. - Add observability (token counts, latency) to LLM calls - Combine with `ai-prompt-template`, `ai-prompt-decorator`, or content moderation plugins for a full AI gateway pipeline -- Apply consistent AI proxy configurations across services using **Service Templates** +- Apply consistent AI proxy configurations directly on services or routes ## Supported Providers @@ -168,12 +168,12 @@ curl http://127.0.0.1:9080/v1/chat/completions \ }' ``` -## Using Service Templates +## Using Services -In API7 EE, you can enable `ai-proxy` on a **Service Template** to standardize AI gateway configurations across multiple services. +In API7 EE, configure `ai-proxy` directly on a service or route. Services are the preferred place for reusable upstream and plugin configuration. ```bash -a7 service-template create -f - <<'EOF' +a7 service create -g default -f - <<'EOF' { "id": "standard-ai-proxy", "name": "Standard AI Proxy", diff --git a/skills/a7-plugin-basic-auth/SKILL.md b/skills/a7-plugin-basic-auth/SKILL.md index e10181a..2552f03 100644 --- a/skills/a7-plugin-basic-auth/SKILL.md +++ b/skills/a7-plugin-basic-auth/SKILL.md @@ -91,9 +91,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -191,12 +189,12 @@ gateway_groups: uri: /api/* plugins: basic-auth: {} - upstream_id: my-upstream - upstreams: - - id: my-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` > **Note**: Consumer credentials (username/password) must be created separately diff --git a/skills/a7-plugin-consumer-restriction/SKILL.md b/skills/a7-plugin-consumer-restriction/SKILL.md index 2af0ae8..8e3c2c6 100644 --- a/skills/a7-plugin-consumer-restriction/SKILL.md +++ b/skills/a7-plugin-consumer-restriction/SKILL.md @@ -2,8 +2,8 @@ name: a7-plugin-consumer-restriction description: >- Skill for configuring the API7 Enterprise Edition consumer-restriction plugin via the - a7 CLI. Covers restricting access by consumer name, consumer group ID, - service ID, or route ID using whitelist/blacklist modes and per-consumer + a7 CLI. Covers restricting access by consumer name, service ID, or route ID + using whitelist/blacklist modes and per-consumer HTTP method restrictions. version: "1.0.0" author: API7.ai Contributors @@ -36,7 +36,7 @@ consumer. ## When to Use -- Restrict specific routes to certain consumers or consumer groups. +- Restrict specific routes to certain consumers. - Implement tiered access (free vs premium consumers). - Control which HTTP methods each consumer can use. - Restrict consumers to specific services or routes. @@ -45,7 +45,7 @@ consumer. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `type` | string | No | `consumer_name` | Restriction type: `consumer_name`, `consumer_group_id`, `service_id`, `route_id` | +| `type` | string | No | `consumer_name` | Restriction type: `consumer_name`, `service_id`, `route_id` | | `whitelist` | array[string] | One of three\* | — | Allowed identifiers | | `blacklist` | array[string] | One of three\* | — | Blocked identifiers | | `allowed_by_methods` | array[object] | One of three\* | — | Per-consumer HTTP method restrictions | @@ -71,7 +71,6 @@ blacklist (highest) > whitelist > allowed_by_methods (lowest) | Type | Configure On | Description | |------|-------------|-------------| | `consumer_name` | Route/Service | Restrict which consumers can access this route | -| `consumer_group_id` | Route/Service | Restrict which consumer groups can access this route | | `service_id` | **Consumer** | Restrict which services this consumer can access | | `route_id` | **Consumer** | Restrict which routes this consumer can access | @@ -114,7 +113,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -142,43 +141,28 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF ``` -### 3. Restrict by Consumer Group +### 3. Restrict Named Consumers -Only allow consumers in `enterprise` group: +Current API7 EE does not expose consumer group management through the Admin API. +Use `consumer_name` allowlists or denylists when configuring consumer restrictions. ```bash -# Create consumer group -a7 consumer-group create --gateway-group default -f - <<'EOF' -{ - "id": "enterprise", - "plugins": { - "limit-count": { - "count": 10000, - "time_window": 60, - "group": "enterprise" - } - } -} -EOF - -# Create consumer in the group a7 consumer create --gateway-group default -f - <<'EOF' { "username": "acme-corp", "plugins": { "key-auth": {"key": "acme-key"} - }, - "group_id": "enterprise" + } } EOF -# Route restricted to enterprise group +# Route restricted to named consumers a7 route create --gateway-group default -f - <<'EOF' { "id": "enterprise-only", @@ -186,13 +170,13 @@ a7 route create --gateway-group default -f - <<'EOF' "plugins": { "key-auth": {}, "consumer-restriction": { - "type": "consumer_group_id", - "whitelist": ["enterprise"] + "type": "consumer_name", + "whitelist": ["acme-corp"] } }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -224,7 +208,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -295,7 +279,12 @@ routes: - admin rejected_code: 403 rejected_msg: "Admin access required" - upstream_id: admin-backend + upstream: + type: roundrobin + nodes: + - host: admin-backend + port: 8080 + weight: 1 - id: public-api uri: /api/* @@ -307,7 +296,12 @@ routes: methods: ["GET"] - user: admin methods: ["GET", "POST", "PUT", "DELETE"] - upstream_id: api-backend + upstream: + type: roundrobin + nodes: + - host: api-backend + port: 8080 + weight: 1 ``` ## Common Patterns @@ -319,8 +313,8 @@ routes: "plugins": { "key-auth": {}, "consumer-restriction": { - "type": "consumer_group_id", - "whitelist": ["enterprise", "pro"], + "type": "consumer_name", + "whitelist": ["enterprise-user", "pro-user"], "rejected_code": 402, "rejected_msg": "Upgrade required for this endpoint" } diff --git a/skills/a7-plugin-cors/SKILL.md b/skills/a7-plugin-cors/SKILL.md index b385896..f8a7f13 100644 --- a/skills/a7-plugin-cors/SKILL.md +++ b/skills/a7-plugin-cors/SKILL.md @@ -70,9 +70,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -105,9 +103,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -225,10 +221,10 @@ gateway_groups: expose_headers: "X-Request-Id" max_age: 3600 allow_credential: true - upstream_id: api-upstream - upstreams: - - id: api-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-datadog/SKILL.md b/skills/a7-plugin-datadog/SKILL.md index 0d22357..b861da2 100644 --- a/skills/a7-plugin-datadog/SKILL.md +++ b/skills/a7-plugin-datadog/SKILL.md @@ -134,9 +134,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -210,7 +208,12 @@ routes: include_method: true constant_tags: - "team:platform" - upstream_id: my-upstream + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` ## Troubleshooting diff --git a/skills/a7-plugin-ext-plugin/SKILL.md b/skills/a7-plugin-ext-plugin/SKILL.md index d348604..5bb1c79 100644 --- a/skills/a7-plugin-ext-plugin/SKILL.md +++ b/skills/a7-plugin-ext-plugin/SKILL.md @@ -155,7 +155,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -179,7 +179,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -205,7 +205,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -227,7 +227,12 @@ routes: - name: AuthFilter value: '{"token_required":true}' allow_degradation: true - upstream_id: my-upstream + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` ## Compatibility Matrix diff --git a/skills/a7-plugin-fault-injection/SKILL.md b/skills/a7-plugin-fault-injection/SKILL.md index b7f5804..7d4fcec 100644 --- a/skills/a7-plugin-fault-injection/SKILL.md +++ b/skills/a7-plugin-fault-injection/SKILL.md @@ -112,7 +112,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -136,7 +136,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -164,7 +164,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -198,7 +198,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -225,7 +225,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -253,7 +253,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -284,7 +284,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -307,7 +307,12 @@ routes: http_status: 503 body: "Service unavailable" percentage: 5 - upstream_id: my-upstream + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` ## Execution Behavior diff --git a/skills/a7-plugin-grpc-transcode/SKILL.md b/skills/a7-plugin-grpc-transcode/SKILL.md index a2c3889..505d506 100644 --- a/skills/a7-plugin-grpc-transcode/SKILL.md +++ b/skills/a7-plugin-grpc-transcode/SKILL.md @@ -100,9 +100,7 @@ a7 route create --gateway-group default -f - <<'EOF' "upstream": { "scheme": "grpc", "type": "roundrobin", - "nodes": { - "grpc-server:50051": 1 - } + "nodes": [{"host": "grpc-server", "port": 50051, "weight": 1}] } } EOF @@ -236,11 +234,11 @@ routes: pb_option: - int64_as_string - enum_as_name - upstream_id: grpc-backend -upstreams: - - id: grpc-backend - scheme: grpc - type: roundrobin - nodes: - "grpc-server:50051": 1 + upstream: + scheme: grpc + type: roundrobin + nodes: + - host: grpc-server + port: 50051 + weight: 1 ``` diff --git a/skills/a7-plugin-hmac-auth/SKILL.md b/skills/a7-plugin-hmac-auth/SKILL.md index 225f573..b94f843 100644 --- a/skills/a7-plugin-hmac-auth/SKILL.md +++ b/skills/a7-plugin-hmac-auth/SKILL.md @@ -97,9 +97,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -258,12 +256,12 @@ gateway_groups: uri: /api/* plugins: hmac-auth: {} - upstream_id: my-upstream - upstreams: - - id: my-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` > **Note**: Consumer credentials (key_id/secret_key) must be created separately diff --git a/skills/a7-plugin-http-logger/SKILL.md b/skills/a7-plugin-http-logger/SKILL.md index cb6ff4a..ce88dd6 100644 --- a/skills/a7-plugin-http-logger/SKILL.md +++ b/skills/a7-plugin-http-logger/SKILL.md @@ -106,9 +106,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -218,5 +216,10 @@ routes: client_ip: "$remote_addr" method: "$request_method" status: "$status" - upstream_id: my-upstream + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-ip-restriction/SKILL.md b/skills/a7-plugin-ip-restriction/SKILL.md index a24ed6c..8b5a839 100644 --- a/skills/a7-plugin-ip-restriction/SKILL.md +++ b/skills/a7-plugin-ip-restriction/SKILL.md @@ -76,9 +76,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -113,9 +111,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -201,10 +197,10 @@ gateway_groups: whitelist: - "10.0.0.0/8" - "172.16.0.0/12" - upstream_id: admin-upstream - upstreams: - - id: admin-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-jwt-auth/SKILL.md b/skills/a7-plugin-jwt-auth/SKILL.md index 12918f8..5c3e491 100644 --- a/skills/a7-plugin-jwt-auth/SKILL.md +++ b/skills/a7-plugin-jwt-auth/SKILL.md @@ -120,9 +120,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -285,12 +283,12 @@ gateway_groups: uri: /api/* plugins: jwt-auth: {} - upstream_id: my-upstream - upstreams: - - id: my-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` > **Note**: Consumer credentials (including JWT keys/secrets) must be created diff --git a/skills/a7-plugin-kafka-logger/SKILL.md b/skills/a7-plugin-kafka-logger/SKILL.md index c02cd5f..d189350 100644 --- a/skills/a7-plugin-kafka-logger/SKILL.md +++ b/skills/a7-plugin-kafka-logger/SKILL.md @@ -95,9 +95,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -214,5 +212,10 @@ routes: required_acks: 1 batch_max_size: 200 inactive_timeout: 5 - upstream_id: my-upstream + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-key-auth/SKILL.md b/skills/a7-plugin-key-auth/SKILL.md index 4a45fd8..2b386db 100644 --- a/skills/a7-plugin-key-auth/SKILL.md +++ b/skills/a7-plugin-key-auth/SKILL.md @@ -99,9 +99,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -225,12 +223,12 @@ gateway_groups: uri: /api/* plugins: key-auth: {} - upstream_id: my-upstream - upstreams: - - id: my-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` > **Note**: Consumer credentials must be created separately via the Admin API; diff --git a/skills/a7-plugin-limit-count/SKILL.md b/skills/a7-plugin-limit-count/SKILL.md index 3ded9f8..f581bca 100644 --- a/skills/a7-plugin-limit-count/SKILL.md +++ b/skills/a7-plugin-limit-count/SKILL.md @@ -117,9 +117,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -298,10 +296,10 @@ gateway_groups: time_window: 60 key: remote_addr rejected_code: 429 - upstream_id: api-upstream - upstreams: - - id: api-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-limit-req/SKILL.md b/skills/a7-plugin-limit-req/SKILL.md index 222eaa5..f931e92 100644 --- a/skills/a7-plugin-limit-req/SKILL.md +++ b/skills/a7-plugin-limit-req/SKILL.md @@ -114,9 +114,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -141,9 +139,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -255,10 +251,10 @@ gateway_groups: burst: 20 key: remote_addr rejected_code: 429 - upstream_id: api-upstream - upstreams: - - id: api-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-openid-connect/SKILL.md b/skills/a7-plugin-openid-connect/SKILL.md index dc276f3..870368f 100644 --- a/skills/a7-plugin-openid-connect/SKILL.md +++ b/skills/a7-plugin-openid-connect/SKILL.md @@ -176,9 +176,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "webapp:3000": 1 - } + "nodes": [{"host": "webapp", "port": 3000, "weight": 1}] } } EOF @@ -212,9 +210,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -332,10 +328,10 @@ gateway_groups: redirect_uri: http://127.0.0.1:9080/app/redirect session: secret: my-16-char-secret - upstream_id: webapp-upstream - upstreams: - - id: webapp-upstream - type: roundrobin - nodes: - "webapp:3000": 1 + upstream: + type: roundrobin + nodes: + - host: webapp + port: 3000 + weight: 1 ``` diff --git a/skills/a7-plugin-prometheus/SKILL.md b/skills/a7-plugin-prometheus/SKILL.md index f3996fe..3da1a4a 100644 --- a/skills/a7-plugin-prometheus/SKILL.md +++ b/skills/a7-plugin-prometheus/SKILL.md @@ -83,9 +83,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -163,5 +161,10 @@ routes: uri: /api/* plugins: prometheus: {} - upstream_id: my-upstream + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-proxy-rewrite/SKILL.md b/skills/a7-plugin-proxy-rewrite/SKILL.md index 53f9a25..5ba9fa7 100644 --- a/skills/a7-plugin-proxy-rewrite/SKILL.md +++ b/skills/a7-plugin-proxy-rewrite/SKILL.md @@ -71,9 +71,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -95,9 +93,7 @@ a7 route create --gateway-group prod -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -126,9 +122,7 @@ a7 route create --gateway-group stage -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -237,10 +231,10 @@ routes: X-Forwarded-Prefix: "/api/v1" remove: - X-Debug - upstream_id: backend -upstreams: - - id: backend - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-response-rewrite/SKILL.md b/skills/a7-plugin-response-rewrite/SKILL.md index 27319c2..53915e2 100644 --- a/skills/a7-plugin-response-rewrite/SKILL.md +++ b/skills/a7-plugin-response-rewrite/SKILL.md @@ -82,9 +82,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -111,9 +109,7 @@ a7 route create --gateway-group prod -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -138,9 +134,7 @@ a7 route create --gateway-group stage -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -286,10 +280,10 @@ routes: - X-Powered-By vars: - ["status", "==", 200] - upstream_id: api-backend -upstreams: - - id: api-backend - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-serverless/SKILL.md b/skills/a7-plugin-serverless/SKILL.md index fdd874e..8ea51ee 100644 --- a/skills/a7-plugin-serverless/SKILL.md +++ b/skills/a7-plugin-serverless/SKILL.md @@ -110,7 +110,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -133,7 +133,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -156,7 +156,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -179,7 +179,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -202,7 +202,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -225,7 +225,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -249,7 +249,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -272,7 +272,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -338,7 +338,12 @@ routes: phase: log functions: - "return function() ngx.log(ngx.WARN, 'request completed') end" - upstream_id: my-upstream + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` ## Key Differences: Pre vs Post diff --git a/skills/a7-plugin-skywalking/SKILL.md b/skills/a7-plugin-skywalking/SKILL.md index 2c14216..3d529d1 100644 --- a/skills/a7-plugin-skywalking/SKILL.md +++ b/skills/a7-plugin-skywalking/SKILL.md @@ -78,9 +78,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -171,10 +169,10 @@ routes: plugins: skywalking: sample_ratio: 1 - upstream_id: my-upstream -upstreams: - - id: my-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-traffic-split/SKILL.md b/skills/a7-plugin-traffic-split/SKILL.md index 2f25fc0..bd03103 100644 --- a/skills/a7-plugin-traffic-split/SKILL.md +++ b/skills/a7-plugin-traffic-split/SKILL.md @@ -58,24 +58,23 @@ deployments, and A/B testing — all without modifying DNS or load balancers. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `upstream_id` | string/integer | No | — | ID of a pre-configured upstream object. Use this to get health checks, retries, etc. | | `upstream` | object | No | — | Inline upstream configuration (see below). | | `weight` | integer | No | `1` | Traffic weight for this upstream. | -**If only `weight` is set** (no `upstream` or `upstream_id`), traffic goes to the route's default upstream. +**If only `weight` is set** (no inline `upstream`), traffic goes to the route's service upstream. ### Inline upstream object | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `type` | string | No | `"roundrobin"` | Load balancing: `"roundrobin"` or `"chash"`. | -| `nodes` | object | Yes | — | Backend nodes as `{"host:port": weight}`. | +| `nodes` | array | Yes | — | Backend nodes as `[{host, port, weight}]`. | | `timeout` | object | No | `15` (seconds) | `{"connect": N, "send": N, "read": N}` | | `pass_host` | string | No | `"pass"` | `"pass"` = client host, `"node"` = upstream node, `"rewrite"` = use `upstream_host`. | | `upstream_host` | string | No | — | Custom Host header. Only works with `pass_host: "rewrite"`. | | `name` | string | No | — | Human-readable name for the upstream. | -**Not supported in inline upstream**: `service_name`, `discovery_type`, `checks`, `retries`, `retry_timeout`, `scheme`. Use `upstream_id` for these features. +**Not supported in inline weighted upstreams**: `service_name`, `discovery_type`, `checks`, `retries`, and `retry_timeout`. Keep shared upstream behavior on the bound service whenever possible. ## Step-by-Step: Enable traffic-split on a Route @@ -97,9 +96,7 @@ a7 route create --gateway-group default -f - <<'EOF' "upstream": { "name": "new-version-v2", "type": "roundrobin", - "nodes": { - "backend-v2:8080": 1 - } + "nodes": [{"host": "backend-v2", "port": 8080, "weight": 1}] }, "weight": 2 }, @@ -113,9 +110,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend-v1:8080": 1 - } + "nodes": [{"host": "backend-v1", "port": 8080, "weight": 1}] } } EOF @@ -146,9 +141,7 @@ a7 route create --gateway-group prod -f - <<'EOF' "upstream": { "name": "green-env", "type": "roundrobin", - "nodes": { - "green-backend:8080": 1 - } + "nodes": [{"host": "green-backend", "port": 8080, "weight": 1}] }, "weight": 1 } @@ -159,9 +152,7 @@ a7 route create --gateway-group prod -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "blue-backend:8080": 1 - } + "nodes": [{"host": "blue-backend", "port": 8080, "weight": 1}] } } EOF @@ -183,9 +174,7 @@ a7 route update canary-release --gateway-group default -f - <<'EOF' "upstream": { "name": "new-version-v2", "type": "roundrobin", - "nodes": { - "backend-v2:8080": 1 - } + "nodes": [{"host": "backend-v2", "port": 8080, "weight": 1}] }, "weight": 5 }, @@ -223,7 +212,7 @@ EOF "upstream": { "name": "variant-B", "type": "roundrobin", - "nodes": {"variant-b:8080": 1} + "nodes": [{"host": "variant-b", "port": 8080, "weight": 1}] } } ] @@ -246,13 +235,13 @@ Requests with `?variant=B` → variant B backend. { "match": [{"vars": [["http_x-api-id", "==", "1"]]}], "weighted_upstreams": [ - {"upstream": {"type": "roundrobin", "nodes": {"svc-a:8080": 1}}} + {"upstream": {"type": "roundrobin", "nodes": [{"host": "svc-a", "port": 8080, "weight": 1}]}} ] }, { "match": [{"vars": [["http_x-api-id", "==", "2"]]}], "weighted_upstreams": [ - {"upstream": {"type": "roundrobin", "nodes": {"svc-b:8080": 1}}} + {"upstream": {"type": "roundrobin", "nodes": [{"host": "svc-b", "port": 8080, "weight": 1}]}} ] } ] @@ -261,7 +250,7 @@ Requests with `?variant=B` → variant B backend. } ``` -### Using upstream_id for health checks +### Health Check Considerations ```json { @@ -271,7 +260,7 @@ Requests with `?variant=B` → variant B backend. { "weighted_upstreams": [ { - "upstream_id": "canary-upstream", + "upstream": {"type": "roundrobin", "nodes": [{"host": "canary", "port": 8080, "weight": 1}]}, "weight": 2 }, { @@ -285,7 +274,9 @@ Requests with `?variant=B` → variant B backend. } ``` -Pre-create the upstream with `a7 upstream create` to configure health checks, retries, and other advanced settings. +Current API7 EE does not expose standalone upstream CRUD through `a7`. Prefer +inline weighted upstreams for traffic splitting, and keep health-check settings +on service upstream configuration when needed. ## Match Logic Reference @@ -301,7 +292,7 @@ Pre-create the upstream with `a7 upstream create` to configure health checks, re |---------|-------|-----| | Traffic ratio inaccurate | Round-robin algorithm causes slight deviation | Expected behavior; ratios converge over many requests | | Match rule not triggering | Variable name wrong or operator mismatch | Use `http_header-name` for headers, `arg_name` for query params | -| Health checks not working | Inline upstream doesn't support `checks` | Use `upstream_id` referencing a pre-created upstream with health checks | +| Health checks not working | Health checks are not configured on the service upstream | Move stable backend health-check settings to the service upstream configuration | | All traffic going to default | Match conditions never true | Debug with `a7 route get` and verify header/param names | | Weight 0 not blocking traffic | Weight 0 means "never forward" to that upstream | Correct — set weight to 0 to exclude an upstream | | Config not applied | Wrong gateway group specified | Ensure `--gateway-group` matches the desired cluster | @@ -318,17 +309,18 @@ routes: traffic-split: rules: - weighted_upstreams: - - upstream_id: canary-upstream + - upstream: + type: roundrobin + nodes: + - host: canary-backend + port: 8080 + weight: 1 weight: 2 - weight: 8 - upstream_id: stable-upstream -upstreams: - - id: stable-upstream - type: roundrobin - nodes: - "stable-backend:8080": 1 - - id: canary-upstream - type: roundrobin - nodes: - "canary-backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: stable-backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-plugin-wolf-rbac/SKILL.md b/skills/a7-plugin-wolf-rbac/SKILL.md index bde5c14..3d99477 100644 --- a/skills/a7-plugin-wolf-rbac/SKILL.md +++ b/skills/a7-plugin-wolf-rbac/SKILL.md @@ -168,7 +168,7 @@ a7 route create -g default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": {"backend:8080": 1} + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -300,7 +300,12 @@ gateway_groups: uri: /api/* plugins: wolf-rbac: {} - upstream_id: api-backend + upstream: + type: roundrobin + nodes: + - host: api-backend + port: 8080 + weight: 1 - id: wolf-login uri: /apisix/plugin/wolf-rbac/login plugins: @@ -313,11 +318,6 @@ gateway_groups: uri: /apisix/plugin/wolf-rbac/change_pwd plugins: public-api: {} - upstreams: - - id: api-backend - type: roundrobin - nodes: - "backend:8080": 1 ``` ## Injected Headers diff --git a/skills/a7-plugin-zipkin/SKILL.md b/skills/a7-plugin-zipkin/SKILL.md index 4651b51..20e3500 100644 --- a/skills/a7-plugin-zipkin/SKILL.md +++ b/skills/a7-plugin-zipkin/SKILL.md @@ -87,9 +87,7 @@ a7 route create --gateway-group default -f - <<'EOF' }, "upstream": { "type": "roundrobin", - "nodes": { - "backend:8080": 1 - } + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -178,10 +176,10 @@ routes: sample_ratio: 1 service_name: my-gateway span_version: 2 - upstream_id: my-upstream -upstreams: - - id: my-upstream - type: roundrobin - nodes: - "backend:8080": 1 + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 ``` diff --git a/skills/a7-recipe-api-versioning/SKILL.md b/skills/a7-recipe-api-versioning/SKILL.md index d3be4f4..f2e78f3 100644 --- a/skills/a7-recipe-api-versioning/SKILL.md +++ b/skills/a7-recipe-api-versioning/SKILL.md @@ -121,7 +121,7 @@ a7 route create -g production -f - <<'EOF' { "upstream": { "type": "roundrobin", - "nodes": {"v2-backend:8080": 1} + "nodes": [{"host": "v2-backend", "port": 8080, "weight": 1}] }, "weight": 1 } @@ -155,7 +155,7 @@ a7 route update route-v1 -g production -f - <<'EOF' { "upstream": { "type": "roundrobin", - "nodes": {"v2-backend:8080": 1} + "nodes": [{"host": "v2-backend", "port": 8080, "weight": 1}] }, "weight": 1 }, diff --git a/skills/a7-recipe-blue-green/SKILL.md b/skills/a7-recipe-blue-green/SKILL.md index ae2ce9b..33e2db5 100644 --- a/skills/a7-recipe-blue-green/SKILL.md +++ b/skills/a7-recipe-blue-green/SKILL.md @@ -138,7 +138,7 @@ a7 route update api --gateway-group default -f - <<'EOF' { "upstream": { "type": "roundrobin", - "nodes": {"green-backend-1:8080": 1} + "nodes": [{"host": "green-backend-1", "port": 8080, "weight": 1}] }, "weight": 1 } diff --git a/skills/a7-recipe-canary/SKILL.md b/skills/a7-recipe-canary/SKILL.md index ad7a039..dabe4aa 100644 --- a/skills/a7-recipe-canary/SKILL.md +++ b/skills/a7-recipe-canary/SKILL.md @@ -88,7 +88,7 @@ a7 route create --gateway-group default -f - <<'EOF' { "upstream": { "type": "roundrobin", - "nodes": {"canary-v2:8080": 1} + "nodes": [{"host": "canary-v2", "port": 8080, "weight": 1}] }, "weight": 5 }, @@ -118,7 +118,7 @@ a7 route update api --gateway-group default -f - <<'EOF' { "upstream": { "type": "roundrobin", - "nodes": {"canary-v2:8080": 1} + "nodes": [{"host": "canary-v2", "port": 8080, "weight": 1}] }, "weight": 25 }, @@ -195,7 +195,7 @@ a7 route update api --gateway-group default -f - <<'EOF' { "upstream": { "type": "roundrobin", - "nodes": {"canary-v2:8080": 1} + "nodes": [{"host": "canary-v2", "port": 8080, "weight": 1}] }, "weight": 1 } @@ -237,7 +237,7 @@ EOF "traffic-split": { "rules": [{ "weighted_upstreams": [ - {"upstream": {"type": "roundrobin", "nodes": {"canary-v2:8080": 1}}, "weight": $w}, + {"upstream": {"type": "roundrobin", "nodes": [{"host": "canary-v2", "port": 8080, "weight": 1}]}, "weight": $w}, {"weight": $stable_w} ] }] @@ -291,7 +291,9 @@ routes: - upstream: type: roundrobin nodes: - "canary-v2:8080": 1 + - host: canary-v2 + port: 8080 + weight: 1 weight: 10 - weight: 90 ``` diff --git a/skills/a7-recipe-multi-tenant/SKILL.md b/skills/a7-recipe-multi-tenant/SKILL.md index dc693ca..ee998c6 100644 --- a/skills/a7-recipe-multi-tenant/SKILL.md +++ b/skills/a7-recipe-multi-tenant/SKILL.md @@ -2,7 +2,7 @@ name: a7-recipe-multi-tenant description: >- Recipe skill for implementing multi-tenant patterns using API7 Enterprise Edition (API7 EE) - and the a7 CLI. Covers gateway-group isolation, consumer-group policies, + and the a7 CLI. Covers gateway-group isolation, consumer policies, service-backed tenant routes, and credential-based tenant access. version: "1.0.0" author: API7.ai Contributors @@ -13,8 +13,6 @@ metadata: a7_commands: - a7 gateway-group create - a7 global-rule create - - a7 consumer-group create - - a7 consumer-group list - a7 consumer create - a7 consumer list - a7 credential create @@ -32,7 +30,7 @@ metadata: Multi-tenancy in API7 EE is built from three layers: 1. Gateway groups for runtime isolation. -2. Consumer groups for shared tenant policies. +2. Consumers and credentials for tenant identity. 3. Service-backed routes for tenant APIs. For route traffic, use the current a7 model: @@ -75,14 +73,16 @@ a7 global-rule create -g standard-tier -f - <<'EOF' EOF ``` -## Approach B: Consumer Groups and Credentials +## Approach B: Consumers and Credentials -Create consumer groups in the shared gateway group: +Current API7 EE does not expose consumer group management through the Admin API. +Model tenants as consumers, attach per-consumer plugins when needed, and create +credentials with `a7 credential create`. ```bash -a7 consumer-group create -g platform -f - <<'EOF' +a7 consumer create -g platform -f - <<'EOF' { - "id": "tenant-free", + "username": "startup-xyz", "desc": "Free tier tenants", "plugins": { "limit-count": { @@ -97,9 +97,11 @@ a7 consumer-group create -g platform -f - <<'EOF' } EOF -a7 consumer-group create -g platform -f - <<'EOF' +a7 credential create -g platform --consumer startup-xyz --plugins-json '{"key-auth":{"key":"startup-xyz-key"}}' + +a7 consumer create -g platform -f - <<'EOF' { - "id": "tenant-pro", + "username": "acme-corp", "desc": "Pro tier tenants", "plugins": { "limit-count": { @@ -113,29 +115,8 @@ a7 consumer-group create -g platform -f - <<'EOF' } } EOF -``` - -Create consumers with raw payloads when assigning them to consumer groups, then -create key-auth credentials with `a7 credential create`. - -```bash -a7 consumer create -g platform -f - <<'EOF' -{ - "username": "acme-corp", - "group_id": "tenant-pro" -} -EOF a7 credential create -g platform --consumer acme-corp --plugins-json '{"key-auth":{"key":"acme-secret-key"}}' - -a7 consumer create -g platform -f - <<'EOF' -{ - "username": "startup-xyz", - "group_id": "tenant-free" -} -EOF - -a7 credential create -g platform --consumer startup-xyz --plugins-json '{"key-auth":{"key":"startup-xyz-key"}}' ``` ## Approach C: Tenant-Aware Service Route @@ -171,7 +152,7 @@ a7 route create -g platform -f - <<'EOF' "proxy-rewrite": { "headers": { "set": { - "X-Tenant-ID": "$consumer_group_id", + "X-Tenant-ID": "$consumer_name", "X-User-ID": "$consumer_name", "X-Gateway-Group": "platform" } @@ -189,21 +170,6 @@ the current `a7 config sync` workflow. ```yaml version: "1" -consumer_groups: - - id: tenant-free - plugins: - limit-count: - count: 100 - time_window: 86400 - key_type: var - key: consumer_name - - id: tenant-pro - plugins: - limit-count: - count: 10000 - time_window: 86400 - key_type: var - key: consumer_name services: - id: tenant-api-service name: tenant-api-service @@ -224,7 +190,7 @@ routes: proxy-rewrite: headers: set: - X-Tenant-ID: "$consumer_group_id" + X-Tenant-ID: "$consumer_name" X-User-ID: "$consumer_name" X-Gateway-Group: platform ``` @@ -236,12 +202,11 @@ a7 config sync -g platform -f platform-tenants.yaml ``` Use `a7 consumer create -f` and `a7 credential create` for tenant identities and -credentials when consumer group assignment or key material is required. +key material. ## Verification ```bash -a7 consumer-group list -g platform a7 consumer list -g platform a7 service get tenant-api-service -g platform -o json a7 route get multi-tenant-api -g platform -o json @@ -260,7 +225,7 @@ headers after successful authentication. ## Important Considerations - Use different gateway groups for strict runtime isolation. -- Use consumer groups for shared tenant policies inside one gateway group. +- Use consumer-level plugins for tenant-specific policies inside one gateway group. - Keep credentials under `a7 credential`, not embedded directly in consumers. - `a7 config sync -g` manages one gateway group at a time. - Use raw consumer payloads for fields that do not have first-class CLI flags. diff --git a/skills/a7-shared/SKILL.md b/skills/a7-shared/SKILL.md index 0af57eb..9094a7e 100644 --- a/skills/a7-shared/SKILL.md +++ b/skills/a7-shared/SKILL.md @@ -18,7 +18,6 @@ metadata: - a7 ssl - a7 plugin - a7 gateway-group - - a7 service-template - a7 config - a7 context --- @@ -28,15 +27,15 @@ metadata: ## What is a7 a7 is a Go CLI for API7 Enterprise Edition (API7 EE). It provides imperative CRUD -for 13 resource types, declarative config sync, context management, and debug tooling. +for current API7 EE resource types, declarative config sync, context management, and debug tooling. - **Binary**: `a7` - **Module**: `github.com/api7/a7` - **Go**: 1.22+ - **Pattern**: noun-verb (`a7 [flags]`) - **Dual-API Architecture**: - - **Control-plane API**: `/api/*` (e.g., gateway groups, users, service templates) - - **Runtime Admin API**: `/apisix/admin/*` (e.g., routes, upstreams, services) + - **Control-plane API**: `/api/*` (e.g., gateway groups) + - **Runtime Admin API**: `/apisix/admin/*` (e.g., routes, services, consumers) - **Gateway Group Scoping**: All runtime resources must be scoped to a gateway group using `--gateway-group` or `-g`. ## Project Layout @@ -48,10 +47,8 @@ a7/ │ ├── root/root.go # Root command, registers all subcommands │ ├── factory.go # DI: IOStreams, HttpClient, Config │ ├── route/ # a7 route list|get|create|update|delete -g -│ ├── upstream/ # ⚠️ NOT EXPOSED in API7 EE — upstreams are inline-only (defined within services/routes) │ ├── service/ # a7 service ... -g │ ├── gateway-group/ # a7 gateway-group list|get|create|update|delete -│ ├── service-template/ # a7 service-template list|get|create|update|delete │ ├── consumer/ # a7 consumer ... -g │ ├── ssl/ # a7 ssl ... -g │ ├── plugin/ # a7 plugin list|get @@ -122,10 +119,8 @@ For `update` actions that use `PATCH`, a7 implements JSON Patch (RFC 6902) suppo | Resource | Key Field | API Path (Prefix) | |----------|-----------|-------------------| | Gateway Group | `id` | `/api/gateway_groups` | -| Service Template | `id` | `/api/services/template` | | Route | `id` | `/apisix/admin/routes` | | Service | `id` | `/apisix/admin/services` | -| Upstream | `id` | ⚠️ NOT EXPOSED — upstreams are inline-only in API7 EE (defined within services/routes) | | Consumer | `username` | `/apisix/admin/consumers` | | SSL | `id` | `/apisix/admin/ssl` | | Global Rule | `id` | `/apisix/admin/global_rules` | @@ -136,7 +131,7 @@ For `update` actions that use `PATCH`, a7 implements JSON Patch (RFC 6902) suppo | Plugin (read-only) | `name` | `/apisix/admin/plugins` | | Credential | `id` | `/apisix/admin/consumers/{username}/credentials` | -Note: Runtime resources (routes, services, upstreams, etc.) are always scoped by the gateway group in the request URL or via headers. +Note: Runtime resources (routes, services, consumers, etc.) are always scoped by the gateway group in the request URL or via headers. Upstreams are modeled inline on services or routes, not as standalone resources. ## Common Commands diff --git a/test/e2e/completion_version_test.go b/test/e2e/completion_version_test.go index e7c394c..1304c6d 100644 --- a/test/e2e/completion_version_test.go +++ b/test/e2e/completion_version_test.go @@ -82,7 +82,16 @@ func TestHelp(t *testing.T) { // Help should list available commands. assert.Contains(t, stdout, "route") assert.Contains(t, stdout, "service") - assert.Contains(t, stdout, "upstream") + assert.NotContains(t, stdout, "upstream") + assert.NotContains(t, stdout, "consumer-group") + assert.NotContains(t, stdout, "service-template") +} + +func TestUnsupportedResourceCommandsAreRemoved(t *testing.T) { + for _, command := range []string{"upstream", "consumer-group", "service-template"} { + _, _, err := runA7(command, "--help") + assert.Error(t, err, "command %q should not be registered", command) + } } func TestHelp_SubcommandRoute(t *testing.T) { diff --git a/test/e2e/config_test.go b/test/e2e/config_test.go index 986b00e..f453e39 100644 --- a/test/e2e/config_test.go +++ b/test/e2e/config_test.go @@ -71,10 +71,16 @@ func TestConfigValidate_Valid(t *testing.T) { routes: - id: valid-route-1 uri: /test + service_id: valid-service-1 +services: + - id: valid-service-1 + name: valid-service-1 upstream: type: roundrobin nodes: - "127.0.0.1:8080": 1 + - host: 127.0.0.1 + port: 8080 + weight: 1 consumers: - username: valid-consumer-1 ` @@ -95,9 +101,18 @@ func TestConfigValidate_ValidJSON(t *testing.T) { { "id": "valid-route-json", "uri": "/test-json", + "service_id": "valid-service-json" + } + ], + "services": [ + { + "id": "valid-service-json", + "name": "valid-service-json", "upstream": { "type": "roundrobin", - "nodes": {"127.0.0.1:8080": 1} + "nodes": [ + {"host": "127.0.0.1", "port": 8080, "weight": 1} + ] } } ] @@ -142,12 +157,50 @@ routes: assert.Error(t, err) } +func TestConfigValidate_RejectsUnsupportedTopLevelUpstreams(t *testing.T) { + env := setupEnv(t) + + invalidYAML := `version: "1" +upstreams: + - id: removed-upstream + type: roundrobin + nodes: + 127.0.0.1:8080: 1 +` + tmpFile := filepath.Join(t.TempDir(), "unsupported-upstreams.yaml") + require.NoError(t, os.WriteFile(tmpFile, []byte(invalidYAML), 0644)) + + _, stderr, err := runA7WithEnv(env, "config", "validate", "-f", tmpFile) + assert.Error(t, err) + assert.Contains(t, stderr, "upstreams are not supported as top-level API7 EE resources") +} + +func TestConfigValidate_RejectsUnsupportedConsumerGroups(t *testing.T) { + env := setupEnv(t) + + invalidYAML := `version: "1" +consumer_groups: + - id: removed-group + plugins: + limit-count: + count: 10 + time_window: 60 +` + tmpFile := filepath.Join(t.TempDir(), "unsupported-consumer-groups.yaml") + require.NoError(t, os.WriteFile(tmpFile, []byte(invalidYAML), 0644)) + + _, stderr, err := runA7WithEnv(env, "config", "validate", "-f", tmpFile) + assert.Error(t, err) + assert.Contains(t, stderr, "consumer_groups are not supported by current API7 EE") +} + func TestConfigValidate_MissingRouteURI(t *testing.T) { env := setupEnv(t) invalidYAML := `version: "1" routes: - id: no-uri-route + service_id: service-1 ` tmpFile := filepath.Join(t.TempDir(), "invalid-no-uri.yaml") require.NoError(t, os.WriteFile(tmpFile, []byte(invalidYAML), 0644)) @@ -163,8 +216,10 @@ func TestConfigValidate_DuplicateIDs(t *testing.T) { routes: - id: dup-route uri: /test-1 + service_id: service-1 - id: dup-route uri: /test-2 + service_id: service-1 ` tmpFile := filepath.Join(t.TempDir(), "invalid-dup-ids.yaml") require.NoError(t, os.WriteFile(tmpFile, []byte(invalidYAML), 0644)) @@ -219,10 +274,16 @@ func TestConfigDiff_WithDifferences(t *testing.T) { routes: - id: e2e-diff-extra-route uri: /diff-extra + service_id: e2e-diff-extra-service +services: + - id: e2e-diff-extra-service + name: e2e-diff-extra-service upstream: type: roundrobin nodes: - "127.0.0.1:8080": 1 + - host: 127.0.0.1 + port: 8080 + weight: 1 ` tmpFile := filepath.Join(t.TempDir(), "diff-config.yaml") require.NoError(t, os.WriteFile(tmpFile, []byte(diffYAML), 0644)) @@ -239,10 +300,16 @@ func TestConfigDiff_JSONOutput(t *testing.T) { routes: - id: e2e-diff-json-route uri: /diff-json + service_id: e2e-diff-json-service +services: + - id: e2e-diff-json-service + name: e2e-diff-json-service upstream: type: roundrobin nodes: - "127.0.0.1:8080": 1 + - host: 127.0.0.1 + port: 8080 + weight: 1 ` tmpFile := filepath.Join(t.TempDir(), "diff-json-config.yaml") require.NoError(t, os.WriteFile(tmpFile, []byte(diffYAML), 0644)) diff --git a/test/e2e/local_stability_ginkgo_test.go b/test/e2e/local_stability_ginkgo_test.go index 1f4bf87..2ea6e6d 100644 --- a/test/e2e/local_stability_ginkgo_test.go +++ b/test/e2e/local_stability_ginkgo_test.go @@ -128,7 +128,7 @@ var _ = Describe("Local Stability", Ordered, func() { Expect(stdout).To(ContainSubstring("vault.example.com")) }) - It("updates a stream route from file without forcing upstream-id flags", func() { + It("updates a stream route from file without forcing upstream flags", func() { t := GinkgoT() env := setupEnv(t) svcID := "ginkgo-stream-svc" diff --git a/test/e2e/service_template_test.go b/test/e2e/service_template_test.go deleted file mode 100644 index ed9dd28..0000000 --- a/test/e2e/service_template_test.go +++ /dev/null @@ -1,236 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func isKnownServiceTemplateCapabilityGap(stdout, stderr string) bool { - combined := strings.ToLower(stdout + "\n" + stderr) - has404 := strings.Contains(combined, "api error (status 404)") || - strings.Contains(combined, "resource not found") || - strings.Contains(combined, "/404") - // This helper is only called for service-template commands. Newer API7 EE - // builds can return a generic 404 body without echoing the removed endpoint. - return has404 -} - -// deleteServiceTemplateViaAdmin deletes a service template via the control-plane API. -func deleteServiceTemplateViaAdmin(t *testing.T, id string) { - t.Helper() - resp, err := adminAPI("DELETE", fmt.Sprintf("/api/services/template/%s", id), nil) - if err == nil { - resp.Body.Close() - } -} - -// deletePublishedServiceViaAdmin unpublishes a service from a gateway group. -func deletePublishedServiceViaAdmin(t *testing.T, gatewayGroupID, serviceID string) { - t.Helper() - resp, err := adminAPI("DELETE", fmt.Sprintf("/api/gateway_groups/%s/services/%s", gatewayGroupID, serviceID), nil) - if err == nil { - resp.Body.Close() - } -} - -// createTestServiceTemplateViaCLI creates a service template via CLI and returns its API-generated ID. -// API7 EE generates UUIDs for service templates; custom IDs are not supported. -func createTestServiceTemplateViaCLI(t *testing.T, env []string, name string) string { - t.Helper() - stJSON := fmt.Sprintf(`{ - "name": %q, - "description": "Created by e2e tests", - "upstream": { - "type": "roundrobin", - "nodes": { - "127.0.0.1:8080": 1 - } - } - }`, name) - - tmpFile := filepath.Join(t.TempDir(), "service-template.json") - require.NoError(t, os.WriteFile(tmpFile, []byte(stJSON), 0644)) - - stdout, stderr, err := runA7WithEnv(env, "service-template", "create", "-f", tmpFile) - if err != nil { - if isKnownServiceTemplateCapabilityGap(stdout, stderr) { - t.Skip("service-template API is unavailable in this environment") - } - require.NoError(t, err, "service-template create failed") - } - - // Parse the returned ID from JSON response. - var resp map[string]interface{} - if err := json.Unmarshal([]byte(stdout), &resp); err == nil { - if id, ok := resp["id"]; ok { - return fmt.Sprintf("%v", id) - } - } - // Fallback: try listing and finding by name. - listOut, _, lerr := runA7WithEnv(env, "service-template", "list", "-o", "json") - if lerr == nil { - var templates []map[string]interface{} - if json.Unmarshal([]byte(listOut), &templates) == nil { - for _, tmpl := range templates { - if n, _ := tmpl["name"].(string); n == name { - if id, ok := tmpl["id"]; ok { - return fmt.Sprintf("%v", id) - } - } - } - } - } - t.Fatalf("failed to capture service template ID for %q", name) - return "" -} - -func TestServiceTemplate_List(t *testing.T) { - env := setupEnv(t) - - // Service templates use /api/services/template — no -g flag. - stdout, stderr, err := runA7WithEnv(env, "service-template", "list") - if err != nil && isKnownServiceTemplateCapabilityGap(stdout, stderr) { - t.Skip("service-template list is unavailable in this environment") - } - require.NoError(t, err, "service-template list failed") - assert.NotEmpty(t, stdout) -} - -func TestServiceTemplate_ListJSON(t *testing.T) { - env := setupEnv(t) - - stdout, stderr, err := runA7WithEnv(env, "service-template", "list", "-o", "json") - if err != nil && isKnownServiceTemplateCapabilityGap(stdout, stderr) { - t.Skip("service-template list is unavailable in this environment") - } - require.NoError(t, err, "service-template list JSON failed") - assert.NotEmpty(t, stdout) -} - -func TestServiceTemplate_Alias(t *testing.T) { - env := setupEnv(t) - - // Test the "st" alias. - stdout, stderr, err := runA7WithEnv(env, "st", "list") - if err != nil && isKnownServiceTemplateCapabilityGap(stdout, stderr) { - t.Skip("service-template list alias is unavailable in this environment") - } - require.NoError(t, err, "service-template list alias failed") - assert.NotEmpty(t, stdout) -} - -func TestServiceTemplate_CRUD(t *testing.T) { - env := setupEnv(t) - stName := "e2e-template-crud" - - // Create (API generates UUID ID). - stID := createTestServiceTemplateViaCLI(t, env, stName) - t.Cleanup(func() { deleteServiceTemplateViaAdmin(t, stID) }) - - // Get - stdout, _, err := runA7WithEnv(env, "service-template", "get", stID) - require.NoError(t, err, "service-template get failed") - assert.Contains(t, stdout, stName) - - // Get JSON - var template map[string]interface{} - runA7JSON(t, env, &template, "service-template", "get", stID, "-o", "json") - assert.Equal(t, stID, fmt.Sprint(template["id"])) - assert.Equal(t, stName, template["name"]) - - // Update via file - updateJSON := `{ - "name": "e2e-template-updated", - "description": "Updated by e2e tests", - "upstream": { - "type": "roundrobin", - "nodes": { - "127.0.0.1:8080": 1 - } - } - }` - tmpFile := filepath.Join(t.TempDir(), "service-template-update.json") - require.NoError(t, os.WriteFile(tmpFile, []byte(updateJSON), 0644)) - - stdout, _, err = runA7WithEnv(env, "service-template", "update", stID, "-f", tmpFile) - require.NoError(t, err, "service-template update failed") - - // Verify update - runA7JSON(t, env, &template, "service-template", "get", stID, "-o", "json") - assert.Equal(t, "e2e-template-updated", template["name"]) - - // Delete - stdout, _, err = runA7WithEnv(env, "service-template", "delete", stID, "--force") - require.NoError(t, err, "service-template delete failed") - _, _, err = runA7WithEnv(env, "service-template", "get", stID) - assert.Error(t, err) -} - -func TestServiceTemplate_CreateWithName(t *testing.T) { - env := setupEnv(t) - // When using --name flag (no -f), the API auto-generates the ID. - // We need to capture the ID from the JSON response to clean up. - stdout, stderr, err := runA7WithEnv(env, "service-template", "create", "--name", "e2e-named-template") - if err != nil && isKnownServiceTemplateCapabilityGap(stdout, stderr) { - t.Skip("service-template create is unavailable in this environment") - } - require.NoError(t, err, "service-template create with name failed") - - // Parse ID from response for cleanup. - var resp map[string]interface{} - if err := json.Unmarshal([]byte(stdout), &resp); err == nil { - if id, ok := resp["id"]; ok { - t.Cleanup(func() { deleteServiceTemplateViaAdmin(t, fmt.Sprintf("%v", id)) }) - } - } -} - -func TestServiceTemplate_Publish(t *testing.T) { - env := setupEnv(t) - stName := "e2e-template-publish" - - stID := createTestServiceTemplateViaCLI(t, env, stName) - t.Cleanup(func() { - deletePublishedServiceViaAdmin(t, gatewayGroup, stID) - deleteServiceTemplateViaAdmin(t, stID) - }) - - // Publish to the default gateway group. - stdout, stderr, err := runA7WithEnv(env, "service-template", "publish", stID, - "--gateway-group-id", gatewayGroup) - if err != nil && isKnownServiceTemplateCapabilityGap(stdout, stderr) { - t.Skip("service-template publish is unavailable in this environment") - } - require.NoError(t, err, "service-template publish failed") -} - -func TestServiceTemplate_PublishMissingFlag(t *testing.T) { - env := setupEnv(t) - - // publish without --gateway-group-id should fail. - _, _, err := runA7WithEnv(env, "service-template", "publish", "some-id") - assert.Error(t, err) -} - -func TestServiceTemplate_DeleteNonexistent(t *testing.T) { - env := setupEnv(t) - - _, _, err := runA7WithEnv(env, "service-template", "delete", "nonexistent-st-12345", "--force") - assert.Error(t, err) -} - -func TestServiceTemplate_GetNonexistent(t *testing.T) { - env := setupEnv(t) - - _, _, err := runA7WithEnv(env, "service-template", "get", "nonexistent-st-12345") - assert.Error(t, err) -} diff --git a/test/e2e/skills/skills_test.go b/test/e2e/skills/skills_test.go index f5c42de..7ffa704 100644 --- a/test/e2e/skills/skills_test.go +++ b/test/e2e/skills/skills_test.go @@ -233,8 +233,14 @@ func TestSkillsDoNotReferenceRemovedA7Commands(t *testing.T) { disallowed := []string{ "a7 health", "a7 portal", + "a7 upstream", "a7 upstream health", + "a7 consumer-group", + "a7 service-template", "a7 consumer-restriction create", + "\nupstreams:", + "\n upstreams:", + "upstream_id:", } for _, pattern := range disallowed { matches, err := filepath.Glob(filepath.Join(root, "skills", "*", "SKILL.md")) From 1cfaac5dab749a07c6a7192a98f51443a8ca1d79 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Mon, 11 May 2026 17:39:47 +0800 Subject: [PATCH 2/3] address PR review feedback --- docs/user-guide/stream-route.md | 12 +-- pkg/cmd/config/configutil/configutil.go | 21 ++++++ pkg/cmd/config/diff/diff_test.go | 25 +++++++ pkg/cmd/config/sync/sync_test.go | 17 ++++- pkg/cmd/route/create/create.go | 8 ++ pkg/cmd/route/create/create_test.go | 73 ++++++++++++++++++- pkg/cmd/stream-route/update/update.go | 11 +++ pkg/cmd/stream-route/update/update_test.go | 40 ++++++++++ skills/a7-plugin-ai-prompt-template/SKILL.md | 12 +++ .../a7-plugin-consumer-restriction/SKILL.md | 2 +- skills/a7-plugin-limit-req/SKILL.md | 68 ++++++++++------- skills/a7-plugin-proxy-rewrite/SKILL.md | 65 ++++++++++++----- skills/a7-plugin-traffic-split/SKILL.md | 49 +++++++++---- skills/a7-recipe-multi-tenant/SKILL.md | 1 + 14 files changed, 331 insertions(+), 73 deletions(-) diff --git a/docs/user-guide/stream-route.md b/docs/user-guide/stream-route.md index 2dfbf8d..9f11e57 100644 --- a/docs/user-guide/stream-route.md +++ b/docs/user-guide/stream-route.md @@ -58,15 +58,14 @@ a7 stream-route create -g default -f stream-route.json ``` **Sample `stream-route.json`:** +Create the service first and use its `id` as `service_id`; current API7 EE stores upstream configuration on services. + ```json { "id": "tcp-proxy", "name": "tcp-proxy", "server_port": 9100, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "127.0.0.1", "port": 8080, "weight": 1}] - } + "service_id": "tcp-service" } ``` @@ -148,9 +147,6 @@ Key fields in the stream route configuration (sent to `/apisix/admin/stream_rout "name": "sni-route", "sni": "tcp.example.com", "server_port": 9443, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "127.0.0.1", "port": 9000, "weight": 1}] - } + "service_id": "sni-service" } ``` diff --git a/pkg/cmd/config/configutil/configutil.go b/pkg/cmd/config/configutil/configutil.go index 22efa39..de5d7f8 100644 --- a/pkg/cmd/config/configutil/configutil.go +++ b/pkg/cmd/config/configutil/configutil.go @@ -176,6 +176,13 @@ func FetchRemoteConfig(client *api.Client, gatewayGroup string) (*api.ConfigFile } func ComputeDiff(local, remote api.ConfigFile) (*DiffResult, error) { + if err := validateSupportedSections(local); err != nil { + return nil, err + } + if err := validateSupportedSections(remote); err != nil { + return nil, fmt.Errorf("remote config: %w", err) + } + type diffSpec struct { local interface{} remote interface{} @@ -227,6 +234,20 @@ func ComputeDiff(local, remote api.ConfigFile) (*DiffResult, error) { }, nil } +func validateSupportedSections(cfg api.ConfigFile) error { + var unsupported []string + if len(cfg.Upstreams) > 0 { + unsupported = append(unsupported, "upstreams") + } + if len(cfg.ConsumerGroups) > 0 { + unsupported = append(unsupported, "consumer_groups") + } + if len(unsupported) > 0 { + return fmt.Errorf("unsupported declarative config sections: %s; define upstreams inline on services and omit API7 EE unsupported resources", strings.Join(unsupported, ", ")) + } + return nil +} + func FormatDiffSummary(result *DiffResult) string { if result == nil || !result.HasDifferences() { return "No differences found.\n" diff --git a/pkg/cmd/config/diff/diff_test.go b/pkg/cmd/config/diff/diff_test.go index 8e37566..b7c1c0f 100644 --- a/pkg/cmd/config/diff/diff_test.go +++ b/pkg/cmd/config/diff/diff_test.go @@ -198,6 +198,31 @@ routes: reg.Verify(t) } +func TestConfigDiff_RejectsUnsupportedSections(t *testing.T) { + reg := &httpmock.Registry{} + registerEmptyResources(reg, nil) + + local := writeConfig(t, ` +version: "1" +upstreams: + - id: u1 + nodes: + 127.0.0.1:8080: 1 +consumer_groups: + - id: group1 +`) + + ios, _, _, _ := iostreams.Test() + c := NewCmdDiff(newFactory(reg, ios)) + c.SetArgs([]string{"-f", local}) + err := c.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported declarative config sections") + assert.Contains(t, err.Error(), "upstreams") + assert.Contains(t, err.Error(), "consumer_groups") +} + func TestConfigDiff_JSONOutput(t *testing.T) { reg := &httpmock.Registry{} registerEmptyResources(reg, nil) diff --git a/pkg/cmd/config/sync/sync_test.go b/pkg/cmd/config/sync/sync_test.go index b91aaa1..44308f8 100644 --- a/pkg/cmd/config/sync/sync_test.go +++ b/pkg/cmd/config/sync/sync_test.go @@ -1,6 +1,8 @@ package sync import ( + "encoding/json" + "io" "net/http" "os" "path/filepath" @@ -75,7 +77,20 @@ func writeConfig(t *testing.T, content string) string { func TestConfigSync_CreatesNewResources(t *testing.T) { reg := &httpmock.Registry{} registerEmptyResources(reg, nil) - reg.Register(http.MethodPut, "/apisix/admin/routes/r1", httpmock.JSONResponse(`{"id":"r1"}`)) + reg.RegisterResponder(http.MethodPut, "/apisix/admin/routes/r1", func(r *http.Request) (httpmock.Response, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return httpmock.Response{}, err + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + return httpmock.Response{}, err + } + if payload["service_id"] != "svc-1" { + t.Fatalf("expected service_id svc-1 in route payload, got: %v", payload["service_id"]) + } + return httpmock.JSONResponse(`{"id":"r1"}`), nil + }) local := writeConfig(t, ` version: "1" diff --git a/pkg/cmd/route/create/create.go b/pkg/cmd/route/create/create.go index 501de34..c7f4955 100644 --- a/pkg/cmd/route/create/create.go +++ b/pkg/cmd/route/create/create.go @@ -77,6 +77,14 @@ func actionRun(opts *Options) error { if err != nil { return err } + if opts.ServiceID != "" { + if _, ok := payload["service_id"]; !ok { + payload["service_id"] = opts.ServiceID + } + } + if serviceID, ok := payload["service_id"]; !ok || strings.TrimSpace(fmt.Sprint(serviceID)) == "" { + return fmt.Errorf("--service-id is required for current API7 EE") + } httpClient, err := opts.Client() if err != nil { diff --git a/pkg/cmd/route/create/create_test.go b/pkg/cmd/route/create/create_test.go index 70421fe..162c142 100644 --- a/pkg/cmd/route/create/create_test.go +++ b/pkg/cmd/route/create/create_test.go @@ -1,6 +1,7 @@ package create import ( + "io" "net/http" "os" "path/filepath" @@ -93,11 +94,11 @@ func TestCreateRoute_MissingServiceID(t *testing.T) { func TestCreateRoute_FromFile(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} - registry.Register(http.MethodPost, "/apisix/admin/routes", httpmock.JSONResponse(`{"id":"r-file","name":"demo-file","uri":"/demo-file"}`)) + registry.Register(http.MethodPost, "/apisix/admin/routes", httpmock.JSONResponse(`{"id":"r-file","name":"demo-file","uri":"/demo-file","service_id":"svc1"}`)) tmp := t.TempDir() path := filepath.Join(tmp, "route.json") - if err := os.WriteFile(path, []byte(`{"name":"demo-file","uri":"/demo-file"}`), 0o600); err != nil { + if err := os.WriteFile(path, []byte(`{"name":"demo-file","uri":"/demo-file","service_id":"svc1"}`), 0o600); err != nil { t.Fatalf("write file: %v", err) } @@ -120,14 +121,78 @@ func TestCreateRoute_FromFile(t *testing.T) { registry.Verify(t) } +func TestCreateRoute_FileMissingServiceID(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tmp := t.TempDir() + path := filepath.Join(tmp, "route.json") + if err := os.WriteFile(path, []byte(`{"name":"demo-file","uri":"/demo-file"}`), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + + opts := &Options{ + IO: ios, + Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, + Config: func() (config.Config, error) { + return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil + }, + GatewayGroup: "gg1", + File: path, + } + + err := actionRun(opts) + if err == nil || !strings.Contains(err.Error(), "--service-id is required") { + t.Fatalf("expected service-id required error, got: %v", err) + } +} + +func TestCreateRoute_FileServiceIDFlag(t *testing.T) { + ios, _, out, _ := iostreams.Test() + registry := &httpmock.Registry{} + registry.RegisterResponder(http.MethodPost, "/apisix/admin/routes", func(r *http.Request) (httpmock.Response, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return httpmock.Response{}, err + } + if !strings.Contains(string(body), `"service_id":"svc-flag"`) { + t.Fatalf("expected injected service_id in request body, got: %s", string(body)) + } + return httpmock.JSONResponse(`{"id":"r-file","service_id":"svc-flag"}`), nil + }) + + tmp := t.TempDir() + path := filepath.Join(tmp, "route.json") + if err := os.WriteFile(path, []byte(`{"name":"demo-file","uri":"/demo-file"}`), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + + opts := &Options{ + IO: ios, + Client: func() (*http.Client, error) { return registry.GetClient(), nil }, + Config: func() (config.Config, error) { + return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil + }, + GatewayGroup: "gg1", + File: path, + ServiceID: "svc-flag", + } + + if err := actionRun(opts); err != nil { + t.Fatalf("actionRun failed: %v", err) + } + if !strings.Contains(out.String(), "\"service_id\": \"svc-flag\"") { + t.Fatalf("expected created route in output, got: %s", out.String()) + } + registry.Verify(t) +} + func TestCreateRoute_FromYAMLFile(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} - registry.Register(http.MethodPost, "/apisix/admin/routes", httpmock.JSONResponse(`{"id":"r-yaml","name":"demo-yaml","uri":"/demo-yaml"}`)) + registry.Register(http.MethodPost, "/apisix/admin/routes", httpmock.JSONResponse(`{"id":"r-yaml","name":"demo-yaml","uri":"/demo-yaml","service_id":"svc1"}`)) tmp := t.TempDir() path := filepath.Join(tmp, "route.yaml") - if err := os.WriteFile(path, []byte("name: demo-yaml\nuri: /demo-yaml\n"), 0o600); err != nil { + if err := os.WriteFile(path, []byte("name: demo-yaml\nuri: /demo-yaml\nservice_id: svc1\n"), 0o600); err != nil { t.Fatalf("write file: %v", err) } diff --git a/pkg/cmd/stream-route/update/update.go b/pkg/cmd/stream-route/update/update.go index d4649bd..100c9a1 100644 --- a/pkg/cmd/stream-route/update/update.go +++ b/pkg/cmd/stream-route/update/update.go @@ -82,6 +82,14 @@ func actionRun(opts *Options) error { if err != nil { return err } + if opts.ServiceID != "" { + if _, ok := payload["service_id"]; !ok { + payload["service_id"] = opts.ServiceID + } + } + if serviceID, ok := payload["service_id"]; !ok || strings.TrimSpace(fmt.Sprint(serviceID)) == "" { + return fmt.Errorf("--service-id is required for current API7 EE") + } client := api.NewClient(httpClient, cfg.BaseURL()) body, err := client.Put("/apisix/admin/stream_routes/"+opts.ID+"?gateway_group_id="+ggID, payload) if err != nil { @@ -93,6 +101,9 @@ func actionRun(opts *Options) error { } return cmdutil.NewExporter(format, opts.IO.Out).Write(json.RawMessage(body)) } + if opts.ServiceID == "" { + return fmt.Errorf("--service-id is required for current API7 EE") + } httpClient, err := opts.Client() if err != nil { return err diff --git a/pkg/cmd/stream-route/update/update_test.go b/pkg/cmd/stream-route/update/update_test.go index e4baa51..dc548d6 100644 --- a/pkg/cmd/stream-route/update/update_test.go +++ b/pkg/cmd/stream-route/update/update_test.go @@ -3,6 +3,8 @@ package update import ( "encoding/json" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -78,6 +80,44 @@ func TestUpdateStreamRoute_MissingGatewayGroup(t *testing.T) { } } +func TestUpdateStreamRoute_MissingServiceID(t *testing.T) { + ios, _, _, _ := iostreams.Test() + err := actionRun(&Options{ + IO: ios, + Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, + GatewayGroup: "gg1", + ID: "sr1", + Config: func() (config.Config, error) { + return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "--service-id is required") { + t.Fatalf("expected service-id required error, got: %v", err) + } +} + +func TestUpdateStreamRoute_FileMissingServiceID(t *testing.T) { + ios, _, _, _ := iostreams.Test() + path := filepath.Join(t.TempDir(), "stream-route.json") + if err := os.WriteFile(path, []byte(`{"desc":"mysql-updated"}`), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + + err := actionRun(&Options{ + IO: ios, + Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, + GatewayGroup: "gg1", + ID: "sr1", + File: path, + Config: func() (config.Config, error) { + return &mockConfig{baseURL: "http://api.local", token: "test", gatewayGroup: "gg1"}, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "--service-id is required") { + t.Fatalf("expected service-id required error, got: %v", err) + } +} + func TestUpdateStreamRoute_APIError(t *testing.T) { ios, _, _, _ := iostreams.Test() registry := &httpmock.Registry{} diff --git a/skills/a7-plugin-ai-prompt-template/SKILL.md b/skills/a7-plugin-ai-prompt-template/SKILL.md index 928c31c..ee7adc6 100644 --- a/skills/a7-plugin-ai-prompt-template/SKILL.md +++ b/skills/a7-plugin-ai-prompt-template/SKILL.md @@ -70,10 +70,18 @@ Instead of sending a standard `messages` array, clients send: All runtime resources must be scoped to a gateway group using `--gateway-group` or `-g`. ```bash +a7 service create -g default -f - <<'EOF' +{ + "id": "ai-chat-service", + "name": "AI Chat Service" +} +EOF + a7 route create -g default -f - <<'EOF' { "id": "templated-chat", "uri": "/v1/chat/completions", + "service_id": "ai-chat-service", "methods": ["POST"], "plugins": { "ai-proxy": { @@ -163,9 +171,13 @@ a7 config sync -f config.yaml --gateway-group default ```yaml version: "1" +services: + - id: ai-chat-service + name: AI Chat Service routes: - id: templated-chat uri: /v1/chat/completions + service_id: ai-chat-service methods: - POST plugins: diff --git a/skills/a7-plugin-consumer-restriction/SKILL.md b/skills/a7-plugin-consumer-restriction/SKILL.md index 8e3c2c6..d7281df 100644 --- a/skills/a7-plugin-consumer-restriction/SKILL.md +++ b/skills/a7-plugin-consumer-restriction/SKILL.md @@ -25,7 +25,7 @@ metadata: ## Overview The `consumer-restriction` plugin in API7 Enterprise Edition (API7 EE) restricts access to routes or services based -on the authenticated consumer's identity. It supports four restriction types +on the authenticated consumer's identity. It supports three restriction types and three matching modes (blacklist, whitelist, method-level). **Priority:** 2400 (runs in the `access` phase after authentication plugins). diff --git a/skills/a7-plugin-limit-req/SKILL.md b/skills/a7-plugin-limit-req/SKILL.md index f931e92..3a1d8f6 100644 --- a/skills/a7-plugin-limit-req/SKILL.md +++ b/skills/a7-plugin-limit-req/SKILL.md @@ -99,10 +99,22 @@ Incoming requests → [ Bucket (burst capacity) ] → Leak at 'rate' per sec ### 1. Strict QPS limit (no burst) ```bash +a7 service create -g default -f - <<'EOF' +{ + "id": "rate-limit-backend", + "name": "rate-limit-backend", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create -g default -f - <<'EOF' { "id": "strict-qps", "uri": "/api/*", + "service_id": "rate-limit-backend", "plugins": { "limit-req": { "rate": 10, @@ -111,10 +123,6 @@ a7 route create -g default -f - <<'EOF' "rejected_code": 429, "nodelay": true } - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -125,10 +133,22 @@ EOF ### 2. Smooth traffic with burst allowance ```bash +a7 service create -g default -f - <<'EOF' +{ + "id": "smooth-api-backend", + "name": "smooth-api-backend", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create -g default -f - <<'EOF' { "id": "smooth-api", "uri": "/api/*", + "service_id": "smooth-api-backend", "plugins": { "limit-req": { "rate": 5, @@ -136,10 +156,6 @@ a7 route create -g default -f - <<'EOF' "key": "remote_addr", "rejected_code": 429 } - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -240,21 +256,23 @@ This prevents both short-term spikes and long-term abuse. ```yaml version: "1" -gateway_groups: - - name: default - routes: - - id: smooth-api - uri: /api/* - plugins: - limit-req: - rate: 10 - burst: 20 - key: remote_addr - rejected_code: 429 - upstream: - type: roundrobin - nodes: - - host: backend - port: 8080 - weight: 1 +services: + - id: smooth-api-backend + name: smooth-api-backend + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 +routes: + - id: smooth-api + uri: /api/* + service_id: smooth-api-backend + plugins: + limit-req: + rate: 10 + burst: 20 + key: remote_addr + rejected_code: 429 ``` diff --git a/skills/a7-plugin-proxy-rewrite/SKILL.md b/skills/a7-plugin-proxy-rewrite/SKILL.md index 5ba9fa7..2b649f9 100644 --- a/skills/a7-plugin-proxy-rewrite/SKILL.md +++ b/skills/a7-plugin-proxy-rewrite/SKILL.md @@ -60,18 +60,26 @@ your backend service. Strip `/api/v1` prefix so `/api/v1/users` becomes `/users` for gateway group `default`: ```bash +a7 service create --gateway-group default -f - <<'EOF' +{ + "id": "proxy-rewrite-backend", + "name": "proxy-rewrite-backend", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create --gateway-group default -f - <<'EOF' { "id": "strip-prefix", "uri": "/api/v1/*", + "service_id": "proxy-rewrite-backend", "plugins": { "proxy-rewrite": { "regex_uri": ["^/api/v1/(.*)", "/$1"] } - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -82,18 +90,26 @@ EOF Route to a different virtual host on the backend: ```bash +a7 service create --gateway-group prod -f - <<'EOF' +{ + "id": "legacy-backend-service", + "name": "legacy-backend-service", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create --gateway-group prod -f - <<'EOF' { "id": "rewrite-host", "uri": "/legacy/*", + "service_id": "legacy-backend-service", "plugins": { "proxy-rewrite": { "host": "legacy.internal.svc" } - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -102,10 +118,22 @@ EOF ### 3. Add and remove headers ```bash +a7 service create --gateway-group stage -f - <<'EOF' +{ + "id": "header-backend-service", + "name": "header-backend-service", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create --gateway-group stage -f - <<'EOF' { "id": "header-manip", "uri": "/api/*", + "service_id": "header-backend-service", "plugins": { "proxy-rewrite": { "headers": { @@ -119,10 +147,6 @@ a7 route create --gateway-group stage -f - <<'EOF' "remove": ["X-Internal-Debug", "X-Secret-Token"] } } - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend", "port": 8080, "weight": 1}] } } EOF @@ -217,10 +241,19 @@ Replace the entire URI path: ```yaml version: "1" -gateway_group: default +services: + - id: proxy-rewrite-backend + name: proxy-rewrite-backend + upstream: + type: roundrobin + nodes: + - host: backend + port: 8080 + weight: 1 routes: - id: api-rewrite uri: /api/v1/* + service_id: proxy-rewrite-backend plugins: proxy-rewrite: regex_uri: @@ -231,10 +264,4 @@ routes: X-Forwarded-Prefix: "/api/v1" remove: - X-Debug - upstream: - type: roundrobin - nodes: - - host: backend - port: 8080 - weight: 1 ``` diff --git a/skills/a7-plugin-traffic-split/SKILL.md b/skills/a7-plugin-traffic-split/SKILL.md index bd03103..dfffb04 100644 --- a/skills/a7-plugin-traffic-split/SKILL.md +++ b/skills/a7-plugin-traffic-split/SKILL.md @@ -83,10 +83,22 @@ deployments, and A/B testing — all without modifying DNS or load balancers. Configure a 20/80 split for gateway group `default`: ```bash +a7 service create --gateway-group default -f - <<'EOF' +{ + "id": "stable-api-service", + "name": "stable-api-service", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "backend-v1", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create --gateway-group default -f - <<'EOF' { "id": "canary-release", "uri": "/api/*", + "service_id": "stable-api-service", "plugins": { "traffic-split": { "rules": [ @@ -107,10 +119,6 @@ a7 route create --gateway-group default -f - <<'EOF' } ] } - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "backend-v1", "port": 8080, "weight": 1}] } } EOF @@ -121,10 +129,22 @@ Result: 20% traffic → `backend-v2`, 80% → `backend-v1` (route default). ### 2. Blue-green deployment — header-based switching ```bash +a7 service create --gateway-group prod -f - <<'EOF' +{ + "id": "blue-api-service", + "name": "blue-api-service", + "upstream": { + "type": "roundrobin", + "nodes": [{"host": "blue-backend", "port": 8080, "weight": 1}] + } +} +EOF + a7 route create --gateway-group prod -f - <<'EOF' { "id": "blue-green", "uri": "/api/*", + "service_id": "blue-api-service", "plugins": { "traffic-split": { "rules": [ @@ -149,10 +169,6 @@ a7 route create --gateway-group prod -f - <<'EOF' } ] } - }, - "upstream": { - "type": "roundrobin", - "nodes": [{"host": "blue-backend", "port": 8080, "weight": 1}] } } EOF @@ -301,10 +317,19 @@ on service upstream configuration when needed. ```yaml version: "1" -gateway_group: default +services: + - id: stable-api-service + name: stable-api-service + upstream: + type: roundrobin + nodes: + - host: stable-backend + port: 8080 + weight: 1 routes: - id: canary-api uri: /api/* + service_id: stable-api-service plugins: traffic-split: rules: @@ -317,10 +342,4 @@ routes: weight: 1 weight: 2 - weight: 8 - upstream: - type: roundrobin - nodes: - - host: stable-backend - port: 8080 - weight: 1 ``` diff --git a/skills/a7-recipe-multi-tenant/SKILL.md b/skills/a7-recipe-multi-tenant/SKILL.md index ee998c6..e00bb80 100644 --- a/skills/a7-recipe-multi-tenant/SKILL.md +++ b/skills/a7-recipe-multi-tenant/SKILL.md @@ -208,6 +208,7 @@ key material. ```bash a7 consumer list -g platform +a7 credential list -g platform --consumer startup-xyz a7 service get tenant-api-service -g platform -o json a7 route get multi-tenant-api -g platform -o json ``` From 5d18471515bf400e47a5ea7eff13945ceba12576 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Tue, 12 May 2026 09:26:42 +0800 Subject: [PATCH 3/3] address follow-up review comments --- docs/user-guide/stream-route.md | 5 +++-- pkg/cmd/config/configutil/configutil.go | 6 +++--- pkg/cmd/config/diff/diff.go | 3 +++ pkg/cmd/config/diff/diff_test.go | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/user-guide/stream-route.md b/docs/user-guide/stream-route.md index 9f11e57..a6c5a7a 100644 --- a/docs/user-guide/stream-route.md +++ b/docs/user-guide/stream-route.md @@ -58,7 +58,8 @@ a7 stream-route create -g default -f stream-route.json ``` **Sample `stream-route.json`:** -Create the service first and use its `id` as `service_id`; current API7 EE stores upstream configuration on services. + +Create a service first with upstream configuration (see [Service Management](service.md)), then use its `id` as `service_id`. API7 EE stores upstream configuration at the service level. ```json { @@ -134,7 +135,7 @@ Key fields in the stream route configuration (sent to `/apisix/admin/stream_rout | `server_addr` | string | Match destination server address | | `server_port` | integer | Match destination server port | | `sni` | string | Match TLS SNI | -| `service_id` | string | Required by current API7 EE; reference to the service that owns the upstream configuration | +| `service_id` | string | Required by API7 EE; reference to the service that owns the upstream configuration | | `plugins` | object | Plugin configurations | | `labels` | object | Key-value pairs for filtering and organization | diff --git a/pkg/cmd/config/configutil/configutil.go b/pkg/cmd/config/configutil/configutil.go index de5d7f8..73f746d 100644 --- a/pkg/cmd/config/configutil/configutil.go +++ b/pkg/cmd/config/configutil/configutil.go @@ -176,10 +176,10 @@ func FetchRemoteConfig(client *api.Client, gatewayGroup string) (*api.ConfigFile } func ComputeDiff(local, remote api.ConfigFile) (*DiffResult, error) { - if err := validateSupportedSections(local); err != nil { + if err := ValidateSupportedSections(local); err != nil { return nil, err } - if err := validateSupportedSections(remote); err != nil { + if err := ValidateSupportedSections(remote); err != nil { return nil, fmt.Errorf("remote config: %w", err) } @@ -234,7 +234,7 @@ func ComputeDiff(local, remote api.ConfigFile) (*DiffResult, error) { }, nil } -func validateSupportedSections(cfg api.ConfigFile) error { +func ValidateSupportedSections(cfg api.ConfigFile) error { var unsupported []string if len(cfg.Upstreams) > 0 { unsupported = append(unsupported, "upstreams") diff --git a/pkg/cmd/config/diff/diff.go b/pkg/cmd/config/diff/diff.go index 71d319b..ae86d90 100644 --- a/pkg/cmd/config/diff/diff.go +++ b/pkg/cmd/config/diff/diff.go @@ -53,6 +53,9 @@ func diffRun(opts *Options) error { if err != nil { return err } + if err := configutil.ValidateSupportedSections(local); err != nil { + return err + } cfg, err := opts.Config() if err != nil { diff --git a/pkg/cmd/config/diff/diff_test.go b/pkg/cmd/config/diff/diff_test.go index b7c1c0f..ab707f2 100644 --- a/pkg/cmd/config/diff/diff_test.go +++ b/pkg/cmd/config/diff/diff_test.go @@ -200,7 +200,6 @@ routes: func TestConfigDiff_RejectsUnsupportedSections(t *testing.T) { reg := &httpmock.Registry{} - registerEmptyResources(reg, nil) local := writeConfig(t, ` version: "1" @@ -221,6 +220,7 @@ consumer_groups: assert.Contains(t, err.Error(), "unsupported declarative config sections") assert.Contains(t, err.Error(), "upstreams") assert.Contains(t, err.Error(), "consumer_groups") + reg.Verify(t) } func TestConfigDiff_JSONOutput(t *testing.T) {