diff --git a/README.md b/README.md index 265fd9d..edf2af0 100644 --- a/README.md +++ b/README.md @@ -426,35 +426,40 @@ Catalog item file format (multi-resource schema): ```yaml api_version: v1alpha1 -display_name: "Small VM" +display_name: "App with Database" spec: resources: - - name: main - service_type: vm + - name: app + service_type: container fields: - - path: metadata + - path: image.reference + display_name: "Container Image" editable: true - - path: vcpu.count - display_name: "CPU Count" + - path: resources.cpu.min + display_name: "CPU Min" editable: true - default: 2 + default: "1" validation_schema: - type: integer - minimum: 1 - maximum: 4 - - path: memory.size - display_name: "Memory (GB)" + type: string + pattern: '^[1-9][0-9]*m?$' + - name: db + service_type: database + requires_resources: + - app + fields: + - path: engine + editable: false + default: "postgres" + - path: version editable: false - default: "2GB" + default: "16" ``` -Each catalog item defines one or more named resources under `spec.resources`. Each resource specifies its `service_type` and the `fields` available for customization. - Example output (table): ``` -ID UID DISPLAY NAME CREATED -my-catalog-item b2c3d4e5-f6a7-8901-bcde-f12345678901 Small Container 2026-03-09T10:00:00Z +UID DISPLAY NAME CREATED +b2c3d4e5-f6a7-8901-bcde-f12345678901 App with Database 2026-03-09T10:00:00Z ``` #### `dcm catalog item list` @@ -520,19 +525,14 @@ display_name: "My Dev VM" spec: catalog_item_id: small-vm user_values: - - resource: main - path: metadata - value: - name: "small-vm" - labels: - env: "dev" - - resource: main - path: vcpu.count - value: 1 + - resource: app + path: image.reference + value: "nginx:latest" + - resource: app + path: resources.cpu.min + value: "2" ``` -Each `user_values` entry includes a `resource` field that identifies which named resource (from the catalog item's `spec.resources`) the value applies to. - Example output (table): ``` @@ -1033,7 +1033,7 @@ dcm policy list dcm catalog instance create --from-file instance.yaml │ ├─▶ Read and parse instance.yaml - │ Contains: catalog_item_id, user_values + │ Contains: catalog_item_id, user_values (each with resource, path, value) ├─▶ POST /api/v1alpha1/catalog-item-instances ├─▶ Display created instance └─▶ Exit 0 diff --git a/go.mod b/go.mod index 3bd1d84..722de93 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-20260814135831-7c173134e932 + github.com/dcm-project/control-plane v0.0.0-20260817201929-c04802d05ecc github.com/onsi/ginkgo/v2 v2.29.0 github.com/onsi/gomega v1.41.0 github.com/spf13/cobra v1.10.2 @@ -48,11 +48,11 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index f31720e..e3abe33 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-20260814135831-7c173134e932 h1:dLCvaQn2vZ/0+ByjhGcYoxLzCimFyNNm0kNMgBt/hQg= -github.com/dcm-project/control-plane v0.0.0-20260814135831-7c173134e932/go.mod h1:oLQKa1vW81Dmpit5iGjU0EQaRARadjrJ/BmCadwqir4= +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/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= @@ -144,20 +144,20 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/commands/catalog_instance_test.go b/internal/commands/catalog_instance_test.go index b0059cc..bf1fbc1 100644 --- a/internal/commands/catalog_instance_test.go +++ b/internal/commands/catalog_instance_test.go @@ -27,6 +27,32 @@ func sampleInstanceResponse() map[string]any { } } +// sampleTwoResourceInstanceResponse returns an instance with user_values for two resources. +func sampleTwoResourceInstanceResponse() map[string]any { + return map[string]any{ + "path": "catalog-item-instances/my-instance", + "uid": "c3d4e5f6-a7b8-9012-cdef-123456789012", + "display_name": "My App Instance", + "create_time": "2026-03-09T10:00:00Z", + "resource_id": "res-abc123", + "spec": map[string]any{ + "catalog_item_id": "my-catalog-item", + "user_values": []any{ + map[string]any{ + "resource": "app", + "path": "image.reference", + "value": "nginx:latest", + }, + map[string]any{ + "resource": "db", + "path": "engine", + "value": "postgres", + }, + }, + }, + } +} + // emptyInstanceListResponse returns a standard empty instance list response body. func emptyInstanceListResponse() map[string]any { return map[string]any{ @@ -82,11 +108,22 @@ var _ = Describe("Catalog Instance Commands", func() { var body map[string]any Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) Expect(body["display_name"]).To(Equal("My App Instance")) + spec, ok := body["spec"].(map[string]any) + Expect(ok).To(BeTrue(), "request body must include spec") + Expect(spec["catalog_item_id"]).To(Equal("my-catalog-item")) + userValues, ok := spec["user_values"].([]any) + Expect(ok).To(BeTrue(), "request body must include spec.user_values") + Expect(userValues).To(HaveLen(1)) + userValue, ok := userValues[0].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(userValue["resource"]).To(Equal("app")) + Expect(userValue["path"]).To(Equal("vcpu.count")) + Expect(userValue["value"]).To(Equal("2")) writeJSONResponse(w, http.StatusCreated, sampleInstanceResponse()) })) - yamlFile := writeTempFile("display_name: My App Instance\napi_version: v1alpha1\nspec:\n catalog_item_id: my-catalog-item\n user_values: []\n", ".yaml") + yamlFile := writeTempFile("display_name: My App Instance\napi_version: v1alpha1\nspec:\n catalog_item_id: my-catalog-item\n user_values:\n - resource: app\n path: vcpu.count\n value: \"2\"\n", ".yaml") err := executeCommand("catalog", "instance", "create", "--from-file", yamlFile) Expect(err).NotTo(HaveOccurred()) @@ -106,7 +143,7 @@ var _ = Describe("Catalog Instance Commands", func() { writeJSONResponse(w, http.StatusCreated, sampleInstanceResponse()) })) - yamlFile := writeTempFile("display_name: My App Instance\napi_version: v1alpha1\nspec:\n catalog_item_id: my-catalog-item\n user_values: []\n", ".yaml") + yamlFile := writeTempFile("display_name: My App Instance\napi_version: v1alpha1\nspec:\n catalog_item_id: my-catalog-item\n user_values:\n - resource: app\n path: vcpu.count\n value: \"2\"\n", ".yaml") err := executeCommand("catalog", "instance", "create", "--from-file", yamlFile, "--id", "my-instance") Expect(err).NotTo(HaveOccurred()) @@ -136,6 +173,30 @@ var _ = Describe("Catalog Instance Commands", func() { Expect(errors.As(err, &fmtErr)).To(BeTrue()) Expect(errBuf.String()).To(ContainSubstring("INTERNAL")) }) + + It("should create an instance with user_values for multiple resources", func() { + expected := sampleTwoResourceInstanceResponse() + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-item-instances")) + + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + Expect(body["display_name"]).To(Equal(expected["display_name"])) + Expect(body["spec"]).To(Equal(expected["spec"])) + + writeJSONResponse(w, http.StatusCreated, expected) + })) + + inputFile := writeTempJSON(map[string]any{ + "display_name": expected["display_name"], + "spec": expected["spec"], + }) + + err := executeCommand("catalog", "instance", "create", "--from-file", inputFile) + Expect(err).NotTo(HaveOccurred()) + }) }) Describe("list", func() { diff --git a/internal/commands/catalog_item.go b/internal/commands/catalog_item.go index 77f59bf..0e81284 100644 --- a/internal/commands/catalog_item.go +++ b/internal/commands/catalog_item.go @@ -13,24 +13,15 @@ import ( ) var catalogItemTableDef = &output.TableDef{ - Headers: []string{"UID", "DISPLAY NAME", "SERVICE TYPE", "CREATED"}, + Headers: []string{"UID", "DISPLAY NAME", "CREATED"}, RowFunc: func(resource any) []string { m, ok := resource.(map[string]any) if !ok { - return []string{"", "", "", ""} - } - var serviceType string - if spec, ok := m["spec"].(map[string]any); ok { - if resources, ok := spec["resources"].([]any); ok && len(resources) > 0 { - if res, ok := resources[0].(map[string]any); ok { - serviceType = stringifyValue(res, "service_type") - } - } + return []string{"", "", ""} } return []string{ stringifyValue(m, "uid"), stringifyValue(m, "display_name"), - serviceType, stringifyValue(m, "create_time"), } }, diff --git a/internal/commands/catalog_item_test.go b/internal/commands/catalog_item_test.go index c730b3b..6b80229 100644 --- a/internal/commands/catalog_item_test.go +++ b/internal/commands/catalog_item_test.go @@ -31,6 +31,48 @@ func sampleCatalogItemResponse() map[string]any { } } +// sampleTwoResourceCatalogItemResponse returns a catalog item with two distinct resources. +func sampleTwoResourceCatalogItemResponse() map[string]any { + return map[string]any{ + "path": "catalog-items/my-catalog-item", + "uid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "display_name": "App with Database", + "create_time": "2026-03-09T10:00:00Z", + "spec": map[string]any{ + "resources": []any{ + map[string]any{ + "name": "app", + "service_type": "container", + "fields": []any{ + map[string]any{ + "path": "image.reference", + "default": "nginx:latest", + }, + }, + }, + map[string]any{ + "name": "db", + "service_type": "database", + "requires_resources": []any{"app"}, + "fields": []any{ + map[string]any{ + "path": "engine", + "default": "postgres", + }, + }, + }, + }, + }, + } +} + +// writeTempJSON marshals v to JSON and writes it to a temporary file. +func writeTempJSON(v any) string { + data, err := json.Marshal(v) + Expect(err).NotTo(HaveOccurred()) + return writeTempFile(string(data), ".json") +} + // emptyCatalogItemListResponse returns a standard empty catalog item list response body. func emptyCatalogItemListResponse() map[string]any { return map[string]any{ @@ -86,6 +128,15 @@ var _ = Describe("Catalog Item Commands", func() { var body map[string]any Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) Expect(body["display_name"]).To(Equal("Small Container")) + spec, ok := body["spec"].(map[string]any) + Expect(ok).To(BeTrue(), "request body must include spec") + resources, ok := spec["resources"].([]any) + Expect(ok).To(BeTrue(), "request body must include spec.resources") + Expect(resources).To(HaveLen(1)) + resource, ok := resources[0].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(resource["name"]).To(Equal("main")) + Expect(resource["service_type"]).To(Equal("container")) writeJSONResponse(w, http.StatusCreated, sampleCatalogItemResponse()) })) @@ -140,6 +191,30 @@ var _ = Describe("Catalog Item Commands", func() { Expect(errors.As(err, &fmtErr)).To(BeTrue()) Expect(errBuf.String()).To(ContainSubstring("INTERNAL")) }) + + It("should create a catalog item with multiple resources", func() { + expected := sampleTwoResourceCatalogItemResponse() + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-items")) + + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + Expect(body["display_name"]).To(Equal(expected["display_name"])) + Expect(body["spec"]).To(Equal(expected["spec"])) + + writeJSONResponse(w, http.StatusCreated, expected) + })) + + inputFile := writeTempJSON(map[string]any{ + "display_name": expected["display_name"], + "spec": expected["spec"], + }) + + err := executeCommand("catalog", "item", "create", "--from-file", inputFile) + Expect(err).NotTo(HaveOccurred()) + }) }) Describe("list", func() { @@ -290,11 +365,9 @@ var _ = Describe("Catalog Item Commands", func() { out := outBuf.String() Expect(out).To(ContainSubstring("UID")) Expect(out).To(ContainSubstring("DISPLAY NAME")) - Expect(out).To(ContainSubstring("SERVICE TYPE")) Expect(out).To(ContainSubstring("CREATED")) Expect(out).To(ContainSubstring("b2c3d4e5-f6a7-8901-bcde-f12345678901")) Expect(out).To(ContainSubstring("Small Container")) - Expect(out).To(ContainSubstring("container")) Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) }) }) diff --git a/internal/commands/contract_test.go b/internal/commands/contract_test.go index 8a92999..8fc3876 100644 --- a/internal/commands/contract_test.go +++ b/internal/commands/contract_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -109,4 +110,18 @@ var _ = Describe("Documentation Contract", func() { } }) }) + + Describe("Catalog Item table output", func() { + It("should emit UID, DISPLAY NAME, CREATED without an ID column", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, sampleCatalogItemResponse()) + })) + + err := executeCommand("catalog", "item", "get", "my-catalog-item") + Expect(err).NotTo(HaveOccurred()) + + header := strings.Split(strings.TrimSpace(outBuf.String()), "\n")[0] + Expect(header).To(MatchRegexp(`^UID\s+DISPLAY NAME\s+CREATED\s*$`)) + }) + }) })