+129
-55
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
// Package dns provides a provider-agnostic interface for DNS
|
||||
// record management, with a Cloudflare implementation.
|
||||
//
|
||||
// Adding a new provider (Route53, Porkbun, etc.):
|
||||
// 1. Implement the Provider interface below.
|
||||
// 2. Add a case in NewProvider().
|
||||
// 3. The rest of kforge uses Provider — no other changes needed.
|
||||
package dns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kforge/internal/config"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Provider interface
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Provider is the DNS provider contract. Implementations must
|
||||
// be idempotent — calling EnsureARecord twice with the same
|
||||
// inputs must not error or create duplicates.
|
||||
type Provider interface {
|
||||
// EnsureARecord creates an A record for hostname pointing to
|
||||
// ip if one does not already exist. If a record exists with a
|
||||
// different IP, it is updated. No-ops if already correct.
|
||||
EnsureARecord(hostname, ip string) error
|
||||
|
||||
// DeleteARecord removes the A record for hostname if it exists.
|
||||
// No-ops if it does not exist.
|
||||
DeleteARecord(hostname string) error
|
||||
}
|
||||
|
||||
// NewProvider returns the configured DNS provider.
|
||||
func NewProvider(cfg config.DNSConfig) (Provider, error) {
|
||||
switch cfg.Provider {
|
||||
case "cloudflare":
|
||||
return newCloudflareProvider(cfg)
|
||||
case "":
|
||||
return &noopProvider{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown dns provider %q (supported: cloudflare)", cfg.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// noopProvider satisfies the interface when DNS management is
|
||||
// disabled (skip_dns: true or no provider configured).
|
||||
type noopProvider struct{}
|
||||
|
||||
func (n *noopProvider) EnsureARecord(hostname, ip string) error { return nil }
|
||||
func (n *noopProvider) DeleteARecord(hostname string) error { return nil }
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Cloudflare implementation
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const cfAPIBase = "https://api.cloudflare.com/client/v4"
|
||||
|
||||
type cloudflareProvider struct {
|
||||
apiToken string
|
||||
zones []config.ZoneEntry // sorted longest-first for matching
|
||||
proxied bool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newCloudflareProvider(cfg config.DNSConfig) (*cloudflareProvider, error) {
|
||||
if cfg.Cloudflare.APIToken == "" {
|
||||
return nil, fmt.Errorf("cloudflare.api_token is required")
|
||||
}
|
||||
if len(cfg.Cloudflare.Zones) == 0 {
|
||||
return nil, fmt.Errorf("cloudflare.zones must have at least one entry")
|
||||
}
|
||||
return &cloudflareProvider{
|
||||
apiToken: cfg.Cloudflare.APIToken,
|
||||
zones: cfg.Cloudflare.Zones,
|
||||
proxied: cfg.Cloudflare.Proxied,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// zoneForHostname finds the zone whose name is the longest suffix
|
||||
// of hostname. This handles both "app.example.com" → "example.com"
|
||||
// and "app.sub.example.co.uk" → "example.co.uk" if that zone exists.
|
||||
func (c *cloudflareProvider) zoneForHostname(hostname string) (config.ZoneEntry, error) {
|
||||
var best config.ZoneEntry
|
||||
bestLen := 0
|
||||
for _, z := range c.zones {
|
||||
if strings.HasSuffix(hostname, z.Name) && len(z.Name) > bestLen {
|
||||
best = z
|
||||
bestLen = len(z.Name)
|
||||
}
|
||||
}
|
||||
if bestLen == 0 {
|
||||
return config.ZoneEntry{}, fmt.Errorf("no configured zone matches hostname %q", hostname)
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// EnsureARecord is idempotent: creates if absent, updates if IP
|
||||
// differs, no-ops if already correct.
|
||||
func (c *cloudflareProvider) EnsureARecord(hostname, ip string) error {
|
||||
zone, err := c.zoneForHostname(hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := c.getRecord(zone.ZoneID, hostname, "A")
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking existing record: %w", err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
if existing.Content == ip {
|
||||
fmt.Printf(" dns: A record %s → %s already correct, skipping\n", hostname, ip)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" dns: updating A record %s → %s (was %s)\n", hostname, ip, existing.Content)
|
||||
return c.updateRecord(zone.ZoneID, existing.ID, hostname, ip)
|
||||
}
|
||||
|
||||
fmt.Printf(" dns: creating A record %s → %s\n", hostname, ip)
|
||||
return c.createRecord(zone.ZoneID, hostname, ip)
|
||||
}
|
||||
|
||||
// DeleteARecord removes the A record for hostname if it exists.
|
||||
func (c *cloudflareProvider) DeleteARecord(hostname string) error {
|
||||
zone, err := c.zoneForHostname(hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := c.getRecord(zone.ZoneID, hostname, "A")
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking existing record: %w", err)
|
||||
}
|
||||
if existing == nil {
|
||||
fmt.Printf(" dns: A record %s not found, skipping delete\n", hostname)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" dns: deleting A record %s\n", hostname)
|
||||
return c.deleteRecord(zone.ZoneID, existing.ID)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Cloudflare API helpers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type cfRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Proxied bool `json:"proxied"`
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
|
||||
type cfListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []cfError `json:"errors"`
|
||||
Result []cfRecord `json:"result"`
|
||||
}
|
||||
|
||||
type cfSingleResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []cfError `json:"errors"`
|
||||
Result cfRecord `json:"result"`
|
||||
}
|
||||
|
||||
type cfError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e cfError) Error() string {
|
||||
return fmt.Sprintf("CF %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) getRecord(zoneID, name, recType string) (*cfRecord, error) {
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records?type=%s&name=%s", cfAPIBase, zoneID, recType, name)
|
||||
resp, err := c.do("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var list cfListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
if !list.Success {
|
||||
return nil, cfErrors(list.Errors)
|
||||
}
|
||||
if len(list.Result) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &list.Result[0], nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) createRecord(zoneID, name, ip string) error {
|
||||
body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`,
|
||||
name, ip, c.proxied)
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID)
|
||||
resp, err := c.do("POST", url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result cfSingleResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
if !result.Success {
|
||||
return cfErrors(result.Errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) updateRecord(zoneID, recordID, name, ip string) error {
|
||||
body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`,
|
||||
name, ip, c.proxied)
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
|
||||
resp, err := c.do("PUT", url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result cfSingleResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
if !result.Success {
|
||||
return cfErrors(result.Errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) deleteRecord(zoneID, recordID string) error {
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
|
||||
resp, err := c.do("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Cloudflare returns {"result":{"id":"..."}} on success — we
|
||||
// don't need to parse it, just check for HTTP errors.
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("delete failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) do(method, url string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cloudflare API %s %s: %w", method, url, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func cfErrors(errs []cfError) error {
|
||||
msgs := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
msgs[i] = e.Error()
|
||||
}
|
||||
return fmt.Errorf("cloudflare API errors: %s", strings.Join(msgs, "; "))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// EnsureRecordsForEnvironment — high-level helper used by
|
||||
// the apply command and generate pipeline.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// EnsureRecordsForEnvironment creates A records for all ingress
|
||||
// hosts in the environment that have dns_record: true.
|
||||
func EnsureRecordsForEnvironment(provider Provider, env *config.ResolvedEnvironment, nodeIP string) error {
|
||||
for _, host := range env.Ingress.Hosts {
|
||||
if !host.DNSRecord {
|
||||
continue
|
||||
}
|
||||
if err := provider.EnsureARecord(host.Hostname, nodeIP); err != nil {
|
||||
return fmt.Errorf("ensuring A record for %s: %w", host.Hostname, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -23,9 +23,8 @@ type GiteaActionsOptions struct {
|
||||
// that builds the Docker image and deploys to each environment
|
||||
// using kforge generate + kubectl apply.
|
||||
//
|
||||
// The generated file is designed to replace your existing
|
||||
// hand-written workflow. It assumes kforge is available in the
|
||||
// PATH (either pre-installed on the runner or fetched as a step).
|
||||
// DNS is handled by external-dns via Ingress annotations — no
|
||||
// separate DNS step is needed in the workflow.
|
||||
func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (string, error) {
|
||||
if opts.Branch == "" {
|
||||
opts.Branch = "main"
|
||||
@@ -39,20 +38,19 @@ func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (s
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
writeGiteaHeader(&b, cfg, opts)
|
||||
writeGiteaJobs(&b, cfg, opts)
|
||||
writeDeployHeader(&b, cfg, opts)
|
||||
writeDeployJobs(&b, cfg, opts)
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func writeGiteaHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
|
||||
func writeDeployHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
|
||||
fmt.Fprintf(b, "# Generated by kforge — do not edit manually.\n")
|
||||
fmt.Fprintf(b, "# Re-generate: kforge gitea-actions > .gitea/workflows/deploy.yml\n")
|
||||
fmt.Fprintf(b, "#\n")
|
||||
fmt.Fprintf(b, "# Required Gitea org secrets:\n")
|
||||
fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD, KFORGE_NODE_IP\n")
|
||||
fmt.Fprintf(b, "# CLOUDFLARE_API_TOKEN, CF_ZONE_ID_* (per zone)\n")
|
||||
fmt.Fprintf(b, "# SOPS_AGE_KEY\n")
|
||||
fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD\n")
|
||||
fmt.Fprintf(b, "# KFORGE_NODE_IP (external-dns annotation target)\n")
|
||||
fmt.Fprintf(b, "# Required Gitea repo secrets:\n")
|
||||
fmt.Fprintf(b, "# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n")
|
||||
fmt.Fprintf(b, "\n")
|
||||
@@ -65,30 +63,21 @@ func writeGiteaHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaAc
|
||||
fmt.Fprintf(b, "\n")
|
||||
}
|
||||
|
||||
func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
|
||||
func writeDeployJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
|
||||
fmt.Fprintf(b, "jobs:\n")
|
||||
fmt.Fprintf(b, " build-and-deploy:\n")
|
||||
fmt.Fprintf(b, " runs-on: ubuntu-latest\n")
|
||||
fmt.Fprintf(b, " steps:\n")
|
||||
|
||||
// Checkout
|
||||
writeStep(b, "Checkout", map[string]any{
|
||||
"uses": "actions/checkout@v4",
|
||||
"with": map[string]any{"fetch-depth": 0},
|
||||
})
|
||||
|
||||
// Node (optional — only if package.json exists)
|
||||
writeStep(b, "Setup Node", map[string]any{
|
||||
"uses": "actions/setup-node@v4",
|
||||
"with": map[string]any{"node-version": opts.NodeVersion},
|
||||
})
|
||||
|
||||
// Short SHA
|
||||
writeStep(b, "Create short commit hash", map[string]any{
|
||||
"run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`,
|
||||
})
|
||||
|
||||
// Docker login
|
||||
writeStep(b, "Login to registry", map[string]any{
|
||||
"uses": "docker/login-action@v2",
|
||||
"with": map[string]any{
|
||||
@@ -98,7 +87,6 @@ func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActi
|
||||
},
|
||||
})
|
||||
|
||||
// Docker build + push
|
||||
fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name
|
||||
writeStep(b, "Build and push image", map[string]any{
|
||||
"uses": "docker/build-push-action@v5",
|
||||
@@ -113,12 +101,10 @@ func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActi
|
||||
},
|
||||
})
|
||||
|
||||
// Install kforge on runner
|
||||
writeStep(b, "Install kforge", map[string]any{
|
||||
"run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge",
|
||||
})
|
||||
|
||||
// Per-environment deploy steps
|
||||
for _, envKey := range opts.Environments {
|
||||
env, err := config.ResolveEnvironment(cfg, envKey)
|
||||
if err != nil {
|
||||
@@ -129,45 +115,26 @@ func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActi
|
||||
}
|
||||
|
||||
func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *config.ResolvedEnvironment, envKey string) {
|
||||
label := strings.Title(envKey) //nolint:staticcheck // simple capitalisation
|
||||
label := strings.Title(envKey) //nolint:staticcheck
|
||||
|
||||
// Validate kforge config before doing anything destructive.
|
||||
writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{
|
||||
"run": "kforge validate",
|
||||
"env": giteaSecretEnv(),
|
||||
"env": giteaNodeIPEnv(),
|
||||
})
|
||||
|
||||
// Apply cluster secrets (only creates if missing — idempotent).
|
||||
writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{
|
||||
"run": fmt.Sprintf("kforge secrets apply --env %s", envKey),
|
||||
"env": giteaKubeEnv(),
|
||||
})
|
||||
|
||||
// DNS records
|
||||
hasDNSHosts := false
|
||||
for _, h := range env.Ingress.Hosts {
|
||||
if h.DNSRecord {
|
||||
hasDNSHosts = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasDNSHosts && !cfg.DNS.SkipDNS {
|
||||
writeStep(b, fmt.Sprintf("Ensure DNS records (%s)", label), map[string]any{
|
||||
"run": fmt.Sprintf("kforge dns ensure --env %s", envKey),
|
||||
"env": mergeMaps(giteaSecretEnv(), giteaKubeEnv()),
|
||||
})
|
||||
}
|
||||
|
||||
// Generate manifests
|
||||
writeStep(b, fmt.Sprintf("Generate manifests (%s)", label), map[string]any{
|
||||
"run": fmt.Sprintf(
|
||||
"kforge generate --env %s --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}",
|
||||
envKey,
|
||||
),
|
||||
"env": giteaSecretEnv(),
|
||||
"env": giteaNodeIPEnv(),
|
||||
})
|
||||
|
||||
// kubectl apply
|
||||
writeStep(b, fmt.Sprintf("Apply manifests (%s)", label), map[string]any{
|
||||
"uses": "actions-hub/kubectl@master",
|
||||
"env": giteaKubeEnv(),
|
||||
@@ -179,7 +146,6 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
|
||||
},
|
||||
})
|
||||
|
||||
// Apply infra manifests if any infrastructure is enabled
|
||||
infra := env.Infrastructure
|
||||
if infra.Database != nil || infra.Cache != nil || infra.Storage != nil ||
|
||||
infra.Queue != nil || infra.Search != nil || infra.Monitoring != nil {
|
||||
@@ -195,7 +161,6 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
|
||||
})
|
||||
}
|
||||
|
||||
// Rollout restart
|
||||
writeStep(b, fmt.Sprintf("Rollout restart (%s)", label), map[string]any{
|
||||
"uses": "actions-hub/kubectl@master",
|
||||
"env": giteaKubeEnv(),
|
||||
@@ -208,11 +173,143 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Preview workflow
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// GeneratePreviewActions produces a Gitea Actions workflow YAML
|
||||
// that deploys an ephemeral environment per pull request.
|
||||
//
|
||||
// On PR open/sync: builds a PR-tagged image, applies secrets,
|
||||
// generates manifests (including Namespace), and deploys.
|
||||
// On PR close: deletes the preview namespace, removing all resources.
|
||||
func GeneratePreviewActions(cfg *config.KforgeConfig) (string, error) {
|
||||
if !cfg.Preview.Enabled {
|
||||
return "", fmt.Errorf("preview is not enabled in kforge.yml (set preview.enabled: true)")
|
||||
}
|
||||
|
||||
nsPrefix := cfg.Preview.NamespacePrefix
|
||||
if nsPrefix == "" {
|
||||
nsPrefix = config.DefaultPreviewNamespacePrefix
|
||||
}
|
||||
|
||||
fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "# Generated by kforge — do not edit manually.\n")
|
||||
fmt.Fprintf(&b, "# Re-generate: kforge gitea-preview > .gitea/workflows/preview.yml\n")
|
||||
fmt.Fprintf(&b, "#\n")
|
||||
fmt.Fprintf(&b, "# Required secrets (same as deploy workflow):\n")
|
||||
fmt.Fprintf(&b, "# DOCKER_USERNAME, DOCKER_PASSWORD\n")
|
||||
fmt.Fprintf(&b, "# KFORGE_NODE_IP, KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "name: Preview Environment\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "on:\n")
|
||||
fmt.Fprintf(&b, " pull_request:\n")
|
||||
fmt.Fprintf(&b, " types: [opened, synchronize, reopened, closed]\n")
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "jobs:\n")
|
||||
fmt.Fprintf(&b, " preview:\n")
|
||||
fmt.Fprintf(&b, " runs-on: ubuntu-latest\n")
|
||||
fmt.Fprintf(&b, " steps:\n")
|
||||
|
||||
writeStep(&b, "Checkout", map[string]any{
|
||||
"uses": "actions/checkout@v4",
|
||||
"with": map[string]any{"fetch-depth": 0},
|
||||
})
|
||||
|
||||
writeStep(&b, "Login to registry", map[string]any{
|
||||
"if": "${{ github.event.action != 'closed' }}",
|
||||
"uses": "docker/login-action@v2",
|
||||
"with": map[string]any{
|
||||
"registry": cfg.Registry.URL,
|
||||
"username": "${{ secrets.DOCKER_USERNAME }}",
|
||||
"password": "${{ secrets.DOCKER_PASSWORD }}",
|
||||
},
|
||||
})
|
||||
|
||||
writeStep(&b, "Build and push preview image", map[string]any{
|
||||
"if": "${{ github.event.action != 'closed' }}",
|
||||
"uses": "docker/build-push-action@v5",
|
||||
"with": map[string]any{
|
||||
"context": ".",
|
||||
"platforms": "linux/amd64",
|
||||
"file": cfg.Defaults.Dockerfile,
|
||||
"push": true,
|
||||
"tags": fmt.Sprintf("%s:pr-${{ github.event.number }}", fullRepo),
|
||||
"provenance": false,
|
||||
"sbom": false,
|
||||
},
|
||||
})
|
||||
|
||||
writeStep(&b, "Install kforge", map[string]any{
|
||||
"if": "${{ github.event.action != 'closed' }}",
|
||||
"run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge",
|
||||
})
|
||||
|
||||
writeStep(&b, "Apply preview secrets", map[string]any{
|
||||
"if": "${{ github.event.action != 'closed' }}",
|
||||
"run": "kforge secrets apply --pr-number ${{ github.event.number }}",
|
||||
"env": giteaKubeEnv(),
|
||||
})
|
||||
|
||||
writeStep(&b, "Generate preview manifests", map[string]any{
|
||||
"if": "${{ github.event.action != 'closed' }}",
|
||||
"run": "kforge generate --pr-number ${{ github.event.number }} --output .kforge-out",
|
||||
"env": map[string]any{
|
||||
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
|
||||
"PR_NUMBER": "${{ github.event.number }}",
|
||||
},
|
||||
})
|
||||
|
||||
writeStep(&b, "Apply preview manifests", map[string]any{
|
||||
"if": "${{ github.event.action != 'closed' }}",
|
||||
"uses": "actions-hub/kubectl@master",
|
||||
"env": giteaKubeEnv(),
|
||||
"with": map[string]any{
|
||||
"args": fmt.Sprintf(
|
||||
"apply -f .kforge-out/ --insecure-skip-tls-verify",
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
writeStep(&b, "Wait for preview rollout", map[string]any{
|
||||
"if": "${{ github.event.action != 'closed' }}",
|
||||
"uses": "actions-hub/kubectl@master",
|
||||
"env": giteaKubeEnv(),
|
||||
"with": map[string]any{
|
||||
"args": fmt.Sprintf(
|
||||
"rollout status deployment -n %s-${{ github.event.number }} --timeout=120s --insecure-skip-tls-verify",
|
||||
nsPrefix,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
writeStep(&b, "Destroy preview namespace", map[string]any{
|
||||
"if": "${{ github.event.action == 'closed' }}",
|
||||
"uses": "actions-hub/kubectl@master",
|
||||
"env": giteaKubeEnv(),
|
||||
"with": map[string]any{
|
||||
"args": fmt.Sprintf(
|
||||
"delete namespace %s-${{ github.event.number }} --ignore-not-found --insecure-skip-tls-verify",
|
||||
nsPrefix,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Shared step helpers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// writeStep writes a single step in the jobs.steps list.
|
||||
func writeStep(b *strings.Builder, name string, fields map[string]any) {
|
||||
fmt.Fprintf(b, "\n - name: %s\n", name)
|
||||
// Emit fields in a stable order.
|
||||
order := []string{"uses", "run", "with", "env"}
|
||||
order := []string{"if", "uses", "run", "with", "env"}
|
||||
for _, k := range order {
|
||||
v, ok := fields[k]
|
||||
if !ok {
|
||||
@@ -240,7 +337,6 @@ func writeStep(b *strings.Builder, name string, fields map[string]any) {
|
||||
}
|
||||
|
||||
func writeMapFields(b *strings.Builder, m map[string]any, indent string) {
|
||||
// Sort keys for stable output.
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
@@ -270,13 +366,11 @@ func writeMapFields(b *strings.Builder, m map[string]any, indent string) {
|
||||
}
|
||||
}
|
||||
|
||||
// giteaSecretEnv returns the env block referencing Gitea secrets
|
||||
// needed for kforge itself (DNS, registry tokens, etc.).
|
||||
func giteaSecretEnv() map[string]any {
|
||||
// giteaNodeIPEnv returns env vars needed for kforge commands that
|
||||
// resolve ${KFORGE_NODE_IP} tokens (validate, generate).
|
||||
func giteaNodeIPEnv() map[string]any {
|
||||
return map[string]any{
|
||||
"CLOUDFLARE_API_TOKEN": "${{ secrets.CLOUDFLARE_API_TOKEN }}",
|
||||
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
|
||||
"SOPS_AGE_KEY": "${{ secrets.SOPS_AGE_KEY }}",
|
||||
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,8 +395,6 @@ func mergeMaps(maps ...map[string]any) map[string]any {
|
||||
|
||||
func sortedEnvKeys(cfg *config.KforgeConfig) []string {
|
||||
keys := config.EnvironmentKeys(cfg)
|
||||
// Put staging/dev before production — a simple heuristic that
|
||||
// matches the most common deploy order.
|
||||
priority := map[string]int{"dev": 0, "development": 0, "staging": 1, "production": 2, "prod": 2}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
pi, pj := priority[keys[i]], priority[keys[j]]
|
||||
|
||||
@@ -2,7 +2,6 @@ package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kforge/internal/config"
|
||||
"kforge/pkg/interpolate"
|
||||
@@ -79,18 +78,22 @@ type InfraManifest struct {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Database — CNPG
|
||||
// Database — CNPG (centralized cluster)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func generateDatabase(env *config.ResolvedEnvironment, db *config.DatabaseInfraConfig) ([]InfraManifest, error) {
|
||||
dbName := db.DatabaseName
|
||||
if dbName == "" {
|
||||
dbName = interpolate.Slug(env.FullName)
|
||||
dbName = interpolate.PGIdentifier(env.FullName)
|
||||
}
|
||||
roleName := dbName + "_role"
|
||||
// pgUser must be a valid unquoted PostgreSQL identifier; same as
|
||||
// the username stored in the db-credentials Secret by `kforge secrets apply`.
|
||||
pgUser := interpolate.PGIdentifier(env.FullName)
|
||||
secretName := env.FullName + "-db-credentials"
|
||||
cnpgHost := env.CNPGHost
|
||||
|
||||
// CNPG Database CR
|
||||
// CNPG Database CR — declaratively manages the database lifecycle.
|
||||
// The owner role is created by the db-init Job below.
|
||||
dbManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Database
|
||||
metadata:
|
||||
@@ -103,47 +106,96 @@ spec:
|
||||
name: %s
|
||||
owner: %s
|
||||
cluster:
|
||||
name: cnpg-main
|
||||
`, dbName, env.Namespace, env.FullName, dbName, roleName)
|
||||
name: %s
|
||||
`, dbName, env.Namespace, env.FullName, dbName, pgUser, env.CNPGClusterName)
|
||||
|
||||
// CNPG Role CR — CNPG creates and rotates the password,
|
||||
// storing it in the secret named below.
|
||||
roleManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1
|
||||
kind: DatabaseRole
|
||||
// db-init Job — runs on every deploy to ensure the PostgreSQL role
|
||||
// exists and its password matches the Secret. The password stored by
|
||||
// `kforge secrets apply` is alphanumeric-only so it is safe to
|
||||
// embed in a shell command without additional escaping.
|
||||
//
|
||||
// Prerequisites:
|
||||
// - The CNPG superuser Secret must exist in the same namespace
|
||||
// (or copy it there as part of cluster bootstrap).
|
||||
// - `kforge secrets apply` must have run before this Job.
|
||||
jobManifest := fmt.Sprintf(`apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: %s
|
||||
name: %s-db-init
|
||||
namespace: %s
|
||||
labels:
|
||||
app: %s
|
||||
managed-by: kforge
|
||||
spec:
|
||||
name: %s
|
||||
passwordSecret:
|
||||
name: %s
|
||||
login: true
|
||||
superuser: false
|
||||
createdb: false
|
||||
`, roleName, env.Namespace, env.FullName, roleName, secretName)
|
||||
|
||||
// The env vars reference the CNPG-managed secret.
|
||||
// CNPG populates: username, password keys in the secret.
|
||||
// We assemble DATABASE_URL from the known CNPG host + db name.
|
||||
cnpgHost := env.CNPGHost
|
||||
dbURL := fmt.Sprintf("postgresql://$(%s_USER):$(%s_PASSWORD)@%s/%s",
|
||||
strings.ToUpper(env.FullName), strings.ToUpper(env.FullName), cnpgHost, dbName)
|
||||
ttlSecondsAfterFinished: 600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: %s
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: db-init
|
||||
image: postgres:16-alpine
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "Ensuring role $DB_USER exists..."
|
||||
PGPASSWORD="$ADMIN_PASSWORD" psql -h "$DB_HOST" -U "$ADMIN_USER" postgres \
|
||||
-c "SELECT 1 FROM pg_roles WHERE rolname = '$DB_USER'" | grep -q 1 || \
|
||||
PGPASSWORD="$ADMIN_PASSWORD" psql -h "$DB_HOST" -U "$ADMIN_USER" postgres \
|
||||
-c "CREATE ROLE $DB_USER WITH LOGIN PASSWORD '$DB_PASSWORD';"
|
||||
echo "Syncing password for $DB_USER..."
|
||||
PGPASSWORD="$ADMIN_PASSWORD" psql -h "$DB_HOST" -U "$ADMIN_USER" postgres \
|
||||
-c "ALTER ROLE $DB_USER WITH PASSWORD '$DB_PASSWORD';"
|
||||
echo "Done."
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: %s
|
||||
- name: DB_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: %s
|
||||
key: username
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: %s
|
||||
key: password
|
||||
- name: ADMIN_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: %s
|
||||
key: username
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: %s
|
||||
key: password
|
||||
`,
|
||||
env.FullName, env.Namespace, env.FullName, env.FullName,
|
||||
cnpgHost,
|
||||
secretName, secretName,
|
||||
env.CNPGSuperuserSecret, env.CNPGSuperuserSecret,
|
||||
)
|
||||
|
||||
// DATABASE_URL uses Kubernetes $(VAR_NAME) substitution — DB_USER and
|
||||
// DB_PASSWORD must be defined earlier in the env list.
|
||||
envVars := []config.EnvVarConfig{
|
||||
{Name: "DB_HOST", Type: config.EnvVarTypePlain, Value: cnpgHost},
|
||||
{Name: "DB_PORT", Type: config.EnvVarTypePlain, Value: "5432"},
|
||||
{Name: "DB_NAME", Type: config.EnvVarTypePlain, Value: dbName},
|
||||
{Name: "DB_USER", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "username"},
|
||||
{Name: "DB_PASSWORD", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "password"},
|
||||
{Name: "DATABASE_URL", Type: config.EnvVarTypePlain, Value: dbURL},
|
||||
{Name: "DATABASE_URL", Type: config.EnvVarTypePlain,
|
||||
Value: fmt.Sprintf("postgresql://$(DB_USER):$(DB_PASSWORD)@%s/%s", cnpgHost, dbName)},
|
||||
}
|
||||
|
||||
return []InfraManifest{
|
||||
{Name: "cnpg-database", Content: dbManifest, EnvVars: envVars},
|
||||
{Name: "cnpg-role", Content: roleManifest},
|
||||
{Name: "cnpg-db-init", Content: jobManifest},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,22 @@ func Ingress(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, err
|
||||
b.WriteString(" nginx.ingress.kubernetes.io/auth-realm: \"Authentication Required\"\n")
|
||||
}
|
||||
|
||||
// external-dns annotations: collect all hostnames that need DNS records.
|
||||
if !env.SkipDNS {
|
||||
var dnsHosts []string
|
||||
for _, h := range env.Ingress.Hosts {
|
||||
if h.DNSRecord {
|
||||
dnsHosts = append(dnsHosts, h.Hostname)
|
||||
}
|
||||
}
|
||||
if len(dnsHosts) > 0 {
|
||||
fmt.Fprintf(&b, " external-dns.alpha.kubernetes.io/hostname: \"%s\"\n", strings.Join(dnsHosts, ","))
|
||||
if env.DNSTarget != "" {
|
||||
fmt.Fprintf(&b, " external-dns.alpha.kubernetes.io/target: \"%s\"\n", env.DNSTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("spec:\n")
|
||||
fmt.Fprintf(&b, " ingressClassName: %s\n", env.IngressClass)
|
||||
|
||||
@@ -405,6 +421,26 @@ func renderStrSlice(ss []string) string {
|
||||
return "[" + strings.Join(quoted, ", ") + "]"
|
||||
}
|
||||
|
||||
// Namespace generates a Namespace manifest. Used for preview
|
||||
// environments where the namespace must be created by kubectl apply.
|
||||
func Namespace(namespace string, labels map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("apiVersion: v1\nkind: Namespace\nmetadata:\n")
|
||||
fmt.Fprintf(&b, " name: %s\n", namespace)
|
||||
if len(labels) > 0 {
|
||||
b.WriteString(" labels:\n")
|
||||
// Stable key order.
|
||||
keys := make([]string, 0, len(labels))
|
||||
for k := range labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, " %s: %q\n", k, labels[k])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// render executes a template with data and returns the result.
|
||||
func render(t *template.Template, data any) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
Reference in New Issue
Block a user