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
+99
View File
@@ -0,0 +1,99 @@
# Generated by kforge — do not edit manually.
# Re-generate: kforge gitea-actions > .gitea/workflows/deploy.yml
#
# Required Gitea org secrets:
# DOCKER_USERNAME, DOCKER_PASSWORD, KFORGE_NODE_IP
# CLOUDFLARE_API_TOKEN, CF_ZONE_ID_* (per zone)
# SOPS_AGE_KEY
# Required Gitea repo secrets:
# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE
name: Build and Deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Create short commit hash
run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Login to registry
uses: docker/login-action@v2
with:
password: ${{ secrets.DOCKER_PASSWORD }}
registry: registry.natelubitz.com
username: ${{ secrets.DOCKER_USERNAME }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64
provenance: false
push: true
sbom: false
tags: |
registry.natelubitz.com/nate-lubitz/www:latest
registry.natelubitz.com/nate-lubitz/www:${{ env.SHORT_SHA }}
- name: Install kforge
run: |
KFORGE_VERSION="latest"
curl -fsSL "https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64" -o /usr/local/bin/kforge
chmod +x /usr/local/bin/kforge
- name: Validate kforge config (Production)
run: kforge validate
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Apply cluster secrets (Production)
run: kforge secrets apply --env production
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Generate manifests (Production)
run: kforge generate --env production --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Apply manifests (Production)
uses: actions-hub/kubectl@master
with:
args: apply -f .kforge-out/production-core.yaml -n production --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Rollout restart (Production)
uses: actions-hub/kubectl@master
with:
args: rollout restart deployment/prod-nate-lubitz-www -n production --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
+3
View File
@@ -0,0 +1,3 @@
kforge
kforge.exe
.kforge-out/
+10
View File
@@ -0,0 +1,10 @@
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o kforge .
FROM alpine:3.19
COPY --from=builder /app/kforge /usr/local/bin/kforge
RUN apk add --no-cache curl && \
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
+53
View File
@@ -0,0 +1,53 @@
name: "K8s YAML Generator"
description: "Builds a Docker image, pushes it to a private registry, generates Kubernetes YAML from a simplified YML file, and deploys it."
author: "Claude Code made this"
inputs:
image_name:
description: "Docker image name to build and push (e.g. my-app)"
required: true
image_tag:
description: "Docker image tag. If omitted, defaults to both 'latest' and the short commit SHA."
required: false
dockerfile:
description: "Path to Dockerfile"
required: false
default: "Dockerfile"
max_tags:
description: "Maximum number of SHA image tags to keep in the registry"
required: false
default: "5"
registry:
description: "Docker registry URL"
required: false
default: "registry.natelubitz.com"
registry_username:
description: "Registry username"
required: true
registry_password:
description: "Registry password"
required: true
kube_host:
description: "Kubernetes API server URL"
required: false
default: "192.168.1.20:16443"
kube_certificate:
description: "Base64 encoded Kubernetes CA certificate"
required: true
kube_token:
description: "Kubernetes service account token"
required: true
# outputs:
# output_file:
# description: "Path to the generated Kubernetes YAML file"
runs:
using: "docker"
image: "Dockerfile"
# args:
# - ${{ inputs.input_file }}
# - ${{ inputs.output_file }}
# - ${{ inputs.auto_deploy }}
+170
View File
@@ -0,0 +1,170 @@
package cmd
import (
"fmt"
"os"
"kforge/internal/config"
dnsProvider "kforge/internal/dns"
"kforge/internal/generator"
"github.com/spf13/cobra"
)
// ------------------------------------------------------------
// kforge dns ensure
// ------------------------------------------------------------
var dnsCmd = &cobra.Command{
Use: "dns",
Short: "Manage DNS records for kforge environments",
}
var dnsEnsureEnvs []string
var dnsEnsureCmd = &cobra.Command{
Use: "ensure",
Short: "Create or update DNS A records for ingress hosts",
Long: `For each ingress host with dns_record: true, creates or updates
an A record pointing to KFORGE_NODE_IP.
Idempotent — safe to run on every deploy. Skips hosts where the
record already points to the correct IP.
Examples:
kforge dns ensure --env staging
kforge dns ensure --env staging --env production`,
RunE: runDNSEnsure,
}
func init() {
dnsEnsureCmd.Flags().StringArrayVarP(&dnsEnsureEnvs, "env", "e", nil,
"Environment(s) to ensure DNS records for (default: all)")
dnsCmd.AddCommand(dnsEnsureCmd)
rootCmd.AddCommand(dnsCmd)
}
func runDNSEnsure(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
if cfg.DNS.SkipDNS {
fmt.Println("dns.skip_dns is true — skipping DNS management")
return nil
}
nodeIP := cfg.DNS.NodeIP
if nodeIP == "" {
nodeIP = os.Getenv("KFORGE_NODE_IP")
}
if nodeIP == "" {
return fmt.Errorf("node IP not set: add dns.node_ip to kforge.yml or set KFORGE_NODE_IP")
}
provider, err := dnsProvider.NewProvider(cfg.DNS)
if err != nil {
return fmt.Errorf("initialising DNS provider: %w", err)
}
envKeys := dnsEnsureEnvs
if len(envKeys) == 0 {
envKeys = config.EnvironmentKeys(cfg)
}
for _, envKey := range envKeys {
fmt.Printf("\nEnsuring DNS records for: %s\n", envKey)
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
return err
}
if err := dnsProvider.EnsureRecordsForEnvironment(provider, &env, nodeIP); err != nil {
return fmt.Errorf("env %q: %w", envKey, err)
}
}
return nil
}
// ------------------------------------------------------------
// kforge gitea-actions
// ------------------------------------------------------------
var (
giteaActionsOutput string
giteaActionsBranch string
giteaActionsEnvs []string
)
var giteaActionsCmd = &cobra.Command{
Use: "gitea-actions",
Short: "Generate a Gitea Actions workflow for this app",
Long: `Generates a complete .gitea/workflows/deploy.yml that:
- Builds and pushes the Docker image on every push to main
- Runs kforge validate
- Applies cluster secrets (idempotent)
- Ensures DNS records
- Generates manifests and applies them with kubectl
- Rolls out the deployment
The generated workflow replaces your hand-written deploy.yml.
Re-run whenever you add environments or change deploy options.
Examples:
kforge gitea-actions
kforge gitea-actions --output .gitea/workflows/deploy.yml
kforge gitea-actions --env staging --env production`,
RunE: runGiteaActions,
}
func init() {
giteaActionsCmd.Flags().StringVarP(&giteaActionsOutput, "output", "o",
".gitea/workflows/deploy.yml",
"Output path for the generated workflow file")
giteaActionsCmd.Flags().StringVar(&giteaActionsBranch, "branch", "main",
"Branch that triggers the workflow")
giteaActionsCmd.Flags().StringArrayVarP(&giteaActionsEnvs, "env", "e", nil,
"Environments to deploy (default: all, staging before production)")
rootCmd.AddCommand(giteaActionsCmd)
}
func runGiteaActions(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
workflow, err := generator.GenerateGiteaActions(cfg, generator.GiteaActionsOptions{
Branch: giteaActionsBranch,
Environments: giteaActionsEnvs,
})
if err != nil {
return fmt.Errorf("generating workflow: %w", err)
}
if giteaActionsOutput == "-" {
fmt.Print(workflow)
return nil
}
// Ensure parent directory exists.
dir := giteaActionsOutput
for i := len(dir) - 1; i >= 0; i-- {
if dir[i] == '/' {
dir = dir[:i]
break
}
}
if dir != giteaActionsOutput {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
}
if err := os.WriteFile(giteaActionsOutput, []byte(workflow), 0o644); err != nil {
return fmt.Errorf("writing workflow: %w", err)
}
fmt.Printf("✓ Gitea Actions workflow written to %s\n", giteaActionsOutput)
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"kforge/internal/config"
"kforge/internal/generator"
"github.com/spf13/cobra"
)
var (
generateEnvs []string
generateOutput string
generateDry bool
)
var generateCmd = &cobra.Command{
Use: "generate",
Short: "Generate Kubernetes manifests from kforge.yml",
Long: `Reads kforge.yml (or the file specified with --config), resolves
each requested environment, and writes flat Kubernetes manifest
files to the output directory.
If no --env flags are given, manifests are generated for all
environments defined in kforge.yml.
Examples:
kforge generate
kforge generate --env staging
kforge generate --env staging --env production
kforge generate --env production --output .kube/
kforge generate --dry-run`,
RunE: runGenerate,
}
func init() {
generateCmd.Flags().StringArrayVarP(&generateEnvs, "env", "e", nil,
"Environment(s) to generate (default: all)")
generateCmd.Flags().StringVarP(&generateOutput, "output", "o", ".kforge-out",
"Directory to write generated manifests into")
generateCmd.Flags().BoolVar(&generateDry, "dry-run", false,
"Print manifests to stdout instead of writing files")
rootCmd.AddCommand(generateCmd)
}
func runGenerate(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
envKeys := generateEnvs
if len(envKeys) == 0 {
envKeys = config.EnvironmentKeys(cfg)
}
if !generateDry {
if err := os.MkdirAll(generateOutput, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
}
for _, envKey := range envKeys {
if err := generateForEnv(cfg, envKey); err != nil {
return fmt.Errorf("env %q: %w", envKey, err)
}
}
return nil
}
func generateForEnv(cfg *config.KforgeConfig, envKey string) error {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
return err
}
// Core manifests (Service, Deployment, Ingress, Certs).
coreYAML, err := generator.GenerateAll(&env, cfg)
if err != nil {
return fmt.Errorf("generating core manifests: %w", err)
}
// Infrastructure manifests.
infraManifests, err := generator.GenerateInfrastructure(&env)
if err != nil {
return fmt.Errorf("generating infrastructure manifests: %w", err)
}
// Collect all infrastructure env vars and append to deployment.
// We re-generate core manifests after injecting infra env vars.
var infraEnvVars []config.EnvVarConfig
for _, m := range infraManifests {
infraEnvVars = append(infraEnvVars, m.EnvVars...)
}
if len(infraEnvVars) > 0 {
env.EnvVars = config.MergeEnvVars(env.EnvVars, infraEnvVars)
coreYAML, err = generator.GenerateAll(&env, cfg)
if err != nil {
return fmt.Errorf("re-generating core manifests with infra vars: %w", err)
}
}
if generateDry {
printDryRun(envKey, "core", coreYAML)
for _, m := range infraManifests {
printDryRun(envKey, m.Name, m.Content)
}
return nil
}
// Write core manifest.
coreFile := filepath.Join(generateOutput, envKey+"-core.yaml")
if err := os.WriteFile(coreFile, []byte(coreYAML), 0o644); err != nil {
return fmt.Errorf("writing core manifest: %w", err)
}
fmt.Printf(" ✓ %s\n", coreFile)
// Write infra manifests.
for _, m := range infraManifests {
infraFile := filepath.Join(generateOutput, envKey+"-infra-"+m.Name+".yaml")
if err := os.WriteFile(infraFile, []byte(m.Content), 0o644); err != nil {
return fmt.Errorf("writing %s manifest: %w", m.Name, err)
}
fmt.Printf(" ✓ %s\n", infraFile)
}
return nil
}
func printDryRun(envKey, name, content string) {
fmt.Printf("\n%s\n# --- %s / %s ---\n%s\n",
generator.Separator,
strings.ToUpper(envKey),
name,
content,
)
}
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"fmt"
"os"
"kforge/internal/config"
"github.com/spf13/cobra"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "kforge",
Short: "kforge — Kubernetes manifest generator for your homelab",
Long: `kforge reads a kforge.yml file and generates flat Kubernetes
manifests for each environment. It handles namespacing, TLS,
ingress, infrastructure (CNPG, Valkey, NATS, Minio, Meilisearch),
cron jobs, and DNS record creation via Cloudflare.
Get started:
kforge validate Check your kforge.yml and secrets
kforge generate Generate manifests for all environments
kforge generate --env production --dry-run
kforge secrets list Show required secrets checklist`,
}
func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "kforge.yml",
"Path to kforge.yml")
}
// Execute runs the root command. Called from main().
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// loadConfig is a shared helper used by all subcommands.
func loadConfig() (*config.KforgeConfig, error) {
cfg, err := config.Load(cfgFile)
if err != nil {
return nil, fmt.Errorf("loading %s: %w", cfgFile, err)
}
return cfg, nil
}
+307
View File
@@ -0,0 +1,307 @@
package cmd
import (
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
"os"
"os/exec"
"strings"
"kforge/internal/config"
"github.com/spf13/cobra"
)
// passwordChars mirrors the character set from your original
// shell command: A-Za-z0-9 + printable special chars.
const passwordChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_{|}~`
var (
secretsApplyEnvs []string
secretsApplyForce bool
)
var secretsApplyCmd = &cobra.Command{
Use: "apply",
Short: "Generate and apply cluster secrets for an environment",
Long: `Generates secure random credentials for all enabled infrastructure
services and creates/updates Kubernetes Secrets in the cluster.
Secrets are created with kubectl — KUBE_HOST, KUBE_TOKEN, and
KUBE_CERTIFICATE must be set in the environment (Gitea injects
these automatically during CI runs).
Already-existing secrets are NOT overwritten unless --force is
passed. This prevents accidental credential rotation.
Examples:
kforge secrets apply --env staging
kforge secrets apply --env production --force`,
RunE: runSecretsApply,
}
func init() {
secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil,
"Environment(s) to apply secrets for (required)")
secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false,
"Overwrite existing secrets (triggers credential rotation)")
_ = secretsApplyCmd.MarkFlagRequired("env")
secretsCmd.AddCommand(secretsApplyCmd)
}
func runSecretsApply(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
for _, envKey := range secretsApplyEnvs {
fmt.Printf("\nApplying secrets for environment: %s\n", envKey)
if err := applySecretsForEnv(cfg, envKey); err != nil {
return fmt.Errorf("env %q: %w", envKey, err)
}
}
return nil
}
func applySecretsForEnv(cfg *config.KforgeConfig, envKey string) error {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
return err
}
// Basic auth htpasswd secret
if env.Ingress.Auth.Enabled {
if err := applyBasicAuthSecret(&env); err != nil {
return fmt.Errorf("basic auth: %w", err)
}
}
infra := env.Infrastructure
if infra.Cache != nil {
if err := applyGenericSecret(
env.FullName+"-cache-credentials",
env.Namespace,
map[string]string{"password": generatePassword(32)},
); err != nil {
return fmt.Errorf("cache credentials: %w", err)
}
}
if infra.Storage != nil {
if err := applyGenericSecret(
env.FullName+"-storage-credentials",
env.Namespace,
map[string]string{
"access_key": generateAlphanumeric(20),
"secret_key": generatePassword(40),
},
); err != nil {
return fmt.Errorf("storage credentials: %w", err)
}
}
if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" {
if err := applyGenericSecret(
env.FullName+"-queue-credentials",
env.Namespace,
map[string]string{
"username": "kforge",
"password": generatePassword(32),
},
); err != nil {
return fmt.Errorf("queue credentials: %w", err)
}
}
if infra.Search != nil {
if err := applyGenericSecret(
env.FullName+"-search-credentials",
env.Namespace,
map[string]string{"master_key": generatePassword(48)},
); err != nil {
return fmt.Errorf("search credentials: %w", err)
}
}
return nil
}
// applyBasicAuthSecret generates an htpasswd entry for each user
// and stores it in the basic-auth Secret.
func applyBasicAuthSecret(env *config.ResolvedEnvironment) error {
auth := env.Ingress.Auth
if len(auth.Users) == 0 {
return fmt.Errorf("auth.users must contain at least one username")
}
var htpasswdLines []string
fmt.Printf(" Generating basic auth credentials:\n")
for _, username := range auth.Users {
password := generatePassword(32)
// Generate bcrypt hash using htpasswd (available on most systems)
// or fall back to a simple SHA1 if htpasswd isn't available.
hash, err := generateHTPasswdEntry(username, password)
if err != nil {
return fmt.Errorf("hashing password for %s: %w", username, err)
}
htpasswdLines = append(htpasswdLines, hash)
fmt.Printf(" user: %-20s password: %s\n", username, password)
fmt.Printf(" ⚠ Save this password now — it will not be shown again.\n")
}
htpasswd := strings.Join(htpasswdLines, "\n") + "\n"
return applyGenericSecret(
auth.SecretName,
env.Namespace,
map[string]string{"auth": htpasswd},
)
}
// generateHTPasswdEntry produces a username:bcrypt_hash string.
// Uses htpasswd binary if available, otherwise uses openssl.
func generateHTPasswdEntry(username, password string) (string, error) {
// Try htpasswd first (apache2-utils package).
if path, err := exec.LookPath("htpasswd"); err == nil {
out, err := exec.Command(path, "-nbB", username, password).Output()
if err == nil {
return strings.TrimSpace(string(out)), nil
}
}
// Fall back to openssl passwd -apr1 (MD5 crypt, still widely supported).
if path, err := exec.LookPath("openssl"); err == nil {
out, err := exec.Command(path, "passwd", "-apr1", password).Output()
if err == nil {
return username + ":" + strings.TrimSpace(string(out)), nil
}
}
return "", fmt.Errorf("neither htpasswd nor openssl found; install apache2-utils")
}
// applyGenericSecret creates or updates a Kubernetes Secret using
// kubectl. Skips creation if the secret already exists and --force
// was not passed.
func applyGenericSecret(name, namespace string, data map[string]string) error {
// Check if secret already exists.
checkCmd := kubectlCmd("get", "secret", name, "-n", namespace, "--ignore-not-found")
out, err := checkCmd.Output()
if err != nil {
return fmt.Errorf("checking secret %s: %w", name, err)
}
exists := strings.TrimSpace(string(out)) != ""
if exists && !secretsApplyForce {
fmt.Printf(" ✓ secret %s already exists (use --force to rotate)\n", name)
return nil
}
// Build kubectl create secret generic args.
args := []string{
"create", "secret", "generic", name,
"-n", namespace,
"--save-config",
"--dry-run=client",
"-o", "yaml",
}
for k, v := range data {
args = append(args, fmt.Sprintf("--from-literal=%s=%s", k, v))
}
// Pipe through kubectl apply to handle create-or-update.
createCmd := kubectlCmd(args...)
yamlBytes, err := createCmd.Output()
if err != nil {
return fmt.Errorf("generating secret manifest for %s: %w", name, err)
}
applyCmd := kubectlCmd("apply", "-f", "-", "-n", namespace)
applyCmd.Stdin = strings.NewReader(string(yamlBytes))
applyCmd.Stdout = os.Stdout
applyCmd.Stderr = os.Stderr
if err := applyCmd.Run(); err != nil {
return fmt.Errorf("applying secret %s: %w", name, err)
}
action := "created"
if exists {
action = "rotated"
}
fmt.Printf(" ✓ secret %s %s\n", name, action)
return nil
}
// kubectlCmd builds a kubectl invocation using the KUBE_HOST,
// KUBE_TOKEN, and KUBE_CERTIFICATE env vars for auth — the same
// pattern used in your existing Gitea Actions workflow.
func kubectlCmd(args ...string) *exec.Cmd {
base := []string{}
if host := os.Getenv("KUBE_HOST"); host != "" {
base = append(base, "--server="+host)
}
if token := os.Getenv("KUBE_TOKEN"); token != "" {
base = append(base, "--token="+token)
}
if cert := os.Getenv("KUBE_CERTIFICATE"); cert != "" {
// KUBE_CERTIFICATE is the base64-encoded CA cert.
// Decode it to a temp file or pass inline.
decoded, err := base64.StdEncoding.DecodeString(cert)
if err == nil {
// Write to a temp file for kubectl.
f, err := os.CreateTemp("", "kforge-ca-*.crt")
if err == nil {
_, _ = f.Write(decoded)
f.Close()
base = append(base, "--certificate-authority="+f.Name())
}
}
} else {
// No cert provided — use insecure skip (matches your current workflow).
base = append(base, "--insecure-skip-tls-verify=true")
}
cmd := exec.Command("kubectl", append(base, args...)...)
return cmd
}
// ------------------------------------------------------------
// Password generation
// ------------------------------------------------------------
// generatePassword produces a cryptographically random password
// of length n using the full printable ASCII character set.
// Mirrors: tr -dc 'A-Za-z0-9!"#$%&...' </dev/urandom | head -c 32
func generatePassword(n int) string {
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(passwordChars))))
if err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
b[i] = passwordChars[idx.Int64()]
}
return string(b)
}
// generateAlphanumeric produces a random alphanumeric string
// suitable for access keys and usernames.
func generateAlphanumeric(n int) string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
b[i] = chars[idx.Int64()]
}
return string(b)
}
+252
View File
@@ -0,0 +1,252 @@
package cmd
import (
"fmt"
"os"
"sort"
"strings"
"kforge/internal/config"
"github.com/spf13/cobra"
)
// ------------------------------------------------------------
// validate
// ------------------------------------------------------------
var validateCmd = &cobra.Command{
Use: "validate",
Short: "Validate kforge.yml and check required secrets",
Long: `Parses kforge.yml and checks for:
- Structural errors (missing required fields, invalid types)
- Required Gitea/CI secrets (checks current process environment)
- Interpolation tokens that can't be resolved
Exits 0 if valid, non-zero if any issue is found.`,
RunE: runValidate,
}
func init() {
rootCmd.AddCommand(validateCmd)
}
func runValidate(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
fmt.Printf("✓ kforge.yml is valid\n")
fmt.Printf(" app: %s/%s\n", cfg.Meta.Tenant, cfg.Meta.Name)
fmt.Printf(" envs: %s\n", strings.Join(config.EnvironmentKeys(cfg), ", "))
// Check required secrets.
missing := checkRequiredSecrets(cfg)
if len(missing) > 0 {
fmt.Println("\n⚠ Missing required secrets (not found in environment):")
for _, s := range missing {
fmt.Printf(" ✗ %s (%s)\n", s.Name, s.Location)
}
return fmt.Errorf("%d required secret(s) not set", len(missing))
}
fmt.Println("\n✓ All required secrets are present")
return nil
}
// ------------------------------------------------------------
// secrets list
// ------------------------------------------------------------
var secretsCmd = &cobra.Command{
Use: "secrets",
Short: "Manage and document kforge secrets",
}
var secretsListCmd = &cobra.Command{
Use: "list",
Short: "List all secrets required for this repository",
Long: `Prints a categorised table of every secret kforge needs,
where each one should live, and whether it is currently set
in the process environment.
Use this as an onboarding checklist when setting up a new repo.`,
RunE: runSecretsList,
}
func init() {
secretsCmd.AddCommand(secretsListCmd)
rootCmd.AddCommand(secretsCmd)
}
func runSecretsList(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
required := buildRequiredSecrets(cfg)
// Group by location.
groups := map[string][]requiredSecret{}
for _, s := range required {
groups[s.Location] = append(groups[s.Location], s)
}
locations := []string{
"Gitea org secret",
"Gitea repo secret",
"Cluster secret (auto-generated)",
}
for _, loc := range locations {
secrets := groups[loc]
if len(secrets) == 0 {
continue
}
fmt.Printf("\n── %s ──\n", loc)
for _, s := range secrets {
status := "✓ set"
if loc != "Cluster secret (auto-generated)" {
if _, ok := os.LookupEnv(s.Name); !ok {
status = "✗ missing"
}
} else {
status = "— managed by kforge"
}
fmt.Printf(" %-40s %s\n", s.Name, status)
if s.Description != "" {
fmt.Printf(" %s\n", s.Description)
}
}
}
fmt.Println()
return nil
}
// ------------------------------------------------------------
// Secret definitions
// ------------------------------------------------------------
type requiredSecret struct {
Name string
Location string // "Gitea org secret" | "Gitea repo secret" | "Cluster secret (auto-generated)"
Description string
Required bool
}
// buildRequiredSecrets returns the full list of secrets this
// kforge.yml requires, based on what's enabled.
func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
secrets := []requiredSecret{
// Always required — org level
{Name: "DOCKER_USERNAME", Location: "Gitea org secret", Required: true},
{Name: "DOCKER_PASSWORD", Location: "Gitea org secret", Required: true},
{Name: "SOPS_AGE_KEY", Location: "Gitea org secret", Description: "Decrypts .kforge/secrets.enc.yml", Required: true},
{Name: "KFORGE_NODE_IP", Location: "Gitea org secret", Description: "MicroK8s node IP for DNS A records"},
// Always required — repo level
{Name: "KUBE_HOST", Location: "Gitea repo secret", Required: true},
{Name: "KUBE_TOKEN", Location: "Gitea repo secret", Required: true},
{Name: "KUBE_CERTIFICATE", Location: "Gitea repo secret"},
}
// DNS secrets
if cfg.DNS.Provider != "" && !cfg.DNS.SkipDNS {
secrets = append(secrets, requiredSecret{
Name: "CLOUDFLARE_API_TOKEN",
Location: "Gitea org secret",
Description: "Zone:Read + DNS:Edit permissions",
Required: true,
})
for _, zone := range cfg.DNS.Cloudflare.Zones {
varName := "CF_ZONE_ID_" + strings.ToUpper(
strings.NewReplacer(".", "_", "-", "_").Replace(zone.Name),
)
secrets = append(secrets, requiredSecret{
Name: varName,
Location: "Gitea org secret",
Description: "Zone ID for " + zone.Name,
Required: true,
})
}
}
// Per-environment infrastructure secrets
envKeys := config.EnvironmentKeys(cfg)
sort.Strings(envKeys)
for _, envKey := range envKeys {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
continue
}
fullName := env.FullName
infra := env.Infrastructure
if infra.Database != nil {
secrets = append(secrets, requiredSecret{
Name: fullName + "-db-credentials",
Location: "Cluster secret (auto-generated)",
Description: "CNPG-managed database credentials (" + envKey + ")",
})
}
if env.Ingress.Auth.Enabled {
secrets = append(secrets, requiredSecret{
Name: env.Ingress.Auth.SecretName,
Location: "Cluster secret (auto-generated)",
Description: "Basic auth htpasswd (" + envKey + ")",
})
}
if infra.Cache != nil {
secrets = append(secrets, requiredSecret{
Name: fullName + "-cache-credentials",
Location: "Cluster secret (auto-generated)",
Description: "Cache password (" + envKey + ")",
})
}
if infra.Storage != nil {
secrets = append(secrets, requiredSecret{
Name: fullName + "-storage-credentials",
Location: "Cluster secret (auto-generated)",
Description: "Minio access/secret keys (" + envKey + ")",
})
}
if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" {
secrets = append(secrets, requiredSecret{
Name: fullName + "-queue-credentials",
Location: "Cluster secret (auto-generated)",
Description: "RabbitMQ credentials (" + envKey + ")",
})
}
if infra.Search != nil {
secrets = append(secrets, requiredSecret{
Name: fullName + "-search-credentials",
Location: "Cluster secret (auto-generated)",
Description: "Meilisearch master key (" + envKey + ")",
})
}
}
return secrets
}
// checkRequiredSecrets returns secrets that are required but not
// set in the current process environment.
func checkRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
var missing []requiredSecret
for _, s := range buildRequiredSecrets(cfg) {
if s.Location == "Cluster secret (auto-generated)" {
continue // Cluster secrets aren't checked in CI env.
}
if !s.Required {
continue
}
if _, ok := os.LookupEnv(s.Name); !ok {
missing = append(missing, s)
}
}
return missing
}
+119
View File
@@ -0,0 +1,119 @@
#!/bin/sh
set -e
# INPUT_FILE="$1"
# OUTPUT_FILE="$2"
# AUTO_DEPLOY="$3"
# ----------------------------------------------------------------
# Registry login
# ----------------------------------------------------------------
if [ -n "$INPUT_REGISTRY_USERNAME" ] && [ -n "$INPUT_REGISTRY_PASSWORD" ]; then
echo "Logging in to $INPUT_REGISTRY..."
echo "$INPUT_REGISTRY_PASSWORD" | docker login "$INPUT_REGISTRY" \
-u "$INPUT_REGISTRY_USERNAME" --password-stdin
fi
# ----------------------------------------------------------------
# Build and push image
# ----------------------------------------------------------------
cleanup_old_tags() {
IMAGE="$1"
KEEP="$2"
echo "Fetching tags for $IMAGE..."
TAGS=$(curl -s -u "$INPUT_REGISTRY_USERNAME:$INPUT_REGISTRY_PASSWORD" \
"https://$INPUT_REGISTRY/v2/$IMAGE/tags/list" \
| tr ',' '\n' \
| grep -o '"[a-f0-9]\{7\}"' \
| tr -d '"')
COUNT=$(echo "$TAGS" | grep -c .)
DELETE_COUNT=$((COUNT - KEEP))
if [ "$DELETE_COUNT" -le 0 ]; then
echo "Only $COUNT hash tags found, no cleanup needed."
return
fi
echo "Found $COUNT hash tags, deleting oldest $DELETE_COUNT..."
echo "$TAGS" | head -n "$DELETE_COUNT" | while read -r TAG; do
echo "Deleting tag: $TAG..."
DIGEST=$(curl -s -I \
-u "$INPUT_REGISTRY_USERNAME:$INPUT_REGISTRY_PASSWORD" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
"https://$INPUT_REGISTRY/v2/$IMAGE/manifests/$TAG" \
| grep -i "docker-content-digest" \
| tr -d '\r' \
| awk '{print $2}')
if [ -n "$DIGEST" ]; then
curl -s -X DELETE \
-u "$INPUT_REGISTRY_USERNAME:$INPUT_REGISTRY_PASSWORD" \
"https://$INPUT_REGISTRY/v2/$IMAGE/manifests/$DIGEST"
echo "Deleted $TAG ($DIGEST)"
else
echo "Could not find digest for $TAG, skipping."
fi
done
}
if [ -n "$INPUT_IMAGE_NAME" ]; then
FULL_IMAGE="$INPUT_REGISTRY/$INPUT_IMAGE_NAME"
if [ -n "$INPUT_IMAGE_TAG" ]; then
echo "Building image $FULL_IMAGE:$INPUT_IMAGE_TAG..."
docker build -t "$FULL_IMAGE:$INPUT_IMAGE_TAG" -f "$INPUT_DOCKERFILE" .
docker push "$FULL_IMAGE:$INPUT_IMAGE_TAG"
else
SHA=$(echo "$GITHUB_SHA" | cut -c1-7)
echo "Building image $FULL_IMAGE:latest and $FULL_IMAGE:$SHA..."
docker build \
-t "$FULL_IMAGE:latest" \
-t "$FULL_IMAGE:$SHA" \
-f "$INPUT_DOCKERFILE" .
docker push "$FULL_IMAGE:latest"
docker push "$FULL_IMAGE:$SHA"
cleanup_old_tags "$INPUT_IMAGE_NAME" "${INPUT_MAX_TAGS:-5}"
fi
fi
# ----------------------------------------------------------------
# Generate Kubernetes YAML
# ----------------------------------------------------------------
echo "Generating Kubernetes YAML from .kforge.yml"
/usr/local/bin/kforge generate
# ----------------------------------------------------------------
# Deploy to Kubernetes
# ----------------------------------------------------------------
# Build kubeconfig from token-based credentials
echo "Configuring kubectl..."
kubectl config set-cluster default \
--server="$INPUT_KUBE_HOST" \
--certificate-authority=<(echo "$INPUT_KUBE_CERTIFICATE" | base64 -d)
kubectl config set-credentials default \
--token="$INPUT_KUBE_TOKEN"
kubectl config set-context default \
--cluster=default \
--user=default
kubectl config use-context default
# Create/update regcred secret idempotently
echo "Creating regcred secret..."
kubectl create secret docker-registry regcred \
--docker-server="$INPUT_REGISTRY" \
--docker-username="$INPUT_REGISTRY_USERNAME" \
--docker-password="$INPUT_REGISTRY_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f -
echo "Deploying to Kubernetes..."
kubectl apply -f ./kforge-out/
echo "Deploy complete."
+175
View File
@@ -0,0 +1,175 @@
# Generated by kforge — do not edit manually.
# Re-generate: kforge gitea-actions > .gitea/workflows/deploy.yml
#
# Required Gitea org secrets:
# DOCKER_USERNAME, DOCKER_PASSWORD, KFORGE_NODE_IP
# CLOUDFLARE_API_TOKEN, CF_ZONE_ID_* (per zone)
# SOPS_AGE_KEY
# Required Gitea repo secrets:
# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE
name: Build and Deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Create short commit hash
run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Login to registry
uses: docker/login-action@v2
with:
password: ${{ secrets.DOCKER_PASSWORD }}
registry: registry.natelubitz.com
username: ${{ secrets.DOCKER_USERNAME }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64
provenance: false
push: true
sbom: false
tags: |
registry.natelubitz.com/midterm-tenant/main-ui:latest
registry.natelubitz.com/midterm-tenant/main-ui:${{ env.SHORT_SHA }}
- name: Install kforge
run: |
KFORGE_VERSION="latest"
curl -fsSL "https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64" -o /usr/local/bin/kforge
chmod +x /usr/local/bin/kforge
- name: Validate kforge config (Staging)
run: kforge validate
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Apply cluster secrets (Staging)
run: kforge secrets apply --env staging
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Ensure DNS records (Staging)
run: kforge dns ensure --env staging
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Generate manifests (Staging)
run: kforge generate --env staging --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Apply manifests (Staging)
uses: actions-hub/kubectl@master
with:
args: apply -f .kforge-out/staging-core.yaml -n staging --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Apply infra manifests (Staging)
uses: actions-hub/kubectl@master
with:
args: apply -f .kforge-out/ -l app=stag-midterm-tenant-main-ui -n staging --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Rollout restart (Staging)
uses: actions-hub/kubectl@master
with:
args: rollout restart deployment/stag-midterm-tenant-main-ui -n staging --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Validate kforge config (Production)
run: kforge validate
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Apply cluster secrets (Production)
run: kforge secrets apply --env production
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Ensure DNS records (Production)
run: kforge dns ensure --env production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Generate manifests (Production)
run: kforge generate --env production --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
KFORGE_NODE_IP: ${{ secrets.KFORGE_NODE_IP }}
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
- name: Apply manifests (Production)
uses: actions-hub/kubectl@master
with:
args: apply -f .kforge-out/production-core.yaml -n production --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Apply infra manifests (Production)
uses: actions-hub/kubectl@master
with:
args: apply -f .kforge-out/ -l app=prod-midterm-tenant-main-ui -n production --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
- name: Rollout restart (Production)
uses: actions-hub/kubectl@master
with:
args: rollout restart deployment/prod-midterm-tenant-main-ui -n production --insecure-skip-tls-verify
env:
KUBE_CERTIFICATE: ${{ secrets.KUBE_CERTIFICATE }}
KUBE_HOST: ${{ secrets.KUBE_HOST }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
+90
View File
@@ -0,0 +1,90 @@
meta:
name: main-ui
tenant: midterm-tenant
registry:
url: registry.natelubitz.com
pull_secret: regcred
dns:
provider: cloudflare
cloudflare:
api_token: ${CLOUDFLARE_API_TOKEN}
zones:
- name: natelubitz.com
zone_id: ${CF_ZONE_ID_NATELUBITZ}
- name: midtermtenant.com
zone_id: ${CF_ZONE_ID_MIDTERM}
proxied: false
node_ip: ${KFORGE_NODE_IP}
cluster:
tls_issuer: letsencrypt-prod
ingress_class: nginx
cnpg:
host: cnpg-main-rw.default.svc.cluster.local
defaults:
port: 3000
health_check:
path: /healthcheck
infrastructure:
database:
provider: cnpg
cache:
provider: valkey
mode: standalone
environments:
staging:
namespace: staging
image_tag: latest
env_vars:
- name: VITE_BASE_URL
value: https://api-staging.midtermtenant.com
type: plain
ingress:
hosts:
- hostname: mtt-ui-staging.natelubitz.com
tls: true
dns_record: true
auth:
enabled: true
users:
- nate
lifecycle:
delete: false
production:
namespace: production
image_tag: latest
env_vars:
- name: VITE_BASE_URL
value: https://api.midtermtenant.com
type: plain
ingress:
hosts:
- hostname: mtt-ui.natelubitz.com
tls: true
dns_record: true
- hostname: midtermtenant.com
tls: true
dns_record: false
auth:
enabled: false
infrastructure:
cache:
mode: cluster
replicas: 3
cron_jobs:
- name: db-cleanup
schedule: "0 2 * * *"
command: ["node", "scripts/cleanup.js"]
inherit_env: true
env_vars:
- name: CLEANUP_BATCH_SIZE
value: "500"
type: plain
lifecycle:
delete: false
+11
View File
@@ -0,0 +1,11 @@
module kforge
go 1.22
require (
github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
gopkg.in/yaml.v3 v3.0.1
)
require github.com/inconshreveable/mousetrap v1.1.0 // indirect
+11
View File
@@ -0,0 +1,11 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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
}
+41
View File
@@ -0,0 +1,41 @@
meta:
name: www
tenant: nate-lubitz
registry:
url: registry.natelubitz.com
pull_secret: regcred
dns:
provider: cloudflare
cloudflare:
api_token: ${CLOUDFLARE_API_TOKEN}
zones:
- name: natelubitz.com
zone_id: ${CF_ZONE_ID_NATELUBITZ}
proxied: false
node_ip: ${KFORGE_NODE_IP}
cluster:
tls_issuer: letsencrypt-prod
ingress_class: nginx
cnpg:
host: cnpg-main-rw.default.svc.cluster.local
defaults:
port: 3000
health_check:
path: /healthcheck
environments:
production:
namespace: production
image_tag: latest
ingress:
hosts:
- hostname: natelubitz.com
tls: true
dns_record: false
lifecycle:
delete: false
+7
View File
@@ -0,0 +1,7 @@
package main
import "kforge/cmd"
func main() {
cmd.Execute()
}
+121
View File
@@ -0,0 +1,121 @@
// Package interpolate resolves ${token} references in kforge.yml
// string values at generation time.
//
// Built-in tokens:
//
// ${name} meta.name
// ${tenant} meta.tenant
// ${env} current environment key
// ${env_prefix} short prefix (e.g. "prod")
// ${full_name} ${env_prefix}-${tenant}-${name}
// ${image_tag} resolved image tag
// ${namespace} resolved namespace
//
// Environment variable tokens (e.g. ${KFORGE_NODE_IP}) are
// resolved from the process environment at runtime, which is
// how Gitea Actions secrets are injected.
package interpolate
import (
"os"
"strings"
)
// Tokens is a map of token name → value used for interpolation.
type Tokens map[string]string
// FromEnvironment builds the standard token map for a resolved
// environment. Pass this to Apply or ApplyToMap.
func FromEnvironment(
name, tenant, envKey, envPrefix, fullName, imageTag, namespace string,
) Tokens {
return Tokens{
"name": name,
"tenant": tenant,
"env": envKey,
"env_prefix": envPrefix,
"full_name": fullName,
"image_tag": imageTag,
"namespace": namespace,
}
}
// Apply replaces all ${token} references in s with their values.
//
// Resolution order:
// 1. tokens map (built-in kforge tokens)
// 2. Process environment (Gitea/CI secrets)
//
// Unknown tokens are left as-is so they surface as obvious errors
// rather than silently becoming empty strings.
func Apply(s string, tokens Tokens) string {
if !strings.Contains(s, "${") {
return s
}
var b strings.Builder
b.Grow(len(s))
i := 0
for i < len(s) {
start := strings.Index(s[i:], "${")
if start == -1 {
b.WriteString(s[i:])
break
}
b.WriteString(s[i : i+start])
i += start
end := strings.Index(s[i:], "}")
if end == -1 {
// Unclosed brace — write the rest as-is.
b.WriteString(s[i:])
break
}
token := s[i+2 : i+end] // content between ${ and }
i += end + 1
if val, ok := tokens[token]; ok {
b.WriteString(val)
} else if val, ok := os.LookupEnv(token); ok {
b.WriteString(val)
} else {
// Leave unknown tokens intact.
b.WriteString("${")
b.WriteString(token)
b.WriteString("}")
}
}
return b.String()
}
// ApplyToSlice applies interpolation to every string in a slice.
func ApplyToSlice(ss []string, tokens Tokens) []string {
out := make([]string, len(ss))
for i, s := range ss {
out[i] = Apply(s, tokens)
}
return out
}
// Slug converts a string into a safe Kubernetes resource name
// component: lowercase, hyphens, no dots or underscores.
func Slug(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
case r == '_' || r == '.' || r == ' ':
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}
// HostSlug produces a safe slug from a hostname for use in
// resource names (e.g. TLS secret names).
// "mtt-ui.natelubitz.com" → "mtt-ui-natelubitz-com"
func HostSlug(hostname string) string {
return Slug(strings.ReplaceAll(hostname, ".", "-"))
}
+566
View File
@@ -0,0 +1,566 @@
# kforge
**kforge** eliminates Kubernetes boilerplate. You define your app once in a `kforge.yml` file — environments, infrastructure, ingress, TLS, DNS — and kforge generates production-ready flat manifests on every CI run. Nothing is committed to your repo except the config.
Built for self-hosted MicroK8s, Gitea Actions, Cloudflare DNS, cert-manager, and CNPG — but designed to be extended.
---
## How it works
1. Add a `kforge.yml` to your repo root describing your app, environments, and infrastructure.
2. kforge generates Kubernetes YAML at CI time — Service, Deployment, Ingress, cert-manager Certificates, CronJobs, and infrastructure (database, cache, storage, queue, search).
3. Generated manifests are applied to the cluster and discarded. Only `kforge.yml` is committed.
```
kforge.yml → kforge generate → kubectl apply → cluster
```
---
## Installation
### Option A — Build from source (recommended for self-hosted runners)
```bash
git clone https://gitea.yourdomain.com/yourorg/kforge.git
cd kforge
go build -o /usr/local/bin/kforge .
```
Install once on your Gitea runner host and it's available to every repo automatically.
### Option B — Gitea Package Registry
If you've published kforge to your Gitea instance's generic package registry:
```bash
curl -fsSL \
-H "Authorization: token $GITEA_TOKEN" \
"https://gitea.yourdomain.com/api/packages/yourorg/generic/kforge/latest/kforge-linux-amd64" \
-o /usr/local/bin/kforge
chmod +x /usr/local/bin/kforge
```
---
## Quick start
**1. Add `kforge.yml` to your repo:**
```yaml
meta:
name: my-app
tenant: my-org
registry:
url: registry.yourdomain.com
dns:
provider: cloudflare
cloudflare:
api_token: ${CLOUDFLARE_API_TOKEN}
zones:
- name: yourdomain.com
zone_id: ${CF_ZONE_ID_YOURDOMAIN}
node_ip: ${KFORGE_NODE_IP}
cluster:
tls_issuer: letsencrypt-prod
cnpg:
host: cnpg-main-rw.default.svc.cluster.local
defaults:
port: 3000
health_check:
path: /healthcheck
infrastructure:
database:
provider: cnpg
cache:
provider: valkey
mode: standalone
environments:
staging:
namespace: staging
image_tag: latest
env_vars:
- name: API_URL
value: https://api-staging.yourdomain.com
type: plain
ingress:
hosts:
- hostname: app-staging.yourdomain.com
tls: true
dns_record: true
auth:
enabled: true
users:
- yourname
production:
namespace: production
image_tag: latest
env_vars:
- name: API_URL
value: https://api.yourdomain.com
type: plain
ingress:
hosts:
- hostname: app.yourdomain.com
tls: true
dns_record: true
infrastructure:
cache:
mode: cluster
replicas: 3
```
**2. Validate your config and see what secrets you need:**
```bash
kforge validate
kforge secrets list
```
**3. Preview what gets generated:**
```bash
kforge generate --env production --dry-run
```
**4. Generate your Gitea Actions workflow:**
```bash
kforge gitea-actions > .gitea/workflows/deploy.yml
```
---
## CLI reference
### `kforge validate`
Parses `kforge.yml`, checks structural correctness, and verifies all required secrets are present in the current environment. Exits non-zero if anything is wrong — use this as the first step in CI to fail fast before touching the cluster.
```bash
kforge validate
kforge validate -c path/to/kforge.yml
```
---
### `kforge generate`
Generates flat Kubernetes manifests for one or all environments. Writes files to `.kforge-out/` by default, or prints to stdout with `--dry-run`.
```bash
kforge generate # all environments
kforge generate --env staging # one environment
kforge generate --env production --dry-run
kforge generate --env production --output .kube/
```
**Output files per environment:**
| File | Contents |
| --------------------------------- | ---------------------------------------------------- |
| `{env}-core.yaml` | Service, Deployment, Ingress, Certificates, CronJobs |
| `{env}-infra-cnpg-database.yaml` | CNPG Database CR |
| `{env}-infra-cnpg-role.yaml` | CNPG DatabaseRole CR |
| `{env}-infra-cache.yaml` | Valkey/Redis Deployment or StatefulSet |
| `{env}-infra-cache-svc.yaml` | Cache Service |
| `{env}-infra-storage.yaml` | Minio Deployment |
| `{env}-infra-queue-nats.yaml` | NATS Deployment |
| `{env}-infra-search.yaml` | Meilisearch Deployment |
| `{env}-infra-servicemonitor.yaml` | Prometheus ServiceMonitor CR |
Infrastructure env vars (`DATABASE_URL`, `CACHE_URL`, `STORAGE_ENDPOINT`, etc.) are automatically injected into the Deployment — you don't wire these up manually.
---
### `kforge secrets list`
Prints the full secrets checklist for this repo, grouped by where each secret needs to live, with live status checks against the current environment.
```bash
kforge secrets list
```
Example output:
```
── Gitea org secret ──
DOCKER_USERNAME ✗ missing
CLOUDFLARE_API_TOKEN ✓ set
CF_ZONE_ID_YOURDOMAIN_COM ✓ set
KFORGE_NODE_IP ✓ set
SOPS_AGE_KEY ✗ missing
── Gitea repo secret ──
KUBE_HOST ✓ set
KUBE_TOKEN ✓ set
KUBE_CERTIFICATE ✓ set
── Cluster secret (auto-generated) ──
prod-my-org-my-app-db-credentials — managed by kforge
prod-my-org-my-app-cache-credentials — managed by kforge
```
---
### `kforge secrets apply`
Generates secure random credentials and creates Kubernetes Secrets in the cluster for all enabled infrastructure services. Safe to run on every deploy — secrets are only created if they don't already exist. Use `--force` to rotate credentials.
```bash
kforge secrets apply --env staging
kforge secrets apply --env production --force # rotates all credentials
```
For basic auth secrets, kforge prints the generated passwords once at apply time. Save them — they are not stored anywhere else.
Requires `KUBE_HOST`, `KUBE_TOKEN`, and `KUBE_CERTIFICATE` to be set (Gitea injects these automatically during CI).
---
### `kforge dns ensure`
Creates or updates Cloudflare DNS A records for all ingress hosts with `dns_record: true`. Idempotent — no-ops if the record already points to the correct IP.
```bash
kforge dns ensure --env staging
kforge dns ensure --env staging --env production
```
Requires `CLOUDFLARE_API_TOKEN` and `KFORGE_NODE_IP` to be set.
---
### `kforge gitea-actions`
Generates a complete `.gitea/workflows/deploy.yml` for this app. Re-run whenever you add environments or change deploy configuration.
```bash
kforge gitea-actions
kforge gitea-actions --output .gitea/workflows/deploy.yml
kforge gitea-actions --branch main --env staging --env production
```
The generated workflow runs these steps for each environment, in order:
1. Build and push Docker image
2. `kforge validate`
3. `kforge secrets apply` — creates missing cluster secrets
4. `kforge dns ensure` — creates missing DNS records
5. `kforge generate` — writes manifests to `.kforge-out/`
6. `kubectl apply` — applies core manifests
7. `kubectl apply` — applies infra manifests
8. `kubectl rollout restart` — triggers rolling update
---
## `kforge.yml` reference
### Naming and interpolation
kforge generates resource names using the pattern `{env_prefix}-{tenant}-{name}` (e.g. `prod-my-org-my-app`). Use `${tokens}` anywhere in string values to reference resolved fields:
| Token | Resolves to |
| --------------- | ------------------------------------------- |
| `${name}` | `meta.name` |
| `${tenant}` | `meta.tenant` |
| `${env}` | current environment key |
| `${env_prefix}` | short env prefix (first 4 chars, or custom) |
| `${full_name}` | `{env_prefix}-{tenant}-{name}` |
| `${namespace}` | resolved namespace for the environment |
Environment variables (e.g. `${CLOUDFLARE_API_TOKEN}`) are resolved from the CI process environment at generation time — never hardcode secrets in `kforge.yml`.
---
### `meta`
```yaml
meta:
name: my-app # required — short app name, lowercase, hyphens ok
tenant: my-org # required — org/tenant identifier
name_override: ~ # optional — override the full generated resource name
previous_name: ~ # optional — set when renaming; kforge patches rather than recreates
```
---
### `registry`
```yaml
registry:
url: registry.yourdomain.com # default: registry.natelubitz.com
repository: my-org/my-app # default: {tenant}/{name}
pull_secret: regcred # default: regcred
```
Override per-environment by adding a `registry:` block under the environment.
---
### `dns`
```yaml
dns:
provider: cloudflare # currently supported: cloudflare
cloudflare:
api_token: ${CLOUDFLARE_API_TOKEN}
zones:
- name: yourdomain.com
zone_id: ${CF_ZONE_ID_YOURDOMAIN}
proxied: false # false = DNS-only, required for cert-manager DNS-01
node_ip: ${KFORGE_NODE_IP} # IP for new A records
skip_dns: false # true to disable all DNS management
```
kforge matches each ingress hostname to the correct zone by longest-suffix match — add one zone entry per domain you own.
---
### `cluster`
```yaml
cluster:
tls_issuer: letsencrypt-prod # default: letsencrypt-prod
ingress_class: nginx # default: nginx
cnpg:
host: cnpg-main-rw.default.svc.cluster.local # your CNPG cluster service
namespace_pattern: "${env}" # default: environment key
```
---
### `defaults`
All fields can be overridden per-environment.
```yaml
defaults:
port: 3000
replicas: 1
image_pull_policy: Always
service_type: ClusterIP
dockerfile: Dockerfile
health_check:
path: /healthcheck
port: 3000
initial_delay_seconds: 15
period_seconds: 10
timeout_seconds: 5
failure_threshold: 3
liveness: true
readiness: true
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
env_vars: []
```
---
### `infrastructure` (root defaults)
Define infrastructure at the root level and it applies to **all environments** by default. Per-environment blocks are a shallow merge — only the fields you specify override; everything else inherits.
```yaml
infrastructure:
database:
provider: cnpg # only supported provider currently
cache:
provider: valkey # valkey (recommended) | redis
mode: standalone # standalone | cluster
replicas: 1
storage:
enabled: false
provider: minio # standalone | distributed
queue:
enabled: false
provider: nats # nats (recommended, ~20MB) | rabbitmq (~200MB)
search:
enabled: false
provider: meilisearch
monitoring:
enabled: false
provider: prometheus
metrics_path: /metrics
```
If a service block exists at root, it is **enabled by default** for all environments. To disable it for a specific environment:
```yaml
environments:
dev:
infrastructure:
cache:
enabled: false
```
To scale up for production while staging uses the root defaults:
```yaml
environments:
production:
infrastructure:
cache:
mode: cluster
replicas: 3 # provider: valkey inherited from root
```
**Injected env vars per service** (automatically added to your Deployment):
| Service | Env vars injected |
| ---------------- | ------------------------------------------------------------------------- |
| database (CNPG) | `DATABASE_URL`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` |
| cache | `CACHE_URL`, `CACHE_HOST`, `CACHE_PORT`, `CACHE_PASSWORD` |
| storage | `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY` |
| queue (NATS) | `QUEUE_URL`, `QUEUE_HOST` |
| queue (RabbitMQ) | `QUEUE_URL`, `QUEUE_USER`, `QUEUE_PASSWORD` |
| search | `SEARCH_URL`, `SEARCH_MASTER_KEY` |
---
### `environments`
```yaml
environments:
production:
namespace: production
replicas: 1
image_tag: latest # override with --set image_tag=$SHA in CI
env_prefix: prod # default: first 4 chars of env key
env_vars:
- name: API_URL
type: plain
value: https://api.yourdomain.com
- name: SOME_SECRET
type: secret_ref # pull from an existing Kubernetes Secret
secret_name: my-secrets
secret_key: some_secret
- name: FEATURE_FLAG
type: configmap_ref # pull from a ConfigMap
configmap_name: my-config
configmap_key: feature_flag
ingress:
hosts:
- hostname: app.yourdomain.com
tls: true # kforge generates a cert-manager Certificate
dns_record: true # kforge creates a Cloudflare A record
auth:
enabled: false # enable for staging/dev to protect unreleased work
users:
- yourname # passwords are auto-generated by kforge secrets apply
infrastructure:
# shallow merge on top of root — only override what differs
cache:
mode: cluster
replicas: 3
cron_jobs:
- name: cleanup
schedule: "0 2 * * *"
command: ["node", "scripts/cleanup.js"]
inherit_env: true # inherits all deployment env vars
env_vars:
- name: BATCH_SIZE
value: "500"
type: plain
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "256Mi"
restart_policy: OnFailure
concurrency_policy: Forbid
lifecycle:
delete: false # if true + previous_name set, deletes old resources
delete_grace_seconds: 300 # 5-minute countdown before deletion runs in CI
```
---
## Secrets architecture
kforge works with three categories of secrets, each living in the right place for its scope.
### Category A — Gitea org secrets
Set once at the organisation level; available to every repo automatically.
| Secret | Purpose |
| ---------------------- | ---------------------------------------------- |
| `DOCKER_USERNAME` | Registry authentication |
| `DOCKER_PASSWORD` | Registry authentication |
| `CLOUDFLARE_API_TOKEN` | DNS record management (Zone:Read + DNS:Edit) |
| `CF_ZONE_ID_{DOMAIN}` | One per zone, e.g. `CF_ZONE_ID_YOURDOMAIN_COM` |
| `KFORGE_NODE_IP` | MicroK8s node IP for DNS A records |
| `SOPS_AGE_KEY` | Decrypts `.kforge/secrets.enc.yml` |
### Category B — Gitea repo secrets
Per-repo, since different apps may deploy to different clusters.
| Secret | Purpose |
| ------------------ | ----------------------------- |
| `KUBE_HOST` | Kubernetes API server URL |
| `KUBE_TOKEN` | Service account token |
| `KUBE_CERTIFICATE` | Base64-encoded CA certificate |
### Category C — Cluster secrets (auto-generated)
Created by `kforge secrets apply`. Never appear in Gitea or in `kforge.yml`.
| Secret name | Contents |
| --------------------------------- | --------------------------------- |
| `{full_name}-db-credentials` | CNPG-managed database credentials |
| `{full_name}-basic-auth` | htpasswd for ingress basic auth |
| `{full_name}-cache-credentials` | Valkey/Redis password |
| `{full_name}-storage-credentials` | Minio access/secret keys |
| `{full_name}-queue-credentials` | RabbitMQ credentials |
| `{full_name}-search-credentials` | Meilisearch master key |
Run `kforge secrets list` at any time to see the full checklist with live status for your current repo.
---
## Project structure
```
kforge.yml ← your app config (committed)
.gitea/
workflows/
deploy.yml ← generated by kforge gitea-actions (committed)
.kforge/
secrets.enc.yml ← SOPS-encrypted sensitive config (committed)
kforge.age ← age private key (NEVER committed — goes in Gitea as SOPS_AGE_KEY)
```
Generated manifests (`.kforge-out/`) are never committed — they are created at CI time and discarded after `kubectl apply`.
---
## Planned
- `kforge serve` — web UI for managing infrastructure across all repos
- `kforge lifecycle rename` — interactive rename flow with resource patching
- Route53 and Porkbun DNS providers
- `--set key=value` flag for runtime overrides (e.g. `--set image_tag=$SHA`)