diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 4c0755af..b8ea9307 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -60,6 +60,9 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "📦 Chart version: $VERSION" + - name: Update Helm dependencies + run: helm dependency update helm/frappe-operator/ + - name: Package Helm chart run: | mkdir -p docs/helm-repo diff --git a/.gitignore b/.gitignore index 7a679127..6bdc0652 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,8 @@ docs/.buildinfo # Helm (if used) charts/*/charts/ +# Allow helm chart dependencies but exclude other tgz files +!helm/frappe-operator/charts/*.tgz *.tgz # Local operator testing diff --git a/controllers/frappebench_resources.go b/controllers/frappebench_resources.go index 65956a7f..e507610c 100644 --- a/controllers/frappebench_resources.go +++ b/controllers/frappebench_resources.go @@ -354,7 +354,7 @@ func (r *FrappeBenchReconciler) ensureRedisStatefulSet(ctx context.Context, benc Image: redisImage, Command: []string{"redis-server"}, // Disable RDB/AOF persistence to avoid stop-writes-on-bgsave-error in ephemeral environments - Args: []string{"--save", "", "--appendonly", "no", "--stop-writes-on-bgsave-error", "no"}, + Args: []string{"--save", "", "--appendonly", "no", "--stop-writes-on-bgsave-error", "no"}, Ports: []corev1.ContainerPort{ { ContainerPort: 6379, diff --git a/controllers/frappesite_controller.go b/controllers/frappesite_controller.go index e7373708..0d5bb6b3 100644 --- a/controllers/frappesite_controller.go +++ b/controllers/frappesite_controller.go @@ -576,13 +576,13 @@ func (r *FrappeSiteReconciler) ensureInitSecrets(ctx context.Context, site *vyog logger := log.FromContext(ctx) secretName := fmt.Sprintf("%s-init-secrets", site.Name) - + // Get DB_PROVIDER from database info dbProvider := "mariadb" // default if site.Spec.DBConfig.Provider != "" { dbProvider = site.Spec.DBConfig.Provider } - + // Get apps to install if specified // Build secret data with all credentials as individual files secretData := map[string][]byte{ @@ -592,7 +592,7 @@ func (r *FrappeSiteReconciler) ensureInitSecrets(ctx context.Context, site *vyog "bench_name": []byte(bench.Name), "db_provider": []byte(dbProvider), } - + // Add database credentials if using external database if dbProvider == "mariadb" || dbProvider == "postgres" { if dbInfo != nil { @@ -605,7 +605,7 @@ func (r *FrappeSiteReconciler) ensureInitSecrets(ctx context.Context, site *vyog secretData["db_password"] = []byte(dbCreds.Password) } } - + // Create or update the secret secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -619,13 +619,13 @@ func (r *FrappeSiteReconciler) ensureInitSecrets(ctx context.Context, site *vyog Type: corev1.SecretTypeOpaque, Data: secretData, } - + // Set controller reference if err := controllerutil.SetControllerReference(site, secret, r.Scheme); err != nil { logger.Error(err, "Failed to set controller reference for secret", "secret", secretName) return err } - + // Create or update secret var existing corev1.Secret err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: site.Namespace}, &existing) @@ -648,7 +648,7 @@ func (r *FrappeSiteReconciler) ensureInitSecrets(ctx context.Context, site *vyog } logger.Info("Updated initialization secret", "secret", secretName) } - + return nil } @@ -939,7 +939,7 @@ exit 0 Name: "site-secrets", VolumeSource: corev1.VolumeSource{ Secret: &corev1.SecretVolumeSource{ - SecretName: fmt.Sprintf("%s-init-secrets", site.Name), + SecretName: fmt.Sprintf("%s-init-secrets", site.Name), DefaultMode: int32Ptr(0400), // Read-only for security }, }, @@ -997,14 +997,14 @@ func (r *FrappeSiteReconciler) deleteSite(ctx context.Context, site *vyogotechv1 // Job doesn't exist, create it logger.Info("Creating site deletion job", "job", jobName) - + // Get MariaDB root credentials for deletion (site user has limited privileges) rootUser, rootPassword, err := r.getMariaDBRootCredentials(ctx, site) if err != nil { return fmt.Errorf("failed to get MariaDB root credentials: %w", err) } - // Create deletion secret with root credentials + // Create deletion secret with root credentials deletionSecretName := fmt.Sprintf("%s-deletion-secret", site.Name) deletionSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -1022,11 +1022,11 @@ func (r *FrappeSiteReconciler) deleteSite(ctx context.Context, site *vyogotechv1 "site_name": []byte(site.Spec.SiteName), }, } - + if err := controllerutil.SetControllerReference(site, deletionSecret, r.Scheme); err != nil { return err } - + if err := r.Create(ctx, deletionSecret); err != nil { if !errors.IsAlreadyExists(err) { return fmt.Errorf("failed to create deletion secret: %w", err) @@ -1041,7 +1041,7 @@ func (r *FrappeSiteReconciler) deleteSite(ctx context.Context, site *vyogotechv1 return fmt.Errorf("failed to update deletion secret: %w", err) } } - + // Use root credentials from secret volume (not environment variables) deleteScript := `#!/bin/bash set -e @@ -1093,7 +1093,7 @@ echo "Site $SITE_NAME dropped successfully!" }, }, SecurityContext: r.getContainerSecurityContext(bench), - Env: []corev1.EnvVar{}, // No environment variables for sensitive data + Env: []corev1.EnvVar{}, // No environment variables for sensitive data }, }, Volumes: []corev1.Volume{ diff --git a/controllers/security_context.go b/controllers/security_context.go new file mode 100644 index 00000000..a0a27105 --- /dev/null +++ b/controllers/security_context.go @@ -0,0 +1,176 @@ +package controllers + +import ( + "os" + "strconv" + + corev1 "k8s.io/api/core/v1" + + vyogotechv1alpha1 "github.com/vyogotech/frappe-operator/api/v1alpha1" +) + +const ( + // Default UID/GID for Frappe containers (non-root user) + defaultRunAsUserID int64 = 1001 + defaultRunAsGroupID int64 = 1001 + defaultFSGroupID int64 = 1001 + + // Environment variable names for operator-level security context configuration + envRunAsUserID = "FRAPPE_RUN_AS_USER" + envRunAsGroupID = "FRAPPE_RUN_AS_GROUP" + envFSGroupID = "FRAPPE_FS_GROUP" +) + +// getConfiguredSecurityIDs returns the configured UID/GID values +// Priority: environment variables > defaults +func getConfiguredSecurityIDs() (userID, groupID, fsGroupID int64) { + userID = defaultRunAsUserID + groupID = defaultRunAsGroupID + fsGroupID = defaultFSGroupID + + // Override from environment if set + if envUser := os.Getenv(envRunAsUserID); envUser != "" { + if parsed, err := strconv.ParseInt(envUser, 10, 64); err == nil { + userID = parsed + } + } + + if envGroup := os.Getenv(envRunAsGroupID); envGroup != "" { + if parsed, err := strconv.ParseInt(envGroup, 10, 64); err == nil { + groupID = parsed + } + } + + if envFS := os.Getenv(envFSGroupID); envFS != "" { + if parsed, err := strconv.ParseInt(envFS, 10, 64); err == nil { + fsGroupID = parsed + } + } + + return +} + +// applyBenchSecurityDefaults ensures that Pods created by the operator adhere to the +// minimum security posture required by hardened clusters while still allowing +// benches to override the defaults through spec.security. +func applyBenchSecurityDefaults(podSpec *corev1.PodSpec, bench *vyogotechv1alpha1.FrappeBench) { + if podSpec == nil { + return + } + + defaultPodContext := getBenchPodSecurityContext(bench) + if podSpec.SecurityContext == nil { + podSpec.SecurityContext = defaultPodContext + } else { + mergePodSecurityContext(podSpec.SecurityContext, defaultPodContext) + } + + for i := range podSpec.InitContainers { + ensureContainerSecurityContext(&podSpec.InitContainers[i], bench) + } + + for i := range podSpec.Containers { + ensureContainerSecurityContext(&podSpec.Containers[i], bench) + } +} + +func ensureContainerSecurityContext(container *corev1.Container, bench *vyogotechv1alpha1.FrappeBench) { + if container == nil { + return + } + + defaultCtx := getBenchContainerSecurityContext(bench) + if container.SecurityContext == nil { + container.SecurityContext = defaultCtx + return + } + + mergeContainerSecurityContext(container.SecurityContext, defaultCtx) +} + +func getBenchPodSecurityContext(bench *vyogotechv1alpha1.FrappeBench) *corev1.PodSecurityContext { + if bench != nil && bench.Spec.Security != nil && bench.Spec.Security.PodSecurityContext != nil { + return bench.Spec.Security.PodSecurityContext.DeepCopy() + } + + userID, groupID, fsGroupID := getConfiguredSecurityIDs() + + return &corev1.PodSecurityContext{ + RunAsUser: int64Ptr(userID), + RunAsGroup: int64Ptr(groupID), + FSGroup: int64Ptr(fsGroupID), + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + } +} + +func getBenchContainerSecurityContext(bench *vyogotechv1alpha1.FrappeBench) *corev1.SecurityContext { + if bench != nil && bench.Spec.Security != nil && bench.Spec.Security.SecurityContext != nil { + return bench.Spec.Security.SecurityContext.DeepCopy() + } + + userID, groupID, _ := getConfiguredSecurityIDs() + + return &corev1.SecurityContext{ + RunAsUser: int64Ptr(userID), + RunAsGroup: int64Ptr(groupID), + AllowPrivilegeEscalation: boolPtr(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + ReadOnlyRootFilesystem: boolPtr(false), + } +} + +func mergePodSecurityContext(target, defaults *corev1.PodSecurityContext) { + if target == nil || defaults == nil { + return + } + + if target.RunAsUser == nil { + target.RunAsUser = defaults.RunAsUser + } + + if target.RunAsGroup == nil { + target.RunAsGroup = defaults.RunAsGroup + } + + if target.FSGroup == nil { + target.FSGroup = defaults.FSGroup + } + + if target.SeccompProfile == nil && defaults.SeccompProfile != nil { + target.SeccompProfile = defaults.SeccompProfile.DeepCopy() + } +} + +func mergeContainerSecurityContext(target, defaults *corev1.SecurityContext) { + if target == nil || defaults == nil { + return + } + + if target.RunAsUser == nil { + target.RunAsUser = defaults.RunAsUser + } + + if target.RunAsGroup == nil { + target.RunAsGroup = defaults.RunAsGroup + } + + if target.AllowPrivilegeEscalation == nil { + target.AllowPrivilegeEscalation = defaults.AllowPrivilegeEscalation + } + + if target.ReadOnlyRootFilesystem == nil { + target.ReadOnlyRootFilesystem = defaults.ReadOnlyRootFilesystem + } + + if target.SeccompProfile == nil && defaults.SeccompProfile != nil { + target.SeccompProfile = defaults.SeccompProfile.DeepCopy() + } + + if target.Capabilities == nil && defaults.Capabilities != nil { + target.Capabilities = defaults.Capabilities.DeepCopy() + } +} diff --git a/helm/frappe-operator/Chart.yaml b/helm/frappe-operator/Chart.yaml index 4b2bfe82..f15045ea 100644 --- a/helm/frappe-operator/Chart.yaml +++ b/helm/frappe-operator/Chart.yaml @@ -15,7 +15,7 @@ sources: - https://github.com/vyogotech/frappe-operator maintainers: - name: Vyogo Technologies - email: support@vyogo.tech + email: dev@vyogo.tech url: https://vyogo.tech icon: https://raw.githubusercontent.com/vyogotech/frappe-operator/main/docs/assets/icon.png