Files
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

416 lines
13 KiB
Go

// Package generator produces Kubernetes manifest strings from
// ResolvedEnvironment values. Each generator returns a YAML
// string ready to be written to a file or piped to kubectl.
package generator
import (
"bytes"
"fmt"
"strings"
"text/template"
"kforge/internal/config"
"kforge/pkg/interpolate"
)
const Separator = "---\n"
// GenerateAll produces the complete set of manifests for one
// environment as a single concatenated YAML string.
func GenerateAll(env *config.ResolvedEnvironment, cfg *config.KforgeConfig) (string, error) {
tokens := interpolate.FromEnvironment(
cfg.Meta.Name,
cfg.Meta.Tenant,
env.EnvKey,
env.EnvPrefix,
env.FullName,
env.Image,
env.Namespace,
)
var parts []string
svc, err := Service(env, tokens)
if err != nil {
return "", fmt.Errorf("service: %w", err)
}
parts = append(parts, svc)
dep, err := Deployment(env, tokens)
if err != nil {
return "", fmt.Errorf("deployment: %w", err)
}
parts = append(parts, dep)
ing, err := Ingress(env, tokens)
if err != nil {
return "", fmt.Errorf("ingress: %w", err)
}
parts = append(parts, ing)
for _, host := range env.Ingress.Hosts {
if !host.TLS {
continue
}
cert, err := Certificate(env, host, tokens)
if err != nil {
return "", fmt.Errorf("certificate for %s: %w", host.Hostname, err)
}
parts = append(parts, cert)
}
if env.Ingress.Auth.Enabled {
parts = append(parts, fmt.Sprintf(
"# Basic auth secret %q — run: kforge secrets apply --env %s\n",
env.Ingress.Auth.SecretName, env.EnvKey,
))
}
for _, job := range env.CronJobs {
cj, err := CronJob(env, &job, tokens)
if err != nil {
return "", fmt.Errorf("cronjob %s: %w", job.Name, err)
}
parts = append(parts, cj)
}
return strings.Join(parts, Separator), nil
}
// ------------------------------------------------------------
// Service
// ------------------------------------------------------------
var serviceTmpl = template.Must(template.New("service").Parse(`apiVersion: v1
kind: Service
metadata:
name: {{ .FullName }}
namespace: {{ .Namespace }}
labels:
app: {{ .FullName }}
managed-by: kforge
spec:
type: {{ .ServiceType }}
ports:
- port: {{ .Port }}
protocol: TCP
targetPort: {{ .Port }}
selector:
app: {{ .FullName }}
`))
func Service(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, error) {
return render(serviceTmpl, env)
}
// ------------------------------------------------------------
// Deployment
// ------------------------------------------------------------
func Deployment(env *config.ResolvedEnvironment, tokens interpolate.Tokens) (string, error) {
// Resolve ${tenant}/${name} tokens in the image string.
image := interpolate.Apply(env.Image, tokens)
var b strings.Builder
b.WriteString("apiVersion: apps/v1\n")
b.WriteString("kind: Deployment\n")
b.WriteString("metadata:\n")
fmt.Fprintf(&b, " name: %s\n", env.FullName)
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" managed-by: kforge\n")
b.WriteString("spec:\n")
fmt.Fprintf(&b, " replicas: %d\n", env.Replicas)
b.WriteString(" selector:\n")
b.WriteString(" matchLabels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" template:\n")
b.WriteString(" metadata:\n")
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" spec:\n")
b.WriteString(" containers:\n")
fmt.Fprintf(&b, " - name: %s\n", env.FullName)
fmt.Fprintf(&b, " image: %s\n", image)
fmt.Fprintf(&b, " imagePullPolicy: %s\n", env.ImagePullPolicy)
b.WriteString(" ports:\n")
fmt.Fprintf(&b, " - containerPort: %d\n", env.Port)
if ev := renderEnvVarLines(env.EnvVars, " "); ev != "" {
b.WriteString(" env:\n")
b.WriteString(ev)
}
if lp := renderProbeLines(env.HealthCheck, "liveness", " "); lp != "" {
b.WriteString(lp)
}
if rp := renderProbeLines(env.HealthCheck, "readiness", " "); rp != "" {
b.WriteString(rp)
}
b.WriteString(renderResourceLines(env.Resources, " "))
b.WriteString(" imagePullSecrets:\n")
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
return b.String(), nil
}
// ------------------------------------------------------------
// Ingress
// ------------------------------------------------------------
func Ingress(env *config.ResolvedEnvironment, _ interpolate.Tokens) (string, error) {
var b strings.Builder
b.WriteString("apiVersion: networking.k8s.io/v1\n")
b.WriteString("kind: Ingress\n")
b.WriteString("metadata:\n")
fmt.Fprintf(&b, " name: %s-ingress\n", env.FullName)
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" managed-by: kforge\n")
b.WriteString(" annotations:\n")
b.WriteString(" nginx.ingress.kubernetes.io/ssl-redirect: \"true\"\n")
if env.Ingress.Auth.Enabled {
b.WriteString(" nginx.ingress.kubernetes.io/auth-type: basic\n")
fmt.Fprintf(&b, " nginx.ingress.kubernetes.io/auth-secret: %s\n", env.Ingress.Auth.SecretName)
b.WriteString(" nginx.ingress.kubernetes.io/auth-realm: \"Authentication Required\"\n")
}
b.WriteString("spec:\n")
fmt.Fprintf(&b, " ingressClassName: %s\n", env.IngressClass)
// TLS block
hasTLS := false
for _, h := range env.Ingress.Hosts {
if h.TLS {
hasTLS = true
break
}
}
if hasTLS {
b.WriteString(" tls:\n")
for _, h := range env.Ingress.Hosts {
if !h.TLS {
continue
}
secretName := fmt.Sprintf("%s-tls-%s", env.FullName, interpolate.HostSlug(h.Hostname))
b.WriteString(" - hosts:\n")
fmt.Fprintf(&b, " - %s\n", h.Hostname)
fmt.Fprintf(&b, " secretName: %s\n", secretName)
}
}
// Rules block
b.WriteString(" rules:\n")
for _, h := range env.Ingress.Hosts {
fmt.Fprintf(&b, " - host: %s\n", h.Hostname)
b.WriteString(" http:\n")
b.WriteString(" paths:\n")
b.WriteString(" - path: /\n")
b.WriteString(" pathType: Prefix\n")
b.WriteString(" backend:\n")
b.WriteString(" service:\n")
fmt.Fprintf(&b, " name: %s\n", env.FullName)
b.WriteString(" port:\n")
fmt.Fprintf(&b, " number: %d\n", env.Port)
}
return b.String(), nil
}
// ------------------------------------------------------------
// Certificate (cert-manager)
// ------------------------------------------------------------
func Certificate(env *config.ResolvedEnvironment, host config.IngressHost, _ interpolate.Tokens) (string, error) {
secretName := fmt.Sprintf("%s-tls-%s", env.FullName, interpolate.HostSlug(host.Hostname))
t := template.Must(template.New("cert").Parse(`apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ .SecretName }}
namespace: {{ .Namespace }}
labels:
app: {{ .FullName }}
managed-by: kforge
spec:
secretName: {{ .SecretName }}
issuerRef:
name: {{ .TLSIssuer }}
kind: ClusterIssuer
dnsNames:
- {{ .Hostname }}
`))
data := struct {
SecretName string
Namespace string
FullName string
TLSIssuer string
Hostname string
}{
SecretName: secretName,
Namespace: env.Namespace,
FullName: env.FullName,
TLSIssuer: env.TLSIssuer,
Hostname: host.Hostname,
}
return render(t, data)
}
// ------------------------------------------------------------
// CronJob
// ------------------------------------------------------------
func CronJob(env *config.ResolvedEnvironment, job *config.ResolvedCronJob, tokens interpolate.Tokens) (string, error) {
cronName := fmt.Sprintf("%s-cron-%s", env.FullName, interpolate.Slug(job.Name))
image := interpolate.Apply(job.Image, tokens)
successHist := 3
if job.SuccessfulJobsHistoryLimit != nil {
successHist = *job.SuccessfulJobsHistoryLimit
}
failHist := 1
if job.FailedJobsHistoryLimit != nil {
failHist = *job.FailedJobsHistoryLimit
}
var b strings.Builder
b.WriteString("apiVersion: batch/v1\n")
b.WriteString("kind: CronJob\n")
b.WriteString("metadata:\n")
fmt.Fprintf(&b, " name: %s\n", cronName)
fmt.Fprintf(&b, " namespace: %s\n", env.Namespace)
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" managed-by: kforge\n")
b.WriteString("spec:\n")
fmt.Fprintf(&b, " schedule: %q\n", job.Schedule)
fmt.Fprintf(&b, " concurrencyPolicy: %s\n", job.ConcurrencyPolicy)
fmt.Fprintf(&b, " successfulJobsHistoryLimit: %d\n", successHist)
fmt.Fprintf(&b, " failedJobsHistoryLimit: %d\n", failHist)
b.WriteString(" jobTemplate:\n")
b.WriteString(" spec:\n")
b.WriteString(" template:\n")
b.WriteString(" metadata:\n")
b.WriteString(" labels:\n")
fmt.Fprintf(&b, " app: %s\n", env.FullName)
b.WriteString(" spec:\n")
fmt.Fprintf(&b, " restartPolicy: %s\n", job.RestartPolicy)
b.WriteString(" containers:\n")
fmt.Fprintf(&b, " - name: %s\n", cronName)
fmt.Fprintf(&b, " image: %s\n", image)
b.WriteString(" imagePullPolicy: Always\n")
fmt.Fprintf(&b, " command: %s\n", renderStrSlice(job.Command))
if ev := renderEnvVarLines(job.EnvVars, " "); ev != "" {
b.WriteString(" env:\n")
b.WriteString(ev)
}
if job.Resources != nil {
b.WriteString(renderResourceLines(*job.Resources, " "))
}
b.WriteString(" imagePullSecrets:\n")
fmt.Fprintf(&b, " - name: %s\n", env.ImagePullSecret)
return b.String(), nil
}
// ------------------------------------------------------------
// Shared rendering helpers
// ------------------------------------------------------------
// renderEnvVarLines returns the env var list lines indented by pad.
// The caller writes the "env:" header line itself.
func renderEnvVarLines(vars []config.EnvVarConfig, pad string) string {
if len(vars) == 0 {
return ""
}
var b strings.Builder
for _, v := range vars {
switch v.Type {
case config.EnvVarTypeSecretRef:
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
fmt.Fprintf(&b, "%s valueFrom:\n", pad)
fmt.Fprintf(&b, "%s secretKeyRef:\n", pad)
fmt.Fprintf(&b, "%s name: %s\n", pad, v.SecretName)
fmt.Fprintf(&b, "%s key: %s\n", pad, v.SecretKey)
case config.EnvVarTypeConfigMapRef:
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
fmt.Fprintf(&b, "%s valueFrom:\n", pad)
fmt.Fprintf(&b, "%s configMapKeyRef:\n", pad)
fmt.Fprintf(&b, "%s name: %s\n", pad, v.ConfigMapName)
fmt.Fprintf(&b, "%s key: %s\n", pad, v.ConfigMapKey)
default: // plain
fmt.Fprintf(&b, "%s- name: %s\n", pad, v.Name)
fmt.Fprintf(&b, "%s value: %q\n", pad, v.Value)
}
}
return b.String()
}
// renderProbeLines returns a liveness or readiness probe block.
func renderProbeLines(hc config.HealthCheckConfig, kind string, pad string) string {
isLiveness := kind == "liveness"
if isLiveness && (hc.Liveness == nil || !*hc.Liveness) {
return ""
}
if !isLiveness && (hc.Readiness == nil || !*hc.Readiness) {
return ""
}
port := 0
if hc.Port != nil {
port = *hc.Port
}
key := kind + "Probe"
var b strings.Builder
fmt.Fprintf(&b, "%s%s:\n", pad, key)
fmt.Fprintf(&b, "%s httpGet:\n", pad)
fmt.Fprintf(&b, "%s path: %s\n", pad, hc.Path)
fmt.Fprintf(&b, "%s port: %d\n", pad, port)
fmt.Fprintf(&b, "%s initialDelaySeconds: %d\n", pad, hc.InitialDelaySeconds)
fmt.Fprintf(&b, "%s periodSeconds: %d\n", pad, hc.PeriodSeconds)
fmt.Fprintf(&b, "%s timeoutSeconds: %d\n", pad, hc.TimeoutSeconds)
fmt.Fprintf(&b, "%s failureThreshold: %d\n", pad, hc.FailureThreshold)
return b.String()
}
// renderResourceLines returns a resources block indented by pad.
func renderResourceLines(r config.ResourceConfig, pad string) string {
var b strings.Builder
fmt.Fprintf(&b, "%sresources:\n", pad)
fmt.Fprintf(&b, "%s requests:\n", pad)
fmt.Fprintf(&b, "%s cpu: %s\n", pad, r.Requests.CPU)
fmt.Fprintf(&b, "%s memory: %s\n", pad, r.Requests.Memory)
fmt.Fprintf(&b, "%s limits:\n", pad)
fmt.Fprintf(&b, "%s cpu: %s\n", pad, r.Limits.CPU)
fmt.Fprintf(&b, "%s memory: %s\n", pad, r.Limits.Memory)
return b.String()
}
// renderStrSlice formats a string slice as an inline YAML sequence.
func renderStrSlice(ss []string) string {
if len(ss) == 0 {
return "[]"
}
quoted := make([]string, len(ss))
for i, s := range ss {
quoted[i] = fmt.Sprintf("%q", s)
}
return "[" + strings.Join(quoted, ", ") + "]"
}
// render executes a template with data and returns the result.
func render(t *template.Template, data any) (string, error) {
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}