mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-08 21:14:56 +09:00
Accessing imgName["properties"].(map[string]any) and diskName["properties"].(map[string]any) without an ok-check would panic if the schema does not contain a "properties" key. Wrap both assertions in ok-guarded form consistent with the outer defensive style. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
464 lines
15 KiB
Go
464 lines
15 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
|
|
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
|
|
|
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
)
|
|
|
|
// ensureCustomFormsOverride creates or updates a CustomFormsOverride resource for the given CRD
|
|
func (m *Manager) ensureCustomFormsOverride(ctx context.Context, crd *cozyv1alpha1.ApplicationDefinition) error {
|
|
g, v, kind := pickGVK(crd)
|
|
plural := pickPlural(kind, crd)
|
|
|
|
name := fmt.Sprintf("%s.%s.%s", g, v, plural)
|
|
customizationID := fmt.Sprintf("default-/%s/%s/%s", g, v, plural)
|
|
|
|
obj := &dashv1alpha1.CustomFormsOverride{}
|
|
obj.SetName(name)
|
|
|
|
// Replicates your Helm includes (system metadata + api + status).
|
|
hidden := []any{}
|
|
hidden = append(hidden, hiddenMetadataSystem()...)
|
|
hidden = append(hidden, hiddenMetadataAPI()...)
|
|
hidden = append(hidden, hiddenStatus()...)
|
|
|
|
// If Name is set, hide metadata
|
|
if crd.Spec.Dashboard != nil && strings.TrimSpace(crd.Spec.Dashboard.Name) != "" {
|
|
hidden = append([]interface{}{
|
|
[]any{"metadata"},
|
|
}, hidden...)
|
|
}
|
|
|
|
var sort []any
|
|
if crd.Spec.Dashboard != nil && len(crd.Spec.Dashboard.KeysOrder) > 0 {
|
|
sort = make([]any, len(crd.Spec.Dashboard.KeysOrder))
|
|
for i, v := range crd.Spec.Dashboard.KeysOrder {
|
|
sort[i] = v
|
|
}
|
|
}
|
|
|
|
// Parse OpenAPI schema once for reuse
|
|
l := log.FromContext(ctx)
|
|
openAPIProps := parseOpenAPIProperties(crd.Spec.Application.OpenAPISchema)
|
|
|
|
// Build schema with multilineString for string fields without enum
|
|
schema, err := buildMultilineStringSchema(crd.Spec.Application.OpenAPISchema)
|
|
if err != nil {
|
|
// If schema parsing fails, log the error and use an empty schema
|
|
l.Error(err, "failed to build multiline string schema, using empty schema", "crd", crd.Name)
|
|
schema = map[string]any{}
|
|
}
|
|
|
|
// Override specific fields with API-backed dropdowns (listInput type)
|
|
applyListInputOverrides(schema, kind, openAPIProps)
|
|
|
|
// Hide deprecated fields from the UI
|
|
hidden = append(hidden, hiddenDeprecatedFields(kind)...)
|
|
|
|
spec := map[string]any{
|
|
"customizationId": customizationID,
|
|
"hidden": hidden,
|
|
"sort": sort,
|
|
"schema": schema,
|
|
"strategy": "merge",
|
|
}
|
|
|
|
_, err = controllerutil.CreateOrUpdate(ctx, m.Client, obj, func() error {
|
|
if err := controllerutil.SetOwnerReference(crd, obj, m.Scheme); err != nil {
|
|
return err
|
|
}
|
|
// Add dashboard labels to dynamic resources
|
|
m.addDashboardLabels(obj, crd, ResourceTypeDynamic)
|
|
b, err := json.Marshal(spec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only update spec if it's different to avoid unnecessary updates
|
|
newSpec := dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
|
|
if !compareArbitrarySpecs(obj.Spec, newSpec) {
|
|
obj.Spec = newSpec
|
|
}
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
// ensureCFOMapping updates the CFOMapping resource to include a mapping for the given CRD
|
|
func (m *Manager) ensureCFOMapping(ctx context.Context, crd *cozyv1alpha1.ApplicationDefinition) error {
|
|
g, v, kind := pickGVK(crd)
|
|
plural := pickPlural(kind, crd)
|
|
|
|
resourcePath := fmt.Sprintf("/%s/%s/%s", g, v, plural)
|
|
customizationID := fmt.Sprintf("default-%s", resourcePath)
|
|
|
|
obj := &dashv1alpha1.CFOMapping{}
|
|
obj.SetName("cfomapping")
|
|
|
|
_, err := controllerutil.CreateOrUpdate(ctx, m.Client, obj, func() error {
|
|
// Parse existing mappings
|
|
mappings := make(map[string]string)
|
|
if obj.Spec.JSON.Raw != nil {
|
|
var spec map[string]any
|
|
if err := json.Unmarshal(obj.Spec.JSON.Raw, &spec); err == nil {
|
|
if m, ok := spec["mappings"].(map[string]any); ok {
|
|
for k, val := range m {
|
|
if s, ok := val.(string); ok {
|
|
mappings[k] = s
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add/update the mapping for this CRD
|
|
mappings[resourcePath] = customizationID
|
|
|
|
specData := map[string]any{
|
|
"mappings": mappings,
|
|
}
|
|
b, err := json.Marshal(specData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
newSpec := dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
|
|
if !compareArbitrarySpecs(obj.Spec, newSpec) {
|
|
obj.Spec = newSpec
|
|
}
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
// buildMultilineStringSchema parses OpenAPI schema and creates schema with multilineString
|
|
// for all string fields inside spec that don't have enum.
|
|
// It handles two structures:
|
|
// - properties.spec.properties (most resources)
|
|
// - properties.properties (VMDisk and similar resources without spec wrapper)
|
|
func buildMultilineStringSchema(openAPISchema string) (map[string]any, error) {
|
|
if openAPISchema == "" {
|
|
return map[string]any{}, nil
|
|
}
|
|
|
|
var root map[string]any
|
|
if err := json.Unmarshal([]byte(openAPISchema), &root); err != nil {
|
|
return nil, fmt.Errorf("cannot parse openAPISchema: %w", err)
|
|
}
|
|
|
|
props, _ := root["properties"].(map[string]any)
|
|
if props == nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
|
|
schema := map[string]any{
|
|
"properties": map[string]any{},
|
|
}
|
|
|
|
var specProps map[string]any
|
|
var hasSpec bool
|
|
|
|
// First try to find properties under spec
|
|
if specProp, ok := props["spec"].(map[string]any); ok {
|
|
specProps, hasSpec = specProp["properties"].(map[string]any)
|
|
}
|
|
|
|
// If no spec wrapper, use top-level properties directly (VMDisk pattern)
|
|
if !hasSpec {
|
|
specProps = props
|
|
// Still wrap in spec for consistency with applyListInputOverrides
|
|
schemaProps := schema["properties"].(map[string]any)
|
|
specSchema := map[string]any{
|
|
"properties": map[string]any{},
|
|
}
|
|
schemaProps["spec"] = specSchema
|
|
processSpecProperties(specProps, specSchema["properties"].(map[string]any))
|
|
return schema, nil
|
|
}
|
|
|
|
// Create spec.properties structure in schema
|
|
schemaProps := schema["properties"].(map[string]any)
|
|
specSchema := map[string]any{
|
|
"properties": map[string]any{},
|
|
}
|
|
schemaProps["spec"] = specSchema
|
|
|
|
// Process spec properties recursively
|
|
processSpecProperties(specProps, specSchema["properties"].(map[string]any))
|
|
|
|
return schema, nil
|
|
}
|
|
|
|
// applyListInputOverrides injects listInput type overrides into the schema
|
|
// for fields that should be rendered as API-backed dropdowns in the dashboard.
|
|
// openAPIProps are the parsed top-level properties from the OpenAPI schema.
|
|
func applyListInputOverrides(schema map[string]any, kind string, openAPIProps map[string]any) {
|
|
switch kind {
|
|
case "VMInstance":
|
|
specProps := ensureSchemaPath(schema, "spec")
|
|
field := map[string]any{
|
|
"type": "listInput",
|
|
"customProps": map[string]any{
|
|
"valueUri": "/api/clusters/{cluster}/k8s/apis/instancetype.kubevirt.io/v1beta1/virtualmachineclusterinstancetypes",
|
|
"keysToValue": []any{"metadata", "name"},
|
|
"keysToLabel": []any{"metadata", "name"},
|
|
"allowEmpty": true,
|
|
},
|
|
}
|
|
if prop, _ := openAPIProps["instanceType"].(map[string]any); prop != nil {
|
|
if def := prop["default"]; def != nil {
|
|
field["default"] = def
|
|
}
|
|
}
|
|
specProps["instanceType"] = field
|
|
|
|
// Override disks[].name to be an API-backed dropdown listing VMDisk resources
|
|
disksItemProps := ensureArrayItemProps(specProps, "disks")
|
|
disksItemProps["name"] = map[string]any{
|
|
"type": "listInput",
|
|
"customProps": map[string]any{
|
|
"valueUri": "/api/clusters/{cluster}/k8s/apis/apps.cozystack.io/v1alpha1/namespaces/{namespace}/vmdisks",
|
|
"keysToValue": []any{"metadata", "name"},
|
|
"keysToLabel": []any{"metadata", "name"},
|
|
},
|
|
}
|
|
|
|
// Override networks[].name to be an API-backed dropdown listing NetworkAttachmentDefinitions
|
|
networksItemProps := ensureArrayItemProps(specProps, "networks")
|
|
networksItemProps["name"] = map[string]any{
|
|
"type": "listInput",
|
|
"customProps": map[string]any{
|
|
"valueUri": "/api/clusters/{cluster}/k8s/apis/k8s.cni.cncf.io/v1/namespaces/{namespace}/network-attachment-definitions",
|
|
"keysToValue": []any{"metadata", "name"},
|
|
"keysToLabel": []any{"metadata", "name"},
|
|
},
|
|
}
|
|
|
|
case "ClickHouse", "Harbor", "HTTPCache", "Kubernetes", "MariaDB", "MongoDB",
|
|
"NATS", "OpenBAO", "Postgres", "Qdrant", "RabbitMQ", "Redis":
|
|
specProps := ensureSchemaPath(schema, "spec")
|
|
specProps["storageClass"] = storageClassListInput()
|
|
|
|
case "VMDisk":
|
|
specProps := ensureSchemaPath(schema, "spec")
|
|
specProps["storageClass"] = storageClassListInput()
|
|
|
|
// Override source.image.name to be an API-backed dropdown listing default images
|
|
if sourceObj, ok := specProps["source"].(map[string]any); ok {
|
|
if imgProps, ok := sourceObj["properties"].(map[string]any); ok {
|
|
if imgName, ok := imgProps["image"].(map[string]any); ok {
|
|
if imgNameProps, ok := imgName["properties"].(map[string]any); ok {
|
|
imgNameProps["name"] = map[string]any{
|
|
"type": "listInput",
|
|
"customProps": map[string]any{
|
|
"valueUri": "/api/clusters/{cluster}/k8s/apis/cdi.kubevirt.io/v1beta1/namespaces/cozy-public/datavolumes",
|
|
"keysToValue": []any{"metadata", "annotations", "vm-default-images.cozystack.io/name"},
|
|
"keysToLabel": []any{"metadata", "annotations", "vm-default-images.cozystack.io/description"},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
// Override source.disk.name to be an API-backed dropdown listing VMDisk resources
|
|
if diskName, ok := imgProps["disk"].(map[string]any); ok {
|
|
if diskNameProps, ok := diskName["properties"].(map[string]any); ok {
|
|
diskNameProps["name"] = map[string]any{
|
|
"type": "listInput",
|
|
"customProps": map[string]any{
|
|
"valueUri": "/api/clusters/{cluster}/k8s/apis/apps.cozystack.io/v1alpha1/namespaces/{namespace}/vmdisks",
|
|
"keysToValue": []any{"metadata", "name"},
|
|
"keysToLabel": []any{"metadata", "name"},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case "FoundationDB":
|
|
storageProps := ensureSchemaPath(schema, "spec", "storage")
|
|
storageProps["storageClass"] = storageClassListInput()
|
|
|
|
case "Kafka":
|
|
kafkaProps := ensureSchemaPath(schema, "spec", "kafka")
|
|
kafkaProps["storageClass"] = storageClassListInput()
|
|
zkProps := ensureSchemaPath(schema, "spec", "zookeeper")
|
|
zkProps["storageClass"] = storageClassListInput()
|
|
|
|
case "Tenant":
|
|
specProps := ensureSchemaPath(schema, "spec")
|
|
specProps["schedulingClass"] = schedulingClassListInput()
|
|
}
|
|
}
|
|
|
|
// hiddenDeprecatedFields returns hidden paths for deprecated fields that should not
|
|
// appear in the dashboard UI for the given kind.
|
|
func hiddenDeprecatedFields(kind string) []any {
|
|
switch kind {
|
|
case "VMInstance":
|
|
return []any{
|
|
[]any{"spec", "subnets"},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// storageClassListInput returns a listInput field config for a storageClass dropdown
|
|
// backed by the cluster's available StorageClasses.
|
|
func storageClassListInput() map[string]any {
|
|
return map[string]any{
|
|
"type": "listInput",
|
|
"customProps": map[string]any{
|
|
"valueUri": "/api/clusters/{cluster}/k8s/apis/storage.k8s.io/v1/storageclasses",
|
|
"keysToValue": []any{"metadata", "name"},
|
|
"keysToLabel": []any{"metadata", "name"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// schedulingClassListInput returns a listInput field config for a schedulingClass dropdown
|
|
// backed by the cluster's available SchedulingClass CRs.
|
|
func schedulingClassListInput() map[string]any {
|
|
return map[string]any{
|
|
"type": "listInput",
|
|
"customProps": map[string]any{
|
|
"valueUri": "/api/clusters/{cluster}/k8s/apis/cozystack.io/v1alpha1/schedulingclasses",
|
|
"keysToValue": []any{"metadata", "name"},
|
|
"keysToLabel": []any{"metadata", "name"},
|
|
"allowEmpty": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ensureArrayItemProps ensures that parentProps[fieldName].items.properties exists
|
|
// and returns the items properties map. Used for overriding fields inside array items.
|
|
func ensureArrayItemProps(parentProps map[string]any, fieldName string) map[string]any {
|
|
field, ok := parentProps[fieldName].(map[string]any)
|
|
if !ok {
|
|
field = map[string]any{}
|
|
parentProps[fieldName] = field
|
|
}
|
|
items, ok := field["items"].(map[string]any)
|
|
if !ok {
|
|
items = map[string]any{}
|
|
field["items"] = items
|
|
}
|
|
props, ok := items["properties"].(map[string]any)
|
|
if !ok {
|
|
props = map[string]any{}
|
|
items["properties"] = props
|
|
}
|
|
return props
|
|
}
|
|
|
|
// parseOpenAPIProperties parses the top-level properties from an OpenAPI schema JSON string.
|
|
func parseOpenAPIProperties(openAPISchema string) map[string]any {
|
|
if openAPISchema == "" {
|
|
return nil
|
|
}
|
|
var root map[string]any
|
|
if err := json.Unmarshal([]byte(openAPISchema), &root); err != nil {
|
|
return nil
|
|
}
|
|
props, _ := root["properties"].(map[string]any)
|
|
return props
|
|
}
|
|
|
|
// ensureSchemaPath ensures the nested properties structure exists in a schema
|
|
// and returns the innermost properties map.
|
|
// e.g. ensureSchemaPath(schema, "spec") returns schema["properties"]["spec"]["properties"]
|
|
func ensureSchemaPath(schema map[string]any, segments ...string) map[string]any {
|
|
current := schema
|
|
for _, seg := range segments {
|
|
props, ok := current["properties"].(map[string]any)
|
|
if !ok {
|
|
props = map[string]any{}
|
|
current["properties"] = props
|
|
}
|
|
child, ok := props[seg].(map[string]any)
|
|
if !ok {
|
|
child = map[string]any{}
|
|
props[seg] = child
|
|
}
|
|
current = child
|
|
}
|
|
props, ok := current["properties"].(map[string]any)
|
|
if !ok {
|
|
props = map[string]any{}
|
|
current["properties"] = props
|
|
}
|
|
return props
|
|
}
|
|
|
|
// processSpecProperties recursively processes spec properties and adds multilineString type
|
|
// for string fields without enum
|
|
func processSpecProperties(props map[string]any, schemaProps map[string]any) {
|
|
for pname, raw := range props {
|
|
sub, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
typ, _ := sub["type"].(string)
|
|
|
|
switch typ {
|
|
case "string":
|
|
// Check if this string field has enum
|
|
if !hasEnum(sub) {
|
|
// Add multilineString type for this field
|
|
if schemaProps[pname] == nil {
|
|
schemaProps[pname] = map[string]any{}
|
|
}
|
|
fieldSchema := schemaProps[pname].(map[string]any)
|
|
fieldSchema["type"] = "multilineString"
|
|
}
|
|
case "object":
|
|
// Recursively process nested objects
|
|
if childProps, ok := sub["properties"].(map[string]any); ok {
|
|
fieldSchema, ok := schemaProps[pname].(map[string]any)
|
|
if !ok {
|
|
fieldSchema = map[string]any{}
|
|
schemaProps[pname] = fieldSchema
|
|
}
|
|
nestedSchemaProps, ok := fieldSchema["properties"].(map[string]any)
|
|
if !ok {
|
|
nestedSchemaProps = map[string]any{}
|
|
fieldSchema["properties"] = nestedSchemaProps
|
|
}
|
|
processSpecProperties(childProps, nestedSchemaProps)
|
|
}
|
|
case "array":
|
|
// Check if array items are objects with properties
|
|
if items, ok := sub["items"].(map[string]any); ok {
|
|
if itemProps, ok := items["properties"].(map[string]any); ok {
|
|
// Create array item schema
|
|
fieldSchema, ok := schemaProps[pname].(map[string]any)
|
|
if !ok {
|
|
fieldSchema = map[string]any{}
|
|
schemaProps[pname] = fieldSchema
|
|
}
|
|
itemSchema, ok := fieldSchema["items"].(map[string]any)
|
|
if !ok {
|
|
itemSchema = map[string]any{}
|
|
fieldSchema["items"] = itemSchema
|
|
}
|
|
itemSchemaProps, ok := itemSchema["properties"].(map[string]any)
|
|
if !ok {
|
|
itemSchemaProps = map[string]any{}
|
|
itemSchema["properties"] = itemSchemaProps
|
|
}
|
|
processSpecProperties(itemProps, itemSchemaProps)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|