474 lines
14 KiB
Go
474 lines
14 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.
|
|
//
|
|
// 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"
|
|
}
|
|
if opts.NodeVersion == "" {
|
|
opts.NodeVersion = "22"
|
|
}
|
|
if len(opts.Environments) == 0 {
|
|
opts.Environments = sortedEnvKeys(cfg)
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
writeDeployHeader(&b, cfg, opts)
|
|
writeDeployJobs(&b, cfg, opts)
|
|
|
|
return b.String(), nil
|
|
}
|
|
|
|
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\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")
|
|
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 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")
|
|
|
|
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{
|
|
"uses": "docker/login-action@v2",
|
|
"with": map[string]any{
|
|
"registry": cfg.Registry.URL,
|
|
"username": "${{ secrets.DOCKER_USERNAME }}",
|
|
"password": "${{ secrets.DOCKER_PASSWORD }}",
|
|
},
|
|
})
|
|
|
|
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,
|
|
},
|
|
})
|
|
|
|
if cfg.ActionRef != "" {
|
|
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, "Create short commit hash", map[string]any{
|
|
"run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`,
|
|
})
|
|
|
|
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",
|
|
})
|
|
|
|
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
|
|
|
|
writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{
|
|
"run": "kforge validate",
|
|
"env": giteaNodeIPEnv(),
|
|
})
|
|
|
|
writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{
|
|
"run": fmt.Sprintf("kforge secrets apply --env %s", envKey),
|
|
"env": giteaKubeEnv(),
|
|
})
|
|
|
|
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": giteaNodeIPEnv(),
|
|
})
|
|
|
|
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,
|
|
),
|
|
},
|
|
})
|
|
|
|
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,
|
|
),
|
|
},
|
|
})
|
|
}
|
|
|
|
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,
|
|
),
|
|
},
|
|
})
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// 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,
|
|
},
|
|
})
|
|
|
|
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.
|
|
func writeStep(b *strings.Builder, name string, fields map[string]any) {
|
|
fmt.Fprintf(b, "\n - name: %s\n", name)
|
|
order := []string{"if", "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) {
|
|
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+" ")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
"KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}",
|
|
}
|
|
}
|
|
|
|
// 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 }}",
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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)
|
|
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
|
|
}
|