Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d2c3f7b5 |
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(), "_")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user