more updates
Publish Action Image / build (push) Successful in 1m8s

This commit is contained in:
2026-06-29 15:14:55 +10:00
parent 01223c176f
commit 4ee1b9e13c
15 changed files with 1155 additions and 832 deletions
+149 -57
View File
@@ -23,9 +23,8 @@ type GiteaActionsOptions struct {
// that builds the Docker image and deploys to each environment
// using kforge generate + kubectl apply.
//
// The generated file is designed to replace your existing
// hand-written workflow. It assumes kforge is available in the
// PATH (either pre-installed on the runner or fetched as a step).
// DNS is handled by external-dns via Ingress annotations — no
// separate DNS step is needed in the workflow.
func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (string, error) {
if opts.Branch == "" {
opts.Branch = "main"
@@ -39,20 +38,19 @@ func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (s
var b strings.Builder
writeGiteaHeader(&b, cfg, opts)
writeGiteaJobs(&b, cfg, opts)
writeDeployHeader(&b, cfg, opts)
writeDeployJobs(&b, cfg, opts)
return b.String(), nil
}
func writeGiteaHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
func writeDeployHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
fmt.Fprintf(b, "# Generated by kforge — do not edit manually.\n")
fmt.Fprintf(b, "# Re-generate: kforge gitea-actions > .gitea/workflows/deploy.yml\n")
fmt.Fprintf(b, "#\n")
fmt.Fprintf(b, "# Required Gitea org secrets:\n")
fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD, KFORGE_NODE_IP\n")
fmt.Fprintf(b, "# CLOUDFLARE_API_TOKEN, CF_ZONE_ID_* (per zone)\n")
fmt.Fprintf(b, "# SOPS_AGE_KEY\n")
fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD\n")
fmt.Fprintf(b, "# KFORGE_NODE_IP (external-dns annotation target)\n")
fmt.Fprintf(b, "# Required Gitea repo secrets:\n")
fmt.Fprintf(b, "# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n")
fmt.Fprintf(b, "\n")
@@ -65,30 +63,21 @@ func writeGiteaHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaAc
fmt.Fprintf(b, "\n")
}
func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
func writeDeployJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
fmt.Fprintf(b, "jobs:\n")
fmt.Fprintf(b, " build-and-deploy:\n")
fmt.Fprintf(b, " runs-on: ubuntu-latest\n")
fmt.Fprintf(b, " steps:\n")
// Checkout
writeStep(b, "Checkout", map[string]any{
"uses": "actions/checkout@v4",
"with": map[string]any{"fetch-depth": 0},
})
// Node (optional — only if package.json exists)
writeStep(b, "Setup Node", map[string]any{
"uses": "actions/setup-node@v4",
"with": map[string]any{"node-version": opts.NodeVersion},
})
// Short SHA
writeStep(b, "Create short commit hash", map[string]any{
"run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`,
})
// Docker login
writeStep(b, "Login to registry", map[string]any{
"uses": "docker/login-action@v2",
"with": map[string]any{
@@ -98,7 +87,6 @@ func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActi
},
})
// Docker build + push
fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name
writeStep(b, "Build and push image", map[string]any{
"uses": "docker/build-push-action@v5",
@@ -113,12 +101,10 @@ func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActi
},
})
// Install kforge on runner
writeStep(b, "Install kforge", map[string]any{
"run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge",
})
// Per-environment deploy steps
for _, envKey := range opts.Environments {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
@@ -129,45 +115,26 @@ func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActi
}
func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *config.ResolvedEnvironment, envKey string) {
label := strings.Title(envKey) //nolint:staticcheck // simple capitalisation
label := strings.Title(envKey) //nolint:staticcheck
// Validate kforge config before doing anything destructive.
writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{
"run": "kforge validate",
"env": giteaSecretEnv(),
"env": giteaNodeIPEnv(),
})
// Apply cluster secrets (only creates if missing — idempotent).
writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{
"run": fmt.Sprintf("kforge secrets apply --env %s", envKey),
"env": giteaKubeEnv(),
})
// DNS records
hasDNSHosts := false
for _, h := range env.Ingress.Hosts {
if h.DNSRecord {
hasDNSHosts = true
break
}
}
if hasDNSHosts && !cfg.DNS.SkipDNS {
writeStep(b, fmt.Sprintf("Ensure DNS records (%s)", label), map[string]any{
"run": fmt.Sprintf("kforge dns ensure --env %s", envKey),
"env": mergeMaps(giteaSecretEnv(), giteaKubeEnv()),
})
}
// Generate manifests
writeStep(b, fmt.Sprintf("Generate manifests (%s)", label), map[string]any{
"run": fmt.Sprintf(
"kforge generate --env %s --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}",
envKey,
),
"env": giteaSecretEnv(),
"env": giteaNodeIPEnv(),
})
// kubectl apply
writeStep(b, fmt.Sprintf("Apply manifests (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
@@ -179,7 +146,6 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
},
})
// Apply infra manifests if any infrastructure is enabled
infra := env.Infrastructure
if infra.Database != nil || infra.Cache != nil || infra.Storage != nil ||
infra.Queue != nil || infra.Search != nil || infra.Monitoring != nil {
@@ -195,7 +161,6 @@ func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *conf
})
}
// Rollout restart
writeStep(b, fmt.Sprintf("Rollout restart (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
@@ -208,11 +173,143 @@ 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 (same as deploy workflow):\n")
fmt.Fprintf(&b, "# DOCKER_USERNAME, DOCKER_PASSWORD\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},
})
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, "Build and push preview image", map[string]any{
"if": "${{ github.event.action != 'closed' }}",
"uses": "docker/build-push-action@v5",
"with": 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,
},
})
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": fmt.Sprintf(
"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.
func writeStep(b *strings.Builder, name string, fields map[string]any) {
fmt.Fprintf(b, "\n - name: %s\n", name)
// Emit fields in a stable order.
order := []string{"uses", "run", "with", "env"}
order := []string{"if", "uses", "run", "with", "env"}
for _, k := range order {
v, ok := fields[k]
if !ok {
@@ -240,7 +337,6 @@ func writeStep(b *strings.Builder, name string, fields map[string]any) {
}
func writeMapFields(b *strings.Builder, m map[string]any, indent string) {
// Sort keys for stable output.
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
@@ -270,13 +366,11 @@ func writeMapFields(b *strings.Builder, m map[string]any, indent string) {
}
}
// giteaSecretEnv returns the env block referencing Gitea secrets
// needed for kforge itself (DNS, registry tokens, etc.).
func giteaSecretEnv() map[string]any {
// giteaNodeIPEnv returns env vars needed for kforge commands that
// resolve ${KFORGE_NODE_IP} tokens (validate, generate).
func giteaNodeIPEnv() map[string]any {
return map[string]any{
"CLOUDFLARE_API_TOKEN": "${{ secrets.CLOUDFLARE_API_TOKEN }}",
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
"SOPS_AGE_KEY": "${{ secrets.SOPS_AGE_KEY }}",
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
}
}
@@ -301,8 +395,6 @@ func mergeMaps(maps ...map[string]any) map[string]any {
func sortedEnvKeys(cfg *config.KforgeConfig) []string {
keys := config.EnvironmentKeys(cfg)
// Put staging/dev before production — a simple heuristic that
// matches the most common deploy order.
priority := map[string]int{"dev": 0, "development": 0, "staging": 1, "production": 2, "prod": 2}
sort.Slice(keys, func(i, j int) bool {
pi, pj := priority[keys[i]], priority[keys[j]]
+80 -28
View File
@@ -2,7 +2,6 @@ package generator
import (
"fmt"
"strings"
"kforge/internal/config"
"kforge/pkg/interpolate"
@@ -79,18 +78,22 @@ type InfraManifest struct {
}
// ------------------------------------------------------------
// Database — CNPG
// Database — CNPG (centralized cluster)
// ------------------------------------------------------------
func generateDatabase(env *config.ResolvedEnvironment, db *config.DatabaseInfraConfig) ([]InfraManifest, error) {
dbName := db.DatabaseName
if dbName == "" {
dbName = interpolate.Slug(env.FullName)
dbName = interpolate.PGIdentifier(env.FullName)
}
roleName := dbName + "_role"
// pgUser must be a valid unquoted PostgreSQL identifier; same as
// the username stored in the db-credentials Secret by `kforge secrets apply`.
pgUser := interpolate.PGIdentifier(env.FullName)
secretName := env.FullName + "-db-credentials"
cnpgHost := env.CNPGHost
// CNPG Database CR
// CNPG Database CR — declaratively manages the database lifecycle.
// The owner role is created by the db-init Job below.
dbManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
@@ -103,47 +106,96 @@ spec:
name: %s
owner: %s
cluster:
name: cnpg-main
`, dbName, env.Namespace, env.FullName, dbName, roleName)
name: %s
`, dbName, env.Namespace, env.FullName, dbName, pgUser, env.CNPGClusterName)
// CNPG Role CR — CNPG creates and rotates the password,
// storing it in the secret named below.
roleManifest := fmt.Sprintf(`apiVersion: postgresql.cnpg.io/v1
kind: DatabaseRole
// db-init Job — runs on every deploy to ensure the PostgreSQL role
// exists and its password matches the Secret. The password stored by
// `kforge secrets apply` is alphanumeric-only so it is safe to
// embed in a shell command without additional escaping.
//
// 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:
name: %s
name: %s-db-init
namespace: %s
labels:
app: %s
managed-by: kforge
spec:
name: %s
passwordSecret:
name: %s
login: true
superuser: false
createdb: false
`, roleName, env.Namespace, env.FullName, roleName, secretName)
// The env vars reference the CNPG-managed secret.
// CNPG populates: username, password keys in the secret.
// We assemble DATABASE_URL from the known CNPG host + db name.
cnpgHost := env.CNPGHost
dbURL := fmt.Sprintf("postgresql://$(%s_USER):$(%s_PASSWORD)@%s/%s",
strings.ToUpper(env.FullName), strings.ToUpper(env.FullName), cnpgHost, dbName)
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: %s
spec:
restartPolicy: OnFailure
containers:
- name: db-init
image: postgres:16-alpine
command:
- /bin/sh
- -c
- |
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{
{Name: "DB_HOST", Type: config.EnvVarTypePlain, Value: cnpgHost},
{Name: "DB_PORT", Type: config.EnvVarTypePlain, Value: "5432"},
{Name: "DB_NAME", Type: config.EnvVarTypePlain, Value: dbName},
{Name: "DB_USER", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "username"},
{Name: "DB_PASSWORD", Type: config.EnvVarTypeSecretRef, SecretName: secretName, SecretKey: "password"},
{Name: "DATABASE_URL", Type: config.EnvVarTypePlain, Value: dbURL},
{Name: "DATABASE_URL", Type: config.EnvVarTypePlain,
Value: fmt.Sprintf("postgresql://$(DB_USER):$(DB_PASSWORD)@%s/%s", cnpgHost, dbName)},
}
return []InfraManifest{
{Name: "cnpg-database", Content: dbManifest, EnvVars: envVars},
{Name: "cnpg-role", Content: roleManifest},
{Name: "cnpg-db-init", Content: jobManifest},
}, nil
}
+36
View File
@@ -181,6 +181,22 @@ func Ingress(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, err
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")
fmt.Fprintf(&b, " ingressClassName: %s\n", env.IngressClass)
@@ -405,6 +421,26 @@ func renderStrSlice(ss []string) string {
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.
func render(t *template.Template, data any) (string, error) {
var buf bytes.Buffer