From 5a7b38ad268fb5492511f571ad9b444fffbc3f07 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 20 Sep 2026 20:33:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Instance=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E8=83=BD=E5=8A=9B=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/database/README.md | 5 +- internal/database/domain/instance/instance.go | 30 ++++- .../instance/instance_extensions_test.go | 105 ++++++++++++++++++ 3 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 internal/database/domain/instance/instance_extensions_test.go diff --git a/docs/database/README.md b/docs/database/README.md index c6a22cc..2855d14 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -20,8 +20,9 @@ Database 是 Ayatori 首批实际产品领域之一。第一个迁移切片只 `instance_extensions_test.go` 未进入本切片。 代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新; -领域行为未在本切片中扩展。后续 Ready/observation 行为应在 Ayatori 内先更新合同与测试,再继续 -实现,不能把旧运行链路接回该模型。 +首个后续切片按已批准合同增加 Instance extension observation:观测与当前 target 绑定,进入重新 +验证或删除时失效,且支持判定不授权 Tenant provisioning。其余 Ready/observation 行为仍应先更新 +合同与测试再实现,不能把旧运行链路接回该模型。 ## 边界 diff --git a/internal/database/domain/instance/instance.go b/internal/database/domain/instance/instance.go index 3eff6dd..ef5ff8b 100644 --- a/internal/database/domain/instance/instance.go +++ b/internal/database/domain/instance/instance.go @@ -50,9 +50,10 @@ type Snapshot struct { // 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 + target ObservationTarget + snapshot Snapshot + deleting bool + extensions ExtensionSupport } func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*Instance, error) { @@ -83,6 +84,7 @@ func (i *Instance) BeginValidation() error { } i.snapshot.Phase = PhaseValidating i.snapshot.Readiness = Unknown + i.extensions = ExtensionSupport{} return nil } @@ -97,5 +99,27 @@ func (i *Instance) BeginDeletion() error { } i.snapshot.Phase = PhaseDeleting i.snapshot.Readiness = Unknown + i.extensions = ExtensionSupport{} return nil } + +// ObserveExtensions accepts facts only for this registration and configuration. +// Unobserved support clears a previous list after a failed read; the application +// reports the dependency failure separately. This does not establish readiness. +// Same-target freshness and Secret refresh are enforced by the application. +func (i *Instance) ObserveExtensions(target ObservationTarget, support ExtensionSupport) error { + if !i.target.Matches(target) { + return errors.New("extension observation target does not match instance") + } + if i.deleting { + return errors.New("cannot accept extension observations after deletion was requested") + } + i.extensions = support + return nil +} + +// CheckExtensions checks support only; Accepted is not authorization to provision. +// The aggregate does not perform IO, mutate its snapshot or uninstall extensions. +func (i *Instance) CheckExtensions(requested ExtensionSet) ExtensionCheck { + return i.extensions.Check(requested) +} diff --git a/internal/database/domain/instance/instance_extensions_test.go b/internal/database/domain/instance/instance_extensions_test.go new file mode 100644 index 0000000..92f012f --- /dev/null +++ b/internal/database/domain/instance/instance_extensions_test.go @@ -0,0 +1,105 @@ +/* +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/ayatori/internal/database/domain/instance" +) + +// Acceptance: docs/database/domain-instance.md §2/§3, extension observations are +// bound to the current registration and definition and never authorize provisioning. +func TestInstanceAcceptsExtensionObservationForCurrentTarget(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) + target := value.Target() + snapshot := value.Snapshot() + + if err := value.ObserveExtensions(target, instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(instance.NewExtensionSet([]string{"pg_trgm"})); got.Decision != instance.ExtensionsAccepted { + t.Fatalf("CheckExtensions() = %v, want accepted", got) + } + if value.Snapshot() != snapshot { + t.Fatal("extension observation changed the persisted lifecycle snapshot") + } +} + +func TestInstanceRejectsExtensionObservationForDifferentTarget(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) + identity, _, definition := targetParts(t) + revision, err := instance.NewRevision(value.Target().Revision().Value() + 1) + if err != nil { + t.Fatal(err) + } + different, err := instance.NewObservationTarget(identity, revision, definition) + if err != nil { + t.Fatal(err) + } + + if err := value.ObserveExtensions(different, instance.ObserveExtensionSupport([]string{"pg_trgm"})); err == nil { + t.Fatal("observation for a different target was accepted") + } + if got := value.CheckExtensions(instance.NewExtensionSet([]string{"pg_trgm"})); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("rejected observation changed support: %v", got) + } +} + +func TestInstanceClearsExtensionObservationAcrossLifecycleBoundaries(t *testing.T) { + requested := instance.NewExtensionSet([]string{"pg_trgm"}) + + t.Run("validation", func(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, false) + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + t.Fatal(err) + } + if err := value.BeginValidation(); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("validation retained stale support: %v", got) + } + }) + + t.Run("deletion", func(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseReady}, true) + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err == nil { + t.Fatal("deleting instance accepted a new observation") + } + if err := value.BeginDeletion(); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("deletion retained extension support: %v", got) + } + }) +} + +func TestInstanceCanExplicitlyInvalidateExtensionObservation(t *testing.T) { + value := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false) + requested := instance.NewExtensionSet([]string{"pg_trgm"}) + if err := value.ObserveExtensions(value.Target(), instance.ObserveExtensionSupport([]string{"pg_trgm"})); err != nil { + t.Fatal(err) + } + if err := value.ObserveExtensions(value.Target(), instance.ExtensionSupport{}); err != nil { + t.Fatal(err) + } + if got := value.CheckExtensions(requested); got.Decision != instance.ExtensionSupportUnobserved { + t.Fatalf("explicit invalidation retained stale support: %v", got) + } +}