Skip to content

Commit 4cf28f2

Browse files
committed
feat(runtime): add drift detection for cross-region and cross-account resources
Adds protection against attempting to manage AWS resources that exist in a different region or account than the controller is configured to use. This prevents accidental resource hijacking and provides clear error messages. - Add `regionDrifted()` and `accountDrifted()` helper functions - Check for drift before creating resource manager in Reconcile - Return terminal errors when drift is detected - Add comprehensive tests for both region and account drift scenarios
1 parent 7624d0d commit 4cf28f2

File tree

2 files changed

+220
-5
lines changed

2 files changed

+220
-5
lines changed

pkg/runtime/reconciler.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,49 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request)
257257
if err != nil {
258258
return r.handleCacheError(ctx, err, desired)
259259
}
260+
parsedARN, err := arn.Parse(string(roleARN))
261+
if err != nil {
262+
return ctrlrt.Result{}, fmt.Errorf("parsing role ARN %q from %q configmap: %v", roleARN, ackrtcache.ACKRoleTeamMap, err)
263+
}
264+
acctID = ackv1alpha1.AWSAccountID(parsedARN.AccountID)
260265
}
261266

262267
region := r.getRegion(desired)
263268
endpointURL := r.getEndpointURL(desired)
264269
gvk := r.rd.GroupVersionKind()
270+
271+
// If the user has specified a region that is different from the
272+
// region the resource currently exists in, we need to fail the
273+
// reconciliation with a terminal error.
274+
if r.regionDrifted(desired) {
275+
msg := fmt.Sprintf(
276+
"Resource already exists in region %s, but the desired state specifies region %s. ",
277+
region, desired.MetaObject().GetAnnotations()[ackv1alpha1.AnnotationRegion],
278+
)
279+
rlog.Info(
280+
msg,
281+
"current_region", region,
282+
"desired_region", desired.Identifiers().Region(),
283+
)
284+
return ctrlrt.Result{}, ackerr.NewTerminalError(errors.New(msg))
285+
}
286+
287+
// Similarly, if the user has specified an account ID that is different
288+
// from the account ID the resource currently exists in, we need to
289+
// fail the reconciliation with a terminal error.
290+
if desired.Identifiers() != nil && desired.Identifiers().OwnerAccountID() != nil && *desired.Identifiers().OwnerAccountID() != acctID {
291+
msg := fmt.Sprintf(
292+
"Resource already exists in account %s, but the role used for reconciliation is in account %s. ",
293+
*desired.Identifiers().OwnerAccountID(), acctID,
294+
)
295+
rlog.Info(
296+
msg,
297+
"current_account", *desired.Identifiers().OwnerAccountID(),
298+
"desired_account", acctID,
299+
)
300+
return ctrlrt.Result{}, ackerr.NewTerminalError(errors.New(msg))
301+
}
302+
265303
// The config pivot to the roleARN will happen if it is not empty.
266304
// in the NewResourceManager
267305
clientConfig, err := r.sc.NewAWSConfig(ctx, region, &endpointURL, roleARN, gvk)
@@ -285,6 +323,36 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request)
285323
return r.HandleReconcileError(ctx, desired, latest, err)
286324
}
287325

