Skip to content

Commit 94bb33d

Browse files
committed
Add external deletion detection and recreation
Modify GetOrCreateOSResource to detect when a managed, non-imported resource has been deleted externally from OpenStack. Signal the caller via the typed ExternallyDeleted ReconcileStatus so it can clear status.id and trigger recreation. Restore the safety guard for unexpected nil returns from actuators. Fix Available condition to correctly transition from Unknown to False when a terminal error is present during resync. Include unit tests for external deletion handling and E2E tests covering both managed resource recreation and imported resource terminal error behavior.
1 parent 9dbed5f commit 94bb33d

19 files changed

Lines changed: 770 additions & 3 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package flavor
2+
3+
import (
4+
"testing"
5+
6+
"k8s.io/utils/ptr"
7+
8+
orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
9+
)
10+
11+
func TestFlavorAdapterIsImported(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
spec orcv1alpha1.FlavorSpec
15+
want bool
16+
}{
17+
{
18+
name: "no import - created by ORC",
19+
spec: orcv1alpha1.FlavorSpec{
20+
Resource: &orcv1alpha1.FlavorResourceSpec{},
21+
},
22+
want: false,
23+
},
24+
{
25+
name: "import is nil - created by ORC",
26+
spec: orcv1alpha1.FlavorSpec{
27+
Import: nil,
28+
},
29+
want: false,
30+
},
31+
{
32+
name: "import with non-nil ID",
33+
spec: orcv1alpha1.FlavorSpec{
34+
Import: &orcv1alpha1.FlavorImport{
35+
ID: ptr.To("some-uuid-1234"),
36+
},
37+
},
38+
want: true,
39+
},
40+
{
41+
name: "import with non-nil filter",
42+
spec: orcv1alpha1.FlavorSpec{
43+
Import: &orcv1alpha1.FlavorImport{
44+
Filter: &orcv1alpha1.FlavorFilter{
45+
Name: (*orcv1alpha1.OpenStackName)(ptr.To("my-flavor")),
46+
},
47+
},
48+
},
49+
want: true,
50+
},
51+
}
52+
53+
for _, tt := range tests {
54+
t.Run(tt.name, func(t *testing.T) {
55+
adapter := flavorAdapter{
56+
Flavor: &orcv1alpha1.Flavor{
57+
Spec: tt.spec,
58+
},
59+
}
60+
got := adapter.IsImported()
61+
if got != tt.want {
62+
t.Errorf("IsImported() = %v, want %v", got, tt.want)
63+
}
64+
})
65+
}
66+
}

