more updates
Publish Action Image / build (push) Successful in 1m8s

This commit is contained in:
2026-06-29 15:14:55 +10:00
parent 01223c176f
commit 4ee1b9e13c
15 changed files with 1155 additions and 832 deletions
+129 -55
View File
@@ -1,5 +1,12 @@
package config
import (
"fmt"
"strings"
"kforge/pkg/interpolate"
)
// ------------------------------------------------------------
// Default values — single source of truth for every default
// referenced in the schema. Change a default here and it
@@ -10,6 +17,8 @@ const (
DefaultTLSIssuer = "letsencrypt-prod"
DefaultIngressClass = "nginx"
DefaultCNPGHost = "cnpg-main-rw.default.svc.cluster.local"
DefaultCNPGClusterName = "cnpg-main"
DefaultCNPGSuperuserSecret = "cnpg-main-superuser"
DefaultNamespacePattern = "${env}"
DefaultImagePullPolicy = "Always"
DefaultServiceType = "ClusterIP"
@@ -19,15 +28,15 @@ const (
DefaultHealthCheckPath = "/healthcheck"
DefaultRegistryURL = "registry.natelubitz.com"
DefaultPort = 3000
DefaultReplicas = 1
DefaultInitialDelaySecs = 15
DefaultPeriodSecs = 10
DefaultTimeoutSecs = 5
DefaultFailureThreshold = 3
DefaultDeleteGraceSecs = 300
DefaultSuccessfulJobsHist = 3
DefaultFailedJobsHist = 1
DefaultPort = 3000
DefaultReplicas = 1
DefaultInitialDelaySecs = 15
DefaultPeriodSecs = 10
DefaultTimeoutSecs = 5
DefaultFailureThreshold = 3
DefaultDeleteGraceSecs = 300
DefaultSuccessfulJobsHist = 3
DefaultFailedJobsHist = 1
DefaultCacheProvider = "valkey"
DefaultCacheMode = "standalone"
@@ -40,6 +49,8 @@ const (
DefaultRestartPolicy = "OnFailure"
DefaultConcurrencyPolicy = "Forbid"
DefaultPreviewNamespacePrefix = "preview-pr"
)
// boolPtr / intPtr are helpers for pointer defaults.
@@ -76,6 +87,12 @@ func applyClusterDefaults(c *ClusterConfig) {
if c.CNPG.Host == "" {
c.CNPG.Host = DefaultCNPGHost
}
if c.CNPG.ClusterName == "" {
c.CNPG.ClusterName = DefaultCNPGClusterName
}
if c.CNPG.SuperuserSecret == "" {
c.CNPG.SuperuserSecret = DefaultCNPGSuperuserSecret
}
if c.NamespacePattern == "" {
c.NamespacePattern = DefaultNamespacePattern
}
@@ -156,9 +173,7 @@ func applyResourceDefaults(r *ResourceConfig) {
}
// 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.
// infrastructure block.
func applyRootInfraDefaults(infra *InfrastructureConfig) {
if infra.Cache != nil {
if infra.Cache.Provider == "" {
@@ -240,9 +255,15 @@ type ResolvedEnvironment struct {
CronJobs []ResolvedCronJob
// Cluster-level settings (carried through for generators)
TLSIssuer string
IngressClass string
CNPGHost string
TLSIssuer string
IngressClass string
CNPGHost string
CNPGClusterName string
CNPGSuperuserSecret string
// DNS (for external-dns Ingress annotations)
DNSTarget string
SkipDNS bool
// Lifecycle
Lifecycle LifecycleConfig
@@ -318,30 +339,101 @@ func ResolveEnvironment(cfg *KforgeConfig, envKey string) (ResolvedEnvironment,
Auth: auth,
}
// Resolve DNS target token (${KFORGE_NODE_IP} etc.) from env.
tokens := interpolate.FromEnvironment(
cfg.Meta.Name, cfg.Meta.Tenant, envKey, prefix, fullName, imageTag, namespace,
)
dnsTarget := interpolate.Apply(cfg.DNS.Target, tokens)
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,
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,
CNPGClusterName: cfg.Cluster.CNPG.ClusterName,
CNPGSuperuserSecret: cfg.Cluster.CNPG.SuperuserSecret,
DNSTarget: dnsTarget,
SkipDNS: cfg.DNS.SkipDNS,
Lifecycle: lifecycle,
}, nil
}
// ------------------------------------------------------------
// SynthesizePreviewEnvironment creates a ResolvedEnvironment
// for a PR preview from the preview config + a base environment.
// ------------------------------------------------------------
// SynthesizePreviewEnvironment derives a preview environment by
// cloning the base environment and overriding namespace, hostname,
// full name, and image tag for the given PR number.
func SynthesizePreviewEnvironment(cfg *KforgeConfig, prNumber string) (ResolvedEnvironment, error) {
if !cfg.Preview.Enabled {
return ResolvedEnvironment{}, fmt.Errorf("preview is not enabled in kforge.yml")
}
if cfg.Preview.BaseEnvironment == "" {
return ResolvedEnvironment{}, fmt.Errorf("preview.base_environment is required")
}
env, err := ResolveEnvironment(cfg, cfg.Preview.BaseEnvironment)
if err != nil {
return ResolvedEnvironment{}, fmt.Errorf("resolving base environment %q: %w", cfg.Preview.BaseEnvironment, err)
}
nsPrefix := cfg.Preview.NamespacePrefix
if nsPrefix == "" {
nsPrefix = DefaultPreviewNamespacePrefix
}
env.EnvKey = "preview-" + prNumber
env.Namespace = nsPrefix + "-" + prNumber
env.FullName = "pr" + prNumber + "-" + cfg.Meta.Tenant + "-" + cfg.Meta.Name
// Override image tag to a PR-specific tag.
if idx := strings.LastIndex(env.Image, ":"); idx >= 0 {
env.Image = env.Image[:idx+1] + "pr-" + prNumber
}
// Override ingress hostname using the template.
hostnameTemplate := cfg.Preview.HostnameTemplate
if hostnameTemplate == "" {
hostnameTemplate = "pr-${PR_NUMBER}.${name}.example.com"
}
previewTokens := interpolate.Tokens{
"PR_NUMBER": prNumber,
"name": cfg.Meta.Name,
"tenant": cfg.Meta.Tenant,
}
hostname := interpolate.Apply(hostnameTemplate, previewTokens)
env.Ingress = IngressConfig{
Hosts: []IngressHost{{
Hostname: hostname,
TLS: true,
DNSRecord: true,
}},
Auth: IngressAuth{
SecretName: env.FullName + "-basic-auth",
},
}
return env, nil
}
// ------------------------------------------------------------
// Internal resolution helpers
// ------------------------------------------------------------
@@ -350,7 +442,6 @@ 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
}
@@ -361,13 +452,10 @@ 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 == "" {
if pattern == "" {
return envKey
}
return result
return pattern
}
func resolveFullName(cfg *KforgeConfig, prefix string, env EnvironmentConfig) string {
@@ -379,7 +467,6 @@ func resolveFullName(cfg *KforgeConfig, prefix string, env EnvironmentConfig) st
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
}
@@ -428,15 +515,6 @@ func mergeEnvVars(base, override []EnvVarConfig) []EnvVarConfig {
// 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),
@@ -635,14 +713,12 @@ func resolveCronJobs(jobs []CronJobConfig, deploymentImage string, deploymentEnv
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)
@@ -650,12 +726,10 @@ func resolveCronJobs(jobs []CronJobConfig, deploymentImage string, deploymentEnv
r.EnvVars = job.EnvVars
}
// Resources
if job.Resources == nil {
r.Resources = &defaultResources
}
// Tuning defaults
if r.RestartPolicy == "" {
r.RestartPolicy = DefaultRestartPolicy
}
+73 -54
View File
@@ -6,13 +6,14 @@ package config
// 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 MetaConfig `yaml:"meta"`
Registry RegistryConfig `yaml:"registry"`
DNS DNSConfig `yaml:"dns"`
Cluster ClusterConfig `yaml:"cluster"`
Defaults DefaultsConfig `yaml:"defaults"`
Infrastructure InfrastructureConfig `yaml:"infrastructure"`
Preview PreviewConfig `yaml:"preview,omitempty"`
Environments map[string]EnvironmentConfig `yaml:"environments"`
}
// ------------------------------------------------------------
@@ -38,24 +39,21 @@ type RegistryConfig struct {
// ------------------------------------------------------------
// DNS
//
// DNS records and TLS are managed inside the cluster:
// - external-dns reads Ingress annotations and creates DNS records
// - cert-manager issues TLS certificates via a ClusterIssuer
//
// kforge emits the appropriate annotations on the Ingress resource
// for each host with dns_record: true.
// ------------------------------------------------------------
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"`
// Target is the value written to the external-dns target annotation.
// Typically the cluster node's public IP or a static hostname.
// Supports token interpolation (e.g. ${KFORGE_NODE_IP}).
Target string `yaml:"target,omitempty"`
SkipDNS bool `yaml:"skip_dns,omitempty"` // true = no external-dns annotations
}
// ------------------------------------------------------------
@@ -63,15 +61,16 @@ type ZoneEntry struct {
// ------------------------------------------------------------
type ClusterConfig struct {
TLSIssuer string `yaml:"tls_issuer,omitempty"` // default: letsencrypt-prod
IngressClass string `yaml:"ingress_class,omitempty"` // default: nginx
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"`
Host string `yaml:"host,omitempty"` // default: cnpg-main-rw.default.svc.cluster.local
ClusterName string `yaml:"cluster_name,omitempty"` // default: cnpg-main
SuperuserSecret string `yaml:"superuser_secret,omitempty"` // default: cnpg-main-superuser
}
// ------------------------------------------------------------
@@ -79,19 +78,19 @@ type CNPGConfig struct {
// ------------------------------------------------------------
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
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"`
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
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
@@ -155,9 +154,6 @@ type InfrastructureConfig struct {
// 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"`
}
@@ -197,6 +193,31 @@ type MonitoringInfraConfig struct {
MetricsPort *int `yaml:"metrics_port,omitempty"` // default: defaults.port
}
// ------------------------------------------------------------
// Preview environments
// ------------------------------------------------------------
// PreviewConfig defines how PR preview environments are deployed.
// When enabled, `kforge gitea-preview` generates a workflow that
// deploys an isolated environment per pull request.
type PreviewConfig struct {
// Enabled must be true for kforge gitea-preview to generate a workflow.
Enabled bool `yaml:"enabled,omitempty"`
// BaseEnvironment is the environment whose infrastructure and env var
// settings are inherited (e.g. "staging"). Required when enabled.
BaseEnvironment string `yaml:"base_environment,omitempty"`
// NamespacePrefix is prepended to the PR number to form the namespace.
// Default: "preview-pr" → namespace "preview-pr-123".
NamespacePrefix string `yaml:"namespace_prefix,omitempty"`
// HostnameTemplate is the ingress hostname for the preview, using
// ${PR_NUMBER}, ${name}, and ${tenant} as substitution tokens.
// Example: "pr-${PR_NUMBER}.${name}.example.com"
HostnameTemplate string `yaml:"hostname_template,omitempty"`
}
// ------------------------------------------------------------
// Environment
// ------------------------------------------------------------
@@ -215,10 +236,10 @@ type EnvironmentConfig struct {
EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"`
Ingress IngressConfig `yaml:"ingress"`
Ingress IngressConfig `yaml:"ingress"`
Infrastructure InfrastructureConfig `yaml:"infrastructure"`
CronJobs []CronJobConfig `yaml:"cron_jobs,omitempty"`
Lifecycle LifecycleConfig `yaml:"lifecycle"`
CronJobs []CronJobConfig `yaml:"cron_jobs,omitempty"`
Lifecycle LifecycleConfig `yaml:"lifecycle"`
}
// ------------------------------------------------------------
@@ -233,7 +254,7 @@ type IngressConfig struct {
type IngressHost struct {
Hostname string `yaml:"hostname"`
TLS bool `yaml:"tls"`
DNSRecord bool `yaml:"dns_record,omitempty"`
DNSRecord bool `yaml:"dns_record,omitempty"` // emit external-dns annotations
}
type IngressAuth struct {
@@ -247,19 +268,19 @@ type IngressAuth struct {
// ------------------------------------------------------------
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"`
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
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
}
// ------------------------------------------------------------
@@ -267,8 +288,6 @@ type CronJobConfig struct {
// ------------------------------------------------------------
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
}