diff --git a/.ai/checkpoints/topic-11-sp-provider-commands.md b/.ai/checkpoints/topic-11-sp-provider-commands.md new file mode 100644 index 0000000..6684282 --- /dev/null +++ b/.ai/checkpoints/topic-11-sp-provider-commands.md @@ -0,0 +1,66 @@ +# Checkpoint: Topic 11 — SP Provider Commands + +- **Branch:** `topic-11-sp-provider-plans` +- **Base:** `d076a8a` (Add SP provider commands to spec and test plan) +- **Date:** 2026-03-27 +- **Status:** Complete + +--- + +## Scope + +Topic 11 implements the `dcm sp provider` command group with read-only subcommands (`list` and `get`) per spec section 4.11. 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 (`service-provider-manager/pkg/client`). + +### Requirements Addressed + +| ID | Description | Status | +|----|-------------|--------| +| REQ-SPP-010 | `dcm sp provider list` with `--type`, `--page-size`, `--page-token` flags | Done | +| REQ-SPP-020 | Display SP providers in configured output format | Done | +| REQ-SPP-030 | `dcm sp provider get PROVIDER_ID` | Done | +| REQ-SPP-040 | Missing `PROVIDER_ID` → usage error (exit code 2) | Done | +| REQ-SPP-050 | All commands use generated SP Manager client | Done | + +### Tests Implemented (12 specs) + +| TC ID | Description | Status | +|-------|-------------|--------| +| TC-U139 | List SP providers — GET `/api/v1alpha1/providers`, displays results | Pass | +| TC-U140 | List with `--page-size 5` — passes `max_page_size=5` query parameter | Pass | +| TC-U140 | List with `--page-token abc123` — passes `page_token=abc123` query parameter | Pass | +| TC-U141 | List with `--type compute` — passes `type=compute` query parameter | Pass | +| TC-U142 | Get SP provider — GET `/api/v1alpha1/providers/kubevirt-123` | Pass | +| TC-U143 | Get without PROVIDER_ID → UsageError (exit code 2) | Pass | +| TC-U144 | Empty list — table shows headers only; JSON shows empty `results` array | Pass | +| TC-U145 | Get non-existent SP provider — 404 RFC 7807 error formatted to stderr | Pass | +| TC-U146 | Table output columns: ID, NAME, SERVICE TYPE, STATUS, HEALTH, CREATED | Pass | +| TC-U147 | SP command registers `provider` subcommand alongside `resource` | Pass | + +--- + +## Files Created / Modified + +| File | Change | Purpose | +|------|--------|---------| +| `internal/commands/sp_provider.go` | Created | `list` and `get` commands with generated SP Manager client | +| `internal/commands/sp_provider_test.go` | Created | 10 Ginkgo test specs with httptest-based mocking | +| `internal/commands/helpers.go` | Modified | Added `newSPProviderClient` using the top-level SP Manager client package | +| `internal/commands/sp.go` | Modified | Registered `newSPProviderCommand()` alongside `newSPResourceCommand()` | +| `internal/commands/root_test.go` | Modified | Updated TC-U129 to check for `provider` subcommand, added `sp provider get` usage error entry | +| `.ai/checkpoints/topic-11-sp-provider-commands.md` | Created | This checkpoint | + +--- + +## Key Design Decisions + +1. **Top-level SP Manager client** — Per REQ-SPP-050, all SP provider operations use the oapi-codegen generated client from `github.com/dcm-project/service-provider-manager/pkg/client` (not the `resource_manager` sub-package). The `newSPProviderClient` function follows the same pattern as `newSPResourceClient`. + +2. **Separate API type import** — The SP Manager has its API types in `api/v1alpha1`, imported as `spmapi` for `ListProvidersParams`. + +3. **Table columns** — ID, NAME, SERVICE TYPE, STATUS, HEALTH, CREATED per spec section 4.11. Fields map to `id`, `name`, `service_type`, `status`, `health_status`, `create_time` from the `Provider` type. + +4. **List response uses `providers` field** — The SP Manager's `ProviderList` type uses `providers` for the array and `next_page_token` for pagination. + +5. **`--type` filter** — The `ListProvidersParams` includes a `Type` field passed as the `type` query parameter, matching REQ-SPP-010. + +6. **Same patterns as SP resource commands** — List and get follow the identical patterns established in Topic 9, reusing `handleErrorResponse`, `newFormatter`, `connectionError`, and `requestContext`. diff --git a/.ai/specs/dcm-cli.spec.md b/.ai/specs/dcm-cli.spec.md index 3408982..b16671f 100644 --- a/.ai/specs/dcm-cli.spec.md +++ b/.ai/specs/dcm-cli.spec.md @@ -17,6 +17,7 @@ dependencies. - Catalog item operations (create, list, get, delete) - Catalog item instance operations (create, list, get, delete) - SP resource read operations (list, get) via Service Provider Resource Manager +- SP provider read operations (list, get) via Service Provider Manager - Version display - Output formatting (table, JSON, YAML) - Configuration via file, environment variables, and flags @@ -78,6 +79,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 │ │ └── completion.go ← Shell completion command │ └── version/ ← Build-time version info ├── test/e2e/ ← E2E tests (build tag: e2e) @@ -103,6 +105,7 @@ dcm-cli/ | 8 | Version Command | VER | 1 | | 9 | SP Resource Commands | SPR | 1, 2, 3 | | 10 | Shell Completion Command | CMP | 1 | +| 11 | SP Provider Commands | SPP | 1, 2, 3 | ``` Topic 1: CLI Framework (independent) @@ -114,6 +117,7 @@ Topic 3: Output Formatting (independent) +---------+---------+---> Topic 6: Catalog Item Commands (depends on 1, 2, 3) +---------+---------+---> Topic 7: Catalog Instance Cmds (depends on 1, 2, 3) +---------+---------+---> Topic 9: SP Resource Commands (depends on 1, 2, 3) + +---------+---------+---> Topic 11: SP Provider Commands (depends on 1, 2, 3) | +-----------------------> Topic 8: Version Command (depends on 1) +-----------------------> Topic 10: Shell Completion Cmd (depends on 1) @@ -166,7 +170,7 @@ 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** `dcm sp --help` MUST list `resource` and `provider` ##### AC-CLI-040: Exit code on success @@ -1147,6 +1151,104 @@ Depends on Topic 1 (CLI Framework). --- +### 4.11 SP Provider 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 (`service-provider-manager/pkg/client`). + +Out of scope: SP provider create/update/delete (managed via other flows), +SP provider health check. + +#### 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 (`service-provider-manager/pkg/client`) | MUST | | + +#### Table Output Columns + +``` +ID NAME SERVICE TYPE STATUS HEALTH CREATED +kubevirt-123 KubeVirt SP compute registered healthy 2026-03-09T10:00:00Z +``` + +#### Acceptance Criteria + +##### AC-SPP-010: List SP providers + +- **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 + +##### AC-SPP-020: List SP providers with pagination + +- **Validates:** REQ-SPP-010 +- **Given** SP providers exist in the system +- **When** `dcm sp provider 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 + +- **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 + +##### AC-SPP-040: Get SP provider + +- **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 + +##### AC-SPP-050: Get without PROVIDER_ID + +- **Validates:** REQ-SPP-040 +- **Given** no positional argument is provided +- **When** `dcm sp provider 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 +Formatting). + +--- + ## 5. Cross-Cutting Concerns ### 5.1 Error Handling @@ -1250,7 +1352,8 @@ Depends on Topic 1 (CLI Framework). |----|-------------|----------|-------| | REQ-XC-CLI-010 | The CLI MUST use the generated Policy Manager client (`github.com/dcm-project/policy-manager/pkg/client`) for all policy operations | MUST | | | REQ-XC-CLI-020 | The CLI MUST use the generated Catalog Manager client (`github.com/dcm-project/catalog-manager/pkg/client`) for all catalog operations | MUST | | -| REQ-XC-CLI-025 | The CLI MUST use the generated SP Resource Manager client (`github.com/dcm-project/service-provider-manager/pkg/client`) for all SP resource operations | MUST | | +| REQ-XC-CLI-025 | The CLI MUST use the generated SP Resource Manager client (`github.com/dcm-project/service-provider-manager/pkg/client/resource_manager`) for all SP resource operations | MUST | | +| REQ-XC-CLI-026 | The CLI MUST use the generated SP Manager client (`github.com/dcm-project/service-provider-manager/pkg/client`) for all SP provider operations | MUST | | | REQ-XC-CLI-030 | All clients MUST be instantiated with the API Gateway 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 API Gateway URL uses `https://` | MUST | | @@ -1265,6 +1368,7 @@ Depends on Topic 1 (CLI Framework). - **Then** the Policy Manager client MUST be created with `http://localhost:9080/api/v1alpha1` - **And** the Catalog Manager client MUST be created with `http://localhost:9080/api/v1alpha1` - **And** the SP Resource Manager client MUST be created with `http://localhost:9080/api/v1alpha1` +- **And** the SP Manager client MUST be created with `http://localhost:9080/api/v1alpha1` ##### AC-XC-CLI-020: Request timeout @@ -1405,7 +1509,7 @@ and `catalog-manager/pkg/client` instead of hand-writing HTTP client code. boilerplate, and evolve with the OpenAPI specs. The CLI is a thin wrapper around these clients. -**Related requirements:** REQ-XC-CLI-010, REQ-XC-CLI-020, REQ-XC-CLI-025 +**Related requirements:** REQ-XC-CLI-010, REQ-XC-CLI-020, REQ-XC-CLI-025, REQ-XC-CLI-026 ### DD-020: Cobra + Viper for CLI framework @@ -1522,8 +1626,9 @@ REQ-OUT-090, REQ-OUT-110, REQ-OUT-120 | REQ-VER-NNN | 4.8: Version Command | 3 | | REQ-SPR-NNN | 4.9: SP Resource Commands | 5 | | REQ-CMP-NNN | 4.10: Shell Completion Command | 6 | +| REQ-SPP-NNN | 4.11: SP Provider Commands | 5 | | REQ-XC-ERR-NNN | 5.1: Error Handling | 7 | | REQ-XC-INP-NNN | 5.2: Input File Parsing | 3 | -| REQ-XC-CLI-NNN | 5.3: Generated Client Usage | 5 | +| REQ-XC-CLI-NNN | 5.3: Generated Client Usage | 6 | | REQ-XC-PAG-NNN | 5.4: Pagination | 3 | -| **Total** | | **100** | +| **Total** | | **106** | diff --git a/.ai/test-plans/dcm-cli-unit.test-plan.md b/.ai/test-plans/dcm-cli-unit.test-plan.md index 2a628e5..bd13a3a 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-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-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 - **Framework:** Ginkgo v2 + Gomega - **Created:** 2026-03-09 @@ -280,7 +280,7 @@ test classes. Instead: - **Type:** Unit - **Given:** The root command is created - **When:** `dcm sp --help` is executed -- **Then:** Subcommand `resource` is listed +- **Then:** Subcommands `resource` and `provider` are listed ### TC-U021: Global flags are registered @@ -1024,6 +1024,97 @@ test classes. Instead: --- +## 10a · SP Provider 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. + +### TC-U139: List SP providers + +- **Requirement:** REQ-SPP-010, REQ-SPP-020 +- **Acceptance Criteria:** AC-SPP-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 + +### TC-U140: List SP providers with pagination + +- **Requirement:** REQ-SPP-010 +- **Acceptance Criteria:** AC-SPP-020 +- **Type:** Unit +- **Transitively covers:** TC-U069 (pagination flags present) +- **Given:** A mock server +- **When:** `dcm sp provider 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 + +- **Requirement:** REQ-SPP-010 +- **Acceptance Criteria:** AC-SPP-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 + +### TC-U142: Get SP provider + +- **Requirement:** REQ-SPP-030 +- **Acceptance Criteria:** AC-SPP-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 + +### TC-U143: Get SP provider without PROVIDER_ID fails + +- **Requirement:** REQ-SPP-040 +- **Acceptance Criteria:** AC-SPP-050 +- **Type:** Unit +- **Given:** No positional argument is provided +- **When:** `dcm sp provider get` is executed +- **Then:** The CLI exits with code 2 and displays a usage error + +### TC-U144: List SP providers returns empty list + +- **Requirement:** REQ-SPP-010, REQ-SPP-020 +- **Acceptance Criteria:** AC-SPP-060 +- **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 +- **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 + +- **Requirement:** REQ-SPP-030, REQ-XC-ERR-010 +- **Acceptance Criteria:** AC-SPP-070, 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 +- **Then:** The CLI displays the error in the configured output format AND exits with code 1 + +### TC-U146: SP provider 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, STATUS, 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` + +--- + ## 11 · TLS Configuration > **Suggested Ginkgo structure:** `Describe("TLS Configuration")` with `Context` @@ -1329,6 +1420,26 @@ 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 + +- **Requirement:** REQ-XC-CLI-026, REQ-XC-CLI-030 +- **Acceptance Criteria:** AC-XC-CLI-010 +- **Type:** Unit +- **Given:** The API Gateway URL is `http://localhost:9080` +- **When:** The SP Manager client is created +- **Then:** The client base URL is `http://localhost:9080/api/v1alpha1` +- **Referenced by:** TC-U139 (list SP providers verifies request goes to correct URL path) + +#### TC-U149: SP Manager generated client used for SP provider operations + +- **Requirement:** REQ-XC-CLI-026, REQ-SPP-050 +- **Acceptance Criteria:** AC-SPP-080 +- **Type:** Unit (structural) +- **Given:** Any SP provider 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) +- **Referenced by:** TC-U139 (list), TC-U142 (get) + #### TC-U068: Request timeout applied to HTTP requests - **Requirement:** REQ-XC-CLI-040 @@ -1346,10 +1457,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`) +- **Given:** Any list command (`policy list`, `catalog service-type list`, `catalog item list`, `catalog instance list`, `sp resource list`, `sp provider 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) +- **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) #### TC-U070: Pagination parameters passed as query parameters @@ -1417,7 +1528,7 @@ dedicated test class or `Describe` block. | REQ-OUT-020 | TC-U009 (table is default) | Covered | | REQ-OUT-030 | TC-U017 | Covered | | REQ-OUT-040 | TC-U009, TC-U010, TC-U018 | Covered | -| REQ-OUT-050 | TC-U009, TC-U010, TC-U041, TC-U057, TC-U079, TC-U128 | Covered | +| REQ-OUT-050 | TC-U009, TC-U010, TC-U041, TC-U057, TC-U079, TC-U128, TC-U146 | Covered | | REQ-OUT-060 | TC-U011 | Covered | | REQ-OUT-070 | TC-U012 | Covered | | REQ-OUT-080 | TC-U014, TC-U015 | Covered | @@ -1470,6 +1581,11 @@ dedicated test class or `Describe` block. | REQ-SPR-030 | TC-U124 | Covered | | REQ-SPR-040 | TC-U125 | Covered | | REQ-SPR-050 | TC-U131 (via TC-U121, TC-U124) | 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-VER-010 | TC-U024 | Covered | | REQ-VER-020 | TC-U024 | Covered | | REQ-VER-030 | TC-U025 | Covered | @@ -1492,10 +1608,11 @@ dedicated test class or `Describe` block. | REQ-XC-CLI-010 | TC-U064 (via TC-U026), TC-U066 (via TC-U026/U030/U034/U036/U039) | Covered | | REQ-XC-CLI-020 | TC-U065 (via TC-U042), TC-U067 (via TC-U042/U044/U046/U058) | Covered | | REQ-XC-CLI-025 | TC-U130 (via TC-U121), TC-U131 (via TC-U121/U124) | Covered | -| REQ-XC-CLI-030 | TC-U064 (via TC-U026), TC-U065 (via TC-U042), TC-U130 (via TC-U121) | Covered | +| REQ-XC-CLI-026 | TC-U148 (via TC-U139), TC-U149 (via TC-U139/U142) | Covered | +| REQ-XC-CLI-030 | TC-U064 (via TC-U026), TC-U065 (via TC-U042), TC-U130 (via TC-U121), TC-U148 (via TC-U139) | Covered | | REQ-XC-CLI-040 | TC-U068 (via TC-U084) | Covered | | REQ-XC-CLI-050 | TC-U088, TC-U090, TC-U091 | Covered | -| REQ-XC-PAG-010 | TC-U069 (via TC-U033, TC-U043, TC-U074, TC-U122) | Covered | +| REQ-XC-PAG-010 | TC-U069 (via TC-U033, TC-U043, TC-U074, TC-U122, TC-U140) | Covered | | REQ-XC-PAG-020 | TC-U070 (via TC-U033) | Covered | | REQ-XC-PAG-030 | TC-U071 (via TC-U013, TC-U014, TC-U015) | Covered | | REQ-XC-TLS-010 | TC-U088 | Covered | @@ -1507,7 +1624,7 @@ dedicated test class or `Describe` block. | REQ-XC-TLS-070 | TC-U095, TC-U096 | Covered | | REQ-XC-TLS-080 | TC-U090, TC-U097 | Covered | -**Total:** 102 test case IDs — 78 in behavioural test classes, 24 in the utility +**Total:** 113 test case IDs — 87 in behavioural test classes, 26 in the utility index (tested transitively through higher-level behavioural tests). --- diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 68ec5ae..f76def3 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -14,6 +14,7 @@ import ( "time" catalogclient "github.com/dcm-project/catalog-manager/pkg/client" + spmclient "github.com/dcm-project/service-provider-manager/pkg/client" sprmclient "github.com/dcm-project/service-provider-manager/pkg/client/resource_manager" "github.com/dcm-project/cli/internal/config" @@ -22,6 +23,14 @@ import ( "go.yaml.in/yaml/v3" ) +func newSPProviderClient(cfg *config.Config) (*spmclient.Client, error) { + httpClient, err := buildHTTPClient(cfg) + if err != nil { + return nil, err + } + return spmclient.NewClient(apiBaseURL(cfg), spmclient.WithHTTPClient(httpClient)) +} + func newSPResourceClient(cfg *config.Config) (*sprmclient.Client, error) { httpClient, err := buildHTTPClient(cfg) if err != nil { diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go index c123805..045510e 100644 --- a/internal/commands/root_test.go +++ b/internal/commands/root_test.go @@ -54,7 +54,7 @@ var _ = Describe("Root Command", func() { // TC-U129: SP command registers subcommand groups Describe("TC-U129: SP subcommand registration", func() { - It("should list resource subcommand in sp help", func() { + It("should list resource and provider subcommands in sp help", func() { cmd := commands.NewRootCommand() out := new(bytes.Buffer) cmd.SetOut(out) @@ -66,6 +66,7 @@ var _ = Describe("Root Command", func() { helpOutput := out.String() Expect(helpOutput).To(ContainSubstring("resource")) + Expect(helpOutput).To(ContainSubstring("provider")) }) }) @@ -168,6 +169,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"}), ) }) }) diff --git a/internal/commands/sp.go b/internal/commands/sp.go index 0b46e70..c350e32 100644 --- a/internal/commands/sp.go +++ b/internal/commands/sp.go @@ -11,6 +11,7 @@ func newSPCommand() *cobra.Command { } cmd.AddCommand(newSPResourceCommand()) + cmd.AddCommand(newSPProviderCommand()) return cmd } diff --git a/internal/commands/sp_provider.go b/internal/commands/sp_provider.go new file mode 100644 index 0000000..c49fd6c --- /dev/null +++ b/internal/commands/sp_provider.go @@ -0,0 +1,154 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + + spmapi "github.com/dcm-project/service-provider-manager/api/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", "STATUS", "HEALTH", "CREATED"}, + RowFunc: func(resource any) []string { + m, ok := resource.(map[string]any) + if !ok { + return []string{"", "", "", "", "", ""} + } + return []string{ + stringifyValue(m, "id"), + stringifyValue(m, "name"), + stringifyValue(m, "service_type"), + stringifyValue(m, "status"), + stringifyValue(m, "health_status"), + stringifyValue(m, "create_time"), + } + }, +} + +func newSPProviderCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "provider", + Short: "Manage SP providers", + } + + cmd.AddCommand(newSPProviderListCommand()) + cmd.AddCommand(newSPProviderGetCommand()) + + return cmd +} + +func newSPProviderListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List SP providers", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := config.FromCommand(cmd) + + listCmd := "sp provider list" + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + listCmd += fmt.Sprintf(" --page-size %d", pageSize) + } + + formatter, err := newFormatter(cmd, spProviderTableDef, listCmd) + if err != nil { + return err + } + + params := &spmapi.ListProvidersParams{} + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + maxPageSize := int(pageSize) + params.MaxPageSize = &maxPageSize + } + if pageToken, _ := cmd.Flags().GetString("page-token"); pageToken != "" { + params.PageToken = &pageToken + } + if providerType, _ := cmd.Flags().GetString("type"); providerType != "" { + params.Type = &providerType + } + + client, err := newSPProviderClient(cfg) + if err != nil { + return fmt.Errorf("creating SP provider client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.ListProviders(ctx, params) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var listResp struct { + Providers []map[string]any `json:"providers"` + 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[i] = r + } + + return formatter.FormatList(resources, listResp.NextPageToken) + }, + } + + 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") + + return cmd +} + +func newSPProviderGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get PROVIDER_ID", + Short: "Get an SP provider 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") + if err != nil { + return err + } + + client, err := newSPProviderClient(cfg) + if err != nil { + return fmt.Errorf("creating SP provider client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.GetProvider(ctx, args[0]) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + return formatter.FormatOne(result) + }, + } +} diff --git a/internal/commands/sp_provider_test.go b/internal/commands/sp_provider_test.go new file mode 100644 index 0000000..a73c005 --- /dev/null +++ b/internal/commands/sp_provider_test.go @@ -0,0 +1,239 @@ +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", + "status": "registered", + "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, "--api-gateway-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")) + Expect(out).To(ContainSubstring("registered")) + }) + + // 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")) + Expect(out).To(ContainSubstring("registered")) + }) + + // 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("STATUS")) + Expect(out).To(ContainSubstring("HEALTH")) + Expect(out).To(ContainSubstring("CREATED")) + Expect(out).To(ContainSubstring("kubevirt-123")) + Expect(out).To(ContainSubstring("KubeVirt SP")) + Expect(out).To(ContainSubstring("compute")) + Expect(out).To(ContainSubstring("registered")) + Expect(out).To(ContainSubstring("healthy")) + Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) + }) + }) +})