// 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 }