This commit is contained in:
@@ -0,0 +1,687 @@
|
||||
package config
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Default values — single source of truth for every default
|
||||
// referenced in the schema. Change a default here and it
|
||||
// propagates everywhere automatically.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const (
|
||||
DefaultTLSIssuer = "letsencrypt-prod"
|
||||
DefaultIngressClass = "nginx"
|
||||
DefaultCNPGHost = "cnpg-main-rw.default.svc.cluster.local"
|
||||
DefaultNamespacePattern = "${env}"
|
||||
DefaultImagePullPolicy = "Always"
|
||||
DefaultServiceType = "ClusterIP"
|
||||
DefaultDockerfile = "Dockerfile"
|
||||
DefaultImageTag = "latest"
|
||||
DefaultPullSecret = "regcred"
|
||||
DefaultHealthCheckPath = "/healthcheck"
|
||||
DefaultRegistryURL = "registry.natelubitz.com"
|
||||
|
||||
DefaultPort = 3000
|
||||
DefaultReplicas = 1
|
||||
DefaultInitialDelaySecs = 15
|
||||
DefaultPeriodSecs = 10
|
||||
DefaultTimeoutSecs = 5
|
||||
DefaultFailureThreshold = 3
|
||||
DefaultDeleteGraceSecs = 300
|
||||
DefaultSuccessfulJobsHist = 3
|
||||
DefaultFailedJobsHist = 1
|
||||
|
||||
DefaultCacheProvider = "valkey"
|
||||
DefaultCacheMode = "standalone"
|
||||
DefaultStorageProvider = "minio"
|
||||
DefaultStorageMode = "standalone"
|
||||
DefaultQueueProvider = "nats"
|
||||
DefaultSearchProvider = "meilisearch"
|
||||
DefaultMonProvider = "prometheus"
|
||||
DefaultMetricsPath = "/metrics"
|
||||
|
||||
DefaultRestartPolicy = "OnFailure"
|
||||
DefaultConcurrencyPolicy = "Forbid"
|
||||
)
|
||||
|
||||
// boolPtr / intPtr are helpers for pointer defaults.
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
// isEnabled returns true if the InfraBase has no explicit Enabled
|
||||
// value set (nil = inherits root default, which is true when the
|
||||
// block exists) or if Enabled is explicitly true.
|
||||
func isEnabled(b *bool) bool {
|
||||
return b == nil || *b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// ApplyDefaults fills in every zero/nil value in the config
|
||||
// with the canonical default. Called once after unmarshalling,
|
||||
// before any environment resolution.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func ApplyDefaults(cfg *KforgeConfig) {
|
||||
applyClusterDefaults(&cfg.Cluster)
|
||||
applyRegistryDefaults(&cfg.Registry, &cfg.Meta)
|
||||
applyDeploymentDefaults(&cfg.Defaults)
|
||||
applyRootInfraDefaults(&cfg.Infrastructure)
|
||||
}
|
||||
|
||||
func applyClusterDefaults(c *ClusterConfig) {
|
||||
if c.TLSIssuer == "" {
|
||||
c.TLSIssuer = DefaultTLSIssuer
|
||||
}
|
||||
if c.IngressClass == "" {
|
||||
c.IngressClass = DefaultIngressClass
|
||||
}
|
||||
if c.CNPG.Host == "" {
|
||||
c.CNPG.Host = DefaultCNPGHost
|
||||
}
|
||||
if c.NamespacePattern == "" {
|
||||
c.NamespacePattern = DefaultNamespacePattern
|
||||
}
|
||||
}
|
||||
|
||||
func applyRegistryDefaults(r *RegistryConfig, m *MetaConfig) {
|
||||
if r.URL == "" {
|
||||
r.URL = DefaultRegistryURL
|
||||
}
|
||||
if r.PullSecret == "" {
|
||||
r.PullSecret = DefaultPullSecret
|
||||
}
|
||||
if r.Repository == "" {
|
||||
r.Repository = "${tenant}/${name}"
|
||||
}
|
||||
}
|
||||
|
||||
func applyDeploymentDefaults(d *DefaultsConfig) {
|
||||
if d.ImagePullPolicy == "" {
|
||||
d.ImagePullPolicy = DefaultImagePullPolicy
|
||||
}
|
||||
if d.Replicas == nil {
|
||||
d.Replicas = intPtr(DefaultReplicas)
|
||||
}
|
||||
if d.Port == nil {
|
||||
d.Port = intPtr(DefaultPort)
|
||||
}
|
||||
if d.ServiceType == "" {
|
||||
d.ServiceType = DefaultServiceType
|
||||
}
|
||||
if d.Dockerfile == "" {
|
||||
d.Dockerfile = DefaultDockerfile
|
||||
}
|
||||
applyHealthCheckDefaults(&d.HealthCheck, d.Port)
|
||||
applyResourceDefaults(&d.Resources)
|
||||
}
|
||||
|
||||
func applyHealthCheckDefaults(h *HealthCheckConfig, defaultPort *int) {
|
||||
if h.Path == "" {
|
||||
h.Path = DefaultHealthCheckPath
|
||||
}
|
||||
if h.Port == nil {
|
||||
h.Port = defaultPort
|
||||
}
|
||||
if h.InitialDelaySeconds == 0 {
|
||||
h.InitialDelaySeconds = DefaultInitialDelaySecs
|
||||
}
|
||||
if h.PeriodSeconds == 0 {
|
||||
h.PeriodSeconds = DefaultPeriodSecs
|
||||
}
|
||||
if h.TimeoutSeconds == 0 {
|
||||
h.TimeoutSeconds = DefaultTimeoutSecs
|
||||
}
|
||||
if h.FailureThreshold == 0 {
|
||||
h.FailureThreshold = DefaultFailureThreshold
|
||||
}
|
||||
if h.Liveness == nil {
|
||||
h.Liveness = boolPtr(true)
|
||||
}
|
||||
if h.Readiness == nil {
|
||||
h.Readiness = boolPtr(true)
|
||||
}
|
||||
}
|
||||
|
||||
func applyResourceDefaults(r *ResourceConfig) {
|
||||
if r.Requests.CPU == "" {
|
||||
r.Requests.CPU = "100m"
|
||||
}
|
||||
if r.Requests.Memory == "" {
|
||||
r.Requests.Memory = "128Mi"
|
||||
}
|
||||
if r.Limits.CPU == "" {
|
||||
r.Limits.CPU = "500m"
|
||||
}
|
||||
if r.Limits.Memory == "" {
|
||||
r.Limits.Memory = "512Mi"
|
||||
}
|
||||
}
|
||||
|
||||
// applyRootInfraDefaults sets provider/mode defaults on the root
|
||||
// infrastructure block. The enabled flag is handled by the merge
|
||||
// step: if a block exists at root with no explicit enabled:false,
|
||||
// it is considered enabled.
|
||||
func applyRootInfraDefaults(infra *InfrastructureConfig) {
|
||||
if infra.Cache != nil {
|
||||
if infra.Cache.Provider == "" {
|
||||
infra.Cache.Provider = DefaultCacheProvider
|
||||
}
|
||||
if infra.Cache.Mode == "" {
|
||||
infra.Cache.Mode = DefaultCacheMode
|
||||
}
|
||||
if infra.Cache.Replicas == nil {
|
||||
infra.Cache.Replicas = intPtr(1)
|
||||
}
|
||||
}
|
||||
if infra.Storage != nil {
|
||||
if infra.Storage.Provider == "" {
|
||||
infra.Storage.Provider = DefaultStorageProvider
|
||||
}
|
||||
if infra.Storage.Mode == "" {
|
||||
infra.Storage.Mode = DefaultStorageMode
|
||||
}
|
||||
}
|
||||
if infra.Queue != nil {
|
||||
if infra.Queue.Provider == "" {
|
||||
infra.Queue.Provider = DefaultQueueProvider
|
||||
}
|
||||
}
|
||||
if infra.Search != nil {
|
||||
if infra.Search.Provider == "" {
|
||||
infra.Search.Provider = DefaultSearchProvider
|
||||
}
|
||||
}
|
||||
if infra.Monitoring != nil {
|
||||
if infra.Monitoring.Provider == "" {
|
||||
infra.Monitoring.Provider = DefaultMonProvider
|
||||
}
|
||||
if infra.Monitoring.MetricsPath == "" {
|
||||
infra.Monitoring.MetricsPath = DefaultMetricsPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// ResolveEnvironment produces a fully-merged, fully-defaulted
|
||||
// ResolvedEnvironment for a given environment key.
|
||||
//
|
||||
// Merge order (last wins):
|
||||
// 1. Compiled defaults (from ApplyDefaults)
|
||||
// 2. Root infrastructure block
|
||||
// 3. Per-environment overrides
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// ResolvedEnvironment is the flattened, ready-to-generate view
|
||||
// of a single environment. Generators consume this type only —
|
||||
// they never touch raw config or merge logic.
|
||||
type ResolvedEnvironment struct {
|
||||
// Identity
|
||||
EnvKey string
|
||||
EnvPrefix string
|
||||
Namespace string
|
||||
FullName string // ${env_prefix}-${tenant}-${name}
|
||||
|
||||
// Deployment
|
||||
Image string
|
||||
ImagePullPolicy string
|
||||
ImagePullSecret string
|
||||
Replicas int
|
||||
Port int
|
||||
ServiceType string
|
||||
HealthCheck HealthCheckConfig
|
||||
Resources ResourceConfig
|
||||
EnvVars []EnvVarConfig
|
||||
|
||||
// Ingress
|
||||
Ingress IngressConfig
|
||||
|
||||
// Infrastructure (merged root + env override)
|
||||
Infrastructure ResolvedInfrastructure
|
||||
|
||||
// Cron jobs
|
||||
CronJobs []ResolvedCronJob
|
||||
|
||||
// Cluster-level settings (carried through for generators)
|
||||
TLSIssuer string
|
||||
IngressClass string
|
||||
CNPGHost string
|
||||
|
||||
// Lifecycle
|
||||
Lifecycle LifecycleConfig
|
||||
}
|
||||
|
||||
// ResolvedInfrastructure holds the fully-merged infra state for
|
||||
// one environment. Each field is nil if the service is disabled.
|
||||
type ResolvedInfrastructure struct {
|
||||
Database *DatabaseInfraConfig
|
||||
Cache *CacheInfraConfig
|
||||
Storage *StorageInfraConfig
|
||||
Queue *QueueInfraConfig
|
||||
Search *SearchInfraConfig
|
||||
Monitoring *MonitoringInfraConfig
|
||||
}
|
||||
|
||||
// ResolvedCronJob is a fully-defaulted cron job ready for the
|
||||
// CronJob manifest generator.
|
||||
type ResolvedCronJob struct {
|
||||
CronJobConfig
|
||||
Image string
|
||||
EnvVars []EnvVarConfig // merged: deployment vars + job-specific vars
|
||||
}
|
||||
|
||||
// ResolveEnvironment merges root config + env overrides into a
|
||||
// single ResolvedEnvironment. cfg must have had ApplyDefaults
|
||||
// called on it first.
|
||||
func ResolveEnvironment(cfg *KforgeConfig, envKey string) (ResolvedEnvironment, error) {
|
||||
env, ok := cfg.Environments[envKey]
|
||||
if !ok {
|
||||
return ResolvedEnvironment{}, &UnknownEnvironmentError{Key: envKey}
|
||||
}
|
||||
|
||||
prefix := resolveEnvPrefix(envKey, env.EnvPrefix)
|
||||
namespace := resolveNamespace(envKey, env.Namespace, cfg.Cluster.NamespacePattern)
|
||||
fullName := resolveFullName(cfg, prefix, env)
|
||||
|
||||
registry := resolveRegistry(cfg, env)
|
||||
imageTag := env.ImageTag
|
||||
if imageTag == "" {
|
||||
imageTag = DefaultImageTag
|
||||
}
|
||||
image := registry.URL + "/" + registry.Repository + ":" + imageTag
|
||||
|
||||
replicas := *cfg.Defaults.Replicas
|
||||
if env.Replicas != nil {
|
||||
replicas = *env.Replicas
|
||||
}
|
||||
|
||||
port := *cfg.Defaults.Port
|
||||
hc := cfg.Defaults.HealthCheck
|
||||
if hc.Port == nil || *hc.Port == 0 {
|
||||
hc.Port = intPtr(port)
|
||||
}
|
||||
|
||||
envVars := mergeEnvVars(cfg.Defaults.EnvVars, env.EnvVars)
|
||||
|
||||
infra := mergeInfrastructure(&cfg.Infrastructure, &env.Infrastructure)
|
||||
|
||||
cronJobs := resolveCronJobs(env.CronJobs, image, envVars, cfg.Defaults.Resources)
|
||||
|
||||
lifecycle := env.Lifecycle
|
||||
if lifecycle.DeleteGraceSeconds == 0 {
|
||||
lifecycle.DeleteGraceSeconds = DefaultDeleteGraceSecs
|
||||
}
|
||||
|
||||
auth := env.Ingress.Auth
|
||||
if auth.SecretName == "" {
|
||||
auth.SecretName = fullName + "-basic-auth"
|
||||
}
|
||||
ingress := IngressConfig{
|
||||
Hosts: env.Ingress.Hosts,
|
||||
Auth: auth,
|
||||
}
|
||||
|
||||
return ResolvedEnvironment{
|
||||
EnvKey: envKey,
|
||||
EnvPrefix: prefix,
|
||||
Namespace: namespace,
|
||||
FullName: fullName,
|
||||
Image: image,
|
||||
ImagePullPolicy: cfg.Defaults.ImagePullPolicy,
|
||||
ImagePullSecret: registry.PullSecret,
|
||||
Replicas: replicas,
|
||||
Port: port,
|
||||
ServiceType: cfg.Defaults.ServiceType,
|
||||
HealthCheck: hc,
|
||||
Resources: cfg.Defaults.Resources,
|
||||
EnvVars: envVars,
|
||||
Ingress: ingress,
|
||||
Infrastructure: infra,
|
||||
CronJobs: cronJobs,
|
||||
TLSIssuer: cfg.Cluster.TLSIssuer,
|
||||
IngressClass: cfg.Cluster.IngressClass,
|
||||
CNPGHost: cfg.Cluster.CNPG.Host,
|
||||
Lifecycle: lifecycle,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Internal resolution helpers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func resolveEnvPrefix(envKey string, override *string) string {
|
||||
if override != nil && *override != "" {
|
||||
return *override
|
||||
}
|
||||
// Default: first 4 chars of envKey, or full key if shorter.
|
||||
if len(envKey) <= 4 {
|
||||
return envKey
|
||||
}
|
||||
return envKey[:4]
|
||||
}
|
||||
|
||||
func resolveNamespace(envKey, explicit, pattern string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
// Apply the namespace pattern (simple token replace here;
|
||||
// full interpolation runs later via the interpolate package).
|
||||
result := pattern
|
||||
if result == "" {
|
||||
return envKey
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveFullName(cfg *KforgeConfig, prefix string, env EnvironmentConfig) string {
|
||||
if cfg.Meta.NameOverride != nil && *cfg.Meta.NameOverride != "" {
|
||||
return *cfg.Meta.NameOverride
|
||||
}
|
||||
return prefix + "-" + cfg.Meta.Tenant + "-" + cfg.Meta.Name
|
||||
}
|
||||
|
||||
func resolveRegistry(cfg *KforgeConfig, env EnvironmentConfig) RegistryConfig {
|
||||
base := cfg.Registry
|
||||
// Resolve the repository token now that we have meta values.
|
||||
if base.Repository == "${tenant}/${name}" || base.Repository == "" {
|
||||
base.Repository = cfg.Meta.Tenant + "/" + cfg.Meta.Name
|
||||
}
|
||||
if env.Registry != nil {
|
||||
r := *env.Registry
|
||||
if r.URL == "" {
|
||||
r.URL = base.URL
|
||||
}
|
||||
if r.Repository == "" {
|
||||
r.Repository = base.Repository
|
||||
}
|
||||
if r.PullSecret == "" {
|
||||
r.PullSecret = base.PullSecret
|
||||
}
|
||||
return r
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// MergeEnvVars is the exported form of mergeEnvVars, used by the
|
||||
// generate command to inject infrastructure env vars into deployments.
|
||||
func MergeEnvVars(base, override []EnvVarConfig) []EnvVarConfig {
|
||||
return mergeEnvVars(base, override)
|
||||
}
|
||||
|
||||
// mergeEnvVars combines base and override slices. If an env var
|
||||
// with the same Name appears in both, the override wins.
|
||||
func mergeEnvVars(base, override []EnvVarConfig) []EnvVarConfig {
|
||||
merged := make([]EnvVarConfig, 0, len(base)+len(override))
|
||||
seen := make(map[string]int) // name → index in merged
|
||||
|
||||
for _, v := range base {
|
||||
seen[v.Name] = len(merged)
|
||||
merged = append(merged, v)
|
||||
}
|
||||
for _, v := range override {
|
||||
if idx, ok := seen[v.Name]; ok {
|
||||
merged[idx] = v // override
|
||||
} else {
|
||||
seen[v.Name] = len(merged)
|
||||
merged = append(merged, v)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// mergeInfrastructure performs a shallow merge of root infra
|
||||
// defaults with per-environment overrides.
|
||||
//
|
||||
// Rules:
|
||||
// - If root has a service block with no explicit enabled:false,
|
||||
// it is enabled in every environment.
|
||||
// - An env block with enabled:false disables the service.
|
||||
// - An env block with partial fields overrides only those fields;
|
||||
// everything else inherits from root.
|
||||
// - If root has no block for a service, env can still enable it
|
||||
// by providing its own block (enabled defaults to true if present).
|
||||
func mergeInfrastructure(root, env *InfrastructureConfig) ResolvedInfrastructure {
|
||||
return ResolvedInfrastructure{
|
||||
Database: mergeDatabase(root.Database, env.Database),
|
||||
Cache: mergeCache(root.Cache, env.Cache),
|
||||
Storage: mergeStorage(root.Storage, env.Storage),
|
||||
Queue: mergeQueue(root.Queue, env.Queue),
|
||||
Search: mergeSearch(root.Search, env.Search),
|
||||
Monitoring: mergeMonitoring(root.Monitoring, env.Monitoring),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeDatabase(root, env *DatabaseInfraConfig) *DatabaseInfraConfig {
|
||||
if root == nil && env == nil {
|
||||
return nil
|
||||
}
|
||||
merged := &DatabaseInfraConfig{}
|
||||
if root != nil {
|
||||
*merged = *root
|
||||
}
|
||||
if env != nil {
|
||||
if env.Enabled != nil {
|
||||
merged.Enabled = env.Enabled
|
||||
}
|
||||
if env.Provider != "" {
|
||||
merged.Provider = env.Provider
|
||||
}
|
||||
if env.DatabaseName != "" {
|
||||
merged.DatabaseName = env.DatabaseName
|
||||
}
|
||||
}
|
||||
if !isEnabled(merged.Enabled) {
|
||||
return nil
|
||||
}
|
||||
if merged.Provider == "" {
|
||||
merged.Provider = "cnpg"
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeCache(root, env *CacheInfraConfig) *CacheInfraConfig {
|
||||
if root == nil && env == nil {
|
||||
return nil
|
||||
}
|
||||
merged := &CacheInfraConfig{}
|
||||
if root != nil {
|
||||
*merged = *root
|
||||
}
|
||||
if env != nil {
|
||||
if env.Enabled != nil {
|
||||
merged.Enabled = env.Enabled
|
||||
}
|
||||
if env.Provider != "" {
|
||||
merged.Provider = env.Provider
|
||||
}
|
||||
if env.Mode != "" {
|
||||
merged.Mode = env.Mode
|
||||
}
|
||||
if env.Replicas != nil {
|
||||
merged.Replicas = env.Replicas
|
||||
}
|
||||
}
|
||||
if !isEnabled(merged.Enabled) {
|
||||
return nil
|
||||
}
|
||||
if merged.Provider == "" {
|
||||
merged.Provider = DefaultCacheProvider
|
||||
}
|
||||
if merged.Mode == "" {
|
||||
merged.Mode = DefaultCacheMode
|
||||
}
|
||||
if merged.Replicas == nil {
|
||||
merged.Replicas = intPtr(1)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeStorage(root, env *StorageInfraConfig) *StorageInfraConfig {
|
||||
if root == nil && env == nil {
|
||||
return nil
|
||||
}
|
||||
merged := &StorageInfraConfig{}
|
||||
if root != nil {
|
||||
*merged = *root
|
||||
}
|
||||
if env != nil {
|
||||
if env.Enabled != nil {
|
||||
merged.Enabled = env.Enabled
|
||||
}
|
||||
if env.Provider != "" {
|
||||
merged.Provider = env.Provider
|
||||
}
|
||||
if env.Mode != "" {
|
||||
merged.Mode = env.Mode
|
||||
}
|
||||
}
|
||||
if !isEnabled(merged.Enabled) {
|
||||
return nil
|
||||
}
|
||||
if merged.Provider == "" {
|
||||
merged.Provider = DefaultStorageProvider
|
||||
}
|
||||
if merged.Mode == "" {
|
||||
merged.Mode = DefaultStorageMode
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeQueue(root, env *QueueInfraConfig) *QueueInfraConfig {
|
||||
if root == nil && env == nil {
|
||||
return nil
|
||||
}
|
||||
merged := &QueueInfraConfig{}
|
||||
if root != nil {
|
||||
*merged = *root
|
||||
}
|
||||
if env != nil {
|
||||
if env.Enabled != nil {
|
||||
merged.Enabled = env.Enabled
|
||||
}
|
||||
if env.Provider != "" {
|
||||
merged.Provider = env.Provider
|
||||
}
|
||||
}
|
||||
if !isEnabled(merged.Enabled) {
|
||||
return nil
|
||||
}
|
||||
if merged.Provider == "" {
|
||||
merged.Provider = DefaultQueueProvider
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeSearch(root, env *SearchInfraConfig) *SearchInfraConfig {
|
||||
if root == nil && env == nil {
|
||||
return nil
|
||||
}
|
||||
merged := &SearchInfraConfig{}
|
||||
if root != nil {
|
||||
*merged = *root
|
||||
}
|
||||
if env != nil {
|
||||
if env.Enabled != nil {
|
||||
merged.Enabled = env.Enabled
|
||||
}
|
||||
if env.Provider != "" {
|
||||
merged.Provider = env.Provider
|
||||
}
|
||||
}
|
||||
if !isEnabled(merged.Enabled) {
|
||||
return nil
|
||||
}
|
||||
if merged.Provider == "" {
|
||||
merged.Provider = DefaultSearchProvider
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeMonitoring(root, env *MonitoringInfraConfig) *MonitoringInfraConfig {
|
||||
if root == nil && env == nil {
|
||||
return nil
|
||||
}
|
||||
merged := &MonitoringInfraConfig{}
|
||||
if root != nil {
|
||||
*merged = *root
|
||||
}
|
||||
if env != nil {
|
||||
if env.Enabled != nil {
|
||||
merged.Enabled = env.Enabled
|
||||
}
|
||||
if env.Provider != "" {
|
||||
merged.Provider = env.Provider
|
||||
}
|
||||
if env.MetricsPath != "" {
|
||||
merged.MetricsPath = env.MetricsPath
|
||||
}
|
||||
if env.MetricsPort != nil {
|
||||
merged.MetricsPort = env.MetricsPort
|
||||
}
|
||||
}
|
||||
if !isEnabled(merged.Enabled) {
|
||||
return nil
|
||||
}
|
||||
if merged.Provider == "" {
|
||||
merged.Provider = DefaultMonProvider
|
||||
}
|
||||
if merged.MetricsPath == "" {
|
||||
merged.MetricsPath = DefaultMetricsPath
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// resolveCronJobs applies defaults to each cron job and merges
|
||||
// env vars from the parent deployment.
|
||||
func resolveCronJobs(jobs []CronJobConfig, deploymentImage string, deploymentEnvVars []EnvVarConfig, defaultResources ResourceConfig) []ResolvedCronJob {
|
||||
resolved := make([]ResolvedCronJob, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
r := ResolvedCronJob{CronJobConfig: job}
|
||||
|
||||
// Image
|
||||
if job.ImageOverride != nil && *job.ImageOverride != "" {
|
||||
r.Image = *job.ImageOverride
|
||||
} else {
|
||||
r.Image = deploymentImage
|
||||
}
|
||||
|
||||
// Env vars: deployment vars first, then job-specific (with merge)
|
||||
inheritEnv := job.InheritEnv == nil || *job.InheritEnv
|
||||
if inheritEnv {
|
||||
r.EnvVars = mergeEnvVars(deploymentEnvVars, job.EnvVars)
|
||||
} else {
|
||||
r.EnvVars = job.EnvVars
|
||||
}
|
||||
|
||||
// Resources
|
||||
if job.Resources == nil {
|
||||
r.Resources = &defaultResources
|
||||
}
|
||||
|
||||
// Tuning defaults
|
||||
if r.RestartPolicy == "" {
|
||||
r.RestartPolicy = DefaultRestartPolicy
|
||||
}
|
||||
if r.ConcurrencyPolicy == "" {
|
||||
r.ConcurrencyPolicy = DefaultConcurrencyPolicy
|
||||
}
|
||||
if r.SuccessfulJobsHistoryLimit == nil {
|
||||
r.SuccessfulJobsHistoryLimit = intPtr(DefaultSuccessfulJobsHist)
|
||||
}
|
||||
if r.FailedJobsHistoryLimit == nil {
|
||||
r.FailedJobsHistoryLimit = intPtr(DefaultFailedJobsHist)
|
||||
}
|
||||
|
||||
resolved = append(resolved, r)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Errors
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type UnknownEnvironmentError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *UnknownEnvironmentError) Error() string {
|
||||
return "unknown environment: " + e.Key
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Load reads a kforge.yml file from disk, applies all defaults,
|
||||
// and returns the parsed config ready for environment resolution.
|
||||
func Load(path string) (*KforgeConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
return LoadBytes(data)
|
||||
}
|
||||
|
||||
// LoadBytes parses kforge.yml from a byte slice. Useful in tests.
|
||||
func LoadBytes(data []byte) (*KforgeConfig, error) {
|
||||
cfg := &KforgeConfig{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing kforge.yml: %w", err)
|
||||
}
|
||||
if err := validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ApplyDefaults(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// validate performs structural validation before defaults are
|
||||
// applied. Returns the first error found.
|
||||
func validate(cfg *KforgeConfig) error {
|
||||
if cfg.Meta.Name == "" {
|
||||
return fmt.Errorf("meta.name is required")
|
||||
}
|
||||
if cfg.Meta.Tenant == "" {
|
||||
return fmt.Errorf("meta.tenant is required")
|
||||
}
|
||||
if len(cfg.Environments) == 0 {
|
||||
return fmt.Errorf("at least one environment must be defined")
|
||||
}
|
||||
for envKey, env := range cfg.Environments {
|
||||
if len(env.Ingress.Hosts) == 0 {
|
||||
return fmt.Errorf("environments.%s: at least one ingress host is required", envKey)
|
||||
}
|
||||
for _, host := range env.Ingress.Hosts {
|
||||
if host.Hostname == "" {
|
||||
return fmt.Errorf("environments.%s: ingress host hostname cannot be empty", envKey)
|
||||
}
|
||||
}
|
||||
for _, job := range env.CronJobs {
|
||||
if job.Name == "" {
|
||||
return fmt.Errorf("environments.%s: cron job is missing a name", envKey)
|
||||
}
|
||||
if job.Schedule == "" {
|
||||
return fmt.Errorf("environments.%s.cron_jobs.%s: schedule is required", envKey, job.Name)
|
||||
}
|
||||
if len(job.Command) == 0 {
|
||||
return fmt.Errorf("environments.%s.cron_jobs.%s: command is required", envKey, job.Name)
|
||||
}
|
||||
}
|
||||
for _, v := range env.EnvVars {
|
||||
if err := validateEnvVar(envKey, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.Defaults.EnvVars {
|
||||
if err := validateEnvVar("defaults", v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEnvVar(context string, v EnvVarConfig) error {
|
||||
if v.Name == "" {
|
||||
return fmt.Errorf("%s: env var is missing a name", context)
|
||||
}
|
||||
switch v.Type {
|
||||
case EnvVarTypePlain, "":
|
||||
// value can technically be empty (empty string env var is valid)
|
||||
case EnvVarTypeSecretRef:
|
||||
if v.SecretName == "" || v.SecretKey == "" {
|
||||
return fmt.Errorf("%s: env var %q (secret_ref) requires secret_name and secret_key", context, v.Name)
|
||||
}
|
||||
case EnvVarTypeConfigMapRef:
|
||||
if v.ConfigMapName == "" || v.ConfigMapKey == "" {
|
||||
return fmt.Errorf("%s: env var %q (configmap_ref) requires configmap_name and configmap_key", context, v.Name)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s: env var %q has unknown type %q (valid: plain, secret_ref, configmap_ref)", context, v.Name, v.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnvironmentKeys returns the sorted list of environment names.
|
||||
func EnvironmentKeys(cfg *KforgeConfig) []string {
|
||||
keys := make([]string, 0, len(cfg.Environments))
|
||||
for k := range cfg.Environments {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package config
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Root config — mirrors kforge.yml top-level structure
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// KforgeConfig is the top-level struct unmarshalled from kforge.yml.
|
||||
type KforgeConfig struct {
|
||||
Meta MetaConfig `yaml:"meta"`
|
||||
Registry RegistryConfig `yaml:"registry"`
|
||||
DNS DNSConfig `yaml:"dns"`
|
||||
Cluster ClusterConfig `yaml:"cluster"`
|
||||
Defaults DefaultsConfig `yaml:"defaults"`
|
||||
Infrastructure InfrastructureConfig `yaml:"infrastructure"`
|
||||
Environments map[string]EnvironmentConfig `yaml:"environments"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Meta
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type MetaConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Tenant string `yaml:"tenant"`
|
||||
NameOverride *string `yaml:"name_override,omitempty"`
|
||||
PreviousName *string `yaml:"previous_name,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Registry
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type RegistryConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
Repository string `yaml:"repository,omitempty"` // default: ${tenant}/${name}
|
||||
PullSecret string `yaml:"pull_secret,omitempty"` // default: regcred
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// DNS
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type DNSConfig struct {
|
||||
Provider string `yaml:"provider"` // "cloudflare"
|
||||
Cloudflare CloudflareConfig `yaml:"cloudflare"`
|
||||
NodeIP string `yaml:"node_ip,omitempty"` // default: ${KFORGE_NODE_IP}
|
||||
SkipDNS bool `yaml:"skip_dns,omitempty"`
|
||||
}
|
||||
|
||||
type CloudflareConfig struct {
|
||||
APIToken string `yaml:"api_token"`
|
||||
Zones []ZoneEntry `yaml:"zones"`
|
||||
Proxied bool `yaml:"proxied,omitempty"`
|
||||
}
|
||||
|
||||
type ZoneEntry struct {
|
||||
Name string `yaml:"name"`
|
||||
ZoneID string `yaml:"zone_id"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Cluster
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type ClusterConfig struct {
|
||||
TLSIssuer string `yaml:"tls_issuer,omitempty"` // default: letsencrypt-prod
|
||||
IngressClass string `yaml:"ingress_class,omitempty"` // default: nginx
|
||||
CNPG CNPGConfig `yaml:"cnpg"`
|
||||
NamespacePattern string `yaml:"namespace_pattern,omitempty"` // default: "${env}"
|
||||
}
|
||||
|
||||
type CNPGConfig struct {
|
||||
Host string `yaml:"host,omitempty"` // default: cnpg-main-rw.default.svc.cluster.local
|
||||
HostOverride *string `yaml:"host_override,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Defaults
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type DefaultsConfig struct {
|
||||
ImagePullPolicy string `yaml:"image_pull_policy,omitempty"` // default: Always
|
||||
Replicas *int `yaml:"replicas,omitempty"` // default: 1
|
||||
Port *int `yaml:"port,omitempty"` // default: 3000
|
||||
ServiceType string `yaml:"service_type,omitempty"` // default: ClusterIP
|
||||
Dockerfile string `yaml:"dockerfile,omitempty"` // default: Dockerfile
|
||||
HealthCheck HealthCheckConfig `yaml:"health_check"`
|
||||
Resources ResourceConfig `yaml:"resources"`
|
||||
EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"`
|
||||
}
|
||||
|
||||
type HealthCheckConfig struct {
|
||||
Path string `yaml:"path,omitempty"` // default: /healthcheck
|
||||
Port *int `yaml:"port,omitempty"` // default: defaults.port
|
||||
InitialDelaySeconds int `yaml:"initial_delay_seconds,omitempty"` // default: 15
|
||||
PeriodSeconds int `yaml:"period_seconds,omitempty"` // default: 10
|
||||
TimeoutSeconds int `yaml:"timeout_seconds,omitempty"` // default: 5
|
||||
FailureThreshold int `yaml:"failure_threshold,omitempty"` // default: 3
|
||||
Liveness *bool `yaml:"liveness,omitempty"` // default: true
|
||||
Readiness *bool `yaml:"readiness,omitempty"` // default: true
|
||||
}
|
||||
|
||||
type ResourceConfig struct {
|
||||
Requests ResourceRequirements `yaml:"requests"`
|
||||
Limits ResourceRequirements `yaml:"limits"`
|
||||
}
|
||||
|
||||
type ResourceRequirements struct {
|
||||
CPU string `yaml:"cpu,omitempty"`
|
||||
Memory string `yaml:"memory,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Env vars — three source types
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type EnvVarType string
|
||||
|
||||
const (
|
||||
EnvVarTypePlain EnvVarType = "plain"
|
||||
EnvVarTypeSecretRef EnvVarType = "secret_ref"
|
||||
EnvVarTypeConfigMapRef EnvVarType = "configmap_ref"
|
||||
)
|
||||
|
||||
type EnvVarConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Type EnvVarType `yaml:"type"` // plain | secret_ref | configmap_ref
|
||||
Value string `yaml:"value,omitempty"` // plain only
|
||||
|
||||
// secret_ref
|
||||
SecretName string `yaml:"secret_name,omitempty"`
|
||||
SecretKey string `yaml:"secret_key,omitempty"`
|
||||
|
||||
// configmap_ref
|
||||
ConfigMapName string `yaml:"configmap_name,omitempty"`
|
||||
ConfigMapKey string `yaml:"configmap_key,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Infrastructure — root defaults + per-env overrides
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// InfrastructureConfig holds the full infra config for either
|
||||
// the root defaults block or a per-environment override block.
|
||||
// Pointers are used so we can distinguish "not set" from "false".
|
||||
type InfrastructureConfig struct {
|
||||
Database *DatabaseInfraConfig `yaml:"database,omitempty"`
|
||||
Cache *CacheInfraConfig `yaml:"cache,omitempty"`
|
||||
Storage *StorageInfraConfig `yaml:"storage,omitempty"`
|
||||
Queue *QueueInfraConfig `yaml:"queue,omitempty"`
|
||||
Search *SearchInfraConfig `yaml:"search,omitempty"`
|
||||
Monitoring *MonitoringInfraConfig `yaml:"monitoring,omitempty"`
|
||||
}
|
||||
|
||||
// InfraBase holds the common enabled flag present on every
|
||||
// infrastructure service. Embedded in each service config.
|
||||
type InfraBase struct {
|
||||
// Enabled defaults to true if the block exists in the root
|
||||
// infrastructure section, and inherits that value in envs.
|
||||
// Set explicitly to false to disable for a specific env.
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type DatabaseInfraConfig struct {
|
||||
InfraBase `yaml:",inline"`
|
||||
Provider string `yaml:"provider,omitempty"` // cnpg
|
||||
DatabaseName string `yaml:"database_name,omitempty"` // default: ${full_name} slugified
|
||||
}
|
||||
|
||||
type CacheInfraConfig struct {
|
||||
InfraBase `yaml:",inline"`
|
||||
Provider string `yaml:"provider,omitempty"` // valkey | redis
|
||||
Mode string `yaml:"mode,omitempty"` // standalone | cluster
|
||||
Replicas *int `yaml:"replicas,omitempty"` // cluster mode only
|
||||
}
|
||||
|
||||
type StorageInfraConfig struct {
|
||||
InfraBase `yaml:",inline"`
|
||||
Provider string `yaml:"provider,omitempty"` // minio
|
||||
Mode string `yaml:"mode,omitempty"` // standalone | distributed
|
||||
}
|
||||
|
||||
type QueueInfraConfig struct {
|
||||
InfraBase `yaml:",inline"`
|
||||
Provider string `yaml:"provider,omitempty"` // nats | rabbitmq
|
||||
}
|
||||
|
||||
type SearchInfraConfig struct {
|
||||
InfraBase `yaml:",inline"`
|
||||
Provider string `yaml:"provider,omitempty"` // meilisearch
|
||||
}
|
||||
|
||||
type MonitoringInfraConfig struct {
|
||||
InfraBase `yaml:",inline"`
|
||||
Provider string `yaml:"provider,omitempty"` // prometheus
|
||||
MetricsPath string `yaml:"metrics_path,omitempty"` // default: /metrics
|
||||
MetricsPort *int `yaml:"metrics_port,omitempty"` // default: defaults.port
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Environment
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type EnvironmentConfig struct {
|
||||
// EnvPrefix overrides the short prefix used in ${env_prefix}.
|
||||
// Defaults to first 4 chars of the environment key if omitted.
|
||||
EnvPrefix *string `yaml:"env_prefix,omitempty"`
|
||||
|
||||
Namespace string `yaml:"namespace,omitempty"` // overrides cluster.namespace_pattern
|
||||
Replicas *int `yaml:"replicas,omitempty"`
|
||||
ImageTag string `yaml:"image_tag,omitempty"` // default: latest
|
||||
|
||||
// Registry override: overrides the root registry for this env.
|
||||
Registry *RegistryConfig `yaml:"registry,omitempty"`
|
||||
|
||||
EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"`
|
||||
|
||||
Ingress IngressConfig `yaml:"ingress"`
|
||||
Infrastructure InfrastructureConfig `yaml:"infrastructure"`
|
||||
CronJobs []CronJobConfig `yaml:"cron_jobs,omitempty"`
|
||||
Lifecycle LifecycleConfig `yaml:"lifecycle"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Ingress
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type IngressConfig struct {
|
||||
Hosts []IngressHost `yaml:"hosts"`
|
||||
Auth IngressAuth `yaml:"auth"`
|
||||
}
|
||||
|
||||
type IngressHost struct {
|
||||
Hostname string `yaml:"hostname"`
|
||||
TLS bool `yaml:"tls"`
|
||||
DNSRecord bool `yaml:"dns_record,omitempty"`
|
||||
}
|
||||
|
||||
type IngressAuth struct {
|
||||
Enabled bool `yaml:"enabled,omitempty"`
|
||||
Users []string `yaml:"users,omitempty"`
|
||||
SecretName string `yaml:"secret_name,omitempty"` // default: ${full_name}-basic-auth
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// CronJob
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type CronJobConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Schedule string `yaml:"schedule"`
|
||||
Command []string `yaml:"command"`
|
||||
ImageOverride *string `yaml:"image_override,omitempty"`
|
||||
InheritEnv *bool `yaml:"inherit_env,omitempty"` // default: true
|
||||
EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"`
|
||||
Resources *ResourceConfig `yaml:"resources,omitempty"` // inherits defaults if nil
|
||||
|
||||
// Kubernetes CronJob tuning
|
||||
RestartPolicy string `yaml:"restart_policy,omitempty"` // default: OnFailure
|
||||
ConcurrencyPolicy string `yaml:"concurrency_policy,omitempty"` // default: Forbid
|
||||
SuccessfulJobsHistoryLimit *int `yaml:"successful_jobs_history,omitempty"` // default: 3
|
||||
FailedJobsHistoryLimit *int `yaml:"failed_jobs_history,omitempty"` // default: 1
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type LifecycleConfig struct {
|
||||
// If false (default), kforge renames resources when meta.name changes.
|
||||
// If true, kforge deletes old resources after delete_grace_seconds.
|
||||
Delete bool `yaml:"delete,omitempty"`
|
||||
DeleteGraceSeconds int `yaml:"delete_grace_seconds,omitempty"` // default: 300
|
||||
}
|
||||
Reference in New Issue
Block a user