Files
kforge/cmd/secrets_apply.go
T
nate.lubitz 532b912ffb
Build and Deploy / build-and-deploy (push) Failing after 20s
add tool to gitea
2026-06-05 02:34:00 +10:00

308 lines
8.8 KiB
Go

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)
}