Skip to content

Commit f924185

Browse files
Add schema validation in controller
1 parent ecc5054 commit f924185

7 files changed

Lines changed: 216 additions & 12 deletions

File tree

api/v1alpha1/virtualcluster_types.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,9 @@ type VirtualClusterSpec struct {
3333
}
3434

3535
type HelmChart struct {
36-
// Name is the name of the helm chart
37-
// +default:value="vcluster"
38-
Name string
39-
40-
// Repo is the name of the helm chart repository
41-
// +default:value="https://charts.loft.sh"
42-
Repo string
43-
4436
// Version is the version of the helm chart
4537
// +default:value="v0.24.1"
46-
Version string
38+
Version string `json:"version,omitempty"`
4739
}
4840

4941
type Values struct {

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/core.openvc.dev_virtualclusters.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ spec:
4949
spec:
5050
description: VirtualClusterSpec defines the desired state of VirtualCluster.
5151
properties:
52+
chart:
53+
properties:
54+
version:
55+
default: v0.24.1
56+
description: Version is the version of the helm chart
57+
type: string
58+
type: object
5259
values:
5360
x-kubernetes-preserve-unknown-fields: true
5461
type: object

config/samples/core_v1alpha1_virtualcluster.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ metadata:
44
name: sample-vcluster
55
namespace: default
66
spec:
7+
chart:
8+
version: v0.24.1
79
values:
810
exportKubeConfig:
911
server: https://sample-vcluster.cluster.openvc.dev:443

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ require (
8585
github.com/ulikunitz/xz v0.5.11 // indirect
8686
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
8787
github.com/x448/float16 v0.8.4 // indirect
88+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
89+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
90+
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
8891
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect
8992
go.opentelemetry.io/otel v1.30.0 // indirect
9093
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,12 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
222222
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
223223
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
224224
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
225+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
226+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
227+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
228+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
229+
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
230+
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
225231
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
226232
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
227233
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

internal/controller/virtualcluster_controller.go

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"io"
24+
"net/http"
2325
"os"
2426
"os/exec"
2527
"path/filepath"
2628
"strings"
2729

30+
"github.com/xeipuuv/gojsonschema"
2831
corev1 "k8s.io/api/core/v1"
2932
"k8s.io/apimachinery/pkg/api/errors"
3033
"k8s.io/apimachinery/pkg/api/meta"
@@ -54,6 +57,7 @@ const (
5457
VirtualClusterConditionAvailable = "Available"
5558
VirtualClusterConditionDeploying = "Deploying"
5659
VirtualClusterConditionError = "Error"
60+
VirtualClusterConditionValidated = "SchemaValidated"
5761
)
5862

5963
// VirtualClusterReconciler reconciles a VirtualCluster object
@@ -298,7 +302,7 @@ func (r *VirtualClusterReconciler) createValuesFile(ctx context.Context, vcluste
298302
logger := log.FromContext(ctx)
299303

300304
// Convert the values to YAML
301-
valuesYAML, err := yaml.Marshal(vcluster.Spec.Values.Config)
305+
valuesYAML, err := yaml.Marshal(vcluster.Spec.Values)
302306
if err != nil {
303307
logger.Error(err, "Failed to marshal values to YAML")
304308
return "", err
@@ -333,6 +337,58 @@ func (r *VirtualClusterReconciler) installOrUpgradeVCluster(ctx context.Context,
333337
releaseName := vcluster.Name
334338
namespace := vcluster.Namespace
335339

340+
// Get the chart version from spec if provided, otherwise use default
341+
chartVersion := vclusterVersion
342+
if vcluster.Spec.Chart.Version != "" {
343+
chartVersion = vcluster.Spec.Chart.Version
344+
logger.Info("Using chart version from spec", "version", chartVersion)
345+
} else {
346+
logger.Info("Using default chart version", "version", chartVersion)
347+
}
348+
349+
// Ensure schema ConfigMap exists
350+
schemaData, err := r.ensureSchemaConfigMap(ctx, vcluster, chartVersion)
351+
if err != nil {
352+
logger.Error(err, "Failed to ensure schema ConfigMap exists")
353+
return err
354+
}
355+
356+
// Validate values against schema if we have the schema
357+
if schemaData != "" {
358+
if err := r.validateValuesAgainstSchema(ctx, vcluster, schemaData); err != nil {
359+
logger.Error(err, "Schema validation failed")
360+
// Don't return error, we continue but update the status
361+
meta.SetStatusCondition(&vcluster.Status.Conditions, metav1.Condition{
362+
Type: VirtualClusterConditionValidated,
363+
Status: metav1.ConditionFalse,
364+
Reason: "ValidationFailed",
365+
Message: fmt.Sprintf("Values schema validation failed: %v", err),
366+
})
367+
368+
if vcluster.Status.Message == "" || !strings.Contains(vcluster.Status.Message, "Schema validation") {
369+
oldMessage := vcluster.Status.Message
370+
vcluster.Status.Message = fmt.Sprintf("Schema validation failed, helm install might fail: %v. %s", err, oldMessage)
371+
}
372+
373+
err := r.Status().Update(ctx, vcluster)
374+
if err != nil {
375+
logger.Error(err, "Failed to update status with validation error")
376+
}
377+
} else {
378+
// Update the status to indicate successful validation
379+
meta.SetStatusCondition(&vcluster.Status.Conditions, metav1.Condition{
380+
Type: VirtualClusterConditionValidated,
381+
Status: metav1.ConditionTrue,
382+
Reason: "ValidationSucceeded",
383+
Message: "Values successfully validated against schema",
384+
})
385+
err := r.Status().Update(ctx, vcluster)
386+
if err != nil {
387+
logger.Error(err, "Failed to update status with validation success")
388+
}
389+
}
390+
}
391+
336392
// Add the vCluster repo if not exists
337393
addRepoCmd := exec.Command("helm", "repo", "add", "loft", vclusterRepo)
338394
if output, err := addRepoCmd.CombinedOutput(); err != nil {
@@ -354,7 +410,7 @@ func (r *VirtualClusterReconciler) installOrUpgradeVCluster(ctx context.Context,
354410
"helm", "upgrade",
355411
releaseName,
356412
fmt.Sprintf("loft/%s", vclusterChart),
357-
"--version", vclusterVersion,
413+
"--version", chartVersion,
358414
"--namespace", namespace,
359415
"--values", valuesFile,
360416
)
@@ -365,7 +421,7 @@ func (r *VirtualClusterReconciler) installOrUpgradeVCluster(ctx context.Context,
365421
"helm", "install",
366422
releaseName,
367423
fmt.Sprintf("loft/%s", vclusterChart),
368-
"--version", vclusterVersion,
424+
"--version", chartVersion,
369425
"--namespace", namespace,
370426
"--create-namespace",
371427
"--values", valuesFile,
@@ -383,6 +439,128 @@ func (r *VirtualClusterReconciler) installOrUpgradeVCluster(ctx context.Context,
383439
return nil
384440
}
385441

442+
// ensureSchemaConfigMap ensures that a ConfigMap with the schema for the specified version exists
443+
// Returns the schema data if available, empty string otherwise
444+
func (r *VirtualClusterReconciler) ensureSchemaConfigMap(ctx context.Context, vcluster *corev1alpha1.VirtualCluster, version string) (string, error) {
445+
logger := log.FromContext(ctx)
446+
447+
// Define ConfigMap name based on version
448+
configMapName := fmt.Sprintf("vcluster-schema-%s", strings.ReplaceAll(version, ".", "-"))
449+
configMapNamespace := vcluster.Namespace
450+
451+
// Check if ConfigMap already exists
452+
configMap := &corev1.ConfigMap{}
453+
err := r.Get(ctx, client.ObjectKey{Namespace: configMapNamespace, Name: configMapName}, configMap)
454+
if err == nil {
455+
logger.Info("Schema ConfigMap already exists", "name", configMapName, "namespace", configMapNamespace)
456+
return configMap.Data["values.schema.json"], nil
457+
}
458+
459+
if !errors.IsNotFound(err) {
460+
return "", err
461+
}
462+
463+
// ConfigMap doesn't exist, fetch schema and create it
464+
logger.Info("Fetching schema for vCluster version", "version", version)
465+
466+
// Construct URL to fetch schema
467+
schemaURL := fmt.Sprintf("https://github.com/loft-sh/vcluster/releases/download/%s/values.schema.json", version)
468+
469+
// Fetch schema
470+
resp, err := http.Get(schemaURL)
471+
if err != nil {
472+
logger.Error(err, "Failed to fetch schema", "url", schemaURL)
473+
// Don't return error, we'll continue without the schema
474+
logger.Info("Continuing without schema ConfigMap")
475+
return "", nil
476+
}
477+
defer resp.Body.Close()
478+
479+
if resp.StatusCode != http.StatusOK {
480+
logger.Error(fmt.Errorf("HTTP status code: %d", resp.StatusCode), "Failed to fetch schema", "url", schemaURL)
481+
// Don't return error, we'll continue without the schema
482+
logger.Info("Continuing without schema ConfigMap")
483+
return "", nil
484+
}
485+
486+
// Read schema content
487+
schemaContent, err := io.ReadAll(resp.Body)
488+
if err != nil {
489+
logger.Error(err, "Failed to read schema content")
490+
// Don't return error, we'll continue without the schema
491+
logger.Info("Continuing without schema ConfigMap")
492+
return "", nil
493+
}
494+
495+
// Create ConfigMap
496+
newConfigMap := &corev1.ConfigMap{
497+
ObjectMeta: metav1.ObjectMeta{
498+
Name: configMapName,
499+
Namespace: configMapNamespace,
500+
Labels: map[string]string{
501+
"app.kubernetes.io/managed-by": "openvc-controller",
502+
"app.kubernetes.io/name": "vcluster-schema",
503+
"app.kubernetes.io/version": version,
504+
},
505+
},
506+
Data: map[string]string{
507+
"values.schema.json": string(schemaContent),
508+
},
509+
}
510+
511+
// Set owner reference
512+
if err := ctrl.SetControllerReference(vcluster, newConfigMap, r.Scheme); err != nil {
513+
logger.Error(err, "Failed to set controller reference on ConfigMap")
514+
// Don't return error, we'll continue without the schema
515+
logger.Info("Continuing without schema ConfigMap")
516+
return "", nil
517+
}
518+
519+
// Create ConfigMap
520+
if err := r.Create(ctx, newConfigMap); err != nil {
521+
logger.Error(err, "Failed to create schema ConfigMap")
522+
// Don't return error, we'll continue without the schema
523+
logger.Info("Continuing without schema ConfigMap")
524+
return "", nil
525+
}
526+
527+
logger.Info("Created schema ConfigMap", "name", configMapName, "namespace", configMapNamespace)
528+
return string(schemaContent), nil
529+
}
530+
531+
// validateValuesAgainstSchema validates the values against the schema
532+
func (r *VirtualClusterReconciler) validateValuesAgainstSchema(ctx context.Context, vcluster *corev1alpha1.VirtualCluster, schemaData string) error {
533+
logger := log.FromContext(ctx)
534+
logger.Info("Validating values against schema")
535+
536+
// Convert the values to JSON for validation
537+
valuesJSON, err := json.Marshal(vcluster.Spec.Values)
538+
if err != nil {
539+
return fmt.Errorf("failed to marshal values to JSON: %w", err)
540+
}
541+
542+
// Create schema and document loaders
543+
schemaLoader := gojsonschema.NewStringLoader(schemaData)
544+
documentLoader := gojsonschema.NewBytesLoader(valuesJSON)
545+
546+
// Validate
547+
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
548+
if err != nil {
549+
return fmt.Errorf("validation failed: %w", err)
550+
}
551+
552+
if !result.Valid() {
553+
var errors []string
554+
for _, desc := range result.Errors() {
555+
errors = append(errors, desc.String())
556+
}
557+
return fmt.Errorf("schema validation errors: %s", strings.Join(errors, "; "))
558+
}
559+
560+
logger.Info("Values successfully validated against schema")
561+
return nil
562+
}
563+
386564
// helmReleaseExists checks if a Helm release exists
387565
func (r *VirtualClusterReconciler) helmReleaseExists(ctx context.Context, vcluster *corev1alpha1.VirtualCluster) (bool, error) {
388566
logger := log.FromContext(ctx)

0 commit comments

Comments
 (0)