This commit is contained in:
alessandro barucci
2026-10-11 23:49:35 +02:00
parent 23f276417f
commit 34213918fe
32 changed files with 1411 additions and 9 deletions
+88
View File
@@ -0,0 +1,88 @@
# MySQL Database Operator
A small Kubernetes controller that provides a declarative `MySQLDatabase` CRD on top of an existing MySQL service, including Oracle MySQL Operator / InnoDB Cluster deployments.
## Scope
This MVP manages:
- `CREATE DATABASE IF NOT EXISTS`
- optional application user creation
- application credentials Secret generation
- database-scoped grants
- `Retain` or `Delete` deletion policy
- status/conditions
It does **not** create or manage the MySQL/InnoDBCluster itself. The Oracle MySQL Operator remains responsible for the MySQL infrastructure.
## Important production notes
1. `adminSecretRef` must contain `username` and `password`.
2. The controller currently connects to the host in `spec.connection.host`; if omitted it uses `spec.clusterRef.name`.
3. The generated application Secret contains `username`, `password`, and `database`.
4. The user is created as `'user'@'%'` because Kubernetes workloads normally connect over the cluster network. Tighten this later if your network model supports it.
5. The controller intentionally supports a small allow-list of privileges; do not accept arbitrary SQL from the CRD.
6. For production, add TLS support, NetworkPolicy, Secret rotation policy, metrics, admission validation, and tests before broad multi-team rollout.
## Build
```bash
go mod tidy
go build ./cmd
docker build -t harbor.example.com/platform/mysql-database-operator:0.1.0 .
docker push harbor.example.com/platform/mysql-database-operator:0.1.0
```
## Deploy on RKE2
```bash
kubectl create namespace mysql-database-operator-system
kubectl apply -f config/crd/mysqldatabase-crd.yaml
kubectl apply -f deploy/rbac.yaml
sed 's#ghcr.io/example/mysql-database-operator:0.1.0#harbor.example.com/platform/mysql-database-operator:0.1.0#' deploy/deployment.yaml | kubectl apply -f -
```
Or Helm:
```bash
helm upgrade --install mysql-database-operator ./helm/mysql-database-operator \
--namespace mysql-database-operator-system \
--create-namespace \
--set image.repository=harbor.example.com/platform/mysql-database-operator \
--set image.tag=0.1.0
```
Then create the admin Secret and database:
```bash
kubectl create namespace team-a
kubectl apply -f examples/admin-secret.yaml
kubectl apply -f examples/mysql-database.yaml
kubectl get mysqldatabase -n team-a
kubectl describe mysqldatabase orders -n team-a
kubectl get secret orders-db-credentials -n team-a
```
## Oracle MySQL Operator integration
Example architecture:
```text
InnoDBCluster mysql-prod
|
| Service mysql-prod:3306
v
MySQLDatabase orders
|
+-- CREATE DATABASE orders
+-- CREATE USER orders-app
+-- GRANT SELECT, INSERT, UPDATE, DELETE
+-- Secret orders-db-credentials
```
Use the actual Router/primary Service exposed by your Oracle MySQL Operator installation in `spec.connection.host` if it is not the same as `clusterRef.name`.
## Verification
The source tree was formatted with `gofmt`. Full `go test ./...` could not be executed in this environment because external Go module downloads are disabled; run `go mod tidy && go test ./...` in a network-enabled build environment before publishing the image.
+23
View File
@@ -0,0 +1,23 @@
bcdb6dd502904deb1982acff76dbcecef61fe64c1edce33dbc535fe714809d0f ./Dockerfile
811616c4bfe6df79795d4cfe845926b9e1af47501421d092f67bf511f4cdd673 ./README.md
ffe83a66f937391f9c6b79450f5161095bdba5c794838b2bc8965c715c5849ca ./api/v1alpha1/groupversion_info.go
12f4d95346545e4dd390dcd5718ae0e5104ffd3e6153b9ead6bfaf78e7293d09 ./api/v1alpha1/mysqldatabase_types.go
89e9b851001dcb3a3bf88de546fbde273d9cab9951b389d9a07bf106adc570f5 ./api/v1alpha1/zz_generated.deepcopy.go
08be703122880eb86372acdbe9d560280f9c2428cf2279cc05061bbf79cb8836 ./cmd/main.go
78813f202aa5a135a25f1c1524fdc8e09174f13f601985923a00cf6d9fcc6f28 ./config/crd/mysqldatabase-crd.yaml
c9f3d6c121d19bf780f6a35b69eb242202164c38fc201ad67f6789e363620bf4 ./controllers/mysqldatabase_controller.go
b164fdebc25270c033e6b5608a6b7eb005007757913ff3f7dea692f2ff306777 ./controllers/setup.go
75d21caeb9f41976855fa8421e07f31cee39877844ca30d9f0a3104863090458 ./deploy/deployment.yaml
18f1896539466ce1d0707ca42877d94e97b6e50af72924ecb236cbc276dd4690 ./deploy/namespace.yaml
0de7361b72bcd016c49d44e1bc1a292d6ceba4ab2836218732bd133380151472 ./deploy/rbac.yaml
f31d3c24dc3f666ddd62af0bc8dde2b2623edfa370099e4b759e3cfc87a40cf5 ./examples/admin-secret.yaml
9f954df207eebfa0f1551bdeb05ec97f33ea7184a8c69aad7526b25ffb98cb99 ./examples/mysql-database.yaml
a17f89658ffb2d16a6c64e6bae3a99f8b3fc5130a6c00fdeb6bbd231cde0488f ./go.mod
3266515bc31fd4465da3eacc34d98ddbb18e67444d855438c683a8875f7bce03 ./helm/mysql-database-operator/Chart.yaml
dcb57bf001e957e3abad642ca56c76ba2b65b6c6419dc9148b95be3293f29a4b ./helm/mysql-database-operator/templates/_helpers.tpl
78813f202aa5a135a25f1c1524fdc8e09174f13f601985923a00cf6d9fcc6f28 ./helm/mysql-database-operator/templates/crd.yaml
55159600ff11ee14ab542d6ad647129fb1321fc591e17693340d481af4467ab2 ./helm/mysql-database-operator/templates/deployment.yaml
dd8a29aa027dfc40a77f295fd4d70ab950c14bcc8a3c8bf7efc98f2ff77ee75d ./helm/mysql-database-operator/templates/namespace.yaml
e06e41e699589b14609bb5f32555c01851ba4a7e514d647f3191623832e09e01 ./helm/mysql-database-operator/templates/rbac.yaml
107ddf20ea52d35fbd2dfbbbcadc86e8848b6013566e4fd4229326d9087cce7e ./helm/mysql-database-operator/templates/serviceaccount.yaml
8d6267287c8b41e1c30b11d13c73273aaf48d2a69f717ea0de1f7dda72a26c78 ./helm/mysql-database-operator/values.yaml
@@ -0,0 +1,15 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var GroupVersion = schema.GroupVersion{Group: "platform.example.com", Version: "v1alpha1"}
var SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
var AddToScheme = SchemeBuilder.AddToScheme
func init() { SchemeBuilder.Register(&MySQLDatabase{}, &MySQLDatabaseList{}) }
var _ = metav1.Now
@@ -0,0 +1,68 @@
package v1alpha1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// MySQLDatabaseSpec defines the desired state of a logical MySQL database.
type MySQLDatabaseSpec struct {
ClusterRef ClusterReference `json:"clusterRef"`
DatabaseName string `json:"databaseName"`
AdminSecretRef SecretReference `json:"adminSecretRef"`
Connection ConnectionSpec `json:"connection,omitempty"`
User *UserSpec `json:"user,omitempty"`
Credentials *CredentialsSpec `json:"credentials,omitempty"`
Privileges []string `json:"privileges,omitempty"`
Charset string `json:"charset,omitempty"`
Collation string `json:"collation,omitempty"`
DeletionPolicy string `json:"deletionPolicy,omitempty"`
}
type ClusterReference struct {
Name string `json:"name"`
}
type SecretReference struct {
Name string `json:"name"`
}
type ConnectionSpec struct {
Host string `json:"host,omitempty"`
Port int32 `json:"port,omitempty"`
}
type UserSpec struct {
Name string `json:"name"`
Create bool `json:"create,omitempty"`
}
type CredentialsSpec struct {
SecretName string `json:"secretName"`
}
type MySQLDatabaseStatus struct {
Phase string `json:"phase,omitempty"`
Message string `json:"message,omitempty"`
DatabaseName string `json:"databaseName,omitempty"`
UserName string `json:"userName,omitempty"`
SecretName string `json:"secretName,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Database",type="string",JSONPath=".status.databaseName"
// +kubebuilder:printcolumn:name="User",type="string",JSONPath=".status.userName"
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
type MySQLDatabase struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MySQLDatabaseSpec `json:"spec"`
Status MySQLDatabaseStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type MySQLDatabaseList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []MySQLDatabase `json:"items"`
}
@@ -0,0 +1,37 @@
// Code generated by controller-gen style deepcopy implementation. DO NOT EDIT.
package v1alpha1
func (in *MySQLDatabase) DeepCopyInto(out *MySQLDatabase) {
*out = *in
out.ObjectMeta = *in.ObjectMeta.DeepCopy()
out.Status = in.Status
if in.Status.Conditions != nil {
out.Status.Conditions = make([]metav1.Condition, len(in.Status.Conditions))
copy(out.Status.Conditions, in.Status.Conditions)
}
}
func (in *MySQLDatabase) DeepCopy() *MySQLDatabase {
if in == nil {
return nil
}
out := new(MySQLDatabase)
in.DeepCopyInto(out)
return out
}
func (in *MySQLDatabaseList) DeepCopyInto(out *MySQLDatabaseList) {
*out = *in
if in.Items != nil {
out.Items = make([]MySQLDatabase, len(in.Items))
for i := range in.Items {
in.Items[i].DeepCopyInto(&out.Items[i])
}
}
}
func (in *MySQLDatabaseList) DeepCopy() *MySQLDatabaseList {
if in == nil {
return nil
}
out := new(MySQLDatabaseList)
in.DeepCopyInto(out)
return out
}
@@ -0,0 +1,45 @@
package main
import (
"flag"
"github.com/example/mysql-database-operator/api/v1alpha1"
"github.com/example/mysql-database-operator/controllers"
"k8s.io/apimachinery/pkg/runtime"
"os"
"sigs.k8s.io/controller-runtime/pkg/client/config"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/metrics/server"
)
func main() {
var metricsAddr, probeAddr string
var leader bool
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics address")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe address")
flag.BoolVar(&leader, "leader-elect", true, "enable leader election")
var zo zap.Options
zo.BindFlags(flag.CommandLine)
flag.Parse()
controller.SetLogger(zap.New(zap.UseFlagOptions(&zo)))
scheme := runtime.NewScheme()
_ = v1alpha1.AddToScheme(scheme)
mgr, err := manager.New(config.GetConfigOrDie(), manager.Options{Scheme: scheme, Metrics: server.Options{BindAddress: metricsAddr}, HealthProbeBindAddress: probeAddr, LeaderElection: leader, LeaderElectionID: "mysql-database-operator"})
if err != nil {
os.Exit(1)
}
if err := (&controllers.MySQLDatabaseReconciler{Client: mgr.GetClient(), Scheme: scheme}).SetupWithManager(mgr); err != nil {
os.Exit(1)
}
if err := mgr.AddHealthzCheck("health", healthz.Ping); err != nil {
os.Exit(1)
}
if err := mgr.AddReadyzCheck("ready", healthz.Ping); err != nil {
os.Exit(1)
}
if err := mgr.Start(controller.SetupSignalHandler()); err != nil {
os.Exit(1)
}
}
@@ -0,0 +1,93 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: mysqldatabases.platform.example.com
spec:
group: platform.example.com
scope: Namespaced
names:
plural: mysqldatabases
singular: mysqldatabase
kind: MySQLDatabase
shortNames: [mysqldb]
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Database
type: string
jsonPath: .status.databaseName
- name: User
type: string
jsonPath: .status.userName
- name: Phase
type: string
jsonPath: .status.phase
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [clusterRef,databaseName,adminSecretRef]
properties:
clusterRef:
type: object
required: [name]
properties: {name: {type: string}}
databaseName:
type: string
minLength: 1
maxLength: 64
pattern: '^[A-Za-z0-9_$-]+$'
adminSecretRef:
type: object
required: [name]
properties: {name: {type: string}}
connection:
type: object
properties:
host: {type: string}
port: {type: integer, minimum: 1, maximum: 65535}
user:
type: object
properties:
name: {type: string, maxLength: 32}
create: {type: boolean}
credentials:
type: object
properties:
secretName: {type: string}
privileges:
type: array
items: {type: string}
charset: {type: string}
collation: {type: string}
deletionPolicy:
type: string
enum: [Retain, Delete]
default: Retain
status:
type: object
properties:
phase: {type: string}
message: {type: string}
databaseName: {type: string}
userName: {type: string}
secretName: {type: string}
observedGeneration: {type: integer, format: int64}
conditions:
type: array
items:
type: object
required: [type,status,reason,message,lastTransitionTime]
properties:
type: {type: string}
status: {type: string}
reason: {type: string}
message: {type: string}
lastTransitionTime: {type: string, format: date-time}
@@ -0,0 +1,242 @@
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
}
}
@@ -0,0 +1,13 @@
package controllers
import (
platformv1alpha1 "github.com/example/mysql-database-operator/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/manager"
)
func (r *MySQLDatabaseReconciler) SetupWithManager(mgr manager.Manager) error {
r.Client = mgr.GetClient()
return builder.ControllerManagedBy(mgr).For(&platformv1alpha1.MySQLDatabase{}).Owns(&corev1.Secret{}).Complete(r)
}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
name: mysql-prod-admin
namespace: team-a
type: Opaque
stringData:
username: root
password: CHANGE-ME
@@ -0,0 +1,28 @@
apiVersion: platform.example.com/v1alpha1
kind: MySQLDatabase
metadata:
name: orders
namespace: team-a
spec:
clusterRef:
name: mysql-prod
# If omitted, the controller uses clusterRef.name as the Service DNS name.
connection:
host: mysql-prod
port: 3306
databaseName: orders
adminSecretRef:
name: mysql-prod-admin
user:
name: orders-app
create: true
credentials:
secretName: orders-db-credentials
privileges:
- SELECT
- INSERT
- UPDATE
- DELETE
charset: utf8mb4
collation: utf8mb4_0900_ai_ci
deletionPolicy: Retain
@@ -0,0 +1,6 @@
apiVersion: v2
name: mysql-database-operator
description: Declarative MySQL database/user controller for Kubernetes
version: 0.1.0
appVersion: "0.1.0"
type: application
@@ -0,0 +1,2 @@
{{- define "mysql-database-operator.name" -}}mysql-database-operator{{- end }}
{{- define "mysql-database-operator.fullname" -}}{{ include "mysql-database-operator.name" . }}{{- end }}
@@ -0,0 +1,93 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: mysqldatabases.platform.example.com
spec:
group: platform.example.com
scope: Namespaced
names:
plural: mysqldatabases
singular: mysqldatabase
kind: MySQLDatabase
shortNames: [mysqldb]
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Database
type: string
jsonPath: .status.databaseName
- name: User
type: string
jsonPath: .status.userName
- name: Phase
type: string
jsonPath: .status.phase
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [clusterRef,databaseName,adminSecretRef]
properties:
clusterRef:
type: object
required: [name]
properties: {name: {type: string}}
databaseName:
type: string
minLength: 1
maxLength: 64
pattern: '^[A-Za-z0-9_$-]+$'
adminSecretRef:
type: object
required: [name]
properties: {name: {type: string}}
connection:
type: object
properties:
host: {type: string}
port: {type: integer, minimum: 1, maximum: 65535}
user:
type: object
properties:
name: {type: string, maxLength: 32}
create: {type: boolean}
credentials:
type: object
properties:
secretName: {type: string}
privileges:
type: array
items: {type: string}
charset: {type: string}
collation: {type: string}
deletionPolicy:
type: string
enum: [Retain, Delete]
default: Retain
status:
type: object
properties:
phase: {type: string}
message: {type: string}
databaseName: {type: string}
userName: {type: string}
secretName: {type: string}
observedGeneration: {type: integer, format: int64}
conditions:
type: array
items:
type: object
required: [type,status,reason,message,lastTransitionTime]
properties:
type: {type: string}
status: {type: string}
reason: {type: string}
message: {type: string}
lastTransitionTime: {type: string, format: date-time}
@@ -0,0 +1,28 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql-database-operator
namespace: mysql-database-operator-system
spec:
replicas: {{ .Values.replicaCount }}
selector: {matchLabels: {app: mysql-database-operator}}
template:
metadata: {labels: {app: mysql-database-operator}}
spec:
serviceAccountName: {{ .Values.serviceAccount.name }}
securityContext: {runAsNonRoot: true}
containers:
- name: manager
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args: ["--leader-elect={{ .Values.leaderElect }}"]
ports:
- {name: metrics, containerPort: 8080}
- {name: health, containerPort: 8081}
resources: {{- toYaml .Values.resources | nindent 10 }}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
seccompProfile: {type: RuntimeDefault}
readinessProbe: {httpGet: {path: /readyz, port: health}}
livenessProbe: {httpGet: {path: /healthz, port: health}}
@@ -0,0 +1,6 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: Namespace
metadata:
name: mysql-database-operator-system
{{- end }}
@@ -0,0 +1,33 @@
{{- if .Values.rbac.create }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata: {name: mysql-database-operator}
rules:
- apiGroups: [platform.example.com]
resources: [mysqldatabases]
verbs: [get,list,watch,create,update,patch,delete]
- apiGroups: [platform.example.com]
resources: [mysqldatabases/status]
verbs: [get,update,patch]
- apiGroups: [platform.example.com]
resources: [mysqldatabases/finalizers]
verbs: [update]
- apiGroups: [""]
resources: [secrets]
verbs: [get,list,watch,create,update,patch,delete]
- apiGroups: [""]
resources: [events]
verbs: [create,patch]
- apiGroups: [coordination.k8s.io]
resources: [leases]
verbs: [get,list,watch,create,update,patch,delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata: {name: mysql-database-operator}
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: mysql-database-operator}
subjects:
- kind: ServiceAccount
name: {{ .Values.serviceAccount.name }}
namespace: mysql-database-operator-system
{{- end }}
@@ -0,0 +1,5 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Values.serviceAccount.name }}
namespace: mysql-database-operator-system
@@ -0,0 +1,16 @@
image:
repository: ghcr.io/example/mysql-database-operator
tag: 0.1.0
pullPolicy: IfNotPresent
replicaCount: 1
leaderElect: true
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
serviceAccount:
create: true
name: mysql-database-operator
rbac:
create: true
crd:
install: true