319 lines
9.0 KiB
Go
319 lines
9.0 KiB
Go
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)
|
|
}
|