1 Commits

Author SHA1 Message Date
Renovate Bot 64d2c3f7b5 Update module github.com/spf13/cobra to v1.10.2 2026-06-23 00:01:38 +00:00
23 changed files with 1096 additions and 1460 deletions
+24
View File
@@ -0,0 +1,24 @@
# .gitea/workflows/publish.yml
name: Publish Action Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v2
with:
registry: registry.natelubitz.com
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push action image
run: |
docker build -t registry.natelubitz.com/infra/kforge:latest .
docker push registry.natelubitz.com/infra/kforge:latest
-34
View File
@@ -1,34 +0,0 @@
# Builds and pushes the kforge Docker action image on every push to main.
# Other repos reference this image via:
# action_ref: registry.container-registry.svc.cluster.local:5000/infra/kforge:latest
# in their kforge.yml, which generates:
# uses: docker://registry.container-registry.svc.cluster.local:5000/infra/kforge:latest
name: Publish Action Image
on:
push:
branches:
- main
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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 kforge action image
env:
DOCKER_BUILDKIT: "0"
run: |
docker build -t registry.natelubitz.com/infra/kforge:latest .
docker push registry.natelubitz.com/infra/kforge:latest
-167
View File
@@ -1,167 +0,0 @@
# kforge
**kforge** generates production-ready Kubernetes manifests from a single `kforge.yml` in your repository root. Designed for self-hosted homelab deployments on MicroK8s with Gitea Actions CI/CD.
## Core concept
```
kforge.yml → kforge generate → kubectl apply → cluster
```
Only `kforge.yml` and generated workflow files are committed. Generated manifests are applied and discarded each CI run.
## Build and test
```sh
go build -o kforge .
go test ./...
```
## Commands
| Command | Purpose |
|---|---|
| `kforge validate` | Validate kforge.yml, list required secrets |
| `kforge generate [--env E]` | Generate Kubernetes manifests to `.kforge-out/` |
| `kforge generate --pr-number N` | Generate manifests for a PR preview environment |
| `kforge secrets apply --env E` | Generate and apply random credentials to cluster |
| `kforge secrets apply --pr-number N` | Apply credentials for a PR preview environment |
| `kforge gitea-actions` | Generate `.gitea/workflows/deploy.yml` |
| `kforge gitea-preview` | Generate `.gitea/workflows/preview.yml` for PR previews |
## Architecture
### Config system (`internal/config/`)
- `types.go` — all config structs mirroring `kforge.yml`
- `loader.go` — YAML parsing and structural validation
- `defaults.go``ApplyDefaults()`, `ResolveEnvironment()`, `SynthesizePreviewEnvironment()`
### Generator system (`internal/generator/`)
- `manifests.go` — Service, Deployment, Ingress (with external-dns annotations), Certificate, CronJob
- `infrastructure.go` — CNPG Database CR + db-init Job, Valkey, Minio, NATS, Meilisearch
- `gitea_actions.go` — deploy workflow (`GenerateGiteaActions`) + preview workflow (`GeneratePreviewActions`)
### CLI (`cmd/`)
- Uses [Cobra](https://github.com/spf13/cobra) for subcommands
- `root.go` — shared `loadConfig()` helper
- Each command calls `loadConfig()` then delegates to generators
### Token interpolation (`pkg/interpolate/`)
- Built-in: `${name}`, `${tenant}`, `${env}`, `${env_prefix}`, `${full_name}`, `${namespace}`, `${image_tag}`
- Falls back to `os.Getenv()` — CI secrets (e.g. `${KFORGE_NODE_IP}`) are injected this way
- `PGIdentifier()` converts a kforge name to a valid unquoted PostgreSQL identifier
## DNS and TLS
DNS is managed by **external-dns** running in the cluster — no Cloudflare API calls from kforge.
When a host has `dns_record: true`, kforge adds to the Ingress:
```yaml
annotations:
external-dns.alpha.kubernetes.io/hostname: "app.example.com"
external-dns.alpha.kubernetes.io/target: "<dns.target>"
```
`dns.target` in `kforge.yml` sets the target value (your node IP or a static hostname). Supports `${KFORGE_NODE_IP}` token which is resolved from the CI environment.
TLS is managed by **cert-manager** via a `ClusterIssuer`. A `Certificate` CR is generated for each host with `tls: true`. No separate DNS step needed in the workflow.
## CNPG database (centralized cluster)
For a centralized CNPG cluster, kforge generates:
1. A CNPG `Database` CR — declaratively manages the database lifecycle
2. A `db-init` Kubernetes Job — creates the PostgreSQL role and syncs its password on every deploy
`kforge secrets apply` creates a `${full_name}-db-credentials` Secret containing an alphanumeric `username` and random `password` **before** the Job runs. The password is alphanumeric-only so it can be safely used in shell commands within the Job.
Required cluster resources:
- CNPG superuser Secret named by `cluster.cnpg.superuser_secret` (default: `cnpg-main-superuser`)
- CNPG cluster named by `cluster.cnpg.cluster_name` (default: `cnpg-main`)
## PR Preview environments
Add a `preview:` block to `kforge.yml` and run `kforge gitea-preview` to generate `.gitea/workflows/preview.yml`.
The preview workflow:
- **PR open / sync**: Creates namespace `preview-pr-{N}`, builds image tagged `:pr-{N}`, deploys app
- **PR close**: Deletes the namespace (removes all preview resources)
`kforge generate --pr-number N` and `kforge secrets apply --pr-number N` synthesize the preview environment at runtime using `preview.base_environment` settings with namespace/hostname overrides.
A `Namespace` manifest is included in the generated output so `kubectl apply` creates it automatically.
## Required secrets (Gitea)
### Org-level
- `DOCKER_USERNAME`, `DOCKER_PASSWORD` — registry auth
- `KFORGE_NODE_IP` — cluster node IP (used as external-dns target)
### Repo-level
- `KUBE_HOST`, `KUBE_TOKEN`, `KUBE_CERTIFICATE` — kubectl auth
## kforge.yml reference
```yaml
meta:
name: my-app
tenant: my-tenant
registry:
url: registry.example.com
pull_secret: regcred # default
dns:
target: ${KFORGE_NODE_IP} # external-dns annotation target
cluster:
tls_issuer: letsencrypt-prod # default
ingress_class: nginx # default
cnpg:
host: cnpg-main-rw.default.svc.cluster.local # default
cluster_name: cnpg-main # default
superuser_secret: cnpg-main-superuser # default
defaults:
port: 3000
health_check:
path: /healthcheck
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
infrastructure:
database:
provider: cnpg
cache:
provider: valkey
mode: standalone
preview:
enabled: true
base_environment: staging
namespace_prefix: preview-pr # namespace = preview-pr-{N}
hostname_template: "pr-${PR_NUMBER}.${name}.example.com"
environments:
staging:
namespace: staging
image_tag: latest
ingress:
hosts:
- hostname: app-staging.example.com
tls: true
dns_record: true
production:
namespace: production
image_tag: latest
ingress:
hosts:
- hostname: app.example.com
tls: true
dns_record: true
infrastructure:
cache:
mode: cluster
replicas: 3
```
+8 -11
View File
@@ -1,18 +1,15 @@
FROM golang:1.22-alpine AS builder FROM golang:1.22-alpine AS builder
WORKDIR /src WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . . COPY . .
RUN go build -o /usr/local/bin/kforge . RUN go build -o kforge .
FROM alpine:3.20 FROM alpine:3.19
RUN apk add --no-cache ca-certificates curl git COPY --from=builder /app/kforge /usr/local/bin/kforge
RUN apk add --no-cache curl docker-cli && \
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 && \
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
ARG KUBECTL_VERSION=v1.31.0
RUN curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
-o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl
COPY --from=builder /usr/local/bin/kforge /usr/local/bin/kforge
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
+52 -19
View File
@@ -1,29 +1,62 @@
name: 'kforge' name: "K8s YAML Generator"
description: 'Generate and apply Kubernetes manifests from kforge.yml' 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: inputs:
command: image_name:
description: 'deploy | preview-up | preview-down | validate | secrets' 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 required: false
default: 'deploy' dockerfile:
env: description: "Path to Dockerfile"
description: 'Environment to target (e.g. production, staging). Omit to target all.'
required: false required: false
config: default: "Dockerfile"
description: 'Path to kforge.yml relative to the workspace root' max_tags:
description: "Maximum number of SHA image tags to keep in the registry"
required: false required: false
default: 'kforge.yml' default: "5"
namespace:
description: 'Kubernetes namespace for rollout restart (defaults to env name)' registry:
description: "Docker registry URL"
required: false required: false
pr_number: default: "registry.natelubitz.com"
description: 'PR number — required for preview-up and preview-down' registry_username:
description: "Registry username"
required: true
registry_password:
description: "Registry password"
required: true
kube_host:
description: "Kubernetes API server URL"
required: false required: false
namespace_prefix: default: "192.168.1.20:16443"
description: 'Namespace prefix for preview environments' kube_certificate:
description: "Base64 encoded Kubernetes CA certificate"
required: true
kube_token:
description: "Kubernetes service account token"
required: true
scan_image:
description: "Scan image for vulnerabilities before pushing"
required: false required: false
default: 'preview-pr' default: "true"
scan_severity:
description: "Fail on these severity levels (UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL)"
required: false
default: "HIGH,CRITICAL"
# outputs:
# output_file:
# description: "Path to the generated Kubernetes YAML file"
runs: runs:
using: docker using: "docker"
image: Dockerfile image: "docker://registry.natelubitz.com/infra/kforge:latest"
# args:
# - ${{ inputs.input_file }}
# - ${{ inputs.output_file }}
# - ${{ inputs.auto_deploy }}
+89 -71
View File
@@ -4,11 +4,88 @@ import (
"fmt" "fmt"
"os" "os"
"kforge/internal/config"
dnsProvider "kforge/internal/dns"
"kforge/internal/generator" "kforge/internal/generator"
"github.com/spf13/cobra" "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 // kforge gitea-actions
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -21,17 +98,16 @@ var (
var giteaActionsCmd = &cobra.Command{ var giteaActionsCmd = &cobra.Command{
Use: "gitea-actions", Use: "gitea-actions",
Short: "Generate a Gitea Actions deploy workflow for this app", Short: "Generate a Gitea Actions workflow for this app",
Long: `Generates .gitea/workflows/deploy.yml that: Long: `Generates a complete .gitea/workflows/deploy.yml that:
- Builds and pushes the Docker image on push to main - Builds and pushes the Docker image on every push to main
- Runs kforge validate - Runs kforge validate
- Applies cluster secrets (idempotent) - Applies cluster secrets (idempotent)
- Ensures DNS records
- Generates manifests and applies them with kubectl - Generates manifests and applies them with kubectl
- Rolls out the deployment - Rolls out the deployment
DNS is handled automatically by external-dns reading the Ingress The generated workflow replaces your hand-written deploy.yml.
annotations that kforge writes — no separate DNS step needed.
Re-run whenever you add environments or change deploy options. Re-run whenever you add environments or change deploy options.
Examples: Examples:
@@ -66,87 +142,29 @@ func runGiteaActions(cmd *cobra.Command, args []string) error {
return fmt.Errorf("generating workflow: %w", err) return fmt.Errorf("generating workflow: %w", err)
} }
return writeWorkflow(giteaActionsOutput, workflow) if giteaActionsOutput == "-" {
} fmt.Print(workflow)
// ------------------------------------------------------------
// kforge gitea-preview
// ------------------------------------------------------------
var giteaPreviewOutput string
var giteaPreviewCmd = &cobra.Command{
Use: "gitea-preview",
Short: "Generate a Gitea Actions workflow for PR preview environments",
Long: `Generates .gitea/workflows/preview.yml that:
- On PR open/sync: builds a PR-tagged image, applies secrets,
generates manifests (including a Namespace), and deploys.
- On PR close: deletes the preview namespace and all resources.
Requires preview.enabled: true in kforge.yml.
Example kforge.yml preview block:
preview:
enabled: true
base_environment: staging
namespace_prefix: preview-pr
hostname_template: "pr-${PR_NUMBER}.${name}.example.com"
Examples:
kforge gitea-preview
kforge gitea-preview --output .gitea/workflows/preview.yml`,
RunE: runGiteaPreview,
}
func init() {
giteaPreviewCmd.Flags().StringVarP(&giteaPreviewOutput, "output", "o",
".gitea/workflows/preview.yml",
"Output path for the generated preview workflow file")
rootCmd.AddCommand(giteaPreviewCmd)
}
func runGiteaPreview(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
workflow, err := generator.GeneratePreviewActions(cfg)
if err != nil {
return fmt.Errorf("generating preview workflow: %w", err)
}
return writeWorkflow(giteaPreviewOutput, workflow)
}
// ------------------------------------------------------------
// Shared helpers
// ------------------------------------------------------------
func writeWorkflow(path, content string) error {
if path == "-" {
fmt.Print(content)
return nil return nil
} }
// Ensure parent directory exists. // Ensure parent directory exists.
dir := path dir := giteaActionsOutput
for i := len(dir) - 1; i >= 0; i-- { for i := len(dir) - 1; i >= 0; i-- {
if dir[i] == '/' || dir[i] == '\\' { if dir[i] == '/' {
dir = dir[:i] dir = dir[:i]
break break
} }
} }
if dir != path { if dir != giteaActionsOutput {
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err) return fmt.Errorf("creating output directory: %w", err)
} }
} }
if err := os.WriteFile(path, []byte(content), 0o644); err != nil { if err := os.WriteFile(giteaActionsOutput, []byte(workflow), 0o644); err != nil {
return fmt.Errorf("writing workflow: %w", err) return fmt.Errorf("writing workflow: %w", err)
} }
fmt.Printf("✓ Workflow written to %s\n", path) fmt.Printf("✓ Gitea Actions workflow written to %s\n", giteaActionsOutput)
return nil return nil
} }
+42 -115
View File
@@ -13,32 +13,27 @@ import (
) )
var ( var (
generateEnvs []string generateEnvs []string
generateOutput string generateOutput string
generateDry bool generateDry bool
generatePRNumber string
) )
var generateCmd = &cobra.Command{ var generateCmd = &cobra.Command{
Use: "generate", Use: "generate",
Short: "Generate Kubernetes manifests from kforge.yml", Short: "Generate Kubernetes manifests from kforge.yml",
Long: `Reads kforge.yml, resolves each requested environment, and writes Long: `Reads kforge.yml (or the file specified with --config), resolves
flat Kubernetes manifest files to the output directory. each requested environment, and writes flat Kubernetes manifest
files to the output directory.
If no --env flags are given, manifests are generated for all If no --env flags are given, manifests are generated for all
environments defined in kforge.yml. environments defined in kforge.yml.
Use --pr-number to generate manifests for a PR preview environment.
A Namespace manifest is prepended so kubectl apply creates the
namespace automatically.
Examples: Examples:
kforge generate kforge generate
kforge generate --env staging kforge generate --env staging
kforge generate --env staging --env production kforge generate --env staging --env production
kforge generate --env production --output .kube/ kforge generate --env production --output .kube/
kforge generate --dry-run kforge generate --dry-run`,
kforge generate --pr-number 42`,
RunE: runGenerate, RunE: runGenerate,
} }
@@ -49,8 +44,6 @@ func init() {
"Directory to write generated manifests into") "Directory to write generated manifests into")
generateCmd.Flags().BoolVar(&generateDry, "dry-run", false, generateCmd.Flags().BoolVar(&generateDry, "dry-run", false,
"Print manifests to stdout instead of writing files") "Print manifests to stdout instead of writing files")
generateCmd.Flags().StringVar(&generatePRNumber, "pr-number", "",
"PR number — synthesizes and generates a preview environment")
rootCmd.AddCommand(generateCmd) rootCmd.AddCommand(generateCmd)
} }
@@ -60,15 +53,6 @@ func runGenerate(cmd *cobra.Command, args []string) error {
return err return err
} }
// Preview mode: synthesize the preview environment.
if generatePRNumber != "" {
env, err := config.SynthesizePreviewEnvironment(cfg, generatePRNumber)
if err != nil {
return fmt.Errorf("synthesizing preview environment: %w", err)
}
return generateForPreview(cfg, &env, generatePRNumber)
}
envKeys := generateEnvs envKeys := generateEnvs
if len(envKeys) == 0 { if len(envKeys) == 0 {
envKeys = config.EnvironmentKeys(cfg) envKeys = config.EnvironmentKeys(cfg)
@@ -94,114 +78,57 @@ func generateForEnv(cfg *config.KforgeConfig, envKey string) error {
return err return err
} }
coreYAML, infraManifests, err := buildManifests(cfg, &env) // Core manifests (Service, Deployment, Ingress, Certs).
coreYAML, err := generator.GenerateAll(&env, cfg)
if err != nil { if err != nil {
return err return fmt.Errorf("generating core manifests: %w", err)
} }
if generateDry { // Infrastructure manifests.
printDryRun(envKey, "core", coreYAML) infraManifests, err := generator.GenerateInfrastructure(&env)
for _, m := range infraManifests {
printDryRun(envKey, m.Name, m.Content)
}
return nil
}
if err := os.MkdirAll(generateOutput, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
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)
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
}
// generateForPreview generates manifests for a synthesized preview
// environment, prepending a Namespace manifest so kubectl apply
// creates the namespace in a single pass.
func generateForPreview(cfg *config.KforgeConfig, env *config.ResolvedEnvironment, prNumber string) error {
coreYAML, infraManifests, err := buildManifests(cfg, env)
if err != nil { if err != nil {
return err return fmt.Errorf("generating infrastructure manifests: %w", err)
}
// Prepend a Namespace manifest so kubectl apply is self-contained.
nsYAML := generator.Namespace(env.Namespace, map[string]string{
"managed-by": "kforge",
"kforge/preview": "true",
"kforge/pr": prNumber,
})
coreYAML = nsYAML + generator.Separator + coreYAML
envKey := "preview-" + prNumber
if generateDry {
printDryRun(envKey, "core", coreYAML)
for _, m := range infraManifests {
printDryRun(envKey, m.Name, m.Content)
}
return nil
}
if err := os.MkdirAll(generateOutput, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
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)
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
}
// buildManifests generates core + infra manifests for a resolved environment,
// injecting infra env vars into the deployment.
func buildManifests(cfg *config.KforgeConfig, env *config.ResolvedEnvironment) (string, []generator.InfraManifest, error) {
coreYAML, err := generator.GenerateAll(env, cfg)
if err != nil {
return "", nil, fmt.Errorf("generating core manifests: %w", err)
}
infraManifests, err := generator.GenerateInfrastructure(env)
if err != nil {
return "", nil, 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 var infraEnvVars []config.EnvVarConfig
for _, m := range infraManifests { for _, m := range infraManifests {
infraEnvVars = append(infraEnvVars, m.EnvVars...) infraEnvVars = append(infraEnvVars, m.EnvVars...)
} }
if len(infraEnvVars) > 0 { if len(infraEnvVars) > 0 {
env.EnvVars = config.MergeEnvVars(env.EnvVars, infraEnvVars) env.EnvVars = config.MergeEnvVars(env.EnvVars, infraEnvVars)
coreYAML, err = generator.GenerateAll(env, cfg) coreYAML, err = generator.GenerateAll(&env, cfg)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("re-generating core manifests with infra vars: %w", err) return fmt.Errorf("re-generating core manifests with infra vars: %w", err)
} }
} }
return coreYAML, infraManifests, nil 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) { func printDryRun(envKey, name, content string) {
+43 -54
View File
@@ -10,20 +10,17 @@ import (
"strings" "strings"
"kforge/internal/config" "kforge/internal/config"
"kforge/pkg/interpolate"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// passwordChars is the character set for general-purpose passwords. // passwordChars mirrors the character set from your original
// Database passwords use alphanumeric-only (see generateAlphanumeric) // shell command: A-Za-z0-9 + printable special chars.
// so they are safe to embed in shell commands inside the db-init Job.
const passwordChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_{|}~` const passwordChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_{|}~`
var ( var (
secretsApplyEnvs []string secretsApplyEnvs []string
secretsApplyForce bool secretsApplyForce bool
secretsApplyPRNumber string
) )
var secretsApplyCmd = &cobra.Command{ var secretsApplyCmd = &cobra.Command{
@@ -33,25 +30,24 @@ var secretsApplyCmd = &cobra.Command{
services and creates/updates Kubernetes Secrets in the cluster. services and creates/updates Kubernetes Secrets in the cluster.
Secrets are created with kubectl — KUBE_HOST, KUBE_TOKEN, and Secrets are created with kubectl — KUBE_HOST, KUBE_TOKEN, and
KUBE_CERTIFICATE must be set in the environment. KUBE_CERTIFICATE must be set in the environment (Gitea injects
these automatically during CI runs).
Already-existing secrets are NOT overwritten unless --force is Already-existing secrets are NOT overwritten unless --force is
passed. This prevents accidental credential rotation. passed. This prevents accidental credential rotation.
Examples: Examples:
kforge secrets apply --env staging kforge secrets apply --env staging
kforge secrets apply --env production --force kforge secrets apply --env production --force`,
kforge secrets apply --pr-number 42`,
RunE: runSecretsApply, RunE: runSecretsApply,
} }
func init() { func init() {
secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil, secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil,
"Environment(s) to apply secrets for") "Environment(s) to apply secrets for (required)")
secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false, secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false,
"Overwrite existing secrets (triggers credential rotation)") "Overwrite existing secrets (triggers credential rotation)")
secretsApplyCmd.Flags().StringVar(&secretsApplyPRNumber, "pr-number", "", _ = secretsApplyCmd.MarkFlagRequired("env")
"PR number — synthesizes a preview environment instead of a named env")
secretsCmd.AddCommand(secretsApplyCmd) secretsCmd.AddCommand(secretsApplyCmd)
} }
@@ -61,58 +57,30 @@ func runSecretsApply(cmd *cobra.Command, args []string) error {
return err return err
} }
// Preview mode: synthesize the preview environment.
if secretsApplyPRNumber != "" {
fmt.Printf("\nApplying secrets for preview PR #%s\n", secretsApplyPRNumber)
env, err := config.SynthesizePreviewEnvironment(cfg, secretsApplyPRNumber)
if err != nil {
return fmt.Errorf("synthesizing preview environment: %w", err)
}
return applySecretsForEnv(cfg, &env)
}
if len(secretsApplyEnvs) == 0 {
return fmt.Errorf("specify --env or --pr-number")
}
for _, envKey := range secretsApplyEnvs { for _, envKey := range secretsApplyEnvs {
fmt.Printf("\nApplying secrets for environment: %s\n", envKey) fmt.Printf("\nApplying secrets for environment: %s\n", envKey)
env, err := config.ResolveEnvironment(cfg, envKey) if err := applySecretsForEnv(cfg, envKey); err != nil {
if err != nil {
return fmt.Errorf("env %q: %w", envKey, err)
}
if err := applySecretsForEnv(cfg, &env); err != nil {
return fmt.Errorf("env %q: %w", envKey, err) return fmt.Errorf("env %q: %w", envKey, err)
} }
} }
return nil return nil
} }
func applySecretsForEnv(cfg *config.KforgeConfig, env *config.ResolvedEnvironment) error { 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 env.Ingress.Auth.Enabled {
if err := applyBasicAuthSecret(env); err != nil { if err := applyBasicAuthSecret(&env); err != nil {
return fmt.Errorf("basic auth: %w", err) return fmt.Errorf("basic auth: %w", err)
} }
} }
infra := env.Infrastructure infra := env.Infrastructure
// Database: create credentials Secret before the db-init Job runs.
// Password is alphanumeric-only so it's safe in the Job's shell commands.
if infra.Database != nil {
pgUser := interpolate.PGIdentifier(env.FullName)
if err := applyGenericSecret(
env.FullName+"-db-credentials",
env.Namespace,
map[string]string{
"username": pgUser,
"password": generateAlphanumeric(32),
},
); err != nil {
return fmt.Errorf("db credentials: %w", err)
}
}
if infra.Cache != nil { if infra.Cache != nil {
if err := applyGenericSecret( if err := applyGenericSecret(
env.FullName+"-cache-credentials", env.FullName+"-cache-credentials",
@@ -175,6 +143,8 @@ func applyBasicAuthSecret(env *config.ResolvedEnvironment) error {
for _, username := range auth.Users { for _, username := range auth.Users {
password := generatePassword(32) 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) hash, err := generateHTPasswdEntry(username, password)
if err != nil { if err != nil {
return fmt.Errorf("hashing password for %s: %w", username, err) return fmt.Errorf("hashing password for %s: %w", username, err)
@@ -194,25 +164,32 @@ func applyBasicAuthSecret(env *config.ResolvedEnvironment) error {
} }
// generateHTPasswdEntry produces a username:bcrypt_hash string. // generateHTPasswdEntry produces a username:bcrypt_hash string.
// Uses htpasswd binary if available, otherwise uses openssl.
func generateHTPasswdEntry(username, password string) (string, error) { func generateHTPasswdEntry(username, password string) (string, error) {
// Try htpasswd first (apache2-utils package).
if path, err := exec.LookPath("htpasswd"); err == nil { if path, err := exec.LookPath("htpasswd"); err == nil {
out, err := exec.Command(path, "-nbB", username, password).Output() out, err := exec.Command(path, "-nbB", username, password).Output()
if err == nil { if err == nil {
return strings.TrimSpace(string(out)), 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 { if path, err := exec.LookPath("openssl"); err == nil {
out, err := exec.Command(path, "passwd", "-apr1", password).Output() out, err := exec.Command(path, "passwd", "-apr1", password).Output()
if err == nil { if err == nil {
return username + ":" + strings.TrimSpace(string(out)), nil return username + ":" + strings.TrimSpace(string(out)), nil
} }
} }
return "", fmt.Errorf("neither htpasswd nor openssl found; install apache2-utils") return "", fmt.Errorf("neither htpasswd nor openssl found; install apache2-utils")
} }
// applyGenericSecret creates or updates a Kubernetes Secret using kubectl. // applyGenericSecret creates or updates a Kubernetes Secret using
// Skips creation if the secret already exists and --force was not passed. // kubectl. Skips creation if the secret already exists and --force
// was not passed.
func applyGenericSecret(name, namespace string, data map[string]string) error { 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") checkCmd := kubectlCmd("get", "secret", name, "-n", namespace, "--ignore-not-found")
out, err := checkCmd.Output() out, err := checkCmd.Output()
if err != nil { if err != nil {
@@ -225,6 +202,7 @@ func applyGenericSecret(name, namespace string, data map[string]string) error {
return nil return nil
} }
// Build kubectl create secret generic args.
args := []string{ args := []string{
"create", "secret", "generic", name, "create", "secret", "generic", name,
"-n", namespace, "-n", namespace,
@@ -236,6 +214,7 @@ func applyGenericSecret(name, namespace string, data map[string]string) error {
args = append(args, fmt.Sprintf("--from-literal=%s=%s", k, v)) args = append(args, fmt.Sprintf("--from-literal=%s=%s", k, v))
} }
// Pipe through kubectl apply to handle create-or-update.
createCmd := kubectlCmd(args...) createCmd := kubectlCmd(args...)
yamlBytes, err := createCmd.Output() yamlBytes, err := createCmd.Output()
if err != nil { if err != nil {
@@ -259,8 +238,9 @@ func applyGenericSecret(name, namespace string, data map[string]string) error {
return nil return nil
} }
// kubectlCmd builds a kubectl invocation using KUBE_HOST, KUBE_TOKEN, // kubectlCmd builds a kubectl invocation using the KUBE_HOST,
// and KUBE_CERTIFICATE env vars for auth. // 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 { func kubectlCmd(args ...string) *exec.Cmd {
base := []string{} base := []string{}
@@ -271,8 +251,11 @@ func kubectlCmd(args ...string) *exec.Cmd {
base = append(base, "--token="+token) base = append(base, "--token="+token)
} }
if cert := os.Getenv("KUBE_CERTIFICATE"); cert != "" { 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) decoded, err := base64.StdEncoding.DecodeString(cert)
if err == nil { if err == nil {
// Write to a temp file for kubectl.
f, err := os.CreateTemp("", "kforge-ca-*.crt") f, err := os.CreateTemp("", "kforge-ca-*.crt")
if err == nil { if err == nil {
_, _ = f.Write(decoded) _, _ = f.Write(decoded)
@@ -281,6 +264,7 @@ func kubectlCmd(args ...string) *exec.Cmd {
} }
} }
} else { } else {
// No cert provided — use insecure skip (matches your current workflow).
base = append(base, "--insecure-skip-tls-verify=true") base = append(base, "--insecure-skip-tls-verify=true")
} }
@@ -292,6 +276,9 @@ func kubectlCmd(args ...string) *exec.Cmd {
// Password generation // 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 { func generatePassword(n int) string {
b := make([]byte, n) b := make([]byte, n)
for i := range b { for i := range b {
@@ -304,6 +291,8 @@ func generatePassword(n int) string {
return string(b) return string(b)
} }
// generateAlphanumeric produces a random alphanumeric string
// suitable for access keys and usernames.
func generateAlphanumeric(n int) string { func generateAlphanumeric(n int) string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n) b := make([]byte, n)
+26 -3
View File
@@ -141,9 +141,11 @@ type requiredSecret struct {
// kforge.yml requires, based on what's enabled. // kforge.yml requires, based on what's enabled.
func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret { func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
secrets := []requiredSecret{ secrets := []requiredSecret{
// kforge only needs these — DOCKER_USERNAME/PASSWORD are used by // Always required — org level
// docker/login-action and docker/build-push-action, not by kforge itself. {Name: "DOCKER_USERNAME", Location: "Gitea org secret", Required: true},
{Name: "KFORGE_NODE_IP", Location: "Gitea org secret", Description: "Node IP written as the external-dns annotation target"}, {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 // Always required — repo level
{Name: "KUBE_HOST", Location: "Gitea repo secret", Required: true}, {Name: "KUBE_HOST", Location: "Gitea repo secret", Required: true},
@@ -151,6 +153,27 @@ func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
{Name: "KUBE_CERTIFICATE", Location: "Gitea repo secret"}, {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 // Per-environment infrastructure secrets
envKeys := config.EnvironmentKeys(cfg) envKeys := config.EnvironmentKeys(cfg)
sort.Strings(envKeys) sort.Strings(envKeys)
+134 -78
View File
@@ -1,90 +1,146 @@
#!/bin/sh #!/bin/sh
set -e set -e
COMMAND="${INPUT_COMMAND:-deploy}" # INPUT_FILE="$1"
CONFIG="${INPUT_CONFIG:-kforge.yml}" # OUTPUT_FILE="$2"
# AUTO_DEPLOY="$3"
# Build a kubeconfig from the standard KUBE_* CI secrets. # ----------------------------------------------------------------
setup_kube() { # Registry login
[ -z "$KUBE_HOST" ] && return # ----------------------------------------------------------------
mkdir -p ~/.kube if [ -n "$INPUT_REGISTRY_USERNAME" ] && [ -n "$INPUT_REGISTRY_PASSWORD" ]; then
cat > ~/.kube/config <<KUBEEOF echo "Logging in to $INPUT_REGISTRY..."
apiVersion: v1 echo "$INPUT_REGISTRY_PASSWORD" | docker login "$INPUT_REGISTRY" \
kind: Config -u "$INPUT_REGISTRY_USERNAME" --password-stdin
clusters: fi
- cluster:
certificate-authority-data: ${KUBE_CERTIFICATE} # ----------------------------------------------------------------
server: ${KUBE_HOST} # Build and push image
name: kforge # ----------------------------------------------------------------
contexts: cleanup_old_tags() {
- context: IMAGE="$1"
cluster: kforge KEEP="$2"
user: kforge
name: kforge echo "Fetching tags for $IMAGE..."
current-context: kforge
users: TAGS=$(curl -s -u "$INPUT_REGISTRY_USERNAME:$INPUT_REGISTRY_PASSWORD" \
- name: kforge "https://$INPUT_REGISTRY/v2/$IMAGE/tags/list" \
user: | tr ',' '\n' \
token: ${KUBE_TOKEN} | grep -o '"[a-f0-9]\{7\}"' \
KUBEEOF | tr -d '"')
chmod 600 ~/.kube/config
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
} }
cd "${GITHUB_WORKSPACE:-/github/workspace}" if [ -n "$INPUT_IMAGE_NAME" ]; then
FULL_IMAGE="$INPUT_REGISTRY/$INPUT_IMAGE_NAME"
case "$COMMAND" in if [ -n "$INPUT_IMAGE_TAG" ]; then
deploy) echo "Building image $FULL_IMAGE:$INPUT_IMAGE_TAG..."
export KFORGE_IMAGE_TAG="$(git rev-parse --short HEAD)" docker build -t "$FULL_IMAGE:$INPUT_IMAGE_TAG" -f "$INPUT_DOCKERFILE" .
setup_kube
kforge validate -c "$CONFIG"
if [ -n "$INPUT_ENV" ]; then
kforge secrets apply --env "$INPUT_ENV" -c "$CONFIG"
kforge generate --env "$INPUT_ENV" --output .kforge-out -c "$CONFIG"
else
kforge generate --output .kforge-out -c "$CONFIG"
fi
kubectl apply -f .kforge-out/ --insecure-skip-tls-verify --validate=false
NAMESPACE="${INPUT_NAMESPACE:-${INPUT_ENV}}"
if [ -n "$NAMESPACE" ]; then
kubectl rollout restart deployment -n "$NAMESPACE" --insecure-skip-tls-verify || true
fi
;;
preview-up) echo "Scanning image for vulnerabilities..."
[ -z "$INPUT_PR_NUMBER" ] && echo "::error::pr_number input is required for preview-up" && exit 1 trivy image \
setup_kube --exit-code 1 \
kforge secrets apply --pr-number "$INPUT_PR_NUMBER" -c "$CONFIG" --severity "$INPUT_SCAN_SEVERITY" \
kforge generate --pr-number "$INPUT_PR_NUMBER" --output .kforge-out -c "$CONFIG" --no-progress \
kubectl apply -f .kforge-out/ --insecure-skip-tls-verify --validate=false "$FULL_IMAGE:$INPUT_IMAGE_TAG"
NS="${INPUT_NAMESPACE_PREFIX:-preview-pr}-${INPUT_PR_NUMBER}"
kubectl rollout status deployment -n "$NS" --timeout=120s --insecure-skip-tls-verify || true
;;
preview-down) echo "Scan passed, pushing image..."
[ -z "$INPUT_PR_NUMBER" ] && echo "::error::pr_number input is required for preview-down" && exit 1 docker push "$FULL_IMAGE:$INPUT_IMAGE_TAG"
setup_kube else
NS="${INPUT_NAMESPACE_PREFIX:-preview-pr}-${INPUT_PR_NUMBER}" SHA=$(echo "$GITHUB_SHA" | cut -c1-7)
kubectl delete namespace "$NS" --ignore-not-found --insecure-skip-tls-verify echo "Building image $FULL_IMAGE:latest and $FULL_IMAGE:$SHA..."
;; docker build \
-t "$FULL_IMAGE:latest" \
-t "$FULL_IMAGE:$SHA" \
-f "$INPUT_DOCKERFILE" .
validate) echo "Scanning image for vulnerabilities..."
kforge validate -c "$CONFIG" trivy image \
;; --exit-code 1 \
--severity "$INPUT_SCAN_SEVERITY" \
--no-progress \
"$FULL_IMAGE:latest"
secrets) echo "Scan passed, pushing image..."
setup_kube docker push "$FULL_IMAGE:latest"
if [ -n "$INPUT_PR_NUMBER" ]; then docker push "$FULL_IMAGE:$SHA"
kforge secrets apply --pr-number "$INPUT_PR_NUMBER" -c "$CONFIG"
elif [ -n "$INPUT_ENV" ]; then
kforge secrets apply --env "$INPUT_ENV" -c "$CONFIG"
else
echo "::error::env or pr_number input is required for the secrets command"
exit 1
fi
;;
*) cleanup_old_tags "$INPUT_IMAGE_NAME" "${INPUT_MAX_TAGS:-5}"
echo "::error::Unknown command '$COMMAND'. Valid: deploy, preview-up, preview-down, validate, secrets" fi
exit 1 fi
;;
esac # ----------------------------------------------------------------
# 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..."
# Try writing the cert and check if it worked
echo "$INPUT_KUBE_CERTIFICATE" | base64 -d > /tmp/kube-ca.crt 2>&1
echo "Cert file size: $(wc -c < /tmp/kube-ca.crt)"
echo "Cert file contents: $(cat /tmp/kube-ca.crt | head -1)"
kubectl config set-cluster default \
--server="$INPUT_KUBE_HOST" \
--certificate-authority=/tmp/kube-ca.crt
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 - --insecure-skip-tls-verify --validate=false
echo "Deploying to Kubernetes..."
kubectl apply --insecure-skip-tls-verify --validate=false -f ./.kforge-out/
echo "Deploy complete."
echo "Cleanup"
rm -f /tmp/kube-ca.crt
+10 -14
View File
@@ -6,19 +6,23 @@ registry:
url: registry.natelubitz.com url: registry.natelubitz.com
pull_secret: regcred pull_secret: regcred
# DNS records are managed by external-dns inside the cluster.
# Set target to your node's public IP or a static hostname.
# ${KFORGE_NODE_IP} is resolved from the CI secret at generate time.
dns: dns:
target: ${KFORGE_NODE_IP} 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: cluster:
tls_issuer: letsencrypt-prod tls_issuer: letsencrypt-prod
ingress_class: nginx ingress_class: nginx
cnpg: cnpg:
host: cnpg-main-rw.default.svc.cluster.local host: cnpg-main-rw.default.svc.cluster.local
cluster_name: cnpg-main
superuser_secret: cnpg-main-superuser
defaults: defaults:
port: 3000 port: 3000
@@ -32,14 +36,6 @@ infrastructure:
provider: valkey provider: valkey
mode: standalone mode: standalone
# Preview environments are deployed per pull request.
# Run `kforge gitea-preview` to generate .gitea/workflows/preview.yml
preview:
enabled: true
base_environment: staging
namespace_prefix: preview-pr
hostname_template: "pr-${PR_NUMBER}.${name}.natelubitz.com"
environments: environments:
staging: staging:
namespace: staging namespace: staging
+2 -2
View File
@@ -3,8 +3,8 @@ module kforge
go 1.22 go 1.22
require ( require (
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.9
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
+6
View File
@@ -1,11 +1,17 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/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 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+56 -137
View File
@@ -1,13 +1,5 @@
package config package config
import (
"fmt"
"os"
"strings"
"kforge/pkg/interpolate"
)
// ------------------------------------------------------------ // ------------------------------------------------------------
// Default values — single source of truth for every default // Default values — single source of truth for every default
// referenced in the schema. Change a default here and it // referenced in the schema. Change a default here and it
@@ -18,8 +10,6 @@ const (
DefaultTLSIssuer = "letsencrypt-prod" DefaultTLSIssuer = "letsencrypt-prod"
DefaultIngressClass = "nginx" DefaultIngressClass = "nginx"
DefaultCNPGHost = "cnpg-main-rw.default.svc.cluster.local" DefaultCNPGHost = "cnpg-main-rw.default.svc.cluster.local"
DefaultCNPGClusterName = "cnpg-main"
DefaultCNPGSuperuserSecret = "cnpg-main-superuser"
DefaultNamespacePattern = "${env}" DefaultNamespacePattern = "${env}"
DefaultImagePullPolicy = "Always" DefaultImagePullPolicy = "Always"
DefaultServiceType = "ClusterIP" DefaultServiceType = "ClusterIP"
@@ -29,15 +19,15 @@ const (
DefaultHealthCheckPath = "/healthcheck" DefaultHealthCheckPath = "/healthcheck"
DefaultRegistryURL = "registry.natelubitz.com" DefaultRegistryURL = "registry.natelubitz.com"
DefaultPort = 3000 DefaultPort = 3000
DefaultReplicas = 1 DefaultReplicas = 1
DefaultInitialDelaySecs = 15 DefaultInitialDelaySecs = 15
DefaultPeriodSecs = 10 DefaultPeriodSecs = 10
DefaultTimeoutSecs = 5 DefaultTimeoutSecs = 5
DefaultFailureThreshold = 3 DefaultFailureThreshold = 3
DefaultDeleteGraceSecs = 300 DefaultDeleteGraceSecs = 300
DefaultSuccessfulJobsHist = 3 DefaultSuccessfulJobsHist = 3
DefaultFailedJobsHist = 1 DefaultFailedJobsHist = 1
DefaultCacheProvider = "valkey" DefaultCacheProvider = "valkey"
DefaultCacheMode = "standalone" DefaultCacheMode = "standalone"
@@ -50,8 +40,6 @@ const (
DefaultRestartPolicy = "OnFailure" DefaultRestartPolicy = "OnFailure"
DefaultConcurrencyPolicy = "Forbid" DefaultConcurrencyPolicy = "Forbid"
DefaultPreviewNamespacePrefix = "preview-pr"
) )
// boolPtr / intPtr are helpers for pointer defaults. // boolPtr / intPtr are helpers for pointer defaults.
@@ -88,12 +76,6 @@ func applyClusterDefaults(c *ClusterConfig) {
if c.CNPG.Host == "" { if c.CNPG.Host == "" {
c.CNPG.Host = DefaultCNPGHost c.CNPG.Host = DefaultCNPGHost
} }
if c.CNPG.ClusterName == "" {
c.CNPG.ClusterName = DefaultCNPGClusterName
}
if c.CNPG.SuperuserSecret == "" {
c.CNPG.SuperuserSecret = DefaultCNPGSuperuserSecret
}
if c.NamespacePattern == "" { if c.NamespacePattern == "" {
c.NamespacePattern = DefaultNamespacePattern c.NamespacePattern = DefaultNamespacePattern
} }
@@ -103,8 +85,7 @@ func applyRegistryDefaults(r *RegistryConfig, m *MetaConfig) {
if r.URL == "" { if r.URL == "" {
r.URL = DefaultRegistryURL r.URL = DefaultRegistryURL
} }
// Insecure (in-cluster) registries need no imagePullSecret. if r.PullSecret == "" {
if r.PullSecret == "" && !r.Insecure {
r.PullSecret = DefaultPullSecret r.PullSecret = DefaultPullSecret
} }
if r.Repository == "" { if r.Repository == "" {
@@ -175,7 +156,9 @@ func applyResourceDefaults(r *ResourceConfig) {
} }
// applyRootInfraDefaults sets provider/mode defaults on the root // applyRootInfraDefaults sets provider/mode defaults on the root
// infrastructure block. // 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) { func applyRootInfraDefaults(infra *InfrastructureConfig) {
if infra.Cache != nil { if infra.Cache != nil {
if infra.Cache.Provider == "" { if infra.Cache.Provider == "" {
@@ -257,15 +240,9 @@ type ResolvedEnvironment struct {
CronJobs []ResolvedCronJob CronJobs []ResolvedCronJob
// Cluster-level settings (carried through for generators) // Cluster-level settings (carried through for generators)
TLSIssuer string TLSIssuer string
IngressClass string IngressClass string
CNPGHost string CNPGHost string
CNPGClusterName string
CNPGSuperuserSecret string
// DNS (for external-dns Ingress annotations)
DNSTarget string
SkipDNS bool
// Lifecycle // Lifecycle
Lifecycle LifecycleConfig Lifecycle LifecycleConfig
@@ -308,11 +285,6 @@ func ResolveEnvironment(cfg *KforgeConfig, envKey string) (ResolvedEnvironment,
if imageTag == "" { if imageTag == "" {
imageTag = DefaultImageTag imageTag = DefaultImageTag
} }
// Allow the action entrypoint (or CI) to override the image tag at generate
// time without modifying kforge.yml (e.g. KFORGE_IMAGE_TAG=abc1234).
if override := os.Getenv("KFORGE_IMAGE_TAG"); override != "" {
imageTag = override
}
image := registry.URL + "/" + registry.Repository + ":" + imageTag image := registry.URL + "/" + registry.Repository + ":" + imageTag
replicas := *cfg.Defaults.Replicas replicas := *cfg.Defaults.Replicas
@@ -346,101 +318,30 @@ func ResolveEnvironment(cfg *KforgeConfig, envKey string) (ResolvedEnvironment,
Auth: auth, Auth: auth,
} }
// Resolve DNS target token (${KFORGE_NODE_IP} etc.) from env.
tokens := interpolate.FromEnvironment(
cfg.Meta.Name, cfg.Meta.Tenant, envKey, prefix, fullName, imageTag, namespace,
)
dnsTarget := interpolate.Apply(cfg.DNS.Target, tokens)
return ResolvedEnvironment{ return ResolvedEnvironment{
EnvKey: envKey, EnvKey: envKey,
EnvPrefix: prefix, EnvPrefix: prefix,
Namespace: namespace, Namespace: namespace,
FullName: fullName, FullName: fullName,
Image: image, Image: image,
ImagePullPolicy: cfg.Defaults.ImagePullPolicy, ImagePullPolicy: cfg.Defaults.ImagePullPolicy,
ImagePullSecret: registry.PullSecret, ImagePullSecret: registry.PullSecret,
Replicas: replicas, Replicas: replicas,
Port: port, Port: port,
ServiceType: cfg.Defaults.ServiceType, ServiceType: cfg.Defaults.ServiceType,
HealthCheck: hc, HealthCheck: hc,
Resources: cfg.Defaults.Resources, Resources: cfg.Defaults.Resources,
EnvVars: envVars, EnvVars: envVars,
Ingress: ingress, Ingress: ingress,
Infrastructure: infra, Infrastructure: infra,
CronJobs: cronJobs, CronJobs: cronJobs,
TLSIssuer: cfg.Cluster.TLSIssuer, TLSIssuer: cfg.Cluster.TLSIssuer,
IngressClass: cfg.Cluster.IngressClass, IngressClass: cfg.Cluster.IngressClass,
CNPGHost: cfg.Cluster.CNPG.Host, CNPGHost: cfg.Cluster.CNPG.Host,
CNPGClusterName: cfg.Cluster.CNPG.ClusterName, Lifecycle: lifecycle,
CNPGSuperuserSecret: cfg.Cluster.CNPG.SuperuserSecret,
DNSTarget: dnsTarget,
SkipDNS: cfg.DNS.SkipDNS,
Lifecycle: lifecycle,
}, nil }, nil
} }
// ------------------------------------------------------------
// SynthesizePreviewEnvironment creates a ResolvedEnvironment
// for a PR preview from the preview config + a base environment.
// ------------------------------------------------------------
// SynthesizePreviewEnvironment derives a preview environment by
// cloning the base environment and overriding namespace, hostname,
// full name, and image tag for the given PR number.
func SynthesizePreviewEnvironment(cfg *KforgeConfig, prNumber string) (ResolvedEnvironment, error) {
if !cfg.Preview.Enabled {
return ResolvedEnvironment{}, fmt.Errorf("preview is not enabled in kforge.yml")
}
if cfg.Preview.BaseEnvironment == "" {
return ResolvedEnvironment{}, fmt.Errorf("preview.base_environment is required")
}
env, err := ResolveEnvironment(cfg, cfg.Preview.BaseEnvironment)
if err != nil {
return ResolvedEnvironment{}, fmt.Errorf("resolving base environment %q: %w", cfg.Preview.BaseEnvironment, err)
}
nsPrefix := cfg.Preview.NamespacePrefix
if nsPrefix == "" {
nsPrefix = DefaultPreviewNamespacePrefix
}
env.EnvKey = "preview-" + prNumber
env.Namespace = nsPrefix + "-" + prNumber
env.FullName = "pr" + prNumber + "-" + cfg.Meta.Tenant + "-" + cfg.Meta.Name
// Override image tag to a PR-specific tag.
if idx := strings.LastIndex(env.Image, ":"); idx >= 0 {
env.Image = env.Image[:idx+1] + "pr-" + prNumber
}
// Override ingress hostname using the template.
hostnameTemplate := cfg.Preview.HostnameTemplate
if hostnameTemplate == "" {
hostnameTemplate = "pr-${PR_NUMBER}.${name}.example.com"
}
previewTokens := interpolate.Tokens{
"PR_NUMBER": prNumber,
"name": cfg.Meta.Name,
"tenant": cfg.Meta.Tenant,
}
hostname := interpolate.Apply(hostnameTemplate, previewTokens)
env.Ingress = IngressConfig{
Hosts: []IngressHost{{
Hostname: hostname,
TLS: true,
DNSRecord: true,
}},
Auth: IngressAuth{
SecretName: env.FullName + "-basic-auth",
},
}
return env, nil
}
// ------------------------------------------------------------ // ------------------------------------------------------------
// Internal resolution helpers // Internal resolution helpers
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -449,6 +350,7 @@ func resolveEnvPrefix(envKey string, override *string) string {
if override != nil && *override != "" { if override != nil && *override != "" {
return *override return *override
} }
// Default: first 4 chars of envKey, or full key if shorter.
if len(envKey) <= 4 { if len(envKey) <= 4 {
return envKey return envKey
} }
@@ -459,10 +361,13 @@ func resolveNamespace(envKey, explicit, pattern string) string {
if explicit != "" { if explicit != "" {
return explicit return explicit
} }
if pattern == "" { // Apply the namespace pattern (simple token replace here;
// full interpolation runs later via the interpolate package).
result := pattern
if result == "" {
return envKey return envKey
} }
return pattern return result
} }
func resolveFullName(cfg *KforgeConfig, prefix string, env EnvironmentConfig) string { func resolveFullName(cfg *KforgeConfig, prefix string, env EnvironmentConfig) string {
@@ -474,6 +379,7 @@ func resolveFullName(cfg *KforgeConfig, prefix string, env EnvironmentConfig) st
func resolveRegistry(cfg *KforgeConfig, env EnvironmentConfig) RegistryConfig { func resolveRegistry(cfg *KforgeConfig, env EnvironmentConfig) RegistryConfig {
base := cfg.Registry base := cfg.Registry
// Resolve the repository token now that we have meta values.
if base.Repository == "${tenant}/${name}" || base.Repository == "" { if base.Repository == "${tenant}/${name}" || base.Repository == "" {
base.Repository = cfg.Meta.Tenant + "/" + cfg.Meta.Name base.Repository = cfg.Meta.Tenant + "/" + cfg.Meta.Name
} }
@@ -522,6 +428,15 @@ func mergeEnvVars(base, override []EnvVarConfig) []EnvVarConfig {
// mergeInfrastructure performs a shallow merge of root infra // mergeInfrastructure performs a shallow merge of root infra
// defaults with per-environment overrides. // 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 { func mergeInfrastructure(root, env *InfrastructureConfig) ResolvedInfrastructure {
return ResolvedInfrastructure{ return ResolvedInfrastructure{
Database: mergeDatabase(root.Database, env.Database), Database: mergeDatabase(root.Database, env.Database),
@@ -720,12 +635,14 @@ func resolveCronJobs(jobs []CronJobConfig, deploymentImage string, deploymentEnv
for _, job := range jobs { for _, job := range jobs {
r := ResolvedCronJob{CronJobConfig: job} r := ResolvedCronJob{CronJobConfig: job}
// Image
if job.ImageOverride != nil && *job.ImageOverride != "" { if job.ImageOverride != nil && *job.ImageOverride != "" {
r.Image = *job.ImageOverride r.Image = *job.ImageOverride
} else { } else {
r.Image = deploymentImage r.Image = deploymentImage
} }
// Env vars: deployment vars first, then job-specific (with merge)
inheritEnv := job.InheritEnv == nil || *job.InheritEnv inheritEnv := job.InheritEnv == nil || *job.InheritEnv
if inheritEnv { if inheritEnv {
r.EnvVars = mergeEnvVars(deploymentEnvVars, job.EnvVars) r.EnvVars = mergeEnvVars(deploymentEnvVars, job.EnvVars)
@@ -733,10 +650,12 @@ func resolveCronJobs(jobs []CronJobConfig, deploymentImage string, deploymentEnv
r.EnvVars = job.EnvVars r.EnvVars = job.EnvVars
} }
// Resources
if job.Resources == nil { if job.Resources == nil {
r.Resources = &defaultResources r.Resources = &defaultResources
} }
// Tuning defaults
if r.RestartPolicy == "" { if r.RestartPolicy == "" {
r.RestartPolicy = DefaultRestartPolicy r.RestartPolicy = DefaultRestartPolicy
} }
-4
View File
@@ -3,7 +3,6 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -40,9 +39,6 @@ func validate(cfg *KforgeConfig) error {
if cfg.Meta.Tenant == "" { if cfg.Meta.Tenant == "" {
return fmt.Errorf("meta.tenant is required") return fmt.Errorf("meta.tenant is required")
} }
if cfg.ActionRef != "" && (strings.Contains(cfg.ActionRef, "@") && !strings.Contains(cfg.ActionRef, "@sha256:")) {
return fmt.Errorf("action_ref %q looks like a git ref — use a Docker image tag instead (e.g. registry.example.com/infra/kforge:latest)", cfg.ActionRef)
}
if len(cfg.Environments) == 0 { if len(cfg.Environments) == 0 {
return fmt.Errorf("at least one environment must be defined") return fmt.Errorf("at least one environment must be defined")
} }
+56 -87
View File
@@ -6,21 +6,13 @@ package config
// KforgeConfig is the top-level struct unmarshalled from kforge.yml. // KforgeConfig is the top-level struct unmarshalled from kforge.yml.
type KforgeConfig struct { type KforgeConfig struct {
Meta MetaConfig `yaml:"meta"` Meta MetaConfig `yaml:"meta"`
Registry RegistryConfig `yaml:"registry"` Registry RegistryConfig `yaml:"registry"`
DNS DNSConfig `yaml:"dns"` DNS DNSConfig `yaml:"dns"`
Cluster ClusterConfig `yaml:"cluster"` Cluster ClusterConfig `yaml:"cluster"`
Defaults DefaultsConfig `yaml:"defaults"` Defaults DefaultsConfig `yaml:"defaults"`
Infrastructure InfrastructureConfig `yaml:"infrastructure"` Infrastructure InfrastructureConfig `yaml:"infrastructure"`
Preview PreviewConfig `yaml:"preview,omitempty"` Environments map[string]EnvironmentConfig `yaml:"environments"`
Environments map[string]EnvironmentConfig `yaml:"environments"`
// ActionRef is the Docker image reference for the pre-built kforge action
// (e.g. "registry.example.com/infra/kforge:latest"). When set, kforge
// gitea-actions and kforge gitea-preview generate `uses: docker://<ref>`
// steps instead of installing and running kforge inline. The kforge repo
// must publish this image on each release.
ActionRef string `yaml:"action_ref,omitempty"`
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -40,32 +32,30 @@ type MetaConfig struct {
type RegistryConfig struct { type RegistryConfig struct {
URL string `yaml:"url"` URL string `yaml:"url"`
Repository string `yaml:"repository,omitempty"` // default: ${tenant}/${name} Repository string `yaml:"repository,omitempty"` // default: ${tenant}/${name}
PullSecret string `yaml:"pull_secret,omitempty"` // default: regcred; set to "" to disable PullSecret string `yaml:"pull_secret,omitempty"` // default: regcred
// Insecure marks the registry as HTTP-only (no TLS). Skips docker login,
// omits imagePullSecrets from manifests, and configures buildkitd for
// plain-HTTP pushes. Typical for in-cluster registries accessed via
// ClusterIP/service DNS rather than an Ingress.
Insecure bool `yaml:"insecure,omitempty"`
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
// DNS // DNS
//
// DNS records and TLS are managed inside the cluster:
// - external-dns reads Ingress annotations and creates DNS records
// - cert-manager issues TLS certificates via a ClusterIssuer
//
// kforge emits the appropriate annotations on the Ingress resource
// for each host with dns_record: true.
// ------------------------------------------------------------ // ------------------------------------------------------------
type DNSConfig struct { type DNSConfig struct {
// Target is the value written to the external-dns target annotation. Provider string `yaml:"provider"` // "cloudflare"
// Typically the cluster node's public IP or a static hostname. Cloudflare CloudflareConfig `yaml:"cloudflare"`
// Supports token interpolation (e.g. ${KFORGE_NODE_IP}). NodeIP string `yaml:"node_ip,omitempty"` // default: ${KFORGE_NODE_IP}
Target string `yaml:"target,omitempty"` SkipDNS bool `yaml:"skip_dns,omitempty"`
SkipDNS bool `yaml:"skip_dns,omitempty"` // true = no external-dns annotations }
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"`
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -73,16 +63,15 @@ type DNSConfig struct {
// ------------------------------------------------------------ // ------------------------------------------------------------
type ClusterConfig struct { type ClusterConfig struct {
TLSIssuer string `yaml:"tls_issuer,omitempty"` // default: letsencrypt-prod TLSIssuer string `yaml:"tls_issuer,omitempty"` // default: letsencrypt-prod
IngressClass string `yaml:"ingress_class,omitempty"` // default: nginx IngressClass string `yaml:"ingress_class,omitempty"` // default: nginx
CNPG CNPGConfig `yaml:"cnpg"` CNPG CNPGConfig `yaml:"cnpg"`
NamespacePattern string `yaml:"namespace_pattern,omitempty"` // default: "${env}" NamespacePattern string `yaml:"namespace_pattern,omitempty"` // default: "${env}"
} }
type CNPGConfig struct { type CNPGConfig struct {
Host string `yaml:"host,omitempty"` // default: cnpg-main-rw.default.svc.cluster.local Host string `yaml:"host,omitempty"` // default: cnpg-main-rw.default.svc.cluster.local
ClusterName string `yaml:"cluster_name,omitempty"` // default: cnpg-main HostOverride *string `yaml:"host_override,omitempty"`
SuperuserSecret string `yaml:"superuser_secret,omitempty"` // default: cnpg-main-superuser
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -90,19 +79,19 @@ type CNPGConfig struct {
// ------------------------------------------------------------ // ------------------------------------------------------------
type DefaultsConfig struct { type DefaultsConfig struct {
ImagePullPolicy string `yaml:"image_pull_policy,omitempty"` // default: Always ImagePullPolicy string `yaml:"image_pull_policy,omitempty"` // default: Always
Replicas *int `yaml:"replicas,omitempty"` // default: 1 Replicas *int `yaml:"replicas,omitempty"` // default: 1
Port *int `yaml:"port,omitempty"` // default: 3000 Port *int `yaml:"port,omitempty"` // default: 3000
ServiceType string `yaml:"service_type,omitempty"` // default: ClusterIP ServiceType string `yaml:"service_type,omitempty"` // default: ClusterIP
Dockerfile string `yaml:"dockerfile,omitempty"` // default: Dockerfile Dockerfile string `yaml:"dockerfile,omitempty"` // default: Dockerfile
HealthCheck HealthCheckConfig `yaml:"health_check"` HealthCheck HealthCheckConfig `yaml:"health_check"`
Resources ResourceConfig `yaml:"resources"` Resources ResourceConfig `yaml:"resources"`
EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"` EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"`
} }
type HealthCheckConfig struct { type HealthCheckConfig struct {
Path string `yaml:"path,omitempty"` // default: /healthcheck Path string `yaml:"path,omitempty"` // default: /healthcheck
Port *int `yaml:"port,omitempty"` // default: defaults.port Port *int `yaml:"port,omitempty"` // default: defaults.port
InitialDelaySeconds int `yaml:"initial_delay_seconds,omitempty"` // default: 15 InitialDelaySeconds int `yaml:"initial_delay_seconds,omitempty"` // default: 15
PeriodSeconds int `yaml:"period_seconds,omitempty"` // default: 10 PeriodSeconds int `yaml:"period_seconds,omitempty"` // default: 10
TimeoutSeconds int `yaml:"timeout_seconds,omitempty"` // default: 5 TimeoutSeconds int `yaml:"timeout_seconds,omitempty"` // default: 5
@@ -166,6 +155,9 @@ type InfrastructureConfig struct {
// InfraBase holds the common enabled flag present on every // InfraBase holds the common enabled flag present on every
// infrastructure service. Embedded in each service config. // infrastructure service. Embedded in each service config.
type InfraBase struct { 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"` Enabled *bool `yaml:"enabled,omitempty"`
} }
@@ -205,31 +197,6 @@ type MonitoringInfraConfig struct {
MetricsPort *int `yaml:"metrics_port,omitempty"` // default: defaults.port MetricsPort *int `yaml:"metrics_port,omitempty"` // default: defaults.port
} }
// ------------------------------------------------------------
// Preview environments
// ------------------------------------------------------------
// PreviewConfig defines how PR preview environments are deployed.
// When enabled, `kforge gitea-preview` generates a workflow that
// deploys an isolated environment per pull request.
type PreviewConfig struct {
// Enabled must be true for kforge gitea-preview to generate a workflow.
Enabled bool `yaml:"enabled,omitempty"`
// BaseEnvironment is the environment whose infrastructure and env var
// settings are inherited (e.g. "staging"). Required when enabled.
BaseEnvironment string `yaml:"base_environment,omitempty"`
// NamespacePrefix is prepended to the PR number to form the namespace.
// Default: "preview-pr" → namespace "preview-pr-123".
NamespacePrefix string `yaml:"namespace_prefix,omitempty"`
// HostnameTemplate is the ingress hostname for the preview, using
// ${PR_NUMBER}, ${name}, and ${tenant} as substitution tokens.
// Example: "pr-${PR_NUMBER}.${name}.example.com"
HostnameTemplate string `yaml:"hostname_template,omitempty"`
}
// ------------------------------------------------------------ // ------------------------------------------------------------
// Environment // Environment
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -248,10 +215,10 @@ type EnvironmentConfig struct {
EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"` EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"`
Ingress IngressConfig `yaml:"ingress"` Ingress IngressConfig `yaml:"ingress"`
Infrastructure InfrastructureConfig `yaml:"infrastructure"` Infrastructure InfrastructureConfig `yaml:"infrastructure"`
CronJobs []CronJobConfig `yaml:"cron_jobs,omitempty"` CronJobs []CronJobConfig `yaml:"cron_jobs,omitempty"`
Lifecycle LifecycleConfig `yaml:"lifecycle"` Lifecycle LifecycleConfig `yaml:"lifecycle"`
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -266,7 +233,7 @@ type IngressConfig struct {
type IngressHost struct { type IngressHost struct {
Hostname string `yaml:"hostname"` Hostname string `yaml:"hostname"`
TLS bool `yaml:"tls"` TLS bool `yaml:"tls"`
DNSRecord bool `yaml:"dns_record,omitempty"` // emit external-dns annotations DNSRecord bool `yaml:"dns_record,omitempty"`
} }
type IngressAuth struct { type IngressAuth struct {
@@ -280,19 +247,19 @@ type IngressAuth struct {
// ------------------------------------------------------------ // ------------------------------------------------------------
type CronJobConfig struct { type CronJobConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Schedule string `yaml:"schedule"` Schedule string `yaml:"schedule"`
Command []string `yaml:"command"` Command []string `yaml:"command"`
ImageOverride *string `yaml:"image_override,omitempty"` ImageOverride *string `yaml:"image_override,omitempty"`
InheritEnv *bool `yaml:"inherit_env,omitempty"` // default: true InheritEnv *bool `yaml:"inherit_env,omitempty"` // default: true
EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"` EnvVars []EnvVarConfig `yaml:"env_vars,omitempty"`
Resources *ResourceConfig `yaml:"resources,omitempty"` // inherits defaults if nil Resources *ResourceConfig `yaml:"resources,omitempty"` // inherits defaults if nil
// Kubernetes CronJob tuning // Kubernetes CronJob tuning
RestartPolicy string `yaml:"restart_policy,omitempty"` // default: OnFailure RestartPolicy string `yaml:"restart_policy,omitempty"` // default: OnFailure
ConcurrencyPolicy string `yaml:"concurrency_policy,omitempty"` // default: Forbid ConcurrencyPolicy string `yaml:"concurrency_policy,omitempty"` // default: Forbid
SuccessfulJobsHistoryLimit *int `yaml:"successful_jobs_history,omitempty"` // default: 3 SuccessfulJobsHistoryLimit *int `yaml:"successful_jobs_history,omitempty"` // default: 3
FailedJobsHistoryLimit *int `yaml:"failed_jobs_history,omitempty"` // default: 1 FailedJobsHistoryLimit *int `yaml:"failed_jobs_history,omitempty"` // default: 1
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -300,6 +267,8 @@ type CronJobConfig struct {
// ------------------------------------------------------------ // ------------------------------------------------------------
type LifecycleConfig struct { type LifecycleConfig struct {
// If false (default), kforge renames resources when meta.name changes.
// If true, kforge deletes old resources after delete_grace_seconds.
Delete bool `yaml:"delete,omitempty"` Delete bool `yaml:"delete,omitempty"`
DeleteGraceSeconds int `yaml:"delete_grace_seconds,omitempty"` // default: 300 DeleteGraceSeconds int `yaml:"delete_grace_seconds,omitempty"` // default: 300
} }
+301
View File
@@ -0,0 +1,301 @@
// Package dns provides a provider-agnostic interface for DNS
// record management, with a Cloudflare implementation.
//
// Adding a new provider (Route53, Porkbun, etc.):
// 1. Implement the Provider interface below.
// 2. Add a case in NewProvider().
// 3. The rest of kforge uses Provider — no other changes needed.
package dns
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"kforge/internal/config"
)
// ------------------------------------------------------------
// Provider interface
// ------------------------------------------------------------
// Provider is the DNS provider contract. Implementations must
// be idempotent — calling EnsureARecord twice with the same
// inputs must not error or create duplicates.
type Provider interface {
// EnsureARecord creates an A record for hostname pointing to
// ip if one does not already exist. If a record exists with a
// different IP, it is updated. No-ops if already correct.
EnsureARecord(hostname, ip string) error
// DeleteARecord removes the A record for hostname if it exists.
// No-ops if it does not exist.
DeleteARecord(hostname string) error
}
// NewProvider returns the configured DNS provider.
func NewProvider(cfg config.DNSConfig) (Provider, error) {
switch cfg.Provider {
case "cloudflare":
return newCloudflareProvider(cfg)
case "":
return &noopProvider{}, nil
default:
return nil, fmt.Errorf("unknown dns provider %q (supported: cloudflare)", cfg.Provider)
}
}
// noopProvider satisfies the interface when DNS management is
// disabled (skip_dns: true or no provider configured).
type noopProvider struct{}
func (n *noopProvider) EnsureARecord(hostname, ip string) error { return nil }
func (n *noopProvider) DeleteARecord(hostname string) error { return nil }
// ------------------------------------------------------------
// Cloudflare implementation
// ------------------------------------------------------------
const cfAPIBase = "https://api.cloudflare.com/client/v4"
type cloudflareProvider struct {
apiToken string
zones []config.ZoneEntry // sorted longest-first for matching
proxied bool
client *http.Client
}
func newCloudflareProvider(cfg config.DNSConfig) (*cloudflareProvider, error) {
if cfg.Cloudflare.APIToken == "" {
return nil, fmt.Errorf("cloudflare.api_token is required")
}
if len(cfg.Cloudflare.Zones) == 0 {
return nil, fmt.Errorf("cloudflare.zones must have at least one entry")
}
return &cloudflareProvider{
apiToken: cfg.Cloudflare.APIToken,
zones: cfg.Cloudflare.Zones,
proxied: cfg.Cloudflare.Proxied,
client: &http.Client{Timeout: 15 * time.Second},
}, nil
}
// zoneForHostname finds the zone whose name is the longest suffix
// of hostname. This handles both "app.example.com" → "example.com"
// and "app.sub.example.co.uk" → "example.co.uk" if that zone exists.
func (c *cloudflareProvider) zoneForHostname(hostname string) (config.ZoneEntry, error) {
var best config.ZoneEntry
bestLen := 0
for _, z := range c.zones {
if strings.HasSuffix(hostname, z.Name) && len(z.Name) > bestLen {
best = z
bestLen = len(z.Name)
}
}
if bestLen == 0 {
return config.ZoneEntry{}, fmt.Errorf("no configured zone matches hostname %q", hostname)
}
return best, nil
}
// EnsureARecord is idempotent: creates if absent, updates if IP
// differs, no-ops if already correct.
func (c *cloudflareProvider) EnsureARecord(hostname, ip string) error {
zone, err := c.zoneForHostname(hostname)
if err != nil {
return err
}
existing, err := c.getRecord(zone.ZoneID, hostname, "A")
if err != nil {
return fmt.Errorf("checking existing record: %w", err)
}
if existing != nil {
if existing.Content == ip {
fmt.Printf(" dns: A record %s → %s already correct, skipping\n", hostname, ip)
return nil
}
fmt.Printf(" dns: updating A record %s → %s (was %s)\n", hostname, ip, existing.Content)
return c.updateRecord(zone.ZoneID, existing.ID, hostname, ip)
}
fmt.Printf(" dns: creating A record %s → %s\n", hostname, ip)
return c.createRecord(zone.ZoneID, hostname, ip)
}
// DeleteARecord removes the A record for hostname if it exists.
func (c *cloudflareProvider) DeleteARecord(hostname string) error {
zone, err := c.zoneForHostname(hostname)
if err != nil {
return err
}
existing, err := c.getRecord(zone.ZoneID, hostname, "A")
if err != nil {
return fmt.Errorf("checking existing record: %w", err)
}
if existing == nil {
fmt.Printf(" dns: A record %s not found, skipping delete\n", hostname)
return nil
}
fmt.Printf(" dns: deleting A record %s\n", hostname)
return c.deleteRecord(zone.ZoneID, existing.ID)
}
// ------------------------------------------------------------
// Cloudflare API helpers
// ------------------------------------------------------------
type cfRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
TTL int `json:"ttl"`
}
type cfListResponse struct {
Success bool `json:"success"`
Errors []cfError `json:"errors"`
Result []cfRecord `json:"result"`
}
type cfSingleResponse struct {
Success bool `json:"success"`
Errors []cfError `json:"errors"`
Result cfRecord `json:"result"`
}
type cfError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e cfError) Error() string {
return fmt.Sprintf("CF %d: %s", e.Code, e.Message)
}
func (c *cloudflareProvider) getRecord(zoneID, name, recType string) (*cfRecord, error) {
url := fmt.Sprintf("%s/zones/%s/dns_records?type=%s&name=%s", cfAPIBase, zoneID, recType, name)
resp, err := c.do("GET", url, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var list cfListResponse
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
if !list.Success {
return nil, cfErrors(list.Errors)
}
if len(list.Result) == 0 {
return nil, nil
}
return &list.Result[0], nil
}
func (c *cloudflareProvider) createRecord(zoneID, name, ip string) error {
body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`,
name, ip, c.proxied)
url := fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID)
resp, err := c.do("POST", url, strings.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
var result cfSingleResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
if !result.Success {
return cfErrors(result.Errors)
}
return nil
}
func (c *cloudflareProvider) updateRecord(zoneID, recordID, name, ip string) error {
body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`,
name, ip, c.proxied)
url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
resp, err := c.do("PUT", url, strings.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
var result cfSingleResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
if !result.Success {
return cfErrors(result.Errors)
}
return nil
}
func (c *cloudflareProvider) deleteRecord(zoneID, recordID string) error {
url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
resp, err := c.do("DELETE", url, nil)
if err != nil {
return err
}
defer resp.Body.Close()
// Cloudflare returns {"result":{"id":"..."}} on success — we
// don't need to parse it, just check for HTTP errors.
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("delete failed (%d): %s", resp.StatusCode, string(body))
}
return nil
}
func (c *cloudflareProvider) do(method, url string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("cloudflare API %s %s: %w", method, url, err)
}
return resp, nil
}
func cfErrors(errs []cfError) error {
msgs := make([]string, len(errs))
for i, e := range errs {
msgs[i] = e.Error()
}
return fmt.Errorf("cloudflare API errors: %s", strings.Join(msgs, "; "))
}
// ------------------------------------------------------------
// EnsureRecordsForEnvironment — high-level helper used by
// the apply command and generate pipeline.
// ------------------------------------------------------------
// EnsureRecordsForEnvironment creates A records for all ingress
// hosts in the environment that have dns_record: true.
func EnsureRecordsForEnvironment(provider Provider, env *config.ResolvedEnvironment, nodeIP string) error {
for _, host := range env.Ingress.Hosts {
if !host.DNSRecord {
continue
}
if err := provider.EnsureARecord(host.Hostname, nodeIP); err != nil {
return fmt.Errorf("ensuring A record for %s: %w", host.Hostname, err)
}
}
return nil
}
+74 -261
View File
@@ -23,8 +23,9 @@ type GiteaActionsOptions struct {
// that builds the Docker image and deploys to each environment // that builds the Docker image and deploys to each environment
// using kforge generate + kubectl apply. // using kforge generate + kubectl apply.
// //
// DNS is handled by external-dns via Ingress annotations — no // The generated file is designed to replace your existing
// separate DNS step is needed in the workflow. // 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) { func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (string, error) {
if opts.Branch == "" { if opts.Branch == "" {
opts.Branch = "main" opts.Branch = "main"
@@ -38,19 +39,20 @@ func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (s
var b strings.Builder var b strings.Builder
writeDeployHeader(&b, cfg, opts) writeGiteaHeader(&b, cfg, opts)
writeDeployJobs(&b, cfg, opts) writeGiteaJobs(&b, cfg, opts)
return b.String(), nil return b.String(), nil
} }
func writeDeployHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { func writeGiteaHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
fmt.Fprintf(b, "# Generated by kforge — do not edit manually.\n") 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, "# Re-generate: kforge gitea-actions > .gitea/workflows/deploy.yml\n")
fmt.Fprintf(b, "#\n") fmt.Fprintf(b, "#\n")
fmt.Fprintf(b, "# Required Gitea org secrets:\n") fmt.Fprintf(b, "# Required Gitea org secrets:\n")
fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD\n") fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD, KFORGE_NODE_IP\n")
fmt.Fprintf(b, "# KFORGE_NODE_IP (external-dns annotation target)\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, "# Required Gitea repo secrets:\n")
fmt.Fprintf(b, "# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n") fmt.Fprintf(b, "# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n")
fmt.Fprintf(b, "\n") fmt.Fprintf(b, "\n")
@@ -63,93 +65,60 @@ func writeDeployHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaA
fmt.Fprintf(b, "\n") fmt.Fprintf(b, "\n")
} }
func writeDeployJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
fmt.Fprintf(b, "jobs:\n") fmt.Fprintf(b, "jobs:\n")
fmt.Fprintf(b, " build-and-deploy:\n") fmt.Fprintf(b, " build-and-deploy:\n")
fmt.Fprintf(b, " runs-on: ubuntu-latest\n") fmt.Fprintf(b, " runs-on: ubuntu-latest\n")
fmt.Fprintf(b, " steps:\n") fmt.Fprintf(b, " steps:\n")
// Checkout
writeStep(b, "Checkout", map[string]any{ writeStep(b, "Checkout", map[string]any{
"uses": "actions/checkout@v4", "uses": "actions/checkout@v4",
"with": map[string]any{"fetch-depth": 0}, "with": map[string]any{"fetch-depth": 0},
}) })
writeStep(b, "Create short SHA", map[string]any{ // 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`, "run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`,
}) })
if !cfg.Registry.Insecure { // Docker login
writeStep(b, "Login to registry", map[string]any{ writeStep(b, "Login to registry", map[string]any{
"uses": "docker/login-action@v2", "uses": "docker/login-action@v2",
"with": map[string]any{ "with": map[string]any{
"registry": cfg.Registry.URL, "registry": cfg.Registry.URL,
"username": "${{ secrets.DOCKER_USERNAME }}", "username": "${{ secrets.DOCKER_USERNAME }}",
"password": "${{ secrets.DOCKER_PASSWORD }}", "password": "${{ secrets.DOCKER_PASSWORD }}",
}, },
})
}
writeStep(b, "Set up Docker Buildx", map[string]any{
"uses": "docker/setup-buildx-action@v3",
}) })
// Docker build + push
fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name
buildWith := 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,
}
if cfg.Registry.Insecure {
buildWith["buildkitd-config-inline"] = fmt.Sprintf(
"[registry.%q]\n http = true\n insecure = true",
cfg.Registry.URL,
)
}
writeStep(b, "Build and push image", map[string]any{ writeStep(b, "Build and push image", map[string]any{
"uses": "docker/build-push-action@v5", "uses": "docker/build-push-action@v5",
"with": buildWith, "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,
},
}) })
if cfg.ActionRef != "" { // Install kforge on runner
writeActionDeploySteps(b, cfg, opts)
} else {
writeInlineDeploySteps(b, cfg, opts)
}
}
// writeActionDeploySteps emits one `uses: docker://image` step per environment.
// The docker:// prefix tells act/Gitea Actions to pull the image from the OCI
// registry directly, bypassing GitHub/Gitea source resolution.
func writeActionDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
image := "docker://" + cfg.ActionRef
for _, envKey := range opts.Environments {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
continue
}
label := strings.Title(envKey) //nolint:staticcheck
writeStep(b, fmt.Sprintf("Deploy (%s)", label), map[string]any{
"uses": image,
"with": map[string]any{
"command": "deploy",
"env": envKey,
"namespace": env.Namespace,
},
"env": actionEnv(),
})
}
}
// writeInlineDeploySteps emits the classic multi-step inline approach.
func writeInlineDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
writeStep(b, "Install kforge", map[string]any{ 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", "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 { for _, envKey := range opts.Environments {
env, err := config.ResolveEnvironment(cfg, envKey) env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil { if err != nil {
@@ -160,26 +129,45 @@ func writeInlineDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, opts G
} }
func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *config.ResolvedEnvironment, envKey string) { func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *config.ResolvedEnvironment, envKey string) {
label := strings.Title(envKey) //nolint:staticcheck 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{ writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{
"run": "kforge validate", "run": "kforge validate",
"env": giteaNodeIPEnv(), "env": giteaSecretEnv(),
}) })
// Apply cluster secrets (only creates if missing — idempotent).
writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{ writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{
"run": fmt.Sprintf("kforge secrets apply --env %s", envKey), "run": fmt.Sprintf("kforge secrets apply --env %s", envKey),
"env": giteaKubeEnv(), "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{ writeStep(b, fmt.Sprintf("Generate manifests (%s)", label), map[string]any{
"run": fmt.Sprintf( "run": fmt.Sprintf(
"kforge generate --env %s --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}", "kforge generate --env %s --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}",
envKey, envKey,
), ),
"env": giteaNodeIPEnv(), "env": giteaSecretEnv(),
}) })
// kubectl apply
writeStep(b, fmt.Sprintf("Apply manifests (%s)", label), map[string]any{ writeStep(b, fmt.Sprintf("Apply manifests (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master", "uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(), "env": giteaKubeEnv(),
@@ -191,6 +179,7 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
}, },
}) })
// Apply infra manifests if any infrastructure is enabled
infra := env.Infrastructure infra := env.Infrastructure
if infra.Database != nil || infra.Cache != nil || infra.Storage != nil || if infra.Database != nil || infra.Cache != nil || infra.Storage != nil ||
infra.Queue != nil || infra.Search != nil || infra.Monitoring != nil { infra.Queue != nil || infra.Search != nil || infra.Monitoring != nil {
@@ -206,6 +195,7 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
}) })
} }
// Rollout restart
writeStep(b, fmt.Sprintf("Rollout restart (%s)", label), map[string]any{ writeStep(b, fmt.Sprintf("Rollout restart (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master", "uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(), "env": giteaKubeEnv(),
@@ -218,182 +208,11 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
}) })
} }
// ------------------------------------------------------------
// Preview workflow
// ------------------------------------------------------------
// GeneratePreviewActions produces a Gitea Actions workflow YAML
// that deploys an ephemeral environment per pull request.
//
// On PR open/sync: builds a PR-tagged image, applies secrets,
// generates manifests (including Namespace), and deploys.
// On PR close: deletes the preview namespace, removing all resources.
func GeneratePreviewActions(cfg *config.KforgeConfig) (string, error) {
if !cfg.Preview.Enabled {
return "", fmt.Errorf("preview is not enabled in kforge.yml (set preview.enabled: true)")
}
nsPrefix := cfg.Preview.NamespacePrefix
if nsPrefix == "" {
nsPrefix = config.DefaultPreviewNamespacePrefix
}
fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name
var b strings.Builder
fmt.Fprintf(&b, "# Generated by kforge — do not edit manually.\n")
fmt.Fprintf(&b, "# Re-generate: kforge gitea-preview > .gitea/workflows/preview.yml\n")
fmt.Fprintf(&b, "#\n")
fmt.Fprintf(&b, "# Required secrets:\n")
if !cfg.Registry.Insecure {
fmt.Fprintf(&b, "# DOCKER_USERNAME, DOCKER_PASSWORD (registry auth)\n")
}
fmt.Fprintf(&b, "# KFORGE_NODE_IP, KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n")
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, "name: Preview Environment\n")
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, "on:\n")
fmt.Fprintf(&b, " pull_request:\n")
fmt.Fprintf(&b, " types: [opened, synchronize, reopened, closed]\n")
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, "jobs:\n")
fmt.Fprintf(&b, " preview:\n")
fmt.Fprintf(&b, " runs-on: ubuntu-latest\n")
fmt.Fprintf(&b, " steps:\n")
writeStep(&b, "Checkout", map[string]any{
"uses": "actions/checkout@v4",
"with": map[string]any{"fetch-depth": 0},
})
if !cfg.Registry.Insecure {
writeStep(&b, "Login to registry", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"uses": "docker/login-action@v2",
"with": map[string]any{
"registry": cfg.Registry.URL,
"username": "${{ secrets.DOCKER_USERNAME }}",
"password": "${{ secrets.DOCKER_PASSWORD }}",
},
})
}
writeStep(&b, "Set up Docker Buildx", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"uses": "docker/setup-buildx-action@v3",
})
previewBuildWith := map[string]any{
"context": ".",
"platforms": "linux/amd64",
"file": cfg.Defaults.Dockerfile,
"push": true,
"tags": fmt.Sprintf("%s:pr-${{ github.event.number }}", fullRepo),
"provenance": false,
"sbom": false,
}
if cfg.Registry.Insecure {
previewBuildWith["buildkitd-config-inline"] = fmt.Sprintf(
"[registry.%q]\n http = true\n insecure = true",
cfg.Registry.URL,
)
}
writeStep(&b, "Build and push preview image", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"uses": "docker/build-push-action@v5",
"with": previewBuildWith,
})
if cfg.ActionRef != "" {
image := "docker://" + cfg.ActionRef
writeStep(&b, "Deploy preview", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"uses": image,
"with": map[string]any{
"command": "preview-up",
"pr_number": "${{ github.event.number }}",
"namespace_prefix": nsPrefix,
},
"env": actionEnv(),
})
writeStep(&b, "Destroy preview", map[string]any{
"if": "${{ github.event.action == 'closed' }}",
"uses": image,
"with": map[string]any{
"command": "preview-down",
"pr_number": "${{ github.event.number }}",
"namespace_prefix": nsPrefix,
},
"env": actionEnv(),
})
} else {
writeStep(&b, "Install kforge", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge",
})
writeStep(&b, "Apply preview secrets", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"run": "kforge secrets apply --pr-number ${{ github.event.number }}",
"env": giteaKubeEnv(),
})
writeStep(&b, "Generate preview manifests", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"run": "kforge generate --pr-number ${{ github.event.number }} --output .kforge-out",
"env": map[string]any{
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
"PR_NUMBER": "${{ github.event.number }}",
},
})
writeStep(&b, "Apply preview manifests", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": "apply -f .kforge-out/ --insecure-skip-tls-verify",
},
})
writeStep(&b, "Wait for preview rollout", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
"rollout status deployment -n %s-${{ github.event.number }} --timeout=120s --insecure-skip-tls-verify",
nsPrefix,
),
},
})
writeStep(&b, "Destroy preview namespace", map[string]any{
"if": "${{ github.event.action == 'closed' }}",
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
"delete namespace %s-${{ github.event.number }} --ignore-not-found --insecure-skip-tls-verify",
nsPrefix,
),
},
})
}
return b.String(), nil
}
// ------------------------------------------------------------
// Shared step helpers
// ------------------------------------------------------------
// writeStep writes a single step in the jobs.steps list. // writeStep writes a single step in the jobs.steps list.
func writeStep(b *strings.Builder, name string, fields map[string]any) { func writeStep(b *strings.Builder, name string, fields map[string]any) {
fmt.Fprintf(b, "\n - name: %s\n", name) fmt.Fprintf(b, "\n - name: %s\n", name)
order := []string{"if", "uses", "run", "with", "env"} // Emit fields in a stable order.
order := []string{"uses", "run", "with", "env"}
for _, k := range order { for _, k := range order {
v, ok := fields[k] v, ok := fields[k]
if !ok { if !ok {
@@ -421,6 +240,7 @@ func writeStep(b *strings.Builder, name string, fields map[string]any) {
} }
func writeMapFields(b *strings.Builder, m map[string]any, indent string) { func writeMapFields(b *strings.Builder, m map[string]any, indent string) {
// Sort keys for stable output.
keys := make([]string, 0, len(m)) keys := make([]string, 0, len(m))
for k := range m { for k := range m {
keys = append(keys, k) keys = append(keys, k)
@@ -450,11 +270,13 @@ func writeMapFields(b *strings.Builder, m map[string]any, indent string) {
} }
} }
// giteaNodeIPEnv returns env vars needed for kforge commands that // giteaSecretEnv returns the env block referencing Gitea secrets
// resolve ${KFORGE_NODE_IP} tokens (validate, generate). // needed for kforge itself (DNS, registry tokens, etc.).
func giteaNodeIPEnv() map[string]any { func giteaSecretEnv() map[string]any {
return map[string]any{ return map[string]any{
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", "CLOUDFLARE_API_TOKEN": "${{ secrets.CLOUDFLARE_API_TOKEN }}",
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
"SOPS_AGE_KEY": "${{ secrets.SOPS_AGE_KEY }}",
} }
} }
@@ -467,17 +289,6 @@ func giteaKubeEnv() map[string]any {
} }
} }
// actionEnv returns the combined env block for a kforge action step —
// kubectl auth plus the node IP for external-dns annotation resolution.
func actionEnv() map[string]any {
return map[string]any{
"KUBE_CERTIFICATE": "${{ secrets.KUBE_CERTIFICATE }}",
"KUBE_HOST": "${{ secrets.KUBE_HOST }}",
"KUBE_TOKEN": "${{ secrets.KUBE_TOKEN }}",
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
}
}
func mergeMaps(maps ...map[string]any) map[string]any { func mergeMaps(maps ...map[string]any) map[string]any {
result := map[string]any{} result := map[string]any{}
for _, m := range maps { for _, m := range maps {
@@ -490,6 +301,8 @@ func mergeMaps(maps ...map[string]any) map[string]any {
func sortedEnvKeys(cfg *config.KforgeConfig) []string { func sortedEnvKeys(cfg *config.KforgeConfig) []string {
keys := config.EnvironmentKeys(cfg) 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} priority := map[string]int{"dev": 0, "development": 0, "staging": 1, "production": 2, "prod": 2}
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
pi, pj := priority[keys[i]], priority[keys[j]] pi, pj := priority[keys[i]], priority[keys[j]]
+28 -80
View File
@@ -2,6 +2,7 @@ package generator
import ( import (
"fmt" "fmt"
"strings"
"kforge/internal/config" "kforge/internal/config"
"kforge/pkg/interpolate" "kforge/pkg/interpolate"
@@ -78,22 +79,18 @@ type InfraManifest struct {
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
// Database — CNPG (centralized cluster) // Database — CNPG
// ------------------------------------------------------------ // ------------------------------------------------------------
func generateDatabase(env *config.ResolvedEnvironment, db *config.DatabaseInfraConfig) ([]InfraManifest, error) { func generateDatabase(env *config.ResolvedEnvironment, db *config.DatabaseInfraConfig) ([]InfraManifest, error) {
dbName := db.DatabaseName dbName := db.DatabaseName
if dbName == "" { if dbName == "" {
dbName = interpolate.PGIdentifier(env.FullName) dbName = interpolate.Slug(env.FullName)
} }
// pgUser must be a valid unquoted PostgreSQL identifier; same as roleName := dbName + "_role"
// the username stored in the db-credentials Secret by `kforge secrets apply`.
pgUser := interpolate.PGIdentifier(env.FullName)
secretName := env.FullName + "-db-credentials" secretName := env.FullName + "-db-credentials"
cnpgHost := env.CNPGHost
// CNPG Database CR — declaratively manages the database lifecycle. // CNPG Database CR
// The owner role is created by the db-init Job below.
dbManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1 dbManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1
kind: Database kind: Database
metadata: metadata:
@@ -106,96 +103,47 @@ spec:
name: %s name: %s
owner: %s owner: %s
cluster: cluster:
name: %s name: cnpg-main
`, dbName, env.Namespace, env.FullName, dbName, pgUser, env.CNPGClusterName) `, dbName, env.Namespace, env.FullName, dbName, roleName)
// db-init Job — runs on every deploy to ensure the PostgreSQL role // CNPG Role CR — CNPG creates and rotates the password,
// exists and its password matches the Secret. The password stored by // storing it in the secret named below.
// `kforge secrets apply` is alphanumeric-only so it is safe to roleManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1
// embed in a shell command without additional escaping. kind: DatabaseRole
//
// Prerequisites:
// - The CNPG superuser Secret must exist in the same namespace
// (or copy it there as part of cluster bootstrap).
// - `kforge secrets apply` must have run before this Job.
jobManifest := fmt.Sprintf(`apiVersion: batch/v1
kind: Job
metadata: metadata:
name: %s-db-init name: %s
namespace: %s namespace: %s
labels: labels:
app: %s app: %s
managed-by: kforge managed-by: kforge
spec: spec:
ttlSecondsAfterFinished: 600 name: %s
template: passwordSecret:
metadata: name: %s
labels: login: true
app: %s superuser: false
spec: createdb: false
restartPolicy: OnFailure `, roleName, env.Namespace, env.FullName, roleName, secretName)
containers:
- name: db-init // The env vars reference the CNPG-managed secret.
image: postgres:16-alpine // CNPG populates: username, password keys in the secret.
command: // We assemble DATABASE_URL from the known CNPG host + db name.
- /bin/sh cnpgHost := env.CNPGHost
- -c dbURL := fmt.Sprintf("postgresql://$(%s_USER):$(%s_PASSWORD)@%s/%s",
- | strings.ToUpper(env.FullName), strings.ToUpper(env.FullName), cnpgHost, dbName)
set -e
echo "Ensuring role $DB_USER exists..."
PGPASSWORD="$ADMIN_PASSWORD" psql -h "$DB_HOST" -U "$ADMIN_USER" postgres \
-c "SELECT 1 FROM pg_roles WHERE rolname = '$DB_USER'" | grep -q 1 || \
PGPASSWORD="$ADMIN_PASSWORD" psql -h "$DB_HOST" -U "$ADMIN_USER" postgres \
-c "CREATE ROLE $DB_USER WITH LOGIN PASSWORD '$DB_PASSWORD';"
echo "Syncing password for $DB_USER..."
PGPASSWORD="$ADMIN_PASSWORD" psql -h "$DB_HOST" -U "$ADMIN_USER" postgres \
-c "ALTER ROLE $DB_USER WITH PASSWORD '$DB_PASSWORD';"
echo "Done."
env:
- name: DB_HOST
value: %s
- name: DB_USER
valueFrom:
secretKeyRef:
name: %s
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: %s
key: password
- name: ADMIN_USER
valueFrom:
secretKeyRef:
name: %s
key: username
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: %s
key: password
`,
env.FullName, env.Namespace, env.FullName, env.FullName,
cnpgHost,
secretName, secretName,
env.CNPGSuperuserSecret, env.CNPGSuperuserSecret,
)
// DATABASE_URL uses Kubernetes $(VAR_NAME) substitution — DB_USER and
// DB_PASSWORD must be defined earlier in the env list.
envVars := []config.EnvVarConfig{ envVars := []config.EnvVarConfig{
{Name: "DB_HOST", Type: config.EnvVarTypePlain, Value: cnpgHost}, {Name: "DB_HOST", Type: config.EnvVarTypePlain, Value: cnpgHost},
{Name: "DB_PORT", Type: config.EnvVarTypePlain, Value: "5432"}, {Name: "DB_PORT", Type: config.EnvVarTypePlain, Value: "5432"},
{Name: "DB_NAME", Type: config.EnvVarTypePlain, Value: dbName}, {Name: "DB_NAME", Type: config.EnvVarTypePlain, Value: dbName},
{Name: "DB_USER", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "username"}, {Name: "DB_USER", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "username"},
{Name: "DB_PASSWORD", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "password"}, {Name: "DB_PASSWORD", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "password"},
{Name: "DATABASE_URL", Type: config.EnvVarTypePlain, {Name: "DATABASE_URL", Type: config.EnvVarTypePlain, Value: dbURL},
Value: fmt.Sprintf("postgresql://$(DB_USER):$(DB_PASSWORD)@%s/%s", cnpgHost, dbName)},
} }
return []InfraManifest{ return []InfraManifest{
{Name: "cnpg-database", Content: dbManifest, EnvVars: envVars}, {Name: "cnpg-database", Content: dbManifest, EnvVars: envVars},
{Name: "cnpg-db-init", Content: jobManifest}, {Name: "cnpg-role", Content: roleManifest},
}, nil }, nil
} }
+4 -44
View File
@@ -151,10 +151,8 @@ func Deployment(env *config.ResolvedEnvironment, tokens interpolate.Tokens) (str
b.WriteString(renderResourceLines(env.Resources, " ")) b.WriteString(renderResourceLines(env.Resources, " "))
if env.ImagePullSecret != "" { b.WriteString(" imagePullSecrets:\n")
b.WriteString(" imagePullSecrets:\n") fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
}
return b.String(), nil return b.String(), nil
} }
@@ -183,22 +181,6 @@ func Ingress(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, err
b.WriteString(" nginx.ingress.kubernetes.io/auth-realm: \"Authentication Required\"\n") b.WriteString(" nginx.ingress.kubernetes.io/auth-realm: \"Authentication Required\"\n")
} }
// external-dns annotations: collect all hostnames that need DNS records.
if !env.SkipDNS {
var dnsHosts []string
for _, h := range env.Ingress.Hosts {
if h.DNSRecord {
dnsHosts = append(dnsHosts, h.Hostname)
}
}
if len(dnsHosts) > 0 {
fmt.Fprintf(&b, " external-dns.alpha.kubernetes.io/hostname: \"%s\"\n", strings.Join(dnsHosts, ","))
if env.DNSTarget != "" {
fmt.Fprintf(&b, " external-dns.alpha.kubernetes.io/target: \"%s\"\n", env.DNSTarget)
}
}
}
b.WriteString("spec:\n") b.WriteString("spec:\n")
fmt.Fprintf(&b, " ingressClassName: %s\n", env.IngressClass) fmt.Fprintf(&b, " ingressClassName: %s\n", env.IngressClass)
@@ -333,10 +315,8 @@ func CronJob(env *config.ResolvedEnvironment, job *config.ResolvedCronJob, token
b.WriteString(renderResourceLines(*job.Resources, " ")) b.WriteString(renderResourceLines(*job.Resources, " "))
} }
if env.ImagePullSecret != "" { b.WriteString(" imagePullSecrets:\n")
b.WriteString(" imagePullSecrets:\n") fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
}
return b.String(), nil return b.String(), nil
} }
@@ -425,26 +405,6 @@ func renderStrSlice(ss []string) string {
return "[" + strings.Join(quoted, ", ") + "]" return "[" + strings.Join(quoted, ", ") + "]"
} }
// Namespace generates a Namespace manifest. Used for preview
// environments where the namespace must be created by kubectl apply.
func Namespace(namespace string, labels map[string]string) string {
var b strings.Builder
b.WriteString("apiVersion: v1\nkind: Namespace\nmetadata:\n")
fmt.Fprintf(&b, " name: %s\n", namespace)
if len(labels) > 0 {
b.WriteString(" labels:\n")
// Stable key order.
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
for _, k := range keys {
fmt.Fprintf(&b, " %s: %q\n", k, labels[k])
}
}
return b.String()
}
// render executes a template with data and returns the result. // render executes a template with data and returns the result.
func render(t *template.Template, data any) (string, error) { func render(t *template.Template, data any) (string, error) {
var buf bytes.Buffer var buf bytes.Buffer
+10 -3
View File
@@ -3,11 +3,18 @@ meta:
tenant: nate-lubitz tenant: nate-lubitz
registry: registry:
url: registry.container-registry.svc.cluster.local:5000 url: registry.natelubitz.com
insecure: true pull_secret: regcred
dns: dns:
target: ${KFORGE_NODE_IP} 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: cluster:
tls_issuer: letsencrypt-prod tls_issuer: letsencrypt-prod
-17
View File
@@ -119,20 +119,3 @@ func Slug(s string) string {
func HostSlug(hostname string) string { func HostSlug(hostname string) string {
return Slug(strings.ReplaceAll(hostname, ".", "-")) return Slug(strings.ReplaceAll(hostname, ".", "-"))
} }
// PGIdentifier converts a kforge name into a valid unquoted
// PostgreSQL identifier: lowercase, hyphens and spaces become
// underscores, all other non-alphanumeric characters are dropped.
// "prod-my-tenant-myapp" → "prod_my_tenant_myapp"
func PGIdentifier(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 == ' ':
b.WriteRune('_')
}
}
return strings.Trim(b.String(), "_")
}
+130 -258
View File
@@ -1,8 +1,8 @@
# kforge # 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 and generated workflows. **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, external-dns, cert-manager, and CNPG — but designed to be extended. Built for self-hosted MicroK8s, Gitea Actions, Cloudflare DNS, cert-manager, and CNPG — but designed to be extended.
--- ---
@@ -10,14 +10,12 @@ Built for self-hosted MicroK8s, Gitea Actions, external-dns, cert-manager, and C
1. Add a `kforge.yml` to your repo root describing your app, environments, and infrastructure. 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). 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` and generated workflow files are committed. 3. Generated manifests are applied to the cluster and discarded. Only `kforge.yml` is committed.
``` ```
kforge.yml → kforge generate → kubectl apply → cluster kforge.yml → kforge generate → kubectl apply → cluster
``` ```
DNS records are managed by **external-dns** running in the cluster — kforge writes the appropriate annotations on the Ingress and external-dns creates the records automatically. TLS is handled by **cert-manager** reading those same Certificate CRs.
--- ---
## Installation ## Installation
@@ -59,7 +57,13 @@ registry:
url: registry.yourdomain.com url: registry.yourdomain.com
dns: dns:
target: ${KFORGE_NODE_IP} # your cluster node's public IP provider: cloudflare
cloudflare:
api_token: ${CLOUDFLARE_API_TOKEN}
zones:
- name: yourdomain.com
zone_id: ${CF_ZONE_ID_YOURDOMAIN}
node_ip: ${KFORGE_NODE_IP}
cluster: cluster:
tls_issuer: letsencrypt-prod tls_issuer: letsencrypt-prod
@@ -90,7 +94,7 @@ environments:
hosts: hosts:
- hostname: app-staging.yourdomain.com - hostname: app-staging.yourdomain.com
tls: true tls: true
dns_record: true # external-dns creates this record dns_record: true
auth: auth:
enabled: true enabled: true
users: users:
@@ -127,101 +131,19 @@ kforge secrets list
kforge generate --env production --dry-run kforge generate --env production --dry-run
``` ```
**4. Generate your Gitea Actions workflows:** **4. Generate your Gitea Actions workflow:**
```bash ```bash
kforge gitea-actions > .gitea/workflows/deploy.yml kforge gitea-actions > .gitea/workflows/deploy.yml
kforge gitea-preview > .gitea/workflows/preview.yml # optional — PR preview environments
``` ```
--- ---
## Adding kforge to an existing project
### Step 1 — Add `kforge.yml` and generate workflows
Add `kforge.yml` to your repo root (see Quick start above), then:
```bash
kforge gitea-actions # → .gitea/workflows/deploy.yml
kforge gitea-preview # → .gitea/workflows/preview.yml (if preview.enabled: true)
```
Commit `kforge.yml` and both generated workflow files.
### Step 2 — Gitea secrets
**Org-level** (set once, shared across all repos):
| Secret | Value |
|---|---|
| `DOCKER_USERNAME` | Registry login username |
| `DOCKER_PASSWORD` | Registry login password/token |
| `KFORGE_NODE_IP` | Your cluster's public node IP (used as the external-dns record target) |
**Repo-level** (per project):
| Secret | How to get it |
|---|---|
| `KUBE_HOST` | `kubectl config view --raw -o jsonpath='{.clusters[0].cluster.server}'` |
| `KUBE_CERTIFICATE` | `kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}'` |
| `KUBE_TOKEN` | See below |
Create a long-lived deploy service account:
```bash
kubectl create serviceaccount kforge-deployer -n kube-system
kubectl create clusterrolebinding kforge-deployer \
--clusterrole=cluster-admin \
--serviceaccount=kube-system:kforge-deployer
kubectl create token kforge-deployer -n kube-system --duration=8760h
```
### Step 3 — Cluster prerequisites
These must already be running on your cluster:
| Component | Purpose |
|---|---|
| **NGINX ingress controller** | Handles `ingress_class: nginx` |
| **cert-manager** + `ClusterIssuer` named `letsencrypt-prod` | Issues TLS certificates |
| **external-dns** | Reads Ingress annotations, creates DNS records |
| **CNPG operator** | Required if `infrastructure.database` is enabled |
| **`regcred` imagePullSecret** | Must exist in each environment namespace |
For external-dns, configure it with your DNS provider and `--source=ingress`. kforge writes the target annotation automatically from `dns.target`.
Create `regcred` in each namespace:
```bash
kubectl create secret docker-registry regcred \
--docker-server=registry.yourdomain.com \
--docker-username=<user> \
--docker-password=<pass> \
-n production
```
### Step 4 — First deploy
The workflow is fully automated after setup, but the very first time:
1. Create the namespace: `kubectl create namespace production`
2. Create `regcred` in that namespace (above).
3. If using CNPG database, make the CNPG superuser Secret available in the namespace. The default secret name is `cnpg-main-superuser` — copy it from wherever your CNPG cluster lives:
```bash
kubectl get secret cnpg-main-superuser -n cnpg-system -o yaml \
| sed 's/namespace: cnpg-system/namespace: production/' \
| kubectl apply -f -
```
4. Push to `main`. The workflow runs `kforge secrets apply` (creates credentials), `kforge generate` (writes manifests), and `kubectl apply`.
---
## CLI reference ## CLI reference
### `kforge validate` ### `kforge validate`
Parses `kforge.yml`, checks structural correctness, and verifies all required secrets are present in the current environment. Use this as the first step in CI to fail fast before touching the cluster. 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 ```bash
kforge validate kforge validate
@@ -239,24 +161,21 @@ kforge generate # all environments
kforge generate --env staging # one environment kforge generate --env staging # one environment
kforge generate --env production --dry-run kforge generate --env production --dry-run
kforge generate --env production --output .kube/ kforge generate --env production --output .kube/
kforge generate --pr-number 42 # preview environment for PR #42
``` ```
**Output files per environment:** **Output files per environment:**
| File | Contents | | File | Contents |
|---|---| | --------------------------------- | ---------------------------------------------------- |
| `{env}-core.yaml` | Service, Deployment, Ingress, Certificates, CronJobs | | `{env}-core.yaml` | Service, Deployment, Ingress, Certificates, CronJobs |
| `{env}-infra-cnpg-database.yaml` | CNPG Database CR | | `{env}-infra-cnpg-database.yaml` | CNPG Database CR |
| `{env}-infra-cnpg-db-init.yaml` | Job that creates the PostgreSQL role and syncs password | | `{env}-infra-cnpg-role.yaml` | CNPG DatabaseRole CR |
| `{env}-infra-cache.yaml` | Valkey/Redis Deployment or StatefulSet | | `{env}-infra-cache.yaml` | Valkey/Redis Deployment or StatefulSet |
| `{env}-infra-cache-svc.yaml` | Cache Service | | `{env}-infra-cache-svc.yaml` | Cache Service |
| `{env}-infra-storage.yaml` | Minio Deployment | | `{env}-infra-storage.yaml` | Minio Deployment |
| `{env}-infra-queue-nats.yaml` | NATS Deployment | | `{env}-infra-queue-nats.yaml` | NATS Deployment |
| `{env}-infra-search.yaml` | Meilisearch Deployment | | `{env}-infra-search.yaml` | Meilisearch Deployment |
| `{env}-infra-servicemonitor.yaml` | Prometheus ServiceMonitor CR | | `{env}-infra-servicemonitor.yaml` | Prometheus ServiceMonitor CR |
When `--pr-number` is given, the output also includes a `Namespace` manifest so `kubectl apply` is self-contained — no separate namespace creation step needed.
Infrastructure env vars (`DATABASE_URL`, `CACHE_URL`, `STORAGE_ENDPOINT`, etc.) are automatically injected into the Deployment — you don't wire these up manually. Infrastructure env vars (`DATABASE_URL`, `CACHE_URL`, `STORAGE_ENDPOINT`, etc.) are automatically injected into the Deployment — you don't wire these up manually.
@@ -274,17 +193,20 @@ Example output:
``` ```
── Gitea org secret ── ── Gitea org secret ──
DOCKER_USERNAME ✗ missing DOCKER_USERNAME ✗ missing
KFORGE_NODE_IP ✓ set CLOUDFLARE_API_TOKEN ✓ set
CF_ZONE_ID_YOURDOMAIN_COM ✓ set
KFORGE_NODE_IP ✓ set
SOPS_AGE_KEY ✗ missing
── Gitea repo secret ── ── Gitea repo secret ──
KUBE_HOST ✓ set KUBE_HOST ✓ set
KUBE_TOKEN ✓ set KUBE_TOKEN ✓ set
KUBE_CERTIFICATE ✓ set KUBE_CERTIFICATE ✓ set
── Cluster secret (auto-generated) ── ── Cluster secret (auto-generated) ──
prod-my-org-my-app-db-credentials — managed by kforge prod-my-org-my-app-db-credentials — managed by kforge
prod-my-org-my-app-cache-credentials — managed by kforge prod-my-org-my-app-cache-credentials — managed by kforge
``` ```
--- ---
@@ -296,26 +218,27 @@ Generates secure random credentials and creates Kubernetes Secrets in the cluste
```bash ```bash
kforge secrets apply --env staging kforge secrets apply --env staging
kforge secrets apply --env production --force # rotates all credentials kforge secrets apply --env production --force # rotates all credentials
kforge secrets apply --pr-number 42 # apply secrets for PR preview #42
``` ```
**What gets created:** For basic auth secrets, kforge prints the generated passwords once at apply time. Save them — they are not stored anywhere else.
| Secret | Contents |
|---|---|
| `{full_name}-db-credentials` | PostgreSQL `username` (derived from app name) + random alphanumeric `password` |
| `{full_name}-basic-auth` | htpasswd entries for ingress basic auth |
| `{full_name}-cache-credentials` | Valkey/Redis password |
| `{full_name}-storage-credentials` | Minio access key + secret key |
| `{full_name}-queue-credentials` | RabbitMQ username + password (NATS needs no credentials) |
| `{full_name}-search-credentials` | Meilisearch master key |
For basic auth secrets, kforge prints the generated passwords once at apply time — save them.
Requires `KUBE_HOST`, `KUBE_TOKEN`, and `KUBE_CERTIFICATE` to be set (Gitea injects these automatically during CI). 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` ### `kforge gitea-actions`
Generates a complete `.gitea/workflows/deploy.yml` for this app. Re-run whenever you add environments or change deploy configuration. Generates a complete `.gitea/workflows/deploy.yml` for this app. Re-run whenever you add environments or change deploy configuration.
@@ -328,32 +251,14 @@ kforge gitea-actions --branch main --env staging --env production
The generated workflow runs these steps for each environment, in order: The generated workflow runs these steps for each environment, in order:
1. Build and push Docker image (tagged with git SHA) 1. Build and push Docker image
2. `kforge validate` 2. `kforge validate`
3. `kforge secrets apply` — creates any missing cluster secrets 3. `kforge secrets apply` — creates missing cluster secrets
4. `kforge generate` — writes manifests to `.kforge-out/` 4. `kforge dns ensure` — creates missing DNS records
5. `kubectl apply` — applies core manifests (Service, Deployment, Ingress, Certs) 5. `kforge generate` — writes manifests to `.kforge-out/`
6. `kubectl apply` — applies infrastructure manifests (database, cache, etc.) 6. `kubectl apply` — applies core manifests
7. `kubectl rollout restart` — triggers rolling update 7. `kubectl apply` — applies infra manifests
8. `kubectl rollout restart` — triggers rolling update
DNS records are created automatically by external-dns when the Ingress is applied — no separate DNS step needed.
---
### `kforge gitea-preview`
Generates `.gitea/workflows/preview.yml` that deploys an ephemeral environment for each pull request.
```bash
kforge gitea-preview
kforge gitea-preview --output .gitea/workflows/preview.yml
```
Requires `preview.enabled: true` in `kforge.yml`. See the [preview environments](#preview-environments) section below.
The generated workflow:
- **On PR open / sync**: Builds a `:pr-{N}` tagged image, applies secrets to a new namespace (`preview-pr-{N}`), generates manifests (including a `Namespace` resource), and deploys.
- **On PR close**: Deletes the `preview-pr-{N}` namespace, removing all preview resources automatically.
--- ---
@@ -363,16 +268,16 @@ The generated workflow:
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: 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 | | Token | Resolves to |
|---|---| | --------------- | ------------------------------------------- |
| `${name}` | `meta.name` | | `${name}` | `meta.name` |
| `${tenant}` | `meta.tenant` | | `${tenant}` | `meta.tenant` |
| `${env}` | current environment key | | `${env}` | current environment key |
| `${env_prefix}` | short env prefix (first 4 chars, or custom) | | `${env_prefix}` | short env prefix (first 4 chars, or custom) |
| `${full_name}` | `{env_prefix}-{tenant}-{name}` | | `${full_name}` | `{env_prefix}-{tenant}-{name}` |
| `${namespace}` | resolved namespace for the environment | | `${namespace}` | resolved namespace for the environment |
Environment variables (e.g. `${KFORGE_NODE_IP}`) are resolved from the CI process environment at generation time — never hardcode secrets in `kforge.yml`. Environment variables (e.g. `${CLOUDFLARE_API_TOKEN}`) are resolved from the CI process environment at generation time — never hardcode secrets in `kforge.yml`.
--- ---
@@ -380,10 +285,10 @@ Environment variables (e.g. `${KFORGE_NODE_IP}`) are resolved from the CI proces
```yaml ```yaml
meta: meta:
name: my-app # required — short app name, lowercase, hyphens ok name: my-app # required — short app name, lowercase, hyphens ok
tenant: my-org # required — org/tenant identifier tenant: my-org # required — org/tenant identifier
name_override: ~ # optional — override the full generated resource name name_override: ~ # optional — override the full generated resource name
previous_name: ~ # optional — set when renaming; kforge patches rather than recreates previous_name: ~ # optional — set when renaming; kforge patches rather than recreates
``` ```
--- ---
@@ -392,9 +297,9 @@ meta:
```yaml ```yaml
registry: registry:
url: registry.yourdomain.com # default: registry.natelubitz.com url: registry.yourdomain.com # default: registry.natelubitz.com
repository: my-org/my-app # default: {tenant}/{name} repository: my-org/my-app # default: {tenant}/{name}
pull_secret: regcred # default: regcred pull_secret: regcred # default: regcred
``` ```
Override per-environment by adding a `registry:` block under the environment. Override per-environment by adding a `registry:` block under the environment.
@@ -403,20 +308,20 @@ Override per-environment by adding a `registry:` block under the environment.
### `dns` ### `dns`
DNS records are managed by **external-dns** inside the cluster. kforge writes annotations on the Ingress resource for each host with `dns_record: true`:
```
external-dns.alpha.kubernetes.io/hostname: "app.yourdomain.com"
external-dns.alpha.kubernetes.io/target: "<dns.target>"
```
```yaml ```yaml
dns: dns:
target: ${KFORGE_NODE_IP} # value for the external-dns target annotation provider: cloudflare # currently supported: cloudflare
skip_dns: false # true = don't write external-dns annotations 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
``` ```
`target` supports token interpolation — `${KFORGE_NODE_IP}` is the most common value, resolved from the `KFORGE_NODE_IP` Gitea org secret at generate time. kforge matches each ingress hostname to the correct zone by longest-suffix match — add one zone entry per domain you own.
--- ---
@@ -424,13 +329,11 @@ dns:
```yaml ```yaml
cluster: cluster:
tls_issuer: letsencrypt-prod # default: letsencrypt-prod tls_issuer: letsencrypt-prod # default: letsencrypt-prod
ingress_class: nginx # default: nginx ingress_class: nginx # default: nginx
cnpg: cnpg:
host: cnpg-main-rw.default.svc.cluster.local # your CNPG cluster's read-write service host: cnpg-main-rw.default.svc.cluster.local # your CNPG cluster service
cluster_name: cnpg-main # default: cnpg-main namespace_pattern: "${env}" # default: environment key
superuser_secret: cnpg-main-superuser # default: cnpg-main-superuser
namespace_pattern: "${env}" # default: environment key
``` ```
--- ---
@@ -474,17 +377,17 @@ Define infrastructure at the root level and it applies to **all environments** b
```yaml ```yaml
infrastructure: infrastructure:
database: database:
provider: cnpg # only supported provider currently provider: cnpg # only supported provider currently
cache: cache:
provider: valkey # valkey (recommended) | redis provider: valkey # valkey (recommended) | redis
mode: standalone # standalone | cluster mode: standalone # standalone | cluster
replicas: 1 replicas: 1
storage: storage:
enabled: false enabled: false
provider: minio # standalone | distributed provider: minio # standalone | distributed
queue: queue:
enabled: false enabled: false
provider: nats # nats (recommended, ~20MB) | rabbitmq (~200MB) provider: nats # nats (recommended, ~20MB) | rabbitmq (~200MB)
search: search:
enabled: false enabled: false
provider: meilisearch provider: meilisearch
@@ -512,53 +415,19 @@ environments:
infrastructure: infrastructure:
cache: cache:
mode: cluster mode: cluster
replicas: 3 # provider: valkey inherited from root replicas: 3 # provider: valkey inherited from root
``` ```
**Injected env vars per service** (automatically added to your Deployment): **Injected env vars per service** (automatically added to your Deployment):
| Service | Env vars injected | | Service | Env vars injected |
|---|---| | ---------------- | ------------------------------------------------------------------------- |
| database (CNPG) | `DATABASE_URL`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` | | database (CNPG) | `DATABASE_URL`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` |
| cache | `CACHE_URL`, `CACHE_HOST`, `CACHE_PORT`, `CACHE_PASSWORD` | | cache | `CACHE_URL`, `CACHE_HOST`, `CACHE_PORT`, `CACHE_PASSWORD` |
| storage | `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY` | | storage | `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY` |
| queue (NATS) | `QUEUE_URL`, `QUEUE_HOST` | | queue (NATS) | `QUEUE_URL`, `QUEUE_HOST` |
| queue (RabbitMQ) | `QUEUE_URL`, `QUEUE_USER`, `QUEUE_PASSWORD` | | queue (RabbitMQ) | `QUEUE_URL`, `QUEUE_USER`, `QUEUE_PASSWORD` |
| search | `SEARCH_URL`, `SEARCH_MASTER_KEY` | | search | `SEARCH_URL`, `SEARCH_MASTER_KEY` |
#### CNPG database details
For a centralized CNPG cluster, kforge generates two resources:
1. A **`Database` CR** — declaratively manages the database lifecycle (CNPG v1.22+).
2. A **`db-init` Job** — runs on every deploy to create the PostgreSQL role (if it doesn't exist) and sync its password from the `{full_name}-db-credentials` Secret.
`kforge secrets apply` must run before the Job so the Secret exists. The PostgreSQL username is derived from the app's full name (`prod_my_org_my_app`). The password is alphanumeric only, which keeps the init Job shell script simple and safe.
The `cnpg-main-superuser` Secret (created by CNPG for the cluster) must be present in the target namespace. Copy it once during cluster bootstrap or namespace creation.
---
### `preview`
Enables PR preview environments. Run `kforge gitea-preview` to generate the workflow.
```yaml
preview:
enabled: true
base_environment: staging # inherit infra and env vars from this env
namespace_prefix: preview-pr # namespace = preview-pr-{PR_NUMBER}
hostname_template: "pr-${PR_NUMBER}.${name}.yourdomain.com"
```
Supported tokens in `hostname_template`: `${PR_NUMBER}`, `${name}`, `${tenant}`.
On PR open/sync, kforge deploys the app to a `preview-pr-{N}` namespace with:
- Image tagged `:pr-{N}` (built from the PR branch)
- Hostname from the template
- Infrastructure and env vars inherited from `base_environment`
On PR close, the entire namespace is deleted.
--- ---
@@ -569,8 +438,8 @@ environments:
production: production:
namespace: production namespace: production
replicas: 1 replicas: 1
image_tag: latest # override with --set image_tag=$SHA in CI image_tag: latest # override with --set image_tag=$SHA in CI
env_prefix: prod # default: first 4 chars of env key env_prefix: prod # default: first 4 chars of env key
env_vars: env_vars:
- name: API_URL - name: API_URL
@@ -578,24 +447,24 @@ environments:
value: https://api.yourdomain.com value: https://api.yourdomain.com
- name: SOME_SECRET - name: SOME_SECRET
type: secret_ref # pull from an existing Kubernetes Secret type: secret_ref # pull from an existing Kubernetes Secret
secret_name: my-secrets secret_name: my-secrets
secret_key: some_secret secret_key: some_secret
- name: FEATURE_FLAG - name: FEATURE_FLAG
type: configmap_ref # pull from a ConfigMap type: configmap_ref # pull from a ConfigMap
configmap_name: my-config configmap_name: my-config
configmap_key: feature_flag configmap_key: feature_flag
ingress: ingress:
hosts: hosts:
- hostname: app.yourdomain.com - hostname: app.yourdomain.com
tls: true # kforge generates a cert-manager Certificate tls: true # kforge generates a cert-manager Certificate
dns_record: true # external-dns creates this record dns_record: true # kforge creates a Cloudflare A record
auth: auth:
enabled: false # enable for staging/dev to protect unreleased work enabled: false # enable for staging/dev to protect unreleased work
users: users:
- yourname # passwords are auto-generated by kforge secrets apply - yourname # passwords are auto-generated by kforge secrets apply
infrastructure: infrastructure:
# shallow merge on top of root — only override what differs # shallow merge on top of root — only override what differs
@@ -607,7 +476,7 @@ environments:
- name: cleanup - name: cleanup
schedule: "0 2 * * *" schedule: "0 2 * * *"
command: ["node", "scripts/cleanup.js"] command: ["node", "scripts/cleanup.js"]
inherit_env: true # inherits all deployment env vars inherit_env: true # inherits all deployment env vars
env_vars: env_vars:
- name: BATCH_SIZE - name: BATCH_SIZE
value: "500" value: "500"
@@ -623,8 +492,8 @@ environments:
concurrency_policy: Forbid concurrency_policy: Forbid
lifecycle: lifecycle:
delete: false # if true + previous_name set, deletes old resources delete: false # if true + previous_name set, deletes old resources
delete_grace_seconds: 300 delete_grace_seconds: 300 # 5-minute countdown before deletion runs in CI
``` ```
--- ---
@@ -637,36 +506,37 @@ kforge works with three categories of secrets, each living in the right place fo
Set once at the organisation level; available to every repo automatically. Set once at the organisation level; available to every repo automatically.
| Secret | Used by | Purpose | | Secret | Purpose |
|---|---|---| | ---------------------- | ---------------------------------------------- |
| `DOCKER_USERNAME` | `docker/login-action` | Registry authentication for image push | | `DOCKER_USERNAME` | Registry authentication |
| `DOCKER_PASSWORD` | `docker/login-action` | Registry authentication for image push | | `DOCKER_PASSWORD` | Registry authentication |
| `KFORGE_NODE_IP` | kforge | Cluster node IP — written as the external-dns target annotation | | `CLOUDFLARE_API_TOKEN` | DNS record management (Zone:Read + DNS:Edit) |
| `CF_ZONE_ID_{DOMAIN}` | One per zone, e.g. `CF_ZONE_ID_YOURDOMAIN_COM` |
`DOCKER_USERNAME` and `DOCKER_PASSWORD` are consumed by the Docker build steps in the generated workflow, not by kforge itself. `kforge validate` does not check for them. | `KFORGE_NODE_IP` | MicroK8s node IP for DNS A records |
| `SOPS_AGE_KEY` | Decrypts `.kforge/secrets.enc.yml` |
### Category B — Gitea repo secrets ### Category B — Gitea repo secrets
Per-repo, since different apps may deploy to different clusters. Per-repo, since different apps may deploy to different clusters.
| Secret | Purpose | | Secret | Purpose |
|---|---| | ------------------ | ----------------------------- |
| `KUBE_HOST` | Kubernetes API server URL | | `KUBE_HOST` | Kubernetes API server URL |
| `KUBE_TOKEN` | Service account token | | `KUBE_TOKEN` | Service account token |
| `KUBE_CERTIFICATE` | Base64-encoded CA certificate | | `KUBE_CERTIFICATE` | Base64-encoded CA certificate |
### Category C — Cluster secrets (auto-generated) ### Category C — Cluster secrets (auto-generated)
Created by `kforge secrets apply`. Never appear in Gitea or in `kforge.yml`. Created by `kforge secrets apply`. Never appear in Gitea or in `kforge.yml`.
| Secret name | Contents | | Secret name | Contents |
|---|---| | --------------------------------- | --------------------------------- |
| `{full_name}-db-credentials` | PostgreSQL `username` + alphanumeric `password` | | `{full_name}-db-credentials` | CNPG-managed database credentials |
| `{full_name}-basic-auth` | htpasswd for ingress basic auth | | `{full_name}-basic-auth` | htpasswd for ingress basic auth |
| `{full_name}-cache-credentials` | Valkey/Redis password | | `{full_name}-cache-credentials` | Valkey/Redis password |
| `{full_name}-storage-credentials` | Minio access/secret keys | | `{full_name}-storage-credentials` | Minio access/secret keys |
| `{full_name}-queue-credentials` | RabbitMQ credentials | | `{full_name}-queue-credentials` | RabbitMQ credentials |
| `{full_name}-search-credentials` | Meilisearch master key | | `{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. Run `kforge secrets list` at any time to see the full checklist with live status for your current repo.
@@ -679,7 +549,9 @@ kforge.yml ← your app config (committed)
.gitea/ .gitea/
workflows/ workflows/
deploy.yml ← generated by kforge gitea-actions (committed) deploy.yml ← generated by kforge gitea-actions (committed)
preview.yml ← generated by kforge gitea-preview (committed, optional) .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`. Generated manifests (`.kforge-out/`) are never committed — they are created at CI time and discarded after `kubectl apply`.