Skip to content

test(listener): add ignore-field-drift e2e coverage for weight drift - #90

Merged
ack-prow[bot] merged 3 commits into
aws-controllers-k8s:mainfrom
sapphirew:test/listener-ignore-field-drift
Sep 10, 2026
Merged

ack-prow[bot] merged 3 commits into
aws-controllers-k8s:mainfrom
sapphirew:test/listener-ignore-field-drift

Conversation

@sapphirew

@sapphirew sapphirew commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Adds end-to-end coverage for the services.k8s.aws/ignore-field-drift annotation (aws-controllers-k8s/runtime#256) on an ELBv2 Listener's forward-action target-group weights.

This mirrors "Scenario 2" of #85: a blue/green deploy tool shifts traffic weights across target groups on the live listener, and without ignore-field-drift the controller reconciles the weights back to the declared spec, breaking the deployment. The generic runtime feature lets the resource opt spec.defaultActions out of drift reconciliation via the annotation — no controller-specific code required.

Test-only change: test/e2e/tests/test_listener.py, test/e2e/resources/listener_ignore_field_drift.yaml, test/e2e/resources/target_group_ip.yaml.

What the test does

TestListenerIgnoreFieldDrift.test_weight_drift_ignored:

  1. Creates a Listener with a weighted forward action across two target groups (declared 90/10), annotated services.k8s.aws/ignore-field-drift: "spec.defaultActions".
  2. Asserts the declared weights were applied at create and the resource is Synced.
  3. Shifts the weights out-of-band to 50/50 via ModifyListener (simulating the deploy tool).
  4. Asserts the external distribution survives (controller does not revert), the resource stays Synced, and a subsequent spec edit (→70/30) is retained in the CR but not pushed to AWS.

Two ip-type target groups back the weighted action so the fixture does not depend on registered targets.

Feature gate handling

The IgnoreFieldDrift gate is Alpha and disabled by default, so the test enables it on the deployed controller by patching FEATURE_GATES on the Deployment. The gate landed in runtime v0.62.0; main is now on v0.63.0, so it is available.

The ignore_field_drift_enabled fixture is session-scoped, check-then-set, and never restores — this matters and is not just style. pytest-xdist spreads individual tests across 16 worker processes under LoadScheduling, so a fixture at any scope is instantiated once in every worker that picks up a test from this file. An enable/restore pair therefore rolls the shared controller Deployment twice per such worker, and each restart stops reconciliation cluster-wide long enough to fail an unrelated listener / load balancer / target group test waiting on ACK.ResourceSynced. This is the same fix as ec2-controller#361 b2cb92a, where the module-scoped version cost three unrelated tests in one run.

Check-then-set needs no cross-process lock: concurrent workers that both observe the gate off compute the same FEATURE_GATES string from the same starting value, so the second patch leaves the pod template byte-identical and does not bump the Deployment generation. Leaving the gate on is safe — it is inert unless a resource carries the annotation, and only this file's drift resources do; the kind cluster is torn down at the end of the run.

Follow-up worth doing: set the gate at controller setup time (FEATURE_GATES in test-infra's controller-setup.sh, as IAMRoleSelector already does), after which this fixture degrades to a no-op check and the mid-run rollout disappears entirely.

Testing

Verified locally via a full kind e2e run with the gate enabled: 1 passed. The external 50/50 shift survived, the resource stayed Synced, and the 70/30 spec edit was retained but not pushed.

Note for anyone hitting this on an older base: against a runtime without the gate, the controller treats the unknown gate as fatal and exits, which surfaces only as controller deployment ack-elbv2-controller did not roll out within 120s. The crashlooping pod's logs are not in the prow controller_logs artifact (it captures the surviving pod), so that artifact looks misleadingly clean.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@ack-prow
ack-prow Bot requested review from gustavodiaz7722 and michaelhtm July 30, 2026 22:02
@ack-prow ack-prow Bot added the approved label Jul 30, 2026
Add end-to-end coverage for the services.k8s.aws/ignore-field-drift
annotation (aws-controllers-k8s/runtime#256) on an ELBv2 Listener's
forward-action target-group weights, mirroring "Scenario 2" of aws-controllers-k8s#85: a
blue/green deploy tool shifts traffic weights across target groups on
the live listener, and without ignore-field-drift the controller
reconciles the weights back to the declared spec, breaking the
deployment.

TestListenerIgnoreFieldDrift.test_weight_drift_ignored declares a
weighted forward action (90/10) annotated to ignore spec.defaultActions,
shifts the weights out-of-band to 50/50 via ModifyListener, and asserts
that the external distribution survives, the resource stays Synced, and
a subsequent spec edit is retained in the CR but not pushed to AWS.

The IgnoreFieldDrift feature gate is Alpha and disabled by default, so
the test enables it on the deployed controller for the module and
restores the prior value afterwards (mirrors the ec2-controller VPC
coverage for the same runtime feature). The gate is only present once
the controller's runtime dependency includes runtime#256, so this
coverage runs green after the corresponding runtime bump.

Two ip-type target groups back the weighted action so the fixture does
not depend on registered targets.

Signed-off-by: Hao Wang <rhaowang@amazon.com>
The suite runs 16 pytest-xdist workers with LoadScheduling, which spreads
individual tests across worker PROCESSES. A fixture is therefore created
once in every worker that picks up a test from this file, at any scope, so
the module-scoped enable/restore pair rolls the shared ack-elbv2-controller
Deployment twice per such worker.

Each rollout stops reconciliation cluster-wide for a few seconds, which is
enough to fail an unrelated worker mid-assertion -- in ec2-controller the
same pattern lost three tests to KeyError on a status field that was never
populated and to an ACK.ResourceSynced wait that expired. elbv2 has fewer
drift tests so fewer rollouts, but the listener, load balancer and target
group tests running in parallel are exposed the same way.

Turn the gate on once and leave it on:

  - Check-then-set, so a worker that finds it already on skips the patch.
    Concurrent workers observing it off compute the same FEATURE_GATES value
    from the same starting point, so the second patch leaves the pod template
    identical, does not bump the Deployment generation and does not roll a
    second time. No cross-process lock needed.
  - No restore, which would cost a second rollout and could disable the gate
    underneath a drift test still running in another worker. Safe to leave
    on: the gate is inert unless a resource carries the ignore-field-drift
    annotation, and only this file's drift resources do.

Session scope also makes the ordering explicit -- the gate is now guaranteed
to be on before the module-scoped listener fixture creates its resources,
rather than relying on fixture argument order.

Still one rollout per run. Removing it entirely means setting the gate at
controller setup time (FEATURE_GATES in test-infra's controller-setup.sh,
alongside the existing IAMRoleSelector case), after which this fixture
degrades to a no-op check.

Ports ec2-controller b2cb92a to elbv2.

Signed-off-by: Hao Wang <rhaowang@amazon.com>
@sapphirew
sapphirew force-pushed the test/listener-ignore-field-drift branch from 1cf24d1 to c21c503 Compare September 3, 2026 22:15
@gustavodiaz7722

Copy link
Copy Markdown
Member

1. The "drift survives" and "stays Synced" assertions cannot fail — verified

  • Category: tests
  • File: test/e2e/tests/test_listener.py, test_weight_drift_ignored, everything
    between the modify_listener call and the spec patch

Issue. The out-of-band ModifyListener produces no watch event, and the controller's
resync period is 10 hours — RECONCILE_DEFAULT_RESYNC_SECONDS=36000 on the deployed
chart, and config/controller/deployment.yaml (what e2e uses) sets no override at all,
inheriting defaultResyncPeriod = 10 * time.Hour from
runtime/pkg/runtime/reconciler.go:57. No reconcile occurs during
MODIFY_WAIT_AFTER_SECONDS = 20.

Proven two independent ways.

First, a control listener identical to the fixture's except for carrying no
ignore-field-drift annotation retained the external 50/50 exactly as the annotated one
did — not for 20 seconds but for ~50 minutes. Every runtime path for this feature is
gated on the annotation being present (HasIgnoreFieldDrift), so that control is
equivalent to the feature not existing:

Phase annotated listener control listener (no annotation)
after create AWS 90/10, Synced@19:04:30 AWS 90/10, Synced@19:04:31
after out-of-band shift to 50/50 AWS 50/50, Synced@19:04:30 AWS 50/50, Synced@19:04:31
~50–60 min later AWS 50/50, Synced@19:04:30 AWS 50/50, Synced@19:04:31
after CR spec patch to 70/30 AWS 50/50, CR 70/30, Synced@20:16:13 AWS 70/30, CR 70/30, Synced@20:16:15

Second, the controller's log for the annotated listener contains exactly two entries
across its entire lifetime:

19:04:30  created new resource                                                     generation 1
20:16:13  ignore-field-drift: skipping drifted ... fields:["spec.defaultActions"]   generation 2

Nothing between them. The controller never examined the resource during the window in
which the test asserts the drift survived.

The Synced assertion fails the same way, and worse: ACK.ResourceSynced
lastTransitionTime stayed pinned at the create timestamp (19:04:30) through the
entire drift window and only moved at 20:16:13, after the spec patch.
wait_on_condition(ref, "ACK.ResourceSynced", "True", ...) returns on its first poll
off a condition written an hour earlier. acktest ships wait_on_condition_after and
condition.get_synced_last_transition_time for exactly this trap and names it in the
docstring (test-infra/src/acktest/k8s/resource.py:390-415).

Fix. Capture condition.get_synced_last_transition_time(ref) before the out-of-band
shift, force a reconcile after it (patch a benign annotation, or a spec field outside the
ignored path), then gate both assertions on
wait_on_condition_after(..., last_transition_after=<captured>) so a fresh reconcile
is known to have run. Alternatively delete the intermediate survival and Synced
assertions and rely solely on the spec-edit step, so the test stops advertising coverage
it does not provide. The timestamp did move on a real reconcile, so the
wait_on_condition_after form is a genuine check.

@gustavodiaz7722

Copy link
Copy Markdown
Member

2. Fixtures read status.ackResourceMetadata.arn before it exists — reproduced

  • Category: tests
  • File: test/e2e/tests/test_listener.py, two_target_groups,
    ignore_field_drift_listener, and the ARN read in test_weight_drift_ignored

Issue. wait_resource_consumed_by_controller returns as soon as
'status' in resource (test-infra/src/acktest/k8s/resource.py:270-286) — the first
status write, before the ARN lands when reference resolution is still pending. The
listener depends on two TargetGroup refs created only CREATE_WAIT_AFTER_SECONDS
(10s) earlier that are never waited on for ACK.ResourceSynced. Reproduced directly
with a listener whose targetGroupRef could not yet resolve:

"status" in resource : True
status keys          : ['conditions']
cr["status"]["ackResourceMetadata"]["arn"] -> KeyError: 'ackResourceMetadata'

The window closed ~20s later once the target group existed, which is what makes this an
intermittent flake rather than a deterministic failure — and it is the same
KeyError-on-an-unpopulated-status-field class the PR description cites from
ec2-controller. The test compounds it by reading the ARN from the cr captured at
fixture time instead of re-reading, so it cannot recover.

Fix. Wait for ACK.ResourceSynced on each target group in two_target_groups before
yielding; wait for ACK.ResourceSynced on the listener in ignore_field_drift_listener,
then re-read with k8s.get_resource(ref) and yield that CR.

Addresses both review findings from @gustavodiaz7722.

1. The drift-survives and stays-Synced assertions could not fail.

An out-of-band ModifyListener produces no watch event, and this controller's
resync period is the runtime default of 10 hours: config/controller/deployment.yaml
(what controller-setup.sh deploys via kustomize) passes no
--reconcile-default-resync-seconds or --reconcile-resource-resync-seconds, and
listener's RequeueOnSuccessSeconds() returns 0, so getResyncPeriod falls through
to defaultResyncPeriod. No reconcile occurred during MODIFY_WAIT_AFTER_SECONDS,
so "the weights are still 50/50" observed that nobody had looked, not that the
controller declined to revert -- it held identically with the feature absent.
The Synced check was worse: wait_on_condition returned on its first poll off the
ACK.ResourceSynced condition written back at create time.

Now the test captures condition.get_synced_last_transition_time before the
shift, forces a reconcile by patching an inert annotation, and requires via
wait_on_condition_after that a reconcile which started after the drift completed
with ACK.ResourceSynced=True. That single gate carries both original claims, so
the separate (vacuous) Synced wait is gone. Only past it is the weight assertion
meaningful.

An annotation is enough to trigger the reconcile because the runtime adds
AnnotationChangedPredicate to the event filter whenever the IgnoreFieldDrift
gate is on; the default filter is GenerationChangedPredicate alone. Keeping the
probe off the spec means the only delta the reconcile sees is the external drift
itself. A precondition assert also confirms AWS reports the shifted weights
before the reconcile is forced, so a race with ModifyListener cannot make the
test pass for the wrong reason.

The same treatment is applied to the final spec-edit step. That patch does bump
generation, so a reconcile was guaranteed to be queued -- but not to have
finished within a fixed sleep.

2. The fixtures read status.ackResourceMetadata.arn before it existed.

wait_resource_consumed_by_controller returns as soon as the resource has any
.status, which is the first status write and predates the ARN. Neither
two_target_groups nor ignore_field_drift_listener waited on
ACK.ResourceSynced, and the listener resolves two target group references
created 10s earlier, so the ARN read could KeyError. The listener fixture
compounded it by yielding the CR snapshot taken at that moment, leaving the
test no way to recover.

Both fixtures now wait for ACK.ResourceSynced, and the listener fixture
re-reads and asserts the ARN is present before yielding.

Signed-off-by: Hao Wang <rhaowang@amazon.com>
@sapphirew

Copy link
Copy Markdown
Contributor Author

Both findings confirmed and fixed in acb154e. I verified each independently against the code rather than taking the reproductions at face value; the chains hold.

1. Vacuous drift / Synced assertions

Confirmed end to end. config/controller/deployment.yaml — what controller-setup.sh:90 copies and applies via kustomize — passes --reconcile-default-max-concurrent-syncs and --feature-gates but no --reconcile-default-resync-seconds or --reconcile-resource-resync-seconds, and pkg/resource/listener/manager_factory.go:88 returns 0 from RequeueOnSuccessSeconds(). So getResyncPeriod falls through all three overrides to defaultResyncPeriod = 10 * time.Hour. With no watch event from the AWS-side ModifyListener, nothing reconciled inside the 20s window.

One refinement: the test's window is ~30s after create, not the ~hour in your experiment, so a post-create status-write reconcile could occasionally land inside it. That makes the assertion unreliable rather than strictly unfailable — which argues for the same fix, since the test never verified a reconcile had happened either way.

The fix follows your first option. The test now captures condition.get_synced_last_transition_time before the shift, forces a reconcile, and gates on wait_on_condition_after(..., last_transition_after=...). That one gate carries both original claims — the controller looked, and it still considers the resource synced despite live 50/50 vs declared 90/10 — so the separate Synced wait is deleted rather than kept.

Two details worth recording:

  • The reconcile is forced by patching an inert annotation rather than a spec field, because the runtime adds AnnotationChangedPredicate to the event filter whenever the IgnoreFieldDrift gate is on (reconciler.go:118-124; the default is GenerationChangedPredicate alone). Keeping the probe off the spec means the only delta the reconcile sees is the external drift itself.
  • A precondition assert now confirms AWS reports the shifted weights before the reconcile is forced. Otherwise a race with ModifyListener would let sdkFind read the original 90/10, find no drift, and pass for an unrelated reason.

I applied the same treatment to the final spec-edit step. That patch does bump metadata.generation, so a reconcile was guaranteed to be queued — but not to have finished within a fixed sleep.

2. ARN read before it exists

Confirmed. wait_resource_consumed_by_controller returns on if 'status' in resource (resource.py:270-286) and never checks for the ARN; neither fixture waited on ACK.ResourceSynced, and the listener resolves two target group references created 10s earlier. The listener fixture compounded it by yielding the snapshot taken at that moment, leaving the test no way to recover.

Both fixtures now wait for ACK.ResourceSynced, and the listener fixture re-reads with k8s.get_resource(ref) and asserts the ARN is present before yielding. Both helpers you cited do exist at the acktest commit this repo pins (ee58acc): wait_on_condition_after at resource.py:390 and condition.get_synced_last_transition_time at condition.py:121.

Evidence the fix is not just green

The controller log for the passing run now contains two suppression events where the old test produced one:

21:35:35  ignore-field-drift: skipping drifted ... fields:["spec.defaultActions"]  generation 1
21:35:45  ignore-field-drift: skipping drifted ... fields:["spec.defaultActions"]  generation 2

The generation: 1 entry is the one that did not exist before — a reconcile against the drift itself, with no spec change, which is precisely the gap you identified. It also settles a point I could not confirm from the code alone: ACK.ResourceSynced.lastTransitionTime does move on the forced reconcile, so the gate is a real check rather than a tautology.

@gustavodiaz7722

Copy link
Copy Markdown
Member

/LGTM

@ack-prow ack-prow Bot added the lgtm Indicates that a PR is ready to be merged. label Sep 10, 2026
@ack-prow

ack-prow Bot commented Sep 10, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: gustavodiaz7722, sapphirew

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:
  • OWNERS [gustavodiaz7722,sapphirew]

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@ack-prow
ack-prow Bot merged commit 594a483 into aws-controllers-k8s:main Sep 10, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved lgtm Indicates that a PR is ready to be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants