diff --git a/internal/controller/postgresqldatabase_controller.go b/internal/controller/postgresqldatabase_controller.go index 785bcf2..e3e5577 100644 --- a/internal/controller/postgresqldatabase_controller.go +++ b/internal/controller/postgresqldatabase_controller.go @@ -112,7 +112,7 @@ func (r *PostgreSQLDatabaseReconciler) reconcile(ctx context.Context, reqLogger status.host = host reqLogger = reqLogger.WithValues("host", host) - if err := r.runPreflight(reqLogger, host, *adminCredentials); err != nil { + if err := r.prepareHost(reqLogger, host, *adminCredentials); err != nil { return status, err } @@ -290,11 +290,10 @@ func (r *PostgreSQLDatabaseReconciler) EnsurePostgreSQLDatabase(ctx context.Cont return nil } -// runPreflight opens an admin connection to the host and verifies controller -// invariants (superuser role membership) before any reconcile work. The -// connection is closed before returning - the downstream postgres.Database -// call opens its own. -func (r *PostgreSQLDatabaseReconciler) runPreflight(log logr.Logger, host string, admin postgres.Credentials) error { +// prepareHost opens an admin connection to the host, runs preflight checks, +// and ensures the management role exists. The connection is closed before +// returning - the downstream postgres.Database call opens its own. +func (r *PostgreSQLDatabaseReconciler) prepareHost(log logr.Logger, host string, admin postgres.Credentials) error { db, err := postgres.Connect(postgres.ConnectionString{ Host: host, Database: "postgres", @@ -303,10 +302,17 @@ func (r *PostgreSQLDatabaseReconciler) runPreflight(log logr.Logger, host string Params: admin.Params, }) if err != nil { - return fmt.Errorf("preflight: connect to host %s: %w", host, err) + return fmt.Errorf("prepare host %s: connect: %w", host, err) } defer db.Close() - return postgres.Preflight(log, db, r.SuperuserRoleName) + + if err := postgres.Preflight(log, db, r.SuperuserRoleName); err != nil { + return err + } + if err := postgres.EnsureManagerRole(log, db, r.ManagerRoleName); err != nil { + return fmt.Errorf("prepare host %s: ensure management role: %w", host, err) + } + return nil } type adminCredentialsParams struct { diff --git a/pkg/postgres/manager_role.go b/pkg/postgres/manager_role.go new file mode 100644 index 0000000..6955738 --- /dev/null +++ b/pkg/postgres/manager_role.go @@ -0,0 +1,23 @@ +package postgres + +import ( + "database/sql" + "fmt" + + "github.com/go-logr/logr" + "github.com/lib/pq" +) + +// EnsureManagerRole creates the management role on db if it does not +// already exist. The connecting user must have privileges to create roles. +// Idempotent: a duplicate_object error is treated as a no-op. +func EnsureManagerRole(log logr.Logger, db *sql.DB, role string) error { + if role == "" { + return fmt.Errorf("ensure manager role: role name is empty") + } + return tryExec(log, db, tryExecReq{ + objectType: "management role", + errorCode: "duplicate_object", + query: fmt.Sprintf("CREATE ROLE %s", pq.QuoteIdentifier(role)), + }) +} diff --git a/pkg/postgres/manager_role_test.go b/pkg/postgres/manager_role_test.go new file mode 100644 index 0000000..d20a187 --- /dev/null +++ b/pkg/postgres/manager_role_test.go @@ -0,0 +1,40 @@ +package postgres_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lunarway.com/postgresql-controller/pkg/postgres" + "go.lunarway.com/postgresql-controller/test" +) + +func TestEnsureManagerRole_createsAndIsIdempotent(t *testing.T) { + host := test.Integration(t) + log := test.SetLogger(t) + + db := preflightAdminConn(t, host) + defer db.Close() + + role := fmt.Sprintf("ensure_manager_%d", time.Now().UnixNano()) + defer func() { + _, _ = db.Exec(fmt.Sprintf("DROP ROLE %s", role)) + }() + + require.NoError(t, postgres.EnsureManagerRole(log, db, role), "first call should create the role") + + var exists bool + require.NoError(t, db.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = $1)`, role).Scan(&exists)) + assert.True(t, exists, "role should exist after EnsureManagerRole") + + assert.NoError(t, postgres.EnsureManagerRole(log, db, role), "second call should be a no-op") +} + +func TestEnsureManagerRole_emptyName(t *testing.T) { + log := test.SetLogger(t) + err := postgres.EnsureManagerRole(log, nil, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "role name is empty") +}