This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package cmd
|
||||
|
||||
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
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var (
|
||||
giteaActionsOutput string
|
||||
giteaActionsBranch string
|
||||
giteaActionsEnvs []string
|
||||
)
|
||||
|
||||
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
|
||||
- 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.
|
||||
Re-run whenever you add environments or change deploy options.
|
||||
|
||||
Examples:
|
||||
kforge gitea-actions
|
||||
kforge gitea-actions --output .gitea/workflows/deploy.yml
|
||||
kforge gitea-actions --env staging --env production`,
|
||||
RunE: runGiteaActions,
|
||||
}
|
||||
|
||||
func init() {
|
||||
giteaActionsCmd.Flags().StringVarP(&giteaActionsOutput, "output", "o",
|
||||
".gitea/workflows/deploy.yml",
|
||||
"Output path for the generated workflow file")
|
||||
giteaActionsCmd.Flags().StringVar(&giteaActionsBranch, "branch", "main",
|
||||
"Branch that triggers the workflow")
|
||||
giteaActionsCmd.Flags().StringArrayVarP(&giteaActionsEnvs, "env", "e", nil,
|
||||
"Environments to deploy (default: all, staging before production)")
|
||||
rootCmd.AddCommand(giteaActionsCmd)
|
||||
}
|
||||
|
||||
func runGiteaActions(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workflow, err := generator.GenerateGiteaActions(cfg, generator.GiteaActionsOptions{
|
||||
Branch: giteaActionsBranch,
|
||||
Environments: giteaActionsEnvs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating workflow: %w", err)
|
||||
}
|
||||
|
||||
if giteaActionsOutput == "-" {
|
||||
fmt.Print(workflow)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
dir := giteaActionsOutput
|
||||
for i := len(dir) - 1; i >= 0; i-- {
|
||||
if dir[i] == '/' {
|
||||
dir = dir[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if dir != giteaActionsOutput {
|
||||
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 {
|
||||
return fmt.Errorf("writing workflow: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gitea Actions workflow written to %s\n", giteaActionsOutput)
|
||||
return nil
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kforge/internal/config"
|
||||
"kforge/internal/generator"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
generateEnvs []string
|
||||
generateOutput string
|
||||
generateDry bool
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
If no --env flags are given, manifests are generated for all
|
||||
environments defined in kforge.yml.
|
||||
|
||||
Examples:
|
||||
kforge generate
|
||||
kforge generate --env staging
|
||||
kforge generate --env staging --env production
|
||||
kforge generate --env production --output .kube/
|
||||
kforge generate --dry-run`,
|
||||
RunE: runGenerate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
generateCmd.Flags().StringArrayVarP(&generateEnvs, "env", "e", nil,
|
||||
"Environment(s) to generate (default: all)")
|
||||
generateCmd.Flags().StringVarP(&generateOutput, "output", "o", ".kforge-out",
|
||||
"Directory to write generated manifests into")
|
||||
generateCmd.Flags().BoolVar(&generateDry, "dry-run", false,
|
||||
"Print manifests to stdout instead of writing files")
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
}
|
||||
|
||||
func runGenerate(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
envKeys := generateEnvs
|
||||
if len(envKeys) == 0 {
|
||||
envKeys = config.EnvironmentKeys(cfg)
|
||||
}
|
||||
|
||||
if !generateDry {
|
||||
if err := os.MkdirAll(generateOutput, 0o755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, envKey := range envKeys {
|
||||
if err := generateForEnv(cfg, envKey); err != nil {
|
||||
return fmt.Errorf("env %q: %w", envKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateForEnv(cfg *config.KforgeConfig, envKey string) error {
|
||||
env, err := config.ResolveEnvironment(cfg, envKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Core manifests (Service, Deployment, Ingress, Certs).
|
||||
coreYAML, err := generator.GenerateAll(&env, cfg)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if generateDry {
|
||||
printDryRun(envKey, "core", coreYAML)
|
||||
for _, m := range infraManifests {
|
||||
printDryRun(envKey, m.Name, m.Content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write core manifest.
|
||||
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 {
|
||||
return fmt.Errorf("writing %s manifest: %w", m.Name, err)
|
||||
}
|
||||
fmt.Printf(" ✓ %s\n", infraFile)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDryRun(envKey, name, content string) {
|
||||
fmt.Printf("\n%s\n# --- %s / %s ---\n%s\n",
|
||||
generator.Separator,
|
||||
strings.ToUpper(envKey),
|
||||
name,
|
||||
content,
|
||||
)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"kforge/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "kforge",
|
||||
Short: "kforge — Kubernetes manifest generator for your homelab",
|
||||
Long: `kforge reads a kforge.yml file and generates flat Kubernetes
|
||||
manifests for each environment. It handles namespacing, TLS,
|
||||
ingress, infrastructure (CNPG, Valkey, NATS, Minio, Meilisearch),
|
||||
cron jobs, and DNS record creation via Cloudflare.
|
||||
|
||||
Get started:
|
||||
kforge validate Check your kforge.yml and secrets
|
||||
kforge generate Generate manifests for all environments
|
||||
kforge generate --env production --dry-run
|
||||
kforge secrets list Show required secrets checklist`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "kforge.yml",
|
||||
"Path to kforge.yml")
|
||||
}
|
||||
|
||||
// Execute runs the root command. Called from main().
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig is a shared helper used by all subcommands.
|
||||
func loadConfig() (*config.KforgeConfig, error) {
|
||||
cfg, err := config.Load(cfgFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading %s: %w", cfgFile, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kforge/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// passwordChars mirrors the character set from your original
|
||||
// shell command: A-Za-z0-9 + printable special chars.
|
||||
const passwordChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_{|}~`
|
||||
|
||||
var (
|
||||
secretsApplyEnvs []string
|
||||
secretsApplyForce bool
|
||||
)
|
||||
|
||||
var secretsApplyCmd = &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "Generate and apply cluster secrets for an environment",
|
||||
Long: `Generates secure random credentials for all enabled infrastructure
|
||||
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).
|
||||
|
||||
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`,
|
||||
RunE: runSecretsApply,
|
||||
}
|
||||
|
||||
func init() {
|
||||
secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil,
|
||||
"Environment(s) to apply secrets for (required)")
|
||||
secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false,
|
||||
"Overwrite existing secrets (triggers credential rotation)")
|
||||
_ = secretsApplyCmd.MarkFlagRequired("env")
|
||||
secretsCmd.AddCommand(secretsApplyCmd)
|
||||
}
|
||||
|
||||
func runSecretsApply(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, envKey := range secretsApplyEnvs {
|
||||
fmt.Printf("\nApplying secrets for environment: %s\n", envKey)
|
||||
if err := applySecretsForEnv(cfg, envKey); 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
|
||||
if env.Ingress.Auth.Enabled {
|
||||
if err := applyBasicAuthSecret(&env); err != nil {
|
||||
return fmt.Errorf("basic auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
infra := env.Infrastructure
|
||||
|
||||
if infra.Cache != nil {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-cache-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{"password": generatePassword(32)},
|
||||
); err != nil {
|
||||
return fmt.Errorf("cache credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if infra.Storage != nil {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-storage-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{
|
||||
"access_key": generateAlphanumeric(20),
|
||||
"secret_key": generatePassword(40),
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("storage credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-queue-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{
|
||||
"username": "kforge",
|
||||
"password": generatePassword(32),
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("queue credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if infra.Search != nil {
|
||||
if err := applyGenericSecret(
|
||||
env.FullName+"-search-credentials",
|
||||
env.Namespace,
|
||||
map[string]string{"master_key": generatePassword(48)},
|
||||
); err != nil {
|
||||
return fmt.Errorf("search credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBasicAuthSecret generates an htpasswd entry for each user
|
||||
// and stores it in the basic-auth Secret.
|
||||
func applyBasicAuthSecret(env *config.ResolvedEnvironment) error {
|
||||
auth := env.Ingress.Auth
|
||||
if len(auth.Users) == 0 {
|
||||
return fmt.Errorf("auth.users must contain at least one username")
|
||||
}
|
||||
|
||||
var htpasswdLines []string
|
||||
fmt.Printf(" Generating basic auth credentials:\n")
|
||||
|
||||
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)
|
||||
}
|
||||
htpasswdLines = append(htpasswdLines, hash)
|
||||
fmt.Printf(" user: %-20s password: %s\n", username, password)
|
||||
fmt.Printf(" ⚠ Save this password now — it will not be shown again.\n")
|
||||
}
|
||||
|
||||
htpasswd := strings.Join(htpasswdLines, "\n") + "\n"
|
||||
|
||||
return applyGenericSecret(
|
||||
auth.SecretName,
|
||||
env.Namespace,
|
||||
map[string]string{"auth": htpasswd},
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
return fmt.Errorf("checking secret %s: %w", name, err)
|
||||
}
|
||||
|
||||
exists := strings.TrimSpace(string(out)) != ""
|
||||
if exists && !secretsApplyForce {
|
||||
fmt.Printf(" ✓ secret %s already exists (use --force to rotate)\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build kubectl create secret generic args.
|
||||
args := []string{
|
||||
"create", "secret", "generic", name,
|
||||
"-n", namespace,
|
||||
"--save-config",
|
||||
"--dry-run=client",
|
||||
"-o", "yaml",
|
||||
}
|
||||
for k, v := range data {
|
||||
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 {
|
||||
return fmt.Errorf("generating secret manifest for %s: %w", name, err)
|
||||
}
|
||||
|
||||
applyCmd := kubectlCmd("apply", "-f", "-", "-n", namespace)
|
||||
applyCmd.Stdin = strings.NewReader(string(yamlBytes))
|
||||
applyCmd.Stdout = os.Stdout
|
||||
applyCmd.Stderr = os.Stderr
|
||||
|
||||
if err := applyCmd.Run(); err != nil {
|
||||
return fmt.Errorf("applying secret %s: %w", name, err)
|
||||
}
|
||||
|
||||
action := "created"
|
||||
if exists {
|
||||
action = "rotated"
|
||||
}
|
||||
fmt.Printf(" ✓ secret %s %s\n", name, action)
|
||||
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.
|
||||
func kubectlCmd(args ...string) *exec.Cmd {
|
||||
base := []string{}
|
||||
|
||||
if host := os.Getenv("KUBE_HOST"); host != "" {
|
||||
base = append(base, "--server="+host)
|
||||
}
|
||||
if token := os.Getenv("KUBE_TOKEN"); token != "" {
|
||||
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)
|
||||
f.Close()
|
||||
base = append(base, "--certificate-authority="+f.Name())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No cert provided — use insecure skip (matches your current workflow).
|
||||
base = append(base, "--insecure-skip-tls-verify=true")
|
||||
}
|
||||
|
||||
cmd := exec.Command("kubectl", append(base, args...)...)
|
||||
return 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 {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(passwordChars))))
|
||||
if err != nil {
|
||||
panic("crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
b[i] = passwordChars[idx.Int64()]
|
||||
}
|
||||
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)
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||
if err != nil {
|
||||
panic("crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
b[i] = chars[idx.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"kforge/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// validate
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var validateCmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate kforge.yml and check required secrets",
|
||||
Long: `Parses kforge.yml and checks for:
|
||||
- Structural errors (missing required fields, invalid types)
|
||||
- Required Gitea/CI secrets (checks current process environment)
|
||||
- Interpolation tokens that can't be resolved
|
||||
|
||||
Exits 0 if valid, non-zero if any issue is found.`,
|
||||
RunE: runValidate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(validateCmd)
|
||||
}
|
||||
|
||||
func runValidate(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ kforge.yml is valid\n")
|
||||
fmt.Printf(" app: %s/%s\n", cfg.Meta.Tenant, cfg.Meta.Name)
|
||||
fmt.Printf(" envs: %s\n", strings.Join(config.EnvironmentKeys(cfg), ", "))
|
||||
|
||||
// Check required secrets.
|
||||
missing := checkRequiredSecrets(cfg)
|
||||
if len(missing) > 0 {
|
||||
fmt.Println("\n⚠ Missing required secrets (not found in environment):")
|
||||
for _, s := range missing {
|
||||
fmt.Printf(" ✗ %s (%s)\n", s.Name, s.Location)
|
||||
}
|
||||
return fmt.Errorf("%d required secret(s) not set", len(missing))
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ All required secrets are present")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// secrets list
|
||||
// ------------------------------------------------------------
|
||||
|
||||
var secretsCmd = &cobra.Command{
|
||||
Use: "secrets",
|
||||
Short: "Manage and document kforge secrets",
|
||||
}
|
||||
|
||||
var secretsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all secrets required for this repository",
|
||||
Long: `Prints a categorised table of every secret kforge needs,
|
||||
where each one should live, and whether it is currently set
|
||||
in the process environment.
|
||||
|
||||
Use this as an onboarding checklist when setting up a new repo.`,
|
||||
RunE: runSecretsList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
secretsCmd.AddCommand(secretsListCmd)
|
||||
rootCmd.AddCommand(secretsCmd)
|
||||
}
|
||||
|
||||
func runSecretsList(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
required := buildRequiredSecrets(cfg)
|
||||
|
||||
// Group by location.
|
||||
groups := map[string][]requiredSecret{}
|
||||
for _, s := range required {
|
||||
groups[s.Location] = append(groups[s.Location], s)
|
||||
}
|
||||
|
||||
locations := []string{
|
||||
"Gitea org secret",
|
||||
"Gitea repo secret",
|
||||
"Cluster secret (auto-generated)",
|
||||
}
|
||||
|
||||
for _, loc := range locations {
|
||||
secrets := groups[loc]
|
||||
if len(secrets) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\n── %s ──\n", loc)
|
||||
for _, s := range secrets {
|
||||
status := "✓ set"
|
||||
if loc != "Cluster secret (auto-generated)" {
|
||||
if _, ok := os.LookupEnv(s.Name); !ok {
|
||||
status = "✗ missing"
|
||||
}
|
||||
} else {
|
||||
status = "— managed by kforge"
|
||||
}
|
||||
fmt.Printf(" %-40s %s\n", s.Name, status)
|
||||
if s.Description != "" {
|
||||
fmt.Printf(" %s\n", s.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Secret definitions
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type requiredSecret struct {
|
||||
Name string
|
||||
Location string // "Gitea org secret" | "Gitea repo secret" | "Cluster secret (auto-generated)"
|
||||
Description string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// buildRequiredSecrets returns the full list of secrets this
|
||||
// kforge.yml requires, based on what's enabled.
|
||||
func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
|
||||
secrets := []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"},
|
||||
|
||||
// Always required — repo level
|
||||
{Name: "KUBE_HOST", Location: "Gitea repo secret", Required: true},
|
||||
{Name: "KUBE_TOKEN", Location: "Gitea repo secret", Required: true},
|
||||
{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)
|
||||
|
||||
for _, envKey := range envKeys {
|
||||
env, err := config.ResolveEnvironment(cfg, envKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fullName := env.FullName
|
||||
infra := env.Infrastructure
|
||||
|
||||
if infra.Database != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-db-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "CNPG-managed database credentials (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if env.Ingress.Auth.Enabled {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: env.Ingress.Auth.SecretName,
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Basic auth htpasswd (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Cache != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-cache-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Cache password (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Storage != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-storage-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Minio access/secret keys (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-queue-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "RabbitMQ credentials (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
if infra.Search != nil {
|
||||
secrets = append(secrets, requiredSecret{
|
||||
Name: fullName + "-search-credentials",
|
||||
Location: "Cluster secret (auto-generated)",
|
||||
Description: "Meilisearch master key (" + envKey + ")",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return secrets
|
||||
}
|
||||
|
||||
// checkRequiredSecrets returns secrets that are required but not
|
||||
// set in the current process environment.
|
||||
func checkRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
|
||||
var missing []requiredSecret
|
||||
for _, s := range buildRequiredSecrets(cfg) {
|
||||
if s.Location == "Cluster secret (auto-generated)" {
|
||||
continue // Cluster secrets aren't checked in CI env.
|
||||
}
|
||||
if !s.Required {
|
||||
continue
|
||||
}
|
||||
if _, ok := os.LookupEnv(s.Name); !ok {
|
||||
missing = append(missing, s)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
Reference in New Issue
Block a user