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
+71 -89
View File
@@ -4,88 +4,11 @@ import (
"fmt"
"os"
"kforge/internal/config"
dnsProvider "kforge/internal/dns"
"kforge/internal/generator"
"github.com/spf13/cobra"
)
// ------------------------------------------------------------
// kforge dns ensure
// ------------------------------------------------------------
var dnsCmd = &cobra.Command{
Use: "dns",
Short: "Manage DNS records for kforge environments",
}
var dnsEnsureEnvs []string
var dnsEnsureCmd = &cobra.Command{
Use: "ensure",
Short: "Create or update DNS A records for ingress hosts",
Long: `For each ingress host with dns_record: true, creates or updates
an A record pointing to KFORGE_NODE_IP.
Idempotent — safe to run on every deploy. Skips hosts where the
record already points to the correct IP.
Examples:
kforge dns ensure --env staging
kforge dns ensure --env staging --env production`,
RunE: runDNSEnsure,
}
func init() {
dnsEnsureCmd.Flags().StringArrayVarP(&dnsEnsureEnvs, "env", "e", nil,
"Environment(s) to ensure DNS records for (default: all)")
dnsCmd.AddCommand(dnsEnsureCmd)
rootCmd.AddCommand(dnsCmd)
}
func runDNSEnsure(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
if cfg.DNS.SkipDNS {
fmt.Println("dns.skip_dns is true — skipping DNS management")
return nil
}
nodeIP := cfg.DNS.NodeIP
if nodeIP == "" {
nodeIP = os.Getenv("KFORGE_NODE_IP")
}
if nodeIP == "" {
return fmt.Errorf("node IP not set: add dns.node_ip to kforge.yml or set KFORGE_NODE_IP")
}
provider, err := dnsProvider.NewProvider(cfg.DNS)
if err != nil {
return fmt.Errorf("initialising DNS provider: %w", err)
}
envKeys := dnsEnsureEnvs
if len(envKeys) == 0 {
envKeys = config.EnvironmentKeys(cfg)
}
for _, envKey := range envKeys {
fmt.Printf("\nEnsuring DNS records for: %s\n", envKey)
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
return err
}
if err := dnsProvider.EnsureRecordsForEnvironment(provider, &env, nodeIP); err != nil {
return fmt.Errorf("env %q: %w", envKey, err)
}
}
return nil
}
// ------------------------------------------------------------
// kforge gitea-actions
// ------------------------------------------------------------
@@ -98,16 +21,17 @@ var (
var giteaActionsCmd = &cobra.Command{
Use: "gitea-actions",
Short: "Generate a Gitea Actions workflow for this app",
Long: `Generates a complete .gitea/workflows/deploy.yml that:
- Builds and pushes the Docker image on every push to main
Short: "Generate a Gitea Actions deploy workflow for this app",
Long: `Generates .gitea/workflows/deploy.yml that:
- Builds and pushes the Docker image on push to main
- Runs kforge validate
- Applies cluster secrets (idempotent)
- Ensures DNS records
- Generates manifests and applies them with kubectl
- Rolls out the deployment
The generated workflow replaces your hand-written deploy.yml.
DNS is handled automatically by external-dns reading the Ingress
annotations that kforge writes — no separate DNS step needed.
Re-run whenever you add environments or change deploy options.
Examples:
@@ -142,29 +66,87 @@ func runGiteaActions(cmd *cobra.Command, args []string) error {
return fmt.Errorf("generating workflow: %w", err)
}
if giteaActionsOutput == "-" {
fmt.Print(workflow)
return writeWorkflow(giteaActionsOutput, workflow)
}
// ------------------------------------------------------------
// kforge gitea-preview
// ------------------------------------------------------------
var giteaPreviewOutput string
var giteaPreviewCmd = &cobra.Command{
Use: "gitea-preview",
Short: "Generate a Gitea Actions workflow for PR preview environments",
Long: `Generates .gitea/workflows/preview.yml that:
- On PR open/sync: builds a PR-tagged image, applies secrets,
generates manifests (including a Namespace), and deploys.
- On PR close: deletes the preview namespace and all resources.
Requires preview.enabled: true in kforge.yml.
Example kforge.yml preview block:
preview:
enabled: true
base_environment: staging
namespace_prefix: preview-pr
hostname_template: "pr-${PR_NUMBER}.${name}.example.com"
Examples:
kforge gitea-preview
kforge gitea-preview --output .gitea/workflows/preview.yml`,
RunE: runGiteaPreview,
}
func init() {
giteaPreviewCmd.Flags().StringVarP(&giteaPreviewOutput, "output", "o",
".gitea/workflows/preview.yml",
"Output path for the generated preview workflow file")
rootCmd.AddCommand(giteaPreviewCmd)
}
func runGiteaPreview(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
workflow, err := generator.GeneratePreviewActions(cfg)
if err != nil {
return fmt.Errorf("generating preview workflow: %w", err)
}
return writeWorkflow(giteaPreviewOutput, workflow)
}
// ------------------------------------------------------------
// Shared helpers
// ------------------------------------------------------------
func writeWorkflow(path, content string) error {
if path == "-" {
fmt.Print(content)
return nil
}
// Ensure parent directory exists.
dir := giteaActionsOutput
dir := path
for i := len(dir) - 1; i >= 0; i-- {
if dir[i] == '/' {
if dir[i] == '/' || dir[i] == '\\' {
dir = dir[:i]
break
}
}
if dir != giteaActionsOutput {
if dir != path {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
}
if err := os.WriteFile(giteaActionsOutput, []byte(workflow), 0o644); err != nil {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("writing workflow: %w", err)
}
fmt.Printf("✓ Gitea Actions workflow written to %s\n", giteaActionsOutput)
fmt.Printf("✓ Workflow written to %s\n", path)
return nil
}
+105 -32
View File
@@ -13,27 +13,32 @@ import (
)
var (
generateEnvs []string
generateOutput string
generateDry bool
generateEnvs []string
generateOutput string
generateDry bool
generatePRNumber string
)
var generateCmd = &cobra.Command{
Use: "generate",
Short: "Generate Kubernetes manifests from kforge.yml",
Long: `Reads kforge.yml (or the file specified with --config), resolves
each requested environment, and writes flat Kubernetes manifest
files to the output directory.
Long: `Reads kforge.yml, resolves each requested environment, and writes
flat Kubernetes manifest files to the output directory.
If no --env flags are given, manifests are generated for all
environments defined in kforge.yml.
Use --pr-number to generate manifests for a PR preview environment.
A Namespace manifest is prepended so kubectl apply creates the
namespace automatically.
Examples:
kforge generate
kforge generate --env staging
kforge generate --env staging --env production
kforge generate --env production --output .kube/
kforge generate --dry-run`,
kforge generate --dry-run
kforge generate --pr-number 42`,
RunE: runGenerate,
}
@@ -44,6 +49,8 @@ func init() {
"Directory to write generated manifests into")
generateCmd.Flags().BoolVar(&generateDry, "dry-run", false,
"Print manifests to stdout instead of writing files")
generateCmd.Flags().StringVar(&generatePRNumber, "pr-number", "",
"PR number — synthesizes and generates a preview environment")
rootCmd.AddCommand(generateCmd)
}
@@ -53,6 +60,15 @@ func runGenerate(cmd *cobra.Command, args []string) error {
return err
}
// Preview mode: synthesize the preview environment.
if generatePRNumber != "" {
env, err := config.SynthesizePreviewEnvironment(cfg, generatePRNumber)
if err != nil {
return fmt.Errorf("synthesizing preview environment: %w", err)
}
return generateForPreview(cfg, &env, generatePRNumber)
}
envKeys := generateEnvs
if len(envKeys) == 0 {
envKeys = config.EnvironmentKeys(cfg)
@@ -78,30 +94,9 @@ func generateForEnv(cfg *config.KforgeConfig, envKey string) error {
return err
}
// Core manifests (Service, Deployment, Ingress, Certs).
coreYAML, err := generator.GenerateAll(&env, cfg)
coreYAML, infraManifests, err := buildManifests(cfg, &env)
if err != nil {
return fmt.Errorf("generating core manifests: %w", err)
}
// Infrastructure manifests.
infraManifests, err := generator.GenerateInfrastructure(&env)
if err != nil {
return fmt.Errorf("generating infrastructure manifests: %w", err)
}
// Collect all infrastructure env vars and append to deployment.
// We re-generate core manifests after injecting infra env vars.
var infraEnvVars []config.EnvVarConfig
for _, m := range infraManifests {
infraEnvVars = append(infraEnvVars, m.EnvVars...)
}
if len(infraEnvVars) > 0 {
env.EnvVars = config.MergeEnvVars(env.EnvVars, infraEnvVars)
coreYAML, err = generator.GenerateAll(&env, cfg)
if err != nil {
return fmt.Errorf("re-generating core manifests with infra vars: %w", err)
}
return err
}
if generateDry {
@@ -112,14 +107,16 @@ func generateForEnv(cfg *config.KforgeConfig, envKey string) error {
return nil
}
// Write core manifest.
if err := os.MkdirAll(generateOutput, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
coreFile := filepath.Join(generateOutput, envKey+"-core.yaml")
if err := os.WriteFile(coreFile, []byte(coreYAML), 0o644); err != nil {
return fmt.Errorf("writing core manifest: %w", err)
}
fmt.Printf(" ✓ %s\n", coreFile)
// Write infra manifests.
for _, m := range infraManifests {
infraFile := filepath.Join(generateOutput, envKey+"-infra-"+m.Name+".yaml")
if err := os.WriteFile(infraFile, []byte(m.Content), 0o644); err != nil {
@@ -131,6 +128,82 @@ func generateForEnv(cfg *config.KforgeConfig, envKey string) error {
return nil
}
// generateForPreview generates manifests for a synthesized preview
// environment, prepending a Namespace manifest so kubectl apply
// creates the namespace in a single pass.
func generateForPreview(cfg *config.KforgeConfig, env *config.ResolvedEnvironment, prNumber string) error {
coreYAML, infraManifests, err := buildManifests(cfg, env)
if err != nil {
return err
}
// Prepend a Namespace manifest so kubectl apply is self-contained.
nsYAML := generator.Namespace(env.Namespace, map[string]string{
"managed-by": "kforge",
"kforge/preview": "true",
"kforge/pr": prNumber,
})
coreYAML = nsYAML + generator.Separator + coreYAML
envKey := "preview-" + prNumber
if generateDry {
printDryRun(envKey, "core", coreYAML)
for _, m := range infraManifests {
printDryRun(envKey, m.Name, m.Content)
}
return nil
}
if err := os.MkdirAll(generateOutput, 0o755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
coreFile := filepath.Join(generateOutput, envKey+"-core.yaml")
if err := os.WriteFile(coreFile, []byte(coreYAML), 0o644); err != nil {
return fmt.Errorf("writing core manifest: %w", err)
}
fmt.Printf(" ✓ %s\n", coreFile)
for _, m := range infraManifests {
infraFile := filepath.Join(generateOutput, envKey+"-infra-"+m.Name+".yaml")
if err := os.WriteFile(infraFile, []byte(m.Content), 0o644); err != nil {
return fmt.Errorf("writing %s manifest: %w", m.Name, err)
}
fmt.Printf(" ✓ %s\n", infraFile)
}
return nil
}
// buildManifests generates core + infra manifests for a resolved environment,
// injecting infra env vars into the deployment.
func buildManifests(cfg *config.KforgeConfig, env *config.ResolvedEnvironment) (string, []generator.InfraManifest, error) {
coreYAML, err := generator.GenerateAll(env, cfg)
if err != nil {
return "", nil, fmt.Errorf("generating core manifests: %w", err)
}
infraManifests, err := generator.GenerateInfrastructure(env)
if err != nil {
return "", nil, fmt.Errorf("generating infrastructure manifests: %w", err)
}
var infraEnvVars []config.EnvVarConfig
for _, m := range infraManifests {
infraEnvVars = append(infraEnvVars, m.EnvVars...)
}
if len(infraEnvVars) > 0 {
env.EnvVars = config.MergeEnvVars(env.EnvVars, infraEnvVars)
coreYAML, err = generator.GenerateAll(env, cfg)
if err != nil {
return "", nil, fmt.Errorf("re-generating core manifests with infra vars: %w", err)
}
}
return coreYAML, infraManifests, nil
}
func printDryRun(envKey, name, content string) {
fmt.Printf("\n%s\n# --- %s / %s ---\n%s\n",
generator.Separator,
+54 -43
View File
@@ -10,17 +10,20 @@ import (
"strings"
"kforge/internal/config"
"kforge/pkg/interpolate"
"github.com/spf13/cobra"
)
// passwordChars mirrors the character set from your original
// shell command: A-Za-z0-9 + printable special chars.
// passwordChars is the character set for general-purpose passwords.
// Database passwords use alphanumeric-only (see generateAlphanumeric)
// so they are safe to embed in shell commands inside the db-init Job.
const passwordChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_{|}~`
var (
secretsApplyEnvs []string
secretsApplyForce bool
secretsApplyEnvs []string
secretsApplyForce bool
secretsApplyPRNumber string
)
var secretsApplyCmd = &cobra.Command{
@@ -30,24 +33,25 @@ var secretsApplyCmd = &cobra.Command{
services and creates/updates Kubernetes Secrets in the cluster.
Secrets are created with kubectl — KUBE_HOST, KUBE_TOKEN, and
KUBE_CERTIFICATE must be set in the environment (Gitea injects
these automatically during CI runs).
KUBE_CERTIFICATE must be set in the environment.
Already-existing secrets are NOT overwritten unless --force is
passed. This prevents accidental credential rotation.
Examples:
kforge secrets apply --env staging
kforge secrets apply --env production --force`,
kforge secrets apply --env production --force
kforge secrets apply --pr-number 42`,
RunE: runSecretsApply,
}
func init() {
secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil,
"Environment(s) to apply secrets for (required)")
"Environment(s) to apply secrets for")
secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false,
"Overwrite existing secrets (triggers credential rotation)")
_ = secretsApplyCmd.MarkFlagRequired("env")
secretsApplyCmd.Flags().StringVar(&secretsApplyPRNumber, "pr-number", "",
"PR number — synthesizes a preview environment instead of a named env")
secretsCmd.AddCommand(secretsApplyCmd)
}
@@ -57,30 +61,58 @@ func runSecretsApply(cmd *cobra.Command, args []string) error {
return err
}
// Preview mode: synthesize the preview environment.
if secretsApplyPRNumber != "" {
fmt.Printf("\nApplying secrets for preview PR #%s\n", secretsApplyPRNumber)
env, err := config.SynthesizePreviewEnvironment(cfg, secretsApplyPRNumber)
if err != nil {
return fmt.Errorf("synthesizing preview environment: %w", err)
}
return applySecretsForEnv(cfg, &env)
}
if len(secretsApplyEnvs) == 0 {
return fmt.Errorf("specify --env or --pr-number")
}
for _, envKey := range secretsApplyEnvs {
fmt.Printf("\nApplying secrets for environment: %s\n", envKey)
if err := applySecretsForEnv(cfg, envKey); err != nil {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
return fmt.Errorf("env %q: %w", envKey, err)
}
if err := applySecretsForEnv(cfg, &env); err != nil {
return fmt.Errorf("env %q: %w", envKey, err)
}
}
return nil
}
func applySecretsForEnv(cfg *config.KforgeConfig, envKey string) error {
env, err := config.ResolveEnvironment(cfg, envKey)
if err != nil {
return err
}
// Basic auth htpasswd secret
func applySecretsForEnv(cfg *config.KforgeConfig, env *config.ResolvedEnvironment) error {
if env.Ingress.Auth.Enabled {
if err := applyBasicAuthSecret(&env); err != nil {
if err := applyBasicAuthSecret(env); err != nil {
return fmt.Errorf("basic auth: %w", err)
}
}
infra := env.Infrastructure
// Database: create credentials Secret before the db-init Job runs.
// Password is alphanumeric-only so it's safe in the Job's shell commands.
if infra.Database != nil {
pgUser := interpolate.PGIdentifier(env.FullName)
if err := applyGenericSecret(
env.FullName+"-db-credentials",
env.Namespace,
map[string]string{
"username": pgUser,
"password": generateAlphanumeric(32),
},
); err != nil {
return fmt.Errorf("db credentials: %w", err)
}
}
if infra.Cache != nil {
if err := applyGenericSecret(
env.FullName+"-cache-credentials",
@@ -143,8 +175,6 @@ func applyBasicAuthSecret(env *config.ResolvedEnvironment) error {
for _, username := range auth.Users {
password := generatePassword(32)
// Generate bcrypt hash using htpasswd (available on most systems)
// or fall back to a simple SHA1 if htpasswd isn't available.
hash, err := generateHTPasswdEntry(username, password)
if err != nil {
return fmt.Errorf("hashing password for %s: %w", username, err)
@@ -164,32 +194,25 @@ func applyBasicAuthSecret(env *config.ResolvedEnvironment) error {
}
// generateHTPasswdEntry produces a username:bcrypt_hash string.
// Uses htpasswd binary if available, otherwise uses openssl.
func generateHTPasswdEntry(username, password string) (string, error) {
// Try htpasswd first (apache2-utils package).
if path, err := exec.LookPath("htpasswd"); err == nil {
out, err := exec.Command(path, "-nbB", username, password).Output()
if err == nil {
return strings.TrimSpace(string(out)), nil
}
}
// Fall back to openssl passwd -apr1 (MD5 crypt, still widely supported).
if path, err := exec.LookPath("openssl"); err == nil {
out, err := exec.Command(path, "passwd", "-apr1", password).Output()
if err == nil {
return username + ":" + strings.TrimSpace(string(out)), nil
}
}
return "", fmt.Errorf("neither htpasswd nor openssl found; install apache2-utils")
}
// applyGenericSecret creates or updates a Kubernetes Secret using
// kubectl. Skips creation if the secret already exists and --force
// was not passed.
// applyGenericSecret creates or updates a Kubernetes Secret using kubectl.
// Skips creation if the secret already exists and --force was not passed.
func applyGenericSecret(name, namespace string, data map[string]string) error {
// Check if secret already exists.
checkCmd := kubectlCmd("get", "secret", name, "-n", namespace, "--ignore-not-found")
out, err := checkCmd.Output()
if err != nil {
@@ -202,7 +225,6 @@ func applyGenericSecret(name, namespace string, data map[string]string) error {
return nil
}
// Build kubectl create secret generic args.
args := []string{
"create", "secret", "generic", name,
"-n", namespace,
@@ -214,7 +236,6 @@ func applyGenericSecret(name, namespace string, data map[string]string) error {
args = append(args, fmt.Sprintf("--from-literal=%s=%s", k, v))
}
// Pipe through kubectl apply to handle create-or-update.
createCmd := kubectlCmd(args...)
yamlBytes, err := createCmd.Output()
if err != nil {
@@ -238,9 +259,8 @@ func applyGenericSecret(name, namespace string, data map[string]string) error {
return nil
}
// kubectlCmd builds a kubectl invocation using the KUBE_HOST,
// KUBE_TOKEN, and KUBE_CERTIFICATE env vars for auth — the same
// pattern used in your existing Gitea Actions workflow.
// kubectlCmd builds a kubectl invocation using KUBE_HOST, KUBE_TOKEN,
// and KUBE_CERTIFICATE env vars for auth.
func kubectlCmd(args ...string) *exec.Cmd {
base := []string{}
@@ -251,11 +271,8 @@ func kubectlCmd(args ...string) *exec.Cmd {
base = append(base, "--token="+token)
}
if cert := os.Getenv("KUBE_CERTIFICATE"); cert != "" {
// KUBE_CERTIFICATE is the base64-encoded CA cert.
// Decode it to a temp file or pass inline.
decoded, err := base64.StdEncoding.DecodeString(cert)
if err == nil {
// Write to a temp file for kubectl.
f, err := os.CreateTemp("", "kforge-ca-*.crt")
if err == nil {
_, _ = f.Write(decoded)
@@ -264,7 +281,6 @@ func kubectlCmd(args ...string) *exec.Cmd {
}
}
} else {
// No cert provided — use insecure skip (matches your current workflow).
base = append(base, "--insecure-skip-tls-verify=true")
}
@@ -276,9 +292,6 @@ func kubectlCmd(args ...string) *exec.Cmd {
// Password generation
// ------------------------------------------------------------
// generatePassword produces a cryptographically random password
// of length n using the full printable ASCII character set.
// Mirrors: tr -dc 'A-Za-z0-9!"#$%&...' </dev/urandom | head -c 32
func generatePassword(n int) string {
b := make([]byte, n)
for i := range b {
@@ -291,8 +304,6 @@ func generatePassword(n int) string {
return string(b)
}
// generateAlphanumeric produces a random alphanumeric string
// suitable for access keys and usernames.
func generateAlphanumeric(n int) string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
+1 -23
View File
@@ -144,8 +144,7 @@ func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
// Always required — org level
{Name: "DOCKER_USERNAME", Location: "Gitea org secret", Required: true},
{Name: "DOCKER_PASSWORD", Location: "Gitea org secret", Required: true},
{Name: "SOPS_AGE_KEY", Location: "Gitea org secret", Description: "Decrypts .kforge/secrets.enc.yml", Required: true},
{Name: "KFORGE_NODE_IP", Location: "Gitea org secret", Description: "MicroK8s node IP for DNS A records"},
{Name: "KFORGE_NODE_IP", Location: "Gitea org secret", Description: "Node IP written as the external-dns annotation target"},
// Always required — repo level
{Name: "KUBE_HOST", Location: "Gitea repo secret", Required: true},
@@ -153,27 +152,6 @@ func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
{Name: "KUBE_CERTIFICATE", Location: "Gitea repo secret"},
}
// DNS secrets
if cfg.DNS.Provider != "" && !cfg.DNS.SkipDNS {
secrets = append(secrets, requiredSecret{
Name: "CLOUDFLARE_API_TOKEN",
Location: "Gitea org secret",
Description: "Zone:Read + DNS:Edit permissions",
Required: true,
})
for _, zone := range cfg.DNS.Cloudflare.Zones {
varName := "CF_ZONE_ID_" + strings.ToUpper(
strings.NewReplacer(".", "_", "-", "_").Replace(zone.Name),
)
secrets = append(secrets, requiredSecret{
Name: varName,
Location: "Gitea org secret",
Description: "Zone ID for " + zone.Name,
Required: true,
})
}
}
// Per-environment infrastructure secrets
envKeys := config.EnvironmentKeys(cfg)
sort.Strings(envKeys)