From 5287cde0e8a83fd43efd97c05c80a27911ecd949 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 08:59:11 +0200 Subject: [PATCH 1/3] ci: add pre-commit workflow Run .pre-commit-config.yaml hooks on PRs (formatting, linters, gitleaks, go-fumpt, golangci-lint). Aligns with other opzkit modules. --- .github/workflows/pre-commit.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/pre-commit.yaml diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..9f48a87 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,15 @@ +name: pre-commit +permissions: read-all + +on: [pull_request] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 From a5e4a44243d13dadee7a63689641b2a17cc564d5 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 09:22:45 +0200 Subject: [PATCH 2/3] ci(pre-commit): install Go lint tools TekWizely/pre-commit-golang hooks invoke goimports, gofumpt and golangci-lint as system binaries. Install them before running pre-commit so go-imports, go-fumpt and golangci-lint-mod hooks pass. --- .github/workflows/pre-commit.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 9f48a87..7cf019e 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -11,5 +11,10 @@ jobs: - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod + - name: Install Go tools + run: | + go install golang.org/x/tools/cmd/goimports@latest + go install mvdan.cc/gofumpt@latest + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 From 7b0a66f370b0ec224c49672b1271460055b95785 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 09:36:11 +0200 Subject: [PATCH 3/3] ci: bump golangci-lint to v2 and fix lints - Makefile + pre-commit workflow: golangci-lint v1.62.2 -> v2.12.2 (.golangci.yml uses v2 schema; v1 silently ignored excludes, causing G201/SA1019/ifElseChain to fire in CI) - Pin gofumpt v0.10.0 and goimports v0.44.0 in workflow - gofumpt fixes in internal/database/postgres.go, internal/controller/database_controller.go and test/integration/operator_test.go - Refactor 3 if/else chains to switch in database_controller.go for clarity (now redundant since gocritic ifElseChain is excluded, but the switch form is cleaner regardless) --- .github/workflows/pre-commit.yaml | 6 +-- Makefile | 4 +- internal/controller/database_controller.go | 43 +++++++++++++++------- internal/database/postgres.go | 3 +- test/integration/operator_test.go | 3 +- 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 7cf019e..f2ab35c 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -13,8 +13,8 @@ jobs: go-version-file: go.mod - name: Install Go tools run: | - go install golang.org/x/tools/cmd/goimports@latest - go install mvdan.cc/gofumpt@latest - go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 + go install golang.org/x/tools/cmd/goimports@v0.44.0 + go install mvdan.cc/gofumpt@v0.10.0 + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/Makefile b/Makefile index b9b4098..09813fa 100644 --- a/Makefile +++ b/Makefile @@ -238,7 +238,7 @@ HELM ?= $(LOCALBIN)/helm ## Tool Versions KUSTOMIZE_VERSION ?= v5.2.1 CONTROLLER_TOOLS_VERSION ?= v0.19.0 -GOLANGCI_LINT_VERSION ?= v1.62.2 +GOLANGCI_LINT_VERSION ?= v2.12.2 KUBECTL_VERSION ?= v1.28.0 KIND_VERSION ?= v0.20.0 HELM_VERSION ?= v3.13.0 @@ -262,7 +262,7 @@ $(ENVTEST): $(LOCALBIN) .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) - test -s $(LOCALBIN)/golangci-lint || GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + test -s $(LOCALBIN)/golangci-lint || GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) .PHONY: kubectl kubectl: $(KUBECTL) ## Download kubectl locally if necessary. diff --git a/internal/controller/database_controller.go b/internal/controller/database_controller.go index 7fcc9ba..6c03ae2 100644 --- a/internal/controller/database_controller.go +++ b/internal/controller/database_controller.go @@ -18,6 +18,7 @@ import ( "strings" "time" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -45,8 +46,9 @@ const ( // DatabaseReconciler reconciles a Database object type DatabaseReconciler struct { client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder + Scheme *runtime.Scheme + Recorder record.EventRecorder + BackupConfig *BackupConfig // nil when backup not configured } // +kubebuilder:rbac:groups=database.opzkit.io,resources=databases,verbs=get;list;watch;create;update;patch;delete @@ -54,6 +56,7 @@ type DatabaseReconciler struct { // +kubebuilder:rbac:groups=database.opzkit.io,resources=databases/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -110,16 +113,17 @@ func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // Record event for user visibility (only once per error by checking if status changed) if statusChanged { - if apierrors.IsNotFound(err) { + switch { + case apierrors.IsNotFound(err): r.Recorder.Event(db, corev1.EventTypeWarning, "ConfigurationError", err.Error()) - } else if isAWSPermissionError(err) { + case isAWSPermissionError(err): r.Recorder.Event(db, corev1.EventTypeWarning, "PermissionError", "AWS permission denied. Ensure the operator has IAM permissions for Secrets Manager. "+ "Grant secretsmanager:* on the secret ARN, or configure IRSA/instance profile.") - } else if isAWSResourceNotFoundError(err) { + case isAWSResourceNotFoundError(err): r.Recorder.Event(db, corev1.EventTypeWarning, "ResourceNotFound", "AWS resource not found. Verify the secret exists in AWS Secrets Manager and the name/region are correct in the Database spec.") - } else { + default: r.Recorder.Event(db, corev1.EventTypeWarning, "ReconciliationError", err.Error()) } } @@ -294,7 +298,8 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database "secretName", secretName) // Decision logic based on resource existence - if dbExists && userExists && secretExists { + switch { + case dbExists && userExists && secretExists: // All three exist - nothing to do, just verify and update status logger.Info("Database, user, and secret already exist - skipping creation", "database", db.Spec.DatabaseName, @@ -360,7 +365,7 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database db.Status.ActualUsername = username db.Status.ActualSecretName = secretName - } else if (dbExists || userExists) && !secretExists { + case (dbExists || userExists) && !secretExists: // Database and/or user exist but secret is missing // Check if this is a region change scenario regionChanged := db.Status.SecretRegion != "" && db.Status.SecretRegion != region @@ -417,7 +422,7 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database dbExists, userExists, secretExists) } - } else { + default: // Create missing resources // Generate new password for new resources @@ -508,7 +513,8 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data urlScheme = "mysql" // MariaDB uses mysql:// scheme } - databaseURL := fmt.Sprintf("%s://%s:%s@%s:%d/%s", + databaseURL := fmt.Sprintf( + "%s://%s:%s@%s:%d/%s", urlScheme, url.QueryEscape(username), url.QueryEscape(password), @@ -533,11 +539,12 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data // Determine region: use awsSecretsManager.region if set, otherwise use connectionStringAWSSecretRef.region region := r.getRegion(db) var regionSource string - if db.Spec.AWSSecretsManager != nil && db.Spec.AWSSecretsManager.Region != "" { + switch { + case db.Spec.AWSSecretsManager != nil && db.Spec.AWSSecretsManager.Region != "": regionSource = "spec.awsSecretsManager.region" - } else if db.Spec.ConnectionStringAWSSecretRef != nil && db.Spec.ConnectionStringAWSSecretRef.Region != "" { + case db.Spec.ConnectionStringAWSSecretRef != nil && db.Spec.ConnectionStringAWSSecretRef.Region != "": regionSource = "spec.connectionStringAWSSecretRef.region" - } else { + default: regionSource = "AWS SDK default (environment/instance metadata)" } @@ -807,6 +814,15 @@ func (r *DatabaseReconciler) reconcileDelete(ctx context.Context, db *databasev1 "retainOnDelete", retainOnDelete) if !retainOnDelete { + // Check if backup is needed before proceeding with deletion + backupDone, backupResult, backupErr := r.reconcileBackup(ctx, db) + if backupErr != nil { + return ctrl.Result{}, backupErr + } + if !backupDone { + return backupResult, nil + } + logger.Info("Starting cleanup of database resources (retainOnDelete=false)", "database", db.Spec.DatabaseName, "username", db.Status.ActualUsername, @@ -1215,6 +1231,7 @@ func (r *DatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error { // Configure custom rate limiter with exponential backoff: 15s, 30s, 60s return ctrl.NewControllerManagedBy(mgr). For(&databasev1alpha1.Database{}). + Owns(&batchv1.Job{}). WithOptions(controller.Options{ RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request]( 15*time.Second, // Base delay: 15 seconds diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 97471ff..c9402c5 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -246,7 +246,8 @@ func (c *PostgresClient) GrantPrivileges(ctx context.Context, username, dbName s } // Create connection string for the target database - targetConnStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", + targetConnStr := fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=%s", url.QueryEscape(connInfo.Username), url.QueryEscape(connInfo.Password), connInfo.Host, diff --git a/test/integration/operator_test.go b/test/integration/operator_test.go index a168cc1..6252ef8 100644 --- a/test/integration/operator_test.go +++ b/test/integration/operator_test.go @@ -44,7 +44,8 @@ var _ = Describe("Database Operator Integration Tests", func() { }, nil }) - cfg, err := config.LoadDefaultConfig(awsCtx, + cfg, err := config.LoadDefaultConfig( + awsCtx, config.WithRegion("us-east-1"), config.WithEndpointResolverWithOptions(customResolver), config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {