6 Commits
Author SHA1 Message Date
panxiao81 dae546e58e Merge pull request 'ci: 按 pod/vm 标签分配测试 runner' (#14) from ci/runner-labels into main
E2E Tests / Run on Ubuntu (push) Failing after 2s
Tests / Run on Ubuntu (push) Failing after 8m29s
Lint / Run on Ubuntu (push) Successful in 11m24s
Reviewed-on: #14
2026-09-20 20:02:43 +00:00
panxiao81 2270e013f0 ci: route lint and tests to pod runners and e2e to vm
E2E Tests / Run on Ubuntu (pull_request) Failing after 2s
Lint / Run on Ubuntu (pull_request) Failing after 1m2s
Tests / Run on Ubuntu (pull_request) Failing after 1m3s
2026-09-16 17:46:15 +00:00
panxiao81 a84c37d842 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
2026-09-16 17:40:20 +00:00
panxiao81 0c66760628 feat: add Instance lifecycle checkpoints
Tests / Run on Ubuntu (pull_request) Failing after 1m33s
Lint / Run on Ubuntu (pull_request) Failing after 1m36s
E2E Tests / Run on Ubuntu (pull_request) Failing after 2m14s
2026-09-16 11:24:41 +00:00
panxiao81 da22b6b266 Merge pull request 'feat: Instance 配置与观察目标值对象' (#12) from feature/instance-observation-target into main
E2E Tests / Run on Ubuntu (push) Failing after 57s
Tests / Run on Ubuntu (push) Successful in 5m5s
Lint / Run on Ubuntu (push) Successful in 6m29s
Reviewed-on: #12
2026-09-16 11:20:37 +00:00
panxiao81 e1c6eec91a feat: add Instance definition and observation target
E2E Tests / Run on Ubuntu (pull_request) Failing after 31s
Tests / Run on Ubuntu (pull_request) Successful in 5m10s
Lint / Run on Ubuntu (pull_request) Successful in 6m50s
2026-09-16 11:15:29 +00:00
7 changed files with 484 additions and 3 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
permissions:
contents: read
name: Run on Ubuntu
runs-on: self-hosted
runs-on: [self-hosted, pod]
steps:
- name: Clone the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
permissions:
contents: read
name: Run on Ubuntu
runs-on: self-hosted
runs-on: [self-hosted, vm]
steps:
- name: Clone the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
permissions:
contents: read
name: Run on Ubuntu
runs-on: self-hosted
runs-on: [self-hosted, pod]
steps:
- name: Clone the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+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")
}
}
@@ -0,0 +1,81 @@
/*
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
// Definition is the immutable effective configuration of an Instance.
// Available extensions are observations, not part of the declared configuration.
type Definition struct {
endpoint Endpoint
adminCredential CredentialReference
}
func NewDefinition(endpoint Endpoint, adminCredential CredentialReference) (Definition, error) {
definition := Definition{endpoint: endpoint, adminCredential: adminCredential}
if err := definition.Validate(); err != nil {
return Definition{}, err
}
return definition, nil
}
func (d Definition) Endpoint() Endpoint { return d.endpoint }
func (d Definition) AdminCredential() CredentialReference { return d.adminCredential }
// Validate rejects invalid zero-value components even when constructors were bypassed.
func (d Definition) Validate() error {
if err := d.endpoint.Validate(); err != nil {
return err
}
return d.adminCredential.Validate()
}
// ObservationTarget binds facts to a registration and its declared configuration.
// It does not identify a physical PostgreSQL server or prove observation freshness.
// Secret content refresh and same-target observation freshness remain application
// responsibilities; no credentials or Secret contents are carried by this value.
type ObservationTarget struct {
identity Identity
revision Revision
definition Definition
}
func NewObservationTarget(identity Identity, revision Revision, definition Definition) (ObservationTarget, error) {
target := ObservationTarget{identity: identity, revision: revision, definition: definition}
if err := target.Validate(); err != nil {
return ObservationTarget{}, err
}
return target, nil
}
func (t ObservationTarget) Identity() Identity { return t.identity }
func (t ObservationTarget) Revision() Revision { return t.revision }
func (t ObservationTarget) Definition() Definition { return t.definition }
func (t ObservationTarget) Validate() error {
if err := t.identity.Validate(); err != nil {
return err
}
if err := t.revision.Validate(); err != nil {
return err
}
return t.definition.Validate()
}
// Matches rejects invalid targets before comparing values. Matching is necessary,
// but not sufficient, for the aggregate to accept a fresh capability observation.
func (t ObservationTarget) Matches(other ObservationTarget) bool {
return t.Validate() == nil && other.Validate() == nil && t == other
}
@@ -0,0 +1,165 @@
/*
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 targetParts(t *testing.T) (instance.Identity, instance.Revision, instance.Definition) {
t.Helper()
identity, err := instance.NewIdentity("uid-1", "shared")
if err != nil {
t.Fatal(err)
}
revision, err := instance.NewRevision(1)
if err != nil {
t.Fatal(err)
}
endpoint, err := instance.NewEndpoint(validEndpoint())
if err != nil {
t.Fatal(err)
}
credential, err := instance.NewCredentialReference(validCredentialReference())
if err != nil {
t.Fatal(err)
}
definition, err := instance.NewDefinition(endpoint, credential)
if err != nil {
t.Fatal(err)
}
return identity, revision, definition
}
func TestDefinitionRejectsInvalidComponents(t *testing.T) {
_, _, definition := targetParts(t)
cases := []struct {
endpoint instance.Endpoint
credential instance.CredentialReference
}{
{instance.Endpoint{}, definition.AdminCredential()},
{definition.Endpoint(), instance.CredentialReference{}},
}
for _, tc := range cases {
value, err := instance.NewDefinition(tc.endpoint, tc.credential)
if err == nil || value != (instance.Definition{}) {
t.Fatal("invalid component accepted or partial value returned")
}
}
if err := (instance.Definition{}).Validate(); err == nil {
t.Fatal("zero definition accepted")
}
}
func TestObservationTargetRejectsInvalidComponents(t *testing.T) {
identity, revision, definition := targetParts(t)
cases := []struct {
identity instance.Identity
revision instance.Revision
definition instance.Definition
}{
{instance.Identity{}, revision, definition},
{identity, instance.Revision{}, definition},
{identity, revision, instance.Definition{}},
}
for _, tc := range cases {
value, err := instance.NewObservationTarget(tc.identity, tc.revision, tc.definition)
if err == nil || value != (instance.ObservationTarget{}) {
t.Fatal("invalid component accepted or partial target returned")
}
}
zero := instance.ObservationTarget{}
if err := zero.Validate(); err == nil {
t.Fatal("zero target accepted")
}
if zero.Matches(zero) {
t.Fatal("two invalid targets must not authorize observation reuse")
}
}
// Acceptance: docs/domain-instance.md §2/§6, observations cannot cross target bindings.
func TestObservationTargetMatchesOnlySameBinding(t *testing.T) {
identity, revision, definition := targetParts(t)
original, err := instance.NewObservationTarget(identity, revision, definition)
if err != nil {
t.Fatal(err)
}
same, err := instance.NewObservationTarget(identity, revision, definition)
if err != nil {
t.Fatal(err)
}
if !original.Matches(same) || original.Identity() != identity ||
original.Revision() != revision || original.Definition() != definition {
t.Fatal("target did not preserve its declared binding")
}
changedIdentity, err := instance.NewIdentity("uid-2", identity.Name())
if err != nil {
t.Fatal(err)
}
changedRevision, err := instance.NewRevision(2)
if err != nil {
t.Fatal(err)
}
for _, parts := range []struct {
identity instance.Identity
revision instance.Revision
}{{changedIdentity, revision}, {identity, changedRevision}} {
changed, err := instance.NewObservationTarget(parts.identity, parts.revision, definition)
if err != nil {
t.Fatal(err)
}
if original.Matches(changed) || changed.Matches(original) {
t.Fatal("different registration or revision matched")
}
}
endpointValues := definition.Endpoint().Values()
endpointValues.Host = "other.example"
endpoint, err := instance.NewEndpoint(endpointValues)
if err != nil {
t.Fatal(err)
}
credentialValues := definition.AdminCredential().Values()
credentialValues.PasswordKey = "replacement"
credential, err := instance.NewCredentialReference(credentialValues)
if err != nil {
t.Fatal(err)
}
for _, components := range []struct {
endpoint instance.Endpoint
credential instance.CredentialReference
}{{endpoint, definition.AdminCredential()}, {definition.Endpoint(), credential}} {
changedDefinition, err := instance.NewDefinition(components.endpoint, components.credential)
if err != nil {
t.Fatal(err)
}
changed, err := instance.NewObservationTarget(identity, revision, changedDefinition)
if err != nil {
t.Fatal(err)
}
if original.Matches(changed) {
t.Fatal("changed definition matched even with the same revision")
}
}
if original.Matches(instance.ObservationTarget{}) {
t.Fatal("valid target matched zero target")
}
if !original.Matches(same) {
t.Fatal("constructing changed targets mutated the original")
}
}