diff --git a/internal/controller/postgresqlinstance_controller.go b/internal/controller/postgresqlinstance_controller.go index cabbc31..c0f2ac0 100644 --- a/internal/controller/postgresqlinstance_controller.go +++ b/internal/controller/postgresqlinstance_controller.go @@ -56,13 +56,10 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R } before := instance.DeepCopy() - - if !instance.DeletionTimestamp.IsZero() { - instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting - setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance deletion is reconciling") - } else if instance.Status.Phase == "" { - instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating - setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are being validated") + phaseResult := newInstanceStateMachine().reconcile(instance) + instance.Status.Phase = phaseResult.phase + if phaseResult.reconcilingMessage != "" { + setReconcilingCondition(&instance.Status.Conditions, instance.Generation, phaseResult.reconcilingMessage) } if !reflect.DeepEqual(before.Status, instance.Status) { diff --git a/internal/controller/postgresqlinstance_state_machine.go b/internal/controller/postgresqlinstance_state_machine.go new file mode 100644 index 0000000..ddad1c1 --- /dev/null +++ b/internal/controller/postgresqlinstance_state_machine.go @@ -0,0 +1,73 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" + +type instancePhaseResult struct { + phase databasev1alpha1.PostgreSQLInstancePhase + reconcilingMessage string +} + +type instancePhaseHandler func(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult + +type instanceStateMachine struct { + handlers map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler +} + +func newInstanceStateMachine() instanceStateMachine { + return instanceStateMachine{handlers: map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler{ + databasev1alpha1.PostgreSQLInstancePhasePending: reconcileInstancePending, + databasev1alpha1.PostgreSQLInstancePhaseValidating: keepInstancePhase, + databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry: keepInstancePhase, + databasev1alpha1.PostgreSQLInstancePhaseReady: keepInstancePhase, + databasev1alpha1.PostgreSQLInstancePhaseDeleting: reconcileInstanceDeleting, + }} +} + +func (m instanceStateMachine) reconcile(instance *databasev1alpha1.PostgreSQLInstance) instancePhaseResult { + phase := instance.Status.Phase + if !instance.DeletionTimestamp.IsZero() { + phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting + } else if phase == "" { + phase = databasev1alpha1.PostgreSQLInstancePhasePending + } + + handler, found := m.handlers[phase] + if !found { + return instancePhaseResult{phase: phase} + } + return handler(instance) +} + +func reconcileInstancePending(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult { + return instancePhaseResult{ + phase: databasev1alpha1.PostgreSQLInstancePhaseValidating, + reconcilingMessage: "instance dependencies are being validated", + } +} + +func reconcileInstanceDeleting(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult { + return instancePhaseResult{ + phase: databasev1alpha1.PostgreSQLInstancePhaseDeleting, + reconcilingMessage: "instance deletion is reconciling", + } +} + +func keepInstancePhase(instance *databasev1alpha1.PostgreSQLInstance) instancePhaseResult { + return instancePhaseResult{phase: instance.Status.Phase} +} diff --git a/internal/controller/postgresqltenant_controller.go b/internal/controller/postgresqltenant_controller.go index d3cef12..b4eaf22 100644 --- a/internal/controller/postgresqltenant_controller.go +++ b/internal/controller/postgresqltenant_controller.go @@ -56,12 +56,10 @@ func (r *PostgreSQLTenantReconciler) Reconcile(ctx context.Context, req ctrl.Req } before := tenant.DeepCopy() - if !tenant.DeletionTimestamp.IsZero() { - tenant.Status.Phase = databasev1alpha1.PostgreSQLTenantPhaseDeleting - setReconcilingCondition(&tenant.Status.Conditions, tenant.Generation, "tenant deletion is reconciling") - } else if tenant.Status.Phase == "" { - tenant.Status.Phase = databasev1alpha1.PostgreSQLTenantPhasePending - setReconcilingCondition(&tenant.Status.Conditions, tenant.Generation, "tenant is waiting for its instance") + phaseResult := newTenantStateMachine().reconcile(tenant) + tenant.Status.Phase = phaseResult.phase + if phaseResult.reconcilingMessage != "" { + setReconcilingCondition(&tenant.Status.Conditions, tenant.Generation, phaseResult.reconcilingMessage) } if !reflect.DeepEqual(before.Status, tenant.Status) { diff --git a/internal/controller/postgresqltenant_state_machine.go b/internal/controller/postgresqltenant_state_machine.go new file mode 100644 index 0000000..25ed1d0 --- /dev/null +++ b/internal/controller/postgresqltenant_state_machine.go @@ -0,0 +1,77 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" + +type tenantPhaseResult struct { + phase databasev1alpha1.PostgreSQLTenantPhase + reconcilingMessage string +} + +type tenantPhaseHandler func(*databasev1alpha1.PostgreSQLTenant) tenantPhaseResult + +type tenantStateMachine struct { + handlers map[databasev1alpha1.PostgreSQLTenantPhase]tenantPhaseHandler +} + +func newTenantStateMachine() tenantStateMachine { + return tenantStateMachine{handlers: map[databasev1alpha1.PostgreSQLTenantPhase]tenantPhaseHandler{ + databasev1alpha1.PostgreSQLTenantPhasePending: reconcileTenantPending, + databasev1alpha1.PostgreSQLTenantPhasePlanned: keepTenantPhase, + databasev1alpha1.PostgreSQLTenantPhaseCredentialCreated: keepTenantPhase, + databasev1alpha1.PostgreSQLTenantPhaseRoleCreated: keepTenantPhase, + databasev1alpha1.PostgreSQLTenantPhaseDatabaseCreated: keepTenantPhase, + databasev1alpha1.PostgreSQLTenantPhaseExternalSecretCreated: keepTenantPhase, + databasev1alpha1.PostgreSQLTenantPhaseCredentialProjected: keepTenantPhase, + databasev1alpha1.PostgreSQLTenantPhaseReady: keepTenantPhase, + databasev1alpha1.PostgreSQLTenantPhaseDeleting: reconcileTenantDeleting, + }} +} + +func (m tenantStateMachine) reconcile(tenant *databasev1alpha1.PostgreSQLTenant) tenantPhaseResult { + phase := tenant.Status.Phase + if !tenant.DeletionTimestamp.IsZero() { + phase = databasev1alpha1.PostgreSQLTenantPhaseDeleting + } else if phase == "" { + phase = databasev1alpha1.PostgreSQLTenantPhasePending + } + + handler, found := m.handlers[phase] + if !found { + return tenantPhaseResult{phase: phase} + } + return handler(tenant) +} + +func reconcileTenantPending(*databasev1alpha1.PostgreSQLTenant) tenantPhaseResult { + return tenantPhaseResult{ + phase: databasev1alpha1.PostgreSQLTenantPhasePending, + reconcilingMessage: "tenant is waiting for its instance", + } +} + +func reconcileTenantDeleting(*databasev1alpha1.PostgreSQLTenant) tenantPhaseResult { + return tenantPhaseResult{ + phase: databasev1alpha1.PostgreSQLTenantPhaseDeleting, + reconcilingMessage: "tenant deletion is reconciling", + } +} + +func keepTenantPhase(tenant *databasev1alpha1.PostgreSQLTenant) tenantPhaseResult { + return tenantPhaseResult{phase: tenant.Status.Phase} +} diff --git a/internal/controller/state_machine_test.go b/internal/controller/state_machine_test.go new file mode 100644 index 0000000..ba1c3a6 --- /dev/null +++ b/internal/controller/state_machine_test.go @@ -0,0 +1,107 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" +) + +var _ = Describe("phase handler state machines", func() { + DescribeTable("dispatches Instance phases", + func(instance *databasev1alpha1.PostgreSQLInstance, expected databasev1alpha1.PostgreSQLInstancePhase, hasMessage bool) { + result := newInstanceStateMachine().reconcile(instance) + Expect(result.phase).To(Equal(expected)) + if hasMessage { + Expect(result.reconcilingMessage).NotTo(BeEmpty()) + } else { + Expect(result.reconcilingMessage).To(BeEmpty()) + } + }, + Entry("starts validation from an empty checkpoint", + &databasev1alpha1.PostgreSQLInstance{}, + databasev1alpha1.PostgreSQLInstancePhaseValidating, + true, + ), + Entry("keeps an active phase until its handler can observe dependencies", + &databasev1alpha1.PostgreSQLInstance{Status: databasev1alpha1.PostgreSQLInstanceStatus{ + Phase: databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry, + }}, + databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry, + false, + ), + Entry("routes deletion independently of the previous phase", + deletingInstance(), + databasev1alpha1.PostgreSQLInstancePhaseDeleting, + true, + ), + ) + + DescribeTable("dispatches Tenant phases", + func(tenant *databasev1alpha1.PostgreSQLTenant, expected databasev1alpha1.PostgreSQLTenantPhase, hasMessage bool) { + result := newTenantStateMachine().reconcile(tenant) + Expect(result.phase).To(Equal(expected)) + if hasMessage { + Expect(result.reconcilingMessage).NotTo(BeEmpty()) + } else { + Expect(result.reconcilingMessage).To(BeEmpty()) + } + }, + Entry("starts pending from an empty checkpoint", + &databasev1alpha1.PostgreSQLTenant{}, + databasev1alpha1.PostgreSQLTenantPhasePending, + true, + ), + Entry("keeps an active phase until its handler can observe dependencies", + &databasev1alpha1.PostgreSQLTenant{Status: databasev1alpha1.PostgreSQLTenantStatus{ + Phase: databasev1alpha1.PostgreSQLTenantPhaseCredentialCreated, + }}, + databasev1alpha1.PostgreSQLTenantPhaseCredentialCreated, + false, + ), + Entry("routes deletion independently of the previous phase", + deletingTenant(), + databasev1alpha1.PostgreSQLTenantPhaseDeleting, + true, + ), + ) +}) + +func deletingInstance() *databasev1alpha1.PostgreSQLInstance { + deletionTimestamp := metav1.NewTime(time.Now()) + return &databasev1alpha1.PostgreSQLInstance{ + ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &deletionTimestamp}, + Status: databasev1alpha1.PostgreSQLInstanceStatus{ + Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, + }, + } +} + +func deletingTenant() *databasev1alpha1.PostgreSQLTenant { + deletionTimestamp := metav1.NewTime(time.Now()) + return &databasev1alpha1.PostgreSQLTenant{ + ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &deletionTimestamp}, + Status: databasev1alpha1.PostgreSQLTenantStatus{ + Phase: databasev1alpha1.PostgreSQLTenantPhaseReady, + }, + } +}