diff --git a/apps/operator/internal/controller/database_resources.go b/apps/operator/internal/controller/database_resources.go index e314004..e363578 100644 --- a/apps/operator/internal/controller/database_resources.go +++ b/apps/operator/internal/controller/database_resources.go @@ -15,10 +15,13 @@ import ( "math/big" "strings" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -42,6 +45,21 @@ const ( // DatabaseTraitType is the trait type identifier for database traits DatabaseTraitType = "database" + + // DefaultPostgresVersion is the default Postgres image tag when not specified. + DefaultPostgresVersion = "16" + + // DefaultPostgresPort is the default port for Postgres. + DefaultPostgresPort = 5432 + + // DefaultDatabaseStorage is the default PVC size for database volumes. + DefaultDatabaseStorage = "1Gi" + + // PostgresDataPath is the mount path for Postgres data directory. + PostgresDataPath = "/var/lib/postgresql/data" + + // PostgresDataSubPath is the subPath within the PVC to avoid lost+found issues. + PostgresDataSubPath = "pgdata" ) // DatabaseCredentials holds generated database credentials @@ -278,6 +296,344 @@ func (r *HeliosAppReconciler) reconcileDatabaseSecrets(ctx context.Context, app return nil } +// reconcileDatabaseInstance provisions database StatefulSets and headless +// Services for components with database traits. This runs AFTER +// reconcileDatabaseSecrets so that the credential Secret already exists +// when the StatefulSet is created. +func (r *HeliosAppReconciler) reconcileDatabaseInstance(ctx context.Context, app *appv1alpha1.HeliosApp) error { + log := logf.FromContext(ctx) + + dbTraits := ExtractDatabaseTraits(app) + if len(dbTraits) == 0 { + log.V(1).Info("No database traits found, skipping instance provisioning") + return nil + } + + for _, dbTrait := range dbTraits { + // Only provision postgres instances for now + if strings.ToLower(dbTrait.Properties.DBType) != "postgres" { + log.V(1).Info("Skipping non-postgres database type", + "component", dbTrait.ComponentName, + "dbType", dbTrait.Properties.DBType) + continue + } + + dbHost := GetDatabaseHost(dbTrait.ComponentName) + secretName := GetDatabaseSecretName(dbTrait.ComponentName) + + // Determine effective database name + effectiveDBName := dbTrait.Properties.DBName + if effectiveDBName == "" { + effectiveDBName = fmt.Sprintf("%s-db", dbTrait.ComponentName) + } + + // Determine version — CUE schema requires version!, but we + // guard here defensively in case of direct API usage. + version := dbTrait.Properties.Version + if version == "" { + version = DefaultPostgresVersion + } + + // Determine port + port := dbTrait.Properties.Port + if port <= 0 { + port = DefaultPostgresPort + } + + // Determine storage + storage := dbTrait.Properties.Storage + if storage == "" { + storage = DefaultDatabaseStorage + } + + // --- StatefulSet --- + sts, err := GenerateDatabaseStatefulSet( + app.Namespace, dbHost, secretName, effectiveDBName, version, storage, int32(port), + ) + if err != nil { + log.Error(err, "Failed to generate database StatefulSet", + "component", dbTrait.ComponentName, "storage", storage) + return fmt.Errorf("failed to generate StatefulSet for %s: %w", dbHost, err) + } + + if err := ctrl.SetControllerReference(app, sts, r.Scheme); err != nil { + log.Error(err, "Failed to set owner reference for database StatefulSet", + "component", dbTrait.ComponentName) + return fmt.Errorf("failed to set owner reference for StatefulSet %s: %w", dbHost, err) + } + + existingSts := &appsv1.StatefulSet{} + err = r.Get(ctx, types.NamespacedName{Name: dbHost, Namespace: app.Namespace}, existingSts) + if err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to check for StatefulSet %s: %w", dbHost, err) + } + + log.Info("Creating database StatefulSet", + "component", dbTrait.ComponentName, + "statefulset", dbHost, + "image", fmt.Sprintf("postgres:%s", version)) + + if err := r.Create(ctx, sts); err != nil { + if errors.IsAlreadyExists(err) { + log.Info("Database StatefulSet was created concurrently, skipping", + "component", dbTrait.ComponentName) + } else { + return fmt.Errorf("failed to create StatefulSet %s: %w", dbHost, err) + } + } + } else { + // Handle StatefulSet drift: update spec to match the new template + log.Info("Database StatefulSet already exists, updating if necessary", + "component", dbTrait.ComponentName, + "statefulset", dbHost) + + // We only update the mutable fields (Replicas, Template) + updatedSts := existingSts.DeepCopy() + updatedSts.Spec.Replicas = sts.Spec.Replicas + updatedSts.Spec.Template = sts.Spec.Template + + // We need to preserve the existing VolumeClaimTemplates when updating + updatedSts.Spec.VolumeClaimTemplates = existingSts.Spec.VolumeClaimTemplates + + if err := r.Update(ctx, updatedSts); err != nil { + return fmt.Errorf("failed to update StatefulSet %s: %w", dbHost, err) + } + } + + // --- Headless Service --- + svc := GenerateDatabaseService(app.Namespace, dbHost, int32(port)) + + if err := ctrl.SetControllerReference(app, svc, r.Scheme); err != nil { + log.Error(err, "Failed to set owner reference for database Service", + "component", dbTrait.ComponentName) + return fmt.Errorf("failed to set owner reference for Service %s: %w", dbHost, err) + } + + existingSvc := &corev1.Service{} + err = r.Get(ctx, types.NamespacedName{Name: dbHost, Namespace: app.Namespace}, existingSvc) + if err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to check for Service %s: %w", dbHost, err) + } + + log.Info("Creating database headless Service", + "component", dbTrait.ComponentName, + "service", dbHost) + + if err := r.Create(ctx, svc); err != nil { + if errors.IsAlreadyExists(err) { + log.Info("Database Service was created concurrently, skipping", + "component", dbTrait.ComponentName) + } else { + return fmt.Errorf("failed to create Service %s: %w", dbHost, err) + } + } + } else { + log.Info("Database Service already exists, updating if necessary", + "component", dbTrait.ComponentName, + "service", dbHost) + + updatedSvc := existingSvc.DeepCopy() + updatedSvc.Spec.Ports = svc.Spec.Ports + + if err := r.Update(ctx, updatedSvc); err != nil { + return fmt.Errorf("failed to update Service %s: %w", dbHost, err) + } + } + + log.Info("Successfully reconciled database instance", + "component", dbTrait.ComponentName, + "statefulset", dbHost, + "dbName", effectiveDBName) + } + + return nil +} + +// GenerateDatabaseStatefulSet creates a StatefulSet for a Postgres database instance. +// The StatefulSet injects POSTGRES_DB from the CRD's database.name value, and +// uses the Secret from Issue #33 for POSTGRES_USER and POSTGRES_PASSWORD. +func GenerateDatabaseStatefulSet(namespace, name, secretName, dbName, version, storage string, port int32) (*appsv1.StatefulSet, error) { + storageQty, err := resource.ParseQuantity(storage) + if err != nil { + return nil, fmt.Errorf("invalid storage size format %q: %w", storage, err) + } + + replicas := int32(1) + labels := map[string]string{ + "app": name, + "helios.io/managed-by": "operator", + "helios.io/trait": "database", + "helios.io/db-type": "postgres", + } + + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: name, + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "postgres", + Image: fmt.Sprintf("postgres:%s", version), + Ports: []corev1.ContainerPort{ + { + ContainerPort: port, + Name: "postgres", + }, + }, + Env: []corev1.EnvVar{ + { + Name: "POSTGRES_DB", + Value: dbName, + }, + { + Name: "POSTGRES_USER", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretName, + }, + Key: "DB_USER", + }, + }, + }, + { + Name: "POSTGRES_PASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretName, + }, + Key: "DB_PASS", + }, + }, + }, + { + // PGDATA tells Postgres where to store cluster data. + // Must match volumeMount + subPath to avoid lost+found conflicts. + Name: "PGDATA", + Value: PostgresDataPath + "/" + PostgresDataSubPath, + }, + { + // Ensure consistent UTF-8 encoding for all databases. + Name: "POSTGRES_INITDB_ARGS", + Value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C", + }, + { + // Explicitly set the custom port so that postgres knows to listen on it. + Name: "PGPORT", + Value: fmt.Sprintf("%d", port), + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "data", + MountPath: PostgresDataPath, + SubPath: PostgresDataSubPath, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resourceMustParse("100m"), + corev1.ResourceMemory: resourceMustParse("256Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resourceMustParse("500m"), + corev1.ResourceMemory: resourceMustParse("512Mi"), + }, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: []string{"pg_isready", "-U", "$(POSTGRES_USER)", "-d", dbName, "-p", "$(PGPORT)"}, + }, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 10, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: []string{"pg_isready", "-U", "$(POSTGRES_USER)", "-d", dbName, "-p", "$(PGPORT)"}, + }, + }, + InitialDelaySeconds: 30, + PeriodSeconds: 10, + }, + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storageQty, + }, + }, + }, + }, + }, + }, + }, nil +} + +// GenerateDatabaseService creates a headless Service for a database StatefulSet. +// The headless Service (clusterIP: None) provides stable DNS resolution +// so resolves to the database pod. +func GenerateDatabaseService(namespace, name string, port int32) *corev1.Service { + labels := map[string]string{ + "app": name, + "helios.io/managed-by": "operator", + "helios.io/trait": "database", + } + + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "None", + Selector: map[string]string{"app": name}, + Ports: []corev1.ServicePort{ + { + Port: port, + TargetPort: intstr.FromInt32(port), + Name: "db", + }, + }, + }, + } +} + +// resourceMustParse is a helper to parse resource quantities. Panics on invalid input. +func resourceMustParse(s string) resource.Quantity { + return resource.MustParse(s) +} + // GenerateBase64Token generates a random base64-encoded token // Useful for generating secure webhook secrets or API tokens func GenerateBase64Token(byteLength int) (string, error) { diff --git a/apps/operator/internal/controller/database_resources_test.go b/apps/operator/internal/controller/database_resources_test.go index 41e5384..42e09c3 100644 --- a/apps/operator/internal/controller/database_resources_test.go +++ b/apps/operator/internal/controller/database_resources_test.go @@ -3,8 +3,10 @@ package controller import ( "context" "encoding/json" + "strings" "testing" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -525,3 +527,424 @@ func TestGenerateBase64Token(t *testing.T) { }) } } + +func TestGenerateDatabaseStatefulSet(t *testing.T) { + sts, err := GenerateDatabaseStatefulSet( + "test-ns", "my-app-db", "my-app-db-secret", + "my_custom_db", "16", "2Gi", 5432, + ) + + if err != nil { + t.Fatalf("GenerateDatabaseStatefulSet failed: %v", err) + } + + // Verify metadata + if sts.Name != "my-app-db" { + t.Errorf("Expected name %q, got %q", "my-app-db", sts.Name) + } + if sts.Namespace != "test-ns" { + t.Errorf("Expected namespace %q, got %q", "test-ns", sts.Namespace) + } + + // Verify labels + if sts.Labels["helios.io/db-type"] != "postgres" { + t.Errorf("Expected db-type label %q, got %q", "postgres", sts.Labels["helios.io/db-type"]) + } + if sts.Labels["helios.io/trait"] != "database" { + t.Errorf("Expected trait label %q, got %q", "database", sts.Labels["helios.io/trait"]) + } + + // Verify replicas + if *sts.Spec.Replicas != 1 { + t.Errorf("Expected 1 replica, got %d", *sts.Spec.Replicas) + } + + // Verify serviceName + if sts.Spec.ServiceName != "my-app-db" { + t.Errorf("Expected serviceName %q, got %q", "my-app-db", sts.Spec.ServiceName) + } + + // Verify container + containers := sts.Spec.Template.Spec.Containers + if len(containers) != 1 { + t.Fatalf("Expected 1 container, got %d", len(containers)) + } + + container := containers[0] + if container.Image != "postgres:16" { + t.Errorf("Expected image %q, got %q", "postgres:16", container.Image) + } + + // Verify ports + if len(container.Ports) != 1 || container.Ports[0].ContainerPort != 5432 { + t.Errorf("Expected container port 5432, got %v", container.Ports) + } + // Verify POSTGRES_DB env var (the core acceptance criteria) + foundPostgresDB := false + for _, env := range container.Env { + if env.Name == "POSTGRES_DB" { + foundPostgresDB = true + if env.Value != "my_custom_db" { + t.Errorf("Expected POSTGRES_DB value %q, got %q", "my_custom_db", env.Value) + } + } + if env.Name == "POSTGRES_USER" { + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Error("POSTGRES_USER should reference a secret") + } else { + if env.ValueFrom.SecretKeyRef.Name != "my-app-db-secret" { + t.Errorf("Expected secret name %q, got %q", + "my-app-db-secret", env.ValueFrom.SecretKeyRef.Name) + } + if env.ValueFrom.SecretKeyRef.Key != "DB_USER" { + t.Errorf("Expected secret key %q, got %q", + "DB_USER", env.ValueFrom.SecretKeyRef.Key) + } + } + } + if env.Name == "POSTGRES_PASSWORD" { + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Error("POSTGRES_PASSWORD should reference a secret") + } else { + if env.ValueFrom.SecretKeyRef.Name != "my-app-db-secret" { + t.Errorf("Expected secret name %q, got %q", + "my-app-db-secret", env.ValueFrom.SecretKeyRef.Name) + } + if env.ValueFrom.SecretKeyRef.Key != "DB_PASS" { + t.Errorf("Expected secret key %q, got %q", + "DB_PASS", env.ValueFrom.SecretKeyRef.Key) + } + } + } + } + if !foundPostgresDB { + t.Error("POSTGRES_DB env var not found in container") + } + + // Verify PGDATA env var + foundPGDATA := false + for _, env := range container.Env { + if env.Name == "PGDATA" { + foundPGDATA = true + expectedPGDATA := PostgresDataPath + "/" + PostgresDataSubPath + if env.Value != expectedPGDATA { + t.Errorf("Expected PGDATA value %q, got %q", expectedPGDATA, env.Value) + } + } + } + if !foundPGDATA { + t.Error("PGDATA env var not found in container") + } + + // Verify POSTGRES_INITDB_ARGS env var + foundInitDB := false + for _, env := range container.Env { + if env.Name == "POSTGRES_INITDB_ARGS" { + foundInitDB = true + } + } + if !foundInitDB { + t.Error("POSTGRES_INITDB_ARGS env var not found in container") + } + + // Verify PGPORT env var + foundPGPORT := false + for _, env := range container.Env { + if env.Name == "PGPORT" { + foundPGPORT = true + if env.Value != "5432" { + t.Errorf("Expected PGPORT value %q, got %q", "5432", env.Value) + } + } + } + if !foundPGPORT { + t.Error("PGPORT env var not found in container") + } + + // Verify livenessProbe exists and uses custom port + if container.LivenessProbe == nil { + t.Error("LivenessProbe should be set on Postgres container") + } else { + cmdStr := strings.Join(container.LivenessProbe.Exec.Command, " ") + if !strings.Contains(cmdStr, "-p $(PGPORT)") { + t.Errorf("LivenessProbe command missing custom port flag. Got: %s", cmdStr) + } + } + + // Verify readinessProbe uses custom port + if container.ReadinessProbe == nil { + t.Error("ReadinessProbe should be set on Postgres container") + } else { + cmdStr := strings.Join(container.ReadinessProbe.Exec.Command, " ") + if !strings.Contains(cmdStr, "-p $(PGPORT)") { + t.Errorf("ReadinessProbe command missing custom port flag. Got: %s", cmdStr) + } + } + + // Verify volume claim template + if len(sts.Spec.VolumeClaimTemplates) != 1 { + t.Fatalf("Expected 1 VolumeClaimTemplate, got %d", len(sts.Spec.VolumeClaimTemplates)) + } + vct := sts.Spec.VolumeClaimTemplates[0] + storageQty := vct.Spec.Resources.Requests[corev1.ResourceStorage] + if storageQty.String() != "2Gi" { + t.Errorf("Expected storage %q, got %q", "2Gi", storageQty.String()) + } +} + +func TestGenerateDatabaseStatefulSet_InvalidStorage(t *testing.T) { + _, err := GenerateDatabaseStatefulSet("default", "my-app-db", "my-app-db-secret", "my_custom_db", "16", "invalid-size", 5432) + + if err == nil { + t.Fatal("Expected error for invalid storage size, got nil") + } + if !strings.Contains(err.Error(), "invalid storage size format") { + t.Errorf("Expected error to mention invalid storage format, got %v", err) + } +} + +func TestGenerateDatabaseService(t *testing.T) { + svc := GenerateDatabaseService("test-ns", "api-server-db", 5432) + + // Verify metadata + if svc.Name != "api-server-db" { + t.Errorf("Expected name %q, got %q", "api-server-db", svc.Name) + } + if svc.Namespace != "test-ns" { + t.Errorf("Expected namespace %q, got %q", "test-ns", svc.Namespace) + } + + // Verify headless (clusterIP: None) + if svc.Spec.ClusterIP != "None" { + t.Errorf("Expected clusterIP %q, got %q", "None", svc.Spec.ClusterIP) + } + + // Verify selector + if svc.Spec.Selector["app"] != "api-server-db" { + t.Errorf("Expected selector app=%q, got %q", "api-server-db", svc.Spec.Selector["app"]) + } + + // Verify port + if len(svc.Spec.Ports) != 1 { + t.Fatalf("Expected 1 port, got %d", len(svc.Spec.Ports)) + } + if svc.Spec.Ports[0].Port != 5432 { + t.Errorf("Expected port 5432, got %d", svc.Spec.Ports[0].Port) + } + if svc.Spec.Ports[0].Name != "db" { + t.Errorf("Expected port name %q, got %q", "db", svc.Spec.Ports[0].Name) + } +} + +func TestReconcileDatabaseInstance(t *testing.T) { + + dbProps := map[string]interface{}{ + "dbType": "postgres", + "dbName": "my_custom_db", + "version": "16", + "storage": "2Gi", + } + dbPropsJSON, _ := json.Marshal(dbProps) + + app := &appv1alpha1.HeliosApp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "default", + UID: "test-uid-789", + }, + Spec: appv1alpha1.HeliosAppSpec{ + Components: []appv1alpha1.Component{ + { + Name: "api-server", + Type: "web-service", + Traits: []appv1alpha1.Trait{ + { + Type: "database", + Properties: &runtime.RawExtension{ + Raw: dbPropsJSON, + }, + }, + }, + }, + }, + }, + } + + t.Run("CreatesStatefulSetAndService", func(t *testing.T) { + fullScheme := runtime.NewScheme() + _ = corev1.AddToScheme(fullScheme) + _ = appv1alpha1.AddToScheme(fullScheme) + _ = appsv1.AddToScheme(fullScheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(fullScheme). + WithObjects(app). + Build() + + r := &HeliosAppReconciler{ + Client: fakeClient, + Scheme: fullScheme, + } + + ctx := context.Background() + err := r.reconcileDatabaseInstance(ctx, app) + if err != nil { + t.Fatalf("reconcileDatabaseInstance failed: %v", err) + } + + // Verify StatefulSet was created + stsList := &appsv1.StatefulSetList{} + err = fakeClient.List(ctx, stsList) + if err != nil { + t.Fatalf("Failed to list StatefulSets: %v", err) + } + if len(stsList.Items) != 1 { + t.Fatalf("Expected 1 StatefulSet, got %d", len(stsList.Items)) + } + + sts := stsList.Items[0] + if sts.Name != "api-server-db" { + t.Errorf("Expected StatefulSet name %q, got %q", "api-server-db", sts.Name) + } + + // Verify POSTGRES_DB env var + containers := sts.Spec.Template.Spec.Containers + if len(containers) != 1 { + t.Fatalf("Expected 1 container, got %d", len(containers)) + } + foundDB := false + for _, env := range containers[0].Env { + if env.Name == "POSTGRES_DB" && env.Value == "my_custom_db" { + foundDB = true + } + } + if !foundDB { + t.Error("POSTGRES_DB env var not found with expected value") + } + + // Verify headless Service was created + svcList := &corev1.ServiceList{} + err = fakeClient.List(ctx, svcList) + if err != nil { + t.Fatalf("Failed to list Services: %v", err) + } + if len(svcList.Items) != 1 { + t.Fatalf("Expected 1 Service, got %d", len(svcList.Items)) + } + if svcList.Items[0].Spec.ClusterIP != "None" { + t.Errorf("Expected headless Service (clusterIP: None), got %q", svcList.Items[0].Spec.ClusterIP) + } + }) + + t.Run("SkipsWhenNoTraits", func(t *testing.T) { + appWithoutDB := &appv1alpha1.HeliosApp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-db-app", + Namespace: "default", + UID: "test-uid-no-db", + }, + Spec: appv1alpha1.HeliosAppSpec{ + Components: []appv1alpha1.Component{ + { + Name: "frontend", + Type: "web-service", + }, + }, + }, + } + + fullScheme := runtime.NewScheme() + _ = corev1.AddToScheme(fullScheme) + _ = appv1alpha1.AddToScheme(fullScheme) + _ = appsv1.AddToScheme(fullScheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(fullScheme). + WithObjects(appWithoutDB). + Build() + + r := &HeliosAppReconciler{ + Client: fakeClient, + Scheme: fullScheme, + } + + ctx := context.Background() + err := r.reconcileDatabaseInstance(ctx, appWithoutDB) + if err != nil { + t.Fatalf("reconcileDatabaseInstance should not fail for app without database traits: %v", err) + } + + // Verify no StatefulSet or Service was created + stsList := &appsv1.StatefulSetList{} + _ = fakeClient.List(ctx, stsList) + if len(stsList.Items) != 0 { + t.Errorf("Expected no StatefulSets, got %d", len(stsList.Items)) + } + + svcList := &corev1.ServiceList{} + _ = fakeClient.List(ctx, svcList) + if len(svcList.Items) != 0 { + t.Errorf("Expected no Services, got %d", len(svcList.Items)) + } + }) + + t.Run("SkipsNonPostgresType", func(t *testing.T) { + redisProps := map[string]interface{}{ + "dbType": "redis", + "version": "7", + } + redisPropsJSON, _ := json.Marshal(redisProps) + + appWithRedis := &appv1alpha1.HeliosApp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "redis-app", + Namespace: "default", + UID: "test-uid-redis", + }, + Spec: appv1alpha1.HeliosAppSpec{ + Components: []appv1alpha1.Component{ + { + Name: "cache", + Type: "web-service", + Traits: []appv1alpha1.Trait{ + { + Type: "database", + Properties: &runtime.RawExtension{ + Raw: redisPropsJSON, + }, + }, + }, + }, + }, + }, + } + + fullScheme := runtime.NewScheme() + _ = corev1.AddToScheme(fullScheme) + _ = appv1alpha1.AddToScheme(fullScheme) + _ = appsv1.AddToScheme(fullScheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(fullScheme). + WithObjects(appWithRedis). + Build() + + r := &HeliosAppReconciler{ + Client: fakeClient, + Scheme: fullScheme, + } + + ctx := context.Background() + err := r.reconcileDatabaseInstance(ctx, appWithRedis) + if err != nil { + t.Fatalf("reconcileDatabaseInstance should not fail for redis type: %v", err) + } + + // Verify no StatefulSet was created (only postgres is provisioned) + stsList := &appsv1.StatefulSetList{} + _ = fakeClient.List(ctx, stsList) + if len(stsList.Items) != 0 { + t.Errorf("Expected no StatefulSets for redis type, got %d", len(stsList.Items)) + } + }) +} diff --git a/apps/operator/internal/controller/heliosapp_controller.go b/apps/operator/internal/controller/heliosapp_controller.go index ba69c7a..f0441f2 100644 --- a/apps/operator/internal/controller/heliosapp_controller.go +++ b/apps/operator/internal/controller/heliosapp_controller.go @@ -85,19 +85,6 @@ func (r *HeliosAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, err } - // VALIDATION: Ensure image is present (Fix "First Commit Missing Image") - for _, comp := range appModel.App.Components { - // We can add more specific checks here based on component type - // For now, checks if 'image' property exists and is not empty for all components - // assuming all workloads need an image. - if img, ok := comp.Properties["image"].(string); !ok || img == "" { - msg := fmt.Sprintf("Component '%s' is waiting for image (likely building). Status: Pending.", comp.Name) - log.Info(msg) - r.updateStatus(ctx, &heliosApp, appv1alpha1.PhasePending, msg) - return ctrl.Result{}, nil // Wait for next update (CI/CD will update CR with image) - } - } - // 3. Render via CUE Engine manifestBytes, err := r.CueEngine.Render(appModel) if err != nil { @@ -128,6 +115,33 @@ func (r *HeliosAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, err } + // ------------------------------------------------------------------ + // PHASE 0.7: Database Instance Provisioning + // Provision StatefulSets and headless Services for database traits. + // Runs AFTER secrets so that the credential Secret already exists + // when the database pod starts. + // ------------------------------------------------------------------ + if err := r.reconcileDatabaseInstance(ctx, &heliosApp); err != nil { + log.Error(err, "Failed to reconcile database instance") + r.updateStatus(ctx, &heliosApp, appv1alpha1.PhaseFailed, fmt.Sprintf("Database instance provisioning failed: %v", err)) + return ctrl.Result{}, err + } + + // VALIDATION: Ensure image is present (Fix "First Commit Missing Image") + // This validation is for application workloads (GitOps pipeline downstream). + // We run this AFTER DB provisioning so databases can come up while app is building. + for _, comp := range appModel.App.Components { + // We can add more specific checks here based on component type + // For now, checks if 'image' property exists and is not empty for all components + // assuming all workloads need an image. + if img, ok := comp.Properties["image"].(string); !ok || img == "" { + msg := fmt.Sprintf("Component '%s' is waiting for image (likely building). Status: Pending.", comp.Name) + log.Info(msg) + r.updateStatus(ctx, &heliosApp, appv1alpha1.PhasePending, msg) + return ctrl.Result{}, nil // Wait for next update (CI/CD will update CR with image) + } + } + // ------------------------------------------------------------------ // PHASE 0.6: Trigger Initial PipelineRun (if not already done) // ------------------------------------------------------------------ @@ -507,6 +521,7 @@ func (r *HeliosAppReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&appv1alpha1.HeliosApp{}). Owns(&appsv1.Deployment{}). + Owns(&appsv1.StatefulSet{}). Owns(&corev1.Service{}). Owns(&networkingv1.Ingress{}). Watches( diff --git a/apps/operator/internal/controller/suite_test.go b/apps/operator/internal/controller/suite_test.go index aeb402a..d57dd21 100644 --- a/apps/operator/internal/controller/suite_test.go +++ b/apps/operator/internal/controller/suite_test.go @@ -102,6 +102,21 @@ var _ = AfterSuite(func() { // properly set up, run 'make setup-envtest' beforehand. func getFirstFoundEnvTestBinaryDir() string { basePath := filepath.Join("..", "..", "bin", "k8s") + var found string + _ = filepath.WalkDir(basePath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable dirs + } + if !d.IsDir() && (d.Name() == "etcd" || d.Name() == "etcd.exe") { + found = filepath.Dir(path) + return filepath.SkipAll + } + return nil + }) + if found != "" { + return found + } + // Fallback: return first subdirectory (original behavior) entries, err := os.ReadDir(basePath) if err != nil { logf.Log.Error(err, "Failed to read directory", "path", basePath) diff --git a/cue/definitions/traits/database.cue b/cue/definitions/traits/database.cue index 02cb04d..e6af6be 100644 --- a/cue/definitions/traits/database.cue +++ b/cue/definitions/traits/database.cue @@ -3,8 +3,14 @@ package traits import "strings" // DatabaseTrait — declares database requirements for a Component. -// Renders a ConfigMap with connection metadata. Credentials (Secret) are -// generated by the HeliosAppReconciler (see issue #33), not by CUE. +// Renders: +// 1. ConfigMap with connection metadata (all dbTypes) +// +// StatefulSet + Service provisioning is handled directly by the +// Go Operator (reconcileDatabaseInstance), NOT by CUE output. +// +// Credentials (Secret) are generated by the HeliosAppReconciler +// (see issue #33), not by CUE. // // Usage via the trait system: // traits: [{ @@ -48,29 +54,32 @@ _#defaultPorts: { "\(_p.name)-db", ][0] + // Conventional names shared by ConfigMap and Operator provisioning. + let _dbHostName = "\(_p.name)-db" + let _secretName = "\(_p.name)-db-secret" + outputs: { // ConfigMap: non-sensitive connection metadata for the application. - // DB_SECRET_NAME follows a convention so the app (and Operator) - // know where to find the credentials once they are generated. + // The app uses these values to connect to the database provisioned + // by the Go Operator. configmap: { apiVersion: "v1" kind: "ConfigMap" metadata: { name: "\(_p.name)-db-config" labels: { - app: _p.name "helios.io/managed-by": "operator" "helios.io/trait": "database" } } data: { DB_TYPE: _p.dbType - DB_HOST: "\(_p.name)-db" + DB_HOST: _dbHostName DB_PORT: "\(_p.port)" DB_NAME: _effectiveDBName DB_VERSION: _p.version DB_STORAGE: _p.storage - DB_SECRET_NAME: "\(_p.name)-db-secret" + DB_SECRET_NAME: _secretName } } }