Files
kforge/internal/generator/gitea_actions.go
T
nate.lubitz 532b912ffb
Build and Deploy / build-and-deploy (push) Failing after 20s
add tool to gitea
2026-06-05 02:34:00 +10:00

316 lines
9.3 KiB
Go

package generator
import (
"fmt"
"sort"
"strings"
"kforge/internal/config"
)
// GiteaActionsOptions controls what the generated workflow does.
type GiteaActionsOptions struct {
// Branch that triggers the workflow. Default: main.
Branch string
// NodeVersion for setup-node. Default: 22.
NodeVersion string
// Environments to deploy, in order. Default: all environments
// sorted alphabetically (staging before production).
Environments []string
}
// GenerateGiteaActions produces a Gitea Actions workflow YAML
// that builds the Docker image and deploys to each environment
// using kforge generate + kubectl apply.
//
// The generated file is designed to replace your existing
// hand-written workflow. It assumes kforge is available in the
// PATH (either pre-installed on the runner or fetched as a step).
func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (string, error) {
if opts.Branch == "" {
opts.Branch = "main"
}
if opts.NodeVersion == "" {
opts.NodeVersion = "22"
}
if len(opts.Environments) == 0 {
opts.Environments = sortedEnvKeys(cfg)
}
var b strings.Builder
writeGiteaHeader(&b, cfg, opts)
writeGiteaJobs(&b, cfg, opts)
return b.String(), nil
}
func writeGiteaHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
fmt.Fprintf(b, "# Generated by kforge — do not edit manually.\n")
fmt.Fprintf(b, "# Re-generate: kforge gitea-actions > .gitea/workflows/deploy.yml\n")
fmt.Fprintf(b, "#\n")
fmt.Fprintf(b, "# Required Gitea org secrets:\n")
fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD, KFORGE_NODE_IP\n")
fmt.Fprintf(b, "# CLOUDFLARE_API_TOKEN, CF_ZONE_ID_* (per zone)\n")
fmt.Fprintf(b, "# SOPS_AGE_KEY\n")
fmt.Fprintf(b, "# Required Gitea repo secrets:\n")
fmt.Fprintf(b, "# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n")
fmt.Fprintf(b, "\n")
fmt.Fprintf(b, "name: Build and Deploy\n")
fmt.Fprintf(b, "\n")
fmt.Fprintf(b, "on:\n")
fmt.Fprintf(b, " push:\n")
fmt.Fprintf(b, " branches:\n")
fmt.Fprintf(b, " - %s\n", opts.Branch)
fmt.Fprintf(b, "\n")
}
func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) {
fmt.Fprintf(b, "jobs:\n")
fmt.Fprintf(b, " build-and-deploy:\n")
fmt.Fprintf(b, " runs-on: ubuntu-latest\n")
fmt.Fprintf(b, " steps:\n")
// Checkout
writeStep(b, "Checkout", map[string]any{
"uses": "actions/checkout@v4",
"with": map[string]any{"fetch-depth": 0},
})
// Node (optional — only if package.json exists)
writeStep(b, "Setup Node", map[string]any{
"uses": "actions/setup-node@v4",
"with": map[string]any{"node-version": opts.NodeVersion},
})
// Short SHA
writeStep(b, "Create short commit hash", map[string]any{
"run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`,
})
// Docker login
writeStep(b, "Login to registry", map[string]any{
"uses": "docker/login-action@v2",
"with": map[string]any{
"registry": cfg.Registry.URL,
"username": "${{ secrets.DOCKER_USERNAME }}",
"password": "${{ secrets.DOCKER_PASSWORD }}",
},
})
// Docker build + push
fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name
writeStep(b, "Build and push image", map[string]any{
"uses": "docker/build-push-action@v5",
"with": map[string]any{
"context": ".",
"platforms": "linux/amd64",
"file": cfg.Defaults.Dockerfile,
"push": true,
"tags": fmt.Sprintf("%s:latest\n%s:${{ env.SHORT_SHA }}", fullRepo, fullRepo),
"provenance": false,
"sbom": false,
},
})
// Install kforge on runner
writeStep(b, "Install kforge", map[string]any{
"run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge",
})
// Per-environment deploy steps
for _, envKey := range opts.Environments {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
continue
}
writeEnvDeploySteps(b, cfg, &env, envKey)
}
}
func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *config.ResolvedEnvironment, envKey string) {
label := strings.Title(envKey) //nolint:staticcheck // simple capitalisation
// Validate kforge config before doing anything destructive.
writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{
"run": "kforge validate",
"env": giteaSecretEnv(),
})
// Apply cluster secrets (only creates if missing — idempotent).
writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{
"run": fmt.Sprintf("kforge secrets apply --env %s", envKey),
"env": giteaKubeEnv(),
})
// DNS records
hasDNSHosts := false
for _, h := range env.Ingress.Hosts {
if h.DNSRecord {
hasDNSHosts = true
break
}
}
if hasDNSHosts && !cfg.DNS.SkipDNS {
writeStep(b, fmt.Sprintf("Ensure DNS records (%s)", label), map[string]any{
"run": fmt.Sprintf("kforge dns ensure --env %s", envKey),
"env": mergeMaps(giteaSecretEnv(), giteaKubeEnv()),
})
}
// Generate manifests
writeStep(b, fmt.Sprintf("Generate manifests (%s)", label), map[string]any{
"run": fmt.Sprintf(
"kforge generate --env %s --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}",
envKey,
),
"env": giteaSecretEnv(),
})
// kubectl apply
writeStep(b, fmt.Sprintf("Apply manifests (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
"apply -f .kforge-out/%s-core.yaml -n %s --insecure-skip-tls-verify",
envKey, env.Namespace,
),
},
})
// Apply infra manifests if any infrastructure is enabled
infra := env.Infrastructure
if infra.Database != nil || infra.Cache != nil || infra.Storage != nil ||
infra.Queue != nil || infra.Search != nil || infra.Monitoring != nil {
writeStep(b, fmt.Sprintf("Apply infra manifests (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
`apply -f .kforge-out/ -l app=%s -n %s --insecure-skip-tls-verify`,
env.FullName, env.Namespace,
),
},
})
}
// Rollout restart
writeStep(b, fmt.Sprintf("Rollout restart (%s)", label), map[string]any{
"uses": "actions-hub/kubectl@master",
"env": giteaKubeEnv(),
"with": map[string]any{
"args": fmt.Sprintf(
"rollout restart deployment/%s -n %s --insecure-skip-tls-verify",
env.FullName, env.Namespace,
),
},
})
}
// writeStep writes a single step in the jobs.steps list.
func writeStep(b *strings.Builder, name string, fields map[string]any) {
fmt.Fprintf(b, "\n - name: %s\n", name)
// Emit fields in a stable order.
order := []string{"uses", "run", "with", "env"}
for _, k := range order {
v, ok := fields[k]
if !ok {
continue
}
switch val := v.(type) {
case string:
if strings.Contains(val, "\n") {
fmt.Fprintf(b, " %s: |\n", k)
for _, line := range strings.Split(val, "\n") {
fmt.Fprintf(b, " %s\n", line)
}
} else {
fmt.Fprintf(b, " %s: %s\n", k, val)
}
case bool:
fmt.Fprintf(b, " %s: %v\n", k, val)
case int:
fmt.Fprintf(b, " %s: %d\n", k, val)
case map[string]any:
fmt.Fprintf(b, " %s:\n", k)
writeMapFields(b, val, " ")
}
}
}
func writeMapFields(b *strings.Builder, m map[string]any, indent string) {
// Sort keys for stable output.
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := m[k]
switch val := v.(type) {
case string:
if strings.Contains(val, "\n") {
fmt.Fprintf(b, "%s%s: |\n", indent, k)
for _, line := range strings.Split(val, "\n") {
fmt.Fprintf(b, "%s %s\n", indent, line)
}
} else {
fmt.Fprintf(b, "%s%s: %s\n", indent, k, val)
}
case bool:
fmt.Fprintf(b, "%s%s: %v\n", indent, k, val)
case int:
fmt.Fprintf(b, "%s%s: %d\n", indent, k, val)
case map[string]any:
fmt.Fprintf(b, "%s%s:\n", indent, k)
writeMapFields(b, val, indent+" ")
}
}
}
// giteaSecretEnv returns the env block referencing Gitea secrets
// needed for kforge itself (DNS, registry tokens, etc.).
func giteaSecretEnv() map[string]any {
return map[string]any{
"CLOUDFLARE_API_TOKEN": "${{ secrets.CLOUDFLARE_API_TOKEN }}",
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
"SOPS_AGE_KEY": "${{ secrets.SOPS_AGE_KEY }}",
}
}
// giteaKubeEnv returns the env block for kubectl auth.
func giteaKubeEnv() map[string]any {
return map[string]any{
"KUBE_CERTIFICATE": "${{ secrets.KUBE_CERTIFICATE }}",
"KUBE_HOST": "${{ secrets.KUBE_HOST }}",
"KUBE_TOKEN": "${{ secrets.KUBE_TOKEN }}",
}
}
func mergeMaps(maps ...map[string]any) map[string]any {
result := map[string]any{}
for _, m := range maps {
for k, v := range m {
result[k] = v
}
}
return result
}
func sortedEnvKeys(cfg *config.KforgeConfig) []string {
keys := config.EnvironmentKeys(cfg)
// Put staging/dev before production — a simple heuristic that
// matches the most common deploy order.
priority := map[string]int{"dev": 0, "development": 0, "staging": 1, "production": 2, "prod": 2}
sort.Slice(keys, func(i, j int) bool {
pi, pj := priority[keys[i]], priority[keys[j]]
if pi != pj {
return pi < pj
}
return keys[i] < keys[j]
})
return keys
}