This commit is contained in:
@@ -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 }}
|
||||
@@ -0,0 +1,3 @@
|
||||
kforge
|
||||
kforge.exe
|
||||
.kforge-out/
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
FROM golang:1.22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN go build -o kforge .
|
||||
|
||||
FROM alpine:3.19
|
||||
COPY --from=builder /app/kforge /usr/local/bin/kforge
|
||||
RUN apk add --no-cache curl && \
|
||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
|
||||
install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
|
||||
+53
@@ -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 }}
|
||||
@@ -0,0 +1,170 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"kforge/internal/config"
|
||||
dnsProvider "kforge/internal/dns"
|
||||
"kforge/internal/generator"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// kforge dns ensure
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var dnsCmd = &cobra.Command{
|
||||
Use: "dns",
|
||||
Short: "Manage DNS records for kforge environments",
|
||||
}
|
||||
|
||||
var dnsEnsureEnvs []string
|
||||
|
||||
var dnsEnsureCmd = &cobra.Command{
|
||||
Use: "ensure",
|
||||
Short: "Create or update DNS A records for ingress hosts",
|
||||
Long: `For each ingress host with dns_record: true, creates or updates
|
||||
an A record pointing to KFORGE_NODE_IP.
|
||||
|
||||
Idempotent — safe to run on every deploy. Skips hosts where the
|
||||
record already points to the correct IP.
|
||||
|
||||
Examples:
|
||||
kforge dns ensure --env staging
|
||||
kforge dns ensure --env staging --env production`,
|
||||
RunE: runDNSEnsure,
|
||||
}
|
||||
|
||||
func init() {
|
||||
dnsEnsureCmd.Flags().StringArrayVarP(&dnsEnsureEnvs, "env", "e", nil,
|
||||
"Environment(s) to ensure DNS records for (default: all)")
|
||||
dnsCmd.AddCommand(dnsEnsureCmd)
|
||||
rootCmd.AddCommand(dnsCmd)
|
||||
}
|
||||
|
||||
func runDNSEnsure(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.DNS.SkipDNS {
|
||||
fmt.Println("dns.skip_dns is true — skipping DNS management")
|
||||
return nil
|
||||
}
|
||||
|
||||
nodeIP := cfg.DNS.NodeIP
|
||||
if nodeIP == "" {
|
||||
nodeIP = os.Getenv("KFORGE_NODE_IP")
|
||||
}
|
||||
if nodeIP == "" {
|
||||
return fmt.Errorf("node IP not set: add dns.node_ip to kforge.yml or set KFORGE_NODE_IP")
|
||||
}
|
||||
|
||||
provider, err := dnsProvider.NewProvider(cfg.DNS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialising DNS provider: %w", err)
|
||||
}
|
||||
|
||||
envKeys := dnsEnsureEnvs
|
||||
if len(envKeys) == 0 {
|
||||
envKeys = config.EnvironmentKeys(cfg)
|
||||
}
|
||||
|
||||
for _, envKey := range envKeys {
|
||||
fmt.Printf("\nEnsuring DNS records for: %s\n", envKey)
|
||||
env, err := config.ResolveEnvironment(cfg, envKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dnsProvider.EnsureRecordsForEnvironment(provider, &env, nodeIP); err != nil {
|
||||
return fmt.Errorf("env %q: %w", envKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// kforge gitea-actions
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var (
|
||||
giteaActionsOutput string
|
||||
giteaActionsBranch string
|
||||
giteaActionsEnvs []string
|
||||
)
|
||||
|
||||
var giteaActionsCmd = &cobra.Command{
|
||||
Use: "gitea-actions",
|
||||
Short: "Generate a Gitea Actions workflow for this app",
|
||||
Long: `Generates a complete .gitea/workflows/deploy.yml that:
|
||||
- Builds and pushes the Docker image on every push to main
|
||||
- Runs kforge validate
|
||||
- Applies cluster secrets (idempotent)
|
||||
- Ensures DNS records
|
||||
- Generates manifests and applies them with kubectl
|
||||
- Rolls out the deployment
|
||||
|
||||
The generated workflow replaces your hand-written deploy.yml.
|
||||
Re-run whenever you add environments or change deploy options.
|
||||
|
||||
Examples:
|
||||
kforge gitea-actions
|
||||
kforge gitea-actions --output .gitea/workflows/deploy.yml
|
||||
kforge gitea-actions --env staging --env production`,
|
||||
RunE: runGiteaActions,
|
||||
}
|
||||
|
||||
func init() {
|
||||
giteaActionsCmd.Flags().StringVarP(&giteaActionsOutput, "output", "o",
|
||||
".gitea/workflows/deploy.yml",
|
||||
"Output path for the generated workflow file")
|
||||
giteaActionsCmd.Flags().StringVar(&giteaActionsBranch, "branch", "main",
|
||||
"Branch that triggers the workflow")
|
||||
giteaActionsCmd.Flags().StringArrayVarP(&giteaActionsEnvs, "env", "e", nil,
|
||||
"Environments to deploy (default: all, staging before production)")
|
||||
rootCmd.AddCommand(giteaActionsCmd)
|
||||
}
|
||||
|
||||
func runGiteaActions(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workflow, err := generator.GenerateGiteaActions(cfg, generator.GiteaActionsOptions{
|
||||
Branch: giteaActionsBranch,
|
||||
Environments: giteaActionsEnvs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating workflow: %w", err)
|
||||
}
|
||||
|
||||
if giteaActionsOutput == "-" {
|
||||
fmt.Print(workflow)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
dir := giteaActionsOutput
|
||||
for i := len(dir) - 1; i >= 0; i-- {
|
||||
if dir[i] == '/' {
|
||||
dir = dir[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if dir != giteaActionsOutput {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(giteaActionsOutput, []byte(workflow), 0o644); err != nil {
|
||||
return fmt.Errorf("writing workflow: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gitea Actions workflow written to %s\n", giteaActionsOutput)
|
||||
return nil
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kforge/internal/config"
|
||||
"kforge/internal/generator"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
generateEnvs []string
|
||||
generateOutput string
|
||||
generateDry bool
|
||||
)
|
||||
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate Kubernetes manifests from kforge.yml",
|
||||
Long: `Reads kforge.yml (or the file specified with --config), resolves
|
||||
each requested environment, and writes flat Kubernetes manifest
|
||||
files to the output directory.
|
||||
|
||||
If no --env flags are given, manifests are generated for all
|
||||
environments defined in kforge.yml.
|
||||
|
||||
Examples:
|
||||
kforge generate
|
||||
kforge generate --env staging
|
||||
kforge generate --env staging --env production
|
||||
kforge generate --env production --output .kube/
|
||||
kforge generate --dry-run`,
|
||||
RunE: runGenerate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
generateCmd.Flags().StringArrayVarP(&generateEnvs, "env", "e", nil,
|
||||
"Environment(s) to generate (default: all)")
|
||||
generateCmd.Flags().StringVarP(&generateOutput, "output", "o", ".kforge-out",
|
||||
"Directory to write generated manifests into")
|
||||
generateCmd.Flags().BoolVar(&generateDry, "dry-run", false,
|
||||
"Print manifests to stdout instead of writing files")
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
}
|
||||
|
||||
func runGenerate(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
envKeys := generateEnvs
|
||||
if len(envKeys) == 0 {
|
||||
envKeys = config.EnvironmentKeys(cfg)
|
||||
}
|
||||
|
||||
if !generateDry {
|
||||
if err := os.MkdirAll(generateOutput, 0o755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, envKey := range envKeys {
|
||||
if err := generateForEnv(cfg, envKey); err != nil {
|
||||
return fmt.Errorf("env %q: %w", envKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateForEnv(cfg *config.KforgeConfig, envKey string) error {
|
||||
env, err := config.ResolveEnvironment(cfg, envKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Core manifests (Service, Deployment, Ingress, Certs).
|
||||
coreYAML, err := generator.GenerateAll(&env, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating core manifests: %w", err)
|
||||
}
|
||||
|
||||
// Infrastructure manifests.
|
||||
infraManifests, err := generator.GenerateInfrastructure(&env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating infrastructure manifests: %w", err)
|
||||
}
|
||||
|
||||
// Collect all infrastructure env vars and append to deployment.
|
||||
// We re-generate core manifests after injecting infra env vars.
|
||||
var infraEnvVars []config.EnvVarConfig
|
||||
for _, m := range infraManifests {
|
||||
infraEnvVars = append(infraEnvVars, m.EnvVars...)
|
||||
}
|
||||
if len(infraEnvVars) > 0 {
|
||||
env.EnvVars = config.MergeEnvVars(env.EnvVars, infraEnvVars)
|
||||
coreYAML, err = generator.GenerateAll(&env, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-generating core manifests with infra vars: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if generateDry {
|
||||
printDryRun(envKey, "core", coreYAML)
|
||||
for _, m := range infraManifests {
|
||||
printDryRun(envKey, m.Name, m.Content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write core manifest.
|
||||
coreFile := filepath.Join(generateOutput, envKey+"-core.yaml")
|
||||
if err := os.WriteFile(coreFile, []byte(coreYAML), 0o644); err != nil {
|
||||
return fmt.Errorf("writing core manifest: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ %s\n", coreFile)
|
||||
|
||||
// Write infra manifests.
|
||||
for _, m := range infraManifests {
|
||||
infraFile := filepath.Join(generateOutput, envKey+"-infra-"+m.Name+".yaml")
|
||||
if err := os.WriteFile(infraFile, []byte(m.Content), 0o644); err != nil {
|
||||
return fmt.Errorf("writing %s manifest: %w", m.Name, err)
|
||||
}
|
||||
fmt.Printf(" ✓ %s\n", infraFile)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDryRun(envKey, name, content string) {
|
||||
fmt.Printf("\n%s\n# --- %s / %s ---\n%s\n",
|
||||
generator.Separator,
|
||||
strings.ToUpper(envKey),
|
||||
name,
|
||||
content,
|
||||
)
|
||||
}
|
||||
+49
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kforge/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// passwordChars mirrors the character set from your original
|
||||
// shell command: A-Za-z0-9 + printable special chars.
|
||||
const passwordChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_{|}~`
|
||||
|
||||
var (
|
||||
secretsApplyEnvs []string
|
||||
secretsApplyForce bool
|
||||
)
|
||||
|
||||
var secretsApplyCmd = &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "Generate and apply cluster secrets for an environment",
|
||||
Long: `Generates secure random credentials for all enabled infrastructure
|
||||
services and creates/updates Kubernetes Secrets in the cluster.
|
||||
|
||||
Secrets are created with kubectl — KUBE_HOST, KUBE_TOKEN, and
|
||||
KUBE_CERTIFICATE must be set in the environment (Gitea injects
|
||||
these automatically during CI runs).
|
||||
|
||||
Already-existing secrets are NOT overwritten unless --force is
|
||||
passed. This prevents accidental credential rotation.
|
||||
|
||||
Examples:
|
||||
kforge secrets apply --env staging
|
||||
kforge secrets apply --env production --force`,
|
||||
RunE: runSecretsApply,
|
||||
}
|
||||
|
||||
func init() {
|
||||
secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil,
|
||||
"Environment(s) to apply secrets for (required)")
|
||||
secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false,
|
||||
"Overwrite existing secrets (triggers credential rotation)")
|
||||
_ = secretsApplyCmd.MarkFlagRequired("env")
|
||||
secretsCmd.AddCommand(secretsApplyCmd)
|
||||
}
|
||||
|
||||
func runSecretsApply(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, envKey := range secretsApplyEnvs {
|
||||
fmt.Printf("\nApplying secrets for environment: %s\n", envKey)
|
||||
if err := applySecretsForEnv(cfg, envKey); err != nil {
|
||||
return fmt.Errorf("env %q: %w", envKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applySecretsForEnv(cfg *config.KforgeConfig, envKey string) error {
|
||||
env, err := config.ResolveEnvironment(cfg, envKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Basic auth htpasswd secret
|
||||
if env.Ingress.Auth.Enabled {
|
||||
if err := applyBasicAuthSecret(&env); err != nil {
|
||||
return fmt.Errorf("basic auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
infra := env.Infrastructure
|
||||
|
||||
if infra.Cache != nil {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-cache-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{"password": generatePassword(32)},
|
||||
); err != nil {
|
||||
return fmt.Errorf("cache credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if infra.Storage != nil {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-storage-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{
|
||||
"access_key": generateAlphanumeric(20),
|
||||
"secret_key": generatePassword(40),
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("storage credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-queue-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{
|
||||
"username": "kforge",
|
||||
"password": generatePassword(32),
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("queue credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if infra.Search != nil {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-search-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{"master_key": generatePassword(48)},
|
||||
); err != nil {
|
||||
return fmt.Errorf("search credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBasicAuthSecret generates an htpasswd entry for each user
|
||||
// and stores it in the basic-auth Secret.
|
||||
func applyBasicAuthSecret(env *config.ResolvedEnvironment) error {
|
||||
auth := env.Ingress.Auth
|
||||
if len(auth.Users) == 0 {
|
||||
return fmt.Errorf("auth.users must contain at least one username")
|
||||
}
|
||||
|
||||
var htpasswdLines []string
|
||||
fmt.Printf(" Generating basic auth credentials:\n")
|
||||
|
||||
for _, username := range auth.Users {
|
||||
password := generatePassword(32)
|
||||
// Generate bcrypt hash using htpasswd (available on most systems)
|
||||
// or fall back to a simple SHA1 if htpasswd isn't available.
|
||||
hash, err := generateHTPasswdEntry(username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing password for %s: %w", username, err)
|
||||
}
|
||||
htpasswdLines = append(htpasswdLines, hash)
|
||||
fmt.Printf(" user: %-20s password: %s\n", username, password)
|
||||
fmt.Printf(" ⚠ Save this password now — it will not be shown again.\n")
|
||||
}
|
||||
|
||||
htpasswd := strings.Join(htpasswdLines, "\n") + "\n"
|
||||
|
||||
return applyGenericSecret(
|
||||
auth.SecretName,
|
||||
env.Namespace,
|
||||
map[string]string{"auth": htpasswd},
|
||||
)
|
||||
}
|
||||
|
||||
// generateHTPasswdEntry produces a username:bcrypt_hash string.
|
||||
// Uses htpasswd binary if available, otherwise uses openssl.
|
||||
func generateHTPasswdEntry(username, password string) (string, error) {
|
||||
// Try htpasswd first (apache2-utils package).
|
||||
if path, err := exec.LookPath("htpasswd"); err == nil {
|
||||
out, err := exec.Command(path, "-nbB", username, password).Output()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to openssl passwd -apr1 (MD5 crypt, still widely supported).
|
||||
if path, err := exec.LookPath("openssl"); err == nil {
|
||||
out, err := exec.Command(path, "passwd", "-apr1", password).Output()
|
||||
if err == nil {
|
||||
return username + ":" + strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("neither htpasswd nor openssl found; install apache2-utils")
|
||||
}
|
||||
|
||||
// applyGenericSecret creates or updates a Kubernetes Secret using
|
||||
// kubectl. Skips creation if the secret already exists and --force
|
||||
// was not passed.
|
||||
func applyGenericSecret(name, namespace string, data map[string]string) error {
|
||||
// Check if secret already exists.
|
||||
checkCmd := kubectlCmd("get", "secret", name, "-n", namespace, "--ignore-not-found")
|
||||
out, err := checkCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking secret %s: %w", name, err)
|
||||
}
|
||||
|
||||
exists := strings.TrimSpace(string(out)) != ""
|
||||
if exists && !secretsApplyForce {
|
||||
fmt.Printf(" ✓ secret %s already exists (use --force to rotate)\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build kubectl create secret generic args.
|
||||
args := []string{
|
||||
"create", "secret", "generic", name,
|
||||
"-n", namespace,
|
||||
"--save-config",
|
||||
"--dry-run=client",
|
||||
"-o", "yaml",
|
||||
}
|
||||
for k, v := range data {
|
||||
args = append(args, fmt.Sprintf("--from-literal=%s=%s", k, v))
|
||||
}
|
||||
|
||||
// Pipe through kubectl apply to handle create-or-update.
|
||||
createCmd := kubectlCmd(args...)
|
||||
yamlBytes, err := createCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating secret manifest for %s: %w", name, err)
|
||||
}
|
||||
|
||||
applyCmd := kubectlCmd("apply", "-f", "-", "-n", namespace)
|
||||
applyCmd.Stdin = strings.NewReader(string(yamlBytes))
|
||||
applyCmd.Stdout = os.Stdout
|
||||
applyCmd.Stderr = os.Stderr
|
||||
|
||||
if err := applyCmd.Run(); err != nil {
|
||||
return fmt.Errorf("applying secret %s: %w", name, err)
|
||||
}
|
||||
|
||||
action := "created"
|
||||
if exists {
|
||||
action = "rotated"
|
||||
}
|
||||
fmt.Printf(" ✓ secret %s %s\n", name, action)
|
||||
return nil
|
||||
}
|
||||
|
||||
// kubectlCmd builds a kubectl invocation using the KUBE_HOST,
|
||||
// KUBE_TOKEN, and KUBE_CERTIFICATE env vars for auth — the same
|
||||
// pattern used in your existing Gitea Actions workflow.
|
||||
func kubectlCmd(args ...string) *exec.Cmd {
|
||||
base := []string{}
|
||||
|
||||
if host := os.Getenv("KUBE_HOST"); host != "" {
|
||||
base = append(base, "--server="+host)
|
||||
}
|
||||
if token := os.Getenv("KUBE_TOKEN"); token != "" {
|
||||
base = append(base, "--token="+token)
|
||||
}
|
||||
if cert := os.Getenv("KUBE_CERTIFICATE"); cert != "" {
|
||||
// KUBE_CERTIFICATE is the base64-encoded CA cert.
|
||||
// Decode it to a temp file or pass inline.
|
||||
decoded, err := base64.StdEncoding.DecodeString(cert)
|
||||
if err == nil {
|
||||
// Write to a temp file for kubectl.
|
||||
f, err := os.CreateTemp("", "kforge-ca-*.crt")
|
||||
if err == nil {
|
||||
_, _ = f.Write(decoded)
|
||||
f.Close()
|
||||
base = append(base, "--certificate-authority="+f.Name())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No cert provided — use insecure skip (matches your current workflow).
|
||||
base = append(base, "--insecure-skip-tls-verify=true")
|
||||
}
|
||||
|
||||
cmd := exec.Command("kubectl", append(base, args...)...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Password generation
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// generatePassword produces a cryptographically random password
|
||||
// of length n using the full printable ASCII character set.
|
||||
// Mirrors: tr -dc 'A-Za-z0-9!"#$%&...' </dev/urandom | head -c 32
|
||||
func generatePassword(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(passwordChars))))
|
||||
if err != nil {
|
||||
panic("crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
b[i] = passwordChars[idx.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// generateAlphanumeric produces a random alphanumeric string
|
||||
// suitable for access keys and usernames.
|
||||
func generateAlphanumeric(n int) string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||
if err != nil {
|
||||
panic("crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
b[i] = chars[idx.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"kforge/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// validate
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var validateCmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate kforge.yml and check required secrets",
|
||||
Long: `Parses kforge.yml and checks for:
|
||||
- Structural errors (missing required fields, invalid types)
|
||||
- Required Gitea/CI secrets (checks current process environment)
|
||||
- Interpolation tokens that can't be resolved
|
||||
|
||||
Exits 0 if valid, non-zero if any issue is found.`,
|
||||
RunE: runValidate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(validateCmd)
|
||||
}
|
||||
|
||||
func runValidate(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ kforge.yml is valid\n")
|
||||
fmt.Printf(" app: %s/%s\n", cfg.Meta.Tenant, cfg.Meta.Name)
|
||||
fmt.Printf(" envs: %s\n", strings.Join(config.EnvironmentKeys(cfg), ", "))
|
||||
|
||||
// Check required secrets.
|
||||
missing := checkRequiredSecrets(cfg)
|
||||
if len(missing) > 0 {
|
||||
fmt.Println("\n⚠ Missing required secrets (not found in environment):")
|
||||
for _, s := range missing {
|
||||
fmt.Printf(" ✗ %s (%s)\n", s.Name, s.Location)
|
||||
}
|
||||
return fmt.Errorf("%d required secret(s) not set", len(missing))
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ All required secrets are present")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// secrets list
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var secretsCmd = &cobra.Command{
|
||||
Use: "secrets",
|
||||
Short: "Manage and document kforge secrets",
|
||||
}
|
||||
|
||||
var secretsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all secrets required for this repository",
|
||||
Long: `Prints a categorised table of every secret kforge needs,
|
||||
where each one should live, and whether it is currently set
|
||||
in the process environment.
|
||||
|
||||
Use this as an onboarding checklist when setting up a new repo.`,
|
||||
RunE: runSecretsList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
secretsCmd.AddCommand(secretsListCmd)
|
||||
rootCmd.AddCommand(secretsCmd)
|
||||
}
|
||||
|
||||
func runSecretsList(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
required := buildRequiredSecrets(cfg)
|
||||
|
||||
// Group by location.
|
||||
groups := map[string][]requiredSecret{}
|
||||
for _, s := range required {
|
||||
groups[s.Location] = append(groups[s.Location], s)
|
||||
}
|
||||
|
||||
locations := []string{
|
||||
"Gitea org secret",
|
||||
"Gitea repo secret",
|
||||
"Cluster secret (auto-generated)",
|
||||
}
|
||||
|
||||
for _, loc := range locations {
|
||||
secrets := groups[loc]
|
||||
if len(secrets) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\n── %s ──\n", loc)
|
||||
for _, s := range secrets {
|
||||
status := "✓ set"
|
||||
if loc != "Cluster secret (auto-generated)" {
|
||||
if _, ok := os.LookupEnv(s.Name); !ok {
|
||||
status = "✗ missing"
|
||||
}
|
||||
} else {
|
||||
status = "— managed by kforge"
|
||||
}
|
||||
fmt.Printf(" %-40s %s\n", s.Name, status)
|
||||
if s.Description != "" {
|
||||
fmt.Printf(" %s\n", s.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Secret definitions
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type requiredSecret struct {
|
||||
Name string
|
||||
Location string // "Gitea org secret" | "Gitea repo secret" | "Cluster secret (auto-generated)"
|
||||
Description string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// buildRequiredSecrets returns the full list of secrets this
|
||||
// kforge.yml requires, based on what's enabled.
|
||||
func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
|
||||
secrets := []requiredSecret{
|
||||
// Always required — org level
|
||||
{Name: "DOCKER_USERNAME", Location: "Gitea org secret", Required: true},
|
||||
{Name: "DOCKER_PASSWORD", Location: "Gitea org secret", Required: true},
|
||||
{Name: "SOPS_AGE_KEY", Location: "Gitea org secret", Description: "Decrypts .kforge/secrets.enc.yml", Required: true},
|
||||
{Name: "KFORGE_NODE_IP", Location: "Gitea org secret", Description: "MicroK8s node IP for DNS A records"},
|
||||
|
||||
// Always required — repo level
|
||||
{Name: "KUBE_HOST", Location: "Gitea repo secret", Required: true},
|
||||
{Name: "KUBE_TOKEN", Location: "Gitea repo secret", Required: true},
|
||||
{Name: "KUBE_CERTIFICATE", Location: "Gitea repo secret"},
|
||||
}
|
||||
|
||||
// DNS secrets
|
||||
if cfg.DNS.Provider != "" && !cfg.DNS.SkipDNS {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: "CLOUDFLARE_API_TOKEN",
|
||||
Location: "Gitea org secret",
|
||||
Description: "Zone:Read + DNS:Edit permissions",
|
||||
Required: true,
|
||||
})
|
||||
for _, zone := range cfg.DNS.Cloudflare.Zones {
|
||||
varName := "CF_ZONE_ID_" + strings.ToUpper(
|
||||
strings.NewReplacer(".", "_", "-", "_").Replace(zone.Name),
|
||||
)
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: varName,
|
||||
Location: "Gitea org secret",
|
||||
Description: "Zone ID for " + zone.Name,
|
||||
Required: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Per-environment infrastructure secrets
|
||||
envKeys := config.EnvironmentKeys(cfg)
|
||||
sort.Strings(envKeys)
|
||||
|
||||
for _, envKey := range envKeys {
|
||||
env, err := config.ResolveEnvironment(cfg, envKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fullName := env.FullName
|
||||
infra := env.Infrastructure
|
||||
|
||||
if infra.Database != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-db-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "CNPG-managed database credentials (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if env.Ingress.Auth.Enabled {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: env.Ingress.Auth.SecretName,
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Basic auth htpasswd (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Cache != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-cache-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Cache password (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Storage != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-storage-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Minio access/secret keys (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-queue-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "RabbitMQ credentials (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Search != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-search-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Meilisearch master key (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return secrets
|
||||
}
|
||||
|
||||
// checkRequiredSecrets returns secrets that are required but not
|
||||
// set in the current process environment.
|
||||
func checkRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
|
||||
var missing []requiredSecret
|
||||
for _, s := range buildRequiredSecrets(cfg) {
|
||||
if s.Location == "Cluster secret (auto-generated)" {
|
||||
continue // Cluster secrets aren't checked in CI env.
|
||||
}
|
||||
if !s.Required {
|
||||
continue
|
||||
}
|
||||
if _, ok := os.LookupEnv(s.Name); !ok {
|
||||
missing = append(missing, s)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
+119
@@ -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."
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// Package generator produces Kubernetes manifest strings from
|
||||
// ResolvedEnvironment values. Each generator returns a YAML
|
||||
// string ready to be written to a file or piped to kubectl.
|
||||
package generator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"kforge/internal/config"
|
||||
"kforge/pkg/interpolate"
|
||||
)
|
||||
|
||||
const Separator = "---\n"
|
||||
|
||||
// GenerateAll produces the complete set of manifests for one
|
||||
// environment as a single concatenated YAML string.
|
||||
func GenerateAll(env *config.ResolvedEnvironment, cfg *config.KforgeConfig) (string, error) {
|
||||
tokens := interpolate.FromEnvironment(
|
||||
cfg.Meta.Name,
|
||||
cfg.Meta.Tenant,
|
||||
env.EnvKey,
|
||||
env.EnvPrefix,
|
||||
env.FullName,
|
||||
env.Image,
|
||||
env.Namespace,
|
||||
)
|
||||
|
||||
var parts []string
|
||||
|
||||
svc, err := Service(env, tokens)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service: %w", err)
|
||||
}
|
||||
parts = append(parts, svc)
|
||||
|
||||
dep, err := Deployment(env, tokens)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deployment: %w", err)
|
||||
}
|
||||
parts = append(parts, dep)
|
||||
|
||||
ing, err := Ingress(env, tokens)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ingress: %w", err)
|
||||
}
|
||||
parts = append(parts, ing)
|
||||
|
||||
for _, host := range env.Ingress.Hosts {
|
||||
if !host.TLS {
|
||||
continue
|
||||
}
|
||||
cert, err := Certificate(env, host, tokens)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("certificate for %s: %w", host.Hostname, err)
|
||||
}
|
||||
parts = append(parts, cert)
|
||||
}
|
||||
|
||||
if env.Ingress.Auth.Enabled {
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"# Basic auth secret %q — run: kforge secrets apply --env %s\n",
|
||||
env.Ingress.Auth.SecretName, env.EnvKey,
|
||||
))
|
||||
}
|
||||
|
||||
for _, job := range env.CronJobs {
|
||||
cj, err := CronJob(env, &job, tokens)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cronjob %s: %w", job.Name, err)
|
||||
}
|
||||
parts = append(parts, cj)
|
||||
}
|
||||
|
||||
return strings.Join(parts, Separator), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Service
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var serviceTmpl = template.Must(template.New("service").Parse(`apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .FullName }}
|
||||
namespace: {{ .Namespace }}
|
||||
labels:
|
||||
app: {{ .FullName }}
|
||||
managed-by: kforge
|
||||
spec:
|
||||
type: {{ .ServiceType }}
|
||||
ports:
|
||||
- port: {{ .Port }}
|
||||
protocol: TCP
|
||||
targetPort: {{ .Port }}
|
||||
selector:
|
||||
app: {{ .FullName }}
|
||||
`))
|
||||
|
||||
func Service(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, error) {
|
||||
return render(serviceTmpl, env)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Deployment
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func Deployment(env *config.ResolvedEnvironment, tokens interpolate.Tokens) (string, error) {
|
||||
// Resolve ${tenant}/${name} tokens in the image string.
|
||||
image := interpolate.Apply(env.Image, tokens)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("apiVersion: apps/v1\n")
|
||||
b.WriteString("kind: Deployment\n")
|
||||
b.WriteString("metadata:\n")
|
||||
fmt.Fprintf(&b, " name: %s\n", env.FullName)
|
||||
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
|
||||
b.WriteString(" labels:\n")
|
||||
fmt.Fprintf(&b, " app: %s\n", env.FullName)
|
||||
b.WriteString(" managed-by: kforge\n")
|
||||
b.WriteString("spec:\n")
|
||||
fmt.Fprintf(&b, " replicas: %d\n", env.Replicas)
|
||||
b.WriteString(" selector:\n")
|
||||
b.WriteString(" matchLabels:\n")
|
||||
fmt.Fprintf(&b, " app: %s\n", env.FullName)
|
||||
b.WriteString(" template:\n")
|
||||
b.WriteString(" metadata:\n")
|
||||
b.WriteString(" labels:\n")
|
||||
fmt.Fprintf(&b, " app: %s\n", env.FullName)
|
||||
b.WriteString(" spec:\n")
|
||||
b.WriteString(" containers:\n")
|
||||
fmt.Fprintf(&b, " - name: %s\n", env.FullName)
|
||||
fmt.Fprintf(&b, " image: %s\n", image)
|
||||
fmt.Fprintf(&b, " imagePullPolicy: %s\n", env.ImagePullPolicy)
|
||||
b.WriteString(" ports:\n")
|
||||
fmt.Fprintf(&b, " - containerPort: %d\n", env.Port)
|
||||
|
||||
if ev := renderEnvVarLines(env.EnvVars, " "); ev != "" {
|
||||
b.WriteString(" env:\n")
|
||||
b.WriteString(ev)
|
||||
}
|
||||
|
||||
if lp := renderProbeLines(env.HealthCheck, "liveness", " "); lp != "" {
|
||||
b.WriteString(lp)
|
||||
}
|
||||
if rp := renderProbeLines(env.HealthCheck, "readiness", " "); rp != "" {
|
||||
b.WriteString(rp)
|
||||
}
|
||||
|
||||
b.WriteString(renderResourceLines(env.Resources, " "))
|
||||
|
||||
b.WriteString(" imagePullSecrets:\n")
|
||||
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Ingress
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func Ingress(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, error) {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("apiVersion: networking.k8s.io/v1\n")
|
||||
b.WriteString("kind: Ingress\n")
|
||||
b.WriteString("metadata:\n")
|
||||
fmt.Fprintf(&b, " name: %s-ingress\n", env.FullName)
|
||||
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
|
||||
b.WriteString(" labels:\n")
|
||||
fmt.Fprintf(&b, " app: %s\n", env.FullName)
|
||||
b.WriteString(" managed-by: kforge\n")
|
||||
b.WriteString(" annotations:\n")
|
||||
b.WriteString(" nginx.ingress.kubernetes.io/ssl-redirect: \"true\"\n")
|
||||
|
||||
if env.Ingress.Auth.Enabled {
|
||||
b.WriteString(" nginx.ingress.kubernetes.io/auth-type: basic\n")
|
||||
fmt.Fprintf(&b, " nginx.ingress.kubernetes.io/auth-secret: %s\n", env.Ingress.Auth.SecretName)
|
||||
b.WriteString(" nginx.ingress.kubernetes.io/auth-realm: \"Authentication Required\"\n")
|
||||
}
|
||||
|
||||
b.WriteString("spec:\n")
|
||||
fmt.Fprintf(&b, " ingressClassName: %s\n", env.IngressClass)
|
||||
|
||||
// TLS block
|
||||
hasTLS := false
|
||||
for _, h := range env.Ingress.Hosts {
|
||||
if h.TLS {
|
||||
hasTLS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasTLS {
|
||||
b.WriteString(" tls:\n")
|
||||
for _, h := range env.Ingress.Hosts {
|
||||
if !h.TLS {
|
||||
continue
|
||||
}
|
||||
secretName := fmt.Sprintf("%s-tls-%s", env.FullName, interpolate.HostSlug(h.Hostname))
|
||||
b.WriteString(" - hosts:\n")
|
||||
fmt.Fprintf(&b, " - %s\n", h.Hostname)
|
||||
fmt.Fprintf(&b, " secretName: %s\n", secretName)
|
||||
}
|
||||
}
|
||||
|
||||
// Rules block
|
||||
b.WriteString(" rules:\n")
|
||||
for _, h := range env.Ingress.Hosts {
|
||||
fmt.Fprintf(&b, " - host: %s\n", h.Hostname)
|
||||
b.WriteString(" http:\n")
|
||||
b.WriteString(" paths:\n")
|
||||
b.WriteString(" - path: /\n")
|
||||
b.WriteString(" pathType: Prefix\n")
|
||||
b.WriteString(" backend:\n")
|
||||
b.WriteString(" service:\n")
|
||||
fmt.Fprintf(&b, " name: %s\n", env.FullName)
|
||||
b.WriteString(" port:\n")
|
||||
fmt.Fprintf(&b, " number: %d\n", env.Port)
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Certificate (cert-manager)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func Certificate(env *config.ResolvedEnvironment, host config.IngressHost, _ interpolate.Tokens) (string, error) {
|
||||
secretName := fmt.Sprintf("%s-tls-%s", env.FullName, interpolate.HostSlug(host.Hostname))
|
||||
t := template.Must(template.New("cert").Parse(`apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ .SecretName }}
|
||||
namespace: {{ .Namespace }}
|
||||
labels:
|
||||
app: {{ .FullName }}
|
||||
managed-by: kforge
|
||||
spec:
|
||||
secretName: {{ .SecretName }}
|
||||
issuerRef:
|
||||
name: {{ .TLSIssuer }}
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- {{ .Hostname }}
|
||||
`))
|
||||
data := struct {
|
||||
SecretName string
|
||||
Namespace string
|
||||
FullName string
|
||||
TLSIssuer string
|
||||
Hostname string
|
||||
}{
|
||||
SecretName: secretName,
|
||||
Namespace: env.Namespace,
|
||||
FullName: env.FullName,
|
||||
TLSIssuer: env.TLSIssuer,
|
||||
Hostname: host.Hostname,
|
||||
}
|
||||
return render(t, data)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// CronJob
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func CronJob(env *config.ResolvedEnvironment, job *config.ResolvedCronJob, tokens interpolate.Tokens) (string, error) {
|
||||
cronName := fmt.Sprintf("%s-cron-%s", env.FullName, interpolate.Slug(job.Name))
|
||||
image := interpolate.Apply(job.Image, tokens)
|
||||
|
||||
successHist := 3
|
||||
if job.SuccessfulJobsHistoryLimit != nil {
|
||||
successHist = *job.SuccessfulJobsHistoryLimit
|
||||
}
|
||||
failHist := 1
|
||||
if job.FailedJobsHistoryLimit != nil {
|
||||
failHist = *job.FailedJobsHistoryLimit
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("apiVersion: batch/v1\n")
|
||||
b.WriteString("kind: CronJob\n")
|
||||
b.WriteString("metadata:\n")
|
||||
fmt.Fprintf(&b, " name: %s\n", cronName)
|
||||
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
|
||||
b.WriteString(" labels:\n")
|
||||
fmt.Fprintf(&b, " app: %s\n", env.FullName)
|
||||
b.WriteString(" managed-by: kforge\n")
|
||||
b.WriteString("spec:\n")
|
||||
fmt.Fprintf(&b, " schedule: %q\n", job.Schedule)
|
||||
fmt.Fprintf(&b, " concurrencyPolicy: %s\n", job.ConcurrencyPolicy)
|
||||
fmt.Fprintf(&b, " successfulJobsHistoryLimit: %d\n", successHist)
|
||||
fmt.Fprintf(&b, " failedJobsHistoryLimit: %d\n", failHist)
|
||||
b.WriteString(" jobTemplate:\n")
|
||||
b.WriteString(" spec:\n")
|
||||
b.WriteString(" template:\n")
|
||||
b.WriteString(" metadata:\n")
|
||||
b.WriteString(" labels:\n")
|
||||
fmt.Fprintf(&b, " app: %s\n", env.FullName)
|
||||
b.WriteString(" spec:\n")
|
||||
fmt.Fprintf(&b, " restartPolicy: %s\n", job.RestartPolicy)
|
||||
b.WriteString(" containers:\n")
|
||||
fmt.Fprintf(&b, " - name: %s\n", cronName)
|
||||
fmt.Fprintf(&b, " image: %s\n", image)
|
||||
b.WriteString(" imagePullPolicy: Always\n")
|
||||
fmt.Fprintf(&b, " command: %s\n", renderStrSlice(job.Command))
|
||||
|
||||
if ev := renderEnvVarLines(job.EnvVars, " "); ev != "" {
|
||||
b.WriteString(" env:\n")
|
||||
b.WriteString(ev)
|
||||
}
|
||||
|
||||
if job.Resources != nil {
|
||||
b.WriteString(renderResourceLines(*job.Resources, " "))
|
||||
}
|
||||
|
||||
b.WriteString(" imagePullSecrets:\n")
|
||||
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Shared rendering helpers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// renderEnvVarLines returns the env var list lines indented by pad.
|
||||
// The caller writes the "env:" header line itself.
|
||||
func renderEnvVarLines(vars []config.EnvVarConfig, pad string) string {
|
||||
if len(vars) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, v := range vars {
|
||||
switch v.Type {
|
||||
case config.EnvVarTypeSecretRef:
|
||||
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
|
||||
fmt.Fprintf(&b, "%s valueFrom:\n", pad)
|
||||
fmt.Fprintf(&b, "%s secretKeyRef:\n", pad)
|
||||
fmt.Fprintf(&b, "%s name: %s\n", pad, v.SecretName)
|
||||
fmt.Fprintf(&b, "%s key: %s\n", pad, v.SecretKey)
|
||||
case config.EnvVarTypeConfigMapRef:
|
||||
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
|
||||
fmt.Fprintf(&b, "%s valueFrom:\n", pad)
|
||||
fmt.Fprintf(&b, "%s configMapKeyRef:\n", pad)
|
||||
fmt.Fprintf(&b, "%s name: %s\n", pad, v.ConfigMapName)
|
||||
fmt.Fprintf(&b, "%s key: %s\n", pad, v.ConfigMapKey)
|
||||
default: // plain
|
||||
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
|
||||
fmt.Fprintf(&b, "%s value: %q\n", pad, v.Value)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderProbeLines returns a liveness or readiness probe block.
|
||||
func renderProbeLines(hc config.HealthCheckConfig, kind string, pad string) string {
|
||||
isLiveness := kind == "liveness"
|
||||
if isLiveness && (hc.Liveness == nil || !*hc.Liveness) {
|
||||
return ""
|
||||
}
|
||||
if !isLiveness && (hc.Readiness == nil || !*hc.Readiness) {
|
||||
return ""
|
||||
}
|
||||
port := 0
|
||||
if hc.Port != nil {
|
||||
port = *hc.Port
|
||||
}
|
||||
key := kind + "Probe"
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s%s:\n", pad, key)
|
||||
fmt.Fprintf(&b, "%s httpGet:\n", pad)
|
||||
fmt.Fprintf(&b, "%s path: %s\n", pad, hc.Path)
|
||||
fmt.Fprintf(&b, "%s port: %d\n", pad, port)
|
||||
fmt.Fprintf(&b, "%s initialDelaySeconds: %d\n", pad, hc.InitialDelaySeconds)
|
||||
fmt.Fprintf(&b, "%s periodSeconds: %d\n", pad, hc.PeriodSeconds)
|
||||
fmt.Fprintf(&b, "%s timeoutSeconds: %d\n", pad, hc.TimeoutSeconds)
|
||||
fmt.Fprintf(&b, "%s failureThreshold: %d\n", pad, hc.FailureThreshold)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderResourceLines returns a resources block indented by pad.
|
||||
func renderResourceLines(r config.ResourceConfig, pad string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%sresources:\n", pad)
|
||||
fmt.Fprintf(&b, "%s requests:\n", pad)
|
||||
fmt.Fprintf(&b, "%s cpu: %s\n", pad, r.Requests.CPU)
|
||||
fmt.Fprintf(&b, "%s memory: %s\n", pad, r.Requests.Memory)
|
||||
fmt.Fprintf(&b, "%s limits:\n", pad)
|
||||
fmt.Fprintf(&b, "%s cpu: %s\n", pad, r.Limits.CPU)
|
||||
fmt.Fprintf(&b, "%s memory: %s\n", pad, r.Limits.Memory)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderStrSlice formats a string slice as an inline YAML sequence.
|
||||
func renderStrSlice(ss []string) string {
|
||||
if len(ss) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
quoted := make([]string, len(ss))
|
||||
for i, s := range ss {
|
||||
quoted[i] = fmt.Sprintf("%q", s)
|
||||
}
|
||||
return "[" + strings.Join(quoted, ", ") + "]"
|
||||
}
|
||||
|
||||
// render executes a template with data and returns the result.
|
||||
func render(t *template.Template, data any) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
+41
@@ -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
|
||||
@@ -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, ".", "-"))
|
||||
}
|
||||
@@ -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`)
|
||||
Reference in New Issue
Block a user