package cmd import ( "crypto/rand" "encoding/base64" "fmt" "math/big" "os" "os/exec" "strings" "kforge/internal/config" "kforge/pkg/interpolate" "github.com/spf13/cobra" ) // 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 secretsApplyPRNumber string ) 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. 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 --pr-number 42`, RunE: runSecretsApply, } func init() { secretsApplyCmd.Flags().StringArrayVarP(&secretsApplyEnvs, "env", "e", nil, "Environment(s) to apply secrets for") secretsApplyCmd.Flags().BoolVar(&secretsApplyForce, "force", false, "Overwrite existing secrets (triggers credential rotation)") secretsApplyCmd.Flags().StringVar(&secretsApplyPRNumber, "pr-number", "", "PR number — synthesizes a preview environment instead of a named env") secretsCmd.AddCommand(secretsApplyCmd) } func runSecretsApply(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { 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) 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, env *config.ResolvedEnvironment) error { if env.Ingress.Auth.Enabled { 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", 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) 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. func generateHTPasswdEntry(username, password string) (string, error) { 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 } } 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 { 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 } 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)) } 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 KUBE_HOST, KUBE_TOKEN, // and KUBE_CERTIFICATE env vars for auth. 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 != "" { decoded, err := base64.StdEncoding.DecodeString(cert) if err == nil { f, err := os.CreateTemp("", "kforge-ca-*.crt") if err == nil { _, _ = f.Write(decoded) f.Close() base = append(base, "--certificate-authority="+f.Name()) } } } else { base = append(base, "--insecure-skip-tls-verify=true") } cmd := exec.Command("kubectl", append(base, args...)...) return cmd } // ------------------------------------------------------------ // Password generation // ------------------------------------------------------------ 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) } 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) }