Compare commits

..

1 Commits

Author SHA1 Message Date
Andrei Kvapil
156c1e8524 PoC: Move workloadmonitors to controller
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2025-11-04 19:31:31 +01:00
66 changed files with 1795 additions and 767 deletions

View File

@@ -59,6 +59,10 @@ type CozystackResourceDefinitionSpec struct {
// Dashboard configuration for this resource
Dashboard *CozystackResourceDefinitionDashboard `json:"dashboard,omitempty"`
// WorkloadMonitors configuration for this resource
// List of WorkloadMonitor templates to be created for each application instance
WorkloadMonitors []WorkloadMonitorTemplate `json:"workloadMonitors,omitempty"`
}
type CozystackResourceDefinitionChart struct {
@@ -110,17 +114,18 @@ type CozystackResourceDefinitionRelease struct {
// - {{ .namespace }}: The namespace of the resource being processed
//
// Example YAML:
// secrets:
// include:
// - matchExpressions:
// - key: badlabel
// operator: DoesNotExist
// matchLabels:
// goodlabel: goodvalue
// resourceNames:
// - "{{ .name }}-secret"
// - "{{ .kind }}-{{ .name }}-tls"
// - "specificname"
//
// secrets:
// include:
// - matchExpressions:
// - key: badlabel
// operator: DoesNotExist
// matchLabels:
// goodlabel: goodvalue
// resourceNames:
// - "{{ .name }}-secret"
// - "{{ .kind }}-{{ .name }}-tls"
// - "specificname"
type CozystackResourceDefinitionResourceSelector struct {
metav1.LabelSelector `json:",inline"`
// ResourceNames is a list of resource names to match
@@ -191,3 +196,47 @@ type CozystackResourceDefinitionDashboard struct {
// +optional
Module bool `json:"module,omitempty"`
}
// ---- WorkloadMonitor types ----
// WorkloadMonitorTemplate defines a template for creating WorkloadMonitor resources
// for application instances. Fields support Go template syntax with the following variables:
// - {{ .Release.Name }}: The name of the Helm release
// - {{ .Release.Namespace }}: The namespace of the Helm release
// - {{ .Chart.Version }}: The version of the Helm chart
// - {{ .Values.<path> }}: Any value from the Helm values
type WorkloadMonitorTemplate struct {
// Name is the name of the WorkloadMonitor.
// Supports Go template syntax (e.g., "{{ .Release.Name }}-keeper")
// +required
Name string `json:"name"`
// Kind specifies the kind of the workload (e.g., "postgres", "kafka")
// +required
Kind string `json:"kind"`
// Type specifies the type of the workload (e.g., "postgres", "zookeeper")
// +required
Type string `json:"type"`
// Selector is a map of label key-value pairs for matching workloads.
// Supports Go template syntax in values (e.g., "app.kubernetes.io/instance: {{ .Release.Name }}")
// +required
Selector map[string]string `json:"selector"`
// Replicas is a Go template expression that evaluates to the desired number of replicas.
// Example: "{{ .Values.replicas }}" or "{{ .Values.clickhouseKeeper.replicas }}"
// +optional
Replicas string `json:"replicas,omitempty"`
// MinReplicas is a Go template expression that evaluates to the minimum number of replicas.
// Example: "1" or "{{ div .Values.replicas 2 | add1 }}"
// +optional
MinReplicas string `json:"minReplicas,omitempty"`
// Condition is a Go template expression that must evaluate to "true" for the monitor to be created.
// Example: "{{ .Values.clickhouseKeeper.enabled }}"
// If empty, the monitor is always created.
// +optional
Condition string `json:"condition,omitempty"`
}

View File

@@ -244,6 +244,13 @@ func (in *CozystackResourceDefinitionSpec) DeepCopyInto(out *CozystackResourceDe
*out = new(CozystackResourceDefinitionDashboard)
(*in).DeepCopyInto(*out)
}
if in.WorkloadMonitors != nil {
in, out := &in.WorkloadMonitors, &out.WorkloadMonitors
*out = make([]WorkloadMonitorTemplate, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CozystackResourceDefinitionSpec.
@@ -461,6 +468,28 @@ func (in *WorkloadMonitorStatus) DeepCopy() *WorkloadMonitorStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WorkloadMonitorTemplate) DeepCopyInto(out *WorkloadMonitorTemplate) {
*out = *in
if in.Selector != nil {
in, out := &in.Selector, &out.Selector
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadMonitorTemplate.
func (in *WorkloadMonitorTemplate) DeepCopy() *WorkloadMonitorTemplate {
if in == nil {
return nil
}
out := new(WorkloadMonitorTemplate)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WorkloadStatus) DeepCopyInto(out *WorkloadStatus) {
*out = *in

View File

@@ -192,6 +192,14 @@ func main() {
os.Exit(1)
}
if err = (&controller.WorkloadMonitorFromCRDReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "WorkloadMonitorFromCRD")
os.Exit(1)
}
if err = (&controller.WorkloadReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),

View File

@@ -11,7 +11,6 @@ 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
@@ -46,24 +45,15 @@ 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": schema,
"schema": map[string]any{}, // {}
"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
}
@@ -83,94 +73,3 @@ 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)
}
}
}
}
}

View File

@@ -1,155 +0,0 @@
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)
}
}

View File

