add tool to gitea
Build and Deploy / build-and-deploy (push) Failing after 20s

This commit is contained in:
2026-06-05 02:34:00 +10:00
commit 532b912ffb
25 changed files with 4981 additions and 0 deletions
+687
View File
@@ -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
}
+107
View File
@@ -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
}
+274
View File
@@ -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
}
+301
View File
@@ -0,0 +1,301 @@
// 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
}
+315
View File
@@ -0,0 +1,315 @@
package generator
import (
"fmt"
"sort"
"strings"
"kforge/internal/config"
)
// GiteaActionsOptions controls what the generated workflow does.
type GiteaActionsOptions struct {
// Branch that triggers the workflow. Default: main.
Branch string
// NodeVersion for setup-node. Default: 22.
NodeVersion string
// Environments to deploy, in order. Default: all environments
// sorted alphabetically (staging before production).
Environments []string
}
// GenerateGiteaActions produces a Gitea Actions workflow YAML
// 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).
func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (string, error) {
if opts.Branch == "" {
opts.Branch = "main"
}
if opts.NodeVersion == "" {
opts.NodeVersion = "22"
}
if len(opts.Environments) == 0 {
opts.Environments = sortedEnvKeys(cfg)
}
var b strings.Builder
writeGiteaHeader(&b, cfg, opts)
writeGiteaJobs(&b, cfg, opts)
return b.String(), nil
}
func writeGiteaHeader(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, "# Required Gitea repo secrets:\n")
fmt.Fprintf(b, "# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n")
fmt.Fprintf(b, "\n")
fmt.Fprintf(b, "name: Build and Deploy\n")
fmt.Fprintf(b, "\n")
fmt.Fprintf(b, "on:\n")
fmt.Fprintf(b, " push:\n")
fmt.Fprintf(b, " branches:\n")
fmt.Fprintf(b, " - %s\n", opts.Branch)
fmt.Fprintf(b, "\n")
}
func writeGiteaJobs(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{
"registry": cfg.Registry.URL,
"username": "${{ secrets.DOCKER_USERNAME }}",
"password": "${{ secrets.DOCKER_PASSWORD }}",
},
})
// 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",
"with": map[string]any{
"context": ".",
"platforms": "linux/amd64",
"file": cfg.Defaults.Dockerfile,
"push": true,
"tags": fmt.Sprintf("%s:latest\n%s:${{ env.SHORT_SHA }}", fullRepo, fullRepo),
"provenance": false,
"sbom": false,
},
})
// 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 {
continue
}
writeEnvDeploySteps(b, cfg, &env, envKey)
}
}
func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *config.ResolvedEnvironment, envKey string) {
label := strings.Title(envKey) //nolint:staticcheck // simple capitalisation
// Validate kforge config before doing anything destructive.
writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{
"run": "kforge validate",
"env": giteaSecretEnv(),
})
// 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(),
})
// kubectl apply
writeStep(b, fmt.Sprintf("Apply manifests (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
"apply -f .kforge-out/%s-core.yaml -n %s --insecure-skip-tls-verify",
envKey, env.Namespace,
),
},
})
// 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 {
writeStep(b, fmt.Sprintf("Apply infra manifests (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
`apply -f .kforge-out/ -l app=%s -n %s --insecure-skip-tls-verify`,
env.FullName, env.Namespace,
),
},
})
}
// Rollout restart
writeStep(b, fmt.Sprintf("Rollout restart (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
"rollout restart deployment/%s -n %s --insecure-skip-tls-verify",
env.FullName, env.Namespace,
),
},
})
}
// 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"}
for _, k := range order {
v, ok := fields[k]
if !ok {
continue
}
switch val := v.(type) {
case string:
if strings.Contains(val, "\n") {
fmt.Fprintf(b, " %s: |\n", k)
for _, line := range strings.Split(val, "\n") {
fmt.Fprintf(b, " %s\n", line)
}
} else {
fmt.Fprintf(b, " %s: %s\n", k, val)
}
case bool:
fmt.Fprintf(b, " %s: %v\n", k, val)
case int:
fmt.Fprintf(b, " %s: %d\n", k, val)
case map[string]any:
fmt.Fprintf(b, " %s:\n", k)
writeMapFields(b, val, " ")
}
}
}
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)
}
sort.Strings(keys)
for _, k := range keys {
v := m[k]
switch val := v.(type) {
case string:
if strings.Contains(val, "\n") {
fmt.Fprintf(b, "%s%s: |\n", indent, k)
for _, line := range strings.Split(val, "\n") {
fmt.Fprintf(b, "%s %s\n", indent, line)
}
} else {
fmt.Fprintf(b, "%s%s: %s\n", indent, k, val)
}
case bool:
fmt.Fprintf(b, "%s%s: %v\n", indent, k, val)
case int:
fmt.Fprintf(b, "%s%s: %d\n", indent, k, val)
case map[string]any:
fmt.Fprintf(b, "%s%s:\n", indent, k)
writeMapFields(b, val, indent+" ")
}
}
}
// giteaSecretEnv returns the env block referencing Gitea secrets
// needed for kforge itself (DNS, registry tokens, etc.).
func giteaSecretEnv() 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 }}",
}
}
// giteaKubeEnv returns the env block for kubectl auth.
func giteaKubeEnv() map[string]any {
return map[string]any{
"KUBE_CERTIFICATE": "${{ secrets.KUBE_CERTIFICATE }}",
"KUBE_HOST": "${{ secrets.KUBE_HOST }}",
"KUBE_TOKEN": "${{ secrets.KUBE_TOKEN }}",
}
}
func mergeMaps(maps ...map[string]any) map[string]any {
result := map[string]any{}
for _, m := range maps {
for k, v := range m {
result[k] = v
}
}
return result
}
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]]
if pi != pj {
return pi < pj
}
return keys[i] < keys[j]
})
return keys
}
+657
View File
@@ -0,0 +1,657 @@
package generator
import (
"fmt"
"strings"
"kforge/internal/config"
"kforge/pkg/interpolate"
)
// GenerateInfrastructure produces manifests for all enabled
// infrastructure services for a given environment.
// Returns a slice of (resourceName, yamlContent) pairs so the
// caller can write them to separate files if desired.
func GenerateInfrastructure(env *config.ResolvedEnvironment) ([]InfraManifest, error) {
var manifests []InfraManifest
infra := env.Infrastructure
if infra.Database != nil {
m, err := generateDatabase(env, infra.Database)
if err != nil {
return nil, fmt.Errorf("database: %w", err)
}
manifests = append(manifests, m...)
}
if infra.Cache != nil {
m, err := generateCache(env, infra.Cache)
if err != nil {
return nil, fmt.Errorf("cache: %w", err)
}
manifests = append(manifests, m...)
}
if infra.Storage != nil {
m, err := generateStorage(env, infra.Storage)
if err != nil {
return nil, fmt.Errorf("storage: %w", err)
}
manifests = append(manifests, m...)
}
if infra.Queue != nil {
m, err := generateQueue(env, infra.Queue)
if err != nil {
return nil, fmt.Errorf("queue: %w", err)
}
manifests = append(manifests, m...)
}
if infra.Search != nil {
m, err := generateSearch(env, infra.Search)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
manifests = append(manifests, m...)
}
if infra.Monitoring != nil {
m, err := generateMonitoring(env, infra.Monitoring)
if err != nil {
return nil, fmt.Errorf("monitoring: %w", err)
}
manifests = append(manifests, m...)
}
return manifests, nil
}
// InfraManifest is a named manifest produced by an infra generator.
type InfraManifest struct {
// Name is a short identifier for the manifest (used as filename suffix).
Name string
Content string
// EnvVars are the env vars that should be injected into the
// deployment to connect to this infrastructure service.
EnvVars []config.EnvVarConfig
}
// ------------------------------------------------------------
// Database — CNPG
// ------------------------------------------------------------
func generateDatabase(env *config.ResolvedEnvironment, db *config.DatabaseInfraConfig) ([]InfraManifest, error) {
dbName := db.DatabaseName
if dbName == "" {
dbName = interpolate.Slug(env.FullName)
}
roleName := dbName + "_role"
secretName := env.FullName + "-db-credentials"
// CNPG Database CR
dbManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
name: %s
owner: %s
cluster:
name: cnpg-main
`, dbName, env.Namespace, env.FullName, dbName, roleName)
// 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
metadata:
name: %s
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)
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},
}
return []InfraManifest{
{Name: "cnpg-database", Content: dbManifest, EnvVars: envVars},
{Name: "cnpg-role", Content: roleManifest},
}, nil
}
// ------------------------------------------------------------
// Cache — Valkey or Redis (standalone or cluster)
// ------------------------------------------------------------
func generateCache(env *config.ResolvedEnvironment, cache *config.CacheInfraConfig) ([]InfraManifest, error) {
name := env.FullName + "-cache"
secretName := env.FullName + "-cache-credentials"
image := "valkey/valkey:7-alpine"
if cache.Provider == "redis" {
image = "redis:7-alpine"
}
replicas := 1
if cache.Replicas != nil {
replicas = *cache.Replicas
}
var manifest string
if cache.Mode == "cluster" && replicas > 1 {
manifest = generateCacheStatefulSet(name, env.Namespace, env.FullName, image, secretName, replicas)
} else {
manifest = generateCacheDeployment(name, env.Namespace, env.FullName, image, secretName)
}
// Service
svcManifest := fmt.Sprintf(`apiVersion: v1
kind: Service
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
type: ClusterIP
ports:
- port: 6379
targetPort: 6379
selector:
app: %s
`, name, env.Namespace, env.FullName, name)
// Password secret placeholder — actual password generated by
// `kforge secrets apply`.
secretManifest := fmt.Sprintf(`# Secret %q — generated by: kforge secrets apply --env %s
# Do not commit generated passwords. This comment is a placeholder.
`, secretName, env.EnvKey)
envVars := []config.EnvVarConfig{
{
Name: "CACHE_URL",
Type: config.EnvVarTypePlain,
Value: fmt.Sprintf("redis://:%s@%s:6379", "$(CACHE_PASSWORD)", name),
},
{
Name: "CACHE_HOST",
Type: config.EnvVarTypePlain,
Value: name,
},
{
Name: "CACHE_PORT",
Type: config.EnvVarTypePlain,
Value: "6379",
},
{
Name: "CACHE_PASSWORD",
Type: config.EnvVarTypeSecretRef,
SecretName: secretName,
SecretKey: "password",
},
}
return []InfraManifest{
{Name: "cache", Content: manifest},
{Name: "cache-svc", Content: svcManifest},
{Name: "cache-secret", Content: secretManifest, EnvVars: envVars},
}, nil
}
func generateCacheDeployment(name, namespace, appLabel, image, secretName string) string {
return fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
replicas: 1
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
spec:
containers:
- name: %s
image: %s
ports:
- containerPort: 6379
command: ["valkey-server", "--requirepass", "$(CACHE_PASSWORD)"]
env:
- name: CACHE_PASSWORD
valueFrom:
secretKeyRef:
name: %s
key: password
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
`, name, namespace, appLabel, name, name, name, image, secretName)
}
func generateCacheStatefulSet(name, namespace, appLabel, image, secretName string, replicas int) string {
return fmt.Sprintf(`apiVersion: apps/v1
kind: StatefulSet
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
replicas: %d
serviceName: %s
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
spec:
containers:
- name: %s
image: %s
ports:
- containerPort: 6379
command: ["valkey-server", "--requirepass", "$(CACHE_PASSWORD)", "--cluster-enabled", "yes"]
env:
- name: CACHE_PASSWORD
valueFrom:
secretKeyRef:
name: %s
key: password
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
`, name, namespace, appLabel, replicas, name, name, name, name, image, secretName)
}
// ------------------------------------------------------------
// Storage — Minio
// ------------------------------------------------------------
func generateStorage(env *config.ResolvedEnvironment, storage *config.StorageInfraConfig) ([]InfraManifest, error) {
name := env.FullName + "-storage"
secretName := env.FullName + "-storage-credentials"
manifest := fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
replicas: 1
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
spec:
containers:
- name: %s
image: minio/minio:latest
args: ["server", "/data", "--console-address", ":9001"]
ports:
- containerPort: 9000
name: api
- containerPort: 9001
name: console
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: %s
key: access_key
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: %s
key: secret_key
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 1Gi
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
emptyDir: {}
`, name, env.Namespace, env.FullName, name, name, name, secretName, secretName)
svcManifest := fmt.Sprintf(`apiVersion: v1
kind: Service
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
type: ClusterIP
ports:
- name: api
port: 9000
targetPort: 9000
- name: console
port: 9001
targetPort: 9001
selector:
app: %s
`, name, env.Namespace, env.FullName, name)
envVars := []config.EnvVarConfig{
{Name: "STORAGE_ENDPOINT", Type: config.EnvVarTypePlain, Value: fmt.Sprintf("http://%s:9000", name)},
{Name: "STORAGE_ACCESS_KEY", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "access_key"},
{Name: "STORAGE_SECRET_KEY", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "secret_key"},
}
return []InfraManifest{
{Name: "storage", Content: manifest},
{Name: "storage-svc", Content: svcManifest, EnvVars: envVars},
}, nil
}
// ------------------------------------------------------------
// Queue — NATS or RabbitMQ
// ------------------------------------------------------------
func generateQueue(env *config.ResolvedEnvironment, queue *config.QueueInfraConfig) ([]InfraManifest, error) {
if queue.Provider == "rabbitmq" {
return generateRabbitMQ(env)
}
return generateNATS(env)
}
func generateNATS(env *config.ResolvedEnvironment) ([]InfraManifest, error) {
name := env.FullName + "-queue"
manifest := fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
replicas: 1
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
spec:
containers:
- name: %s
image: nats:2-alpine
ports:
- containerPort: 4222
name: client
- containerPort: 8222
name: monitor
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
`, name, env.Namespace, env.FullName, name, name, name)
svcManifest := fmt.Sprintf(`apiVersion: v1
kind: Service
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
type: ClusterIP
ports:
- name: client
port: 4222
targetPort: 4222
selector:
app: %s
`, name, env.Namespace, env.FullName, name)
envVars := []config.EnvVarConfig{
{Name: "QUEUE_URL", Type: config.EnvVarTypePlain, Value: fmt.Sprintf("nats://%s:4222", name)},
{Name: "QUEUE_HOST", Type: config.EnvVarTypePlain, Value: name},
}
return []InfraManifest{
{Name: "queue-nats", Content: manifest},
{Name: "queue-svc", Content: svcManifest, EnvVars: envVars},
}, nil
}
func generateRabbitMQ(env *config.ResolvedEnvironment) ([]InfraManifest, error) {
name := env.FullName + "-queue"
secretName := env.FullName + "-queue-credentials"
manifest := fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
replicas: 1
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
spec:
containers:
- name: %s
image: rabbitmq:3-management-alpine
ports:
- containerPort: 5672
name: amqp
- containerPort: 15672
name: management
env:
- name: RABBITMQ_DEFAULT_USER
valueFrom:
secretKeyRef:
name: %s
key: username
- name: RABBITMQ_DEFAULT_PASS
valueFrom:
secretKeyRef:
name: %s
key: password
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
`, name, env.Namespace, env.FullName, name, name, name, secretName, secretName)
envVars := []config.EnvVarConfig{
{
Name: "QUEUE_URL",
Type: config.EnvVarTypePlain,
Value: fmt.Sprintf("amqp://$(QUEUE_USER):$(QUEUE_PASSWORD)@%s:5672", name),
},
{Name: "QUEUE_USER", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "username"},
{Name: "QUEUE_PASSWORD", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "password"},
}
return []InfraManifest{
{Name: "queue-rabbitmq", Content: manifest, EnvVars: envVars},
}, nil
}
// ------------------------------------------------------------
// Search — Meilisearch
// ------------------------------------------------------------
func generateSearch(env *config.ResolvedEnvironment, search *config.SearchInfraConfig) ([]InfraManifest, error) {
name := env.FullName + "-search"
secretName := env.FullName + "-search-credentials"
manifest := fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
replicas: 1
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
spec:
containers:
- name: %s
image: getmeili/meilisearch:latest
ports:
- containerPort: 7700
env:
- name: MEILI_MASTER_KEY
valueFrom:
secretKeyRef:
name: %s
key: master_key
- name: MEILI_ENV
value: production
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
`, name, env.Namespace, env.FullName, name, name, name, secretName)
svcManifest := fmt.Sprintf(`apiVersion: v1
kind: Service
metadata:
name: %s
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
type: ClusterIP
ports:
- port: 7700
targetPort: 7700
selector:
app: %s
`, name, env.Namespace, env.FullName, name)
envVars := []config.EnvVarConfig{
{Name: "SEARCH_URL", Type: config.EnvVarTypePlain, Value: fmt.Sprintf("http://%s:7700", name)},
{Name: "SEARCH_MASTER_KEY", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "master_key"},
}
return []InfraManifest{
{Name: "search", Content: manifest},
{Name: "search-svc", Content: svcManifest, EnvVars: envVars},
}, nil
}
// ------------------------------------------------------------
// Monitoring — Prometheus ServiceMonitor
// ------------------------------------------------------------
func generateMonitoring(env *config.ResolvedEnvironment, mon *config.MonitoringInfraConfig) ([]InfraManifest, error) {
port := env.Port
if mon.MetricsPort != nil {
port = *mon.MetricsPort
}
// ServiceMonitor CR (requires Prometheus Operator).
manifest := fmt.Sprintf(`apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: %s-monitor
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
selector:
matchLabels:
app: %s
endpoints:
- path: %s
port: %d
interval: 30s
`, env.FullName, env.Namespace, env.FullName, env.FullName, mon.MetricsPath, port)
return []InfraManifest{
{Name: "servicemonitor", Content: manifest},
}, nil
}
+415
View File
@@ -0,0 +1,415 @@
// Package generator produces Kubernetes manifest strings from
// ResolvedEnvironment values. Each generator returns a YAML
// string ready to be written to a file or piped to kubectl.
package generator
import (
"bytes"
"fmt"
"strings"
"text/template"
"kforge/internal/config"
"kforge/pkg/interpolate"
)
const Separator = "---\n"
// GenerateAll produces the complete set of manifests for one
// environment as a single concatenated YAML string.
func GenerateAll(env *config.ResolvedEnvironment, cfg *config.KforgeConfig) (string, error) {
tokens := interpolate.FromEnvironment(
cfg.Meta.Name,
cfg.Meta.Tenant,
env.EnvKey,
env.EnvPrefix,
env.FullName,
env.Image,
env.Namespace,
)
var parts []string
svc, err := Service(env, tokens)
if err != nil {
return "", fmt.Errorf("service: %w", err)
}
parts = append(parts, svc)
dep, err := Deployment(env, tokens)
if err != nil {
return "", fmt.Errorf("deployment: %w", err)
}
parts = append(parts, dep)
ing, err := Ingress(env, tokens)
if err != nil {
return "", fmt.Errorf("ingress: %w", err)
}
parts = append(parts, ing)
for _, host := range env.Ingress.Hosts {
if !host.TLS {
continue
}
cert, err := Certificate(env, host, tokens)
if err != nil {
return "", fmt.Errorf("certificate for %s: %w", host.Hostname, err)
}
parts = append(parts, cert)
}
if env.Ingress.Auth.Enabled {
parts = append(parts, fmt.Sprintf(
"# Basic auth secret %q — run: kforge secrets apply --env %s\n",
env.Ingress.Auth.SecretName, env.EnvKey,
))
}
for _, job := range env.CronJobs {
cj, err := CronJob(env, &job, tokens)
if err != nil {
return "", fmt.Errorf("cronjob %s: %w", job.Name, err)
}
parts = append(parts, cj)
}
return strings.Join(parts, Separator), nil
}
// ------------------------------------------------------------
// Service
// ------------------------------------------------------------
var serviceTmpl = template.Must(template.New("service").Parse(`apiVersion: v1
kind: Service
metadata:
name: {{ .FullName }}
namespace: {{ .Namespace }}
labels:
app: {{ .FullName }}
managed-by: kforge
spec:
type: {{ .ServiceType }}
ports:
- port: {{ .Port }}
protocol: TCP
targetPort: {{ .Port }}
selector:
app: {{ .FullName }}
`))
func Service(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, error) {
return render(serviceTmpl, env)
}
// ------------------------------------------------------------
// Deployment
// ------------------------------------------------------------
func Deployment(env *config.ResolvedEnvironment, tokens interpolate.Tokens) (string, error) {
// Resolve ${tenant}/${name} tokens in the image string.
image := interpolate.Apply(env.Image, tokens)
var b strings.Builder
b.WriteString("apiVersion: apps/v1\n")
b.WriteString("kind: Deployment\n")
b.WriteString("metadata:\n")
fmt.Fprintf(&b, " name: %s\n", env.FullName)
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" managed-by: kforge\n")
b.WriteString("spec:\n")
fmt.Fprintf(&b, " replicas: %d\n", env.Replicas)
b.WriteString(" selector:\n")
b.WriteString(" matchLabels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" template:\n")
b.WriteString(" metadata:\n")
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" spec:\n")
b.WriteString(" containers:\n")
fmt.Fprintf(&b, " - name: %s\n", env.FullName)
fmt.Fprintf(&b, " image: %s\n", image)
fmt.Fprintf(&b, " imagePullPolicy: %s\n", env.ImagePullPolicy)
b.WriteString(" ports:\n")
fmt.Fprintf(&b, " - containerPort: %d\n", env.Port)
if ev := renderEnvVarLines(env.EnvVars, " "); ev != "" {
b.WriteString(" env:\n")
b.WriteString(ev)
}
if lp := renderProbeLines(env.HealthCheck, "liveness", " "); lp != "" {
b.WriteString(lp)
}
if rp := renderProbeLines(env.HealthCheck, "readiness", " "); rp != "" {
b.WriteString(rp)
}
b.WriteString(renderResourceLines(env.Resources, " "))
b.WriteString(" imagePullSecrets:\n")
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
return b.String(), nil
}
// ------------------------------------------------------------
// Ingress
// ------------------------------------------------------------
func Ingress(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, error) {
var b strings.Builder
b.WriteString("apiVersion: networking.k8s.io/v1\n")
b.WriteString("kind: Ingress\n")
b.WriteString("metadata:\n")
fmt.Fprintf(&b, " name: %s-ingress\n", env.FullName)
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" managed-by: kforge\n")
b.WriteString(" annotations:\n")
b.WriteString(" nginx.ingress.kubernetes.io/ssl-redirect: \"true\"\n")
if env.Ingress.Auth.Enabled {
b.WriteString(" nginx.ingress.kubernetes.io/auth-type: basic\n")
fmt.Fprintf(&b, " nginx.ingress.kubernetes.io/auth-secret: %s\n", env.Ingress.Auth.SecretName)
b.WriteString(" nginx.ingress.kubernetes.io/auth-realm: \"Authentication Required\"\n")
}
b.WriteString("spec:\n")
fmt.Fprintf(&b, " ingressClassName: %s\n", env.IngressClass)
// TLS block
hasTLS := false
for _, h := range env.Ingress.Hosts {
if h.TLS {
hasTLS = true
break
}
}
if hasTLS {
b.WriteString(" tls:\n")
for _, h := range env.Ingress.Hosts {
if !h.TLS {
continue
}
secretName := fmt.Sprintf("%s-tls-%s", env.FullName, interpolate.HostSlug(h.Hostname))
b.WriteString(" - hosts:\n")
fmt.Fprintf(&b, " - %s\n", h.Hostname)
fmt.Fprintf(&b, " secretName: %s\n", secretName)
}
}
// Rules block
b.WriteString(" rules:\n")
for _, h := range env.Ingress.Hosts {
fmt.Fprintf(&b, " - host: %s\n", h.Hostname)
b.WriteString(" http:\n")
b.WriteString(" paths:\n")
b.WriteString(" - path: /\n")
b.WriteString(" pathType: Prefix\n")
b.WriteString(" backend:\n")
b.WriteString(" service:\n")
fmt.Fprintf(&b, " name: %s\n", env.FullName)
b.WriteString(" port:\n")
fmt.Fprintf(&b, " number: %d\n", env.Port)
}
return b.String(), nil
}
// ------------------------------------------------------------
// Certificate (cert-manager)
// ------------------------------------------------------------
func Certificate(env *config.ResolvedEnvironment, host config.IngressHost, _ interpolate.Tokens) (string, error) {
secretName := fmt.Sprintf("%s-tls-%s", env.FullName, interpolate.HostSlug(host.Hostname))
t := template.Must(template.New("cert").Parse(`apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ .SecretName }}
namespace: {{ .Namespace }}
labels:
app: {{ .FullName }}
managed-by: kforge
spec:
secretName: {{ .SecretName }}
issuerRef:
name: {{ .TLSIssuer }}
kind: ClusterIssuer
dnsNames:
- {{ .Hostname }}
`))
data := struct {
SecretName string
Namespace string
FullName string
TLSIssuer string
Hostname string
}{
SecretName: secretName,
Namespace: env.Namespace,
FullName: env.FullName,
TLSIssuer: env.TLSIssuer,
Hostname: host.Hostname,
}
return render(t, data)
}
// ------------------------------------------------------------
// CronJob
// ------------------------------------------------------------
func CronJob(env *config.ResolvedEnvironment, job *config.ResolvedCronJob, tokens interpolate.Tokens) (string, error) {
cronName := fmt.Sprintf("%s-cron-%s", env.FullName, interpolate.Slug(job.Name))
image := interpolate.Apply(job.Image, tokens)
successHist := 3
if job.SuccessfulJobsHistoryLimit != nil {
successHist = *job.SuccessfulJobsHistoryLimit
}
failHist := 1
if job.FailedJobsHistoryLimit != nil {
failHist = *job.FailedJobsHistoryLimit
}
var b strings.Builder
b.WriteString("apiVersion: batch/v1\n")
b.WriteString("kind: CronJob\n")
b.WriteString("metadata:\n")
fmt.Fprintf(&b, " name: %s\n", cronName)
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" managed-by: kforge\n")
b.WriteString("spec:\n")
fmt.Fprintf(&b, " schedule: %q\n", job.Schedule)
fmt.Fprintf(&b, " concurrencyPolicy: %s\n", job.ConcurrencyPolicy)
fmt.Fprintf(&b, " successfulJobsHistoryLimit: %d\n", successHist)
fmt.Fprintf(&b, " failedJobsHistoryLimit: %d\n", failHist)
b.WriteString(" jobTemplate:\n")
b.WriteString(" spec:\n")
b.WriteString(" template:\n")
b.WriteString(" metadata:\n")
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" spec:\n")
fmt.Fprintf(&b, " restartPolicy: %s\n", job.RestartPolicy)
b.WriteString(" containers:\n")
fmt.Fprintf(&b, " - name: %s\n", cronName)
fmt.Fprintf(&b, " image: %s\n", image)
b.WriteString(" imagePullPolicy: Always\n")
fmt.Fprintf(&b, " command: %s\n", renderStrSlice(job.Command))
if ev := renderEnvVarLines(job.EnvVars, " "); ev != "" {
b.WriteString(" env:\n")
b.WriteString(ev)
}
if job.Resources != nil {
b.WriteString(renderResourceLines(*job.Resources, " "))
}
b.WriteString(" imagePullSecrets:\n")
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
return b.String(), nil
}
// ------------------------------------------------------------
// Shared rendering helpers
// ------------------------------------------------------------
// renderEnvVarLines returns the env var list lines indented by pad.
// The caller writes the "env:" header line itself.
func renderEnvVarLines(vars []config.EnvVarConfig, pad string) string {
if len(vars) == 0 {
return ""
}
var b strings.Builder
for _, v := range vars {
switch v.Type {
case config.EnvVarTypeSecretRef:
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
fmt.Fprintf(&b, "%s valueFrom:\n", pad)
fmt.Fprintf(&b, "%s secretKeyRef:\n", pad)
fmt.Fprintf(&b, "%s name: %s\n", pad, v.SecretName)
fmt.Fprintf(&b, "%s key: %s\n", pad, v.SecretKey)
case config.EnvVarTypeConfigMapRef:
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
fmt.Fprintf(&b, "%s valueFrom:\n", pad)
fmt.Fprintf(&b, "%s configMapKeyRef:\n", pad)
fmt.Fprintf(&b, "%s name: %s\n", pad, v.ConfigMapName)
fmt.Fprintf(&b, "%s key: %s\n", pad, v.ConfigMapKey)
default: // plain
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
fmt.Fprintf(&b, "%s value: %q\n", pad, v.Value)
}
}
return b.String()
}
// renderProbeLines returns a liveness or readiness probe block.
func renderProbeLines(hc config.HealthCheckConfig, kind string, pad string) string {
isLiveness := kind == "liveness"
if isLiveness && (hc.Liveness == nil || !*hc.Liveness) {
return ""
}
if !isLiveness && (hc.Readiness == nil || !*hc.Readiness) {
return ""
}
port := 0
if hc.Port != nil {
port = *hc.Port
}
key := kind + "Probe"
var b strings.Builder
fmt.Fprintf(&b, "%s%s:\n", pad, key)
fmt.Fprintf(&b, "%s httpGet:\n", pad)
fmt.Fprintf(&b, "%s path: %s\n", pad, hc.Path)
fmt.Fprintf(&b, "%s port: %d\n", pad, port)
fmt.Fprintf(&b, "%s initialDelaySeconds: %d\n", pad, hc.InitialDelaySeconds)
fmt.Fprintf(&b, "%s periodSeconds: %d\n", pad, hc.PeriodSeconds)
fmt.Fprintf(&b, "%s timeoutSeconds: %d\n", pad, hc.TimeoutSeconds)
fmt.Fprintf(&b, "%s failureThreshold: %d\n", pad, hc.FailureThreshold)
return b.String()
}
// renderResourceLines returns a resources block indented by pad.
func renderResourceLines(r config.ResourceConfig, pad string) string {
var b strings.Builder
fmt.Fprintf(&b, "%sresources:\n", pad)
fmt.Fprintf(&b, "%s requests:\n", pad)
fmt.Fprintf(&b, "%s cpu: %s\n", pad, r.Requests.CPU)
fmt.Fprintf(&b, "%s memory: %s\n", pad, r.Requests.Memory)
fmt.Fprintf(&b, "%s limits:\n", pad)
fmt.Fprintf(&b, "%s cpu: %s\n", pad, r.Limits.CPU)
fmt.Fprintf(&b, "%s memory: %s\n", pad, r.Limits.Memory)
return b.String()
}
// renderStrSlice formats a string slice as an inline YAML sequence.
func renderStrSlice(ss []string) string {
if len(ss) == 0 {
return "[]"
}
quoted := make([]string, len(ss))
for i, s := range ss {
quoted[i] = fmt.Sprintf("%q", s)
}
return "[" + strings.Join(quoted, ", ") + "]"
}
// render executes a template with data and returns the result.
func render(t *template.Template, data any) (string, error) {
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}