internal/controllers/generic/reconciler/resource_actions.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,26 @@ func GetOrCreateOSResource[
7070
osResource, reconcileStatus := actuator.GetOSResourceByID(ctx, *resourceID)
7171
if needsReschedule, err := reconcileStatus.NeedsReschedule(); needsReschedule {
7272
if orcerrors.IsNotFound(err) {
73-
// An OpenStack resource we previously referenced has been deleted unexpectedly. We can't recover from this.
73+
// The OpenStack resource referenced by status.id no longer exists.
74+
// For managed, non-imported resources we trigger recreation by returning
75+
// nil with no error: the caller will clear status.id and re-enter the
76+
// creation path on the next reconcile.
77+
// For unmanaged resources or resources that were originally imported we
78+
// cannot recreate them, so we return a terminal error.
79+
if objAdapter.GetManagementPolicy() == orcv1alpha1.ManagementPolicyManaged && !objAdapter.IsImported() {
80+
log.V(logging.Info).Info("OpenStack resource was deleted externally; will signal caller to clear status ID and trigger recreation")
81+
return nil, progress.ExternallyDeleted()
82+
}
7483
return osResource, progress.WrapError(
7584
orcerrors.Terminal(orcv1alpha1.ConditionReasonUnrecoverableError, "resource has been deleted from OpenStack"))
7685
} else {
7786
return osResource, reconcileStatus
7887
}
7988
}
80-
if osResource != nil {
81-
log.V(logging.Verbose).Info("Got existing OpenStack resource", "ID", actuator.GetResourceID(osResource))
89+
if osResource == nil {
90+
return nil, progress.WrapError(fmt.Errorf("GetOSResourceByID returned nil resource with no error for ID %q", *resourceID))
8291
}
92+
log.V(logging.Verbose).Info("Got existing OpenStack resource", "ID", actuator.GetResourceID(osResource))
8393
return osResource, nil
8494
}
8595

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*
2+
Copyright 2025 The ORC Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package reconciler contains unit tests for external deletion handling in
18+
// GetOrCreateOSResource.
19+
//
20+
// These tests cover all combinations of management policy and import status
21+
// when a resource's OpenStack counterpart is not found (404), verifying that:
22+
//
23+
// - Managed, ORC-created resources trigger recreation (IsExternallyDeleted)
24+
// - Managed, imported-by-ID resources return a terminal error
25+
// - Managed, imported-by-filter resources return a terminal error
26+
// - Unmanaged resources return a terminal error
27+
// - Managed, existing resources continue through the normal update flow
28+
package reconciler
29+
30+
import (
31+
"context"
32+
"errors"
33+
"testing"
34+
35+
"github.com/go-logr/logr"
36+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
37+
"k8s.io/utils/ptr"
38+
39+
orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
40+
orcerrors "github.com/k-orc/openstack-resource-controller/v2/internal/util/errors"
41+
)
42+
43+
// --------------------------------------------------------------------------
44+
// Helpers for building test Flavors used in external-deletion tests.
45+
//
46+
// All helpers pre-set the controller finalizer so that GetOrCreateOSResource
47+
// does not attempt to call the Kubernetes client to add it.
48+
// --------------------------------------------------------------------------
49+
50+
// managedFlavorImportedByFilter builds a managed Flavor that was imported via
51+
// a filter (has a non-nil import.Filter) and whose status.ID is already set.
52+
// When GetOSResourceByID returns a not-found error, IsImported() returns true
53+
// because GetImportFilter() is non-nil, so the controller must return a
54+
// terminal error rather than triggering recreation.
55+
func managedFlavorImportedByFilter(statusID string) fakeAdapter {
56+
return fakeAdapter{
57+
Flavor: &orcv1alpha1.Flavor{
58+
ObjectMeta: metav1.ObjectMeta{
59+
Name: "test-flavor",
60+
Namespace: "default",
61+
Finalizers: []string{finalizerFor()},
62+
},
63+
Spec: orcv1alpha1.FlavorSpec{
64+
ManagementPolicy: orcv1alpha1.ManagementPolicyManaged,
65+
Import: &orcv1alpha1.FlavorImport{
66+
Filter: &orcv1alpha1.FlavorFilter{
67+
Name: ptr.To[orcv1alpha1.OpenStackName]("my-flavor"),
68+
},
69+
},
70+
},
71+
Status: orcv1alpha1.FlavorStatus{
72+
ID: ptr.To(statusID),
73+
},
74+
},
75+
}
76+
}
77+
78+
// --------------------------------------------------------------------------
79+
// External deletion tests — all use GetOrCreateOSResource directly.
80+
// --------------------------------------------------------------------------
81+
82+
// TestGetOrCreateOSResource_ExternalDeletion_ManagedOrcCreated verifies that
83+
// when a managed, ORC-created resource (not imported) is externally deleted,
84+
// GetOrCreateOSResource returns IsExternallyDeleted to signal the caller should
85+
// clear status.ID and trigger recreation on the next reconcile.
86+
func TestGetOrCreateOSResource_ExternalDeletion_ManagedOrcCreated(t *testing.T) {
87+
t.Parallel()
88+
89+
const resourceID = "orc-created-flavor-id"
90+
91+
actuator := &noWriteActuator{t: t, readByIDErr: notFoundErr()}
92+
adapter := managedFlavorWithStatusID(resourceID)
93+
94+
got, rs := GetOrCreateOSResource(context.Background(), logr.Discard(), &fakeResourceController{}, adapter, actuator)
95+
96+
if !rs.IsExternallyDeleted() {
97+
t.Fatal("expected IsExternallyDeleted for externally-deleted managed resource")
98+
}
99+
if got != nil {
100+
t.Errorf("expected nil osResource for recreation path, got %v", got)
101+
}
102+
if !actuator.getByIDCalled {
103+
t.Error("GetOSResourceByID was not called: controller must attempt to fetch the resource")
104+
}
105+
}
106+
107+
// TestGetOrCreateOSResource_ExternalDeletion_ManagedImportedByID verifies that
108+
// when a managed resource originally imported by ID is externally deleted, the
109+
// controller returns a terminal error. Recreation is not possible for imported
110+
// resources because we cannot know the original creation parameters.
111+
func TestGetOrCreateOSResource_ExternalDeletion_ManagedImportedByID(t *testing.T) {
112+
t.Parallel()
113+
114+
const resourceID = "imported-by-id-flavor-id"
115+
116+
actuator := &noWriteActuator{t: t, readByIDErr: notFoundErr()}
117+
adapter := managedFlavorImportedByID(resourceID)
118+
119+
_, rs := GetOrCreateOSResource(context.Background(), logr.Discard(), &fakeResourceController{}, adapter, actuator)
120+
121+
_, err := rs.NeedsReschedule()
122+
if err == nil {
123+
t.Fatal("expected a terminal error for externally-deleted imported-by-ID resource, got nil")
124+
}
125+
126+
var termErr *orcerrors.TerminalError
127+
if !errors.As(err, &termErr) {
128+
t.Errorf("expected a TerminalError for externally-deleted imported-by-ID resource, got %T: %v", err, err)
129+
}
130+
if !actuator.getByIDCalled {
131+
t.Error("GetOSResourceByID was not called")
132+
}
133+
}
134+
135+
// TestGetOrCreateOSResource_ExternalDeletion_ManagedImportedByFilter verifies
136+
// that when a managed resource originally imported via a filter is externally
137+
// deleted, the controller returns a terminal error. As with import-by-ID,
138+
// recreation is not possible.
139+
func TestGetOrCreateOSResource_ExternalDeletion_ManagedImportedByFilter(t *testing.T) {
140+
t.Parallel()
141+
142+
const resourceID = "imported-by-filter-flavor-id"
143+
144+
actuator := &noWriteActuator{t: t, readByIDErr: notFoundErr()}
145+
adapter := managedFlavorImportedByFilter(resourceID)
146+
147+
_, rs := GetOrCreateOSResource(context.Background(), logr.Discard(), &fakeResourceController{}, adapter, actuator)
148+
149+
_, err := rs.NeedsReschedule()
150+
if err == nil {
151+
t.Fatal("expected a terminal error for externally-deleted imported-by-filter resource, got nil")
152+
}
153+
154+
var termErr *orcerrors.TerminalError
155+
if !errors.As(err, &termErr) {
156+
t.Errorf("expected a TerminalError for externally-deleted imported-by-filter resource, got %T: %v", err, err)
157+
}
158+
if !actuator.getByIDCalled {
159+
t.Error("GetOSResourceByID was not called")
160+
}
161+
}
162+
163+
// TestGetOrCreateOSResource_ExternalDeletion_Unmanaged verifies that when an
164+
// unmanaged resource is externally deleted (404), the controller returns a
165+
// terminal error instead of calling CreateResource.
166+
func TestGetOrCreateOSResource_ExternalDeletion_Unmanaged(t *testing.T) {
167+
t.Parallel()
168+
169+
const resourceID = "unmanaged-deleted-flavor-id"
170+
171+
actuator := &noWriteActuator{t: t, readByIDErr: notFoundErr()}
172+
adapter := unmanagedFlavorWithStatusID(resourceID)
173+
174+
_, rs := GetOrCreateOSResource(context.Background(), logr.Discard(), &fakeResourceController{}, adapter, actuator)
175+
176+
_, err := rs.NeedsReschedule()
177+
if err == nil {
178+
t.Fatal("expected a terminal error for externally-deleted unmanaged resource, got nil")
179+
}
180+
181+
var termErr *orcerrors.TerminalError
182+
if !errors.As(err, &termErr) {
183+
t.Errorf("expected a TerminalError for externally-deleted unmanaged resource, got %T: %v", err, err)
184+
}
185+
if !actuator.getByIDCalled {
186+
t.Error("GetOSResourceByID was not called: controller must attempt to fetch the resource")
187+
}
188+
}
189+
190+
// TestGetOrCreateOSResource_ExternalDeletion_ManagedResourceExists verifies
191+
// the normal update flow: when a managed, ORC-created resource still exists in
192+
// OpenStack, GetOrCreateOSResource returns the resource with a nil reconcile
193+
// status so the caller proceeds with reconciliation (no recreation, no error).
194+
func TestGetOrCreateOSResource_ExternalDeletion_ManagedResourceExists(t *testing.T) {
195+
t.Parallel()
196+
197+
const resourceID = "existing-managed-flavor-id"
198+
osResource := &fakeOSResource{ID: resourceID}
199+
200+
actuator := &noWriteActuator{t: t, readByIDResult: osResource}
201+
adapter := managedFlavorWithStatusID(resourceID)
202+
203+
got, rs := GetOrCreateOSResource(context.Background(), logr.Discard(), &fakeResourceController{}, adapter, actuator)
204+
205+
needsReschedule, err := rs.NeedsReschedule()
206+
if needsReschedule {
207+
t.Fatalf("expected no rescheduling for existing resource, got needsReschedule=%v err=%v", needsReschedule, err)
208+
}
209+
if got == nil || got.ID != resourceID {
210+
t.Errorf("expected osResource with ID=%q, got %v", resourceID, got)
211+
}
212+
if !actuator.getByIDCalled {
213+
t.Error("GetOSResourceByID was not called")
214+
}
215+
}

internal/controllers/generic/status/conditions.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ func SetCommonConditions[T any](
4040
reconcileStatus progress.ReconcileStatus,
4141
now metav1.Time,
4242
) {
43+
// Terminal errors make the resource definitively unavailable.
44+
// Override Unknown → False so Available matches the error severity.
45+
if availableStatus != metav1.ConditionTrue {
46+
var terminalErr *orcerrors.TerminalError
47+
if errors.As(reconcileStatus.GetError(), &terminalErr) {
48+
availableStatus = metav1.ConditionFalse
49+
}
50+
}
51+
4352
availableCondition := applyconfigv1.Condition().
4453
WithType(orcv1alpha1.ConditionAvailable).
4554
WithStatus(availableStatus).

0 commit comments

Comments
 (0)