diff --git a/internal/domain/instance/instance.go b/internal/domain/instance/instance.go new file mode 100644 index 0000000..3eff6dd --- /dev/null +++ b/internal/domain/instance/instance.go @@ -0,0 +1,101 @@ +/* +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 instance + +import "errors" + +// Phase is a workflow checkpoint, never evidence of external resource state. +type Phase string + +const ( + PhasePending Phase = "Pending" + PhaseValidating Phase = "Validating" + PhaseInitializingRegistry Phase = "InitializingRegistry" + PhaseReady Phase = "Ready" + PhaseDeleting Phase = "Deleting" +) + +type Readiness string + +const ( + Unknown Readiness = "Unknown" + Ready Readiness = "Ready" + NotReady Readiness = "NotReady" +) + +// Snapshot contains persisted observations only, without credentials or live evidence. +// Failure detail mapping will be added with capability assessment, not intent transitions. +type Snapshot struct { + Phase Phase + ObservedRevision int64 + Readiness Readiness + ReportedVersion string +} + +// Instance protects registration state and pure lifecycle transitions. +// Reconstitution does not establish live capability evidence, even for a Ready snapshot. +// This initial slice deliberately exposes no operation that authorizes provisioning. +type Instance struct { + target ObservationTarget + snapshot Snapshot + deleting bool +} + +func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*Instance, error) { + if err := target.Validate(); err != nil { + return nil, err + } + switch snapshot.Phase { + case PhasePending, PhaseValidating, PhaseInitializingRegistry, PhaseReady, PhaseDeleting: + default: + snapshot.Phase = PhasePending + snapshot.Readiness = Unknown + } + return &Instance{target: target, snapshot: snapshot, deleting: deleting}, nil +} + +func (i *Instance) Target() ObservationTarget { return i.target } + +// Snapshot returns a detached value. Persisting it remains the application's job. +func (i *Instance) Snapshot() Snapshot { return i.snapshot } + +// BeginValidation records intent only; it does not claim a concluded observation. +func (i *Instance) BeginValidation() error { + if err := i.target.Validate(); err != nil { + return err + } + if i.deleting { + return errors.New("cannot begin validation after deletion was requested") + } + i.snapshot.Phase = PhaseValidating + i.snapshot.Readiness = Unknown + return nil +} + +// BeginDeletion stops the lifecycle from accepting validation. It does not delete +// resources, inspect Tenant references, close connections or modify finalizers. +func (i *Instance) BeginDeletion() error { + if err := i.target.Validate(); err != nil { + return err + } + if !i.deleting { + return errors.New("cannot begin deletion without a deletion request") + } + i.snapshot.Phase = PhaseDeleting + i.snapshot.Readiness = Unknown + return nil +} diff --git a/internal/domain/instance/instance_test.go b/internal/domain/instance/instance_test.go new file mode 100644 index 0000000..e10728f --- /dev/null +++ b/internal/domain/instance/instance_test.go @@ -0,0 +1,134 @@ +/* +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 instance_test + +import ( + "testing" + + "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance" +) + +func lifecycleInstance(t *testing.T, snapshot instance.Snapshot, deleting bool) *instance.Instance { + t.Helper() + identity, revision, definition := targetParts(t) + target, err := instance.NewObservationTarget(identity, revision, definition) + if err != nil { + t.Fatal(err) + } + value, err := instance.Reconstitute(target, snapshot, deleting) + if err != nil { + t.Fatal(err) + } + return value +} + +// Acceptance: docs/domain-instance.md ยง3, checkpoint reconstruction and intent-only transitions. +func TestReconstituteCheckpoints(t *testing.T) { + for _, phase := range []instance.Phase{ + instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry, + instance.PhaseReady, instance.PhaseDeleting, + } { + snapshot := instance.Snapshot{Phase: phase, ObservedRevision: 1, Readiness: instance.Ready, ReportedVersion: "17"} + value := lifecycleInstance(t, snapshot, false) + if value.Snapshot() != snapshot { + t.Fatal("known checkpoint was not preserved") + } + // A snapshot is detached; it is not a setter on the aggregate. + copy := value.Snapshot() + copy.Phase = instance.PhasePending + copy.ReportedVersion = "changed" + if value.Snapshot() != snapshot { + t.Fatal("snapshot mutation changed aggregate") + } + } + for _, phase := range []instance.Phase{"", "unknown"} { + value := lifecycleInstance(t, instance.Snapshot{Phase: phase, Readiness: instance.Ready}, false) + if value.Snapshot().Phase != instance.PhasePending || value.Snapshot().Readiness != instance.Unknown { + t.Fatal("missing or unknown checkpoint did not restart conservatively") + } + } + if value, err := instance.Reconstitute(instance.ObservationTarget{}, instance.Snapshot{}, false); err == nil || value != nil { + t.Fatal("invalid target reconstructed an aggregate") + } +} + +func TestBeginValidationPreservesObservedRevision(t *testing.T) { + snapshot := instance.Snapshot{ + Phase: instance.PhaseReady, ObservedRevision: 0, Readiness: instance.Ready, ReportedVersion: "17", + } + value := lifecycleInstance(t, snapshot, false) + target := value.Target() + for range 2 { + if err := value.BeginValidation(); err != nil { + t.Fatal(err) + } + got := value.Snapshot() + if got.Phase != instance.PhaseValidating || got.Readiness != instance.Unknown || + got.ObservedRevision != snapshot.ObservedRevision || got.ReportedVersion != snapshot.ReportedVersion { + t.Fatal("recording validation intent claimed a completed observation or erased diagnostic version") + } + } + if value.Target() != target { + t.Fatal("lifecycle action mutated identity or configuration") + } +} + +func TestDeletionRequiresRequestAndPreventsValidation(t *testing.T) { + snapshot := instance.Snapshot{Phase: instance.PhaseReady, Readiness: instance.Ready, ObservedRevision: 1} + active := lifecycleInstance(t, snapshot, false) + if err := active.BeginDeletion(); err == nil { + t.Fatal("deletion without a request accepted") + } + if active.Snapshot() != snapshot { + t.Fatal("rejected deletion mutated state") + } + for _, phase := range []instance.Phase{ + instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry, + instance.PhaseReady, instance.PhaseDeleting, + } { + snapshot.Phase = phase + value := lifecycleInstance(t, snapshot, true) + if err := value.BeginValidation(); err == nil { + t.Fatal("validation accepted after deletion request") + } + if value.Snapshot() != snapshot { + t.Fatal("rejected validation mutated state") + } + for range 2 { + if err := value.BeginDeletion(); err != nil { + t.Fatal(err) + } + if got := value.Snapshot(); got.Phase != instance.PhaseDeleting || got.Readiness != instance.Unknown || + got.ObservedRevision != snapshot.ObservedRevision { + t.Fatal("incorrect deletion checkpoint") + } + } + } +} + +func TestZeroInstanceCannotTransition(t *testing.T) { + var value instance.Instance + if err := value.BeginValidation(); err == nil { + t.Fatal("zero instance started validation") + } + if err := value.BeginDeletion(); err == nil { + t.Fatal("zero instance started deletion") + } + if value.Snapshot() != (instance.Snapshot{}) { + t.Fatal("invalid transition changed zero instance") + } +}