diff --git a/.ai/specs/dcm-cli.spec.md b/.ai/specs/dcm-cli.spec.md index d07a4e6..e857252 100644 --- a/.ai/specs/dcm-cli.spec.md +++ b/.ai/specs/dcm-cli.spec.md @@ -18,7 +18,7 @@ control-plane monolith on port 8080. The CLI uses generated clients from - Catalog item operations (create, list, get, delete) - Catalog item instance operations (create, list, get, delete, rehydrate) - SP resource read operations (list, get) via Service Provider Resource Manager -- SP provider read operations (list, get) via Service Provider Manager +- Environment agent read operations (list, get) via Agent Manager - Version display - Output formatting (table, JSON, YAML) - Configuration via file, environment variables, and flags @@ -76,7 +76,7 @@ dcm-cli/ │ │ ├── catalog_item.go ← Catalog item command group │ │ ├── catalog_instance.go ← Catalog instance command group │ │ ├── sp_resource.go ← SP resource command group -│ │ ├── sp_provider.go ← SP provider command group +│ │ ├── agent.go ← Environment agent command group │ │ └── completion.go ← Shell completion command │ └── version/ ← Build-time version info ├── test/e2e/ ← E2E tests (build tag: e2e) @@ -167,7 +167,8 @@ Out of scope: shell autocompletion, plugin system, interactive prompts. - **When** `dcm --help` is run - **Then** subcommands `policy`, `catalog`, `sp`, `version`, and `completion` MUST be listed - **And** `dcm catalog --help` MUST list `service-type`, `item`, and `instance` -- **And** `dcm sp --help` MUST list `resource` and `provider` +- **And** `dcm sp --help` MUST list `resource` +- **And** `dcm --help` MUST list `agent` ##### AC-CLI-040: Exit code on success @@ -1015,7 +1016,7 @@ SP health check. | ID | Requirement | Priority | Notes | |----|-------------|----------|-------| -| REQ-SPR-010 | `dcm sp resource list` MUST list SP resources (service type instances) with optional `--provider`, `--show-deleted`, `--page-size`, `--page-token` flags | MUST | | +| REQ-SPR-010 | `dcm sp resource list` MUST list SP resources (service type instances) with optional `--agent-name`, `--show-deleted`, `--page-size`, `--page-token` flags | MUST | | | REQ-SPR-020 | `dcm sp resource list` MUST display SP resources in the configured output format | MUST | | | REQ-SPR-030 | `dcm sp resource get` MUST accept an `INSTANCE_ID` positional argument and display the SP resource | MUST | | | REQ-SPR-035 | `dcm sp resource get` MUST support an optional `--show-deleted` flag | MUST | | @@ -1028,15 +1029,15 @@ SP health check. Default: ``` -ID PROVIDER STATUS CREATED -my-instance kubevirt-123 READY 2026-03-09T10:00:00Z +ID AGENT STATUS CREATED +my-instance kubevirt-east READY 2026-03-09T10:00:00Z ``` With `--show-deleted`: ``` -ID PROVIDER STATUS DELETION STATUS CREATED -my-instance kubevirt-123 READY 2026-03-09T10:00:00Z -deleted-instance kubevirt-123 DELETED PENDING 2026-03-09T10:00:00Z +ID AGENT STATUS DELETION STATUS CREATED +my-instance kubevirt-east READY 2026-03-09T10:00:00Z +deleted-instance kubevirt-east DELETED PENDING 2026-03-09T10:00:00Z ``` #### Acceptance Criteria @@ -1056,12 +1057,12 @@ deleted-instance kubevirt-123 DELETED PENDING 2026-03-09T10:00:00Z - **When** `dcm sp resource list --page-size 5` is invoked - **Then** the GET request MUST include `max_page_size=5` as a query parameter -##### AC-SPR-030: List SP resources with provider filter +##### AC-SPR-030: List SP resources with agent-name filter - **Validates:** REQ-SPR-010 - **Given** SP resources exist in the system -- **When** `dcm sp resource list --provider kubevirt-123` is invoked -- **Then** the GET request MUST include `provider=kubevirt-123` as a query parameter +- **When** `dcm sp resource list --agent-name kubevirt-east` is invoked +- **Then** the GET request MUST include `agent_name=kubevirt-east` as a query parameter ##### AC-SPR-035: List SP resources with show-deleted @@ -1218,97 +1219,73 @@ Depends on Topic 1 (CLI Framework). --- -### 4.11 SP Provider Commands +### 4.11 Environment Agent Commands #### Overview -Implement the `dcm sp provider` command group with read-only subcommands: `list` -and `get`. Providers are service providers registered with the Service Provider -Manager. The CLI provides read-only access to these resources via the top-level -generated SP Manager client ([pkg/sp/client/provider](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/provider)). +Implement the `dcm agent` command group with read-only subcommands: `list` +and `get`. Agents are environment agents registered with the Agent Manager. +The CLI provides read-only access via the generated agent client +([pkg/agent/client](https://github.com/dcm-project/control-plane/tree/main/pkg/agent/client)). -Out of scope: SP provider create/update/delete (managed via other flows), -SP provider health check. +Out of scope: agent create/register, heartbeat, delete. #### Requirements | ID | Requirement | Priority | Notes | |----|-------------|----------|-------| -| REQ-SPP-010 | `dcm sp provider list` MUST list SP providers with optional `--type`, `--page-size`, `--page-token` flags | MUST | | -| REQ-SPP-020 | `dcm sp provider list` MUST display SP providers in the configured output format | MUST | | -| REQ-SPP-030 | `dcm sp provider get` MUST accept a `PROVIDER_ID` positional argument and display the SP provider | MUST | | -| REQ-SPP-040 | Missing `PROVIDER_ID` argument for `get` MUST result in a usage error (exit code 2) | MUST | | -| REQ-SPP-050 | All SP provider commands MUST use the generated SP Manager client ([pkg/sp/client/provider](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/provider)) | MUST | | +| REQ-AGT-010 | `dcm agent list` MUST list agents with optional `--health-status`, `--page-size`, `--page-token` flags | MUST | | +| REQ-AGT-020 | `dcm agent list` MUST display agents in the configured output format | MUST | | +| REQ-AGT-030 | `dcm agent get` MUST accept an `AGENT_ID` positional argument and display the agent | MUST | | +| REQ-AGT-040 | Missing `AGENT_ID` argument for `get` MUST result in a usage error (exit code 2) | MUST | | +| REQ-AGT-050 | All agent commands MUST use the generated Agent Manager client ([pkg/agent/client](https://github.com/dcm-project/control-plane/tree/main/pkg/agent/client)) | MUST | | #### Table Output Columns ``` -ID NAME SERVICE TYPE HEALTH CREATED -kubevirt-123 KubeVirt SP compute healthy 2026-03-09T10:00:00Z +ID NAME ENVIRONMENT HEALTH CREATED +agent-123 kubevirt-east production ready 2026-03-09T10:00:00Z ``` #### Acceptance Criteria -##### AC-SPP-010: List SP providers +##### AC-AGT-010: List agents -- **Validates:** REQ-SPP-010, REQ-SPP-020 -- **Given** SP providers exist in the system -- **When** `dcm sp provider list` is invoked -- **Then** a GET request MUST be sent to `/api/v1alpha1/providers` -- **And** the SP providers MUST be displayed in the configured output format +- **Validates:** REQ-AGT-010, REQ-AGT-020 +- **Given** agents exist in the system +- **When** `dcm agent list` is invoked +- **Then** a GET request MUST be sent to `/api/v1alpha1/agents` +- **And** the agents MUST be displayed in the configured output format -##### AC-SPP-020: List SP providers with pagination +##### AC-AGT-020: List agents with pagination -- **Validates:** REQ-SPP-010 -- **Given** SP providers exist in the system -- **When** `dcm sp provider list --page-size 5` is invoked +- **Validates:** REQ-AGT-010 +- **Given** agents exist in the system +- **When** `dcm agent list --page-size 5` is invoked - **Then** the GET request MUST include `max_page_size=5` as a query parameter -##### AC-SPP-030: List SP providers with type filter +##### AC-AGT-030: List agents with health-status filter -- **Validates:** REQ-SPP-010 -- **Given** SP providers exist in the system -- **When** `dcm sp provider list --type compute` is invoked -- **Then** the GET request MUST include `type=compute` as a query parameter +- **Validates:** REQ-AGT-010 +- **Given** agents exist in the system +- **When** `dcm agent list --health-status ready` is invoked +- **Then** the GET request MUST include `health_status=ready` as a query parameter -##### AC-SPP-040: Get SP provider +##### AC-AGT-040: Get agent -- **Validates:** REQ-SPP-030 -- **Given** an SP provider with ID `kubevirt-123` exists -- **When** `dcm sp provider get kubevirt-123` is invoked -- **Then** a GET request MUST be sent to `/api/v1alpha1/providers/kubevirt-123` -- **And** the SP provider MUST be displayed in the configured output format +- **Validates:** REQ-AGT-030 +- **Given** an agent with ID `agent-123` exists +- **When** `dcm agent get agent-123` is invoked +- **Then** a GET request MUST be sent to `/api/v1alpha1/agents/agent-123` +- **And** the agent MUST be displayed in the configured output format -##### AC-SPP-050: Get without PROVIDER_ID +##### AC-AGT-050: Get without AGENT_ID -- **Validates:** REQ-SPP-040 +- **Validates:** REQ-AGT-040 - **Given** no positional argument is provided -- **When** `dcm sp provider get` is invoked +- **When** `dcm agent get` is invoked - **Then** the CLI MUST exit with code 2 and display a usage error -##### AC-SPP-060: List SP providers returns empty list - -- **Validates:** REQ-SPP-010, REQ-SPP-020 -- **Given** no SP providers exist in the system -- **When** `dcm sp provider list` is invoked -- **Then** a GET request MUST be sent to `/api/v1alpha1/providers` -- **And** an empty result MUST be displayed (empty table with headers only, or empty JSON array/YAML list) - -##### AC-SPP-070: Get non-existent SP provider - -- **Validates:** REQ-SPP-030, REQ-XC-ERR-010 -- **Given** no SP provider with ID `nonexistent` exists -- **When** `dcm sp provider get nonexistent` is invoked -- **Then** the API returns a 404 with RFC 7807 body -- **And** the CLI MUST display the error in the configured output format and exit with code 1 - -##### AC-SPP-080: Generated client usage - -- **Validates:** REQ-SPP-050 -- **Given** any SP provider command is invoked -- **When** the command communicates with the API -- **Then** the generated SP Manager client MUST be used - #### Dependencies Depends on Topic 1 (CLI Framework), Topic 2 (Configuration), Topic 3 (Output @@ -1420,7 +1397,7 @@ Formatting). | REQ-XC-CLI-010 | The CLI MUST use the generated Policy Manager client ([pkg/policy/client](https://github.com/dcm-project/control-plane/tree/main/pkg/policy/client)) for all policy operations | MUST | | | REQ-XC-CLI-020 | The CLI MUST use the generated Catalog Manager client ([pkg/catalog/client](https://github.com/dcm-project/control-plane/tree/main/pkg/catalog/client)) for all catalog operations | MUST | | | REQ-XC-CLI-025 | The CLI MUST use the generated SP Resource Manager client ([pkg/sp/client/resource_manager](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/resource_manager)) for all SP resource operations | MUST | | -| REQ-XC-CLI-026 | The CLI MUST use the generated SP Manager client ([pkg/sp/client/provider](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/provider)) for all SP provider operations | MUST | | +| REQ-XC-CLI-026 | The CLI MUST use the generated Agent Manager client ([pkg/agent/client](https://github.com/dcm-project/control-plane/tree/main/pkg/agent/client)) for all agent operations | MUST | | | REQ-XC-CLI-030 | All clients MUST be instantiated with the control-plane URL appended with `/api/v1alpha1` | MUST | | | REQ-XC-CLI-040 | All clients MUST respect the configured request timeout. The timeout applies to the HTTP request deadline (context timeout) only; file I/O and output formatting are not subject to the timeout. | MUST | | | REQ-XC-CLI-050 | All clients MUST use a custom HTTP client with TLS transport when the control-plane URL uses `https://` | MUST | | diff --git a/.ai/test-plans/dcm-cli-unit.test-plan.md b/.ai/test-plans/dcm-cli-unit.test-plan.md index d0e31b6..76c2bbf 100644 --- a/.ai/test-plans/dcm-cli-unit.test-plan.md +++ b/.ai/test-plans/dcm-cli-unit.test-plan.md @@ -4,7 +4,7 @@ - **Related Spec:** .ai/specs/dcm-cli.spec.md - **Related Plan:** .ai/plan/dcm-cli.plan.md -- **Related Requirements:** REQ-CLI-010–070, REQ-CFG-010–070, REQ-OUT-010–120, REQ-POL-010–130, REQ-CST-010–050, REQ-CIT-010–130, REQ-CIN-010–110, REQ-SPR-010–050, REQ-SPP-010–050, REQ-VER-010–030, REQ-CMP-010–060, REQ-XC-ERR-010–070, REQ-XC-INP-010–030, REQ-XC-CLI-010–050, REQ-XC-PAG-010–030, REQ-XC-TLS-010–080 +- **Related Requirements:** REQ-CLI-010–070, REQ-CFG-010–070, REQ-OUT-010–120, REQ-POL-010–130, REQ-CST-010–050, REQ-CIT-010–130, REQ-CIN-010–110, REQ-SPR-010–050, REQ-AGT-010–050, REQ-VER-010–030, REQ-CMP-010–060, REQ-XC-ERR-010–070, REQ-XC-INP-010–030, REQ-XC-CLI-010–050, REQ-XC-PAG-010–030, REQ-XC-TLS-010–080 - **Framework:** Ginkgo v2 + Gomega - **Created:** 2026-03-09 @@ -279,7 +279,7 @@ test classes. Instead: - **Type:** Unit - **Given:** The root command is created - **When:** `dcm sp --help` is executed -- **Then:** Subcommands `resource` and `provider` are listed +- **Then:** Subcommand `resource` is listed ### TC-U021: Global flags are registered @@ -933,14 +933,14 @@ test classes. Instead: - **When:** `dcm sp resource list --page-size 5` is executed - **Then:** The GET request includes `max_page_size=5` as a query parameter -### TC-U123: List SP resources with provider filter +### TC-U123: List SP resources with agent-name filter - **Requirement:** REQ-SPR-010 - **Acceptance Criteria:** AC-SPR-030 - **Type:** Unit - **Given:** A mock server -- **When:** `dcm sp resource list --provider kubevirt-123` is executed -- **Then:** The GET request includes `provider=kubevirt-123` as a query parameter +- **When:** `dcm sp resource list --agent-name kubevirt-east` is executed +- **Then:** The GET request includes `agent_name=kubevirt-east` as a query parameter ### TC-U124: Get SP resource @@ -1021,7 +1021,7 @@ test classes. Instead: - **Type:** Unit - **Given:** A mock server returning an SP resource with all fields populated - **When:** `dcm sp resource get my-instance` is executed with `--output table` -- **Then:** The table output includes columns: ID, PROVIDER, STATUS, CREATED +- **Then:** The table output includes columns: ID, AGENT, STATUS, CREATED --- @@ -1095,94 +1095,82 @@ test classes. Instead: --- -## 10a · SP Provider Commands +## 10a · Environment Agent Commands -> **Suggested Ginkgo structure:** `Describe("SP Provider Commands")` with -> nested `Describe` per subcommand. All tests use `net/http/httptest` to mock -> the generated client's HTTP calls. +> **Suggested Ginkgo structure:** `Describe("Agent Commands")` with nested +> `Describe` per subcommand. -### TC-U139: List SP providers +### TC-U139: List environment agents -- **Requirement:** REQ-SPP-010, REQ-SPP-020 -- **Acceptance Criteria:** AC-SPP-010 +- **Requirement:** REQ-AGT-010, REQ-AGT-020 +- **Acceptance Criteria:** AC-AGT-010 - **Type:** Unit -- **Transitively covers:** TC-U149 (generated SP Manager client usage) -- **Given:** A mock server returning 200 with a list of SP providers -- **When:** `dcm sp provider list` is executed -- **Then:** A GET request is sent to `/api/v1alpha1/providers` AND the SP providers are displayed in the configured output format +- **Given:** A mock server returning 200 with a list of agents +- **When:** `dcm agent list` is executed +- **Then:** A GET request is sent to `/api/v1alpha1/agents` AND the agents are displayed -### TC-U140: List SP providers with pagination +### TC-U140: List agents with pagination -- **Requirement:** REQ-SPP-010 -- **Acceptance Criteria:** AC-SPP-020 +- **Requirement:** REQ-AGT-010 +- **Acceptance Criteria:** AC-AGT-020 - **Type:** Unit -- **Transitively covers:** TC-U069 (pagination flags present) - **Given:** A mock server -- **When:** `dcm sp provider list --page-size 5` is executed +- **When:** `dcm agent list --page-size 5` is executed - **Then:** The GET request includes `max_page_size=5` as a query parameter -### TC-U141: List SP providers with type filter +### TC-U141: List agents with health-status filter -- **Requirement:** REQ-SPP-010 -- **Acceptance Criteria:** AC-SPP-030 +- **Requirement:** REQ-AGT-010 +- **Acceptance Criteria:** AC-AGT-030 - **Type:** Unit - **Given:** A mock server -- **When:** `dcm sp provider list --type compute` is executed -- **Then:** The GET request includes `type=compute` as a query parameter +- **When:** `dcm agent list --health-status ready` is executed +- **Then:** The GET request includes `health_status=ready` as a query parameter -### TC-U142: Get SP provider +### TC-U142: Get agent -- **Requirement:** REQ-SPP-030 -- **Acceptance Criteria:** AC-SPP-040 +- **Requirement:** REQ-AGT-030 +- **Acceptance Criteria:** AC-AGT-040 - **Type:** Unit -- **Given:** A mock server returning 200 with an SP provider -- **When:** `dcm sp provider get kubevirt-123` is executed -- **Then:** A GET request is sent to `/api/v1alpha1/providers/kubevirt-123` AND the SP provider is displayed +- **Given:** A mock server returning 200 with an agent +- **When:** `dcm agent get agent-123` is executed +- **Then:** A GET request is sent to `/api/v1alpha1/agents/agent-123` AND the agent is displayed -### TC-U143: Get SP provider without PROVIDER_ID fails +### TC-U143: Get agent without AGENT_ID fails -- **Requirement:** REQ-SPP-040 -- **Acceptance Criteria:** AC-SPP-050 +- **Requirement:** REQ-AGT-040 +- **Acceptance Criteria:** AC-AGT-050 - **Type:** Unit - **Given:** No positional argument is provided -- **When:** `dcm sp provider get` is executed +- **When:** `dcm agent get` is executed - **Then:** The CLI exits with code 2 and displays a usage error -### TC-U144: List SP providers returns empty list +### TC-U144: List agents returns empty list -- **Requirement:** REQ-SPP-010, REQ-SPP-020 -- **Acceptance Criteria:** AC-SPP-060 +- **Requirement:** REQ-AGT-010, REQ-AGT-020 +- **Acceptance Criteria:** AC-AGT-010 - **Type:** Unit -- **Given:** A mock server returning 200 with an empty SP provider list (`{"providers":[],"next_page_token":""}`) -- **When:** `dcm sp provider list` is executed +- **Given:** A mock server returning 200 with an empty agent list (`{"agents":[],"next_page_token":""}`) +- **When:** `dcm agent list` is executed - **Then:** An empty result is displayed (empty table with headers only for table format, empty array for JSON, empty list for YAML) -### TC-U145: Get non-existent SP provider +### TC-U145: Get non-existent agent -- **Requirement:** REQ-SPP-030, REQ-XC-ERR-010 -- **Acceptance Criteria:** AC-SPP-070, AC-XC-ERR-010 +- **Requirement:** REQ-AGT-030, REQ-XC-ERR-010 +- **Acceptance Criteria:** AC-AGT-040, AC-XC-ERR-010 - **Type:** Unit -- **Given:** A mock server returning 404 with RFC 7807 body for provider ID `nonexistent` -- **When:** `dcm sp provider get nonexistent` is executed +- **Given:** A mock server returning 404 with RFC 7807 body for agent ID `nonexistent` +- **When:** `dcm agent get nonexistent` is executed - **Then:** The CLI displays the error in the configured output format AND exits with code 1 -### TC-U146: SP provider table output columns +### TC-U146: Agent table output columns - **Requirement:** REQ-OUT-050 - **Acceptance Criteria:** AC-OUT-010 - **Type:** Unit -- **Given:** A mock server returning an SP provider with all fields populated -- **When:** `dcm sp provider get kubevirt-123` is executed with `--output table` -- **Then:** The table output includes columns: ID, NAME, SERVICE TYPE, HEALTH, CREATED - -### TC-U147: SP command registers provider subcommand - -- **Requirement:** REQ-CLI-030 -- **Acceptance Criteria:** AC-CLI-030 -- **Type:** Unit -- **Given:** The root command is created -- **When:** `dcm sp --help` is executed -- **Then:** Subcommand `provider` is listed alongside `resource` +- **Given:** A mock server returning an agent with all fields populated +- **When:** `dcm agent get agent-123` is executed with `--output table` +- **Then:** The table output includes columns: ID, NAME, ENVIRONMENT, HEALTH, CREATED --- @@ -1516,24 +1504,24 @@ dedicated test class or `Describe` block. - **Then:** The generated SP Resource Manager client is used (verified by mock server receiving correctly structured requests) - **Referenced by:** TC-U121 (list), TC-U124 (get) -#### TC-U148: SP Manager client instantiated with correct URL +#### TC-U148: Agent Manager client instantiated with correct URL - **Requirement:** REQ-XC-CLI-026, REQ-XC-CLI-030 - **Acceptance Criteria:** AC-XC-CLI-010 - **Type:** Unit - **Given:** The control-plane URL is `http://localhost:8080` -- **When:** The SP Manager client is created +- **When:** The Agent Manager client is created - **Then:** The client base URL is `http://localhost:8080/api/v1alpha1` -- **Referenced by:** TC-U139 (list SP providers verifies request goes to correct URL path) +- **Referenced by:** TC-U139 (list agents verifies request goes to correct URL path) -#### TC-U149: SP Manager generated client used for SP provider operations +#### TC-U149: Agent Manager generated client used for agent operations -- **Requirement:** REQ-XC-CLI-026, REQ-SPP-050 -- **Acceptance Criteria:** AC-SPP-080 +- **Requirement:** REQ-XC-CLI-026, REQ-AGT-050 +- **Acceptance Criteria:** AC-AGT-010 - **Type:** Unit (structural) -- **Given:** Any SP provider command is invoked +- **Given:** Any agent command is invoked - **When:** The command communicates with the API -- **Then:** The generated SP Manager client is used (verified by mock server receiving correctly structured requests) +- **Then:** The generated Agent Manager client is used (verified by mock server receiving correctly structured requests) - **Referenced by:** TC-U139 (list), TC-U142 (get) #### TC-U068: Request timeout applied to HTTP requests @@ -1553,10 +1541,10 @@ dedicated test class or `Describe` block. - **Requirement:** REQ-XC-PAG-010 - **Acceptance Criteria:** AC-XC-PAG-010 - **Type:** Unit -- **Given:** Any list command (`policy list`, `catalog service-type list`, `catalog item list`, `catalog instance list`, `sp resource list`, `sp provider list`) +- **Given:** Any list command (`policy list`, `catalog service-type list`, `catalog item list`, `catalog instance list`, `sp resource list`, `agent list`) - **When:** `--help` is displayed - **Then:** `--page-size` and `--page-token` flags are listed -- **Referenced by:** TC-U033 (policy list pagination), TC-U043 (service-type list pagination), TC-U074 (instance list pagination), TC-U122 (SP resource list pagination), TC-U140 (SP provider list pagination) +- **Referenced by:** TC-U033 (policy list pagination), TC-U043 (service-type list pagination), TC-U074 (instance list pagination), TC-U122 (SP resource list pagination), TC-U140 (agent list pagination) #### TC-U070: Pagination parameters passed as query parameters @@ -1682,11 +1670,11 @@ dedicated test class or `Describe` block. | REQ-SPR-050 | TC-U131 (via TC-U121, TC-U124) | Covered | | REQ-SPR-060 | TC-U154, TC-U156 | Covered | | REQ-SPR-070 | TC-U154, TC-U155, TC-U156, TC-U157 | Covered | -| REQ-SPP-010 | TC-U139, TC-U140, TC-U141 | Covered | -| REQ-SPP-020 | TC-U139 | Covered | -| REQ-SPP-030 | TC-U142 | Covered | -| REQ-SPP-040 | TC-U143 | Covered | -| REQ-SPP-050 | TC-U149 (via TC-U139, TC-U142) | Covered | +| REQ-AGT-010 | TC-U139, TC-U140, TC-U141, TC-U144 | Covered | +| REQ-AGT-020 | TC-U139, TC-U144 | Covered | +| REQ-AGT-030 | TC-U142, TC-U145 | Covered | +| REQ-AGT-040 | TC-U143 | Covered | +| REQ-AGT-050 | TC-U139, TC-U142, TC-U149 | Covered | | REQ-VER-010 | TC-U024 | Covered | | REQ-VER-020 | TC-U024 | Covered | | REQ-VER-030 | TC-U025 | Covered | diff --git a/CLAUDE.md b/CLAUDE.md index f82292f..9e0c4e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Generated client packages: - [pkg/policy/client](https://github.com/dcm-project/control-plane/tree/main/pkg/policy/client) — Policy Manager - [pkg/catalog/client](https://github.com/dcm-project/control-plane/tree/main/pkg/catalog/client) — Catalog Manager - [pkg/sp/client/resource_manager](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/resource_manager) — SP Resource Manager -- [pkg/sp/client/provider](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/provider) — SP Manager +- [pkg/agent/client](https://github.com/dcm-project/control-plane/tree/main/pkg/agent/client) — Agent Manager ## Build and Development Commands @@ -77,7 +77,7 @@ make check-fixtures - `catalog_item.go`: Catalog item create/list/get/delete commands - `catalog_instance.go`: Catalog instance create/list/get/delete/rehydrate commands - `sp_resource.go`: SP resource list/get commands - - `sp_provider.go`: SP provider list/get commands + - `agent.go`: Environment agent list/get commands - `completion.go`: Shell completion - `version.go`: Version display command @@ -100,7 +100,7 @@ E2E tests live under `test/e2e/` and use the `e2e` build tag (`//go:build e2e`). ## Key Conventions -1. **Cobra commands**: Each resource group (policy, catalog service-type, catalog item, catalog instance, sp resource, sp provider) has its own file with subcommands. Policy supports create/list/get/update/delete. Catalog item and catalog instance do not support update. SP commands are read-only (list/get). +1. **Cobra commands**: Each resource group (policy, catalog service-type, catalog item, catalog instance, sp resource, agent) has its own file with subcommands. Policy supports create/list/get/update/delete. Catalog item and catalog instance do not support update. SP resource and agent commands are read-only (list/get). 2. **Generated clients**: Import from `github.com/dcm-project/control-plane/pkg/...` (see links in Project Overview). Client constructors live in `helpers.go`. No hand-written HTTP client code. diff --git a/README.md b/README.md index edf2af0..faa3a12 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ### 1.1 Purpose -The DCM CLI (`dcm`) is the primary user-facing command-line interface for interacting with the DCM (Data Center Management) control plane. It provides commands for managing policies, service types, catalog items, catalog item instances, and service provider resources through the control-plane monolith API. +The DCM CLI (`dcm`) is the primary user-facing command-line interface for interacting with the DCM (Data Center Management) control plane. It provides commands for managing policies, service types, catalog items, catalog item instances, service provider resources, and environment agents through the control-plane monolith API. ### 1.2 Version Scope @@ -52,7 +52,7 @@ The CLI communicates exclusively through the control plane (port 8080). When the - `/api/v1alpha1/catalog-items/*` → Catalog Manager - `/api/v1alpha1/catalog-item-instances/*` → Catalog Manager - `/api/v1alpha1/service-type-instances/*` → SP Resource Manager -- `/api/v1alpha1/providers/*` → SP Manager +- `/api/v1alpha1/agents/*` → Agent Manager ### 2.2 Internal Architecture @@ -75,6 +75,7 @@ internal/ catalog_instance.go ← Catalog instance command group sp.go ← SP parent command group sp_resource.go ← SP resource command group + agent.go ← Environment agent command group completion.go ← Shell completion command ``` @@ -586,22 +587,23 @@ List SP resources (service type instances) with optional filtering and paginatio | Flag | Required | Description | |------|----------|-------------| -| `--provider` | No | Filter by provider | +| `--agent-name` | No | Filter by agent name | | `--page-size` | No | Maximum results per page | | `--page-token` | No | Token for next page | +| `--show-deleted` | No | Include soft-deleted resources | ```bash dcm sp resource list -dcm sp resource list --provider kubevirt-123 +dcm sp resource list --agent-name kubevirt-east dcm sp resource list --page-size 5 ``` Example output (table): ``` -ID PROVIDER STATUS CREATED -my-instance kubevirt-123 ACTIVE 2026-03-09T10:00:00Z -other-instance openstack-456 PENDING 2026-03-08T15:30:00Z +ID AGENT STATUS CREATED +my-instance kubevirt-east ACTIVE 2026-03-09T10:00:00Z +other-instance openstack-west PENDING 2026-03-08T15:30:00Z ``` #### `dcm sp resource get` @@ -617,7 +619,45 @@ dcm sp resource get INSTANCE_ID dcm sp resource get INSTANCE_ID -o yaml ``` -### 4.10 Completion Command +### 4.10 Agent Commands + +#### `dcm agent list` + +List environment agents with optional filtering and pagination. + +| Flag | Required | Description | +|------|----------|-------------| +| `--health-status` | No | Filter by health status (`ready`, `congested`, `unavailable`) | +| `--page-size` | No | Maximum results per page | +| `--page-token` | No | Token for next page | + +```bash +dcm agent list +dcm agent list --health-status ready +dcm agent list --page-size 5 +``` + +Example output (table): + +``` +ID NAME ENVIRONMENT HEALTH CREATED +agent-123 kubevirt-east production ready 2026-03-09T10:00:00Z +``` + +#### `dcm agent get` + +Get a single environment agent by ID. + +| Argument | Required | Description | +|----------|----------|-------------| +| `AGENT_ID` | Yes | Agent ID | + +```bash +dcm agent get AGENT_ID +dcm agent get AGENT_ID -o yaml +``` + +### 4.11 Completion Command #### `dcm completion` @@ -641,7 +681,7 @@ dcm completion fish | source dcm completion powershell | Out-String | Invoke-Expression ``` -### 4.11 Version Command +### 4.12 Version Command #### `dcm version` @@ -812,7 +852,7 @@ The CLI imports generated client packages from the control-plane monorepo: - [pkg/policy/client](https://github.com/dcm-project/control-plane/tree/main/pkg/policy/client) — Policy Manager client - [pkg/catalog/client](https://github.com/dcm-project/control-plane/tree/main/pkg/catalog/client) — Catalog Manager client - [pkg/sp/client/resource_manager](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/resource_manager) — SP Resource Manager client -- [pkg/sp/client/provider](https://github.com/dcm-project/control-plane/tree/main/pkg/sp/client/provider) — SP Manager client +- [pkg/agent/client](https://github.com/dcm-project/control-plane/tree/main/pkg/agent/client) — Agent Manager client These are oapi-codegen generated clients providing typed API access. Key interfaces: diff --git a/go.mod b/go.mod index 722de93..36e5d55 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/coreos/go-oidc/v3 v3.20.0 - github.com/dcm-project/control-plane v0.0.0-20260817201929-c04802d05ecc + github.com/dcm-project/control-plane v0.0.0-20260923162333-dd2caea5b205 github.com/onsi/ginkgo/v2 v2.29.0 github.com/onsi/gomega v1.41.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index e3abe33..0d9f5b7 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dcm-project/control-plane v0.0.0-20260817201929-c04802d05ecc h1:akJkh+ASNmDij3GV3O2PvW+FW9VvGdiiGf6/8hYwywQ= -github.com/dcm-project/control-plane v0.0.0-20260817201929-c04802d05ecc/go.mod h1:oLQKa1vW81Dmpit5iGjU0EQaRARadjrJ/BmCadwqir4= +github.com/dcm-project/control-plane v0.0.0-20260923162333-dd2caea5b205 h1:O7TJjPWHz6Yx79XX3jKyLzx0yraiHzGf5QihODXOPpo= +github.com/dcm-project/control-plane v0.0.0-20260923162333-dd2caea5b205/go.mod h1:7POHofThZfXGMlyzjLo4a9U0KH3JXXd5VBLySu3+1BY= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/internal/commands/sp_provider.go b/internal/commands/agent.go similarity index 59% rename from internal/commands/sp_provider.go rename to internal/commands/agent.go index 057bb11..944d859 100644 --- a/internal/commands/sp_provider.go +++ b/internal/commands/agent.go @@ -5,60 +5,63 @@ import ( "fmt" "net/http" - spmapi "github.com/dcm-project/control-plane/api/sp/v1alpha1/provider" + agentapi "github.com/dcm-project/control-plane/api/agent/v1alpha1" "github.com/dcm-project/cli/internal/config" "github.com/dcm-project/cli/internal/output" "github.com/spf13/cobra" ) -var spProviderTableDef = &output.TableDef{ - Headers: []string{"ID", "NAME", "SERVICE TYPE", "HEALTH", "CREATED"}, +var agentTableDef = &output.TableDef{ + Headers: []string{"ID", "NAME", "ENVIRONMENT", "HEALTH", "CREATED"}, RowFunc: func(resource any) []string { m, ok := resource.(map[string]any) if !ok { return []string{"", "", "", "", ""} } return []string{ - stringifyValue(m, "id"), + stringifyValue(m, "agent_id"), stringifyValue(m, "name"), - stringifyValue(m, "service_type"), + stringifyValue(m, "environment"), stringifyValue(m, "health_status"), stringifyValue(m, "create_time"), } }, } -func newSPProviderCommand() *cobra.Command { +func newAgentCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "provider", - Short: "Manage SP providers", + Use: "agent", + Short: "Manage environment agents", } - cmd.AddCommand(newSPProviderListCommand()) - cmd.AddCommand(newSPProviderGetCommand()) + cmd.AddCommand(newAgentListCommand()) + cmd.AddCommand(newAgentGetCommand()) return cmd } -func newSPProviderListCommand() *cobra.Command { +func newAgentListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List SP providers", + Short: "List environment agents", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromCommand(cmd) - listCmd := "sp provider list" + listCmd := "agent list" if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { listCmd += fmt.Sprintf(" --page-size %d", pageSize) } + if healthStatus, _ := cmd.Flags().GetString("health-status"); healthStatus != "" { + listCmd += fmt.Sprintf(" --health-status %s", healthStatus) + } - formatter, err := newFormatter(cmd, spProviderTableDef, listCmd) + formatter, err := newFormatter(cmd, agentTableDef, listCmd) if err != nil { return err } - params := &spmapi.ListProvidersParams{} + params := &agentapi.ListAgentsParams{} if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { maxPageSize := int(pageSize) params.MaxPageSize = &maxPageSize @@ -66,19 +69,20 @@ func newSPProviderListCommand() *cobra.Command { if pageToken, _ := cmd.Flags().GetString("page-token"); pageToken != "" { params.PageToken = &pageToken } - if providerType, _ := cmd.Flags().GetString("type"); providerType != "" { - params.Type = &providerType + if healthStatus, _ := cmd.Flags().GetString("health-status"); healthStatus != "" { + status := agentapi.ListAgentsParamsHealthStatus(healthStatus) + params.HealthStatus = &status } - client, err := newSPProviderClient(cfg) + client, err := newAgentClient(cfg) if err != nil { - return fmt.Errorf("creating SP provider client: %w", err) + return fmt.Errorf("creating agent client: %w", err) } ctx, cancel := requestContext(cmd) defer cancel() - resp, err := client.ListProviders(ctx, params) + resp, err := client.ListAgents(ctx, params) if err != nil { return connectionError(err, cfg) } @@ -89,15 +93,15 @@ func newSPProviderListCommand() *cobra.Command { } var listResp struct { - Providers []map[string]any `json:"providers"` + Agents []map[string]any `json:"agents"` NextPageToken string `json:"next_page_token"` } if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { return fmt.Errorf("parsing response: %w", err) } - resources := make([]any, len(listResp.Providers)) - for i, r := range listResp.Providers { + resources := make([]any, len(listResp.Agents)) + for i, r := range listResp.Agents { resources[i] = r } @@ -107,32 +111,32 @@ func newSPProviderListCommand() *cobra.Command { cmd.Flags().Int32("page-size", 0, "Maximum results per page") cmd.Flags().String("page-token", "", "Token for next page") - cmd.Flags().String("type", "", "Filter by service type") + cmd.Flags().String("health-status", "", "Filter by health status (ready, congested, unavailable)") return cmd } -func newSPProviderGetCommand() *cobra.Command { +func newAgentGetCommand() *cobra.Command { return &cobra.Command{ - Use: "get PROVIDER_ID", - Short: "Get an SP provider by ID", + Use: "get AGENT_ID", + Short: "Get an environment agent by ID", Args: ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromCommand(cmd) - formatter, err := newFormatter(cmd, spProviderTableDef, "sp provider get") + formatter, err := newFormatter(cmd, agentTableDef, "agent get") if err != nil { return err } - client, err := newSPProviderClient(cfg) + client, err := newAgentClient(cfg) if err != nil { - return fmt.Errorf("creating SP provider client: %w", err) + return fmt.Errorf("creating agent client: %w", err) } ctx, cancel := requestContext(cmd) defer cancel() - resp, err := client.GetProvider(ctx, args[0]) + resp, err := client.GetAgent(ctx, args[0]) if err != nil { return connectionError(err, cfg) } diff --git a/internal/commands/agent_test.go b/internal/commands/agent_test.go new file mode 100644 index 0000000..8523bf0 --- /dev/null +++ b/internal/commands/agent_test.go @@ -0,0 +1,237 @@ +package commands_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/dcm-project/cli/internal/commands" +) + +// sampleAgentResponse returns a sample environment agent JSON response body. +func sampleAgentResponse() map[string]any { + return map[string]any{ + "agent_id": "agent-123", + "name": "kubevirt-east", + "environment": "production", + "health_status": "ready", + "create_time": "2026-03-09T10:00:00Z", + } +} + +// emptyAgentListResponse returns a standard empty agent list response body. +func emptyAgentListResponse() map[string]any { + return map[string]any{ + "agents": []any{}, + "next_page_token": "", + } +} + +var _ = Describe("Agent Commands", func() { + var ( + server *httptest.Server + outBuf *bytes.Buffer + errBuf *bytes.Buffer + ) + + BeforeEach(func() { + clearDCMEnvVars() + }) + + AfterEach(func() { + if server != nil { + server.Close() + server = nil + } + }) + + executeCommand := func(args ...string) error { + cmd := commands.NewRootCommand() + outBuf = new(bytes.Buffer) + errBuf = new(bytes.Buffer) + cmd.SetOut(outBuf) + cmd.SetErr(errBuf) + + fullArgs := []string{ + "--config", nonexistentConfigPath(), + } + if server != nil { + fullArgs = append(fullArgs, "--control-plane-url", server.URL) + } + fullArgs = append(fullArgs, args...) + cmd.SetArgs(fullArgs) + + return cmd.Execute() + } + + Describe("list", func() { + It("should list environment agents", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/agents")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "agents": []any{sampleAgentResponse()}, + "next_page_token": "", + }) + })) + + err := executeCommand("agent", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("agent-123")) + Expect(out).To(ContainSubstring("kubevirt-east")) + Expect(out).To(ContainSubstring("production")) + }) + + It("should pass max_page_size query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("max_page_size")).To(Equal("5")) + + writeJSONResponse(w, http.StatusOK, emptyAgentListResponse()) + })) + + err := executeCommand("agent", "list", "--page-size", "5") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should pass page_token query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("page_token")).To(Equal("abc123")) + + writeJSONResponse(w, http.StatusOK, emptyAgentListResponse()) + })) + + err := executeCommand("agent", "list", "--page-token", "abc123") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should pass health_status query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("health_status")).To(Equal("ready")) + + writeJSONResponse(w, http.StatusOK, emptyAgentListResponse()) + })) + + err := executeCommand("agent", "list", "--health-status", "ready") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should include --health-status in the next-page command", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("health_status")).To(Equal("ready")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "agents": []any{sampleAgentResponse()}, + "next_page_token": "page-2", + }) + })) + + err := executeCommand("agent", "list", "--health-status", "ready") + Expect(err).NotTo(HaveOccurred()) + Expect(outBuf.String()).To(ContainSubstring("Next page: dcm agent list --health-status ready --page-token page-2")) + }) + + It("should display empty result for empty list", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyAgentListResponse()) + })) + + err := executeCommand("agent", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("ID")) + Expect(out).To(ContainSubstring("NAME")) + Expect(out).To(ContainSubstring("ENVIRONMENT")) + Expect(out).NotTo(ContainSubstring("agent-123")) + }) + + It("should display empty results array in JSON format", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyAgentListResponse()) + })) + + err := executeCommand("--output", "json", "agent", "list") + Expect(err).NotTo(HaveOccurred()) + + var result map[string]any + Expect(json.Unmarshal(outBuf.Bytes(), &result)).To(Succeed()) + Expect(result["results"]).To(BeAssignableToTypeOf([]any{})) + Expect(result["results"]).To(BeEmpty()) + }) + }) + + Describe("get", func() { + It("should get an environment agent by ID", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/agents/agent-123")) + + writeJSONResponse(w, http.StatusOK, sampleAgentResponse()) + })) + + err := executeCommand("agent", "get", "agent-123") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("agent-123")) + Expect(out).To(ContainSubstring("kubevirt-east")) + Expect(out).To(ContainSubstring("production")) + }) + + It("should return a UsageError when AGENT_ID is missing", func() { + err := executeCommand("agent", "get") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + It("should display error for non-existent agent", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", + `Agent "nonexistent" not found.`, + "The requested agent does not exist.") + })) + + err := executeCommand("agent", "get", "nonexistent") + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + + errOut := errBuf.String() + Expect(errOut).To(ContainSubstring("NOT_FOUND")) + Expect(errOut).To(ContainSubstring("not found")) + Expect(outBuf.String()).To(BeEmpty()) + }) + + It("should display correct table columns", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, sampleAgentResponse()) + })) + + err := executeCommand("agent", "get", "agent-123") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("ID")) + Expect(out).To(ContainSubstring("NAME")) + Expect(out).To(ContainSubstring("ENVIRONMENT")) + Expect(out).To(ContainSubstring("HEALTH")) + Expect(out).To(ContainSubstring("CREATED")) + Expect(out).To(ContainSubstring("agent-123")) + Expect(out).To(ContainSubstring("kubevirt-east")) + Expect(out).To(ContainSubstring("production")) + Expect(out).To(ContainSubstring("ready")) + Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) + }) + }) +}) diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 6742b0a..e0ec101 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -13,8 +13,8 @@ import ( "strings" "time" + agentclient "github.com/dcm-project/control-plane/pkg/agent/client" catalogclient "github.com/dcm-project/control-plane/pkg/catalog/client" - spmclient "github.com/dcm-project/control-plane/pkg/sp/client/provider" sprmclient "github.com/dcm-project/control-plane/pkg/sp/client/resource_manager" "github.com/dcm-project/cli/internal/auth" @@ -24,12 +24,12 @@ import ( "go.yaml.in/yaml/v3" ) -func newSPProviderClient(cfg *config.Config) (*spmclient.Client, error) { +func newAgentClient(cfg *config.Config) (*agentclient.Client, error) { httpClient, err := buildHTTPClient(cfg) if err != nil { return nil, err } - return spmclient.NewClient(apiBaseURL(cfg), spmclient.WithHTTPClient(httpClient)) + return agentclient.NewClient(apiBaseURL(cfg), agentclient.WithHTTPClient(httpClient)) } func newSPResourceClient(cfg *config.Config) (*sprmclient.Client, error) { diff --git a/internal/commands/root.go b/internal/commands/root.go index c681f27..bfd5e9e 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -55,6 +55,7 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand(newPolicyCommand()) cmd.AddCommand(newCatalogCommand()) cmd.AddCommand(newSPCommand()) + cmd.AddCommand(newAgentCommand()) cmd.AddCommand(newVersionCommand()) cmd.AddCommand(newCompletionCommand()) cmd.AddCommand(newLoginCommand()) diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go index fd428ef..bacaa1c 100644 --- a/internal/commands/root_test.go +++ b/internal/commands/root_test.go @@ -56,7 +56,7 @@ var _ = Describe("Root Command", func() { // TC-U129: SP command registers subcommand groups Describe("TC-U129: SP subcommand registration", func() { - It("should list resource and provider subcommands in sp help", func() { + It("should list resource subcommand in sp help", func() { cmd := commands.NewRootCommand() out := new(bytes.Buffer) cmd.SetOut(out) @@ -68,7 +68,21 @@ var _ = Describe("Root Command", func() { helpOutput := out.String() Expect(helpOutput).To(ContainSubstring("resource")) - Expect(helpOutput).To(ContainSubstring("provider")) + }) + }) + + Describe("Agent subcommand registration", func() { + It("should list agent in root help", func() { + cmd := commands.NewRootCommand() + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(new(bytes.Buffer)) + cmd.SetArgs([]string{"--help"}) + + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + Expect(out.String()).To(ContainSubstring("agent")) }) }) @@ -173,7 +187,7 @@ var _ = Describe("Root Command", func() { Entry("catalog instance get without ID", []string{"catalog", "instance", "get"}), Entry("catalog instance delete without ID", []string{"catalog", "instance", "delete"}), Entry("sp resource get without ID", []string{"sp", "resource", "get"}), - Entry("sp provider get without ID", []string{"sp", "provider", "get"}), + Entry("agent get without ID", []string{"agent", "get"}), ) }) }) diff --git a/internal/commands/sp.go b/internal/commands/sp.go index c350e32..0b46e70 100644 --- a/internal/commands/sp.go +++ b/internal/commands/sp.go @@ -11,7 +11,6 @@ func newSPCommand() *cobra.Command { } cmd.AddCommand(newSPResourceCommand()) - cmd.AddCommand(newSPProviderCommand()) return cmd } diff --git a/internal/commands/sp_provider_test.go b/internal/commands/sp_provider_test.go deleted file mode 100644 index 4a523c1..0000000 --- a/internal/commands/sp_provider_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package commands_test - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/dcm-project/cli/internal/commands" -) - -// sampleSPProviderResponse returns a sample SP provider JSON response body. -func sampleSPProviderResponse() map[string]any { - return map[string]any{ - "id": "kubevirt-123", - "path": "providers/kubevirt-123", - "name": "KubeVirt SP", - "service_type": "compute", - "health_status": "healthy", - "create_time": "2026-03-09T10:00:00Z", - } -} - -// emptySPProviderListResponse returns a standard empty SP provider list response body. -func emptySPProviderListResponse() map[string]any { - return map[string]any{ - "providers": []any{}, - "next_page_token": "", - } -} - -var _ = Describe("SP Provider Commands", func() { - var ( - server *httptest.Server - outBuf *bytes.Buffer - errBuf *bytes.Buffer - ) - - BeforeEach(func() { - clearDCMEnvVars() - }) - - AfterEach(func() { - if server != nil { - server.Close() - server = nil - } - }) - - executeCommand := func(args ...string) error { - cmd := commands.NewRootCommand() - outBuf = new(bytes.Buffer) - errBuf = new(bytes.Buffer) - cmd.SetOut(outBuf) - cmd.SetErr(errBuf) - - fullArgs := []string{ - "--config", nonexistentConfigPath(), - } - if server != nil { - fullArgs = append(fullArgs, "--control-plane-url", server.URL) - } - fullArgs = append(fullArgs, args...) - cmd.SetArgs(fullArgs) - - return cmd.Execute() - } - - Describe("list", func() { - // TC-U139: List SP providers - It("TC-U139: should list SP providers", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal(http.MethodGet)) - Expect(r.URL.Path).To(Equal("/api/v1alpha1/providers")) - - writeJSONResponse(w, http.StatusOK, map[string]any{ - "providers": []any{sampleSPProviderResponse()}, - "next_page_token": "", - }) - })) - - err := executeCommand("sp", "provider", "list") - Expect(err).NotTo(HaveOccurred()) - - out := outBuf.String() - Expect(out).To(ContainSubstring("kubevirt-123")) - Expect(out).To(ContainSubstring("KubeVirt SP")) - Expect(out).To(ContainSubstring("compute")) - }) - - // TC-U140: List SP providers with pagination - It("TC-U140: should pass max_page_size query parameter", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.URL.Query().Get("max_page_size")).To(Equal("5")) - - writeJSONResponse(w, http.StatusOK, emptySPProviderListResponse()) - })) - - err := executeCommand("sp", "provider", "list", "--page-size", "5") - Expect(err).NotTo(HaveOccurred()) - }) - - // TC-U140 (page-token variant): List SP providers with page token - It("TC-U140: should pass page_token query parameter", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.URL.Query().Get("page_token")).To(Equal("abc123")) - - writeJSONResponse(w, http.StatusOK, emptySPProviderListResponse()) - })) - - err := executeCommand("sp", "provider", "list", "--page-token", "abc123") - Expect(err).NotTo(HaveOccurred()) - }) - - // TC-U141: List SP providers with type filter - It("TC-U141: should pass type query parameter", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.URL.Query().Get("type")).To(Equal("compute")) - - writeJSONResponse(w, http.StatusOK, emptySPProviderListResponse()) - })) - - err := executeCommand("sp", "provider", "list", "--type", "compute") - Expect(err).NotTo(HaveOccurred()) - }) - - // TC-U144: List SP providers returns empty list - It("TC-U144: should display empty result for empty list", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - writeJSONResponse(w, http.StatusOK, emptySPProviderListResponse()) - })) - - err := executeCommand("sp", "provider", "list") - Expect(err).NotTo(HaveOccurred()) - - out := outBuf.String() - // Table output should have headers but no data rows - Expect(out).To(ContainSubstring("ID")) - Expect(out).To(ContainSubstring("NAME")) - Expect(out).To(ContainSubstring("SERVICE TYPE")) - Expect(out).NotTo(ContainSubstring("kubevirt")) - }) - - // TC-U144 (JSON variant): Empty list in JSON format - It("TC-U144: should display empty results array in JSON format", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - writeJSONResponse(w, http.StatusOK, emptySPProviderListResponse()) - })) - - err := executeCommand("--output", "json", "sp", "provider", "list") - Expect(err).NotTo(HaveOccurred()) - - var result map[string]any - Expect(json.Unmarshal(outBuf.Bytes(), &result)).To(Succeed()) - Expect(result["results"]).To(BeAssignableToTypeOf([]any{})) - Expect(result["results"]).To(BeEmpty()) - }) - }) - - Describe("get", func() { - // TC-U142: Get SP provider - It("TC-U142: should get an SP provider by ID", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal(http.MethodGet)) - Expect(r.URL.Path).To(Equal("/api/v1alpha1/providers/kubevirt-123")) - - writeJSONResponse(w, http.StatusOK, sampleSPProviderResponse()) - })) - - err := executeCommand("sp", "provider", "get", "kubevirt-123") - Expect(err).NotTo(HaveOccurred()) - - out := outBuf.String() - Expect(out).To(ContainSubstring("kubevirt-123")) - Expect(out).To(ContainSubstring("KubeVirt SP")) - Expect(out).To(ContainSubstring("compute")) - }) - - // TC-U143: Get SP provider without PROVIDER_ID fails - It("TC-U143: should return a UsageError when PROVIDER_ID is missing", func() { - err := executeCommand("sp", "provider", "get") - Expect(err).To(HaveOccurred()) - - var usageErr *commands.UsageError - Expect(errors.As(err, &usageErr)).To(BeTrue()) - }) - - // TC-U145: Get non-existent SP provider - It("TC-U145: should display error for non-existent SP provider", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", - `SP provider "nonexistent" not found.`, - "The requested SP provider does not exist.") - })) - - err := executeCommand("sp", "provider", "get", "nonexistent") - Expect(err).To(HaveOccurred()) - - var fmtErr *commands.FormattedError - Expect(errors.As(err, &fmtErr)).To(BeTrue()) - - errOut := errBuf.String() - Expect(errOut).To(ContainSubstring("NOT_FOUND")) - Expect(errOut).To(ContainSubstring("not found")) - Expect(outBuf.String()).To(BeEmpty()) - }) - - // TC-U146: SP provider table output columns - It("TC-U146: should display correct table columns", func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - writeJSONResponse(w, http.StatusOK, sampleSPProviderResponse()) - })) - - err := executeCommand("sp", "provider", "get", "kubevirt-123") - Expect(err).NotTo(HaveOccurred()) - - out := outBuf.String() - Expect(out).To(ContainSubstring("ID")) - Expect(out).To(ContainSubstring("NAME")) - Expect(out).To(ContainSubstring("SERVICE TYPE")) - Expect(out).To(ContainSubstring("HEALTH")) - Expect(out).To(ContainSubstring("CREATED")) - Expect(out).NotTo(ContainSubstring("STATUS")) - Expect(out).To(ContainSubstring("kubevirt-123")) - Expect(out).To(ContainSubstring("KubeVirt SP")) - Expect(out).To(ContainSubstring("compute")) - Expect(out).To(ContainSubstring("healthy")) - Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) - }) - }) -}) diff --git a/internal/commands/sp_resource.go b/internal/commands/sp_resource.go index 9e7bf9f..b8f4c3a 100644 --- a/internal/commands/sp_resource.go +++ b/internal/commands/sp_resource.go @@ -13,7 +13,7 @@ import ( ) var spResourceTableDef = &output.TableDef{ - Headers: []string{"ID", "PROVIDER", "STATUS", "CREATED"}, + Headers: []string{"ID", "AGENT", "STATUS", "CREATED"}, RowFunc: func(resource any) []string { m, ok := resource.(map[string]any) if !ok { @@ -21,7 +21,7 @@ var spResourceTableDef = &output.TableDef{ } return []string{ stringifyValue(m, "id"), - stringifyValue(m, "provider_name"), + stringifyValue(m, "agent_name"), stringifyValue(m, "status"), stringifyValue(m, "create_time"), } @@ -29,7 +29,7 @@ var spResourceTableDef = &output.TableDef{ } var spResourceWithDeletedTableDef = &output.TableDef{ - Headers: []string{"ID", "PROVIDER", "STATUS", "DELETION STATUS", "CREATED"}, + Headers: []string{"ID", "AGENT", "STATUS", "DELETION STATUS", "CREATED"}, RowFunc: func(resource any) []string { m, ok := resource.(map[string]any) if !ok { @@ -37,7 +37,7 @@ var spResourceWithDeletedTableDef = &output.TableDef{ } return []string{ stringifyValue(m, "id"), - stringifyValue(m, "provider_name"), + stringifyValue(m, "agent_name"), stringifyValue(m, "status"), stringifyValue(m, "deletion_status"), stringifyValue(m, "create_time"), @@ -68,9 +68,13 @@ func newSPResourceListCommand() *cobra.Command { if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { listCmd += fmt.Sprintf(" --page-size %d", pageSize) } - + if agentName, _ := cmd.Flags().GetString("agent-name"); agentName != "" { + listCmd += fmt.Sprintf(" --agent-name %s", agentName) + } showDeleted, _ := cmd.Flags().GetBool("show-deleted") - + if showDeleted { + listCmd += " --show-deleted" + } tableDef := spResourceTableDef if showDeleted { tableDef = spResourceWithDeletedTableDef @@ -89,8 +93,8 @@ func newSPResourceListCommand() *cobra.Command { if pageToken, _ := cmd.Flags().GetString("page-token"); pageToken != "" { params.PageToken = &pageToken } - if provider, _ := cmd.Flags().GetString("provider"); provider != "" { - params.Provider = &provider + if agentName, _ := cmd.Flags().GetString("agent-name"); agentName != "" { + params.AgentName = &agentName } if showDeleted { params.ShowDeleted = &showDeleted @@ -133,7 +137,7 @@ func newSPResourceListCommand() *cobra.Command { cmd.Flags().Int32("page-size", 0, "Maximum results per page") cmd.Flags().String("page-token", "", "Token for next page") - cmd.Flags().String("provider", "", "Filter by provider") + cmd.Flags().String("agent-name", "", "Filter by agent name") cmd.Flags().Bool("show-deleted", false, "Include soft-deleted resources") return cmd diff --git a/internal/commands/sp_resource_test.go b/internal/commands/sp_resource_test.go index a89e83d..baeee06 100644 --- a/internal/commands/sp_resource_test.go +++ b/internal/commands/sp_resource_test.go @@ -16,12 +16,12 @@ import ( // sampleSPResourceResponse returns a sample SP resource (service type instance) JSON response body. func sampleSPResourceResponse() map[string]any { return map[string]any{ - "id": "my-instance", - "path": "service-type-instances/my-instance", - "provider_name": "kubevirt-123", - "status": "READY", - "create_time": "2026-03-09T10:00:00Z", - "spec": map[string]any{}, + "id": "my-instance", + "path": "service-type-instances/my-instance", + "agent_name": "kubevirt-east", + "status": "READY", + "create_time": "2026-03-09T10:00:00Z", + "spec": map[string]any{}, } } @@ -30,7 +30,7 @@ func sampleDeletedSPResourceResponse() map[string]any { return map[string]any{ "id": "deleted-instance", "path": "service-type-instances/deleted-instance", - "provider_name": "kubevirt-123", + "agent_name": "kubevirt-east", "status": "DELETED", "deletion_status": "PENDING", "create_time": "2026-03-09T10:00:00Z", @@ -101,7 +101,7 @@ var _ = Describe("SP Resource Commands", func() { out := outBuf.String() Expect(out).To(ContainSubstring("my-instance")) - Expect(out).To(ContainSubstring("kubevirt-123")) + Expect(out).To(ContainSubstring("kubevirt-east")) Expect(out).To(ContainSubstring("READY")) }) @@ -129,18 +129,48 @@ var _ = Describe("SP Resource Commands", func() { Expect(err).NotTo(HaveOccurred()) }) - // TC-U123: List SP resources with provider filter - It("TC-U123: should pass provider query parameter", func() { + // TC-U123: List SP resources with agent-name filter + It("TC-U123: should pass agent_name query parameter", func() { server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.URL.Query().Get("provider")).To(Equal("kubevirt-123")) + Expect(r.URL.Query().Get("agent_name")).To(Equal("kubevirt-east")) writeJSONResponse(w, http.StatusOK, emptySPResourceListResponse()) })) - err := executeCommand("sp", "resource", "list", "--provider", "kubevirt-123") + err := executeCommand("sp", "resource", "list", "--agent-name", "kubevirt-east") Expect(err).NotTo(HaveOccurred()) }) + It("should include --agent-name in the next-page command", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("agent_name")).To(Equal("kubevirt-east")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "instances": []any{sampleSPResourceResponse()}, + "next_page_token": "page-2", + }) + })) + + err := executeCommand("sp", "resource", "list", "--agent-name", "kubevirt-east") + Expect(err).NotTo(HaveOccurred()) + Expect(outBuf.String()).To(ContainSubstring("Next page: dcm sp resource list --agent-name kubevirt-east --page-token page-2")) + }) + + It("should include --show-deleted in the next-page command", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("show_deleted")).To(Equal("true")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "instances": []any{sampleDeletedSPResourceResponse()}, + "next_page_token": "page-2", + }) + })) + + err := executeCommand("sp", "resource", "list", "--show-deleted") + Expect(err).NotTo(HaveOccurred()) + Expect(outBuf.String()).To(ContainSubstring("Next page: dcm sp resource list --show-deleted --page-token page-2")) + }) + It("should pass show_deleted query parameter", func() { server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { Expect(r.URL.Query().Get("show_deleted")).To(Equal("true")) @@ -192,7 +222,7 @@ var _ = Describe("SP Resource Commands", func() { out := outBuf.String() // Table output should have headers but no data rows Expect(out).To(ContainSubstring("ID")) - Expect(out).To(ContainSubstring("PROVIDER")) + Expect(out).To(ContainSubstring("AGENT")) Expect(out).NotTo(ContainSubstring("kubevirt")) }) @@ -227,7 +257,7 @@ var _ = Describe("SP Resource Commands", func() { out := outBuf.String() Expect(out).To(ContainSubstring("my-instance")) - Expect(out).To(ContainSubstring("kubevirt-123")) + Expect(out).To(ContainSubstring("kubevirt-east")) Expect(out).To(ContainSubstring("READY")) }) @@ -302,11 +332,11 @@ var _ = Describe("SP Resource Commands", func() { out := outBuf.String() Expect(out).To(ContainSubstring("ID")) - Expect(out).To(ContainSubstring("PROVIDER")) + Expect(out).To(ContainSubstring("AGENT")) Expect(out).To(ContainSubstring("STATUS")) Expect(out).To(ContainSubstring("CREATED")) Expect(out).To(ContainSubstring("my-instance")) - Expect(out).To(ContainSubstring("kubevirt-123")) + Expect(out).To(ContainSubstring("kubevirt-east")) Expect(out).To(ContainSubstring("READY")) Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) })