diff --git a/.ai/checkpoints/topic-9-sp-resource-commands.md b/.ai/checkpoints/topic-9-sp-resource-commands.md new file mode 100644 index 0000000..a1fe8f5 --- /dev/null +++ b/.ai/checkpoints/topic-9-sp-resource-commands.md @@ -0,0 +1,82 @@ +# Checkpoint: Topic 9 — SP Resource Commands + +- **Branch:** `topic-9-sp-resource-commands` +- **Base:** `topic-9-sp-resource-plans` (commit `62cdb9b`) +- **Date:** 2026-03-24 +- **Status:** Complete + +--- + +## Scope + +Topic 9 implements the `dcm sp resource` command group with read-only subcommands (`list` and `get`) per spec section 4.9. SP resources are service type instances managed by the Service Provider Resource Manager (SPRM). The CLI provides read-only access to these resources. All commands use the generated SP Resource Manager client. + +### Requirements Addressed + +| ID | Description | Status | +|----|-------------|--------| +| REQ-SPR-010 | `dcm sp resource list` with `--provider`, `--page-size`, `--page-token` flags | Done | +| REQ-SPR-020 | Display SP resources in configured output format | Done | +| REQ-SPR-030 | `dcm sp resource get INSTANCE_ID` | Done | +| REQ-SPR-040 | Missing `INSTANCE_ID` → usage error (exit code 2) | Done | +| REQ-SPR-050 | All commands use generated SP Resource Manager client | Done | + +### Tests Implemented (10 specs) + +| TC ID | Description | Status | +|-------|-------------|--------| +| TC-U121 | List SP resources — GET `/api/v1alpha1/service-type-instances`, displays results | Pass | +| TC-U122 | List with `--page-size 5` — passes `max_page_size=5` query parameter | Pass | +| TC-U122 | List with `--page-token abc123` — passes `page_token=abc123` query parameter | Pass | +| TC-U123 | List with `--provider kubevirt-123` — passes `provider=kubevirt-123` query parameter | Pass | +| TC-U124 | Get SP resource — GET `/api/v1alpha1/service-type-instances/my-instance` | Pass | +| TC-U125 | Get without INSTANCE_ID → UsageError (exit code 2) | Pass | +| TC-U126 | Empty list — table shows headers only; JSON shows empty `results` array | Pass | +| TC-U127 | Get non-existent SP resource — 404 RFC 7807 error formatted to stderr | Pass | +| TC-U128 | Table output columns: ID, PROVIDER, STATUS, CREATED | Pass | +| TC-U129 | SP command registers `resource` subcommand | Pass | + +--- + +## Files Created / Modified + +| File | Change | Purpose | +|------|--------|---------| +| `go.mod` / `go.sum` | Modified | Added `github.com/dcm-project/service-provider-manager` dependency | +| `internal/commands/helpers.go` | Modified | Added `newSPResourceClient` using the resource_manager client package | +| `internal/commands/sp.go` | Created | `dcm sp` parent command group | +| `internal/commands/sp_resource.go` | Created | `list` and `get` commands with generated SP Resource Manager client | +| `internal/commands/sp_resource_test.go` | Created | 10 Ginkgo test specs with httptest-based mocking | +| `internal/commands/root.go` | Modified | Registered `newSPCommand()` alongside policy, catalog, version | +| `internal/commands/root_test.go` | Modified | Added TC-U129 (sp subcommand registration), sp resource get usage error entry, updated TC-U019 to include `sp` | +| `.ai/checkpoints/topic-9-sp-resource-commands.md` | Created | This checkpoint | + +--- + +## Key Design Decisions + +1. **Generated client from service-provider-manager** — Per REQ-SPR-050, all SP resource operations use the oapi-codegen generated client from `github.com/dcm-project/service-provider-manager/pkg/client/resource_manager`. The `newSPResourceClient` function follows the same pattern as `newPolicyClient` and `newCatalogClient`, using `sprmclient.NewClient(apiBaseURL(cfg), sprmclient.WithHTTPClient(httpClient))`. + +2. **Separate API type import** — The SP resource manager has its API types in a separate package (`api/v1alpha1/resource_manager`), imported as `sprmapi` for `ListInstancesParams`. + +3. **Table columns** — ID, PROVIDER, STATUS, CREATED per spec section 4.9. Fields map to `id`, `provider_name`, `status`, `create_time` from the `ServiceTypeInstance` type. + +4. **List response uses `instances` field** — Unlike the catalog manager which uses `results`, the SP Resource Manager's `ServiceTypeInstanceList` type uses `instances` for the array and `next_page_token` for pagination. The formatter re-wraps this as `results` for consistent JSON/YAML output. + +5. **`MaxPageSize` type difference** — The SP Resource Manager uses `*int` for `MaxPageSize` (not `*int32` like the Catalog Manager), so the `--page-size` flag value is converted from `int32` to `int`. + +6. **`--provider` filter** — The `ListInstancesParams` includes a `Provider` field passed as the `provider` query parameter, matching the spec's REQ-SPR-010. + +7. **Same patterns as previous command groups** — List and get follow the identical patterns established in Topics 4–7: generated client usage, `handleErrorResponse` for errors, `newFormatter` for output, `connectionError` for connection failures. + +--- + +## What's Next + +All topics (1–9) are now complete. The CLI supports: +- Policy CRUD operations (Topic 4) +- Catalog service-type read operations (Topic 5) +- Catalog item operations (Topic 6) +- Catalog instance operations (Topic 7) +- Version display (Topic 8) +- SP resource read operations (Topic 9) diff --git a/go.mod b/go.mod index bada9cc..b5b9c33 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/dcm-project/catalog-manager v0.0.0-20260313160905-1ff110850088 github.com/dcm-project/policy-manager v0.0.0-20260310132113-15bd45617e87 + github.com/dcm-project/service-provider-manager v0.0.0-20260324094657-8aad860d86d2 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/spf13/cobra v1.10.2 @@ -16,10 +17,10 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/getkin/kin-openapi v0.134.0 // indirect github.com/go-logr/logr v1.4.3 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -27,11 +28,11 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/runtime v1.2.0 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/oapi-codegen/runtime v1.3.0 // indirect + github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect + github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -40,12 +41,12 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 416f659..29261f4 100644 --- a/go.sum +++ b/go.sum @@ -12,12 +12,14 @@ github.com/dcm-project/catalog-manager v0.0.0-20260313160905-1ff110850088 h1:Vdz github.com/dcm-project/catalog-manager v0.0.0-20260313160905-1ff110850088/go.mod h1:NJNzrVwaR0c5YmNLgDZ9ve0ueWqs/GAsLOXeQriIR7g= github.com/dcm-project/policy-manager v0.0.0-20260310132113-15bd45617e87 h1:IgIFK8eWeNHLloVuwbGZLzun8LHA6d5nqVrct7nB+S8= github.com/dcm-project/policy-manager v0.0.0-20260310132113-15bd45617e87/go.mod h1:a9eT8Ws0Gy/6FJGp+dWmrB4s/hyfVE0PQPats/aQW0E= +github.com/dcm-project/service-provider-manager v0.0.0-20260324094657-8aad860d86d2 h1:A0AJ0Yog5w34xA2JSjeEKkehQn7kqObala8IVX6OjkM= +github.com/dcm-project/service-provider-manager v0.0.0-20260324094657-8aad860d86d2/go.mod h1:c2guzDY66gCS9WeqyjsKYLsS7yr17wOvwDku6m8RfxU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= +github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -26,10 +28,12 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01 github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= @@ -51,24 +55,27 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= -github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA= +github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= @@ -114,8 +121,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= -github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= @@ -124,10 +131,10 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index a111950..68ec5ae 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" + sprmclient "github.com/dcm-project/service-provider-manager/pkg/client/resource_manager" "github.com/dcm-project/cli/internal/config" "github.com/dcm-project/cli/internal/output" @@ -21,6 +22,14 @@ import ( "go.yaml.in/yaml/v3" ) +func newSPResourceClient(cfg *config.Config) (*sprmclient.Client, error) { + httpClient, err := buildHTTPClient(cfg) + if err != nil { + return nil, err + } + return sprmclient.NewClient(apiBaseURL(cfg), sprmclient.WithHTTPClient(httpClient)) +} + func newCatalogClient(cfg *config.Config) (*catalogclient.Client, error) { httpClient, err := buildHTTPClient(cfg) if err != nil { diff --git a/internal/commands/root.go b/internal/commands/root.go index 12f15a1..bfaf11d 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -55,6 +55,7 @@ func NewRootCommand() *cobra.Command { // Register subcommand groups cmd.AddCommand(newPolicyCommand()) cmd.AddCommand(newCatalogCommand()) + cmd.AddCommand(newSPCommand()) cmd.AddCommand(newVersionCommand()) return cmd diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go index 964fdf2..fea6dd6 100644 --- a/internal/commands/root_test.go +++ b/internal/commands/root_test.go @@ -14,7 +14,7 @@ import ( var _ = Describe("Root Command", func() { // TC-U019: Root command registers all subcommands Describe("TC-U019: Subcommand registration", func() { - It("should list policy, catalog, and version subcommands in help output", func() { + It("should list policy, catalog, sp, and version subcommands in help output", func() { cmd := commands.NewRootCommand() out := new(bytes.Buffer) cmd.SetOut(out) @@ -27,6 +27,7 @@ var _ = Describe("Root Command", func() { helpOutput := out.String() Expect(helpOutput).To(ContainSubstring("policy")) Expect(helpOutput).To(ContainSubstring("catalog")) + Expect(helpOutput).To(ContainSubstring("sp")) Expect(helpOutput).To(ContainSubstring("version")) }) }) @@ -50,6 +51,23 @@ 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() { + cmd := commands.NewRootCommand() + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(new(bytes.Buffer)) + cmd.SetArgs([]string{"sp", "--help"}) + + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + helpOutput := out.String() + Expect(helpOutput).To(ContainSubstring("resource")) + }) + }) + // TC-U021: Global flags are registered Describe("TC-U021: Global flags", func() { It("should list all global flags in help output", func() { @@ -148,6 +166,7 @@ var _ = Describe("Root Command", func() { Entry("catalog item delete without ID", []string{"catalog", "item", "delete"}), 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"}), ) }) }) diff --git a/internal/commands/sp.go b/internal/commands/sp.go new file mode 100644 index 0000000..0b46e70 --- /dev/null +++ b/internal/commands/sp.go @@ -0,0 +1,16 @@ +package commands + +import ( + "github.com/spf13/cobra" +) + +func newSPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "sp", + Short: "Manage service provider resources", + } + + cmd.AddCommand(newSPResourceCommand()) + + return cmd +} diff --git a/internal/commands/sp_resource.go b/internal/commands/sp_resource.go new file mode 100644 index 0000000..ed2fd44 --- /dev/null +++ b/internal/commands/sp_resource.go @@ -0,0 +1,152 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + + sprmapi "github.com/dcm-project/service-provider-manager/api/v1alpha1/resource_manager" + + "github.com/dcm-project/cli/internal/config" + "github.com/dcm-project/cli/internal/output" + "github.com/spf13/cobra" +) + +var spResourceTableDef = &output.TableDef{ + Headers: []string{"ID", "PROVIDER", "STATUS", "CREATED"}, + RowFunc: func(resource any) []string { + m, ok := resource.(map[string]any) + if !ok { + return []string{"", "", "", ""} + } + return []string{ + stringifyValue(m, "id"), + stringifyValue(m, "provider_name"), + stringifyValue(m, "status"), + stringifyValue(m, "create_time"), + } + }, +} + +func newSPResourceCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "resource", + Short: "Manage SP resources", + } + + cmd.AddCommand(newSPResourceListCommand()) + cmd.AddCommand(newSPResourceGetCommand()) + + return cmd +} + +func newSPResourceListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List SP resources", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := config.FromCommand(cmd) + + listCmd := "sp resource list" + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + listCmd += fmt.Sprintf(" --page-size %d", pageSize) + } + + formatter, err := newFormatter(cmd, spResourceTableDef, listCmd) + if err != nil { + return err + } + + params := &sprmapi.ListInstancesParams{} + 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 provider, _ := cmd.Flags().GetString("provider"); provider != "" { + params.Provider = &provider + } + + client, err := newSPResourceClient(cfg) + if err != nil { + return fmt.Errorf("creating SP resource client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.ListInstances(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 { + Instances []map[string]any `json:"instances"` + 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.Instances)) + for i, r := range listResp.Instances { + 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("provider", "", "Filter by provider") + + return cmd +} + +func newSPResourceGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get INSTANCE_ID", + Short: "Get an SP resource by ID", + Args: ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, spResourceTableDef, "sp resource get") + if err != nil { + return err + } + + client, err := newSPResourceClient(cfg) + if err != nil { + return fmt.Errorf("creating SP resource client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.GetInstance(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_resource_test.go b/internal/commands/sp_resource_test.go new file mode 100644 index 0000000..5488880 --- /dev/null +++ b/internal/commands/sp_resource_test.go @@ -0,0 +1,231 @@ +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" +) + +// 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{}, + } +} + +// emptySPResourceListResponse returns a standard empty SP resource list response body. +func emptySPResourceListResponse() map[string]any { + return map[string]any{ + "instances": []any{}, + "next_page_token": "", + } +} + +var _ = Describe("SP Resource 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-U121: List SP resources + It("TC-U121: should list SP resources", 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/service-type-instances")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "instances": []any{sampleSPResourceResponse()}, + "next_page_token": "", + }) + })) + + err := executeCommand("sp", "resource", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("my-instance")) + Expect(out).To(ContainSubstring("kubevirt-123")) + Expect(out).To(ContainSubstring("READY")) + }) + + // TC-U122: List SP resources with pagination + It("TC-U122: 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, emptySPResourceListResponse()) + })) + + err := executeCommand("sp", "resource", "list", "--page-size", "5") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U122 (page-token variant): List SP resources with page token + It("TC-U122: 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, emptySPResourceListResponse()) + })) + + err := executeCommand("sp", "resource", "list", "--page-token", "abc123") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U123: List SP resources with provider filter + It("TC-U123: should pass provider 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")) + + writeJSONResponse(w, http.StatusOK, emptySPResourceListResponse()) + })) + + err := executeCommand("sp", "resource", "list", "--provider", "kubevirt-123") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U126: List SP resources returns empty list + It("TC-U126: should display empty result for empty list", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptySPResourceListResponse()) + })) + + err := executeCommand("sp", "resource", "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("PROVIDER")) + Expect(out).NotTo(ContainSubstring("kubevirt")) + }) + + // TC-U126 (JSON variant): Empty list in JSON format + It("TC-U126: should display empty results array in JSON format", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptySPResourceListResponse()) + })) + + err := executeCommand("--output", "json", "sp", "resource", "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-U124: Get SP resource + It("TC-U124: should get an SP resource 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/service-type-instances/my-instance")) + + writeJSONResponse(w, http.StatusOK, sampleSPResourceResponse()) + })) + + err := executeCommand("sp", "resource", "get", "my-instance") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("my-instance")) + Expect(out).To(ContainSubstring("kubevirt-123")) + Expect(out).To(ContainSubstring("READY")) + }) + + // TC-U125: Get SP resource without INSTANCE_ID fails + It("TC-U125: should return a UsageError when INSTANCE_ID is missing", func() { + err := executeCommand("sp", "resource", "get") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U127: Get non-existent SP resource + It("TC-U127: should display error for non-existent SP resource", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", + `SP resource "nonexistent" not found.`, + "The requested SP resource does not exist.") + })) + + err := executeCommand("sp", "resource", "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-U128: SP resource table output columns + It("TC-U128: should display correct table columns", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, sampleSPResourceResponse()) + })) + + err := executeCommand("sp", "resource", "get", "my-instance") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("ID")) + Expect(out).To(ContainSubstring("PROVIDER")) + 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("READY")) + Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) + }) + }) +})