@@ -221,7 +221,7 @@ func workloadsTab(kind string) map[string]any {
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-v1alpha1.cozystack.io.workloadmonitors",
"pathToItems": []any{"items"},
"labelSelector": map[string]any{
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",
@@ -246,7 +246,7 @@ func servicesTab(kind string) map[string]any {
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-v1.services",
"pathToItems": []any{"items"},
"labelSelector": map[string]any{
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",
@@ -272,7 +272,7 @@ func ingressesTab(kind string) map[string]any {
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-networking.k8s.io.v1.ingresses",
"pathToItems": []any{"items"},
"labelSelector": map[string]any{
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",
@@ -293,12 +293,12 @@ 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}/tenantsecrets",
"fetchUrl": "/api/clusters/{2}/k8s/apis/core.cozystack.io/v1alpha1/namespaces/{3}/tenantsecretstables",
"clusterNamePartOfUrl": "{2}",
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-v1alpha1.core.cozystack.io.tenantsecrets",
"customizationId": "factory-details-v1alpha1.core.cozystack.io.tenantsecretstables",
"pathToItems": []any{"items"},
"labelSelector": map[string]any{
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",

View File

@@ -15,7 +15,6 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
managerpkg "sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
@@ -54,19 +53,10 @@ func NewManager(c client.Client, scheme *runtime.Scheme) *Manager {
}
func (m *Manager) SetupWithManager(mgr ctrl.Manager) error {
if err := ctrl.NewControllerManagedBy(mgr).
return ctrl.NewControllerManagedBy(mgr).
Named("dashboard-reconciler").
For(&cozyv1alpha1.CozystackResourceDefinition{}).
Complete(m); err != nil {
return err
}
return mgr.Add(managerpkg.RunnableFunc(func(ctx context.Context) error {
if !mgr.GetCache().WaitForCacheSync(ctx) {
return fmt.Errorf("dashboard static resources cache sync failed")
}
return m.ensureStaticResources(ctx)
}))
Complete(m)
}
func (m *Manager) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

View File

@@ -122,7 +122,7 @@ func createCustomColumnsOverride(id string, additionalPrinterColumns []any) *das
}
}
if name == "factory-details-v1alpha1.core.cozystack.io.tenantsecrets" {
if name == "factory-details-v1alpha1.core.cozystack.io.tenantsecretstables" {
data["additionalPrinterColumnsTrimLengths"] = []any{
map[string]any{
"key": "Name",
@@ -1046,15 +1046,6 @@ 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

View File

@@ -173,12 +173,11 @@ func CreateAllCustomColumnsOverrides() []*dashboardv1alpha1.CustomColumnsOverrid
createStringColumn("OBSERVED", ".status.observedReplicas"),
}),
// Factory details v1alpha1 core cozystack io tenantsecrets
createCustomColumnsOverride("factory-details-v1alpha1.core.cozystack.io.tenantsecrets", []any{
// Factory details v1alpha1 core cozystack io tenantsecretstables
createCustomColumnsOverride("factory-details-v1alpha1.core.cozystack.io.tenantsecretstables", []any{
createCustomColumnWithJsonPath("Name", ".metadata.name", "Secret", "", "/openapi-ui/{2}/{reqsJsonPath[0]['.metadata.namespace']['-']}/factory/kube-secret-details/{reqsJsonPath[0]['.metadata.name']['-']}"),
createFlatMapColumn("Data", ".data"),
createStringColumn("Key", "_flatMapData_Key"),
createSecretBase64Column("Value", "._flatMapData_Value"),
createStringColumn("Key", ".data.key"),
createSecretBase64Column("Value", ".data.value"),
createTimestampColumn("Created", ".metadata.creationTimestamp"),
}),
@@ -1056,7 +1055,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory {
"clusterNamePartOfUrl": "{2}",
"customizationId": "factory-kube-service-details-endpointslice",
"fetchUrl": "/api/clusters/{2}/k8s/apis/discovery.k8s.io/v1/namespaces/{3}/endpointslices",
"labelSelector": map[string]any{
"labelsSelector": map[string]any{
"kubernetes.io/service-name": "{reqsJsonPath[0]['.metadata.name']['-']}",
},
"pathToItems": ".items[*].endpoints",
@@ -1397,7 +1396,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory {
"clusterNamePartOfUrl": "{2}",
"customizationId": "factory-details-v1alpha1.cozystack.io.workloads",
"fetchUrl": "/api/clusters/{2}/k8s/apis/cozystack.io/v1alpha1/namespaces/{3}/workloads",
"labelSelector": map[string]any{
"labelsSelector": map[string]any{
"workloads.cozystack.io/monitor": "{reqs[0]['metadata','name']}",
},
"pathToItems": []any{"items"},

View File

@@ -0,0 +1,439 @@
package controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"text/template"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
"k8s.io/apimachinery/pkg/api/errors"
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/types"
"k8s.io/utils/pointer"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
// WorkloadMonitorFromCRDReconciler reconciles HelmReleases and creates WorkloadMonitors
// based on CozystackResourceDefinition templates
type WorkloadMonitorFromCRDReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch
// +kubebuilder:rbac:groups=cozystack.io,resources=cozystackresourcedefinitions,verbs=get;list;watch
// +kubebuilder:rbac:groups=cozystack.io,resources=workloadmonitors,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch
const (
WorkloadMonitorOwnerLabel = "workloadmonitor.cozystack.io/owned-by-crd"
WorkloadMonitorSourceLabel = "workloadmonitor.cozystack.io/helm-release"
)
// Reconcile processes HelmRelease resources and creates corresponding WorkloadMonitors
func (r *WorkloadMonitorFromCRDReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Get the HelmRelease
hr := &helmv2.HelmRelease{}
if err := r.Get(ctx, req.NamespacedName, hr); err != nil {
if errors.IsNotFound(err) {
// HelmRelease deleted - cleanup will be handled by owner references
return ctrl.Result{}, nil
}
logger.Error(err, "unable to fetch HelmRelease")
return ctrl.Result{}, err
}
// Skip system HelmReleases
if strings.HasPrefix(hr.Name, "tenant-") {
return ctrl.Result{}, nil
}
// Find the matching CozystackResourceDefinition
crd, err := r.findCRDForHelmRelease(ctx, hr)
if err != nil {
if errors.IsNotFound(err) {
// No CRD found for this HelmRelease - skip
logger.V(1).Info("No CozystackResourceDefinition found for HelmRelease", "name", hr.Name)
return ctrl.Result{}, nil
}
logger.Error(err, "unable to find CozystackResourceDefinition")
return ctrl.Result{}, err
}
// If CRD doesn't have WorkloadMonitors, cleanup any existing ones we created
if len(crd.Spec.WorkloadMonitors) == 0 {
if err := r.cleanupWorkloadMonitors(ctx, hr); err != nil {
logger.Error(err, "failed to cleanup WorkloadMonitors")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// Get the HelmRelease values for template rendering
values, err := r.getHelmReleaseValues(ctx, hr)
if err != nil {
logger.Error(err, "unable to get HelmRelease values")
return ctrl.Result{}, err
}
// Create/update WorkloadMonitors based on templates
if err := r.reconcileWorkloadMonitors(ctx, hr, crd, values); err != nil {
logger.Error(err, "failed to reconcile WorkloadMonitors")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// findCRDForHelmRelease finds the CozystackResourceDefinition for a given HelmRelease
func (r *WorkloadMonitorFromCRDReconciler) findCRDForHelmRelease(ctx context.Context, hr *helmv2.HelmRelease) (*cozyv1alpha1.CozystackResourceDefinition, error) {
// List all CozystackResourceDefinitions
var crdList cozyv1alpha1.CozystackResourceDefinitionList
if err := r.List(ctx, &crdList); err != nil {
return nil, err
}
// Match by chart name and prefix
for i := range crdList.Items {
crd := &crdList.Items[i]
if crd.Spec.Release.Chart.Name == hr.Spec.Chart.Spec.Chart {
// Check if HelmRelease name matches the prefix
if strings.HasPrefix(hr.Name, crd.Spec.Release.Prefix) {
return crd, nil
}
}
}
return nil, errors.NewNotFound(schema.GroupResource{Group: "cozystack.io", Resource: "cozystackresourcedefinitions"}, "")
}
// getHelmReleaseValues extracts the values from HelmRelease spec
func (r *WorkloadMonitorFromCRDReconciler) getHelmReleaseValues(ctx context.Context, hr *helmv2.HelmRelease) (map[string]interface{}, error) {
if hr.Spec.Values == nil {
return make(map[string]interface{}), nil
}
// Convert apiextensionsv1.JSON to map
values := make(map[string]interface{})
if err := json.Unmarshal(hr.Spec.Values.Raw, &values); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
}
return values, nil
}
// reconcileWorkloadMonitors creates or updates WorkloadMonitors based on CRD templates
func (r *WorkloadMonitorFromCRDReconciler) reconcileWorkloadMonitors(
ctx context.Context,
hr *helmv2.HelmRelease,
crd *cozyv1alpha1.CozystackResourceDefinition,
values map[string]interface{},
) error {
logger := log.FromContext(ctx)
// Get chart version from HelmRelease
chartVersion := ""
if hr.Status.History != nil && len(hr.Status.History) > 0 {
chartVersion = hr.Status.History[0].ChartVersion
}
// Template context
templateData := map[string]interface{}{
"Release": map[string]interface{}{
"Name": hr.Name,
"Namespace": hr.Namespace,
},
"Chart": map[string]interface{}{
"Version": chartVersion,
},
"Values": values,
}
// Track which monitors we should have
expectedMonitors := make(map[string]bool)
// Process each WorkloadMonitor template
for _, tmpl := range crd.Spec.WorkloadMonitors {
// Check condition
if tmpl.Condition != "" {
shouldCreate, err := evaluateCondition(tmpl.Condition, templateData)
if err != nil {
logger.Error(err, "failed to evaluate condition", "template", tmpl.Name, "condition", tmpl.Condition)
continue
}
if !shouldCreate {
logger.V(1).Info("Skipping WorkloadMonitor due to condition", "template", tmpl.Name)
continue
}
}
// Render monitor name
monitorName, err := renderTemplate(tmpl.Name, templateData)
if err != nil {
logger.Error(err, "failed to render monitor name", "template", tmpl.Name)
continue
}
expectedMonitors[monitorName] = true
// Render selector values
selector := make(map[string]string)
for key, valueTmpl := range tmpl.Selector {
renderedValue, err := renderTemplate(valueTmpl, templateData)
if err != nil {
logger.Error(err, "failed to render selector value", "key", key, "template", valueTmpl)
continue
}
selector[key] = renderedValue
}
// Render replicas
var replicas *int32
if tmpl.Replicas != "" {
replicasStr, err := renderTemplate(tmpl.Replicas, templateData)
if err != nil {
logger.Error(err, "failed to render replicas", "template", tmpl.Replicas)
} else {
if replicasInt, err := strconv.ParseInt(replicasStr, 10, 32); err == nil {
replicas = pointer.Int32(int32(replicasInt))
}
}
}
// Render minReplicas
var minReplicas *int32
if tmpl.MinReplicas != "" {
minReplicasStr, err := renderTemplate(tmpl.MinReplicas, templateData)
if err != nil {
logger.Error(err, "failed to render minReplicas", "template", tmpl.MinReplicas)
} else {
if minReplicasInt, err := strconv.ParseInt(minReplicasStr, 10, 32); err == nil {
minReplicas = pointer.Int32(int32(minReplicasInt))
}
}
}
// Create or update WorkloadMonitor
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: monitorName,
Namespace: hr.Namespace,
},
}
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, monitor, func() error {
// Set labels
if monitor.Labels == nil {
monitor.Labels = make(map[string]string)
}
monitor.Labels[WorkloadMonitorOwnerLabel] = "true"
monitor.Labels[WorkloadMonitorSourceLabel] = hr.Name
// Set owner reference to HelmRelease for automatic cleanup
if err := controllerutil.SetControllerReference(hr, monitor, r.Scheme); err != nil {
return err
}
// Update spec
monitor.Spec.Selector = selector
monitor.Spec.Kind = tmpl.Kind
monitor.Spec.Type = tmpl.Type
monitor.Spec.Version = chartVersion
monitor.Spec.Replicas = replicas
monitor.Spec.MinReplicas = minReplicas
return nil
})
if err != nil {
logger.Error(err, "failed to create/update WorkloadMonitor", "name", monitorName)
continue
}
logger.V(1).Info("WorkloadMonitor reconciled", "name", monitorName)
}
// Cleanup WorkloadMonitors that are no longer in templates
if err := r.cleanupUnexpectedMonitors(ctx, hr, expectedMonitors); err != nil {
logger.Error(err, "failed to cleanup unexpected WorkloadMonitors")
return err
}
return nil
}
// cleanupWorkloadMonitors removes all WorkloadMonitors created for a HelmRelease
func (r *WorkloadMonitorFromCRDReconciler) cleanupWorkloadMonitors(ctx context.Context, hr *helmv2.HelmRelease) error {
return r.cleanupUnexpectedMonitors(ctx, hr, make(map[string]bool))
}
// cleanupUnexpectedMonitors removes WorkloadMonitors that are no longer expected
func (r *WorkloadMonitorFromCRDReconciler) cleanupUnexpectedMonitors(
ctx context.Context,
hr *helmv2.HelmRelease,
expectedMonitors map[string]bool,
) error {
logger := log.FromContext(ctx)
// List all WorkloadMonitors in the namespace that we created
var monitorList cozyv1alpha1.WorkloadMonitorList
labelSelector := labels.SelectorFromSet(labels.Set{
WorkloadMonitorOwnerLabel: "true",
WorkloadMonitorSourceLabel: hr.Name,
})
if err := r.List(ctx, &monitorList,
client.InNamespace(hr.Namespace),
client.MatchingLabelsSelector{Selector: labelSelector},
); err != nil {
return err
}
// Delete monitors that are not expected
for i := range monitorList.Items {
monitor := &monitorList.Items[i]
if !expectedMonitors[monitor.Name] {
logger.Info("Deleting unexpected WorkloadMonitor", "name", monitor.Name)
if err := r.Delete(ctx, monitor); err != nil && !errors.IsNotFound(err) {
logger.Error(err, "failed to delete WorkloadMonitor", "name", monitor.Name)
}
}
}
return nil
}
// renderTemplate renders a Go template string with the given data
func renderTemplate(tmplStr string, data interface{}) (string, error) {
// Check if it's already a simple value (no template markers)
if !strings.Contains(tmplStr, "{{") {
return tmplStr, nil
}
// Add Sprig functions for compatibility with Helm templates
tmpl, err := template.New("").Funcs(getTemplateFuncs()).Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return strings.TrimSpace(buf.String()), nil
}
// evaluateCondition evaluates a template condition (should return "true" or non-empty for true)
func evaluateCondition(condition string, data interface{}) (bool, error) {
result, err := renderTemplate(condition, data)
if err != nil {
return false, err
}
// Check for truthy values
result = strings.TrimSpace(strings.ToLower(result))
return result == "true" || result == "1" || result == "yes", nil
}
// getTemplateFuncs returns template functions compatible with Helm
func getTemplateFuncs() template.FuncMap {
return template.FuncMap{
// Math functions
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int {
if b == 0 {
return 0
}
return a / b
},
"add1": func(a int) int { return a + 1 },
"sub1": func(a int) int { return a - 1 },
// String functions
"upper": strings.ToUpper,
"lower": strings.ToLower,
"trim": strings.TrimSpace,
"trimAll": func(cutset, s string) string { return strings.Trim(s, cutset) },
"replace": func(old, new string, n int, s string) string { return strings.Replace(s, old, new, n) },
// Logic functions
"default": func(defaultVal, val interface{}) interface{} {
if val == nil || val == "" {
return defaultVal
}
return val
},
"empty": func(val interface{}) bool {
return val == nil || val == ""
},
"not": func(val bool) bool {
return !val
},
}
}
// SetupWithManager sets up the controller with the Manager
func (r *WorkloadMonitorFromCRDReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("workloadmonitor-from-crd-controller").
For(&helmv2.HelmRelease{}).
Owns(&cozyv1alpha1.WorkloadMonitor{}).
Watches(
&cozyv1alpha1.CozystackResourceDefinition{},
handler.EnqueueRequestsFromMapFunc(r.mapCRDToHelmReleases),
).
Complete(r)
}
// mapCRDToHelmReleases maps CRD changes to HelmRelease reconcile requests
func (r *WorkloadMonitorFromCRDReconciler) mapCRDToHelmReleases(ctx context.Context, obj client.Object) []reconcile.Request {
crd, ok := obj.(*cozyv1alpha1.CozystackResourceDefinition)
if !ok {
return nil
}
// List all HelmReleases
var hrList helmv2.HelmReleaseList
if err := r.List(ctx, &hrList); err != nil {
return nil
}
var requests []reconcile.Request
for i := range hrList.Items {
hr := &hrList.Items[i]
// Skip tenant HelmReleases
if strings.HasPrefix(hr.Name, "tenant-") {
continue
}
// Match by chart name and prefix
if crd.Spec.Release.Chart.Name == hr.Spec.Chart.Spec.Chart {
if strings.HasPrefix(hr.Name, crd.Spec.Release.Prefix) {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: hr.Name,
Namespace: hr.Namespace,
},
})
}
}
}
return requests
}

View File

@@ -1,28 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: clickhouse
type: clickhouse
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}
{{- if .Values.clickhouseKeeper.enabled }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}-keeper
spec:
replicas: {{ .Values.clickhouseKeeper.replicas }}
minReplicas: 1
kind: clickhouse
type: clickhouse
selector:
app: {{ $.Release.Name }}-keeper
version: {{ $.Chart.Version }}
{{- end }}

View File

@@ -1,13 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: ferretdb
type: ferretdb
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -1,20 +0,0 @@
{{- if .Values.monitoring.enabled }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ .Release.Name }}
labels:
app.kubernetes.io/name: foundationdb
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
replicas: {{ .Values.cluster.processCounts.storage }}
minReplicas: {{ include "foundationdb.minReplicas" . }}
kind: foundationdb
type: foundationdb
selector:
foundationdb.org/fdb-cluster-name: {{ .Release.Name }}
foundationdb.org/fdb-process-class: storage
version: {{ .Chart.Version }}
{{- end }}

View File

@@ -1,39 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}-haproxy
spec:
replicas: {{ .Values.haproxy.replicas }}
minReplicas: 1
kind: http-cache
type: http-cache
selector:
app: {{ $.Release.Name }}-haproxy
version: {{ $.Chart.Version }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}-nginx
spec:
replicas: {{ .Values.nginx.replicas }}
minReplicas: 1
kind: http-cache
type: http-cache
selector:
app: {{ $.Release.Name }}-nginx-cache
version: {{ $.Chart.Version }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: http-cache
type: http-cache
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -1,30 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: kafka
type: kafka
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}
app.kubernetes.io/name: kafka
version: {{ $.Chart.Version }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}-zookeeper
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: kafka
type: zookeeper
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}
app.kubernetes.io/name: zookeeper
version: {{ $.Chart.Version }}

View File

@@ -1,76 +0,0 @@
---
apiVersion: batch/v1
kind: Job
metadata:
annotations:
"helm.sh/hook": post-delete
"helm.sh/hook-weight": "10"
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation,hook-failed
name: {{ .Release.Name }}-datavolume-cleanup
spec:
template:
spec:
serviceAccountName: {{ .Release.Name }}-datavolume-cleanup
restartPolicy: Never
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: "NoSchedule"
containers:
- name: kubectl
image: docker.io/clastix/kubectl:v1.32
command:
- /bin/sh
- -c
- kubectl -n {{ .Release.Namespace }} delete datavolumes
-l "cluster.x-k8s.io/cluster-name={{ .Release.Name }}"
--ignore-not-found=true
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Release.Name }}-datavolume-cleanup
annotations:
helm.sh/hook: post-delete
helm.sh/hook-delete-policy: before-hook-creation,hook-failed,hook-succeeded
helm.sh/hook-weight: "0"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
annotations:
"helm.sh/hook": post-delete
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation,hook-failed
"helm.sh/hook-weight": "5"
name: {{ .Release.Name }}-datavolume-cleanup
rules:
- apiGroups:
- "cdi.kubevirt.io"
resources:
- datavolumes
verbs:
- get
- list
- delete
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
annotations:
"helm.sh/hook": post-delete
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation,hook-failed
"helm.sh/hook-weight": "5"
name: {{ .Release.Name }}-datavolume-cleanup
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ .Release.Name }}-datavolume-cleanup
subjects:
- kind: ServiceAccount
name: {{ .Release.Name }}-datavolume-cleanup
namespace: {{ .Release.Namespace }}

View File

@@ -24,26 +24,26 @@ spec:
command:
- /bin/sh
- -c
- >-
kubectl
--namespace={{ .Release.Namespace }}
patch
helmrelease
{{ .Release.Name }}-cilium
{{ .Release.Name }}-gateway-api-crds
{{ .Release.Name }}-csi
{{ .Release.Name }}-cert-manager
{{ .Release.Name }}-cert-manager-crds
{{ .Release.Name }}-vertical-pod-autoscaler
{{ .Release.Name }}-vertical-pod-autoscaler-crds
{{ .Release.Name }}-ingress-nginx
{{ .Release.Name }}-fluxcd-operator
{{ .Release.Name }}-fluxcd
{{ .Release.Name }}-gpu-operator
{{ .Release.Name }}-velero
{{ .Release.Name }}-coredns
-p '{"spec": {"suspend": true}}'
--type=merge --field-manager=flux-client-side-apply || true
- |
kubectl
--namespace={{ .Release.Namespace }}
patch
helmrelease
{{ .Release.Name }}-cilium
{{ .Release.Name }}-gateway-api-crds
{{ .Release.Name }}-csi
{{ .Release.Name }}-cert-manager
{{ .Release.Name }}-cert-manager-crds
{{ .Release.Name }}-vertical-pod-autoscaler
{{ .Release.Name }}-vertical-pod-autoscaler-crds
{{ .Release.Name }}-ingress-nginx
{{ .Release.Name }}-fluxcd-operator
{{ .Release.Name }}-fluxcd
{{ .Release.Name }}-gpu-operator
{{ .Release.Name }}-velero
{{ .Release.Name }}-coredns
-p '{"spec": {"suspend": true}}'
--type=merge --field-manager=flux-client-side-apply || true
---
apiVersion: v1
kind: ServiceAccount
@@ -51,7 +51,7 @@ metadata:
name: {{ .Release.Name }}-flux-teardown
annotations:
helm.sh/hook: pre-delete
helm.sh/hook-delete-policy: before-hook-creation,hook-failed,hook-succeeded
helm.sh/hook-delete-policy: before-hook-creation,hook-failed
helm.sh/hook-weight: "0"
---
apiVersion: rbac.authorization.k8s.io/v1
@@ -75,7 +75,6 @@ rules:
- {{ .Release.Name }}-csi
- {{ .Release.Name }}-cert-manager
- {{ .Release.Name }}-cert-manager-crds
- {{ .Release.Name }}-gateway-api-crds
- {{ .Release.Name }}-vertical-pod-autoscaler
- {{ .Release.Name }}-vertical-pod-autoscaler-crds
- {{ .Release.Name }}-ingress-nginx

View File

@@ -1,13 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: mysql
type: mysql
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -1,14 +1,6 @@
{{- $cozyConfig := lookup "v1" "ConfigMap" "cozy-system" "cozystack" }}
{{- $clusterDomain := (index $cozyConfig.data "cluster-domain") | default "cozy.local" }}
{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace (printf "%s-credentials" .Release.Name) }}
{{- $passwords := dict }}
{{- with (dig "data" (dict) $existingSecret) }}
{{- range $k, $v := . }}
{{- $_ := set $passwords $k (b64dec $v) }}
{{- end }}
{{- end }}
{{- range $user, $u := .Values.users }}
{{- if $u.password }}
{{- $_ := set $passwords $user $u.password }}

