From 532b912ffb9bb37debf5a4ab8ddada8e02b9bd3a Mon Sep 17 00:00:00 2001 From: Nathanial Lubitz Date: Fri, 5 Jun 2026 02:34:00 +1000 Subject: [PATCH] add tool to gitea --- .gitea/workflows/deploy.yml | 99 ++++ .gitignore | 3 + Dockerfile | 10 + action.yml | 53 +++ cmd/dns_gitea.go | 170 +++++++ cmd/generate.go | 141 ++++++ cmd/root.go | 49 ++ cmd/secrets_apply.go | 307 ++++++++++++ cmd/validate.go | 252 ++++++++++ entrypoint.sh | 119 +++++ example/deploy.yml | 175 +++++++ example/kforge.yml | 90 ++++ go.mod | 11 + go.sum | 11 + internal/config/defaults.go | 687 +++++++++++++++++++++++++++ internal/config/loader.go | 107 +++++ internal/config/types.go | 274 +++++++++++ internal/dns/cloudflare.go | 301 ++++++++++++ internal/generator/gitea_actions.go | 315 ++++++++++++ internal/generator/infrastructure.go | 657 +++++++++++++++++++++++++ internal/generator/manifests.go | 415 ++++++++++++++++ kforge.yml | 41 ++ main.go | 7 + pkg/interpolate/interpolate.go | 121 +++++ readme.md | 566 ++++++++++++++++++++++ 25 files changed, 4981 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 action.yml create mode 100644 cmd/dns_gitea.go create mode 100644 cmd/generate.go create mode 100644 cmd/root.go create mode 100644 cmd/secrets_apply.go create mode 100644 cmd/validate.go create mode 100644 entrypoint.sh create mode 100644 example/deploy.yml create mode 100644 example/kforge.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/defaults.go create mode 100644 internal/config/loader.go create mode 100644 internal/config/types.go create mode 100644 internal/dns/cloudflare.go create mode 100644 internal/generator/gitea_actions.go create mode 100644 internal/generator/infrastructure.go create mode 100644 internal/generator/manifests.go create mode 100644 kforge.yml create mode 100644 main.go create mode 100644 pkg/interpolate/interpolate.go create mode 100644 readme.md diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..b47fd9a --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..884d1b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +kforge +kforge.exe +.kforge-out/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..803697d --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..8ccdf5c --- /dev/null +++ b/action.yml @@ -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 }} diff --git a/cmd/dns_gitea.go b/cmd/dns_gitea.go new file mode 100644 index 0000000..75a1b7d --- /dev/null +++ b/cmd/dns_gitea.go @@ -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 +} diff --git a/cmd/generate.go b/cmd/generate.go new file mode 100644 index 0000000..566f409 --- /dev/null +++ b/cmd/generate.go @@ -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, + ) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..a5fef6c --- /dev/null +++ b/cmd/root.go @@ -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 +} diff --git a/cmd/secrets_apply.go b/cmd/secrets_apply.go new file mode 100644 index 0000000..880c3f6 --- /dev/null +++ b/cmd/secrets_apply.go @@ -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!"#$%&...' 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 +} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..1e2aa44 --- /dev/null +++ b/entrypoint.sh @@ -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." \ No newline at end of file diff --git a/example/deploy.yml b/example/deploy.yml new file mode 100644 index 0000000..1df2f3c --- /dev/null +++ b/example/deploy.yml @@ -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 }} diff --git a/example/kforge.yml b/example/kforge.yml new file mode 100644 index 0000000..4bbee01 --- /dev/null +++ b/example/kforge.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1ae6d7a --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b17a5f8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..2af4577 --- /dev/null +++ b/internal/config/defaults.go @@ -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 +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..bb2c792 --- /dev/null +++ b/internal/config/loader.go @@ -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 +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..f9b25ff --- /dev/null +++ b/internal/config/types.go @@ -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 +} diff --git a/internal/dns/cloudflare.go b/internal/dns/cloudflare.go new file mode 100644 index 0000000..f51078b --- /dev/null +++ b/internal/dns/cloudflare.go @@ -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 +} diff --git a/internal/generator/gitea_actions.go b/internal/generator/gitea_actions.go new file mode 100644 index 0000000..65c09d1 --- /dev/null +++ b/internal/generator/gitea_actions.go @@ -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 +} diff --git a/internal/generator/infrastructure.go b/internal/generator/infrastructure.go new file mode 100644 index 0000000..bbe3c96 --- /dev/null +++ b/internal/generator/infrastructure.go @@ -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 +} diff --git a/internal/generator/manifests.go b/internal/generator/manifests.go new file mode 100644 index 0000000..0967fb3 --- /dev/null +++ b/internal/generator/manifests.go @@ -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 +} diff --git a/kforge.yml b/kforge.yml new file mode 100644 index 0000000..dcfaa60 --- /dev/null +++ b/kforge.yml @@ -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 diff --git a/main.go b/main.go new file mode 100644 index 0000000..520b9a3 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "kforge/cmd" + +func main() { + cmd.Execute() +} diff --git a/pkg/interpolate/interpolate.go b/pkg/interpolate/interpolate.go new file mode 100644 index 0000000..b009fe7 --- /dev/null +++ b/pkg/interpolate/interpolate.go @@ -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, ".", "-")) +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..a8ac16d --- /dev/null +++ b/readme.md @@ -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`)