Files
italiadatacenter/add-on/mysql-database-operator/controllers/mysqldatabase_controller.go
T
2026-10-11 23:49:35 +02:00

243 lines
8.6 KiB
Go

package controllers
import (
"context"
"crypto/rand"
"database/sql"
"fmt"
"math/big"
"strings"
"time"
platformv1alpha1 "github.com/example/mysql-database-operator/api/v1alpha1"
_ "github.com/go-sql-driver/mysql"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
const finalizer = "platform.example.com/mysqldatabase-finalizer"
type MySQLDatabaseReconciler struct{ client.Client }
func (r *MySQLDatabaseReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
logger := log.FromContext(ctx)
var obj platformv1alpha1.MySQLDatabase
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
if apierrors.IsNotFound(err) {
return reconcile.Result{}, nil
}
return reconcile.Result{}, err
}
if obj.Spec.DatabaseName == "" {
return r.setError(ctx, &obj, "InvalidSpec", "databaseName is required")
}
if obj.Spec.AdminSecretRef.Name == "" {
return r.setError(ctx, &obj, "InvalidSpec", "adminSecretRef.name is required")
}
if !obj.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&obj, finalizer) {
return reconcile.Result{}, nil
}
if strings.EqualFold(obj.Spec.DeletionPolicy, "Delete") {
if err := r.withDB(ctx, &obj, func(db *sql.DB) error {
_, err := db.ExecContext(ctx, "DROP DATABASE IF EXISTS `"+quoteIdent(obj.Spec.DatabaseName)+"`")
return err
}); err != nil {
return reconcile.Result{RequeueAfter: 10 * time.Second}, err
}
}
controllerutil.RemoveFinalizer(&obj, finalizer)
return reconcile.Result{}, r.Update(ctx, &obj)
}
if !controllerutil.ContainsFinalizer(&obj, finalizer) {
controllerutil.AddFinalizer(&obj, finalizer)
return reconcile.Result{}, r.Update(ctx, &obj)
}
if err := r.withDB(ctx, &obj, func(db *sql.DB) error { return ensureDatabase(ctx, db, &obj) }); err != nil {
return r.setError(ctx, &obj, "ConnectionError", err.Error())
}
if obj.Spec.User != nil && obj.Spec.User.Create {
username := obj.Spec.User.Name
if username == "" {
return r.setError(ctx, &obj, "InvalidSpec", "user.name is required when user.create is true")
}
password, err := r.ensureCredentials(ctx, &obj, username)
if err != nil {
return r.setError(ctx, &obj, "CredentialsError", err.Error())
}
if err := r.withDB(ctx, &obj, func(db *sql.DB) error { return ensureUserAndGrants(ctx, db, &obj, password) }); err != nil {
return r.setError(ctx, &obj, "UserError", err.Error())
}
}
obj.Status.Phase = "Ready"
obj.Status.Message = "Database reconciled"
obj.Status.DatabaseName = obj.Spec.DatabaseName
if obj.Spec.User != nil {
obj.Status.UserName = obj.Spec.User.Name
}
if obj.Spec.Credentials != nil {
obj.Status.SecretName = obj.Spec.Credentials.SecretName
}
obj.Status.ObservedGeneration = obj.Generation
obj.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Reconciled", Message: "MySQL database is ready", LastTransitionTime: metav1.Now()}}
if err := r.Status().Update(ctx, &obj); err != nil {
logger.Error(err, "status update failed")
return reconcile.Result{}, err
}
return reconcile.Result{RequeueAfter: 5 * time.Minute}, nil
}
func (r *MySQLDatabaseReconciler) setError(ctx context.Context, obj *platformv1alpha1.MySQLDatabase, reason, msg string) (reconcile.Result, error) {
obj.Status.Phase = "Error"
obj.Status.Message = msg
obj.Status.ObservedGeneration = obj.Generation
obj.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionFalse, Reason: reason, Message: msg, LastTransitionTime: metav1.Now()}}
_ = r.Status().Update(ctx, obj)
return reconcile.Result{RequeueAfter: 15 * time.Second}, fmt.Errorf("%s: %s", reason, msg)
}
func (r *MySQLDatabaseReconciler) withDB(ctx context.Context, obj *platformv1alpha1.MySQLDatabase, fn func(*sql.DB) error) error {
var admin corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Name: obj.Spec.AdminSecretRef.Name, Namespace: obj.Namespace}, &admin); err != nil {
return err
}
username, password := string(admin.Data["username"]), string(admin.Data["password"])
if username == "" || password == "" {
return fmt.Errorf("admin secret %q must contain username and password", obj.Spec.AdminSecretRef.Name)
}
host := obj.Spec.Connection.Host
if host == "" {
host = obj.Spec.ClusterRef.Name
}
port := obj.Spec.Connection.Port
if port == 0 {
port = 3306
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?parseTime=true&timeout=5s&readTimeout=10s&writeTimeout=10s", username, password, host, port)
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
pingCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return err
}
return fn(db)
}
func ensureDatabase(ctx context.Context, db *sql.DB, obj *platformv1alpha1.MySQLDatabase) error {
q := "CREATE DATABASE IF NOT EXISTS `" + quoteIdent(obj.Spec.DatabaseName) + "`"
if obj.Spec.Charset != "" {
q += " CHARACTER SET " + safeSQLWord(obj.Spec.Charset)
}
if obj.Spec.Collation != "" {
q += " COLLATE " + safeSQLWord(obj.Spec.Collation)
}
_, err := db.ExecContext(ctx, q)
return err
}
func ensureUserAndGrants(ctx context.Context, db *sql.DB, obj *platformv1alpha1.MySQLDatabase, password string) error {
u := quoteIdent(obj.Spec.User.Name)
// User is intentionally scoped to localhost wildcard host. For networked K8s apps, '%' is needed.
if _, err := db.ExecContext(ctx, "CREATE USER IF NOT EXISTS '"+quoteString(obj.Spec.User.Name)+"'@'%' IDENTIFIED BY '"+quoteString(password)+"'"); err != nil {
return err
}
if _, err := db.ExecContext(ctx, "ALTER USER '"+quoteString(obj.Spec.User.Name)+"'@'%' IDENTIFIED BY '"+quoteString(password)+"'"); err != nil {
return err
}
grants := obj.Spec.Privileges
if len(grants) == 0 {
grants = []string{"SELECT", "INSERT", "UPDATE", "DELETE"}
}
for _, g := range grants {
if !isAllowedPrivilege(g) {
return fmt.Errorf("unsupported privilege %q", g)
}
}
grant := strings.Join(grants, ", ")
_, err := db.ExecContext(ctx, "GRANT "+grant+" ON `"+quoteIdent(obj.Spec.DatabaseName)+"`.* TO '"+quoteString(obj.Spec.User.Name)+"'@'%'")
_ = u
return err
}
func (r *MySQLDatabaseReconciler) ensureCredentials(ctx context.Context, obj *platformv1alpha1.MySQLDatabase, username string) (string, error) {
if obj.Spec.Credentials == nil || obj.Spec.Credentials.SecretName == "" {
return "", fmt.Errorf("credentials.secretName is required when user.create is true")
}
key := types.NamespacedName{Name: obj.Spec.Credentials.SecretName, Namespace: obj.Namespace}
var s corev1.Secret
err := r.Get(ctx, key, &s)
if err == nil {
p := string(s.Data["password"])
if p == "" {
return "", fmt.Errorf("credentials secret must contain password")
}
if string(s.Data["username"]) == "" {
s.Data["username"] = []byte(username)
if err := r.Update(ctx, &s); err != nil {
return "", err
}
}
return p, nil
}
if !apierrors.IsNotFound(err) {
return "", err
}
p, err := randomPassword(32)
if err != nil {
return "", err
}
s = corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Type: corev1.SecretTypeOpaque, StringData: map[string]string{"username": username, "password": p, "database": obj.Spec.DatabaseName}}
if err := controllerutil.SetControllerReference(obj, &s, r.Scheme); err != nil {
return "", err
}
if err := r.Create(ctx, &s); err != nil {
return "", err
}
return p, nil
}
func randomPassword(n int) (string, error) {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*_-"
b := make([]byte, n)
for i := range b {
x, e := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if e != nil {
return "", e
}
b[i] = chars[x.Int64()]
}
return string(b), nil
}
func quoteIdent(s string) string { return strings.ReplaceAll(s, "`", "``") }
func quoteString(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, "\\", "\\\\"), "'", "\\'")
}
func safeSQLWord(s string) string {
for _, r := range s {
if !(r == '_' || r == '-' || r == '.' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
return "utf8mb4"
}
}
return s
}
func isAllowedPrivilege(s string) bool {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "INDEX", "DROP", "REFERENCES", "EXECUTE", "SHOW VIEW", "TRIGGER":
return true
default:
return false
}
}