diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 41ccdd37..9532425b 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -176,6 +176,15 @@ func (g *InstallConfig) ValidateInstallConfig() []string { } } + if ob := g.Config.Codesphere.OpenfgaBackups; ob != nil && ob.Enabled { + if ob.DestinationPath == "" { + errors = append(errors, "openfga backups destinationPath is required when openfgaBackups is enabled") + } + if ob.EndpointURL == "" { + errors = append(errors, "openfga backups endpointURL is required when openfgaBackups is enabled") + } + } + return errors } diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index 1fd403d4..34de2c04 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -298,6 +298,35 @@ var _ = Describe("ConfigManager", func() { }) }) + Context("openfga backups validation", func() { + It("should require destinationPath and endpointURL when enabled", func() { + configManager.Config.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: true, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("openfga backups destinationPath is required"))) + Expect(errors).To(ContainElement(ContainSubstring("openfga backups endpointURL is required"))) + }) + + It("should pass when enabled with required fields set", func() { + configManager.Config.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: true, + DestinationPath: "s3://backup-openfga-dev", + EndpointURL: "https://storage.googleapis.com", + } + errors := configManager.ValidateInstallConfig() + Expect(errors).ToNot(ContainElement(ContainSubstring("openfga backups"))) + }) + + It("should not require fields when disabled", func() { + configManager.Config.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: false, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).ToNot(ContainElement(ContainSubstring("openfga backups"))) + }) + }) + Context("ceph validation", func() { It("should require at least one Ceph host", func() { configManager.Config.Ceph.Hosts = []files.CephHost{} diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index 51f041f6..8f60066e 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -317,6 +317,7 @@ type CodesphereConfig struct { OAuth *OAuthProvidersConfig `yaml:"oauth,omitempty"` ManagedServices []ManagedServiceConfig `yaml:"managedServices,omitempty"` OpenBao *OpenBaoConfig `yaml:"openBao,omitempty"` + OpenfgaBackups *OpenfgaBackupsConfig `yaml:"openfgaBackups,omitempty"` Migration *MigrationConfig `yaml:"migration,omitempty"` TelemetryExport *TelemetryExport `yaml:"telemetryExport,omitempty"` Override ChartOverride `yaml:"override,omitempty"` @@ -339,6 +340,21 @@ type OpenBaoConfig struct { User string `yaml:"user,omitempty"` } +// OpenfgaBackupsConfig is the friendly representation of the OpenFGA database +// backup settings. On marshal it is translated into the openfga subchart values +// under codesphere.override (see buildOpenfgaBackupOverride). +type OpenfgaBackupsConfig struct { + Enabled bool `yaml:"enabled"` + // Schedule is an optional 6-field cron expression. When empty the chart default applies. + Schedule string `yaml:"schedule,omitempty"` + // DestinationPath is the S3-style backup target (e.g. s3://backup-openfga-dev). Required when enabled. + DestinationPath string `yaml:"destinationPath,omitempty"` + // EndpointURL is the S3-compatible endpoint (e.g. https://s3.de.io.cloud.ovh.net). Required when enabled. + EndpointURL string `yaml:"endpointURL,omitempty"` + // RetentionPolicy is optional (e.g. "7d"). When empty the chart default of "7d" applies. + RetentionPolicy string `yaml:"retentionPolicy,omitempty"` +} + type OAuthProvidersConfig struct { Oidc *OidcOAuthProvider `yaml:"oidc,omitempty"` } @@ -625,6 +641,7 @@ type S3ManagedServiceConfig struct { // Marshal serializes the RootConfig to YAML func (c *RootConfig) Marshal() ([]byte, error) { c.buildACMEOverride() + c.buildOpenfgaBackupValues() return yaml.Marshal(c) } @@ -714,6 +731,70 @@ func (c *RootConfig) buildACMEOverride() { c.Cluster.Certificates.Override["issuers"] = issuers } +// buildOpenfgaBackupValues translates codesphere.openfgaBackups into the openfga +// application values under pcApps.applications.openfga.valuesObject.postgres.backup. +// OpenFGA is deployed via the pc-apps app-of-apps (ArgoCD), not the codesphere +// umbrella chart, so this is where its Helm values belong. Optional fields +// (schedule, retentionPolicy) are only emitted when set so the chart defaults +// apply otherwise. Existing pcApps entries (e.g. other applications) are preserved. +func (c *RootConfig) buildOpenfgaBackupValues() { + ob := c.Codesphere.OpenfgaBackups + if ob == nil { + return + } + + backup := map[string]interface{}{ + "enabled": ob.Enabled, + } + if ob.Schedule != "" { + backup["schedule"] = ob.Schedule + } + if ob.DestinationPath != "" { + backup["destinationPath"] = ob.DestinationPath + } + if ob.EndpointURL != "" { + backup["endpointURL"] = ob.EndpointURL + } + if ob.RetentionPolicy != "" { + backup["retentionPolicy"] = ob.RetentionPolicy + } + + if c.PcApps == nil { + c.PcApps = ChartValues{} + } + + applications, ok := c.PcApps["applications"].(map[string]interface{}) + if !ok { + applications = map[string]interface{}{} + } + + openfga, ok := applications["openfga"].(map[string]interface{}) + if !ok { + openfga = map[string]interface{}{} + } + // Only enable the openfga application when backups are on; never emit + // enabled:false here, as that would disable the whole openfga application + // (a core component) rather than just its backups. + if ob.Enabled { + openfga["enabled"] = true + } + + valuesObject, ok := openfga["valuesObject"].(map[string]interface{}) + if !ok { + valuesObject = map[string]interface{}{} + } + postgres, ok := valuesObject["postgres"].(map[string]interface{}) + if !ok { + postgres = map[string]interface{}{} + } + + postgres["backup"] = backup + valuesObject["postgres"] = postgres + openfga["valuesObject"] = valuesObject + applications["openfga"] = openfga + c.PcApps["applications"] = applications +} + // extractACMESolverFromOverride populates the ACMEConfig.Solver from // cluster.certificates.override.issuers.acme.dnsSolver after unmarshaling. func (c *RootConfig) extractACMESolverFromOverride() { diff --git a/internal/installer/files/config_yaml_test.go b/internal/installer/files/config_yaml_test.go index 5c764f7e..be8738e1 100644 --- a/internal/installer/files/config_yaml_test.go +++ b/internal/installer/files/config_yaml_test.go @@ -385,4 +385,93 @@ cluster: }) }) + + Describe("OpenFGA backup config structure", func() { + It("should marshal openfgaBackups into the pc-apps openfga application values", func() { + rootConfig.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: true, + Schedule: "0 */30 * * * *", + DestinationPath: "s3://backup-openfga-dev", + EndpointURL: "https://s3.de.io.cloud.ovh.net", + } + + data, err := rootConfig.Marshal() + Expect(err).NotTo(HaveOccurred()) + + var raw map[string]interface{} + Expect(yaml.Unmarshal(data, &raw)).NotTo(HaveOccurred()) + + pcApps := raw["pcApps"].(map[string]interface{}) + applications := pcApps["applications"].(map[string]interface{}) + Expect(applications["openfga"]).To(Equal(map[string]interface{}{ + "enabled": true, + "valuesObject": map[string]interface{}{ + "postgres": map[string]interface{}{ + "backup": map[string]interface{}{ + "enabled": true, + "schedule": "0 */30 * * * *", + "destinationPath": "s3://backup-openfga-dev", + "endpointURL": "https://s3.de.io.cloud.ovh.net", + }, + }, + }, + })) + }) + + It("omits optional fields (schedule, retentionPolicy) when unset", func() { + rootConfig.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: true, + DestinationPath: "s3://backup-openfga-dev", + EndpointURL: "https://s3.de.io.cloud.ovh.net", + } + + data, err := rootConfig.Marshal() + Expect(err).NotTo(HaveOccurred()) + + var raw map[string]interface{} + Expect(yaml.Unmarshal(data, &raw)).NotTo(HaveOccurred()) + + backup := raw["pcApps"].(map[string]interface{})["applications"].(map[string]interface{})["openfga"].(map[string]interface{})["valuesObject"].(map[string]interface{})["postgres"].(map[string]interface{})["backup"].(map[string]interface{}) + Expect(backup).To(HaveKey("destinationPath")) + Expect(backup).NotTo(HaveKey("schedule")) + Expect(backup).NotTo(HaveKey("retentionPolicy")) + }) + + It("preserves other pc-apps applications", func() { + rootConfig.PcApps = files.ChartValues{ + "applications": map[string]interface{}{ + "ssh-workspace-proxy": map[string]interface{}{"enabled": true}, + }, + } + rootConfig.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: true, + DestinationPath: "s3://backup-openfga-dev", + EndpointURL: "https://s3.de.io.cloud.ovh.net", + } + + data, err := rootConfig.Marshal() + Expect(err).NotTo(HaveOccurred()) + + var raw map[string]interface{} + Expect(yaml.Unmarshal(data, &raw)).NotTo(HaveOccurred()) + + applications := raw["pcApps"].(map[string]interface{})["applications"].(map[string]interface{}) + Expect(applications).To(HaveKey("ssh-workspace-proxy")) + Expect(applications).To(HaveKey("openfga")) + }) + + It("does not generate openfga application values when openfgaBackups is unset", func() { + data, err := rootConfig.Marshal() + Expect(err).NotTo(HaveOccurred()) + + var raw map[string]interface{} + Expect(yaml.Unmarshal(data, &raw)).NotTo(HaveOccurred()) + + if pcApps, ok := raw["pcApps"].(map[string]interface{}); ok { + if applications, ok := pcApps["applications"].(map[string]interface{}); ok { + Expect(applications).NotTo(HaveKey("openfga")) + } + } + }) + }) })