View File

@@ -1,13 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: nats
type: nats
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}-system
version: {{ $.Chart.Version }}

View File

@@ -79,17 +79,3 @@ spec:
policy.cozystack.io/allow-to-apiserver: "true"
app.kubernetes.io/name: postgres.apps.cozystack.io
app.kubernetes.io/instance: {{ $.Release.Name }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: postgres
type: postgres
selector:
app.kubernetes.io/name: postgres.apps.cozystack.io
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -1,13 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: rabbitmq
type: rabbitmq
selector:
app.kubernetes.io/name: {{ $.Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -68,34 +68,3 @@ spec:
auth:
secretPath: {{ .Release.Name }}-auth
{{- end }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}-redis
namespace: {{ $.Release.Namespace }}
spec:
minReplicas: 1
replicas: {{ .Values.replicas }}
kind: redis
type: redis
selector:
app.kubernetes.io/component: redis
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}-sentinel
namespace: {{ $.Release.Namespace }}
spec:
minReplicas: 2
replicas: 3
kind: redis
type: sentinel
selector:
app.kubernetes.io/component: sentinel
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -1,13 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: tcp-balancer
type: haproxy
selector:
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -20,7 +20,11 @@ metadata:
name: allow-external-communication
namespace: {{ include "tenant.name" . }}
spec:
endpointSelector: {}
endpointSelector:
matchExpressions:
- key: policy.cozystack.io/allow-external-communication
operator: NotIn
values: ["false"]
ingress:
- fromEntities:
- world

View File

@@ -35,6 +35,7 @@ rules:
resources:
- tenantmodules
- tenantsecrets
- tenantsecretstables
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
@@ -192,6 +193,7 @@ rules:
resources:
- tenantmodules
- tenantsecrets
- tenantsecretstables
verbs: ["get", "list", "watch"]
---
kind: RoleBinding
@@ -291,6 +293,7 @@ rules:
resources:
- tenantmodules
- tenantsecrets
- tenantsecretstables
verbs: ["get", "list", "watch"]
---
kind: RoleBinding
@@ -365,6 +368,7 @@ rules:
resources:
- tenantmodules
- tenantsecrets
- tenantsecretstables
verbs: ["get", "list", "watch"]
---
kind: RoleBinding

View File

@@ -28,3 +28,27 @@ spec:
{{- end }}
{{- end }}
{{- end }}
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: {{ include "virtual-machine.fullname" . }}
spec:
endpointSelector:
matchLabels:
{{- include "virtual-machine.selectorLabels" . | nindent 6 }}
ingress:
- fromEntities:
- cluster
- fromEntities:
- world
{{- if eq .Values.externalMethod "PortList" }}
toPorts:
- ports:
{{- range .Values.externalPorts }}
- port: {{ quote . }}
{{- end }}
{{- end }}
egress:
- toEntities:
- world

View File

@@ -62,6 +62,7 @@ spec:
template:
metadata:
annotations:
policy.cozystack.io/allow-external-communication: "false"
kubevirt.io/allow-pod-bridge-network-live-migration: "true"
labels:
{{- include "virtual-machine.labels" . | nindent 8 }}

View File

@@ -1,12 +0,0 @@
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: 0
minReplicas: 0
kind: vm-disk
type: vm-disk
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -28,3 +28,27 @@ spec:
{{- end }}
{{- end }}
{{- end }}
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: {{ include "virtual-machine.fullname" . }}
spec:
endpointSelector:
matchLabels:
{{- include "virtual-machine.selectorLabels" . | nindent 6 }}
ingress:
- fromEntities:
- cluster
- fromEntities:
- world
{{- if eq .Values.externalMethod "PortList" }}
toPorts:
- ports:
{{- range .Values.externalPorts }}
- port: {{ quote . }}
{{- end }}
{{- end }}
egress:
- toEntities:
- world

View File

@@ -26,6 +26,7 @@ spec:
template:
metadata:
annotations:
policy.cozystack.io/allow-external-communication: "false"
kubevirt.io/allow-pod-bridge-network-live-migration: "true"
labels:
{{- include "virtual-machine.labels" . | nindent 8 }}

View File

@@ -5,13 +5,13 @@ As the service evolves, it will provide more ways to isolate your workloads.
## Service details
To function, the service requires kube-ovn and multus CNI to be present, so by default it will only work on `paas-full` bundle.
Kube-ovn provides VPC and Subnet resources and performs isolation and networking maintenance such as DHCP. Under the hood it uses ovn virtual routers and virtual switches.
Multus enables a multi-nic capability, so a pod or a VM could have two or more network interfaces.
Currently every workload will have a connection to a default management network which will also have a default gateway, and the majority of traffic will go through it.
The service utilizes kube-ovn VPC and Subnet resources, which use ovn logical routers and logical switches under the hood.
Currently every workload will have a connection to a default management network which will also have a default gateway, and the majority of traffic will be going through it.
VPC subnets are for now an additional dedicated networking spaces.
A VM or a pod may be connected to multiple secondary Subnets at once.
Each secondary connection will be represented as an additional network interface.
## Deployment notes
VPC name must be unique within a tenant.
@@ -21,8 +21,6 @@ Currently there are no fail-safe checks, however they are planned for the future
Different VPCs may have subnets with ovelapping ip address ranges.
A VM or a pod may be connected to multiple secondary Subnets at once. Each secondary connection will be represented as an additional network interface.
## Parameters
### Common parameters

View File

@@ -63,10 +63,10 @@ metadata:
cozystack.io/vpcId: {{ $vpcId }}
cozystack.io/tenantName: {{ $.Release.Namespace }}
data:
{{- range $subnetName, $subnetConfig := .Values.subnets }}
{{ $subnetName }}: |-
subnetName: {{ $subnetName }}
subnetId: {{ print "subnet-" (print $.Release.Namespace "/" $vpcId "/" $subnetName | sha256sum | trunc 8) }}
subnetCIDR: {{ $subnetConfig.cidr }}
{{- end }}
subnets: |
{{- range $subnetName, $subnetConfig := .Values.subnets }}
- subnetName: {{ $subnetName }}
subnetId: {{ print "subnet-" (print $.Release.Namespace "/" $vpcId "/" $subnetName | sha256sum | trunc 8) }}
subnetCIDR: {{ $subnetConfig.cidr }}
{{- end }}

View File

@@ -1,12 +0,0 @@
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: 1
kind: vpn
type: vpn
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
version: {{ $.Chart.Version }}

View File

@@ -1,16 +0,0 @@
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
namespace: {{ $.Release.Namespace }}
spec:
replicas: {{ .Values.replicas }}
minReplicas: {{ div .Values.replicas 2 | add1 }}
kind: ingress
type: controller
selector:
app.kubernetes.io/component: controller
app.kubernetes.io/instance: ingress-nginx-system
app.kubernetes.io/name: ingress-nginx
version: {{ $.Chart.Version }}

View File

@@ -154,7 +154,7 @@
{{- $resources := index . 1 }}
{{- $global := index . 2 }}
{{- $presetMap := include "cozy-lib.resources.unsanitizedPreset" $preset | fromYaml }}
{{- $mergedMap := deepCopy (default (dict) $resources) | mergeOverwrite $presetMap }}
{{- $mergedMap := deepCopy $resources | mergeOverwrite $presetMap }}
{{- include "cozy-lib.resources.sanitize" (list $mergedMap $global) }}
{{- end }}

View File

@@ -671,6 +671,62 @@ spec:
x-kubernetes-map-type: atomic
type: array
type: object
workloadMonitors:
description: |-
WorkloadMonitors configuration for this resource
List of WorkloadMonitor templates to be created for each application instance
items:
description: |-
WorkloadMonitorTemplate defines a template for creating WorkloadMonitor resources
for application instances. Fields support Go template syntax with the following variables:
- {{ .Release.Name }}: The name of the Helm release
- {{ .Release.Namespace }}: The namespace of the Helm release
- {{ .Chart.Version }}: The version of the Helm chart
- {{ .Values.<path> }}: Any value from the Helm values
properties:
condition:
description: |-
Condition is a Go template expression that must evaluate to "true" for the monitor to be created.
Example: "{{ .Values.clickhouseKeeper.enabled }}"
If empty, the monitor is always created.
type: string
kind:
description: Kind specifies the kind of the workload (e.g.,
"postgres", "kafka")
type: string
minReplicas:
description: |-
MinReplicas is a Go template expression that evaluates to the minimum number of replicas.
Example: "1" or "{{ div .Values.replicas 2 | add1 }}"
type: string
name:
description: |-
Name is the name of the WorkloadMonitor.
Supports Go template syntax (e.g., "{{ .Release.Name }}-keeper")
type: string
replicas:
description: |-
Replicas is a Go template expression that evaluates to the desired number of replicas.
Example: "{{ .Values.replicas }}" or "{{ .Values.clickhouseKeeper.replicas }}"
type: string
selector:
additionalProperties:
type: string
description: |-
Selector is a map of label key-value pairs for matching workloads.
Supports Go template syntax in values (e.g., "app.kubernetes.io/instance: {{ .Release.Name }}")
type: object
type:
description: Type specifies the type of the workload (e.g.,
"postgres", "zookeeper")
type: string
required:
- kind
- name
- selector
- type
type: object
type: array
required:
- application
- release

View File

@@ -37,3 +37,19 @@ spec:
include:
- resourceNames:
- chendpoint-clickhouse-{{ .name }}
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: clickhouse
type: clickhouse
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"
- name: "{{ .Release.Name }}-keeper"
kind: clickhouse
type: clickhouse
selector:
app: "{{ .Release.Name }}-keeper"
replicas: "{{ .Values.clickhouseKeeper.replicas }}"
minReplicas: "1"
condition: "{{ .Values.clickhouseKeeper.enabled }}"

View File

@@ -38,3 +38,11 @@ spec:
include:
- resourceNames:
- ferretdb-{{ .name }}
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: ferretdb
type: ferretdb
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -28,3 +28,13 @@ spec:
- database
icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX3JhZGlhbF84NThfMzA3MikiLz4KPHBhdGggZD0iTTEzNS43ODQgNzUuNjQ0NkwxMzUuOTM5IDg3Ljc2MzhMODkuNjg0NiA4MS41MzYyTDYyLjA4NjggODQuNTA3OUwzNS4zNDE3IDgxLjQzMjlMOC43NTE2NyA4NC41ODU0TDguNzI1ODMgODEuNTEwNEwzNS4zNjc2IDc3LjU4MjZWNjQuMTcxM0w2Mi4yOTM1IDcwLjczNDhMNjIuMzQ1MiA4MS4yNzc4TDg5LjQ3NzkgNzcuNjg2TDg5LjQwMDQgNjQuMTk3MkwxMzUuNzg0IDc1LjY0NDZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNODkuNDc3OCA4Ni4wMzI1TDEzNS44ODggOTAuODM4OFYxMDIuNzI2SDguNjQ4MjVMOC41MTkwNCA5OS41NzNIMzUuMjY0MUMzNS4yNjQxIDk5LjU3MyAzNS4yNjQxIDkwLjczNTUgMzUuMjY0MSA4Ni4wNTgzQzQ0LjI1NjcgODYuOTM2OSA2Mi4wODY3IDg4LjY5NDEgNjIuMDg2NyA4OC42OTQxVjk5LjI2MjlIODkuNDc3OFY4Ni4wMzI1WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYyLjI5MzQgNjYuODg0Nkw2Mi4yMTU4IDYzLjYyODZDNjIuMjE1OCA2My42Mjg2IDc5LjgxMzMgNTguMzU3MSA4OC45MDkyIDU1LjY2OTdDODguOTA5MiA1MS4zMDI2IDg4LjkwOTIgNDcuMDkwNiA4OC45MDkyIDQyQzEwNC44NzkgNDguNDA4NSAxMjAuMjI4IDU0LjYxMDIgMTM1LjczMyA2MC44Mzc4QzEzNS43MzMgNjQuNzEzOSAxMzUuNzMzIDY4LjQzNSAxMzUuNzMzIDcyLjU2OTVDMTE5Ljg0MSA2OC4yMDI0IDEwNC4yODQgNjMuOTEyOSA4OS4xNjc2IDU5Ljc1MjVDNzkuOTY4NCA2Mi4yMDc0IDYyLjI5MzQgNjYuODg0NiA2Mi4yOTM0IDY2Ljg4NDZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzUuMzk2MiA4MS43MDczTDguODA2MTIgODQuODU5OEw4Ljc4MDI3IDgxLjc4NDhMMzUuNDIyIDc3Ljg1N1Y2NC40NDU3TDYyLjM0OCA3MS4wMDkzTDYyLjM5OTYgODEuNTUyMkw4OS41MzIzIDc3Ljk2MDRMODkuNDU0OCA2NC40NzE2TDEzNS44MzkgNzUuOTE5TDEzNS45OTQgODguMDM4Mkw4OS43MzkxIDgxLjgxMDZMNjIuMTQxMiA4NC43ODIzTDM1LjM5NjIgODEuNzA3M1oiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik04OS41MzIzIDg2LjMwNjlMMTM1Ljk0MiA5MS4xMTMzVjEwM0g4LjcwMjdMOC41NzM0OSA5OS44NDc0SDM1LjMxODZDMzUuMzE4NiA5OS44NDc0IDM1LjMxODYgOTEuMDA5OSAzNS4zMTg2IDg2LjMzMjhDNDQuMzExMSA4Ny4yMTE0IDYyLjE0MTIgODguOTY4NSA2Mi4xNDEyIDg4Ljk2ODVWOTkuNTM3M0g4OS41MzIzVjg2LjMwNjlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNjIuMzQ4MyA2Ny4xNTlMNjIuMjcwOCA2My45MDMxQzYyLjI3MDggNjMuOTAzMSA3OS44NjgyIDU4LjYzMTYgODguOTY0MiA1NS45NDQyQzg4Ljk2NDIgNTEuNTc3MSA4OC45NjQyIDQ3LjM2NTEgODguOTY0MiA0Mi4yNzQ0QzEwNC45MzQgNDguNjgyOSAxMjAuMjgzIDU0Ljg4NDcgMTM1Ljc4NyA2MS4xMTIzQzEzNS43ODcgNjQuOTg4NCAxMzUuNzg3IDY4LjcwOTQgMTM1Ljc4NyA3Mi44NDM5QzExOS44OTUgNjguNDc2OSAxMDQuMzM5IDY0LjE4NzMgODkuMjIyNiA2MC4wMjdDODAuMDIzMyA2Mi40ODE4IDYyLjM0ODMgNjcuMTU5IDYyLjM0ODMgNjcuMTU5WiIgZmlsbD0id2hpdGUiLz4KPGRlZnM+CjxyYWRpYWxHcmFkaWVudCBpZD0icGFpbnQwX3JhZGlhbF84NThfMzA3MiIgY3g9IjAiIGN5PSIwIiByPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09InRyYW5zbGF0ZSgtMjkuNSAtMTgpIHJvdGF0ZSgzOS42OTYzKSBzY2FsZSgzMDIuMTY4IDI3NS4yNzEpIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0JFRERGRiIvPgo8c3RvcCBvZmZzZXQ9IjAuMjU5NjE1IiBzdG9wLWNvbG9yPSIjOUVDQ0ZEIi8+CjxzdG9wIG9mZnNldD0iMC41OTEzNDYiIHN0b3AtY29sb3I9IiMzRjlBRkIiLz4KPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMEI3MEUwIi8+CjwvcmFkaWFsR3JhZGllbnQ+CjwvZGVmcz4KPC9zdmc+Cg==
# keysOrder: []
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: foundationdb
type: foundationdb
selector:
foundationdb.org/fdb-cluster-name: "{{ .Release.Name }}"
foundationdb.org/fdb-process-class: storage
replicas: "{{ .Values.cluster.processCounts.storage }}"
minReplicas: "{{ include \"foundationdb.minReplicas\" . }}"
condition: "{{ .Values.monitoring.enabled }}"

View File

@@ -32,3 +32,25 @@ spec:
secrets:
exclude: []
include: []
workloadMonitors:
- name: "{{ .Release.Name }}-haproxy"
kind: http-cache
type: http-cache
selector:
app: "{{ .Release.Name }}-haproxy"
replicas: "{{ .Values.haproxy.replicas }}"
minReplicas: "1"
- name: "{{ .Release.Name }}-nginx"
kind: http-cache
type: http-cache
selector:
app: "{{ .Release.Name }}-nginx-cache"
replicas: "{{ .Values.nginx.replicas }}"
minReplicas: "1"
- name: "{{ .Release.Name }}"
kind: http-cache
type: http-cache
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -37,3 +37,13 @@ spec:
include:
- resourceNames:
- "{{ slice .namespace 7 }}-ingress-controller"
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: ingress
type: controller
selector:
app.kubernetes.io/component: controller
app.kubernetes.io/instance: ingress-nginx-system
app.kubernetes.io/name: ingress-nginx
replicas: "{{ .Values.replicas }}"
minReplicas: "{{ div .Values.replicas 2 | add1 }}"

View File

@@ -38,3 +38,20 @@ spec:
include:
- resourceNames:
- kafka-{{ .name }}-kafka-bootstrap
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: kafka
type: kafka
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
app.kubernetes.io/name: kafka
replicas: "{{ .Values.kafka.replicas }}"
minReplicas: "1"
- name: "{{ .Release.Name }}-zookeeper"
kind: kafka
type: zookeeper
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
app.kubernetes.io/name: zookeeper
replicas: "{{ .Values.zookeeper.replicas }}"
minReplicas: "1"

View File

@@ -39,3 +39,11 @@ spec:
- resourceNames:
- mysql-{{ .name }}-primary
- mysql-{{ .name }}-secondary
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: mysql
type: mysql
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -38,3 +38,11 @@ spec:
include:
- resourceNames:
- nats-{{ .name }}
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: nats
type: nats
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}-system"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -49,3 +49,12 @@ spec:
- postgres-{{ .name }}-ro
- postgres-{{ .name }}-rw
- postgres-{{ .name }}-external-write
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: postgres
type: postgres
selector:
app.kubernetes.io/name: postgres.apps.cozystack.io
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -40,3 +40,11 @@ spec:
include:
- resourceNames:
- rabbitmq-{{ .name }}
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: rabbitmq
type: rabbitmq
selector:
app.kubernetes.io/name: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -41,3 +41,20 @@ spec:
- rfrm-redis-{{ .name }}
- rfrs-redis-{{ .name }}
- redis-{{ .name }}-external-lb
workloadMonitors:
- name: "{{ .Release.Name }}-redis"
kind: redis
type: redis
selector:
app.kubernetes.io/component: redis
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"
- name: "{{ .Release.Name }}-sentinel"
kind: redis
type: sentinel
selector:
app.kubernetes.io/component: sentinel
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "3"
minReplicas: "2"

View File

@@ -31,3 +31,11 @@ spec:
secrets:
exclude: []
include: []
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: tcp-balancer
type: haproxy
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -32,3 +32,11 @@ spec:
secrets:
exclude: []
include: []
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: vm-disk
type: vm-disk
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "0"
minReplicas: "0"

View File

@@ -38,3 +38,11 @@ spec:
include:
- resourceNames:
- vpn-{{ .name }}-vpn
workloadMonitors:
- name: "{{ .Release.Name }}"
kind: vpn
type: vpn
selector:
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: "{{ .Values.replicas }}"
minReplicas: "1"

View File

@@ -3,7 +3,7 @@ ARG NODE_VERSION=20.18.1
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /src
ARG COMMIT_REF=ba56271739505284aee569f914fc90e6a9c670da
ARG COMMIT_REF=92906a7f21050cfb8e352f98d36b209c57844f63
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

View File

@@ -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=7bd5380c6c4606640dd3bac68bf9dce469470518
ARG COMMIT=7086a2d8a07dcf6a94bb4276433db5d84acfcf3b
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=0c3629b2ce8545e81f7ece4d65372a188c802dfc
ARG COMMIT_REF=fe237518348e94cead6d4f3283b2fce27f26aa12
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

View File

@@ -0,0 +1,230 @@
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<TFormObjectFromSwaggerProps> = ({
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<TFormStringInputProps> = ({
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<TFormStringInputProps> = ({
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)
+ }}
>
<Input.TextArea
placeholder={getStringByName(name)}
diff --git a/src/components/molecules/BlackholeForm/organisms/BlackholeForm/helpers/casts.ts b/src/components/molecules/BlackholeForm/organisms/BlackholeForm/helpers/casts.ts
index 6f9eb39..835224c 100644
--- a/src/components/molecules/BlackholeForm/organisms/BlackholeForm/helpers/casts.ts
+++ b/src/components/molecules/BlackholeForm/organisms/BlackholeForm/helpers/casts.ts
@@ -124,8 +124,26 @@ export const materializeAdditionalFromValues = (
*
* This is used when a new field appears in the data but doesn't yet exist in the schema.
*/
- const makeChildFromAP = (ap: any): OpenAPIV2.SchemaObject => {
- 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)
}}
>
<PlusIcon />

View File

@@ -0,0 +1,91 @@
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) {

View File

@@ -0,0 +1,15 @@
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: <ThemeSelector />,
// },
- {
- key: '2',
- label: <div onClick={() => navigate(`${baseprefix}/inside/clusters`)}>Inside</div>,
- },
{
key: '3',
label: (

View File

@@ -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: "false"
value: "TRUE"
- name: PORT
value: "64231"
image: {{ .Values.openapiUIK8sBff.image | quote }}
@@ -94,8 +94,6 @@ spec:
- env:
- name: BASEPREFIX
value: /openapi-ui
- name: HIDE_INSIDE
value: "true"
- name: CUSTOMIZATION_API_GROUP
value: dashboard.cozystack.io
- name: CUSTOMIZATION_API_VERSION

View File

@@ -1,6 +1,6 @@
openapiUI:
image: ghcr.io/cozystack/cozystack/openapi-ui:latest@sha256:77991f2482c0026d082582b22a8ffb191f3ba6fc948b2f125ef9b1081538f865
image: ghcr.io/cozystack/cozystack/openapi-ui:latest@sha256:b942d98ff0ea36e3c6e864b6459b404d37ed68bc2b0ebc5d3007a1be4faf60c5
openapiUIK8sBff:
image: ghcr.io/cozystack/cozystack/openapi-ui-k8s-bff:latest@sha256:8386f0747266726afb2b30db662092d66b0af0370e3becd8bee9684125fa9cc9
image: ghcr.io/cozystack/cozystack/openapi-ui-k8s-bff:latest@sha256:5ddc6546baf3acdb8e0572536665fe73053a7f985b05e51366454efa11c201d2
tokenProxy:
image: ghcr.io/cozystack/cozystack/token-proxy:latest@sha256:fad27112617bb17816702571e1f39d0ac3fe5283468d25eb12f79906cdab566b

View File

@@ -22,13 +22,7 @@ spec:
- GPU
- VMExport
evictionStrategy: LiveMigrate
vmRolloutStrategy: LiveUpdate
workloadUpdateStrategy:
workloadUpdateMethods:
- LiveMigrate
- Evict
batchEvictionInterval: 1m
batchEvictionSize: 10
customizeComponents: {}
imagePullPolicy: IfNotPresent
monitorNamespace: tenant-root
workloadUpdateStrategy: {}

View File

@@ -59,9 +59,11 @@ 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, TenantModule")
klog.V(1).Info("Registered static kinds: TenantNamespace, TenantSecret, TenantSecretsTable, TenantModule")
}

View File

@@ -0,0 +1,34 @@
// 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

View File

@@ -216,6 +216,22 @@ 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
@@ -248,3 +264,63 @@ 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
}

View File

@@ -44,6 +44,7 @@ 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 (
@@ -176,6 +177,9 @@ 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),
)

View File

@@ -39,7 +39,10 @@ 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),
@@ -554,6 +557,37 @@ 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{
@@ -602,6 +636,95 @@ 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{

View File

@@ -9,7 +9,7 @@ import (
"encoding/base64"
"fmt"
"net/http"
"slices"
"sort"
"time"
corev1 "k8s.io/api/core/v1"
@@ -226,9 +226,6 @@ func (r *REST) Get(
if err != nil {
return nil, err
}
if sec.Labels == nil || sec.Labels[tsLabelKey] != tsLabelValue {
return nil, apierrors.NewNotFound(r.gvr.GroupResource(), name)
}
return secretToTenant(sec), nil
}
@@ -256,13 +253,11 @@ func (r *REST) List(ctx context.Context, opts *metainternal.ListOptions) (runtim
list := &corev1.SecretList{}
err = r.c.List(ctx, list,
&client.ListOptions{
Namespace: ns,
LabelSelector: ls,
Namespace: ns,
Raw: &metav1.ListOptions{
LabelSelector: ls.String(),
FieldSelector: fieldSel,
},
})
}})
if err != nil {
return nil, err
}
@@ -278,17 +273,7 @@ func (r *REST) List(ctx context.Context, opts *metainternal.ListOptions) (runtim
for i := range list.Items {
out.Items = append(out.Items, *secretToTenant(&list.Items[i]))
}
slices.SortFunc(out.Items, func(a, b corev1alpha1.TenantSecret) int {
aKey := fmt.Sprintf("%s/%s", a.Namespace, a.Name)
bKey := fmt.Sprintf("%s/%s", b.Namespace, b.Name)
switch {
case aKey < bKey:
return -1
case aKey > bKey:
return 1
}
return 0
})
sort.Slice(out.Items, func(i, j int) bool { return out.Items[i].Name < out.Items[j].Name })
return out, nil
}
@@ -306,17 +291,10 @@ func (r *REST) Update(
return nil, false, err
}
var cur *corev1.Secret
previous := &corev1.Secret{}
if err := r.c.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, previous, &client.GetOptions{Raw: &metav1.GetOptions{}}); err != nil {
if !apierrors.IsNotFound(err) {
return nil, false, err
}
} else {
if previous.Labels == nil || previous.Labels[tsLabelKey] != tsLabelValue {
return nil, false, apierrors.NewNotFound(r.gvr.GroupResource(), name)
}
cur = previous
cur := &corev1.Secret{}
err = r.c.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, cur, &client.GetOptions{Raw: &metav1.GetOptions{}})
if err != nil && !apierrors.IsNotFound(err) {
return nil, false, err
}
newObj, err := objInfo.UpdatedObject(ctx, nil)
@@ -328,7 +306,7 @@ func (r *REST) Update(
newSec := tenantToSecret(in, cur)
newSec.Namespace = ns
if cur == nil {
if !forceCreate {
if !forceCreate && err == nil {
return nil, false, apierrors.NewNotFound(r.gvr.GroupResource(), name)
}
err := r.c.Create(ctx, newSec, &client.CreateOptions{Raw: &metav1.CreateOptions{}})
@@ -350,13 +328,6 @@ func (r *REST) Delete(
if err != nil {
return nil, false, err
}
current := &corev1.Secret{}
if err := r.c.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, current, &client.GetOptions{Raw: &metav1.GetOptions{}}); err != nil {
return nil, false, err
}
if current.Labels == nil || current.Labels[tsLabelKey] != tsLabelValue {
return nil, false, apierrors.NewNotFound(r.gvr.GroupResource(), name)
}
err = r.c.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}}, &client.DeleteOptions{Raw: opts})
return nil, err == nil, err
}
@@ -376,13 +347,6 @@ func (r *REST) Patch(
if err != nil {
return nil, err
}
current := &corev1.Secret{}
if err := r.c.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, current, &client.GetOptions{Raw: &metav1.GetOptions{}}); err != nil {
return nil, err
}
if current.Labels == nil || current.Labels[tsLabelKey] != tsLabelValue {
return nil, apierrors.NewNotFound(r.gvr.GroupResource(), name)
}
out := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: ns,
@@ -419,16 +383,12 @@ func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch
}
secList := &corev1.SecretList{}
ls := labels.Set{tsLabelKey: tsLabelValue}.AsSelector()
base, err := r.w.Watch(ctx, secList, &client.ListOptions{
Namespace: ns,
LabelSelector: ls,
Raw: &metav1.ListOptions{
Watch: true,
LabelSelector: ls.String(),
ResourceVersion: opts.ResourceVersion,
},
})
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
}

View File

@@ -0,0 +1,335 @@
// 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,
}
}