varie
This commit is contained in:
@@ -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.
|
||||
@@ -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 }}
|
||||
+5
@@ -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
|
||||
Reference in New Issue
Block a user