Merge pull request 'feat: Instance 聚合最小生命周期与 checkpoint' (#13) from feature/instance-lifecycle-checkpoints into main
E2E Tests / Run on Ubuntu (push) Failing after 31s
Lint / Run on Ubuntu (push) Failing after 1m3s
Tests / Run on Ubuntu (push) Successful in 3m42s

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-09-16 17:40:20 +00:00
2 changed files with 235 additions and 0 deletions
+101
View File
@@ -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
}
+134
View File
@@ -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")
}
}