Skip to content

Commit c763f55

Browse files
a-hilalymichaelhtm
authored andcommitted
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 954f537 commit c763f55

File tree

2 files changed

+219
-4
lines changed

2 files changed

+219
-4
lines changed

pkg/runtime/reconciler.go

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

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

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

pkg/runtime/reconciler_test.go

Lines changed: 151 additions & 4 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
@@ -1789,3 +1793,146 @@ func TestReconcilerUpdate_EnsureControllerTagsError(t *testing.T) {
17891793
rm.AssertNotCalled(t, "LateInitialize", ctx, latest)
17901794
rm.AssertCalled(t, "EnsureTags", ctx, desired, scmd)
17911795
}
1796+
1797+
func TestReconcile_AccountDrifted(t *testing.T) {
1798+
require := require.New(t)
1799+
1800+
ctx := context.TODO()
1801+
req := ctrlrt.Request{
1802+
NamespacedName: types.NamespacedName{
1803+
Namespace: "production",
1804+
Name: "mybook",
1805+
},
1806+
}
1807+
1808+
// Create resource with existing account
1809+
existingAccount := ackv1alpha1.AWSAccountID("111111111111")
1810+
1811+
desired, _, metaObj := resourceMocks()
1812+
metaObj.SetNamespace("production")
1813+
1814+
ids := &ackmocks.AWSResourceIdentifiers{}
1815+
ids.On("Region").Return(nil)
1816+
ids.On("OwnerAccountID").Return(&existingAccount)
1817+
desired.On("Identifiers").Return(ids)
1818+
desired.On("Conditions").Return([]*ackv1alpha1.Condition{})
1819+
desired.On(
1820+
"ReplaceConditions",
1821+
mock.AnythingOfType("[]*v1alpha1.Condition"),
1822+
).Return()
1823+
desired.On("IsBeingDeleted").Return(false)
1824+
1825+
// Setup resource descriptor
1826+
rd := &ackmocks.AWSResourceDescriptor{}
1827+
rd.On("GroupVersionKind").Return(schema.GroupVersionKind{
1828+
Group: "test.services.k8s.aws",
1829+
Kind: "Book",
1830+
Version: "v1alpha1",
1831+
})
1832+
rd.On("EmptyRuntimeObject").Return(&fakeBook{})
1833+
rd.On("ResourceFromRuntimeObject", mock.Anything).Return(desired)
1834+
1835+
// Setup service controller
1836+
sc := &ackmocks.ServiceController{}
1837+
sc.On("GetMetadata").Return(acktypes.ServiceControllerMetadata{})
1838+
sc.On("NewAWSConfig",
1839+
mock.Anything,
1840+
mock.AnythingOfType("v1alpha1.AWSRegion"),
1841+
mock.Anything,
1842+
mock.AnythingOfType("v1alpha1.AWSResourceName"),
1843+
mock.AnythingOfType("schema.GroupVersionKind"),
1844+
).Return(aws.Config{}, nil)
1845+
1846+
// Get fakeLogger
1847+
zapOptions := ctrlrtzap.Options{
1848+
Development: true,
1849+
Level: zapcore.InfoLevel,
1850+
}
1851+
fakeLogger := ctrlrtzap.New(ctrlrtzap.UseFlagOptions(&zapOptions))
1852+
1853+
// Create fake k8s client with namespace that has owner account annotation
1854+
k8sClient := k8sfake.NewSimpleClientset()
1855+
1856+
// Create namespace with owner account annotation
1857+
namespace := &corev1.Namespace{
1858+
ObjectMeta: metav1.ObjectMeta{
1859+
Name: "production",
1860+
Annotations: map[string]string{
1861+
ackv1alpha1.AnnotationOwnerAccountID: "222222222222",
1862+
},
1863+
},
1864+
}
1865+
k8sClient.CoreV1().Namespaces().Create(context.Background(), namespace, metav1.CreateOptions{})
1866+
1867+
// Create CARM configmap
1868+
configMap := &corev1.ConfigMap{
1869+
ObjectMeta: metav1.ObjectMeta{
1870+
Name: ackrtcache.ACKRoleAccountMap,
1871+
Namespace: "ack-system",
1872+
},
1873+
Data: map[string]string{
1874+
"222222222222": "arn:aws:iam::222222222222:role/ACKRole",
1875+
},
1876+
}
1877+
k8sClient.CoreV1().ConfigMaps("ack-system").Create(context.Background(), configMap, metav1.CreateOptions{})
1878+
1879+
// Create caches with the k8s client
1880+
caches := ackrtcache.New(fakeLogger, ackrtcache.Config{}, featuregate.FeatureGates{})
1881+
1882+
// Run the caches
1883+
stopCh := make(chan struct{})
1884+
defer close(stopCh)
1885+
caches.Run(k8sClient)
1886+
1887+
// Wait for caches to sync
1888+
time.Sleep(100 * time.Millisecond)
1889+
1890+
kc := &ctrlrtclientmock.Client{}
1891+
statusWriter := &ctrlrtclientmock.SubResourceWriter{}
1892+
kc.On("Status").Return(statusWriter)
1893+
statusWriter.On("Patch", mock.Anything, mock.Anything, mock.Anything).Return(nil)
1894+
1895+
rm := &ackmocks.AWSResourceManager{}
1896+
rmf := &ackmocks.AWSResourceManagerFactory{}
1897+
rmf.On("ResourceDescriptor").Return(rd)
1898+
rmf.On("ManagerFor",
1899+
mock.Anything,
1900+
mock.Anything,
1901+
mock.Anything,
1902+
mock.Anything,
1903+
mock.Anything,
1904+
mock.AnythingOfType("v1alpha1.AWSAccountID"),
1905+
mock.AnythingOfType("v1alpha1.AWSRegion"),
1906+
mock.AnythingOfType("v1alpha1.AWSResourceName"),
1907+
).Return(rm, nil)
1908+
rm.On("ResolveReferences", mock.Anything, mock.Anything, mock.Anything).Return(
1909+
desired, false, nil,
1910+
)
1911+
rm.On("EnsureTags", mock.Anything, mock.Anything, mock.Anything).Return(nil)
1912+
1913+
// Create reconciler with namespace cache
1914+
r := &resourceReconciler{
1915+
reconciler: reconciler{
1916+
kc: kc,
1917+
sc: sc,
1918+
log: fakeLogger,
1919+
cfg: ackcfg.Config{AccountID: "333333333333"},
1920+
cache: caches,
1921+
metrics: ackmetrics.NewMetrics("test"),
1922+
},
1923+
rmf: rmf,
1924+
rd: rd,
1925+
}
1926+
1927+
apiReader := &ctrlrtclientmock.Reader{}
1928+
apiReader.On("Get", ctx, req.NamespacedName, mock.AnythingOfType("*runtime.fakeBook")).Return(nil)
1929+
r.apiReader = apiReader
1930+
1931+
// Call Reconcile
1932+
_, err := r.Reconcile(ctx, req)
1933+
1934+
// Should get terminal error for account drift
1935+
require.NotNil(err)
1936+
assert.Contains(t, err.Error(), "Resource already exists in account 111111111111")
1937+
assert.Contains(t, err.Error(), "but the role used for reconciliation is in account 222222222222")
1938+
}

0 commit comments

Comments
 (0)