Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 29 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
jenniferubah marked this conversation as resolved.
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`
Expand Down Expand Up @@ -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):

```
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
28 changes: 14 additions & 14 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 63 additions & 2 deletions internal/commands/catalog_instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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())
Expand All @@ -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())
Expand Down Expand Up @@ -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() {
Expand Down
13 changes: 2 additions & 11 deletions internal/commands/catalog_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,15 @@ import (
)

var catalogItemTableDef = &output.TableDef{
Headers: []string{"UID", "DISPLAY NAME", "SERVICE TYPE", "CREATED"},
Headers: []string{"UID", "DISPLAY NAME", "CREATED"},
Comment thread
gciavarrini marked this conversation as resolved.
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"),
}
},
Expand Down
77 changes: 75 additions & 2 deletions internal/commands/catalog_item_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new test asserts exactly one resource, so a regression that drops additional resources or resource-specific values can still pass. Please add a two-resource case with distinct values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1
and the catalog item example in the README already defines two resources (app, db), so the test can mirror that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the tests 0ad2494

Expect(ok).To(BeTrue(), "request body must include spec.resources")
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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())
}))
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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"))
})
})
Expand Down
Loading
Loading