diff --git a/internal/controller/dashboard/customformsoverride.go b/internal/controller/dashboard/customformsoverride.go index ae637fa1..f88202bc 100644 --- a/internal/controller/dashboard/customformsoverride.go +++ b/internal/controller/dashboard/customformsoverride.go @@ -11,6 +11,7 @@ import ( 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 @@ -45,15 +46,24 @@ func (m *Manager) ensureCustomFormsOverride(ctx context.Context, crd *cozyv1alph } } + // Build schema with multilineString for string fields without enum + l := log.FromContext(ctx) + 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{} + } + spec := map[string]any{ "customizationId": customizationID, "hidden": hidden, "sort": sort, - "schema": map[string]any{}, // {} + "schema": schema, "strategy": "merge", } - _, err := controllerutil.CreateOrUpdate(ctx, m.Client, obj, func() error { + _, err = controllerutil.CreateOrUpdate(ctx, m.Client, obj, func() error { if err := controllerutil.SetOwnerReference(crd, obj, m.Scheme); err != nil { return err } @@ -73,3 +83,94 @@ func (m *Manager) ensureCustomFormsOverride(ctx context.Context, crd *cozyv1alph }) return err } + +// buildMultilineStringSchema parses OpenAPI schema and creates schema with multilineString +// for all string fields inside spec that don't have enum +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{}, + } + + // Process spec properties recursively + processSpecProperties(props, schema["properties"].(map[string]any)) + + return schema, nil +} + +// 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) + } + } + } + } +} diff --git a/internal/controller/dashboard/customformsoverride_test.go b/internal/controller/dashboard/customformsoverride_test.go new file mode 100644 index 00000000..2766bf17 --- /dev/null +++ b/internal/controller/dashboard/customformsoverride_test.go @@ -0,0 +1,155 @@ +package dashboard + +import ( + "encoding/json" + "testing" +) + +func TestBuildMultilineStringSchema(t *testing.T) { + // Test OpenAPI schema with various field types + openAPISchema := `{ + "properties": { + "simpleString": { + "type": "string", + "description": "A simple string field" + }, + "stringWithEnum": { + "type": "string", + "enum": ["option1", "option2"], + "description": "String with enum should be skipped" + }, + "numberField": { + "type": "number", + "description": "Number field should be skipped" + }, + "nestedObject": { + "type": "object", + "properties": { + "nestedString": { + "type": "string", + "description": "Nested string should get multilineString" + }, + "nestedStringWithEnum": { + "type": "string", + "enum": ["a", "b"], + "description": "Nested string with enum should be skipped" + } + } + }, + "arrayOfObjects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "itemString": { + "type": "string", + "description": "String in array item" + } + } + } + } + } + }` + + schema, err := buildMultilineStringSchema(openAPISchema) + if err != nil { + t.Fatalf("buildMultilineStringSchema failed: %v", err) + } + + // Marshal to JSON for easier inspection + schemaJSON, err := json.MarshalIndent(schema, "", " ") + if err != nil { + t.Fatalf("Failed to marshal schema: %v", err) + } + + t.Logf("Generated schema:\n%s", schemaJSON) + + // Verify that simpleString has multilineString type + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatal("schema.properties is not a map") + } + + // Check simpleString + simpleString, ok := props["simpleString"].(map[string]any) + if !ok { + t.Fatal("simpleString not found in properties") + } + if simpleString["type"] != "multilineString" { + t.Errorf("simpleString should have type multilineString, got %v", simpleString["type"]) + } + + // Check stringWithEnum should not be present (or should not have multilineString) + if stringWithEnum, ok := props["stringWithEnum"].(map[string]any); ok { + if stringWithEnum["type"] == "multilineString" { + t.Error("stringWithEnum should not have multilineString type") + } + } + + // Check numberField should not be present + if numberField, ok := props["numberField"].(map[string]any); ok { + if numberField["type"] != nil { + t.Error("numberField should not have any type override") + } + } + + // Check nested object + nestedObject, ok := props["nestedObject"].(map[string]any) + if !ok { + t.Fatal("nestedObject not found in properties") + } + nestedProps, ok := nestedObject["properties"].(map[string]any) + if !ok { + t.Fatal("nestedObject.properties is not a map") + } + + // Check nestedString + nestedString, ok := nestedProps["nestedString"].(map[string]any) + if !ok { + t.Fatal("nestedString not found in nestedObject.properties") + } + if nestedString["type"] != "multilineString" { + t.Errorf("nestedString should have type multilineString, got %v", nestedString["type"]) + } + + // Check array of objects + arrayOfObjects, ok := props["arrayOfObjects"].(map[string]any) + if !ok { + t.Fatal("arrayOfObjects not found in properties") + } + items, ok := arrayOfObjects["items"].(map[string]any) + if !ok { + t.Fatal("arrayOfObjects.items is not a map") + } + itemProps, ok := items["properties"].(map[string]any) + if !ok { + t.Fatal("arrayOfObjects.items.properties is not a map") + } + itemString, ok := itemProps["itemString"].(map[string]any) + if !ok { + t.Fatal("itemString not found in arrayOfObjects.items.properties") + } + if itemString["type"] != "multilineString" { + t.Errorf("itemString should have type multilineString, got %v", itemString["type"]) + } +} + +func TestBuildMultilineStringSchemaEmpty(t *testing.T) { + schema, err := buildMultilineStringSchema("") + if err != nil { + t.Fatalf("buildMultilineStringSchema failed on empty string: %v", err) + } + if len(schema) != 0 { + t.Errorf("Expected empty schema for empty input, got %v", schema) + } +} + +func TestBuildMultilineStringSchemaInvalidJSON(t *testing.T) { + schema, err := buildMultilineStringSchema("{invalid json") + if err == nil { + t.Error("Expected error for invalid JSON") + } + if schema != nil { + t.Errorf("Expected nil schema for invalid JSON, got %v", schema) + } +} diff --git a/internal/controller/dashboard/factory.go b/internal/controller/dashboard/factory.go index 6f3e8f4c..c23bac4a 100644 --- a/internal/controller/dashboard/factory.go +++ b/internal/controller/dashboard/factory.go @@ -293,10 +293,10 @@ func secretsTab(kind string) map[string]any { "type": "EnrichedTable", "data": map[string]any{ "id": "secrets-table", - "fetchUrl": "/api/clusters/{2}/k8s/apis/core.cozystack.io/v1alpha1/namespaces/{3}/tenantsecretstables", + "fetchUrl": "/api/clusters/{2}/k8s/apis/core.cozystack.io/v1alpha1/namespaces/{3}/tenantsecrets", "clusterNamePartOfUrl": "{2}", "baseprefix": "/openapi-ui", - "customizationId": "factory-details-v1alpha1.core.cozystack.io.tenantsecretstables", + "customizationId": "factory-details-v1alpha1.core.cozystack.io.tenantsecrets", "pathToItems": []any{"items"}, "labelsSelector": map[string]any{ "apps.cozystack.io/application.group": "apps.cozystack.io", diff --git a/internal/controller/dashboard/static_helpers.go b/internal/controller/dashboard/static_helpers.go index 5affc1c1..4c8aae30 100644 --- a/internal/controller/dashboard/static_helpers.go +++ b/internal/controller/dashboard/static_helpers.go @@ -122,7 +122,7 @@ func createCustomColumnsOverride(id string, additionalPrinterColumns []any) *das } } - if name == "factory-details-v1alpha1.core.cozystack.io.tenantsecretstables" { + if name == "factory-details-v1alpha1.core.cozystack.io.tenantsecrets" { data["additionalPrinterColumnsTrimLengths"] = []any{ map[string]any{ "key": "Name", @@ -1046,6 +1046,15 @@ func createConverterBytesColumn(name, jsonPath string) map[string]any { } } +// createFlatMapColumn creates a flatMap column that expands a map into separate rows +func createFlatMapColumn(name, jsonPath string) map[string]any { + return map[string]any{ + "name": name, + "type": "flatMap", + "jsonPath": jsonPath, + } +} + // ---------------- Factory UI helper functions ---------------- // labelsEditor creates a Labels editor component diff --git a/internal/controller/dashboard/static_refactored.go b/internal/controller/dashboard/static_refactored.go index 9ad923e7..31db0377 100644 --- a/internal/controller/dashboard/static_refactored.go +++ b/internal/controller/dashboard/static_refactored.go @@ -173,11 +173,12 @@ func CreateAllCustomColumnsOverrides() []*dashboardv1alpha1.CustomColumnsOverrid createStringColumn("OBSERVED", ".status.observedReplicas"), }), - // Factory details v1alpha1 core cozystack io tenantsecretstables - createCustomColumnsOverride("factory-details-v1alpha1.core.cozystack.io.tenantsecretstables", []any{ + // Factory details v1alpha1 core cozystack io tenantsecrets + createCustomColumnsOverride("factory-details-v1alpha1.core.cozystack.io.tenantsecrets", []any{ createCustomColumnWithJsonPath("Name", ".metadata.name", "Secret", "", "/openapi-ui/{2}/{reqsJsonPath[0]['.metadata.namespace']['-']}/factory/kube-secret-details/{reqsJsonPath[0]['.metadata.name']['-']}"), - createStringColumn("Key", ".data.key"), - createSecretBase64Column("Value", ".data.value"), + createFlatMapColumn("Data", ".data"), + createStringColumn("Key", "_flatMapData_Key"), + createSecretBase64Column("Value", "_flatMapData_Value"), createTimestampColumn("Created", ".metadata.creationTimestamp"), }), diff --git a/packages/apps/tenant/templates/tenant.yaml b/packages/apps/tenant/templates/tenant.yaml index 420fc75c..3bb4a7d5 100644 --- a/packages/apps/tenant/templates/tenant.yaml +++ b/packages/apps/tenant/templates/tenant.yaml @@ -35,7 +35,6 @@ rules: resources: - tenantmodules - tenantsecrets - - tenantsecretstables verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 @@ -193,7 +192,6 @@ rules: resources: - tenantmodules - tenantsecrets - - tenantsecretstables verbs: ["get", "list", "watch"] --- kind: RoleBinding @@ -293,7 +291,6 @@ rules: resources: - tenantmodules - tenantsecrets - - tenantsecretstables verbs: ["get", "list", "watch"] --- kind: RoleBinding @@ -368,7 +365,6 @@ rules: resources: - tenantmodules - tenantsecrets - - tenantsecretstables verbs: ["get", "list", "watch"] --- kind: RoleBinding diff --git a/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile b/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile index b0c34cfe..51444ad0 100644 --- a/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile +++ b/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile @@ -3,7 +3,7 @@ ARG NODE_VERSION=20.18.1 FROM node:${NODE_VERSION}-alpine AS builder WORKDIR /src -ARG COMMIT_REF=92906a7f21050cfb8e352f98d36b209c57844f63 +ARG COMMIT_REF=ba56271739505284aee569f914fc90e6a9c670da RUN wget -O- https://github.com/PRO-Robotech/openapi-ui-k8s-bff/archive/${COMMIT_REF}.tar.gz | tar xzf - --strip-components=1 ENV PATH=/src/node_modules/.bin:$PATH diff --git a/packages/system/dashboard/images/openapi-ui/Dockerfile b/packages/system/dashboard/images/openapi-ui/Dockerfile index 82650cd8..a33be863 100644 --- a/packages/system/dashboard/images/openapi-ui/Dockerfile +++ b/packages/system/dashboard/images/openapi-ui/Dockerfile @@ -5,7 +5,7 @@ ARG NODE_VERSION=20.18.1 FROM node:${NODE_VERSION}-alpine AS openapi-k8s-toolkit-builder RUN apk add git WORKDIR /src -ARG COMMIT=7086a2d8a07dcf6a94bb4276433db5d84acfcf3b +ARG COMMIT=7bd5380c6c4606640dd3bac68bf9dce469470518 RUN wget -O- https://github.com/cozystack/openapi-k8s-toolkit/archive/${COMMIT}.tar.gz | tar -xzvf- --strip-components=1 COPY openapi-k8s-toolkit/patches /patches @@ -19,14 +19,14 @@ RUN npm run build # openapi-ui # imported from https://github.com/cozystack/openapi-ui FROM node:${NODE_VERSION}-alpine AS builder -RUN apk add git +#RUN apk add git WORKDIR /src -ARG COMMIT_REF=fe237518348e94cead6d4f3283b2fce27f26aa12 +ARG COMMIT_REF=0c3629b2ce8545e81f7ece4d65372a188c802dfc RUN wget -O- https://github.com/PRO-Robotech/openapi-ui/archive/${COMMIT_REF}.tar.gz | tar xzf - --strip-components=1 -COPY openapi-ui/patches /patches -RUN git apply /patches/*.diff +#COPY openapi-ui/patches /patches +#RUN git apply /patches/*.diff ENV PATH=/src/node_modules/.bin:$PATH diff --git a/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/additional-properties-types.diff b/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/additional-properties-types.diff deleted file mode 100644 index 7bc9a4c3..00000000 --- a/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/additional-properties-types.diff +++ /dev/null @@ -1,230 +0,0 @@ -diff --git a/src/components/molecules/BlackholeForm/molecules/FormObjectFromSwagger/FormObjectFromSwagger.tsx b/src/components/molecules/BlackholeForm/molecules/FormObjectFromSwagger/FormObjectFromSwagger.tsx -index a7135d4..2fea0bb 100644 ---- a/src/components/molecules/BlackholeForm/molecules/FormObjectFromSwagger/FormObjectFromSwagger.tsx -+++ b/src/components/molecules/BlackholeForm/molecules/FormObjectFromSwagger/FormObjectFromSwagger.tsx -@@ -68,13 +68,60 @@ export const FormObjectFromSwagger: FC = ({ - properties?: OpenAPIV2.SchemaObject['properties'] - required?: string - } -+ -+ // Check if the field name exists in additionalProperties.properties -+ // If so, use the type from that property definition -+ const nestedProp = addProps?.properties?.[additionalPropValue] as OpenAPIV2.SchemaObject | undefined -+ let fieldType: string = addProps.type -+ let fieldItems: { type: string } | undefined = addProps.items -+ let fieldNestedProperties = addProps.properties || {} -+ let fieldRequired: string | undefined = addProps.required -+ -+ if (nestedProp) { -+ // Use the nested property definition if it exists -+ // Handle type - it can be string or string[] in OpenAPI v2 -+ if (nestedProp.type) { -+ if (Array.isArray(nestedProp.type)) { -+ fieldType = nestedProp.type[0] || addProps.type -+ } else if (typeof nestedProp.type === 'string') { -+ fieldType = nestedProp.type -+ } else { -+ fieldType = addProps.type -+ } -+ } else { -+ fieldType = addProps.type -+ } -+ -+ // Handle items - it can be ItemsObject or ReferenceObject -+ if (nestedProp.items) { -+ // Check if it's a valid ItemsObject with type property -+ if ('type' in nestedProp.items && typeof nestedProp.items.type === 'string') { -+ fieldItems = { type: nestedProp.items.type } -+ } else { -+ fieldItems = addProps.items -+ } -+ } else { -+ fieldItems = addProps.items -+ } -+ -+ fieldNestedProperties = nestedProp.properties || {} -+ // Handle required field - it can be string[] in OpenAPI schema -+ if (Array.isArray(nestedProp.required)) { -+ fieldRequired = nestedProp.required.join(',') -+ } else if (typeof nestedProp.required === 'string') { -+ fieldRequired = nestedProp.required -+ } else { -+ fieldRequired = addProps.required -+ } -+ } -+ - inputProps?.addField({ - path: Array.isArray(name) ? [...name, String(collapseTitle)] : [name, String(collapseTitle)], - name: additionalPropValue, -- type: addProps.type, -- items: addProps.items, -- nestedProperties: addProps.properties || {}, -- required: addProps.required, -+ type: fieldType, -+ items: fieldItems, -+ nestedProperties: fieldNestedProperties, -+ required: fieldRequired, - }) - setAddditionalPropValue(undefined) - } -diff --git a/src/components/molecules/BlackholeForm/molecules/FormStringInput/FormStringInput.tsx b/src/components/molecules/BlackholeForm/molecules/FormStringInput/FormStringInput.tsx -index 487d480..3ca46c1 100644 ---- a/src/components/molecules/BlackholeForm/molecules/FormStringInput/FormStringInput.tsx -+++ b/src/components/molecules/BlackholeForm/molecules/FormStringInput/FormStringInput.tsx -@@ -42,7 +42,11 @@ export const FormStringInput: FC = ({ - const formValue = Form.useWatch(formFieldName) - - // Derive multiline based on current local value -- const isMultiline = useMemo(() => isMultilineString(formValue), [formValue]) -+ const isMultiline = useMemo(() => { -+ // Normalize value for multiline check -+ const value = typeof formValue === 'string' ? formValue : (formValue === null || formValue === undefined ? '' : String(formValue)) -+ return isMultilineString(value) -+ }, [formValue]) - - const title = ( - <> -@@ -77,6 +81,23 @@ export const FormStringInput: FC = ({ - rules={[{ required: forceNonRequired === false && required?.includes(getStringByName(name)) }]} - validateTrigger="onBlur" - hasFeedback={designNewLayout ? { icons: feedbackIcons } : true} -+ normalize={(value) => { -+ // Normalize value to string - prevent "[object Object]" display -+ if (value === undefined || value === null) { -+ return '' -+ } -+ if (typeof value === 'string') { -+ return value -+ } -+ if (typeof value === 'number' || typeof value === 'boolean') { -+ return String(value) -+ } -+ // If it's an object or array, it shouldn't be in a string field - return empty string -+ if (typeof value === 'object') { -+ return '' -+ } -+ return String(value) -+ }} - > - { -- const t = ap?.type ?? 'object' -+ const makeChildFromAP = (ap: any, value?: unknown): OpenAPIV2.SchemaObject => { -+ // Determine type based on actual value if not explicitly defined in additionalProperties -+ let t = ap?.type -+ if (!t && value !== undefined && value !== null) { -+ if (Array.isArray(value)) { -+ t = 'array' -+ } else if (typeof value === 'object') { -+ t = 'object' -+ } else if (typeof value === 'string') { -+ t = 'string' -+ } else if (typeof value === 'number') { -+ t = 'number' -+ } else if (typeof value === 'boolean') { -+ t = 'boolean' -+ } else { -+ t = 'object' -+ } -+ } -+ t = t ?? 'object' -+ - const child: OpenAPIV2.SchemaObject = { type: t } as any - - // Copy common schema details (if present) -@@ -134,6 +152,20 @@ export const materializeAdditionalFromValues = ( - if (ap?.required) - (child as any).required = _.cloneDeep(ap.required) - -+ // If value is an array and items type is not defined, infer it from the first item -+ if (t === 'array' && Array.isArray(value) && value.length > 0 && !ap?.items) { -+ const firstItem = value[0] -+ if (typeof firstItem === 'string') { -+ ;(child as any).items = { type: 'string' } -+ } else if (typeof firstItem === 'number') { -+ ;(child as any).items = { type: 'number' } -+ } else if (typeof firstItem === 'boolean') { -+ ;(child as any).items = { type: 'boolean' } -+ } else if (typeof firstItem === 'object') { -+ ;(child as any).items = { type: 'object' } -+ } -+ } -+ - // Mark as originating from `additionalProperties` - ;(child as any).isAdditionalProperties = true - return child -@@ -177,7 +209,16 @@ export const materializeAdditionalFromValues = ( - - // If the key doesn't exist in schema, create it from `additionalProperties` - if (!schemaNode.properties![k]) { -- schemaNode.properties![k] = makeChildFromAP(ap) -+ // Check if there's a nested property definition in additionalProperties -+ const nestedProp = ap?.properties?.[k] -+ if (nestedProp) { -+ // Use the nested property definition from additionalProperties -+ schemaNode.properties![k] = _.cloneDeep(nestedProp) as any -+ ;(schemaNode.properties![k] as any).isAdditionalProperties = true -+ } else { -+ // Create from additionalProperties with value-based type inference -+ schemaNode.properties![k] = makeChildFromAP(ap, vo[k]) -+ } - // If it's an existing additional property, merge any nested structure - } else if ((schemaNode.properties![k] as any).isAdditionalProperties && ap?.properties) { - ;(schemaNode.properties![k] as any).properties ??= _.cloneDeep(ap.properties) -diff --git a/src/components/molecules/BlackholeForm/organisms/BlackholeForm/utils.tsx b/src/components/molecules/BlackholeForm/organisms/BlackholeForm/utils.tsx -index 2d887c7..d69d711 100644 ---- a/src/components/molecules/BlackholeForm/organisms/BlackholeForm/utils.tsx -+++ b/src/components/molecules/BlackholeForm/organisms/BlackholeForm/utils.tsx -@@ -394,9 +394,11 @@ export const getArrayFormItemFromSwagger = ({ - {(fields, { add, remove }, { errors }) => ( - <> - {fields.map(field => { -- const fieldType = ( -+ const rawFieldType = ( - schema.items as (OpenAPIV2.ItemsObject & { properties?: OpenAPIV2.SchemaObject }) | undefined - )?.type -+ // Handle type as string or string[] (OpenAPI v2 allows both) -+ const fieldType = Array.isArray(rawFieldType) ? rawFieldType[0] : rawFieldType - const description = (schema.items as (OpenAPIV2.ItemsObject & { description?: string }) | undefined) - ?.description - const entry = schema.items as -@@ -577,7 +579,29 @@ export const getArrayFormItemFromSwagger = ({ - type="text" - size="small" - onClick={() => { -- add() -+ // Determine initial value based on item type -+ const fieldType = ( -+ schema.items as (OpenAPIV2.ItemsObject & { properties?: OpenAPIV2.SchemaObject }) | undefined -+ )?.type -+ -+ let initialValue: unknown -+ // Handle type as string or string[] (OpenAPI v2 allows both) -+ const typeStr = Array.isArray(fieldType) ? fieldType[0] : fieldType -+ if (typeStr === 'string') { -+ initialValue = '' -+ } else if (typeStr === 'number' || typeStr === 'integer') { -+ initialValue = 0 -+ } else if (typeStr === 'boolean') { -+ initialValue = false -+ } else if (typeStr === 'array') { -+ initialValue = [] -+ } else if (typeStr === 'object') { -+ initialValue = {} -+ } else { -+ initialValue = '' -+ } -+ -+ add(initialValue) - }} - > - diff --git a/packages/system/dashboard/images/openapi-ui/openapi-ui/patches/namespaces.diff b/packages/system/dashboard/images/openapi-ui/openapi-ui/patches/namespaces.diff deleted file mode 100644 index c35ec47d..00000000 --- a/packages/system/dashboard/images/openapi-ui/openapi-ui/patches/namespaces.diff +++ /dev/null @@ -1,91 +0,0 @@ -diff --git a/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx b/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx -index ac56e5f..c6e2350 100644 ---- a/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx -+++ b/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx -@@ -1,6 +1,6 @@ - import React, { FC, useState } from 'react' - import { Button, Alert, Spin, Typography } from 'antd' --import { filterSelectOptions, Spacer, useBuiltinResources, useApiResources } from '@prorobotech/openapi-k8s-toolkit' -+import { filterSelectOptions, Spacer, useApiResources } from '@prorobotech/openapi-k8s-toolkit' - import { useNavigate } from 'react-router-dom' - import { useSelector, useDispatch } from 'react-redux' - import { RootState } from 'store/store' -@@ -11,6 +11,11 @@ import { - CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME, - } from 'constants/customizationApiGroupAndVersion' - import { Styled } from './styled' -+import { -+ BASE_PROJECTS_API_GROUP, -+ BASE_PROJECTS_VERSION, -+ BASE_PROJECTS_RESOURCE_NAME, -+} from 'constants/customizationApiGroupAndVersion' - - export const ListInsideClusterAndNs: FC = () => { - const clusterList = useSelector((state: RootState) => state.clusterList.clusterList) -@@ -33,9 +38,11 @@ export const ListInsideClusterAndNs: FC = () => { - typeof CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME === 'string' && - CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME.length > 0 - -- const namespacesData = useBuiltinResources({ -+ const namespacesData = useApiResources({ - clusterName: selectedCluster || '', -- typeName: 'namespaces', -+ apiGroup: BASE_PROJECTS_API_GROUP, -+ apiVersion: BASE_PROJECTS_VERSION, -+ typeName: BASE_PROJECTS_RESOURCE_NAME, - limit: null, - isEnabled: selectedCluster !== undefined && !isCustomNamespaceResource, - }) -diff --git a/src/hooks/useNavSelectorInside.ts b/src/hooks/useNavSelectorInside.ts -index 5736e2b..1ec0f71 100644 ---- a/src/hooks/useNavSelectorInside.ts -+++ b/src/hooks/useNavSelectorInside.ts -@@ -1,6 +1,11 @@ --import { TClusterList, TSingleResource, useBuiltinResources } from '@prorobotech/openapi-k8s-toolkit' -+import { TClusterList, TSingleResource, useApiResources } from '@prorobotech/openapi-k8s-toolkit' - import { useSelector } from 'react-redux' - import { RootState } from 'store/store' -+import { -+ BASE_PROJECTS_API_GROUP, -+ BASE_PROJECTS_VERSION, -+ BASE_PROJECTS_RESOURCE_NAME, -+} from 'constants/customizationApiGroupAndVersion' - - const mappedClusterToOptionInSidebar = ({ name }: TClusterList[number]): { value: string; label: string } => ({ - value: name, -@@ -15,9 +20,11 @@ const mappedNamespaceToOptionInSidebar = ({ metadata }: TSingleResource): { valu - export const useNavSelectorInside = (clusterName?: string) => { - const clusterList = useSelector((state: RootState) => state.clusterList.clusterList) - -- const { data: namespaces } = useBuiltinResources({ -+ const { data: namespaces } = useApiResources({ - clusterName: clusterName || '', -- typeName: 'namespaces', -+ apiGroup: BASE_PROJECTS_API_GROUP, -+ apiVersion: BASE_PROJECTS_VERSION, -+ typeName: BASE_PROJECTS_RESOURCE_NAME, - limit: null, - isEnabled: Boolean(clusterName), - }) -diff --git a/src/utils/getBacklink.ts b/src/utils/getBacklink.ts -index a862354..f24e2bc 100644 ---- a/src/utils/getBacklink.ts -+++ b/src/utils/getBacklink.ts -@@ -28,7 +28,7 @@ export const getFormsBackLink = ({ - } - - if (namespacesMode) { -- return `${baseprefix}/${clusterName}/builtin-table/namespaces` -+ return `${baseprefix}/${clusterName}/api-table/core.cozystack.io/v1alpha1/tenantnamespaces` - } - - if (possibleProject) { -@@ -64,7 +64,7 @@ export const getTablesBackLink = ({ - } - - if (namespacesMode) { -- return `${baseprefix}/${clusterName}/builtin-table/namespaces` -+ return `${baseprefix}/${clusterName}/api-table/core.cozystack.io/v1alpha1/tenantnamespaces` - } - - if (possibleProject) { diff --git a/packages/system/dashboard/images/openapi-ui/openapi-ui/patches/remove-inside-link.diff b/packages/system/dashboard/images/openapi-ui/openapi-ui/patches/remove-inside-link.diff deleted file mode 100644 index b131b53d..00000000 --- a/packages/system/dashboard/images/openapi-ui/openapi-ui/patches/remove-inside-link.diff +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/src/components/organisms/Header/organisms/User/User.tsx b/src/components/organisms/Header/organisms/User/User.tsx -index efe7ac3..80b715c 100644 ---- a/src/components/organisms/Header/organisms/User/User.tsx -+++ b/src/components/organisms/Header/organisms/User/User.tsx -@@ -23,10 +23,6 @@ export const User: FC = () => { - // key: '1', - // label: , - // }, -- { -- key: '2', -- label:
navigate(`${baseprefix}/inside/clusters`)}>Inside
, -- }, - { - key: '3', - label: ( diff --git a/packages/system/dashboard/templates/web.yaml b/packages/system/dashboard/templates/web.yaml index 77a0cba3..b647c743 100644 --- a/packages/system/dashboard/templates/web.yaml +++ b/packages/system/dashboard/templates/web.yaml @@ -45,9 +45,9 @@ spec: - name: BASE_NAMESPACE_FULL_PATH value: "/apis/core.cozystack.io/v1alpha1/tenantnamespaces" - name: LOGGER - value: "TRUE" + value: "true" - name: LOGGER_WITH_HEADERS - value: "TRUE" + value: "false" - name: PORT value: "64231" image: {{ .Values.openapiUIK8sBff.image | quote }} @@ -94,6 +94,8 @@ spec: - env: - name: BASEPREFIX value: /openapi-ui + - name: HIDE_INSIDE + value: "true" - name: CUSTOMIZATION_API_GROUP value: dashboard.cozystack.io - name: CUSTOMIZATION_API_VERSION diff --git a/packages/system/dashboard/values.yaml b/packages/system/dashboard/values.yaml index cdbee73c..71501fe0 100644 --- a/packages/system/dashboard/values.yaml +++ b/packages/system/dashboard/values.yaml @@ -1,6 +1,6 @@ openapiUI: - image: ghcr.io/cozystack/cozystack/openapi-ui:latest@sha256:b942d98ff0ea36e3c6e864b6459b404d37ed68bc2b0ebc5d3007a1be4faf60c5 + image: ghcr.io/cozystack/cozystack/openapi-ui:latest@sha256:77991f2482c0026d082582b22a8ffb191f3ba6fc948b2f125ef9b1081538f865 openapiUIK8sBff: - image: ghcr.io/cozystack/cozystack/openapi-ui-k8s-bff:latest@sha256:5ddc6546baf3acdb8e0572536665fe73053a7f985b05e51366454efa11c201d2 + image: ghcr.io/cozystack/cozystack/openapi-ui-k8s-bff:latest@sha256:8386f0747266726afb2b30db662092d66b0af0370e3becd8bee9684125fa9cc9 tokenProxy: image: ghcr.io/cozystack/cozystack/token-proxy:latest@sha256:fad27112617bb17816702571e1f39d0ac3fe5283468d25eb12f79906cdab566b diff --git a/pkg/apis/core/v1alpha1/register.go b/pkg/apis/core/v1alpha1/register.go index 39976e39..84923d29 100644 --- a/pkg/apis/core/v1alpha1/register.go +++ b/pkg/apis/core/v1alpha1/register.go @@ -59,11 +59,9 @@ func RegisterStaticTypes(scheme *runtime.Scheme) { &TenantNamespaceList{}, &TenantSecret{}, &TenantSecretList{}, - &TenantSecretsTable{}, - &TenantSecretsTableList{}, &TenantModule{}, &TenantModuleList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) - klog.V(1).Info("Registered static kinds: TenantNamespace, TenantSecret, TenantSecretsTable, TenantModule") + klog.V(1).Info("Registered static kinds: TenantNamespace, TenantSecret, TenantModule") } diff --git a/pkg/apis/core/v1alpha1/tenantsecretstable_types.go b/pkg/apis/core/v1alpha1/tenantsecretstable_types.go deleted file mode 100644 index 94119696..00000000 --- a/pkg/apis/core/v1alpha1/tenantsecretstable_types.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -package v1alpha1 - -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - -// TenantSecretEntry represents a single key from a Secret's data. -type TenantSecretEntry struct { - Name string `json:"name,omitempty"` - Key string `json:"key,omitempty"` - Value string `json:"value,omitempty"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// TenantSecretsTable is a virtual, namespaced resource that exposes every key -// of Secrets labelled cozystack.io/ui=true as a separate object. -type TenantSecretsTable struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Data TenantSecretEntry `json:"data,omitempty"` -} - -// DeepCopy methods are generated by deepcopy-gen - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -type TenantSecretsTableList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []TenantSecretsTable `json:"items"` -} - -// DeepCopy methods are generated by deepcopy-gen diff --git a/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go index 54a7f1e6..a19b1d1a 100644 --- a/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go @@ -216,22 +216,6 @@ func (in *TenantSecret) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TenantSecretEntry) DeepCopyInto(out *TenantSecretEntry) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantSecretEntry. -func (in *TenantSecretEntry) DeepCopy() *TenantSecretEntry { - if in == nil { - return nil - } - out := new(TenantSecretEntry) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TenantSecretList) DeepCopyInto(out *TenantSecretList) { *out = *in @@ -264,63 +248,3 @@ func (in *TenantSecretList) DeepCopyObject() runtime.Object { } return nil } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TenantSecretsTable) DeepCopyInto(out *TenantSecretsTable) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Data = in.Data - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantSecretsTable. -func (in *TenantSecretsTable) DeepCopy() *TenantSecretsTable { - if in == nil { - return nil - } - out := new(TenantSecretsTable) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *TenantSecretsTable) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TenantSecretsTableList) DeepCopyInto(out *TenantSecretsTableList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]TenantSecretsTable, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantSecretsTableList. -func (in *TenantSecretsTableList) DeepCopy() *TenantSecretsTableList { - if in == nil { - return nil - } - out := new(TenantSecretsTableList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *TenantSecretsTableList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index d5814ee7..ba5cee5b 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -44,7 +44,6 @@ import ( tenantmodulestorage "github.com/cozystack/cozystack/pkg/registry/core/tenantmodule" tenantnamespacestorage "github.com/cozystack/cozystack/pkg/registry/core/tenantnamespace" tenantsecretstorage "github.com/cozystack/cozystack/pkg/registry/core/tenantsecret" - tenantsecretstablestorage "github.com/cozystack/cozystack/pkg/registry/core/tenantsecretstable" ) var ( @@ -177,9 +176,6 @@ func (c completedConfig) New() (*CozyServer, error) { coreV1alpha1Storage["tenantsecrets"] = cozyregistry.RESTInPeace( tenantsecretstorage.NewREST(cli, watchCli), ) - coreV1alpha1Storage["tenantsecretstables"] = cozyregistry.RESTInPeace( - tenantsecretstablestorage.NewREST(cli, watchCli), - ) coreV1alpha1Storage["tenantmodules"] = cozyregistry.RESTInPeace( tenantmodulestorage.NewREST(cli, watchCli), ) diff --git a/pkg/generated/openapi/zz_generated.openapi.go b/pkg/generated/openapi/zz_generated.openapi.go index a88f259f..4202bbac 100644 --- a/pkg/generated/openapi/zz_generated.openapi.go +++ b/pkg/generated/openapi/zz_generated.openapi.go @@ -39,10 +39,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantNamespace": schema_pkg_apis_core_v1alpha1_TenantNamespace(ref), "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantNamespaceList": schema_pkg_apis_core_v1alpha1_TenantNamespaceList(ref), "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecret": schema_pkg_apis_core_v1alpha1_TenantSecret(ref), - "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretEntry": schema_pkg_apis_core_v1alpha1_TenantSecretEntry(ref), "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretList": schema_pkg_apis_core_v1alpha1_TenantSecretList(ref), - "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretsTable": schema_pkg_apis_core_v1alpha1_TenantSecretsTable(ref), - "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretsTableList": schema_pkg_apis_core_v1alpha1_TenantSecretsTableList(ref), "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.ConversionRequest": schema_pkg_apis_apiextensions_v1_ConversionRequest(ref), "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.ConversionResponse": schema_pkg_apis_apiextensions_v1_ConversionResponse(ref), "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.ConversionReview": schema_pkg_apis_apiextensions_v1_ConversionReview(ref), @@ -557,37 +554,6 @@ func schema_pkg_apis_core_v1alpha1_TenantSecret(ref common.ReferenceCallback) co } } -func schema_pkg_apis_core_v1alpha1_TenantSecretEntry(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TenantSecretEntry represents a single key from a Secret's data.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "name": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "key": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "value": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - func schema_pkg_apis_core_v1alpha1_TenantSecretList(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -636,95 +602,6 @@ func schema_pkg_apis_core_v1alpha1_TenantSecretList(ref common.ReferenceCallback } } -func schema_pkg_apis_core_v1alpha1_TenantSecretsTable(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TenantSecretsTable is a virtual, namespaced resource that exposes every key of Secrets labelled cozystack.io/ui=true as a separate object.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "data": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretEntry"), - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretEntry", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_core_v1alpha1_TenantSecretsTableList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretsTable"), - }, - }, - }, - }, - }, - }, - Required: []string{"items"}, - }, - }, - Dependencies: []string{ - "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1.TenantSecretsTable", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - func schema_pkg_apis_apiextensions_v1_ConversionRequest(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/registry/core/tenantsecretstable/rest.go b/pkg/registry/core/tenantsecretstable/rest.go deleted file mode 100644 index de2604ca..00000000 --- a/pkg/registry/core/tenantsecretstable/rest.go +++ /dev/null @@ -1,335 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// TenantSecretsTable registry – namespaced, read-only flattened view over -// Secrets labelled "internal.cozystack.io/tenantresource=true". Each data key is a separate object. - -package tenantsecretstable - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "sort" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metainternal "k8s.io/apimachinery/pkg/apis/meta/internalversion" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/selection" - "k8s.io/apimachinery/pkg/watch" - "k8s.io/apiserver/pkg/endpoints/request" - "k8s.io/apiserver/pkg/registry/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - - corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1" -) - -const ( - tsLabelKey = corev1alpha1.TenantResourceLabelKey - tsLabelValue = corev1alpha1.TenantResourceLabelValue - kindObj = "TenantSecretsTable" - kindObjList = "TenantSecretsTableList" - singularName = "tenantsecretstable" - resourcePlural = "tenantsecretstables" -) - -type REST struct { - c client.Client - w client.WithWatch - gvr schema.GroupVersionResource -} - -func NewREST(c client.Client, w client.WithWatch) *REST { - return &REST{ - c: c, - w: w, - gvr: schema.GroupVersionResource{ - Group: corev1alpha1.GroupName, - Version: "v1alpha1", - Resource: resourcePlural, - }, - } -} - -var ( - _ rest.Getter = &REST{} - _ rest.Lister = &REST{} - _ rest.Watcher = &REST{} - _ rest.TableConvertor = &REST{} - _ rest.Scoper = &REST{} - _ rest.SingularNameProvider = &REST{} - _ rest.Storage = &REST{} -) - -func (*REST) NamespaceScoped() bool { return true } -func (*REST) New() runtime.Object { return &corev1alpha1.TenantSecretsTable{} } -func (*REST) NewList() runtime.Object { - return &corev1alpha1.TenantSecretsTableList{} -} -func (*REST) Kind() string { return kindObj } -func (r *REST) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind { - return r.gvr.GroupVersion().WithKind(kindObj) -} -func (*REST) GetSingularName() string { return singularName } -func (*REST) Destroy() {} - -func nsFrom(ctx context.Context) (string, error) { - ns, ok := request.NamespaceFrom(ctx) - if !ok { - return "", fmt.Errorf("namespace required") - } - return ns, nil -} - -// ----------------------- -// Get/List -// ----------------------- - -func (r *REST) Get(ctx context.Context, name string, opts *metav1.GetOptions) (runtime.Object, error) { - ns, err := nsFrom(ctx) - if err != nil { - return nil, err - } - - // We need to identify secret name and key. Iterate secrets in namespace with tenant secret label - // and return the matching composed object. - list := &corev1.SecretList{} - err = r.c.List(ctx, list, - &client.ListOptions{ - Namespace: ns, - Raw: &metav1.ListOptions{ - LabelSelector: labels.Set{tsLabelKey: tsLabelValue}.AsSelector().String(), - }, - }) - if err != nil { - return nil, err - } - for i := range list.Items { - sec := &list.Items[i] - for k, v := range sec.Data { - composed := composedName(sec.Name, k) - if composed == name { - return secretKeyToObj(sec, k, v), nil - } - } - } - return nil, apierrors.NewNotFound(r.gvr.GroupResource(), name) -} - -func (r *REST) List(ctx context.Context, opts *metainternal.ListOptions) (runtime.Object, error) { - ns, err := nsFrom(ctx) - if err != nil { - return nil, err - } - - sel := labels.NewSelector() - req, _ := labels.NewRequirement(tsLabelKey, selection.Equals, []string{tsLabelValue}) - sel = sel.Add(*req) - if opts.LabelSelector != nil { - if reqs, _ := opts.LabelSelector.Requirements(); len(reqs) > 0 { - sel = sel.Add(reqs...) - } - } - fieldSel := "" - if opts.FieldSelector != nil { - fieldSel = opts.FieldSelector.String() - } - - list := &corev1.SecretList{} - err = r.c.List(ctx, list, - &client.ListOptions{ - Namespace: ns, - Raw: &metav1.ListOptions{ - LabelSelector: labels.Set{tsLabelKey: tsLabelValue}.AsSelector().String(), - FieldSelector: fieldSel, - }, - }) - if err != nil { - return nil, err - } - - out := &corev1alpha1.TenantSecretsTableList{ - TypeMeta: metav1.TypeMeta{APIVersion: corev1alpha1.SchemeGroupVersion.String(), Kind: kindObjList}, - ListMeta: list.ListMeta, - } - - for i := range list.Items { - sec := &list.Items[i] - // Ensure stable ordering of keys - keys := make([]string, 0, len(sec.Data)) - for k := range sec.Data { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - v := sec.Data[k] - o := secretKeyToObj(sec, k, v) - out.Items = append(out.Items, *o) - } - } - - sort.Slice(out.Items, func(i, j int) bool { return out.Items[i].Name < out.Items[j].Name }) - return out, nil -} - -// ----------------------- -// Watch -// ----------------------- - -func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch.Interface, error) { - ns, err := nsFrom(ctx) - if err != nil { - return nil, err - } - - secList := &corev1.SecretList{} - ls := labels.Set{tsLabelKey: tsLabelValue}.AsSelector().String() - base, err := r.w.Watch(ctx, secList, &client.ListOptions{Namespace: ns, Raw: &metav1.ListOptions{ - Watch: true, - LabelSelector: ls, - ResourceVersion: opts.ResourceVersion, - }}) - if err != nil { - return nil, err - } - - ch := make(chan watch.Event) - proxy := watch.NewProxyWatcher(ch) - - go func() { - defer proxy.Stop() - for ev := range base.ResultChan() { - sec, ok := ev.Object.(*corev1.Secret) - if !ok || sec == nil { - continue - } - // Emit an event per key - for k, v := range sec.Data { - obj := secretKeyToObj(sec, k, v) - ch <- watch.Event{Type: ev.Type, Object: obj} - } - } - }() - - return proxy, nil -} - -// ----------------------- -// TableConvertor -// ----------------------- - -func (r *REST) ConvertToTable(_ context.Context, obj runtime.Object, _ runtime.Object) (*metav1.Table, error) { - now := time.Now() - row := func(o *corev1alpha1.TenantSecretsTable) metav1.TableRow { - return metav1.TableRow{ - Cells: []interface{}{o.Name, o.Data.Name, o.Data.Key, humanAge(o.CreationTimestamp.Time, now)}, - Object: runtime.RawExtension{Object: o}, - } - } - tbl := &metav1.Table{ - TypeMeta: metav1.TypeMeta{APIVersion: "meta.k8s.io/v1", Kind: "Table"}, - ColumnDefinitions: []metav1.TableColumnDefinition{ - {Name: "NAME", Type: "string"}, - {Name: "SECRET", Type: "string"}, - {Name: "KEY", Type: "string"}, - {Name: "AGE", Type: "string"}, - }, - } - switch v := obj.(type) { - case *corev1alpha1.TenantSecretsTableList: - for i := range v.Items { - tbl.Rows = append(tbl.Rows, row(&v.Items[i])) - } - tbl.ListMeta.ResourceVersion = v.ListMeta.ResourceVersion - case *corev1alpha1.TenantSecretsTable: - tbl.Rows = append(tbl.Rows, row(v)) - tbl.ListMeta.ResourceVersion = v.ResourceVersion - default: - return nil, notAcceptable{r.gvr.GroupResource(), fmt.Sprintf("unexpected %T", obj)} - } - return tbl, nil -} - -// ----------------------- -// Helpers -// ----------------------- - -func composedName(secretName, key string) string { - return secretName + "-" + key -} - -func humanAge(t time.Time, now time.Time) string { - d := now.Sub(t) - // simple human duration - if d.Hours() >= 24 { - return fmt.Sprintf("%dd", int(d.Hours()/24)) - } - if d.Hours() >= 1 { - return fmt.Sprintf("%dh", int(d.Hours())) - } - if d.Minutes() >= 1 { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - return fmt.Sprintf("%ds", int(d.Seconds())) -} - -func secretKeyToObj(sec *corev1.Secret, key string, val []byte) *corev1alpha1.TenantSecretsTable { - return &corev1alpha1.TenantSecretsTable{ - TypeMeta: metav1.TypeMeta{APIVersion: corev1alpha1.SchemeGroupVersion.String(), Kind: kindObj}, - ObjectMeta: metav1.ObjectMeta{ - Name: sec.Name, - Namespace: sec.Namespace, - UID: sec.UID, - ResourceVersion: sec.ResourceVersion, - CreationTimestamp: sec.CreationTimestamp, - Labels: filterUserLabels(sec.Labels), - Annotations: sec.Annotations, - }, - Data: corev1alpha1.TenantSecretEntry{ - Name: sec.Name, - Key: key, - Value: toBase64String(val), - }, - } -} - -func filterUserLabels(m map[string]string) map[string]string { - if m == nil { - return nil - } - out := make(map[string]string, len(m)) - for k, v := range m { - if k == tsLabelKey { - continue - } - out[k] = v - } - return out -} - -func toBase64String(b []byte) string { - const enc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - // Minimal base64 encoder to avoid extra deps; for readability we could use stdlib encoding/base64 - // but keeping inline is fine; however using stdlib is clearer. - // Using stdlib: - return base64.StdEncoding.EncodeToString(b) -} - -type notAcceptable struct { - resource schema.GroupResource - message string -} - -func (e notAcceptable) Error() string { return e.message } -func (e notAcceptable) Status() metav1.Status { - return metav1.Status{ - Status: metav1.StatusFailure, - Code: http.StatusNotAcceptable, - Reason: metav1.StatusReason("NotAcceptable"), - Message: e.message, - } -}