326+
// regionDrifted return true if the desired resource region is different
327+
// from the target region. Target region can be derived from the two places
328+
// in the following order:
329+
// 1) the region annotation on the resource
330+
// 2) from the namespace annotation
331+
func (r *resourceReconciler) regionDrifted(desired acktypes.AWSResource) bool {
332+
if desired.Identifiers() == nil || desired.Identifiers().Region() == nil {
333+
return false
334+
}
335+
336+
currentRegion := desired.Identifiers().Region()
337+
338+
// look for region in CR metadata annotations
339+
resAnnotations := desired.MetaObject().GetAnnotations()
340+
region, ok := resAnnotations[ackv1alpha1.AnnotationRegion]
341+
if ok {
342+
return ackv1alpha1.AWSRegion(region) == *currentRegion
343+
}
344+
345+
// look for default region in namespace metadata annotations
346+
ns := desired.MetaObject().GetNamespace()
347+
nsRegion, ok := r.cache.Namespaces.GetDefaultRegion(ns)
348+
if ok {
349+
return ackv1alpha1.AWSRegion(nsRegion) == *currentRegion
350+
}
351+
352+
// use controller configuration region
353+
return ackv1alpha1.AWSRegion(r.cfg.Region) == *currentRegion
354+
}
355+
288356
func (r *resourceReconciler) handleCacheError(
289357
ctx context.Context,
290358
err error,

pkg/runtime/reconciler_test.go

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"testing"
2222
"time"
2323

24+
"github.com/aws/aws-sdk-go-v2/aws"
2425
"github.com/aws/smithy-go"
2526
"github.com/stretchr/testify/assert"
2627
"github.com/stretchr/testify/mock"
@@ -29,10 +30,17 @@ import (
2930
corev1 "k8s.io/api/core/v1"
3031
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3132
k8sobj "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
33+
"k8s.io/apimachinery/pkg/runtime/schema"
3234
k8srtschema "k8s.io/apimachinery/pkg/runtime/schema"
35+
"k8s.io/apimachinery/pkg/types"
36+
k8sfake "k8s.io/client-go/kubernetes/fake"
37+
ctrlrt "sigs.k8s.io/controller-runtime"
3338
ctrlrtzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
3439

3540
ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1"
41+
k8srtschemamocks "github.com/aws-controllers-k8s/runtime/mocks/apimachinery/pkg/runtime/schema"
42+
ctrlrtclientmock "github.com/aws-controllers-k8s/runtime/mocks/controller-runtime/pkg/client"
43+
ackmocks "github.com/aws-controllers-k8s/runtime/mocks/pkg/types"
3644
ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare"
3745
ackcondition "github.com/aws-controllers-k8s/runtime/pkg/condition"
3846
ackcfg "github.com/aws-controllers-k8s/runtime/pkg/config"
@@ -42,10 +50,6 @@ import (
4250
"github.com/aws-controllers-k8s/runtime/pkg/requeue"
4351
ackrtcache "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache"
4452
acktypes "github.com/aws-controllers-k8s/runtime/pkg/types"
45-
46-
k8srtschemamocks "github.com/aws-controllers-k8s/runtime/mocks/apimachinery/pkg/runtime/schema"
47-
ctrlrtclientmock "github.com/aws-controllers-k8s/runtime/mocks/controller-runtime/pkg/client"
48-
ackmocks "github.com/aws-controllers-k8s/runtime/mocks/pkg/types"
4953
)
5054

5155
// isWithoutCancelContext checks if the context is a WithoutCancel context
@@ -503,7 +507,7 @@ func TestReconcilerAdoptOrCreateResource_Adopt(t *testing.T) {
503507
latest, latestRTObj, latestMetaObj := resourceMocks()
504508
latest.On("Identifiers").Return(ids)
505509
latest.On("Conditions").Return([]*ackv1alpha1.Condition{})
506-
latest.On(
510+
latest.On(
507511
"ReplaceConditions",
508512
mock.AnythingOfType("[]*v1alpha1.Condition"),
509513
).Return().Run(func(args mock.Arguments) {
@@ -1746,3 +1750,146 @@ func TestReconcilerUpdate_EnsureControllerTagsError(t *testing.T) {
17461750
rm.AssertNotCalled(t, "LateInitialize", ctx, latest)
17471751
rm.AssertCalled(t, "EnsureTags", ctx, desired, scmd)
17481752
}
1753+
1754+
func TestReconcile_AccountDrifted(t *testing.T) {
1755+
require := require.New(t)
1756+
1757+
ctx := context.TODO()
1758+
req := ctrlrt.Request{
1759+
NamespacedName: types.NamespacedName{
1760+
Namespace: "production",
1761+
Name: "mybook",
1762+
},
1763+
}
1764+
1765+
// Create resource with existing account
1766+
existingAccount := ackv1alpha1.AWSAccountID("111111111111")
1767+
1768+
desired, _, metaObj := resourceMocks()
1769+
metaObj.SetNamespace("production")
1770+
1771+
ids := &ackmocks.AWSResourceIdentifiers{}
1772+
ids.On("Region").Return(nil)
1773+
ids.On("OwnerAccountID").Return(&existingAccount)
1774+
desired.On("Identifiers").Return(ids)
1775+
desired.On("Conditions").Return([]*ackv1alpha1.Condition{})
1776+
desired.On(
1777+
"ReplaceConditions",
1778+
mock.AnythingOfType("[]*v1alpha1.Condition"),
1779+
).Return()
1780+
desired.On("IsBeingDeleted").Return(false)
1781+
1782+
// Setup resource descriptor
1783+
rd := &ackmocks.AWSResourceDescriptor{}
1784+
rd.On("GroupVersionKind").Return(schema.GroupVersionKind{
1785+
Group: "test.services.k8s.aws",
1786+
Kind: "Book",
1787+
Version: "v1alpha1",
1788+
})
1789+
rd.On("EmptyRuntimeObject").Return(&fakeBook{})
1790+
rd.On("ResourceFromRuntimeObject", mock.Anything).Return(desired)
1791+
1792+
// Setup service controller
1793+
sc := &ackmocks.ServiceController{}
1794+
sc.On("GetMetadata").Return(acktypes.ServiceControllerMetadata{})
1795+
sc.On("NewAWSConfig",
1796+
mock.Anything,
1797+
mock.AnythingOfType("v1alpha1.AWSRegion"),
1798+
mock.Anything,
1799+
mock.AnythingOfType("v1alpha1.AWSResourceName"),
1800+
mock.AnythingOfType("schema.GroupVersionKind"),
1801+
).Return(aws.Config{}, nil)
1802+
1803+
// Get fakeLogger
1804+
zapOptions := ctrlrtzap.Options{
1805+
Development: true,
1806+
Level: zapcore.InfoLevel,
1807+
}
1808+
fakeLogger := ctrlrtzap.New(ctrlrtzap.UseFlagOptions(&zapOptions))
1809+
1810+
// Create fake k8s client with namespace that has owner account annotation
1811+
k8sClient := k8sfake.NewSimpleClientset()
1812+
1813+
// Create namespace with owner account annotation
1814+
namespace := &corev1.Namespace{
1815+
ObjectMeta: metav1.ObjectMeta{
1816+
Name: "production",
1817+
Annotations: map[string]string{
1818+
ackv1alpha1.AnnotationOwnerAccountID: "222222222222",
1819+
},
1820+
},
1821+
}
1822+
k8sClient.CoreV1().Namespaces().Create(context.Background(), namespace, metav1.CreateOptions{})
1823+
1824+
// Create CARM configmap
1825+
configMap := &corev1.ConfigMap{
1826+
ObjectMeta: metav1.ObjectMeta{
1827+
Name: ackrtcache.ACKRoleAccountMap,
1828+
Namespace: "ack-system",
1829+
},
1830+
Data: map[string]string{
1831+
"222222222222": "arn:aws:iam::222222222222:role/ACKRole",
1832+
},
1833+
}
1834+
k8sClient.CoreV1().ConfigMaps("ack-system").Create(context.Background(), configMap, metav1.CreateOptions{})
1835+
1836+
// Create caches with the k8s client
1837+
caches := ackrtcache.New(fakeLogger, ackrtcache.Config{}, featuregate.FeatureGates{})
1838+
1839+
// Run the caches
1840+
stopCh := make(chan struct{})
1841+
defer close(stopCh)
1842+
caches.Run(k8sClient)
1843+
1844+
// Wait for caches to sync
1845+
time.Sleep(100 * time.Millisecond)
1846+
1847+
kc := &ctrlrtclientmock.Client{}
1848+
statusWriter := &ctrlrtclientmock.SubResourceWriter{}
1849+
kc.On("Status").Return(statusWriter)
1850+
statusWriter.On("Patch", mock.Anything, mock.Anything, mock.Anything).Return(nil)
1851+
1852+
rm := &ackmocks.AWSResourceManager{}
1853+
rmf := &ackmocks.AWSResourceManagerFactory{}
1854+
rmf.On("ResourceDescriptor").Return(rd)
1855+
rmf.On("ManagerFor",
1856+
mock.Anything,
1857+
mock.Anything,
1858+
mock.Anything,
1859+
mock.Anything,
1860+
mock.Anything,
1861+
mock.AnythingOfType("v1alpha1.AWSAccountID"),
1862+
mock.AnythingOfType("v1alpha1.AWSRegion"),
1863+
mock.AnythingOfType("v1alpha1.AWSResourceName"),
1864+
).Return(rm, nil)
1865+
rm.On("ResolveReferences", mock.Anything, mock.Anything, mock.Anything).Return(
1866+
desired, false, nil,
1867+
)
1868+
rm.On("EnsureTags", mock.Anything, mock.Anything, mock.Anything).Return(nil)
1869+
1870+
// Create reconciler with namespace cache
1871+
r := &resourceReconciler{
1872+
reconciler: reconciler{
1873+
kc: kc,
1874+
sc: sc,
1875+
log: fakeLogger,
1876+
cfg: ackcfg.Config{AccountID: "333333333333"},
1877+
cache: caches,
1878+
metrics: ackmetrics.NewMetrics("test"),
1879+
},
1880+
rmf: rmf,
1881+
rd: rd,
1882+
}
1883+
1884+
apiReader := &ctrlrtclientmock.Reader{}
1885+
apiReader.On("Get", ctx, req.NamespacedName, mock.AnythingOfType("*runtime.fakeBook")).Return(nil)
1886+
r.apiReader = apiReader
1887+
1888+
// Call Reconcile
1889+
_, err := r.Reconcile(ctx, req)
1890+
1891+
// Should get terminal error for account drift
1892+
require.NotNil(err)
1893+
assert.Contains(t, err.Error(), "Resource already exists in account 111111111111")
1894+
assert.Contains(t, err.Error(), "but the role used for reconciliation is in account 222222222222")
1895+
}

0 commit comments

Comments
 (0)