From 69aeab7141ef3f6444ddc185b88d76aba862bc1f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:08:24 +0800 Subject: [PATCH 01/13] Update module github.com/prometheus/common to v0.68.0 (#8081) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index dd6ecb79793..ce431cf75a8 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/osrg/gobgp/v3 v3.37.0 github.com/pkg/sftp v1.13.10 github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.67.5 + github.com/prometheus/common v0.68.0 github.com/sirupsen/logrus v1.9.4 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 6af23c60eb4..373a425c3f6 100644 --- a/go.sum +++ b/go.sum @@ -222,8 +222,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -465,8 +465,8 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA= +github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= From 61ff7467511e718be2df70d70882560aaee9056d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:33:07 -0700 Subject: [PATCH 02/13] Update AWS SDK Go v2 (#8073) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 42 ++++++++++++++--------------- go.sum | 84 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/go.mod b/go.mod index ce431cf75a8..07ef7e41933 100644 --- a/go.mod +++ b/go.mod @@ -14,12 +14,12 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/Microsoft/hcsshim v0.14.1 github.com/TomCodeLV/OVSDB-golang-lib v0.0.0-20200116135253-9bbdfadcd881 - github.com/aws/aws-sdk-go-v2 v1.41.7 - github.com/aws/aws-sdk-go-v2/config v1.32.17 - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 - github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.20 - github.com/aws/aws-sdk-go-v2/service/ec2 v1.301.0 - github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 + github.com/aws/aws-sdk-go-v2 v1.41.9 + github.com/aws/aws-sdk-go-v2/config v1.32.20 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.22 + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.2 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.304.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 github.com/cheggaaa/pb/v3 v3.1.7 github.com/containernetworking/cni v1.3.0 github.com/containernetworking/plugins v1.9.1 @@ -96,21 +96,21 @@ require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/aws/smithy-go v1.25.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect + github.com/aws/smithy-go v1.26.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/go.sum b/go.sum index 373a425c3f6..0eee0055097 100644 --- a/go.sum +++ b/go.sum @@ -53,48 +53,48 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 h1:9XFUd2lkr7VrbE4Qtrhm7AtNhGgZeGFI5QLZtQIflj8= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18/go.mod h1:trImuKdWelQIJALvyGj6sKolJ1W8t628JOoTdDGVL9Q= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.20 h1:iEd9YuD/T9SrH/7NoMZ3Jz81OLqVfxOa94XZNqpSE9s= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.20/go.mod h1:hHSAgymEQbdCmEDXvNxhXiKJxJOWRJi84Gp34anL858= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.301.0 h1:U+cZAMc8mN0jne8/ae7KrrFuILTXrZReAvc6BIpXGls= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.301.0/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.22 h1:eBnBL5sAT9aIg014yU6DG4YT/ov8r9jnOPW/t7rr8Ig= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.22/go.mod h1:PjXTcjNr3cv5pfRG+PakZ9Yypx2vLRjCAcm1kEfHSjk= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.2 h1:UPuRJU/ymsHRkgGYuDhOo1ZeQN5fNIGA3Ic0NhDQq9k= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.2/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.304.2 h1:puQq1j5XHH/zaeAJS8ngKUaBAlg70VStCvhwH69Vr4o= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.304.2/go.mod h1:BmEhUktSbAPK6oedmAp9w/j4Yaa2WqTmNTQ4ovydhX4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= From 9cad1d2ad7a60d2ea2dbef540e60f626d563d412 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:29:47 +0800 Subject: [PATCH 03/13] Update trivy actions to v0.3.0 (#8084) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/trivy_scan.yml | 2 +- .github/workflows/trivy_scan_before_release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trivy_scan.yml b/.github/workflows/trivy_scan.yml index 0aa8065bfa0..bbc7ca16b67 100644 --- a/.github/workflows/trivy_scan.yml +++ b/.github/workflows/trivy_scan.yml @@ -41,7 +41,7 @@ jobs: docker pull antrea/antrea-controller-ubuntu:latest docker pull antrea/antrea-controller-ubuntu:${{ steps.find-antrea-greatest-version.outputs.antrea_version }} - name: Install Trivy - uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.6 + uses: aquasecurity/setup-trivy@151a37f29c09e4ff49f853d2b23682e688bb1fa1 # v0.3.0 - name: Get current UTC date id: date run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT diff --git a/.github/workflows/trivy_scan_before_release.yml b/.github/workflows/trivy_scan_before_release.yml index f3c85e9bb3f..4eff1eb8d71 100644 --- a/.github/workflows/trivy_scan_before_release.yml +++ b/.github/workflows/trivy_scan_before_release.yml @@ -23,7 +23,7 @@ jobs: run: | ./hack/build-antrea-linux-all.sh --pull - name: Install Trivy - uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.6 + uses: aquasecurity/setup-trivy@151a37f29c09e4ff49f853d2b23682e688bb1fa1 # v0.3.0 - name: Download Trivy DB # Always download the latest DB for releases, don't use a cached version. # Try downloading the vulnerability DB up to 5 times, to account for TOOMANYREQUESTS errors. From c6b5e3d924c947f606c7f49fc40a4ac8b6d3d5b9 Mon Sep 17 00:00:00 2001 From: Gavin Xin Date: Wed, 3 Jun 2026 15:04:55 +0800 Subject: [PATCH 04/13] Add MC e2e tests to GitHub Actions (#8065) Add the Multi-cluster tests to GitHub Actions and update related scripts. Signed-off-by: Shuyang Xin --- .github/workflows/kind.yml | 71 ++++++++++++++++++++++++++++++++++++++ ci/jenkins/test-mc.sh | 32 +++++++++++++---- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.github/workflows/kind.yml b/.github/workflows/kind.yml index bd6f6728cea..55b9c00742e 100644 --- a/.github/workflows/kind.yml +++ b/.github/workflows/kind.yml @@ -606,6 +606,76 @@ jobs: path: log.tar.gz retention-days: 30 + test-multicluster-e2e: + name: Multi-cluster e2e tests on Kind clusters + needs: [check-changes] + if: ${{ needs.check-changes.outputs.has_changes == 'yes' }} + runs-on: [ubuntu-latest-8-cores] + timeout-minutes: 150 + env: + GIT_COMMIT: ${{ github.sha }} + WORKSPACE: ${{ github.workspace }} + steps: + - name: Free disk space (extended) + # Multi-cluster e2e builds Antrea and the multi-cluster controller, then runs three Kind clusters. + run: | + sudo apt-get clean + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo rm -rf "/usr/local/lib/android" + df -h + - uses: actions/checkout@v6 + with: + show-progress: false + - uses: actions/setup-go@v6 + with: + go-version-file: '.go-version' + - uses: ./.github/actions/setup-docker-classic + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver: docker + - uses: ./.github/actions/setup-kind + - name: Run multi-cluster e2e tests + run: | + mkdir -p "$HOME/.kube" + ./ci/jenkins/test-mc.sh \ + --testcase e2e \ + --registry "$(head -n1 ci/docker-registry)" \ + --mc-gateway \ + --coverage \ + --kind \ + --use-system-go \ + --workdir "$GITHUB_WORKSPACE" \ + --kubeconfigs-path "$HOME/.kube" + - name: Tar coverage files + run: tar -czf mc-e2e-coverage.tar.gz mc-e2e-coverage + - name: Upload coverage for test-multicluster-e2e + uses: actions/upload-artifact@v7 + with: + name: test-multicluster-e2e-coverage + path: mc-e2e-coverage.tar.gz + retention-days: 30 + - name: Codecov + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: '**/*.cov.out' + disable_search: true + flags: kind-e2e-tests + name: codecov-test-multicluster-e2e + directory: mc-e2e-coverage + fail_ci_if_error: ${{ github.event_name == 'push' }} + - name: Upload test log + uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: multicluster-e2e-kind.tar.gz + path: antrea-test-logs.tar.gz + retention-days: 30 + test-network-policy-conformance-encap: name: NetworkPolicy conformance tests on a Kind cluster on Linux needs: [build-antrea-coverage-image] @@ -867,6 +937,7 @@ jobs: - test-network-policy-conformance-encap - test-secondary-network - test-e2e-conformance + - test-multicluster-e2e - run-installation-checks runs-on: [ubuntu-latest] steps: diff --git a/ci/jenkins/test-mc.sh b/ci/jenkins/test-mc.sh index e77439c2666..c363de249ee 100755 --- a/ci/jenkins/test-mc.sh +++ b/ci/jenkins/test-mc.sh @@ -33,6 +33,11 @@ CODECOV_TOKEN="" COVERAGE=false KIND=false DEBUG=false +USE_SYSTEM_GO=false +GOLANG_RELEASE_DIR=${WORKDIR}/golang-releases + +multicluster_kubeconfigs=($EAST_CLUSTER_CONFIG $LEADER_CLUSTER_CONFIG $WEST_CLUSTER_CONFIG) +membercluster_kubeconfigs=($EAST_CLUSTER_CONFIG $WEST_CLUSTER_CONFIG) CLEAN_STALE_IMAGES="docker system prune --force --all --filter until=4h" PRINT_DOCKER_STATUS="docker system df -v" @@ -41,7 +46,7 @@ CLEAN_STALE_IMAGES_CONTAINERD="crictl rmi --prune" PRINT_CONTAINERD_STATUS="crictl ps --state Exited" _usage="Usage: $0 [--kubeconfigs-path ] [--workdir ] - [--testcase ] [--mc-gateway] [--codecov-token] [--coverage] [--kind] [--debug] + [--testcase ] [--mc-gateway] [--codecov-token] [--coverage] [--kind] [--use-system-go] [--debug] Run Antrea multi-cluster e2e tests on a remote (Jenkins) Linux Cluster Set. @@ -53,6 +58,7 @@ Run Antrea multi-cluster e2e tests on a remote (Jenkins) Linux Cluster Set. --codecov-token Token used to upload coverage report(s) to Codecov. --coverage Run e2e with coverage. --kind Run e2e on Kind clusters. + --use-system-go Use the Go toolchain already available in PATH. --debug Do not clean up Kind clusters when --kind is set." function print_usage { @@ -97,6 +103,10 @@ case $key in KIND=true shift ;; + --use-system-go) + USE_SYSTEM_GO=true + shift + ;; --debug) DEBUG=true shift @@ -474,7 +484,9 @@ function collect_coverage { trap clean_multicluster EXIT source $WORKSPACE/ci/jenkins/utils.sh -check_and_upgrade_golang +if [[ ${USE_SYSTEM_GO} != "true" ]]; then + check_and_upgrade_golang +fi clean_tmp clean_images @@ -508,9 +520,11 @@ set -e if [[ ${TESTCASE} =~ "e2e" ]]; then export GO111MODULE=on export GOPATH=${WORKDIR}/go - export GOROOT=${GOLANG_RELEASE_DIR}/go export GOCACHE=${WORKDIR}/.cache/go-build - export PATH=$GOROOT/bin:$PATH + if [[ ${USE_SYSTEM_GO} != "true" ]]; then + export GOROOT=${GOLANG_RELEASE_DIR}/go + export PATH=$GOROOT/bin:$PATH + fi deliver_antrea_multicluster modify_config @@ -522,9 +536,13 @@ if [[ ${TESTCASE} =~ "e2e" ]]; then mkdir -p mc-e2e-coverage collect_coverage ${CURRENT_DIR}/mc-e2e-coverage # Backup coverage files for later analysis - set +e;find ${DEFAULT_WORKDIR}/mc-e2e-coverage -maxdepth 1 -mtime +1 -type f | xargs -n 1 rm;set -e; # Clean up backup files older than one day. - cp -r mc-e2e-coverage ${DEFAULT_WORKDIR} - run_codecov "e2e-tests" "*antrea-mc*" "${CURRENT_DIR}/mc-e2e-coverage" + if [[ -d ${DEFAULT_WORKDIR} && -w ${DEFAULT_WORKDIR} ]]; then + set +e;find ${DEFAULT_WORKDIR}/mc-e2e-coverage -maxdepth 1 -mtime +1 -type f | xargs -n 1 rm;set -e; # Clean up backup files older than one day. + cp -r mc-e2e-coverage ${DEFAULT_WORKDIR} + fi + if [[ -n ${CODECOV_TOKEN} ]]; then + run_codecov "e2e-tests" "*antrea-mc*" "${CURRENT_DIR}/mc-e2e-coverage" + fi fi fi From 77b1c4debc3bcba6fe6fcc15e64ae3a53e779c1e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:14:22 +0800 Subject: [PATCH 05/13] Update trivy actions to v0.3.1 (#8088) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/trivy_scan.yml | 2 +- .github/workflows/trivy_scan_before_release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trivy_scan.yml b/.github/workflows/trivy_scan.yml index bbc7ca16b67..765b48dbeb0 100644 --- a/.github/workflows/trivy_scan.yml +++ b/.github/workflows/trivy_scan.yml @@ -41,7 +41,7 @@ jobs: docker pull antrea/antrea-controller-ubuntu:latest docker pull antrea/antrea-controller-ubuntu:${{ steps.find-antrea-greatest-version.outputs.antrea_version }} - name: Install Trivy - uses: aquasecurity/setup-trivy@151a37f29c09e4ff49f853d2b23682e688bb1fa1 # v0.3.0 + uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 - name: Get current UTC date id: date run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT diff --git a/.github/workflows/trivy_scan_before_release.yml b/.github/workflows/trivy_scan_before_release.yml index 4eff1eb8d71..f6ce54f656d 100644 --- a/.github/workflows/trivy_scan_before_release.yml +++ b/.github/workflows/trivy_scan_before_release.yml @@ -23,7 +23,7 @@ jobs: run: | ./hack/build-antrea-linux-all.sh --pull - name: Install Trivy - uses: aquasecurity/setup-trivy@151a37f29c09e4ff49f853d2b23682e688bb1fa1 # v0.3.0 + uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 - name: Download Trivy DB # Always download the latest DB for releases, don't use a cached version. # Try downloading the vulnerability DB up to 5 times, to account for TOOMANYREQUESTS errors. From b815f76e87c4d6f7968bfb055d47c7cdfa25188f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:34:22 +0800 Subject: [PATCH 06/13] Update Prometheus dependencies to v0.68.1 (#8085) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 07ef7e41933..0bc06bca04d 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/osrg/gobgp/v3 v3.37.0 github.com/pkg/sftp v1.13.10 github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.68.0 + github.com/prometheus/common v0.68.1 github.com/sirupsen/logrus v1.9.4 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 0eee0055097..e1c188558ed 100644 --- a/go.sum +++ b/go.sum @@ -465,8 +465,8 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA= -github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ= +github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= +github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= From 089da5cdbfde646958c0b70893d81d8b0f8517cf Mon Sep 17 00:00:00 2001 From: Lan Luo Date: Wed, 1 Apr 2026 07:22:11 +0000 Subject: [PATCH 07/13] Add AntreaNodeConfig agent controller and helpers Introduce pkg/agent/antreanodeconfig: - Match AntreaNodeConfig objects to a Node via nodeSelector; pick the oldest match (creationTimestamp, name as tiebreaker) and apply the first SecondaryNetwork winner only (no field-level merge). - Resolve the effective secondary-network OVS bridge from CRD list + static agent config (EffectiveSecondaryOVSBridge, EffectiveSnapshot). - Add a controller that watches AntreaNodeConfig and the local Node, recomputes the snapshot when labels or CRDs change, and notifies channel.Notifier subscribers (with periodic ANC resync). Add agent-facing SecondaryNetwork types under pkg/agent/types. Set the AntreaNodeConfig feature gate to Beta (default on). Refresh the agent chart, bundled install YAMLs, and feature-gate tests. Signed-off-by: Lan Luo --- build/charts/antrea/conf/antrea-agent.conf | 4 + build/yamls/antrea-aks.yml | 8 +- build/yamls/antrea-eks.yml | 8 +- build/yamls/antrea-gke.yml | 8 +- build/yamls/antrea-ipsec.yml | 8 +- build/yamls/antrea.yml | 8 +- .../antreanodeconfig/antreanodeconfig.go | 124 ++++++ .../antreanodeconfig/antreanodeconfig_test.go | 323 ++++++++++++++ pkg/agent/antreanodeconfig/controller.go | 243 +++++++++++ pkg/agent/antreanodeconfig/controller_test.go | 397 ++++++++++++++++++ .../antreanodeconfig/effective_snapshot.go | 60 +++ .../effective_snapshot_test.go | 56 +++ .../antreanodeconfig/secondary_network.go | 86 ++++ .../secondary_network_test.go | 161 +++++++ pkg/agent/types/antreanodeconfig.go | 102 +++++ .../handlers/featuregates/handler_test.go | 5 + pkg/features/antrea_features.go | 12 +- pkg/features/antrea_features_test.go | 10 + 18 files changed, 1611 insertions(+), 12 deletions(-) create mode 100644 pkg/agent/antreanodeconfig/antreanodeconfig.go create mode 100644 pkg/agent/antreanodeconfig/antreanodeconfig_test.go create mode 100644 pkg/agent/antreanodeconfig/controller.go create mode 100644 pkg/agent/antreanodeconfig/controller_test.go create mode 100644 pkg/agent/antreanodeconfig/effective_snapshot.go create mode 100644 pkg/agent/antreanodeconfig/effective_snapshot_test.go create mode 100644 pkg/agent/antreanodeconfig/secondary_network.go create mode 100644 pkg/agent/antreanodeconfig/secondary_network_test.go create mode 100644 pkg/agent/types/antreanodeconfig.go diff --git a/build/charts/antrea/conf/antrea-agent.conf b/build/charts/antrea/conf/antrea-agent.conf index dff7f2979f4..43ad69cfdc2 100644 --- a/build/charts/antrea/conf/antrea-agent.conf +++ b/build/charts/antrea/conf/antrea-agent.conf @@ -92,6 +92,10 @@ featureGates: # - AntreaProxy (proxyAll) {{- include "featureGate" (dict "featureGates" .Values.featureGates "name" "NFTablesHostNetworkMode" "default" false) }} +# Enable support for AntreaNodeConfig CRD, which allows per-Node configuration +# of Antrea agent settings via nodeSelector-based policies. +{{- include "featureGate" (dict "featureGates" .Values.featureGates "name" "AntreaNodeConfig" "default" true) }} + # Name of the OpenVSwitch bridge antrea-agent will create and use. # Make sure it doesn't conflict with your existing OpenVSwitch bridges. ovsBridge: {{ .Values.ovs.bridgeName | quote }} diff --git a/build/yamls/antrea-aks.yml b/build/yamls/antrea-aks.yml index 24c77d57f65..8bb7ec3cf65 100644 --- a/build/yamls/antrea-aks.yml +++ b/build/yamls/antrea-aks.yml @@ -4471,6 +4471,10 @@ data: # - AntreaProxy (proxyAll) # NFTablesHostNetworkMode: false + # Enable support for AntreaNodeConfig CRD, which allows per-Node configuration + # of Antrea agent settings via nodeSelector-based policies. + # AntreaNodeConfig: true + # Name of the OpenVSwitch bridge antrea-agent will create and use. # Make sure it doesn't conflict with your existing OpenVSwitch bridges. ovsBridge: "br-int" @@ -5899,7 +5903,7 @@ spec: kubectl.kubernetes.io/default-container: antrea-agent # Automatically restart Pods with a RollingUpdate if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: 8210b1e66f6269e7d5a9c85f070a2b1e76459aefa850d7a5066b7b824f00c292 + checksum/config: fc224133cdae1f19d3343015183b27b51dae6251e252a6fbdb30fb7df1e73539 labels: app: antrea component: antrea-agent @@ -6147,7 +6151,7 @@ spec: annotations: # Automatically restart Pod if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: 8210b1e66f6269e7d5a9c85f070a2b1e76459aefa850d7a5066b7b824f00c292 + checksum/config: fc224133cdae1f19d3343015183b27b51dae6251e252a6fbdb30fb7df1e73539 labels: app: antrea component: antrea-controller diff --git a/build/yamls/antrea-eks.yml b/build/yamls/antrea-eks.yml index c6244d28363..ec88ba85baf 100644 --- a/build/yamls/antrea-eks.yml +++ b/build/yamls/antrea-eks.yml @@ -4467,6 +4467,10 @@ data: # - AntreaProxy (proxyAll) # NFTablesHostNetworkMode: false + # Enable support for AntreaNodeConfig CRD, which allows per-Node configuration + # of Antrea agent settings via nodeSelector-based policies. + # AntreaNodeConfig: true + # Name of the OpenVSwitch bridge antrea-agent will create and use. # Make sure it doesn't conflict with your existing OpenVSwitch bridges. ovsBridge: "br-int" @@ -5895,7 +5899,7 @@ spec: kubectl.kubernetes.io/default-container: antrea-agent # Automatically restart Pods with a RollingUpdate if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: 8210b1e66f6269e7d5a9c85f070a2b1e76459aefa850d7a5066b7b824f00c292 + checksum/config: fc224133cdae1f19d3343015183b27b51dae6251e252a6fbdb30fb7df1e73539 labels: app: antrea component: antrea-agent @@ -6144,7 +6148,7 @@ spec: annotations: # Automatically restart Pod if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: 8210b1e66f6269e7d5a9c85f070a2b1e76459aefa850d7a5066b7b824f00c292 + checksum/config: fc224133cdae1f19d3343015183b27b51dae6251e252a6fbdb30fb7df1e73539 labels: app: antrea component: antrea-controller diff --git a/build/yamls/antrea-gke.yml b/build/yamls/antrea-gke.yml index aaa2943f2d6..32df8dabb80 100644 --- a/build/yamls/antrea-gke.yml +++ b/build/yamls/antrea-gke.yml @@ -4467,6 +4467,10 @@ data: # - AntreaProxy (proxyAll) # NFTablesHostNetworkMode: false + # Enable support for AntreaNodeConfig CRD, which allows per-Node configuration + # of Antrea agent settings via nodeSelector-based policies. + # AntreaNodeConfig: true + # Name of the OpenVSwitch bridge antrea-agent will create and use. # Make sure it doesn't conflict with your existing OpenVSwitch bridges. ovsBridge: "br-int" @@ -5886,7 +5890,7 @@ spec: kubectl.kubernetes.io/default-container: antrea-agent # Automatically restart Pods with a RollingUpdate if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: b09c8311a5c77d881bef59af4c7ea0c75d4c0b78dc7b5c548d77f2048c1be2b5 + checksum/config: b1844ac9a453a56e0aa1f485b8f3bdbf162af8758d49b52a5d1f8e0c6ed37186 labels: app: antrea component: antrea-agent @@ -6132,7 +6136,7 @@ spec: annotations: # Automatically restart Pod if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: b09c8311a5c77d881bef59af4c7ea0c75d4c0b78dc7b5c548d77f2048c1be2b5 + checksum/config: b1844ac9a453a56e0aa1f485b8f3bdbf162af8758d49b52a5d1f8e0c6ed37186 labels: app: antrea component: antrea-controller diff --git a/build/yamls/antrea-ipsec.yml b/build/yamls/antrea-ipsec.yml index df5895491f4..30fc6932886 100644 --- a/build/yamls/antrea-ipsec.yml +++ b/build/yamls/antrea-ipsec.yml @@ -4480,6 +4480,10 @@ data: # - AntreaProxy (proxyAll) # NFTablesHostNetworkMode: false + # Enable support for AntreaNodeConfig CRD, which allows per-Node configuration + # of Antrea agent settings via nodeSelector-based policies. + # AntreaNodeConfig: true + # Name of the OpenVSwitch bridge antrea-agent will create and use. # Make sure it doesn't conflict with your existing OpenVSwitch bridges. ovsBridge: "br-int" @@ -5899,7 +5903,7 @@ spec: kubectl.kubernetes.io/default-container: antrea-agent # Automatically restart Pods with a RollingUpdate if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: a9782c402bb6dbad085d0abb61931d4a21a9398bdd12794942ee8c73ef21c193 + checksum/config: cca55471c59e9cee5f5b7d82d2d1f5d94e75ae07cd51c6b9ac678946213bdc9b checksum/ipsec-secret: d0eb9c52d0cd4311b6d252a951126bf9bea27ec05590bed8a394f0f792dcb2a4 labels: app: antrea @@ -6191,7 +6195,7 @@ spec: annotations: # Automatically restart Pod if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: a9782c402bb6dbad085d0abb61931d4a21a9398bdd12794942ee8c73ef21c193 + checksum/config: cca55471c59e9cee5f5b7d82d2d1f5d94e75ae07cd51c6b9ac678946213bdc9b labels: app: antrea component: antrea-controller diff --git a/build/yamls/antrea.yml b/build/yamls/antrea.yml index 93a7be4436e..17daa51f9a5 100644 --- a/build/yamls/antrea.yml +++ b/build/yamls/antrea.yml @@ -4467,6 +4467,10 @@ data: # - AntreaProxy (proxyAll) # NFTablesHostNetworkMode: false + # Enable support for AntreaNodeConfig CRD, which allows per-Node configuration + # of Antrea agent settings via nodeSelector-based policies. + # AntreaNodeConfig: true + # Name of the OpenVSwitch bridge antrea-agent will create and use. # Make sure it doesn't conflict with your existing OpenVSwitch bridges. ovsBridge: "br-int" @@ -5886,7 +5890,7 @@ spec: kubectl.kubernetes.io/default-container: antrea-agent # Automatically restart Pods with a RollingUpdate if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: ca5121f404625ca074a0c3bce23b781e7a2fb88bceb5083cc9d4883f81ce6a67 + checksum/config: 95884506388aff1c6aca5b8418d18be3f7b890fe14384d60026055f890b9f869 labels: app: antrea component: antrea-agent @@ -6132,7 +6136,7 @@ spec: annotations: # Automatically restart Pod if the ConfigMap changes # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments - checksum/config: ca5121f404625ca074a0c3bce23b781e7a2fb88bceb5083cc9d4883f81ce6a67 + checksum/config: 95884506388aff1c6aca5b8418d18be3f7b890fe14384d60026055f890b9f869 labels: app: antrea component: antrea-controller diff --git a/pkg/agent/antreanodeconfig/antreanodeconfig.go b/pkg/agent/antreanodeconfig/antreanodeconfig.go new file mode 100644 index 00000000000..180c61041dc --- /dev/null +++ b/pkg/agent/antreanodeconfig/antreanodeconfig.go @@ -0,0 +1,124 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig provides a utility framework for evaluating which +// AntreaNodeConfig resources apply to a given Node and computing the effective +// merged secondary-network configuration. +// +// Controller watches AntreaNodeConfig and the local Node, +// computes an EffectiveSnapshot of all derived agent settings, and broadcasts +// updates on a util/channel SubscribableChannel so feature controllers do not +// each register their own ANC informer handlers. Extend EffectiveSnapshot and +// ComputeEffectiveSnapshot when new ANC spec fields gain agent consumers. +// +// SelectAndApplySecondaryNetworkConfig (and EffectiveSecondaryOVSBridge) can also be called directly +// with an informer-cached list of AntreaNodeConfig objects and the current Node +// to obtain merged configuration. See EffectiveSecondaryOVSBridge and +// Controller.EffectiveSecondaryOVSBridge for when the result is nil versus static fallback. +// +// # Selection and override semantics +// +// All AntreaNodeConfigs whose nodeSelector matches the Node's labels are +// gathered and sorted by creationTimestamp ascending (oldest first; name is +// used as a stable tiebreaker when timestamps are equal). The first (oldest) +// config that specifies a non-nil SecondaryNetwork takes effect entirely — +// there is no field-level merging within SecondaryNetwork and later configs +// are ignored once a winner is found. +package antreanodeconfig + +import ( + "sort" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/klog/v2" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" +) + +// SelectMatchingSecondaryNetworkConfigs returns the subset of configs whose nodeSelector +// matches node's labels, sorted by creationTimestamp ascending (oldest first). +// Configs with an invalid nodeSelector are skipped with a warning. +func SelectMatchingSecondaryNetworkConfigs(node *corev1.Node, configs []*crdv1alpha1.AntreaNodeConfig) []*crdv1alpha1.AntreaNodeConfig { + nodeLabels := labels.Set(node.Labels) + var matching []*crdv1alpha1.AntreaNodeConfig + for _, cfg := range configs { + sel, err := metav1.LabelSelectorAsSelector(&cfg.Spec.NodeSelector) + if err != nil { + klog.ErrorS(err, "Skipping AntreaNodeConfig with invalid nodeSelector", "config", cfg.Name) + continue + } + if sel.Matches(nodeLabels) { + matching = append(matching, cfg) + } + } + sort.Slice(matching, func(i, j int) bool { + ti := matching[i].CreationTimestamp + tj := matching[j].CreationTimestamp + if ti.Equal(&tj) { + return matching[i].Name < matching[j].Name + } + return ti.Before(&tj) + }) + return matching +} + +// ApplySecondaryNetworkConfigs computes the effective SecondaryNetworkConfig from an +// ordered (oldest-first) slice of AntreaNodeConfigs. It returns the +// SecondaryNetworkConfig from the first (oldest) config that specifies a +// non-nil SecondaryNetwork, and ignores all subsequent configs. It returns +// nil when none of the configs specifies a SecondaryNetwork, meaning the +// static agent config should remain in effect unchanged. +func ApplySecondaryNetworkConfigs(configs []*crdv1alpha1.AntreaNodeConfig) *agenttypes.SecondaryNetworkConfig { + for _, cfg := range configs { + if cfg.Spec.SecondaryNetwork == nil { + continue + } + converted := convertSecondaryNetwork(cfg.Spec.SecondaryNetwork) + return &converted + } + return nil +} + +// SelectAndApplySecondaryNetworkConfigs is a convenience wrapper that calls SelectMatchingSecondaryNetworkConfigsConfigs +// followed by ApplySecondaryNetworkConfigs. It returns the effective SecondaryNetworkConfig +// for node, or nil when no matching AntreaNodeConfig specifies a +// SecondaryNetwork (in which case the static agent config stays in effect). +func SelectAndApplySecondaryNetworkConfigs(node *corev1.Node, configs []*crdv1alpha1.AntreaNodeConfig) *agenttypes.SecondaryNetworkConfig { + return ApplySecondaryNetworkConfigs(SelectMatchingSecondaryNetworkConfigs(node, configs)) +} + +// convertSecondaryNetwork converts from the CRD type to SecondaryNetworkConfig. +// The CRD schema enforces at most one OVS bridge; OVSBridge is nil when the +// list is empty. +func convertSecondaryNetwork(in *crdv1alpha1.SecondaryNetworkConfig) agenttypes.SecondaryNetworkConfig { + if len(in.OVSBridges) == 0 { + return agenttypes.SecondaryNetworkConfig{} + } + b := in.OVSBridges[0] + bridge := &agenttypes.OVSBridgeConfig{ + BridgeName: b.BridgeName, + EnableMulticastSnooping: b.EnableMulticastSnooping, + } + for _, iface := range b.PhysicalInterfaces { + pi := agenttypes.PhysicalInterfaceConfig{Name: iface.Name} + if len(iface.AllowedVLANs) > 0 { + pi.AllowedVLANs = append(pi.AllowedVLANs, iface.AllowedVLANs...) + } + bridge.PhysicalInterfaces = append(bridge.PhysicalInterfaces, pi) + } + return agenttypes.SecondaryNetworkConfig{OVSBridge: bridge} +} diff --git a/pkg/agent/antreanodeconfig/antreanodeconfig_test.go b/pkg/agent/antreanodeconfig/antreanodeconfig_test.go new file mode 100644 index 00000000000..ab1cd77b78d --- /dev/null +++ b/pkg/agent/antreanodeconfig/antreanodeconfig_test.go @@ -0,0 +1,323 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" +) + +func makeNode(labels map[string]string) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: labels, + }, + } +} + +func makeANC(name string, ts time.Time, nodeSelector map[string]string, secNet *crdv1alpha1.SecondaryNetworkConfig) *crdv1alpha1.AntreaNodeConfig { + return &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + CreationTimestamp: metav1.NewTime(ts), + }, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{ + MatchLabels: nodeSelector, + }, + SecondaryNetwork: secNet, + }, + } +} + +func makeBridge(name string, mcast bool, ifaces ...crdv1alpha1.OVSPhysicalInterfaceConfig) crdv1alpha1.OVSBridgeConfig { + return crdv1alpha1.OVSBridgeConfig{ + BridgeName: name, + EnableMulticastSnooping: mcast, + PhysicalInterfaces: ifaces, + } +} + +func makeIface(name string, vlans ...string) crdv1alpha1.OVSPhysicalInterfaceConfig { + return crdv1alpha1.OVSPhysicalInterfaceConfig{Name: name, AllowedVLANs: vlans} +} + +var ( + t0 = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + t1 = t0.Add(time.Minute) + t2 = t0.Add(2 * time.Minute) +) + +func TestSelectMatchingConfigs(t *testing.T) { + node := makeNode(map[string]string{"role": "worker", "zone": "us-east"}) + + anc1 := makeANC("anc1", t0, map[string]string{"role": "worker"}, nil) + anc2 := makeANC("anc2", t1, map[string]string{"role": "control-plane"}, nil) + anc3 := makeANC("anc3", t2, map[string]string{"zone": "us-east"}, nil) + ancInvalidSel := &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid"}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "x", Operator: "BadOp", Values: []string{"v"}}, + }, + }, + }, + } + + tests := []struct { + name string + configs []*crdv1alpha1.AntreaNodeConfig + wantLen int + wantOrder []string + }{ + { + name: "no configs", + configs: nil, + wantLen: 0, + }, + { + name: "one matching", + configs: []*crdv1alpha1.AntreaNodeConfig{anc1}, + wantLen: 1, + }, + { + name: "one non-matching", + configs: []*crdv1alpha1.AntreaNodeConfig{anc2}, + wantLen: 0, + }, + { + name: "two matching sorted oldest-first", + configs: []*crdv1alpha1.AntreaNodeConfig{anc3, anc1}, // deliberately reversed + wantLen: 2, + wantOrder: []string{"anc1", "anc3"}, + }, + { + name: "invalid selector is skipped", + configs: []*crdv1alpha1.AntreaNodeConfig{ancInvalidSel, anc1}, + wantLen: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := SelectMatchingSecondaryNetworkConfigs(node, tc.configs) + require.Len(t, got, tc.wantLen) + if tc.wantOrder != nil { + for i, name := range tc.wantOrder { + assert.Equal(t, name, got[i].Name) + } + } + }) + } +} + +func TestSelectMatchingConfigs_TimestampTiebreaker(t *testing.T) { + // Two configs with the same timestamp; name is the tiebreaker. + node := makeNode(map[string]string{"role": "worker"}) + ancA := makeANC("zzz", t0, map[string]string{"role": "worker"}, nil) + ancB := makeANC("aaa", t0, map[string]string{"role": "worker"}, nil) + + got := SelectMatchingSecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancA, ancB}) + require.Len(t, got, 2) + assert.Equal(t, "aaa", got[0].Name, "alphabetically earlier name should sort first") + assert.Equal(t, "zzz", got[1].Name) +} + +func TestApplyConfigs(t *testing.T) { + secNet1 := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0")), + }, + } + secNet2 := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br1", true, makeIface("eth1", "100", "200-300")), + }, + } + + anc1 := makeANC("anc1", t0, nil, secNet1) + anc2 := makeANC("anc2", t1, nil, secNet2) + ancNoSec := makeANC("ancNoSec", t2, nil, nil) + + tests := []struct { + name string + configs []*crdv1alpha1.AntreaNodeConfig + want *agenttypes.SecondaryNetworkConfig + }{ + { + name: "empty input returns nil", + configs: nil, + want: nil, + }, + { + name: "all configs have nil SecondaryNetwork returns nil", + configs: []*crdv1alpha1.AntreaNodeConfig{ancNoSec}, + want: nil, + }, + { + name: "single config with SecondaryNetwork", + configs: []*crdv1alpha1.AntreaNodeConfig{anc1}, + want: &agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", EnableMulticastSnooping: false, + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0"}, + }, + }, + }, + }, + { + name: "older config wins over newer one", + // anc1 (older) sets br0/eth0; anc2 (newer) sets br1/eth1 — br0 wins. + configs: []*crdv1alpha1.AntreaNodeConfig{anc1, anc2}, + want: &agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", EnableMulticastSnooping: false, + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0"}, + }, + }, + }, + }, + { + name: "nil SecondaryNetwork in between does not clear result", + // anc1 sets br0; ancNoSec has nil; the result should still be anc1's br0. + configs: []*crdv1alpha1.AntreaNodeConfig{anc1, ancNoSec}, + want: &agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0"}, + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ApplySecondaryNetworkConfigs(tc.configs) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestSelectAndApply(t *testing.T) { + node := makeNode(map[string]string{"role": "worker"}) + + secNetA := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{makeBridge("brA", false, makeIface("eth0"))}, + } + secNetB := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{makeBridge("brB", true, makeIface("eth1", "100"))}, + } + + // anc-old matches, sets brA (older timestamp) — should win + ancOld := makeANC("anc-old", t0, map[string]string{"role": "worker"}, secNetA) + // anc-new matches, sets brB (newer timestamp) + ancNew := makeANC("anc-new", t1, map[string]string{"role": "worker"}, secNetB) + // anc-other does not match the node + ancOther := makeANC("anc-other", t2, map[string]string{"role": "control-plane"}, secNetA) + + t.Run("no configs returns nil", func(t *testing.T) { + assert.Nil(t, SelectAndApplySecondaryNetworkConfigs(node, nil)) + }) + + t.Run("non-matching config returns nil", func(t *testing.T) { + assert.Nil(t, SelectAndApplySecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancOther})) + }) + + t.Run("single matching config applied", func(t *testing.T) { + got := SelectAndApplySecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancOld}) + require.NotNil(t, got) + require.NotNil(t, got.OVSBridge) + assert.Equal(t, "brA", got.OVSBridge.BridgeName) + }) + + t.Run("older matching config takes effect over newer one", func(t *testing.T) { + got := SelectAndApplySecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancOld, ancNew, ancOther}) + require.NotNil(t, got) + require.NotNil(t, got.OVSBridge) + assert.Equal(t, "brA", got.OVSBridge.BridgeName) + assert.False(t, got.OVSBridge.EnableMulticastSnooping) + assert.Nil(t, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) + }) +} + +func TestConvertSecondaryNetwork(t *testing.T) { + t.Run("empty bridges yields nil OVSBridge", func(t *testing.T) { + got := convertSecondaryNetwork(&crdv1alpha1.SecondaryNetworkConfig{}) + assert.Nil(t, got.OVSBridge) + }) + + t.Run("interface without AllowedVLANs has nil AllowedVLANs in output", func(t *testing.T) { + in := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0")), + }, + } + got := convertSecondaryNetwork(in) + require.NotNil(t, got.OVSBridge) + require.Len(t, got.OVSBridge.PhysicalInterfaces, 1) + assert.Equal(t, "eth0", got.OVSBridge.PhysicalInterfaces[0].Name) + assert.Nil(t, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) + }) + + t.Run("AllowedVLANs are preserved", func(t *testing.T) { + in := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0", "100", "200-300")), + }, + } + got := convertSecondaryNetwork(in) + require.NotNil(t, got.OVSBridge) + assert.Equal(t, []string{"100", "200-300"}, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) + }) + + t.Run("multicast snooping flag is preserved", func(t *testing.T) { + in := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", true), + }, + } + got := convertSecondaryNetwork(in) + require.NotNil(t, got.OVSBridge) + assert.True(t, got.OVSBridge.EnableMulticastSnooping) + }) + + t.Run("bridge with multiple interfaces", func(t *testing.T) { + in := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0"), makeIface("eth1", "10")), + }, + } + got := convertSecondaryNetwork(in) + require.NotNil(t, got.OVSBridge) + assert.Equal(t, "br0", got.OVSBridge.BridgeName) + assert.Len(t, got.OVSBridge.PhysicalInterfaces, 2) + assert.Nil(t, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) + assert.Equal(t, []string{"10"}, got.OVSBridge.PhysicalInterfaces[1].AllowedVLANs) + }) +} diff --git a/pkg/agent/antreanodeconfig/controller.go b/pkg/agent/antreanodeconfig/controller.go new file mode 100644 index 00000000000..66c92cd58a6 --- /dev/null +++ b/pkg/agent/antreanodeconfig/controller.go @@ -0,0 +1,243 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + "reflect" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + coreinformers "k8s.io/client-go/informers/core/v1" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdinformers "antrea.io/antrea/v2/pkg/client/informers/externalversions/crd/v1alpha1" + crdv1alpha1listers "antrea.io/antrea/v2/pkg/client/listers/crd/v1alpha1" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" + "antrea.io/antrea/v2/pkg/util/channel" +) + +const ( + // ancInformerResyncPeriod limits how long the agent can rely solely on + // SubscribableChannel delivery before reconciling AntreaNodeConfig-derived + // state from the informer cache again. + ancInformerResyncPeriod = 5 * time.Minute +) + +// Controller watches AntreaNodeConfig and the local Node, evaluates derived +// agent settings (see EffectiveSnapshot), and notifies subscribers when that +// aggregate snapshot changes. +type Controller struct { + nodeName string + staticSecondaryNetworkCfg *agentconfig.SecondaryNetworkConfig + ancLister crdv1alpha1listers.AntreaNodeConfigLister + nodeLister corelisters.NodeLister + notifier channel.Notifier + + nodeListerSynced cache.InformerSynced + ancListerSynced cache.InformerSynced + + mu sync.RWMutex + node *corev1.Node + lastNotified *EffectiveSnapshot +} + +// NewController constructs a Controller and registers informer handlers. +// The local Node is loaded from nodeInformer after its cache syncs (see Run) +// and kept up to date via Add/Update/Delete callbacks. notifier.Notify +// receives *EffectiveSnapshot payloads (deep-copied). +func NewController( + ancInformer crdinformers.AntreaNodeConfigInformer, + nodeInformer coreinformers.NodeInformer, + nodeName string, + agentConfig *agentconfig.AgentConfig, + notifier channel.Notifier, +) *Controller { + c := &Controller{ + nodeName: nodeName, + staticSecondaryNetworkCfg: &agentConfig.SecondaryNetwork, + ancLister: ancInformer.Lister(), + nodeLister: nodeInformer.Lister(), + notifier: notifier, + nodeListerSynced: nodeInformer.Informer().HasSynced, + ancListerSynced: ancInformer.Informer().HasSynced, + } + + nodeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.onNodeAdd, + UpdateFunc: c.onNodeUpdate, + DeleteFunc: c.onNodeDelete, + }) + + ancInformer.Informer().AddEventHandlerWithResyncPeriod( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(_ interface{}) { c.recomputeAndNotifyAsync() }, + UpdateFunc: func(_, _ interface{}) { c.recomputeAndNotifyAsync() }, + DeleteFunc: func(_ interface{}) { c.recomputeAndNotifyAsync() }, + }, + ancInformerResyncPeriod, + ) + + return c +} + +// EffectiveSecondaryOVSBridge returns the current effective bridge configuration +// from the informer cache and the latest known Node labels. It is safe to call +// concurrently with Run and informer callbacks. +// +// Before the Node and AntreaNodeConfig informer caches have synced, it returns +// nil so the secondary-network controller does not create a bridge from static +// ConfigMap data while AntreaNodeConfig objects are not yet visible (which would +// later be replaced by CR-driven reconcile). +func (c *Controller) EffectiveSecondaryOVSBridge() *agenttypes.OVSBridgeConfig { + if !c.nodeListerSynced() || !c.ancListerSynced() { + return nil + } + c.mu.RLock() + node := c.node + c.mu.RUnlock() + all, err := c.ancLister.List(labels.Everything()) + snap := ComputeEffectiveSnapshot(node, all, err, c.staticSecondaryNetworkCfg) + if snap == nil { + return nil + } + return snap.SecondaryOVSBridge +} + +// Run waits for the AntreaNodeConfig and Node informer caches to sync, publishes +// the initial effective configuration, then blocks until stopCh is closed. +func (c *Controller) Run(stopCh <-chan struct{}) { + klog.InfoS("Starting AntreaNodeConfig controller") + defer klog.InfoS("Shutting down AntreaNodeConfig controller") + + if !cache.WaitForNamedCacheSync("AntreaNodeConfigController", stopCh, + c.nodeListerSynced, c.ancListerSynced) { + return + } + c.loadLocalNodeFromLister() + c.recomputeAndNotify() + <-stopCh +} + +// loadLocalNodeFromLister sets c.node from the shared Node informer cache after +// sync. Event handlers keep it current afterward. +func (c *Controller) loadLocalNodeFromLister() { + node, err := c.nodeLister.Get(c.nodeName) + if err != nil { + if apierrors.IsNotFound(err) { + klog.InfoS("Local Node not present in informer cache after sync", "node", c.nodeName) + } else { + klog.ErrorS(err, "Failed to get local Node from informer lister", "node", c.nodeName) + } + c.mu.Lock() + c.node = nil + c.mu.Unlock() + return + } + c.mu.Lock() + c.node = node + c.mu.Unlock() +} + +func (c *Controller) onNodeAdd(obj interface{}) { + node, ok := obj.(*corev1.Node) + if !ok { + return + } + if node.Name != c.nodeName { + return + } + c.mu.Lock() + c.node = node + c.mu.Unlock() + c.recomputeAndNotifyAsync() +} + +func (c *Controller) onNodeUpdate(oldObj, newObj interface{}) { + oldNode, ok := oldObj.(*corev1.Node) + if !ok { + return + } + newNode, ok := newObj.(*corev1.Node) + if !ok { + return + } + if newNode.Name != c.nodeName { + return + } + c.mu.Lock() + c.node = newNode + c.mu.Unlock() + if reflect.DeepEqual(oldNode.Labels, newNode.Labels) { + return + } + klog.V(2).InfoS("Local Node labels changed, recomputing AntreaNodeConfig-derived agent settings") + c.recomputeAndNotifyAsync() +} + +func (c *Controller) onNodeDelete(obj interface{}) { + node, ok := obj.(*corev1.Node) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + return + } + n, ok := tombstone.Obj.(*corev1.Node) + if !ok { + return + } + node = n + } + if node.Name != c.nodeName { + return + } + c.mu.Lock() + c.node = nil + c.mu.Unlock() + c.recomputeAndNotifyAsync() +} + +func (c *Controller) recomputeAndNotifyAsync() { + go c.recomputeAndNotify() +} + +func (c *Controller) recomputeAndNotify() { + c.mu.RLock() + node := c.node + c.mu.RUnlock() + all, err := c.ancLister.List(labels.Everything()) + next := ComputeEffectiveSnapshot(node, all, err, c.staticSecondaryNetworkCfg) + + // Compare and store using a deep copy so lastNotified is not aliased to + // memory that callers or the informer cache might reuse, and so future + // EffectiveSnapshot fields remain safe to extend. + payload := next.DeepCopy() + c.mu.Lock() + if c.lastNotified != nil && reflect.DeepEqual(c.lastNotified, payload) { + c.mu.Unlock() + return + } + c.lastNotified = payload + c.mu.Unlock() + + if !c.notifier.Notify(payload) { + klog.Error("Failed to notify AntreaNodeConfig effective snapshot update; subscribers may be stale until next resync") + } +} diff --git a/pkg/agent/antreanodeconfig/controller_test.go b/pkg/agent/antreanodeconfig/controller_test.go new file mode 100644 index 00000000000..ccb39025b19 --- /dev/null +++ b/pkg/agent/antreanodeconfig/controller_test.go @@ -0,0 +1,397 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/informers" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/cache" + + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" + fakeversioned "antrea.io/antrea/v2/pkg/client/clientset/versioned/fake" + crdinformers "antrea.io/antrea/v2/pkg/client/informers/externalversions" + crdv1a1inf "antrea.io/antrea/v2/pkg/client/informers/externalversions/crd/v1alpha1" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" +) + +const ( + testLocalNodeName = "node-under-test" + testRoleWorker = "worker" +) + +var testANCBaseTime = metav1.NewTime(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + +// notifyRecorder implements channel.Notifier for tests. +type notifyRecorder struct { + mu sync.Mutex + seen []interface{} + fail bool +} + +func (n *notifyRecorder) Notify(e interface{}) bool { + n.mu.Lock() + n.seen = append(n.seen, e) + n.mu.Unlock() + return !n.fail +} + +func (n *notifyRecorder) Len() int { + n.mu.Lock() + defer n.mu.Unlock() + return len(n.seen) +} + +func (n *notifyRecorder) Last() interface{} { + n.mu.Lock() + defer n.mu.Unlock() + if len(n.seen) == 0 { + return nil + } + return n.seen[len(n.seen)-1] +} + +func testWorkerNode() *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: testLocalNodeName, + Labels: map[string]string{"role": testRoleWorker}, + }, + } +} + +func testStaticSecondaryNet() *agentconfig.AgentConfig { + return &agentconfig.AgentConfig{ + SecondaryNetwork: agentconfig.SecondaryNetworkConfig{ + OVSBridges: []agentconfig.OVSBridgeConfig{ + {BridgeName: "br-static", PhysicalInterfaces: []string{"eth0"}}, + }, + }, + } +} + +func testANC(name, bridge string) *crdv1alpha1.AntreaNodeConfig { + return &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name, CreationTimestamp: testANCBaseTime}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": testRoleWorker}}, + SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + {BridgeName: bridge, PhysicalInterfaces: []crdv1alpha1.OVSPhysicalInterfaceConfig{{Name: "eth1"}}}, + }, + }, + }, + } +} + +func ancAsRuntime(anc ...*crdv1alpha1.AntreaNodeConfig) []runtime.Object { + out := make([]runtime.Object, len(anc)) + for i := range anc { + out[i] = anc[i] + } + return out +} + +// startTestInformers runs Node and AntreaNodeConfig informers until stopCh is closed. +// kube is the fake Kubernetes clientset backing nodeInformer; use it to mutate Node +// objects so the informer cache stays consistent with what the controller observes. +func startTestInformers(t *testing.T, node *corev1.Node, ancObjs ...runtime.Object) ( + stopCh chan struct{}, + ancInformer crdv1a1inf.AntreaNodeConfigInformer, + nodeInformer corev1informers.NodeInformer, + kube kubernetes.Interface, +) { + t.Helper() + stopCh = make(chan struct{}) + crdClient := fakeversioned.NewSimpleClientset(ancObjs...) + var kubeObjs []runtime.Object + if node != nil { + kubeObjs = append(kubeObjs, node) + } + kubeClient := fake.NewClientset(kubeObjs...) + kubeFactory := informers.NewSharedInformerFactory(kubeClient, 0) + crdFactory := crdinformers.NewSharedInformerFactory(crdClient, 0) + nodeInformer = kubeFactory.Core().V1().Nodes() + ancInformer = crdFactory.Crd().V1alpha1().AntreaNodeConfigs() + // Eagerly construct SharedIndexInformers so factory Start runs their reflectors. + _ = nodeInformer.Informer() + _ = ancInformer.Informer() + kubeFactory.Start(stopCh) + crdFactory.Start(stopCh) + require.Eventually(t, func() bool { + return nodeInformer.Informer().HasSynced() && ancInformer.Informer().HasSynced() + }, 5*time.Second, 5*time.Millisecond, "informer caches should sync") + return stopCh, ancInformer, nodeInformer, kubeClient +} + +// controllerTestEnv is a controller wired to synced fake informers. StopCh is closed via t.Cleanup. +type controllerTestEnv struct { + t *testing.T + C *Controller + Rec *notifyRecorder + Kube kubernetes.Interface +} + +// newControllerTestEnv starts informers and constructs a Controller. If rec is nil, a new notifyRecorder is used. +func newControllerTestEnv(t *testing.T, rec *notifyRecorder, node *corev1.Node, ancObjs ...runtime.Object) *controllerTestEnv { + t.Helper() + if rec == nil { + rec = ¬ifyRecorder{} + } + stopCh, ancInf, nodeInf, kube := startTestInformers(t, node, ancObjs...) + t.Cleanup(func() { close(stopCh) }) + c := NewController(ancInf, nodeInf, testLocalNodeName, testStaticSecondaryNet(), rec) + return &controllerTestEnv{t: t, C: c, Rec: rec, Kube: kube} +} + +func (e *controllerTestEnv) loadLocalNode() { + e.t.Helper() + e.C.loadLocalNodeFromLister() +} + +func (e *controllerTestEnv) recompute() { + e.t.Helper() + e.C.recomputeAndNotify() +} + +func TestLoadLocalNodeFromLister(t *testing.T) { + tests := []struct { + name string + node *corev1.Node + wantNil bool + }{ + {name: "local node present", node: testWorkerNode()}, + {name: "local node missing", node: nil, wantNil: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + env := newControllerTestEnv(t, nil, tc.node) + env.loadLocalNode() + env.C.mu.RLock() + got := env.C.node + env.C.mu.RUnlock() + if tc.wantNil { + assert.Nil(t, got) + } else { + require.NotNil(t, got) + assert.Equal(t, testLocalNodeName, got.Name) + } + }) + } +} + +func TestEffectiveSecondaryOVSBridgeReturnsNilBeforeInformerSync(t *testing.T) { + rec := ¬ifyRecorder{} + kube := fake.NewClientset(testWorkerNode()) + nodeInf := informers.NewSharedInformerFactory(kube, 0).Core().V1().Nodes() + crdClient := fakeversioned.NewSimpleClientset(testANC("a1", "br-anc")) + crdInf := crdinformers.NewSharedInformerFactory(crdClient, 0).Crd().V1alpha1().AntreaNodeConfigs() + + c := NewController(crdInf, nodeInf, testLocalNodeName, testStaticSecondaryNet(), rec) + // Informers are not started: caches are unsynced. Static secondary config + // must not be used while AntreaNodeConfig objects are not yet visible. + assert.Nil(t, c.EffectiveSecondaryOVSBridge()) +} + +func TestEffectiveSecondaryOVSBridgeUsesInformerCaches(t *testing.T) { + env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(testANC("a1", "br-anc"))...) + env.loadLocalNode() + + br := env.C.EffectiveSecondaryOVSBridge() + require.NotNil(t, br) + assert.Equal(t, "br-anc", br.BridgeName) +} + +func TestRecomputeAndNotifyDedup(t *testing.T) { + env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(testANC("a1", "br-anc"))...) + env.loadLocalNode() + env.recompute() + env.recompute() + assert.Equal(t, 1, env.Rec.Len(), "identical snapshot should not notify twice") +} + +func TestRecomputeAndNotifyOnLabelChange(t *testing.T) { + env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(testANC("a1", "br-anc"))...) + env.loadLocalNode() + env.recompute() + require.Equal(t, 1, env.Rec.Len()) + + newNode := testWorkerNode().DeepCopy() + newNode.Labels = map[string]string{"role": "other"} + newNode.ResourceVersion = "2" + _, err := env.Kube.CoreV1().Nodes().Update(context.Background(), newNode, metav1.UpdateOptions{}) + require.NoError(t, err) + + require.Eventually(t, func() bool { return env.Rec.Len() >= 2 }, 2*time.Second, 10*time.Millisecond, + "label change should trigger another notify") + last, ok := env.Rec.Last().(*EffectiveSnapshot) + require.True(t, ok) + require.NotNil(t, last.SecondaryOVSBridge) + assert.Equal(t, "br-static", last.SecondaryOVSBridge.BridgeName, "non-matching ANC should fall back to static") +} + +func TestNodeEventHandlersNoExtraNotify(t *testing.T) { + otherNode := func() *corev1.Node { + return &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "other-node", Labels: map[string]string{"a": "b"}}} + } + // These handlers either no-op before touching the local Node or update c.node + // without calling recomputeAndNotifyAsync (same labels). All paths are + // synchronous — no synctest / sleep needed. + tests := []struct { + name string + act func(t *testing.T, c *Controller) + }{ + { + name: "OnNodeUpdate same labels", + act: func(t *testing.T, c *Controller) { + old := testWorkerNode() + newN := old.DeepCopy() + newN.ResourceVersion = "2" + newN.UID = "updated-uid" + c.onNodeUpdate(old, newN) + }, + }, + { + name: "OnNodeAdd ignores other node", + act: func(t *testing.T, c *Controller) { + c.onNodeAdd(&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "other-node"}}) + }, + }, + { + name: "OnNodeUpdate wrong type ignored", + act: func(t *testing.T, c *Controller) { + c.onNodeUpdate("not-a-node", testWorkerNode()) + }, + }, + { + name: "OnNodeUpdate different node ignored", + act: func(t *testing.T, c *Controller) { + o := otherNode() + o2 := o.DeepCopy() + o2.Labels["c"] = "d" + c.onNodeUpdate(o, o2) + }, + }, + { + name: "OnNodeDelete wrong type ignored", + act: func(t *testing.T, c *Controller) { + c.onNodeDelete("not-a-node") + }, + }, + { + name: "OnNodeDelete different node ignored", + act: func(t *testing.T, c *Controller) { + c.onNodeDelete(&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "other-node"}}) + }, + }, + { + name: "OnNodeAdd wrong type ignored", + act: func(t *testing.T, c *Controller) { + c.onNodeAdd("not-a-node") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + env := newControllerTestEnv(t, nil, testWorkerNode()) + env.loadLocalNode() + env.recompute() + require.Equal(t, 1, env.Rec.Len()) + tc.act(t, env.C) + assert.Equal(t, 1, env.Rec.Len()) + }) + } +} + +func TestRunReturnsWhenStopClosedWhileCachesNeverSynced(t *testing.T) { + rec := ¬ifyRecorder{} + crdClient := fakeversioned.NewSimpleClientset() + kubeClient := fake.NewClientset(testWorkerNode()) + kf := informers.NewSharedInformerFactory(kubeClient, 0) + cf := crdinformers.NewSharedInformerFactory(crdClient, 0) + nodeInf := kf.Core().V1().Nodes() + ancInf := cf.Crd().V1alpha1().AntreaNodeConfigs() + _ = nodeInf.Informer() + _ = ancInf.Informer() + // Intentionally do not Start factories: HasSynced stays false. + c := NewController(ancInf, nodeInf, testLocalNodeName, testStaticSecondaryNet(), rec) + runStop := make(chan struct{}) + close(runStop) + c.Run(runStop) + assert.Equal(t, 0, rec.Len(), "Run should exit when stopCh is closed before caches sync") +} + +func TestOnNodeDeleteTombstone(t *testing.T) { + env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(testANC("a1", "br-anc"))...) + env.loadLocalNode() + env.recompute() + require.Equal(t, 1, env.Rec.Len()) + + env.C.onNodeDelete(cache.DeletedFinalStateUnknown{ + Key: testLocalNodeName, + Obj: testWorkerNode(), + }) + + require.Eventually(t, func() bool { return env.Rec.Len() >= 2 }, 2*time.Second, 10*time.Millisecond) + env.C.mu.RLock() + n := env.C.node + env.C.mu.RUnlock() + assert.Nil(t, n) +} + +func TestRecomputeNotifyFailureStillStoresLastNotified(t *testing.T) { + rec := ¬ifyRecorder{fail: true} + env := newControllerTestEnv(t, rec, testWorkerNode()) + env.loadLocalNode() + env.recompute() + + env.C.mu.RLock() + ln := env.C.lastNotified + env.C.mu.RUnlock() + require.NotNil(t, ln) + require.NotNil(t, ln.SecondaryOVSBridge) + assert.Equal(t, "br-static", ln.SecondaryOVSBridge.BridgeName) +} + +func TestControllerRunPublishesInitialSnapshot(t *testing.T) { + env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(testANC("a1", "br-run"))...) + runStop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + env.C.Run(runStop) + }() + + require.Eventually(t, func() bool { return env.Rec.Len() >= 1 }, 3*time.Second, 10*time.Millisecond) + snap, ok := env.Rec.Last().(*EffectiveSnapshot) + require.True(t, ok) + require.NotNil(t, snap.SecondaryOVSBridge) + assert.Equal(t, "br-run", snap.SecondaryOVSBridge.BridgeName) + + close(runStop) + wg.Wait() +} diff --git a/pkg/agent/antreanodeconfig/effective_snapshot.go b/pkg/agent/antreanodeconfig/effective_snapshot.go new file mode 100644 index 00000000000..9f43ccb3678 --- /dev/null +++ b/pkg/agent/antreanodeconfig/effective_snapshot.go @@ -0,0 +1,60 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + corev1 "k8s.io/api/core/v1" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" +) + +// EffectiveSnapshot aggregates AntreaNodeConfig-derived settings for this Node +// that the agent exposes to SubscribableChannel subscribers. +// +// When new spec fields are added to AntreaNodeConfig and consumed by the agent, +// extend this struct and ComputeEffectiveSnapshot so the sync controller can +// detect changes to any derived sub-state, not only secondary networking. +type EffectiveSnapshot struct { + SecondaryOVSBridge *agenttypes.OVSBridgeConfig +} + +// DeepCopy returns a deep copy of the snapshot suitable for passing to +// channel subscribers. +func (s *EffectiveSnapshot) DeepCopy() *EffectiveSnapshot { + if s == nil { + return nil + } + out := &EffectiveSnapshot{} + if s.SecondaryOVSBridge != nil { + out.SecondaryOVSBridge = s.SecondaryOVSBridge.DeepCopy() + } + return out +} + +// ComputeEffectiveSnapshot builds the full derived state from the informer +// cache and static secondary-network YAML. Keep this the single place that +// maps ANC objects into agent-facing values as new ANC areas are added. +func ComputeEffectiveSnapshot( + node *corev1.Node, + ancConfigs []*crdv1alpha1.AntreaNodeConfig, + listErr error, + staticSecondaryNetworkCfg *agentconfig.SecondaryNetworkConfig, +) *EffectiveSnapshot { + return &EffectiveSnapshot{ + SecondaryOVSBridge: EffectiveSecondaryOVSBridge(node, ancConfigs, listErr, true, staticSecondaryNetworkCfg), + } +} diff --git a/pkg/agent/antreanodeconfig/effective_snapshot_test.go b/pkg/agent/antreanodeconfig/effective_snapshot_test.go new file mode 100644 index 00000000000..549ee81ce42 --- /dev/null +++ b/pkg/agent/antreanodeconfig/effective_snapshot_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" +) + +func TestEffectiveSnapshotDeepCopy(t *testing.T) { + t.Run("nil receiver", func(t *testing.T) { + var s *EffectiveSnapshot + assert.Nil(t, s.DeepCopy()) + }) + + t.Run("empty bridge", func(t *testing.T) { + s := &EffectiveSnapshot{} + cp := s.DeepCopy() + require.NotNil(t, cp) + assert.Nil(t, cp.SecondaryOVSBridge) + }) + + t.Run("with bridge", func(t *testing.T) { + s := &EffectiveSnapshot{ + SecondaryOVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br1", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0", AllowedVLANs: []string{"100"}}, + }, + }, + } + cp := s.DeepCopy() + require.NotNil(t, cp) + require.NotNil(t, cp.SecondaryOVSBridge) + assert.NotSame(t, s.SecondaryOVSBridge, cp.SecondaryOVSBridge) + assert.Equal(t, s.SecondaryOVSBridge.BridgeName, cp.SecondaryOVSBridge.BridgeName) + cp.SecondaryOVSBridge.BridgeName = "mutated" + assert.Equal(t, "br1", s.SecondaryOVSBridge.BridgeName) + }) +} diff --git a/pkg/agent/antreanodeconfig/secondary_network.go b/pkg/agent/antreanodeconfig/secondary_network.go new file mode 100644 index 00000000000..7963bf26b44 --- /dev/null +++ b/pkg/agent/antreanodeconfig/secondary_network.go @@ -0,0 +1,86 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/klog/v2" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" +) + +// EffectiveSecondaryOVSBridge returns the effective OVS bridge configuration for +// secondary networking on this Node. +// +// When useAntreaNodeConfig is false, AntreaNodeConfig objects are ignored and +// only staticCfg from the agent ConfigMap is used (rule 1). +// +// When useAntreaNodeConfig is true, staticCfg is only used after the Node and +// AntreaNodeConfig informer caches have synced (enforced in +// antreanodeconfig.Controller.EffectiveSecondaryOVSBridge) and the local Node +// object is known. That avoids treating a transient empty ANC list as “no CR” +// and applying static config before CRs are visible. +// +// When useAntreaNodeConfig is true and node is nil (Node not loaded yet), this +// function returns nil so static secondary-network config is not applied in +// place of AntreaNodeConfig. +// +// When useAntreaNodeConfig is true and node is non-nil, ancConfigs is the +// current list of AntreaNodeConfig objects from the informer cache. If listErr +// is non-nil, the static config is used after logging. Otherwise, when a +// matching AntreaNodeConfig specifies secondary network settings, those +// override staticCfg entirely (rule 2). When no matching config applies, or the +// winner has no bridge, the return value follows the same semantics as the +// previous resolveEffectiveBridgeConfig helper in the secondary network +// package. +func EffectiveSecondaryOVSBridge( + node *corev1.Node, + ancConfigs []*crdv1alpha1.AntreaNodeConfig, + listErr error, + useAntreaNodeConfig bool, + staticSecondaryNetworkCfg *agentconfig.SecondaryNetworkConfig, +) *agenttypes.OVSBridgeConfig { + if useAntreaNodeConfig && node == nil { + return nil + } + if useAntreaNodeConfig && node != nil { + if listErr != nil { + klog.ErrorS(listErr, "Failed to list AntreaNodeConfigs, falling back to static config") + } else { + effective := SelectAndApplySecondaryNetworkConfigs(node, ancConfigs) + if effective != nil { + if effective.OVSBridge != nil { + klog.V(2).InfoS("Using AntreaNodeConfig secondary network config", "bridge", effective.OVSBridge.BridgeName) + } + return effective.OVSBridge + } + } + } + + if staticSecondaryNetworkCfg == nil || len(staticSecondaryNetworkCfg.OVSBridges) == 0 { + return nil + } + b := staticSecondaryNetworkCfg.OVSBridges[0] + bridge := &agenttypes.OVSBridgeConfig{ + BridgeName: b.BridgeName, + EnableMulticastSnooping: b.EnableMulticastSnooping, + } + for _, iface := range b.PhysicalInterfaces { + bridge.PhysicalInterfaces = append(bridge.PhysicalInterfaces, agenttypes.PhysicalInterfaceConfig{Name: iface}) + } + return bridge +} diff --git a/pkg/agent/antreanodeconfig/secondary_network_test.go b/pkg/agent/antreanodeconfig/secondary_network_test.go new file mode 100644 index 00000000000..81b995cc0be --- /dev/null +++ b/pkg/agent/antreanodeconfig/secondary_network_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" +) + +func TestEffectiveSecondaryOVSBridge(t *testing.T) { + staticCfg := &agentconfig.SecondaryNetworkConfig{ + OVSBridges: []agentconfig.OVSBridgeConfig{ + {BridgeName: "br-static", PhysicalInterfaces: []string{"eth0"}}, + }, + } + emptyCfg := &agentconfig.SecondaryNetworkConfig{} + + workerNode := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node1", Labels: map[string]string{"role": "worker"}}} + + ancTime0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + ancMatchingWithBridge := &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "anc1", CreationTimestamp: metav1.NewTime(ancTime0)}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "worker"}}, + SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + { + BridgeName: "br-anc", + PhysicalInterfaces: []crdv1alpha1.OVSPhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100"}}, + }, + }, + }, + }, + }, + } + ancMatchingNoBridge := &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "anc2", CreationTimestamp: metav1.NewTime(ancTime0)}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "worker"}}, + SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{}, + }, + } + ancNonMatching := &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "anc3", CreationTimestamp: metav1.NewTime(ancTime0)}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "control-plane"}}, + SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{{BridgeName: "br-other"}}, + }, + }, + } + + tests := []struct { + name string + node *corev1.Node + ancConfigs []*crdv1alpha1.AntreaNodeConfig + listErr error + useANC bool + staticCfg *agentconfig.SecondaryNetworkConfig + wantBridge *agenttypes.OVSBridgeConfig + }{ + { + name: "rule 1: AntreaNodeConfig disabled, use static config", + node: workerNode, + useANC: false, + staticCfg: staticCfg, + wantBridge: &agenttypes.OVSBridgeConfig{BridgeName: "br-static", PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: "eth0"}}}, + }, + { + name: "rule 1: no matching ANC, use static config", + node: workerNode, + ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancNonMatching}, + useANC: true, + staticCfg: staticCfg, + wantBridge: &agenttypes.OVSBridgeConfig{BridgeName: "br-static", PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: "eth0"}}}, + }, + { + name: "rule 1: empty static config and no matching ANC", + node: workerNode, + ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancNonMatching}, + useANC: true, + staticCfg: emptyCfg, + wantBridge: nil, + }, + { + name: "rule 2: matching ANC overrides static config", + node: workerNode, + ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, + useANC: true, + staticCfg: staticCfg, + wantBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br-anc", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100"}}, + }, + }, + }, + { + name: "rule 2: matching ANC with no bridge yields nil", + node: workerNode, + ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingNoBridge}, + useANC: true, + staticCfg: staticCfg, + wantBridge: nil, + }, + { + name: "nil node returns nil when ANC enabled — do not prefer static over CR", + node: nil, + ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, + useANC: true, + staticCfg: staticCfg, + wantBridge: nil, + }, + { + name: "list error falls back to static config", + node: workerNode, + ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, + listErr: errors.New("informer list failed"), + useANC: true, + staticCfg: staticCfg, + wantBridge: &agenttypes.OVSBridgeConfig{BridgeName: "br-static", PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: "eth0"}}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := EffectiveSecondaryOVSBridge(tc.node, tc.ancConfigs, tc.listErr, tc.useANC, tc.staticCfg) + if tc.wantBridge == nil { + assert.Nil(t, got) + } else { + require.NotNil(t, got) + assert.Equal(t, tc.wantBridge.BridgeName, got.BridgeName) + assert.Equal(t, tc.wantBridge.EnableMulticastSnooping, got.EnableMulticastSnooping) + assert.Equal(t, tc.wantBridge.PhysicalInterfaces, got.PhysicalInterfaces) + } + }) + } +} diff --git a/pkg/agent/types/antreanodeconfig.go b/pkg/agent/types/antreanodeconfig.go new file mode 100644 index 00000000000..6aa0d154cda --- /dev/null +++ b/pkg/agent/types/antreanodeconfig.go @@ -0,0 +1,102 @@ +// Copyright 2026 Antrea Authors +// +// 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 types + +// SecondaryNetworkConfig is the effective secondary network configuration +// derived from matching AntreaNodeConfig resources after applying override semantics. +// It is a richer superset of secondary-network configuration: physical +// interfaces carry per-interface VLAN filters (AllowedVLANs) that are not +// expressible in the static YAML config. +// +// When a non-nil value is resolved, it is used to override the static +// secondary-network configuration loaded from the agent config file. +type SecondaryNetworkConfig struct { + // OVSBridge is the single OVS bridge configuration. The CRD schema enforces + // at most one bridge, so a pointer is used — nil means no bridge is configured. + OVSBridge *OVSBridgeConfig +} + +// OVSBridgeConfig describes a single OVS bridge and its uplink interfaces. +type OVSBridgeConfig struct { + // BridgeName is the name of the OVS bridge. + BridgeName string + // PhysicalInterfaces is the list of physical interfaces connected to this bridge. + PhysicalInterfaces []PhysicalInterfaceConfig + // EnableMulticastSnooping enables multicast snooping on the bridge. + EnableMulticastSnooping bool +} + +// PhysicalInterfaceConfig describes a physical interface and its optional +// VLAN filter. +type PhysicalInterfaceConfig struct { + // Name is the name of the physical interface. + Name string + // AllowedVLANs is a list of VLAN IDs or VLAN ID ranges (e.g. "100", + // "200-300") that are allowed on this interface. If empty, all VLANs + // are allowed. + AllowedVLANs []string +} + +// DeepCopy returns a deep copy of the OVSBridgeConfig. +func (b *OVSBridgeConfig) DeepCopy() *OVSBridgeConfig { + if b == nil { + return nil + } + cp := &OVSBridgeConfig{ + BridgeName: b.BridgeName, + EnableMulticastSnooping: b.EnableMulticastSnooping, + } + if b.PhysicalInterfaces != nil { + cp.PhysicalInterfaces = make([]PhysicalInterfaceConfig, len(b.PhysicalInterfaces)) + for i, pi := range b.PhysicalInterfaces { + cp.PhysicalInterfaces[i] = PhysicalInterfaceConfig{Name: pi.Name} + if pi.AllowedVLANs != nil { + cp.PhysicalInterfaces[i].AllowedVLANs = append([]string(nil), pi.AllowedVLANs...) + } + } + } + return cp +} + +// WithoutInterface returns a new OVSBridgeConfig that is identical to b except +// that the interface with the given name is removed from PhysicalInterfaces. +func (b *OVSBridgeConfig) WithoutInterface(name string) *OVSBridgeConfig { + cp := b.DeepCopy() + filtered := cp.PhysicalInterfaces[:0] + for _, pi := range cp.PhysicalInterfaces { + if pi.Name != name { + filtered = append(filtered, pi) + } + } + cp.PhysicalInterfaces = filtered + return cp +} + +// WithClearedTrunks returns a new OVSBridgeConfig where AllowedVLANs is +// cleared for any interface whose entry in desired has no AllowedVLANs. This +// reflects the state after clearStaleTrunks has run successfully. +func (b *OVSBridgeConfig) WithClearedTrunks(desired []PhysicalInterfaceConfig) *OVSBridgeConfig { + desiredMap := make(map[string]PhysicalInterfaceConfig, len(desired)) + for _, pi := range desired { + desiredMap[pi.Name] = pi + } + cp := b.DeepCopy() + for i, pi := range cp.PhysicalInterfaces { + if d, ok := desiredMap[pi.Name]; ok && len(d.AllowedVLANs) == 0 { + cp.PhysicalInterfaces[i].AllowedVLANs = nil + } + } + return cp +} diff --git a/pkg/apiserver/handlers/featuregates/handler_test.go b/pkg/apiserver/handlers/featuregates/handler_test.go index 6edc0468bef..aa83c5118d2 100644 --- a/pkg/apiserver/handlers/featuregates/handler_test.go +++ b/pkg/apiserver/handlers/featuregates/handler_test.go @@ -38,6 +38,7 @@ var ( cleanupStaleUDPSvcConntrackStatus string serviceExternalIPStatus string egressSeparateSubnetStatus string + antreaNodeConfigStatus string ) func Test_getGatesResponse(t *testing.T) { @@ -55,6 +56,7 @@ func Test_getGatesResponse(t *testing.T) { }, want: []apis.FeatureGateResponse{ {Component: "agent", Name: "AntreaIPAM", Status: "Disabled", Version: "ALPHA"}, + {Component: "agent", Name: "AntreaNodeConfig", Status: antreaNodeConfigStatus, Version: "BETA"}, {Component: "agent", Name: "AntreaPolicy", Status: "Disabled", Version: "BETA"}, {Component: "agent", Name: "AntreaProxy", Status: "Enabled", Version: "GA"}, {Component: "agent", Name: "BGPPolicy", Status: "Disabled", Version: "ALPHA"}, @@ -230,11 +232,14 @@ func init() { multicastStatus = "Enabled" cleanupStaleUDPSvcConntrackStatus = "Enabled" serviceExternalIPStatus = "Enabled" + antreaNodeConfigStatus = "Enabled" if runtime.IsWindowsPlatform() { egressStatus = "Disabled" egressSeparateSubnetStatus = "Disabled" multicastStatus = "Disabled" cleanupStaleUDPSvcConntrackStatus = "Disabled" serviceExternalIPStatus = "Disabled" + // AntreaNodeConfig is unsupported on Windows; pkg/features init() forces its default off. + antreaNodeConfigStatus = "Disabled" } } diff --git a/pkg/features/antrea_features.go b/pkg/features/antrea_features.go index 47b89048d17..9c4f11c28e6 100644 --- a/pkg/features/antrea_features.go +++ b/pkg/features/antrea_features.go @@ -188,6 +188,11 @@ const ( // features that rely on netfilter. Currently, nftables is supported by the following features: // - AntreaProxy (proxyAll) NFTablesHostNetworkMode featuregate.Feature = "NFTablesHostNetworkMode" + + // beta: v2.7 + // Enable support for AntreaNodeConfig CRD, which allows per-Node configuration + // of Antrea agent settings via nodeSelector-based policies. + AntreaNodeConfig featuregate.Feature = "AntreaNodeConfig" ) var ( @@ -234,12 +239,14 @@ var ( EgressSeparateSubnet: {Default: true, PreRelease: featuregate.Beta}, NodeNetworkPolicy: {Default: false, PreRelease: featuregate.Alpha}, NodeLatencyMonitor: {Default: false, PreRelease: featuregate.Alpha}, + AntreaNodeConfig: {Default: true, PreRelease: featuregate.Beta}, } // AgentGates consists of all known feature gates for the Antrea Agent. // When adding a new feature gate that applies to the Antrea Agent, please also add it here. - AgentGates = sets.New[featuregate.Feature]( + AgentGates = sets.New( AntreaIPAM, + AntreaNodeConfig, AntreaPolicy, AntreaProxy, BGPPolicy, @@ -273,7 +280,7 @@ var ( // ControllerGates consists of all known feature gates for the Antrea Controller. // When adding a new feature gate that applies to the Antrea Controller, please also add it here. - ControllerGates = sets.New[featuregate.Feature]( + ControllerGates = sets.New( AdminNetworkPolicy, AntreaIPAM, AntreaPolicy, @@ -306,6 +313,7 @@ var ( // in the future if it's fully tested on Windows. BGPPolicy: {}, Multicast: {}, + AntreaNodeConfig: {}, SecondaryNetwork: {}, ServiceExternalIP: {}, IPsecCertAuth: {}, diff --git a/pkg/features/antrea_features_test.go b/pkg/features/antrea_features_test.go index 666cba48356..5582f4998d2 100644 --- a/pkg/features/antrea_features_test.go +++ b/pkg/features/antrea_features_test.go @@ -37,6 +37,11 @@ func TestSupportedOnWindows(t *testing.T) { feature: Egress, expected: false, }, + { + name: "AntreaNodeConfig unsupported on Windows Node", + feature: AntreaNodeConfig, + expected: false, + }, { name: "Feature does not exist", feature: "Unsupported", @@ -68,6 +73,11 @@ func TestSupportedOnExternalNode(t *testing.T) { feature: NodePortLocal, expected: false, }, + { + name: "AntreaNodeConfig unsupported on External Node", + feature: AntreaNodeConfig, + expected: false, + }, { name: "Feature does not exist", feature: "Unsupported", From 1b49ccdca71d47485e3b0dd0e13537428bd5414d Mon Sep 17 00:00:00 2001 From: Lan Luo Date: Thu, 9 Apr 2026 08:35:39 +0000 Subject: [PATCH 08/13] Add AntreaNodeConfig-aware secondary network bridge management When the AntreaNodeConfig feature gate is enabled, antrea-agent starts the antreanodeconfig controller plus a SubscribableChannel and passes an effective-bridge callback and channel subscriber into the secondary network controller. The secondary network controller creates the initial OVS bridge from that callback, subscribes to ANC snapshot notifications to enqueue rate-limited bridge reconciliation work, and replaces the podwatch OVS client when the effective bridge changes. - Reconcile bridge name, physical interfaces, and trunk AllowedVLANs on Linux (including clearing stale trunks and tearing down stale host-connection port pairs when moving from single- to multi-interface uplink configs). - Add OVS client support for trunk ports (CreateTrunkPort, SetPortTrunks) and trunk parsing in port listings; extend mocks and tests. - Make podwatch PodController bridge access concurrency-safe and add UpdateOVSBridge for dynamic bridge swaps. - Add OVSBridgeConfig helpers in pkg/agent/types; log uplink restore errors in agent_linux. Signed-off-by: Lan Luo --- cmd/antrea-agent/agent.go | 38 +- pkg/agent/agent_linux.go | 8 +- pkg/agent/secondarynetwork/init.go | 174 +++- pkg/agent/secondarynetwork/init_linux.go | 515 ++++++++++- pkg/agent/secondarynetwork/init_linux_test.go | 798 ++++++++++++++++-- pkg/agent/secondarynetwork/init_windows.go | 5 + .../secondarynetwork/podwatch/controller.go | 82 +- pkg/agent/util/net_linux.go | 30 +- pkg/ovs/ovsconfig/interfaces.go | 2 + pkg/ovs/ovsconfig/ovs_client.go | 175 +++- pkg/ovs/ovsconfig/ovs_client_test.go | 101 +++ pkg/ovs/ovsconfig/ovs_schema.go | 7 + pkg/ovs/ovsconfig/testing/mock_ovsconfig.go | 31 +- 13 files changed, 1813 insertions(+), 153 deletions(-) diff --git a/cmd/antrea-agent/agent.go b/cmd/antrea-agent/agent.go index ab87c48a83b..2578660c52f 100644 --- a/cmd/antrea-agent/agent.go +++ b/cmd/antrea-agent/agent.go @@ -34,6 +34,7 @@ import ( mcinformers "antrea.io/antrea/v2/multicluster/pkg/client/informers/externalversions" "antrea.io/antrea/v2/pkg/agent" + "antrea.io/antrea/v2/pkg/agent/antreanodeconfig" "antrea.io/antrea/v2/pkg/agent/apiserver" "antrea.io/antrea/v2/pkg/agent/client" "antrea.io/antrea/v2/pkg/agent/cniserver" @@ -375,6 +376,9 @@ func run(o *Options) error { // externalEntityUpdateChannel is a channel for receiving ExternalEntity updates from ExternalNodeController and // notifying NetworkPolicyController to reconcile rules related to the updated ExternalEntities. var externalEntityUpdateChannel *channel.SubscribableChannel + // antreaNodeConfigUpdateChannel broadcasts effective AntreaNodeConfig-derived state + // (e.g. secondary-network OVS bridge) to subscribers on the agent. + var antreaNodeConfigUpdateChannel *channel.SubscribableChannel if o.nodeType == config.K8sNode { podUpdateChannel = channel.NewSubscribableChannel("PodUpdate", 100) } else { @@ -620,15 +624,38 @@ func run(o *Options) error { var localExternalNodeInformer cache.SharedIndexInformer var secondaryNetworkController *secondarynetwork.Controller + var antreaNodeConfigController *antreanodeconfig.Controller var cniDeleteChecker agenttypes.CNIDeleteChecker cniDeleteChecker = nil // Secondary network controller should be created before CNIServer.Run() to make sure no Pod CNI updates will be missed. if features.DefaultFeatureGate.Enabled(features.SecondaryNetwork) { + var effectiveBridgeFn func() *agenttypes.OVSBridgeConfig + var ancSubscriber channel.Subscriber + + if features.DefaultFeatureGate.Enabled(features.AntreaNodeConfig) { + antreaNodeConfigInformer := crdInformerFactory.Crd().V1alpha1().AntreaNodeConfigs() + antreaNodeConfigUpdateChannel = channel.NewSubscribableChannel("AntreaNodeConfig", 100) + antreaNodeConfigController = antreanodeconfig.NewController( + antreaNodeConfigInformer, + nodeInformer, + nodeConfig.Name, + o.config, + antreaNodeConfigUpdateChannel, + ) + effectiveBridgeFn = antreaNodeConfigController.EffectiveSecondaryOVSBridge + ancSubscriber = antreaNodeConfigUpdateChannel + } else { + effectiveBridgeFn = func() *agenttypes.OVSBridgeConfig { + return antreanodeconfig.EffectiveSecondaryOVSBridge(nil, nil, nil, false, &o.config.SecondaryNetwork) + } + } + secondaryNetworkController, err = secondarynetwork.NewController( o.config.ClientConnection, o.config.KubeAPIServerOverride, k8sClient, localPodInformer.Get(), podUpdateChannel, ifaceStore, nodeConfig, - &o.config.SecondaryNetwork, ovsdbConnection, ipPoolInformer.Lister()) + &o.config.SecondaryNetwork, ovsdbConnection, ipPoolInformer.Lister(), + effectiveBridgeFn, ancSubscriber) if err != nil { return fmt.Errorf("failed to create secondary network controller: %w", err) } @@ -768,6 +795,9 @@ func run(o *Options) error { if o.nodeType == config.K8sNode { go routeClient.Run(ctx) go podUpdateChannel.Run(stopCh) + if antreaNodeConfigUpdateChannel != nil { + go antreaNodeConfigUpdateChannel.Run(stopCh) + } go cniServer.Run(stopCh) go nodeRouteController.Run(stopCh) } else { @@ -860,6 +890,10 @@ func run(o *Options) error { informerFactory.Start(stopCh) crdInformerFactory.Start(stopCh) + if antreaNodeConfigController != nil { + go antreaNodeConfigController.Run(stopCh) + } + if o.enableEgress || features.DefaultFeatureGate.Enabled(features.ServiceExternalIP) { go externalIPPoolController.Run(stopCh) go memberlistCluster.Run(stopCh) @@ -916,7 +950,7 @@ func run(o *Options) error { nodeConfig, ifaceStore, multicastSocket, - sets.New[string](append(o.config.Multicast.MulticastInterfaces, nodeConfig.NodeTransportInterfaceName)...), + sets.New(append(o.config.Multicast.MulticastInterfaces, nodeConfig.NodeTransportInterfaceName)...), podUpdateChannel, o.igmpQueryInterval, o.igmpQueryVersions, diff --git a/pkg/agent/agent_linux.go b/pkg/agent/agent_linux.go index a456b8bd29c..79f23488586 100644 --- a/pkg/agent/agent_linux.go +++ b/pkg/agent/agent_linux.go @@ -174,8 +174,12 @@ func (i *Initializer) RestoreOVSBridge() { klog.InfoS("Restoring bridge config to uplink...") if i.nodeConfig.UplinkNetConfig.Name != "" { - util.RestoreHostInterfaceConfiguration(i.ovsBridge, i.nodeConfig.UplinkNetConfig.Name) - klog.InfoS("Finished restoring bridge config to uplink...") + if err := util.RestoreHostInterfaceConfiguration(i.ovsBridge, i.nodeConfig.UplinkNetConfig.Name); err != nil { + klog.ErrorS(err, "Failed to restore bridge config to uplink", + "interface", i.nodeConfig.UplinkNetConfig.Name, "bridge", i.ovsBridge) + } else { + klog.InfoS("Finished restoring bridge config to uplink...") + } } } diff --git a/pkg/agent/secondarynetwork/init.go b/pkg/agent/secondarynetwork/init.go index 7edcdb597e5..120ed87660f 100644 --- a/pkg/agent/secondarynetwork/init.go +++ b/pkg/agent/secondarynetwork/init.go @@ -16,17 +16,21 @@ package secondarynetwork import ( "fmt" + "sync" + "time" "github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb" netdefclient "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/client/clientset/versioned/typed/k8s.cni.cncf.io/v1" clientset "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" componentbaseconfig "k8s.io/component-base/config" "k8s.io/klog/v2" "antrea.io/antrea/v2/pkg/agent/config" "antrea.io/antrea/v2/pkg/agent/interfacestore" "antrea.io/antrea/v2/pkg/agent/secondarynetwork/podwatch" + agenttypes "antrea.io/antrea/v2/pkg/agent/types" crdlisters "antrea.io/antrea/v2/pkg/client/listers/crd/v1beta1" agentconfig "antrea.io/antrea/v2/pkg/config/agent" "antrea.io/antrea/v2/pkg/ovs/ovsconfig" @@ -34,14 +38,51 @@ import ( "antrea.io/antrea/v2/pkg/util/k8s" ) +const ( + // reconcileKey is the single key used in the work queue. Any change that + // may affect the effective bridge configuration enqueues this key. + reconcileKey = "reconcile" + + minRetryDelay = 5 * time.Second + maxRetryDelay = 300 * time.Second +) + var ( newOVSBridgeFn = ovsconfig.NewOVSBridge ) +// podControllerInterface is the subset of podwatch.PodController used by Controller. +// Defined as an interface to allow test injection. +type podControllerInterface interface { + Run(stopCh <-chan struct{}) + AllowCNIDelete(podName, podNamespace string) bool + UpdateOVSBridge(newClient ovsconfig.OVSBridgeClient) error +} + +// Controller manages secondary network resources for a Node. type Controller struct { ovsBridgeClient ovsconfig.OVSBridgeClient secNetConfig *agentconfig.SecondaryNetworkConfig - podController *podwatch.PodController + podController podControllerInterface + nodeName string + ovsdbConn *ovsdb.OVSDB + + // effectiveBridgeFn returns the desired OVS bridge configuration (from static + // agent config and, when enabled, AntreaNodeConfig via the controller). + effectiveBridgeFn func() *agenttypes.OVSBridgeConfig + + // mu protects effectiveBridgeCfg for atomic point-in-time reads and writes. + // It must never be held across blocking OVS calls. + // Only init_linux.go references mu; Windows uses stub reconcile/Initialize methods. + mu sync.RWMutex //nolint:unused // platform: Linux-only bridge reconciliation in init_linux.go + effectiveBridgeCfg *agenttypes.OVSBridgeConfig + + // dynamicBridgeReconcile is true when AntreaNodeConfig is enabled: bridge + // updates are driven by the AntreaNodeConfig channel after the AntreaNodeConfig + // controller has synced informers and published the first snapshot. + dynamicBridgeReconcile bool + + queue workqueue.TypedRateLimitingInterface[string] } func NewController( @@ -52,38 +93,102 @@ func NewController( podUpdateSubscriber channel.Subscriber, primaryInterfaceStore interfacestore.InterfaceStore, nodeConfig *config.NodeConfig, - secNetConfig *agentconfig.SecondaryNetworkConfig, ovsdb *ovsdb.OVSDB, + secNetConfig *agentconfig.SecondaryNetworkConfig, + ovsdbConn *ovsdb.OVSDB, ipPoolLister crdlisters.IPPoolLister, + effectiveBridgeFn func() *agenttypes.OVSBridgeConfig, + ancUpdateSubscriber channel.Subscriber, ) (*Controller, error) { - ovsBridgeClient, err := createOVSBridge(secNetConfig.OVSBridges, ovsdb) + if effectiveBridgeFn == nil { + return nil, fmt.Errorf("effectiveBridge must not be nil") + } + + effectiveBridgeCfg, ovsBridgeClient, err := resolveAndCreateOVSBridge(effectiveBridgeFn, ovsdbConn) if err != nil { return nil, err } - // Create the NetworkAttachmentDefinition client, which handles access to secondary network object - // definition from the API Server. netAttachDefClient, err := createNetworkAttachDefClient(clientConnectionConfig, kubeAPIServerOverride) if err != nil { return nil, fmt.Errorf("NetworkAttachmentDefinition client creation failed: %v", err) } - // Create podController to handle secondary network configuration for Pods with - // k8s.v1.cni.cncf.io/networks Annotation defined. podWatchController, err := podwatch.NewPodController( k8sClient, netAttachDefClient, podInformer, podUpdateSubscriber, primaryInterfaceStore, nodeConfig, ovsBridgeClient, ipPoolLister) if err != nil { return nil, err } - return &Controller{ - ovsBridgeClient: ovsBridgeClient, - secNetConfig: secNetConfig, - podController: podWatchController}, nil + + dynamicBridgeReconcile := ancUpdateSubscriber != nil + c := &Controller{ + ovsBridgeClient: ovsBridgeClient, + secNetConfig: secNetConfig, + effectiveBridgeCfg: effectiveBridgeCfg, + podController: podWatchController, + nodeName: nodeConfig.Name, + effectiveBridgeFn: effectiveBridgeFn, + ovsdbConn: ovsdbConn, + dynamicBridgeReconcile: dynamicBridgeReconcile, + queue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), + workqueue.TypedRateLimitingQueueConfig[string]{Name: "secondaryNetworkBridge"}, + ), + } + + if dynamicBridgeReconcile { + // Notify payloads are *antreanodeconfig.EffectiveSnapshot; this controller + // reconciles from effectiveBridge() so it only needs the wakeup. + ancUpdateSubscriber.Subscribe(func(_ interface{}) { + c.enqueue() + }) + } + + return c, nil +} + +// enqueue adds the single reconciliation key to the work queue. +func (c *Controller) enqueue() { + c.queue.Add(reconcileKey) } -// Run starts the Pod controller for secondary networks. +// Run starts the secondary network controller. When AntreaNodeConfig is +// enabled, a bridge reconciliation worker processes items enqueued by the ANC +// SubscribableChannel (the AntreaNodeConfig controller only notifies after its +// informers have synced). When ANC is off, the bridge is static and no worker +// is started. func (c *Controller) Run(stopCh <-chan struct{}) { - c.podController.Run(stopCh) + defer c.queue.ShutDown() + + klog.InfoS("Starting secondary network controller") + defer klog.InfoS("Shutting down secondary network controller") + + if c.dynamicBridgeReconcile { + go func() { + for c.processNextItem() { + } + }() + } + + go c.podController.Run(stopCh) + + <-stopCh +} + +func (c *Controller) processNextItem() bool { + key, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(key) + + if err := c.reconcileBridge(); err != nil { + c.queue.AddRateLimited(key) + klog.ErrorS(err, "Failed to reconcile secondary network bridge, requeuing") + } else { + c.queue.Forget(key) + } + return true } func (c *Controller) AllowCNIDelete(podName, podNamespace string) bool { @@ -91,8 +196,8 @@ func (c *Controller) AllowCNIDelete(podName, podNamespace string) bool { } // CreateNetworkAttachDefClient creates net-attach-def client handle from the given config. -func createNetworkAttachDefClient(config componentbaseconfig.ClientConnectionConfiguration, kubeAPIServerOverride string) (netdefclient.K8sCniCncfIoV1Interface, error) { - kubeConfig, err := k8s.CreateRestConfig(config, kubeAPIServerOverride) +func createNetworkAttachDefClient(cfg componentbaseconfig.ClientConnectionConfiguration, kubeAPIServerOverride string) (netdefclient.K8sCniCncfIoV1Interface, error) { + kubeConfig, err := k8s.CreateRestConfig(cfg, kubeAPIServerOverride) if err != nil { return nil, err } @@ -104,20 +209,35 @@ func createNetworkAttachDefClient(config componentbaseconfig.ClientConnectionCon return netAttachDefClient, nil } -func createOVSBridge(bridges []agentconfig.OVSBridgeConfig, ovsdb *ovsdb.OVSDB) (ovsconfig.OVSBridgeClient, error) { - if len(bridges) == 0 { - return nil, nil - } - // Only one OVS bridge is supported. - bridgeConfig := bridges[0] +// createOVSBridgeClient creates a new OVS bridge with the given name and +// multicast-snooping setting, returning the client for the newly created bridge. +func createOVSBridgeClient(bridgeName string, enableMulticastSnooping bool, ovsdbConn *ovsdb.OVSDB) (ovsconfig.OVSBridgeClient, error) { var options []ovsconfig.OVSBridgeOption - if bridgeConfig.EnableMulticastSnooping { + if enableMulticastSnooping { options = append(options, ovsconfig.WithMcastSnooping()) } - ovsBridgeClient := newOVSBridgeFn(bridgeConfig.BridgeName, ovsconfig.OVSDatapathSystem, ovsdb, options...) - if err := ovsBridgeClient.Create(); err != nil { - return nil, fmt.Errorf("failed to create OVS bridge %s: %v", bridgeConfig.BridgeName, err) + client := newOVSBridgeFn(bridgeName, ovsconfig.OVSDatapathSystem, ovsdbConn, options...) + if err := client.Create(); err != nil { + return nil, fmt.Errorf("failed to create OVS bridge %s: %v", bridgeName, err) + } + klog.InfoS("OVS bridge created", "bridge", bridgeName) + return client, nil +} + +// resolveAndCreateOVSBridge evaluates effectiveBridge() and creates the OVS bridge. +// Returns the effective OVSBridgeConfig (nil when no bridge is configured), the +// corresponding OVSBridgeClient, and any error. +func resolveAndCreateOVSBridge( + effectiveBridge func() *agenttypes.OVSBridgeConfig, + ovsdbConn *ovsdb.OVSDB, +) (*agenttypes.OVSBridgeConfig, ovsconfig.OVSBridgeClient, error) { + effectiveBridgeCfg := effectiveBridge() + if effectiveBridgeCfg == nil { + return nil, nil, nil + } + ovsBridgeClient, err := createOVSBridgeClient(effectiveBridgeCfg.BridgeName, effectiveBridgeCfg.EnableMulticastSnooping, ovsdbConn) + if err != nil { + return nil, nil, err } - klog.InfoS("OVS bridge created", "bridge", bridgeConfig.BridgeName) - return ovsBridgeClient, nil + return effectiveBridgeCfg, ovsBridgeClient, nil } diff --git a/pkg/agent/secondarynetwork/init_linux.go b/pkg/agent/secondarynetwork/init_linux.go index 978f01a6dd5..86de83c4c96 100644 --- a/pkg/agent/secondarynetwork/init_linux.go +++ b/pkg/agent/secondarynetwork/init_linux.go @@ -20,74 +20,523 @@ package secondarynetwork import ( "fmt" "net" + "reflect" "k8s.io/klog/v2" "antrea.io/antrea/v2/pkg/agent/interfacestore" + agenttypes "antrea.io/antrea/v2/pkg/agent/types" "antrea.io/antrea/v2/pkg/agent/util" "antrea.io/antrea/v2/pkg/ovs/ovsconfig" ) var ( // Funcs which will be overridden with mock funcs in tests. - interfaceByNameFn = net.InterfaceByName + interfaceByNameFn = net.InterfaceByName + restoreHostInterfaceConfigFn = util.RestoreHostInterfaceConfiguration // func(brName, ifName string) error ) -// Initialize sets up OVS bridges. +// Initialize sets up OVS bridges at agent start-up. +// It reconciles the current OVS bridge state with the effective bridge config: +// - rule 1: if the effective bridge has the same name as the previous bridge, +// keep the bridge and update the physical interfaces (add/remove ports). +// - rule 2: if the effective bridge name differs from the previous bridge, +// delete the old bridge and recreate with the new config. +// - rule 3: when allowedVLANs are set on a physical interface, configure the +// OVS port in trunk mode with the specified VLAN IDs. func (c *Controller) Initialize() error { - // We only support moving and restoring of interface configuration to OVS Bridge for the single physical interface case. - if len(c.secNetConfig.OVSBridges) != 0 { - phyInterfaces := make([]string, len(c.secNetConfig.OVSBridges[0].PhysicalInterfaces)) - copy(phyInterfaces, c.secNetConfig.OVSBridges[0].PhysicalInterfaces) - if len(phyInterfaces) == 1 { - bridgedName, _, err := util.PrepareHostInterfaceConnection( - c.ovsBridgeClient, - phyInterfaces[0], - 0, - map[string]interface{}{ - interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaHost, - }, - 0, // do not request a specific MTU - ) - if err != nil { - return err - } - phyInterfaces[0] = bridgedName + c.mu.RLock() + bridgeCfg := c.effectiveBridgeCfg + c.mu.RUnlock() + + if bridgeCfg == nil { + return nil + } + + // Only single-interface host-connection migration is supported. + if len(bridgeCfg.PhysicalInterfaces) == 1 { + iface := bridgeCfg.PhysicalInterfaces[0] + bridgedName, _, err := util.PrepareHostInterfaceConnection( + c.ovsBridgeClient, + iface.Name, + 0, + map[string]interface{}{ + interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaHost, + }, + 0, + ) + if err != nil { + return err + } + phyInterfaces := []agenttypes.PhysicalInterfaceConfig{ + {Name: bridgedName, AllowedVLANs: iface.AllowedVLANs}, } if err := connectPhyInterfacesToOVSBridge(c.ovsBridgeClient, phyInterfaces); err != nil { return err } + return clearStaleTrunks(c.ovsBridgeClient, phyInterfaces) + } + + // Multi-interface path: before connecting ports, tear down any stale + // host-connection setup left from a previous single-interface run. A stale + // setup is identified by finding an OVS port whose interface type is + // "internal" AND whose kernel-rename sibling (GenerateUplinkInterfaceName) + // also exists as a port on the bridge. In that case RestoreHostInterface- + // Configuration undoes the rename and removes both OVS ports so that the + // interface can be re-added as a plain uplink below. + if err := restoreStaleHostConnections(c.ovsBridgeClient, bridgeCfg); err != nil { + return err + } + + if err := connectPhyInterfacesToOVSBridge(c.ovsBridgeClient, bridgeCfg.PhysicalInterfaces); err != nil { + return err + } + return clearStaleTrunks(c.ovsBridgeClient, bridgeCfg.PhysicalInterfaces) +} + +// restoreStaleHostConnections detects and tears down host-connection port pairs +// (e.g. "eth1" internal + "eth1~" uplink) that were created by a previous +// single-interface run but are no longer needed because the desired config now +// lists that interface as a plain uplink in a multi-interface setup. +// It calls RestoreHostInterfaceConfiguration for each such interface, which +// removes both OVS ports and renames "eth1~" back to "eth1" on the host. +func restoreStaleHostConnections(ovsBridgeClient ovsconfig.OVSBridgeClient, bridgeCfg *agenttypes.OVSBridgeConfig) error { + // Build a set of desired physical interface names for quick lookup. + desiredNames := make(map[string]struct{}, len(bridgeCfg.PhysicalInterfaces)) + for _, pi := range bridgeCfg.PhysicalInterfaces { + desiredNames[pi.Name] = struct{}{} + } + + portList, err := ovsBridgeClient.GetPortList() + if err != nil { + return fmt.Errorf("failed to list OVS ports on bridge %s: %v", bridgeCfg.BridgeName, err) + } + + // Index all port IFTypes by their IFName for the sibling check below. + portTypes := make(map[string]string, len(portList)) // IFName → IFType + for _, p := range portList { + portTypes[p.IFName] = p.IFType + } + + for _, p := range portList { + // We are looking for ports that: + // (a) are desired as plain uplinks in the new config, AND + // (b) are currently OVS internal ports (created by PrepareHostInterfaceConnection), AND + // (c) have a sibling uplink port named GenerateUplinkInterfaceName(p.IFName) + // still present on the bridge. + if _, desired := desiredNames[p.IFName]; !desired { + continue + } + if p.IFType != "internal" { + continue + } + bridgedName := util.GenerateUplinkInterfaceName(p.IFName) + if _, siblingExists := portTypes[bridgedName]; !siblingExists { + continue + } + klog.InfoS("Detected stale host-connection setup, restoring interface before re-adding as uplink", + "interface", p.IFName, "bridge", bridgeCfg.BridgeName) + if err := restoreHostInterfaceConfigFn(bridgeCfg.BridgeName, p.IFName); err != nil { + return fmt.Errorf("failed to restore stale host-connection interface %s on bridge %s: %w", + p.IFName, bridgeCfg.BridgeName, err) + } } return nil } -// Restore restores interface configuration from secondary-bridge back to host-interface. +// clearStaleTrunks reads the actual OVS port state and calls SetPortTrunks(nil) for any +// port that has a non-empty trunk list in OVS but whose desired config carries no +// AllowedVLANs. This handles the agent-restart scenario where the OVS port was +// previously configured as a trunk but the current desired config no longer requires it. +func clearStaleTrunks(ovsBridgeClient ovsconfig.OVSBridgeClient, phyInterfaces []agenttypes.PhysicalInterfaceConfig) error { + // Build a set of interfaces that should NOT have trunk VLANs. + noTrunkDesired := make(map[string]struct{}, len(phyInterfaces)) + for _, pi := range phyInterfaces { + if len(pi.AllowedVLANs) == 0 { + noTrunkDesired[pi.Name] = struct{}{} + } + } + if len(noTrunkDesired) == 0 { + return nil + } + + portList, err := ovsBridgeClient.GetPortList() + if err != nil { + return fmt.Errorf("failed to list OVS ports: %v", err) + } + for _, p := range portList { + // Match by IFName (interface name) against the desired set, but use p.Name + // (Port name) for SetPortTrunks which filters the Port table by port name. + // For standard uplink ports the two names are identical; being explicit here + // avoids any confusion if they ever diverge. + if _, ok := noTrunkDesired[p.IFName]; !ok { + continue + } + if len(p.Trunks) == 0 { + continue + } + if err := ovsBridgeClient.SetPortTrunks(p.Name, nil); err != nil { + return fmt.Errorf("failed to clear stale trunk VLANs for OVS port %s: %v", p.Name, err) + } + klog.InfoS("Cleared trunk VLAN list on secondary OVS bridge port", "device", p.Name) + } + return nil +} + +// Restore restores interface configuration from the secondary bridge back to the host interface. func (c *Controller) Restore() { - if len(c.secNetConfig.OVSBridges) != 0 && len(c.secNetConfig.OVSBridges[0].PhysicalInterfaces) == 1 { - util.RestoreHostInterfaceConfiguration(c.secNetConfig.OVSBridges[0].BridgeName, c.secNetConfig.OVSBridges[0].PhysicalInterfaces[0]) + c.mu.RLock() + bridgeCfg := c.effectiveBridgeCfg + c.mu.RUnlock() + + if bridgeCfg == nil { + return + } + if len(bridgeCfg.PhysicalInterfaces) == 1 { + if err := util.RestoreHostInterfaceConfiguration(bridgeCfg.BridgeName, bridgeCfg.PhysicalInterfaces[0].Name); err != nil { + klog.ErrorS(err, "Failed to restore host interface configuration on shutdown", + "interface", bridgeCfg.PhysicalInterfaces[0].Name, "bridge", bridgeCfg.BridgeName) + } } } -func connectPhyInterfacesToOVSBridge(ovsBridgeClient ovsconfig.OVSBridgeClient, phyInterfaces []string) error { - for _, phyInterface := range phyInterfaces { - if _, err := interfaceByNameFn(phyInterface); err != nil { - return fmt.Errorf("failed to get interface %s: %v", phyInterface, err) +// reconcileBridge is called by the work queue worker when the AntreaNodeConfig sync +// controller signals a change (or on retries). It re-computes the desired +// bridge configuration and reconciles the OVS state accordingly: +// +// - rule 1: same bridge name as current → keep bridge, update physical interfaces. +// - rule 2: different bridge name → delete old bridge first, then create the new bridge. +// - rule 3: interfaces with allowedVLANs are configured as OVS trunk ports. +// +// State-update discipline: after any destructive operation (bridge deletion) the controller +// state is immediately cleared under the mutex so that a subsequent retry does not attempt to +// delete an already-deleted bridge. +func (c *Controller) reconcileBridge() error { + c.mu.RLock() + prev := c.effectiveBridgeCfg + c.mu.RUnlock() + + desired := c.effectiveBridgeFn() + + // No change — nothing to do. + if reflect.DeepEqual(prev, desired) { + return nil + } + + klog.InfoS("Reconciling secondary network bridge configuration", + "previous", bridgeName(prev), "desired", bridgeName(desired)) + + // Case: no bridge desired — delete the existing one. + if desired == nil { + if err := c.deleteBridge(prev); err != nil { + return err + } + // Clear state immediately after successful deletion so that a retry + // does not attempt to delete an already-removed bridge. + c.mu.Lock() + c.effectiveBridgeCfg = nil + c.ovsBridgeClient = nil + c.mu.Unlock() + // Notify PodController that the bridge is gone. + if err := c.podController.UpdateOVSBridge(nil); err != nil { + return err + } + return nil + } + + // Case: new bridge desired when none existed before. + if prev == nil { + return c.createAndConnectBridge(desired) + } + + // Case: bridge name changed (rule 2). + // The old bridge MUST be deleted before the new one is created. State is + // cleared under the mutex immediately after the deletion succeeds so that if + // createAndConnectBridge subsequently fails the next retry starts from a clean + // "no bridge" state rather than trying to delete the already-gone old bridge. + if prev.BridgeName != desired.BridgeName { + klog.InfoS("Secondary OVS bridge name changed, deleting old bridge before creating new one", + "old", prev.BridgeName, "new", desired.BridgeName) + if err := c.deleteBridge(prev); err != nil { + return err + } + // Old bridge is gone — clear state before proceeding. + c.mu.Lock() + c.effectiveBridgeCfg = nil + c.ovsBridgeClient = nil + c.mu.Unlock() + return c.createAndConnectBridge(desired) + } + + // Case: same bridge name (rule 1) — update physical interfaces in-place. + // effectiveBridgeCfg is updated incrementally inside updatePhysicalInterfaces + // after each mutating step, so a retry always sees accurate state. + klog.InfoS("Secondary OVS bridge name unchanged, updating physical interfaces", + "bridge", desired.BridgeName) + return c.updatePhysicalInterfaces(prev, desired) +} + +// deleteBridge tears down the single-interface host connection (if applicable) and deletes the +// OVS bridge. +func (c *Controller) deleteBridge(cfg *agenttypes.OVSBridgeConfig) error { + if cfg == nil { + return nil + } + // Restore host interface first (only supported for the single-interface case). + if len(cfg.PhysicalInterfaces) == 1 { + if err := util.RestoreHostInterfaceConfiguration(cfg.BridgeName, cfg.PhysicalInterfaces[0].Name); err != nil { + return fmt.Errorf("failed to restore host interface %s before deleting bridge %s: %w", + cfg.PhysicalInterfaces[0].Name, cfg.BridgeName, err) + } + } + if c.ovsBridgeClient != nil { + if err := c.ovsBridgeClient.Delete(); err != nil { + return fmt.Errorf("failed to delete OVS bridge %s: %v", cfg.BridgeName, err) + } + klog.InfoS("OVS bridge deleted", "bridge", cfg.BridgeName) + } + return nil +} + +// createAndConnectBridge creates or attaches to the OVS bridge for the desired config, +// connects physical interfaces, clears stale trunks, and updates the controller state. +// Create() reuses an existing bridge with the same name, so this path mirrors +// Initialize: multi-interface configs run restoreStaleHostConnections before connect, and +// clearStaleTrunks runs after connect for all interface counts. +func (c *Controller) createAndConnectBridge(desired *agenttypes.OVSBridgeConfig) error { + newClient, err := createOVSBridgeClient(desired.BridgeName, desired.EnableMulticastSnooping, c.ovsdbConn) + if err != nil { + return err + } + + physInterfaces := desired.PhysicalInterfaces + if len(physInterfaces) == 1 { + bridgedName, _, err := util.PrepareHostInterfaceConnection( + newClient, + physInterfaces[0].Name, + 0, + map[string]interface{}{ + interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaHost, + }, + 0, + ) + if err != nil { + return err + } + physInterfaces = []agenttypes.PhysicalInterfaceConfig{ + {Name: bridgedName, AllowedVLANs: desired.PhysicalInterfaces[0].AllowedVLANs}, + } + } else if len(physInterfaces) > 1 { + // The OVS bridge may already exist (Create is a no-op) with stale host-connection + // ports from a prior single-interface config — same as Initialize. + if err := restoreStaleHostConnections(newClient, desired); err != nil { + return err + } + } + + if err := connectPhyInterfacesToOVSBridge(newClient, physInterfaces); err != nil { + return err + } + // Pre-existing ports may still carry trunk VLANs from an old config while the new + // desired config has no AllowedVLANs; connectPhyInterfacesToOVSBridge skips plain + // uplinks that are already present (unlike Initialize / updatePhysicalInterfaces). + if err := clearStaleTrunks(newClient, physInterfaces); err != nil { + return err + } + + c.mu.Lock() + c.ovsBridgeClient = newClient + c.effectiveBridgeCfg = desired + c.mu.Unlock() + + // Notify PodController of the new bridge so it uses the correct OVS client + // for future Pod interface operations and reloads its interface store. + return c.podController.UpdateOVSBridge(newClient) +} + +// updatePhysicalInterfaces reconciles OVS ports on an existing bridge to match the +// desired config. effectiveBridgeCfg is committed once per step under a single lock +// acquisition so that, if a later step fails, the next reconciliation retry sees an +// accurate picture of what is actually present on the bridge. +func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridgeConfig) error { + // Build a set of desired interface names. + desiredIfaces := make(map[string]agenttypes.PhysicalInterfaceConfig, len(desired.PhysicalInterfaces)) + for _, pi := range desired.PhysicalInterfaces { + desiredIfaces[pi.Name] = pi + } + + // Build a map of currently present ports on the bridge: interface name → UUID, + // and a map of IFName → IFType for the host-connection sibling check below. + portList, err := c.ovsBridgeClient.GetPortList() + if err != nil { + return fmt.Errorf("failed to list OVS ports on bridge %s: %v", desired.BridgeName, err) + } + existingPorts := make(map[string]string, len(portList)) // IFName → UUID + existingIFTypes := make(map[string]string, len(portList)) // IFName → IFType + for _, p := range portList { + existingPorts[p.IFName] = p.UUID + existingIFTypes[p.IFName] = p.IFType + } + + // Step 1: remove ports that were in the previous config but are no longer desired. + // + // When an interface was connected via PrepareHostInterfaceConnection (single-interface + // host-connection path), the kernel interface was renamed eth1 → eth1~, an internal + // OVS port "eth1" was created, and an uplink port "eth1~" was added. In that case + // prev records the original name "eth1", but the bridge holds TWO ports: "eth1" + // (internal) and "eth1~" (uplink). Simply deleting the "eth1" port via DeletePorts + // would leave "eth1~" orphaned on the bridge and the host kernel interface stranded + // under the renamed name. We must call RestoreHostInterfaceConfiguration instead, + // which removes both ports and renames eth1~ back to eth1 on the host. + // + // For plain uplink ports (no sibling) the normal DeletePorts path is used. Those + // are batched into a single OVSDB transaction for atomicity. + var toRemoveUUIDs []string + var toRemoveNames []string + for _, pi := range prev.PhysicalInterfaces { + if _, ok := desiredIfaces[pi.Name]; ok { + continue + } + bridgedName := util.GenerateUplinkInterfaceName(pi.Name) + if existingIFTypes[pi.Name] == "internal" { + if _, siblingExists := existingPorts[bridgedName]; siblingExists { + // Host-connection pair: restore via the utility that removes both + // OVS ports and renames the kernel interface back. + klog.InfoS("Restoring host interface before removing from bridge", + "device", pi.Name, "bridge", desired.BridgeName) + if err := restoreHostInterfaceConfigFn(desired.BridgeName, pi.Name); err != nil { + return fmt.Errorf("failed to restore host interface %s on bridge %s: %w", + pi.Name, desired.BridgeName, err) + } + // Keep existingPorts in sync so Step 3 does not skip re-adding + // an interface that was just restored (remove-then-re-add scenario). + delete(existingPorts, pi.Name) + delete(existingPorts, bridgedName) + continue + } + } + if uuid, exists := existingPorts[pi.Name]; exists { + toRemoveUUIDs = append(toRemoveUUIDs, uuid) + toRemoveNames = append(toRemoveNames, pi.Name) + } + // Also remove the sibling uplink port if present (e.g. eth1~ left over from + // a partially-restored host-connection setup). + if uuid, exists := existingPorts[bridgedName]; exists { + toRemoveUUIDs = append(toRemoveUUIDs, uuid) + toRemoveNames = append(toRemoveNames, bridgedName) + } + } + if len(toRemoveUUIDs) > 0 { + if err := c.ovsBridgeClient.DeletePorts(toRemoveUUIDs); err != nil { + return fmt.Errorf("failed to remove OVS ports %v from bridge %s: %v", + toRemoveNames, desired.BridgeName, err) + } + for _, name := range toRemoveNames { + klog.InfoS("Physical interface removed from secondary OVS bridge", "device", name) + // Keep existingPorts in sync so Step 3 does not skip re-adding an interface + // that was just removed (remove-then-re-add scenario). + delete(existingPorts, name) + } + } + // Build the post-deletion effective config: drop all interfaces not in desired + // (whether just deleted or already absent) so the next retry does not re-attempt them. + current := prev.DeepCopy() + for _, pi := range prev.PhysicalInterfaces { + if _, ok := desiredIfaces[pi.Name]; !ok { + current = current.WithoutInterface(pi.Name) + } + } + c.mu.Lock() + c.effectiveBridgeCfg = current + c.mu.Unlock() + + // Step 2: clear trunk VLANs on existing ports whose desired config has no AllowedVLANs. + // clearStaleTrunks reads the actual OVS port state and only calls SetPortTrunks + // when the port genuinely has trunks set, so it is safe to call unconditionally. + if err := clearStaleTrunks(c.ovsBridgeClient, desired.PhysicalInterfaces); err != nil { + return err + } + // Reflect the cleared trunk state for any interface whose desired config now + // carries no AllowedVLANs, then commit once for the whole step. + c.mu.Lock() + c.effectiveBridgeCfg = current.WithClearedTrunks(desired.PhysicalInterfaces) + c.mu.Unlock() + + // Step 3: add new ports and update the trunk VLAN list on existing ports that + // have AllowedVLANs. connectPhyInterfacesToOVSBridge creates the port when it + // does not yet exist, and calls SetPortTrunks when it does and AllowedVLANs is + // non-empty. + var toConnect []agenttypes.PhysicalInterfaceConfig + for _, pi := range desired.PhysicalInterfaces { + if _, alreadyExists := existingPorts[pi.Name]; !alreadyExists || len(pi.AllowedVLANs) > 0 { + toConnect = append(toConnect, pi) + } + } + if len(toConnect) > 0 { + if err := connectPhyInterfacesToOVSBridge(c.ovsBridgeClient, toConnect); err != nil { + return err + } + } + // All steps succeeded; record the fully-desired config. + c.mu.Lock() + c.effectiveBridgeCfg = desired + c.mu.Unlock() + return nil +} + +// connectPhyInterfacesToOVSBridge adds each physical interface to the OVS bridge +// as an uplink port. When AllowedVLANs is set the port is created or updated in +// trunk mode with those VLAN IDs (rule 3); otherwise a plain uplink port is created. +// If the port already exists and AllowedVLANs is non-empty, the trunk VLAN list is +// always updated to match the desired config. +func connectPhyInterfacesToOVSBridge(ovsBridgeClient ovsconfig.OVSBridgeClient, phyInterfaces []agenttypes.PhysicalInterfaceConfig) error { + for _, pi := range phyInterfaces { + if _, err := interfaceByNameFn(pi.Name); err != nil { + return fmt.Errorf("failed to get interface %s: %v", pi.Name, err) } } externalIDs := map[string]interface{}{ interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaUplink, } - for i, phyInterface := range phyInterfaces { - if _, err := ovsBridgeClient.GetOFPort(phyInterface, false); err == nil { - klog.V(2).InfoS("Physical interface already connected to secondary OVS bridge, skip the configuration", "device", phyInterface) + for _, pi := range phyInterfaces { + _, notConnected := ovsBridgeClient.GetOFPort(pi.Name, false) + + if len(pi.AllowedVLANs) > 0 { + if notConnected != nil { + // Pass ofPortRequest=0 so OVS auto-assigns the OF port number. + // Pinning a number derived from the loop index would collide across + // reconciliation cycles when the interface list is a filtered subset. + if _, err := ovsBridgeClient.CreateTrunkPort(pi.Name, 0, pi.AllowedVLANs, externalIDs); err != nil { + return fmt.Errorf("failed to create OVS trunk port %s: %v", pi.Name, err) + } + klog.InfoS("Physical interface added to secondary OVS bridge in trunk mode", "device", pi.Name, "vlanIDs", pi.AllowedVLANs) + } else { + if err := ovsBridgeClient.SetPortTrunks(pi.Name, pi.AllowedVLANs); err != nil { + return fmt.Errorf("failed to update trunk VLANs for OVS port %s: %v", pi.Name, err) + } + klog.InfoS("Updated trunk VLAN list on secondary OVS bridge port", "device", pi.Name, "vlanIDs", pi.AllowedVLANs) + } continue } - if _, err := ovsBridgeClient.CreateUplinkPort(phyInterface, ovsconfig.FirstControllerOFPort+int32(i), externalIDs); err != nil { - return fmt.Errorf("failed to create OVS uplink port %s: %v", phyInterface, err) + if notConnected != nil { + // Pass ofPortRequest=0 so OVS auto-assigns the OF port number. + if _, err := ovsBridgeClient.CreateUplinkPort(pi.Name, 0, externalIDs); err != nil { + return fmt.Errorf("failed to create OVS uplink port %s: %v", pi.Name, err) + } + klog.InfoS("Physical interface added to secondary OVS bridge", "device", pi.Name) + } else { + klog.V(2).InfoS("Physical interface already connected to secondary OVS bridge, skipping", "device", pi.Name) } - klog.InfoS("Physical interface added to secondary OVS bridge", "device", phyInterface) } return nil } + +// bridgeName returns the bridge name from a config, or "" for nil. +func bridgeName(cfg *agenttypes.OVSBridgeConfig) string { + if cfg == nil { + return "" + } + return cfg.BridgeName +} diff --git a/pkg/agent/secondarynetwork/init_linux_test.go b/pkg/agent/secondarynetwork/init_linux_test.go index 3158d1c46d3..62d75064ed6 100644 --- a/pkg/agent/secondarynetwork/init_linux_test.go +++ b/pkg/agent/secondarynetwork/init_linux_test.go @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/require" mock "go.uber.org/mock/gomock" + agenttypes "antrea.io/antrea/v2/pkg/agent/types" agentconfig "antrea.io/antrea/v2/pkg/config/agent" "antrea.io/antrea/v2/pkg/ovs/ovsconfig" ovsconfigtest "antrea.io/antrea/v2/pkg/ovs/ovsconfig/testing" @@ -34,140 +35,809 @@ import ( const ( nonExistingInterface = "non-existing" - firstUplinkOFPort = 32768 + // uplinkOFPort is a placeholder OF port number used in GetOFPort mock stubs to indicate + // that an interface is already connected to the bridge. The exact value is not significant. + uplinkOFPort = 1 ) -func TestCreateOVSBridge(t *testing.T) { +func TestConnectPhyInterfacesToOVSBridge(t *testing.T) { tests := []struct { - name string - ovsBridges []string - expectedErr string - expectedCalls func(m *ovsconfigtest.MockOVSBridgeClient) + name string + physicalInterfaces []agenttypes.PhysicalInterfaceConfig + expectedErr string + expectedCalls func(m *ovsconfigtest.MockOVSBridgeClient) }{ { - name: "no bridge", + name: "one interface no VLANs", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0~"}, + }, + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + m.EXPECT().GetOFPort("eth0~", false).Return(int32(uplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateUplinkPort("eth0~", int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + }, }, { - name: "no interface", - ovsBridges: []string{"br1"}, + name: "two interfaces no VLANs", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1"}, + {Name: "eth2"}, + }, expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { - m.EXPECT().Create().Return(nil) + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateUplinkPort("eth1", int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + m.EXPECT().GetOFPort("eth2", false).Return(int32(uplinkOFPort+1), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateUplinkPort("eth2", int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) }, }, { - name: "two bridges", - ovsBridges: []string{"br1", "br2"}, + name: "interface already attached, no VLANs", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1"}, + }, expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { - m.EXPECT().Create().Return(nil) + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), nil) + }, + }, + { + name: "non-existing interface", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: nonExistingInterface}, + {Name: "eth2"}, }, + expectedErr: "failed to get interface", }, { - name: "create br error", - ovsBridges: []string{"br1", "br2"}, + name: "create port error", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1"}, + }, expectedErr: "create error", expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { - m.EXPECT().Create().Return(ovsconfig.InvalidArgumentsError("create error")) + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateUplinkPort("eth1", int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", ovsconfig.InvalidArgumentsError("create error")) + }, + }, + { + name: "one interface with single VLAN", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100"}}, + }, + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateTrunkPort("eth1", int32(0), []string{"100"}, map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + }, + }, + { + name: "one interface with VLAN range", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"200-202"}}, + }, + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateTrunkPort("eth1", int32(0), []string{"200-202"}, map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + }, + }, + { + name: "one interface with mixed VLANs", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100", "200-201"}}, + }, + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateTrunkPort("eth1", int32(0), []string{"100", "200-201"}, map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + }, + }, + { + name: "trunk port creation error", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100"}}, + }, + expectedErr: "trunk error", + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) + m.EXPECT().CreateTrunkPort("eth1", int32(0), []string{"100"}, map[string]interface{}{"antrea-type": "uplink"}).Return("", ovsconfig.InvalidArgumentsError("trunk error")) + }, + }, + { + name: "already attached with VLANs — always update trunks", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100", "300"}}, + }, + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), nil) + m.EXPECT().SetPortTrunks("eth1", []string{"100", "300"}).Return(nil) + }, + }, + { + name: "SetPortTrunks error", + physicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100"}}, + }, + expectedErr: "update error", + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + m.EXPECT().GetOFPort("eth1", false).Return(int32(uplinkOFPort), nil) + m.EXPECT().SetPortTrunks("eth1", []string{"100"}).Return(ovsconfig.InvalidArgumentsError("update error")) }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - var bridges []agentconfig.OVSBridgeConfig - for _, brName := range tc.ovsBridges { - br := agentconfig.OVSBridgeConfig{BridgeName: brName} - bridges = append(bridges, br) - } + ctrl := mock.NewController(t) + mockOVSBridgeClient := ovsconfigtest.NewMockOVSBridgeClient(ctrl) - controller := mock.NewController(t) - mockOVSBridgeClient := ovsconfigtest.NewMockOVSBridgeClient(controller) - - mockNewOVSBridge(t, mockOVSBridgeClient) + mockInterfaceByName(t) if tc.expectedCalls != nil { tc.expectedCalls(mockOVSBridgeClient) } - brClient, err := createOVSBridge(bridges, nil) + err := connectPhyInterfacesToOVSBridge(mockOVSBridgeClient, tc.physicalInterfaces) if tc.expectedErr != "" { assert.ErrorContains(t, err, tc.expectedErr) - assert.Nil(t, brClient) } else { - require.NoError(t, err) - if tc.expectedCalls != nil { - assert.NotNil(t, brClient) - } + assert.NoError(t, err) } }) } } -func TestConnectPhyInterfacesToOVSBridge(t *testing.T) { +func TestInitialize(t *testing.T) { tests := []struct { - name string - physicalInterfaces []string - expectedErr string - expectedCalls func(m *ovsconfigtest.MockOVSBridgeClient) + name string + bridgeCfg *agenttypes.OVSBridgeConfig + expectedCalls func(m *ovsconfigtest.MockOVSBridgeClient) + expectedErr string }{ { - name: "one interface", - physicalInterfaces: []string{"eth0~"}, - expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { - m.EXPECT().GetOFPort("eth0~", false).Return(int32(firstUplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) - m.EXPECT().CreateUplinkPort("eth0~", int32(firstUplinkOFPort), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) - }, + name: "no bridge config — no-op", + bridgeCfg: nil, }, { - name: "two interfaces", - physicalInterfaces: []string{"eth1", "eth2"}, + // Regression: agent restarts; OVS ports have stale trunk VLANs from a + // previous run but the current desired config has no allowedVLANs. + // Initialize must clear the stale trunk list. + name: "existing ports with stale trunks — cleared at startup", + bridgeCfg: &agenttypes.OVSBridgeConfig{ + BridgeName: "br1", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + {Name: eth2}, + }, + }, expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { - m.EXPECT().GetOFPort("eth1", false).Return(int32(firstUplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) - m.EXPECT().CreateUplinkPort("eth1", int32(firstUplinkOFPort), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) - m.EXPECT().GetOFPort("eth2", false).Return(int32(firstUplinkOFPort+1), ovsconfig.InvalidArgumentsError("port not found")) - m.EXPECT().CreateUplinkPort("eth2", int32(firstUplinkOFPort+1), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + // restoreStaleHostConnections: no internal ports — no-op. + m.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, IFType: ""}, + {Name: eth2, IFName: eth2, IFType: ""}, + }, nil).Times(1) + // eth1 and eth2 already present — connectPhyInterfacesToOVSBridge skips them. + m.EXPECT().GetOFPort(eth1, false).Return(int32(uplinkOFPort), nil) + m.EXPECT().GetOFPort(eth2, false).Return(int32(uplinkOFPort+1), nil) + // clearStaleTrunks finds stale trunks on both ports and clears them. + // Name (Port name) and IFName (Interface name) are both set to match + // real OVS behaviour for uplink ports; SetPortTrunks uses p.Name. + m.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: []uint16{100, 300}}, + {Name: eth2, IFName: eth2, Trunks: []uint16{200}}, + }, nil).Times(1) + m.EXPECT().SetPortTrunks(eth1, nil).Return(nil) + m.EXPECT().SetPortTrunks(eth2, nil).Return(nil) }, }, { - name: "interface already attached", - physicalInterfaces: []string{"eth1"}, + name: "existing ports without trunks — no SetPortTrunks call", + bridgeCfg: &agenttypes.OVSBridgeConfig{ + BridgeName: "br1", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + {Name: eth2}, + }, + }, expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { - m.EXPECT().GetOFPort("eth1", false).Return(int32(firstUplinkOFPort), nil) + // restoreStaleHostConnections: no internal ports — no-op. + m.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, IFType: ""}, + {Name: eth2, IFName: eth2, IFType: ""}, + }, nil).Times(1) + m.EXPECT().GetOFPort(eth1, false).Return(int32(uplinkOFPort), nil) + m.EXPECT().GetOFPort(eth2, false).Return(int32(uplinkOFPort+1), nil) + // No stale trunks — clearStaleTrunks must not call SetPortTrunks. + m.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + {Name: eth2, IFName: eth2, Trunks: nil}, + }, nil).Times(1) }, }, { - name: "non-existing interface", - physicalInterfaces: []string{nonExistingInterface, "eth2"}, - expectedErr: "failed to get interface", + // Regression: bridge has a stale host-connection setup (eth1 internal + eth1~ + // uplink) from a previous single-interface run, plus eth2 in trunk mode. + // The new desired config wants both eth1 and eth2 as plain uplinks. + // Initialize must: detect and restore the eth1 host-connection, then add eth1 + // as a plain uplink, and clear the stale trunks on eth2. + name: "stale host-connection (eth1 internal + eth1~) with stale trunk on eth2", + bridgeCfg: &agenttypes.OVSBridgeConfig{ + BridgeName: "br1", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + {Name: eth2}, + }, + }, + expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { + // restoreStaleHostConnections queries GetPortList and finds eth1 (internal) + // + eth1~ (uplink sibling) — triggers RestoreHostInterfaceConfiguration. + // eth2 is a plain uplink (type ""), not an internal port — skipped. + eth1Tilde := eth1 + "~" + m.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, IFType: "internal"}, + {Name: eth1Tilde, IFName: eth1Tilde, IFType: ""}, + {Name: eth2, IFName: eth2, IFType: "", Trunks: []uint16{200}}, + }, nil).Times(1) + + // After restore, eth1~ is gone and eth1 is a plain kernel interface again. + // connectPhyInterfacesToOVSBridge: eth1 not connected → CreateUplinkPort; + // eth2 already connected (no VLANs) → skipped. + m.EXPECT().GetOFPort(eth1, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + m.EXPECT().CreateUplinkPort(eth1, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + m.EXPECT().GetOFPort(eth2, false).Return(int32(uplinkOFPort+1), nil) + + // clearStaleTrunks: eth2 still has Trunks from above GetPortList result. + // A second GetPortList is issued to read the current OVS state. + m.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, IFType: ""}, + {Name: eth2, IFName: eth2, IFType: "", Trunks: []uint16{200}}, + }, nil).Times(1) + m.EXPECT().SetPortTrunks(eth2, nil).Return(nil) + }, }, { - name: "create port error", - physicalInterfaces: []string{"eth1"}, - expectedErr: "create error", + name: "ports with AllowedVLANs — clearStaleTrunks skips them", + bridgeCfg: &agenttypes.OVSBridgeConfig{ + BridgeName: "br1", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1, AllowedVLANs: []string{"100"}}, + {Name: eth2, AllowedVLANs: []string{"200"}}, + }, + }, expectedCalls: func(m *ovsconfigtest.MockOVSBridgeClient) { - m.EXPECT().GetOFPort("eth1", false).Return(int32(firstUplinkOFPort), ovsconfig.InvalidArgumentsError("port not found")) - m.EXPECT().CreateUplinkPort("eth1", int32(firstUplinkOFPort), map[string]interface{}{"antrea-type": "uplink"}).Return("", ovsconfig.InvalidArgumentsError("create error")) + // restoreStaleHostConnections: no internal ports — no-op. + m.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, IFType: ""}, + {Name: eth2, IFName: eth2, IFType: ""}, + }, nil).Times(1) + // Both ports exist — SetPortTrunks is called by connectPhyInterfacesToOVSBridge. + m.EXPECT().GetOFPort(eth1, false).Return(int32(uplinkOFPort), nil) + m.EXPECT().SetPortTrunks(eth1, []string{"100"}).Return(nil) + m.EXPECT().GetOFPort(eth2, false).Return(int32(uplinkOFPort+1), nil) + m.EXPECT().SetPortTrunks(eth2, []string{"200"}).Return(nil) + // clearStaleTrunks has nothing to do: all interfaces have AllowedVLANs. }, }, } + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - - controller := mock.NewController(t) - mockOVSBridgeClient := ovsconfigtest.NewMockOVSBridgeClient(controller) + ctrl := mock.NewController(t) + mockOVSBridgeClient := ovsconfigtest.NewMockOVSBridgeClient(ctrl) mockInterfaceByName(t) + // Replace the real RestoreHostInterfaceConfiguration with a no-op so + // tests don't touch kernel interfaces. + origRestore := restoreHostInterfaceConfigFn + t.Cleanup(func() { restoreHostInterfaceConfigFn = origRestore }) + restoreHostInterfaceConfigFn = func(brName, ifaceName string) error { return nil } + if tc.expectedCalls != nil { tc.expectedCalls(mockOVSBridgeClient) } - err := connectPhyInterfacesToOVSBridge(mockOVSBridgeClient, tc.physicalInterfaces) + c := &Controller{ + ovsBridgeClient: mockOVSBridgeClient, + effectiveBridgeCfg: tc.bridgeCfg, + } + err := c.Initialize() if tc.expectedErr != "" { assert.ErrorContains(t, err, tc.expectedErr) } else { - assert.NoError(t, err) + require.NoError(t, err) + } + }) + } +} + +const ( + brOld = "br-old" + brNew = "br-new" + eth1 = "eth1" + eth2 = "eth2" +) + +// TestReconcileBridge tests the reconcileBridge function with various transitions. +// fakePodController implements podControllerInterface for unit tests. +// It records calls to UpdateOVSBridge so tests can assert on them. +type fakePodController struct { + updateBridgeCalls []ovsconfig.OVSBridgeClient + updateBridgeErr error +} + +func (f *fakePodController) Run(_ <-chan struct{}) {} + +func (f *fakePodController) AllowCNIDelete(_, _ string) bool { return true } + +func (f *fakePodController) UpdateOVSBridge(c ovsconfig.OVSBridgeClient) error { + f.updateBridgeCalls = append(f.updateBridgeCalls, c) + return f.updateBridgeErr +} + +func TestReconcileBridge(t *testing.T) { + portUUID := "uuid-eth1" + + tests := []struct { + name string + prevCfg *agenttypes.OVSBridgeConfig + desiredCfg *agenttypes.OVSBridgeConfig // returned by effectiveBridge() in production + expectedCalls func(old, new *ovsconfigtest.MockOVSBridgeClient) + wantNewClient bool // whether c.ovsBridgeClient should be the "new" mock after reconcile + wantUpdateBridgeN int // expected number of UpdateOVSBridge calls on the podController + wantUpdateBridgeNil bool // whether the last UpdateOVSBridge call should pass nil + // wantRestoreCalls lists the (bridge, iface) pairs that restoreHostInterfaceConfigFn + // must be called with, in order, when an interface is removed from the config. + wantRestoreCalls []struct{ bridge, iface string } + expectedErr string + }{ + { + name: "no change (both nil)", + prevCfg: nil, + desiredCfg: nil, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) {}, + }, + { + name: "no change (same config)", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) {}, + }, + { + name: "bridge deleted (desired is nil)", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + desiredCfg: nil, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + old.EXPECT().Delete().Return(nil) + }, + wantUpdateBridgeN: 1, + wantUpdateBridgeNil: true, + }, + { + // Use two interfaces to bypass the single-interface PrepareHostInterfaceConnection path. + name: "bridge created (prev is nil, two interfaces)", + prevCfg: nil, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brNew, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + new.EXPECT().Create().Return(nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{}, nil).Times(1) + new.EXPECT().GetOFPort(eth1, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + new.EXPECT().CreateUplinkPort(eth1, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + new.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + new.EXPECT().CreateUplinkPort(eth2, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + {Name: eth2, IFName: eth2, Trunks: nil}, + }, nil).Times(1) + }, + wantNewClient: true, + wantUpdateBridgeN: 1, + }, + { + // OVSBridge.Create() attaches to an existing bridge; uplinks may already be present + // with stale trunk VLANs while the new desired config has no AllowedVLANs. + // connectPhyInterfacesToOVSBridge skips existing plain uplinks, so clearStaleTrunks + // after connect must clear the trunks (regression for createAndConnectBridge). + name: "bridge created — pre-existing uplinks with stale trunks cleared", + prevCfg: nil, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brNew, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + new.EXPECT().Create().Return(nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, IFType: ""}, + {Name: eth2, IFName: eth2, IFType: ""}, + }, nil).Times(1) + new.EXPECT().GetOFPort(eth1, false).Return(int32(uplinkOFPort), nil) + new.EXPECT().GetOFPort(eth2, false).Return(int32(uplinkOFPort+1), nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: []uint16{100}}, + {Name: eth2, IFName: eth2, Trunks: []uint16{200}}, + }, nil).Times(1) + new.EXPECT().SetPortTrunks(eth1, nil).Return(nil) + new.EXPECT().SetPortTrunks(eth2, nil).Return(nil) + }, + wantNewClient: true, + wantUpdateBridgeN: 1, + }, + { + // Use two interfaces to bypass the single-interface PrepareHostInterfaceConnection path. + name: "rule 4: different bridge name — delete old, create new", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brNew, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + old.EXPECT().Delete().Return(nil) + new.EXPECT().Create().Return(nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{}, nil).Times(1) + new.EXPECT().GetOFPort(eth1, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + new.EXPECT().CreateUplinkPort(eth1, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + new.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + new.EXPECT().CreateUplinkPort(eth2, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + {Name: eth2, IFName: eth2, Trunks: nil}, + }, nil).Times(1) + }, + wantNewClient: true, + wantUpdateBridgeN: 1, + }, + { + // When the old ANC CR stops matching (e.g. Node labels change) and a new ANC with a + // different bridge name becomes effective, the old OVS bridge must be deleted before + // the new one is created. State must be cleared immediately after deletion so that a + // retry does not attempt to delete an already-removed bridge. + name: "rule 4: old ANC stops matching, new ANC bridge created — old bridge deleted first", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brNew, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + // Deletion of old bridge is expected before creation of new bridge. + old.EXPECT().Delete().Return(nil) + new.EXPECT().Create().Return(nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{}, nil).Times(1) + new.EXPECT().GetOFPort(eth1, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + new.EXPECT().CreateUplinkPort(eth1, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + new.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + new.EXPECT().CreateUplinkPort(eth2, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + new.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + {Name: eth2, IFName: eth2, Trunks: nil}, + }, nil).Times(1) + }, + wantNewClient: true, + wantUpdateBridgeN: 1, + }, + { + name: "rule 3: same bridge name — add new interface", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1}, + }, nil).Times(1) + // eth2 is new — connect it. + old.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + old.EXPECT().CreateUplinkPort(eth2, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + // clearStaleTrunks: eth1 already exists with no AllowedVLANs; second GetPortList + // shows no trunks on eth1 so SetPortTrunks is not called. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + }, nil).Times(1) + }, + // No UpdateOVSBridge: same bridge, client unchanged. + }, + { + name: "rule 3: same bridge name — remove old interface", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1}, + {UUID: "uuid-eth2", IFName: eth2}, + }, nil).Times(1) + // eth2 is removed in a single batch call. + old.EXPECT().DeletePorts([]string{"uuid-eth2"}).Return(nil) + // clearStaleTrunks: eth1 remains with no AllowedVLANs; no trunks → no-op. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + }, nil).Times(1) + }, + // No UpdateOVSBridge: same bridge, client unchanged. + }, + { + name: "rule 3: same bridge, add interface with VLANs (rule 5)", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + {Name: eth2, AllowedVLANs: []string{"100"}}, + }}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1}, + }, nil).Times(1) + old.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + old.EXPECT().CreateTrunkPort(eth2, int32(0), []string{"100"}, map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + // clearStaleTrunks: eth1 exists with no AllowedVLANs; no trunks → no-op. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + }, nil).Times(1) + }, + // No UpdateOVSBridge: same bridge, client unchanged. + }, + { + // Regression test: existing port gains AllowedVLANs (e.g. ANC CR applied after + // agent started with static config that had no VLANs). The port is already + // present on the bridge so only SetPortTrunks must be called to update it. + name: "rule 3: same bridge, existing interface gains AllowedVLANs", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1, AllowedVLANs: []string{"100", "300"}}, + }}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1}, + }, nil) + // eth1 already exists and now has AllowedVLANs — trunk list must be updated. + old.EXPECT().GetOFPort(eth1, false).Return(int32(uplinkOFPort), nil) + old.EXPECT().SetPortTrunks(eth1, []string{"100", "300"}).Return(nil) + }, + // No UpdateOVSBridge: same bridge, client unchanged. + }, + { + // Regression test: existing trunk ports have AllowedVLANs cleared (e.g. ANC CR + // updated to remove allowedVLANs). clearStaleTrunks reads the actual OVS port + // state and calls SetPortTrunks(nil) for ports that still have trunks set. + name: "rule 3: same bridge, existing interface loses AllowedVLANs", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1, AllowedVLANs: []string{"100", "300"}}, + {Name: eth2, AllowedVLANs: []string{"200"}}, + }}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + {Name: eth2}, + }}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + // First GetPortList: build existingPorts map. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1}, + {UUID: "uuid-eth2", IFName: eth2}, + }, nil).Times(1) + // Second GetPortList: clearStaleTrunks reads actual OVS trunks and clears them. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: []uint16{100, 300}}, + {Name: eth2, IFName: eth2, Trunks: []uint16{200}}, + }, nil).Times(1) + old.EXPECT().SetPortTrunks(eth1, nil).Return(nil) + old.EXPECT().SetPortTrunks(eth2, nil).Return(nil) + }, + // No UpdateOVSBridge: same bridge, client unchanged. + }, + { + // Regression: eth1 loses AllowedVLANs AND eth2 has a stale trunk 300 that + // was never reflected in prev (set externally or from a run the controller + // didn't track). clearStaleTrunks reads actual OVS state and clears both. + name: "rule 3: stale trunk on eth2 not in prev config — cleared via OVS state", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1, AllowedVLANs: []string{"100"}}, + {Name: eth2}, + }}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + {Name: eth2}, + }}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + // First GetPortList: build existingPorts map. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1}, + {UUID: "uuid-eth2", IFName: eth2}, + }, nil).Times(1) + // Second GetPortList: clearStaleTrunks reads actual OVS state. + // eth1 has trunks from its prev AllowedVLANs; eth2 has stale trunk 300 + // that was never tracked in prev — both are cleared. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: []uint16{100}}, + {Name: eth2, IFName: eth2, Trunks: []uint16{300}}, + }, nil).Times(1) + old.EXPECT().SetPortTrunks(eth1, nil).Return(nil) + old.EXPECT().SetPortTrunks(eth2, nil).Return(nil) + }, + // No UpdateOVSBridge: same bridge, client unchanged. + }, + { + // anc.yaml → anc1.yaml: eth1 had allowedVLANs:["100"] and eth2 had + // allowedVLANs:["200-300"]. The updated ANC removes allowedVLANs from both. + // Both OVS trunk ports must be cleared to plain uplinks. + name: "anc.yaml→anc1.yaml: both interfaces lose AllowedVLANs", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1, AllowedVLANs: []string{"100"}}, + {Name: eth2, AllowedVLANs: []string{"200-300"}}, + }}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + {Name: eth2}, + }}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + // First GetPortList: both ports already on the bridge. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1}, + {UUID: "uuid-eth2", IFName: eth2}, + }, nil).Times(1) + // Second GetPortList: clearStaleTrunks reads actual OVS trunk state. + // eth1 has trunk [100] and eth2 has trunks [200, 300] (range expanded). + // Both must be cleared; connectPhyInterfacesToOVSBridge is not called + // because toConnect is empty (both ports exist, no desired AllowedVLANs). + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: []uint16{100}}, + {Name: eth2, IFName: eth2, Trunks: []uint16{200, 201, 202, 203, 204, 205, + 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, + 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, + 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, + 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, + 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, + 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, + 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300}}, + }, nil).Times(1) + old.EXPECT().SetPortTrunks(eth1, nil).Return(nil) + old.EXPECT().SetPortTrunks(eth2, nil).Return(nil) + }, + // No UpdateOVSBridge: same bridge, client unchanged. + }, + { + // Regression: eth1 was connected via PrepareHostInterfaceConnection (single-interface + // host-connection path), so the bridge holds two ports: "eth1" (internal) and "eth1~" + // (uplink). The ANC is updated to replace eth1 with eth2. updatePhysicalInterfaces + // must call restoreHostInterfaceConfigFn(brOld, eth1) to remove both ports and restore + // the kernel interface name — NOT merely DeletePorts("eth1"), which would leave "eth1~" + // stranded on the bridge and the host kernel interface stuck under the renamed name. + name: "rule 3: host-connection port removed — RestoreHostInterfaceConfiguration called", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth1}, + }}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: eth2}, + }}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + eth1Tilde := eth1 + "~" + // First GetPortList: bridge has eth1 (internal) + eth1~ (uplink). + // restoreHostInterfaceConfigFn is called (tracked via wantRestoreCalls); + // no DeletePorts expected because the restore handles both ports. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: "uuid-eth1", IFName: eth1, IFType: "internal"}, + {UUID: "uuid-eth1-tilde", IFName: eth1Tilde, IFType: ""}, + }, nil).Times(1) + // eth2 is new — add it as a plain uplink. + old.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + old.EXPECT().CreateUplinkPort(eth2, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + // clearStaleTrunks: eth2 has no AllowedVLANs; second GetPortList shows no trunks. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth2, IFName: eth2, Trunks: nil}, + }, nil).Times(1) + }, + wantRestoreCalls: []struct{ bridge, iface string }{{brOld, eth1}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := mock.NewController(t) + oldMock := ovsconfigtest.NewMockOVSBridgeClient(ctrl) + newMock := ovsconfigtest.NewMockOVSBridgeClient(ctrl) + + mockInterfaceByName(t) + mockNewOVSBridge(t, newMock) + + // Capture restoreHostInterfaceConfigFn calls for verification. + var gotRestoreCalls []struct{ bridge, iface string } + origRestore := restoreHostInterfaceConfigFn + restoreHostInterfaceConfigFn = func(brName, ifaceName string) error { + gotRestoreCalls = append(gotRestoreCalls, struct{ bridge, iface string }{brName, ifaceName}) + return nil + } + t.Cleanup(func() { restoreHostInterfaceConfigFn = origRestore }) + + if tc.expectedCalls != nil { + tc.expectedCalls(oldMock, newMock) + } + + fakePc := &fakePodController{} + desiredCfg := tc.desiredCfg + c := &Controller{ + ovsBridgeClient: oldMock, + secNetConfig: &agentconfig.SecondaryNetworkConfig{}, + effectiveBridgeCfg: tc.prevCfg, + effectiveBridgeFn: func() *agenttypes.OVSBridgeConfig { return desiredCfg }, + ovsdbConn: nil, + podController: fakePc, + } + + err := c.reconcileBridge() + if tc.expectedErr != "" { + assert.ErrorContains(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + c.mu.RLock() + if tc.wantNewClient { + assert.Equal(t, newMock, c.ovsBridgeClient) + } + c.mu.RUnlock() + // Verify UpdateOVSBridge was called the expected number of times. + assert.Len(t, fakePc.updateBridgeCalls, tc.wantUpdateBridgeN, + "unexpected number of UpdateOVSBridge calls") + if tc.wantUpdateBridgeN > 0 { + last := fakePc.updateBridgeCalls[len(fakePc.updateBridgeCalls)-1] + if tc.wantUpdateBridgeNil { + assert.Nil(t, last, "expected UpdateOVSBridge(nil) for bridge deletion") + } else { + assert.Equal(t, newMock, last, "expected UpdateOVSBridge(newMock) for bridge creation") + } + } + // Verify restoreHostInterfaceConfigFn calls. + if tc.wantRestoreCalls != nil { + assert.Equal(t, tc.wantRestoreCalls, gotRestoreCalls, + "unexpected restoreHostInterfaceConfigFn calls") + } else { + assert.Empty(t, gotRestoreCalls, "unexpected restoreHostInterfaceConfigFn calls") + } } }) } } +// TestReconcileBridgeStateCleared verifies that when the bridge name changes (rule 4) — +// for example when an old AntreaNodeConfig CR stops matching the Node and a new ANC with a +// different bridge name takes effect — the controller's state (effectiveBridgeCfg and +// ovsBridgeClient) is cleared immediately after the old bridge is deleted. This ensures that +// if createAndConnectBridge subsequently fails, a retry attempt does not try to delete the +// already-removed bridge a second time. +func TestReconcileBridgeStateCleared(t *testing.T) { + ctrl := mock.NewController(t) + oldMock := ovsconfigtest.NewMockOVSBridgeClient(ctrl) + newMock := ovsconfigtest.NewMockOVSBridgeClient(ctrl) + mockInterfaceByName(t) + + createErr := ovsconfig.InvalidArgumentsError("create failed") + + // The new-bridge factory returns newMock; its Create() will fail to simulate a partial + // failure after the old bridge has already been deleted. + prevNewOVSBridgeFn := newOVSBridgeFn + var capturedController *Controller + newOVSBridgeFn = func(bridgeName string, ovsDatapathType ovsconfig.OVSDatapathType, ovsdb *ovsdb.OVSDB, options ...ovsconfig.OVSBridgeOption) ovsconfig.OVSBridgeClient { + // Verify that state was already cleared at this point (delete happened before create). + if capturedController != nil { + capturedController.mu.RLock() + assert.Nil(t, capturedController.effectiveBridgeCfg, "effectiveBridgeCfg should be nil after old bridge deleted") + assert.Nil(t, capturedController.ovsBridgeClient, "ovsBridgeClient should be nil after old bridge deleted") + capturedController.mu.RUnlock() + } + return newMock + } + t.Cleanup(func() { newOVSBridgeFn = prevNewOVSBridgeFn }) + + prevCfg := &agenttypes.OVSBridgeConfig{ + BridgeName: brOld, + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}, + } + + // Old bridge is deleted; new bridge creation fails. + oldMock.EXPECT().Delete().Return(nil) + newMock.EXPECT().Create().Return(createErr) + + desired := &agenttypes.OVSBridgeConfig{ + BridgeName: brNew, + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}, + } + + c := &Controller{ + ovsBridgeClient: oldMock, + secNetConfig: &agentconfig.SecondaryNetworkConfig{}, + effectiveBridgeCfg: prevCfg, + effectiveBridgeFn: func() *agenttypes.OVSBridgeConfig { return desired }, + ovsdbConn: nil, + podController: &fakePodController{}, + } + capturedController = c + + err := c.reconcileBridge() + require.Error(t, err, "expected error from failed bridge creation") + + // After the failed reconcile, state must reflect that the old bridge is gone. + c.mu.RLock() + assert.Nil(t, c.effectiveBridgeCfg, "effectiveBridgeCfg should remain nil after failed create") + assert.Nil(t, c.ovsBridgeClient, "ovsBridgeClient should remain nil after failed create") + c.mu.RUnlock() +} + func mockInterfaceByName(t *testing.T) { prevFunc := interfaceByNameFn interfaceByNameFn = func(name string) (*net.Interface, error) { diff --git a/pkg/agent/secondarynetwork/init_windows.go b/pkg/agent/secondarynetwork/init_windows.go index 32004e7f64a..c03bde692eb 100644 --- a/pkg/agent/secondarynetwork/init_windows.go +++ b/pkg/agent/secondarynetwork/init_windows.go @@ -24,3 +24,8 @@ func (c *Controller) Initialize() error { func (c *Controller) Restore() { // Not supported on Windows. } + +func (c *Controller) reconcileBridge() error { + // Not supported on Windows. + return nil +} diff --git a/pkg/agent/secondarynetwork/podwatch/controller.go b/pkg/agent/secondarynetwork/podwatch/controller.go index 972d82d16ca..91ac1412dd4 100644 --- a/pkg/agent/secondarynetwork/podwatch/controller.go +++ b/pkg/agent/secondarynetwork/podwatch/controller.go @@ -87,13 +87,16 @@ type podCNIInfo struct { } type PodController struct { - kubeClient clientset.Interface - netAttachDefClient netdefclient.K8sCniCncfIoV1Interface - queue workqueue.TypedRateLimitingInterface[string] - podInformer cache.SharedIndexInformer - podLister corelisters.PodLister - ipPoolLister crdlisters.IPPoolLister - podUpdateSubscriber channel.Subscriber + kubeClient clientset.Interface + netAttachDefClient netdefclient.K8sCniCncfIoV1Interface + queue workqueue.TypedRateLimitingInterface[string] + podInformer cache.SharedIndexInformer + podLister corelisters.PodLister + ipPoolLister crdlisters.IPPoolLister + podUpdateSubscriber channel.Subscriber + // mu protects ovsBridgeClient and interfaceConfigurator, which are replaced atomically + // when the effective OVS bridge changes (e.g. a new AntreaNodeConfig takes effect). + mu sync.RWMutex ovsBridgeClient ovsconfig.OVSBridgeClient interfaceStore interfacestore.InterfaceStore primaryInterfaceStore interfacestore.InterfaceStore @@ -326,6 +329,10 @@ func (pc *PodController) updatePodNetworkStatusAnnotation(netStatus []netdefv1.N } func (pc *PodController) removeInterfaces(interfaces []*interfacestore.InterfaceConfig) error { + pc.mu.RLock() + configurator := pc.interfaceConfigurator + pc.mu.RUnlock() + var savedErr error for _, interfaceConfig := range interfaces { podName := interfaceConfig.PodName @@ -337,7 +344,11 @@ func (pc *PodController) removeInterfaces(interfaces []*interfacestore.Interface // Since only VLAN and SR-IOV interfaces are supported by now, we judge the // interface type by checking interfaceConfig.OVSPortConfig is set or not. if interfaceConfig.OVSPortConfig != nil { - err = pc.interfaceConfigurator.DeleteVLANSecondaryInterface(interfaceConfig) + if configurator == nil { + err = fmt.Errorf("OVS bridge not available, cannot delete VLAN interface") + } else { + err = configurator.DeleteVLANSecondaryInterface(interfaceConfig) + } } else { err = pc.deleteSriovSecondaryInterface(interfaceConfig) } @@ -461,16 +472,24 @@ func (pc *PodController) configureSecondaryInterface( ipamResult = &ipam.IPAMResult{} } + pc.mu.RLock() + configurator := pc.interfaceConfigurator + pc.mu.RUnlock() + switch networkConfig.NetworkType { case sriovNetworkType: ifConfigErr = pc.configureSriovAsSecondaryInterface(pod, network, resourceName, podCNIInfo, networkConfig.MTU, &ipamResult.Result) case vlanNetworkType: + if configurator == nil { + ifConfigErr = fmt.Errorf("OVS bridge not available, cannot configure VLAN interface") + break + } if networkConfig.VLAN > 0 { // Let VLAN ID in the CNI network configuration override the IPPool subnet // VLAN. ipamResult.VLANID = uint16(networkConfig.VLAN) } - ifConfigErr = pc.interfaceConfigurator.ConfigureVLANSecondaryInterface( + ifConfigErr = configurator.ConfigureVLANSecondaryInterface( pod.Name, pod.Namespace, podCNIInfo.containerID, podCNIInfo.netNS, network.InterfaceRequest, networkConfig.MTU, ipamResult, mac) @@ -751,7 +770,12 @@ func (pc *PodController) initializeOVSSecondaryInterfaceStore() error { if pc.ovsBridgeClient == nil { return nil } - ovsPorts, err := pc.ovsBridgeClient.GetPortList() + return pc.loadOVSInterfaceStore(pc.ovsBridgeClient) +} + +// loadOVSInterfaceStore populates the interface store from the current ports on the given bridge. +func (pc *PodController) loadOVSInterfaceStore(client ovsconfig.OVSBridgeClient) error { + ovsPorts, err := client.GetPortList() if err != nil { return fmt.Errorf("failed to list OVS ports for the secondary bridge: %w", err) } @@ -787,6 +811,44 @@ func (pc *PodController) initializeOVSSecondaryInterfaceStore() error { return nil } +// UpdateOVSBridge replaces the OVS bridge client and interface configurator used by the +// PodController. It is called by the secondary network controller whenever the effective +// bridge configuration changes (e.g. a new AntreaNodeConfig takes effect): +// - newClient == nil: the bridge was deleted; clear the client and configurator and purge the +// interface store so future Pod events are handled with no bridge. +// - newClient != nil: a new (or replacement) bridge was created; install the new client and a +// fresh configurator, then reload the interface store from the new bridge. +func (pc *PodController) UpdateOVSBridge(newClient ovsconfig.OVSBridgeClient) error { + var newConfigurator InterfaceConfigurator + if newClient != nil { + var err error + newConfigurator, err = cniserver.NewSecondaryInterfaceConfigurator(newClient, pc.interfaceStore) + if err != nil { + return fmt.Errorf("failed to create SecondaryInterfaceConfigurator for new bridge: %v", err) + } + } + + pc.mu.Lock() + pc.ovsBridgeClient = newClient + pc.interfaceConfigurator = newConfigurator + pc.mu.Unlock() + + if newClient == nil { + // Bridge is gone — drop any stale interface records. + pc.interfaceStore.Initialize(nil) + klog.InfoS("Secondary OVS bridge removed, interface store cleared") + return nil + } + + // Re-populate the interface store from the new bridge so that existing Pod + // interfaces are tracked correctly. + if err := pc.loadOVSInterfaceStore(newClient); err != nil { + return err + } + klog.InfoS("Secondary OVS bridge updated, interface store reloaded") + return nil +} + // initializeSRIOVSecondaryInterfaceStore restores secondary interfaceStore for SR-IOV interfaces // when agent restarts. It will get the Pod info from the store of primary interfaces, and check // the NetworkStatus annotation of a Pod, then restore the SR-IOV interfaces based on the NetworkAttachmentDefinition diff --git a/pkg/agent/util/net_linux.go b/pkg/agent/util/net_linux.go index 51e9cdf97a8..93f96212e9d 100644 --- a/pkg/agent/util/net_linux.go +++ b/pkg/agent/util/net_linux.go @@ -497,14 +497,19 @@ func PrepareHostInterfaceConnection( return bridgedName, false, nil } -// RestoreHostInterfaceConfiguration restore the configuration from bridge back to host interface, reverting the -// actions taken in PrepareHostInterfaceConnection. -func RestoreHostInterfaceConfiguration(brName string, interfaceName string) { +// RestoreHostInterfaceConfiguration restores the configuration from the bridge back to the host +// interface, reverting the actions taken in PrepareHostInterfaceConnection. It returns an error +// only when the critical uplink OVS port (bridgedName, e.g. "eth0~") cannot be deleted, because +// that prevents the kernel-interface rename and leaves the system in a fully-intact state that +// the caller can retry. All other sub-step failures (IP/route restore, internal port delete, +// rename) are logged but do not cause a return error, since they represent best-effort cleanup +// after the point of no return. +func RestoreHostInterfaceConfiguration(brName string, interfaceName string) error { klog.V(4).InfoS("Restoring bridge config to host interface") bridgedName := GenerateUplinkInterfaceName(interfaceName) // restore only when interface eth0~ exists if !HostInterfaceExists(bridgedName) { - return + return nil } // get interface config @@ -519,38 +524,39 @@ func RestoreHostInterfaceConfiguration(brName string, interfaceName string) { // delete internal port (eth0) if err = deleteOVSPort(brName, interfaceName); err != nil { - klog.ErrorS(err, "Delete OVS port failed", "port", bridgedName) + klog.ErrorS(err, "Delete OVS port failed", "port", interfaceName) } } - // remove host interface (eth0~) from bridge + // remove host interface (eth0~) from bridge — this is the critical step: if it fails the + // kernel interface is still renamed and both OVS ports still exist, so the caller can retry. if err = deleteOVSPort(brName, bridgedName); err != nil { - klog.ErrorS(err, "Delete OVS port failed", "port", bridgedName) - return + return fmt.Errorf("failed to delete OVS uplink port %s from bridge %s: %w", bridgedName, brName, err) } // rename host interface(eth0~ -> eth0) if err = RenameInterface(bridgedName, interfaceName); err != nil { klog.ErrorS(err, "Restore host interface name failed", "from", bridgedName, "to", interfaceName) - return + return nil } var link netlink.Link if link, err = netlink.LinkByName(interfaceName); err != nil { klog.ErrorS(err, "Failed to get link", "interface", interfaceName) - return + return nil } if len(interfaceIPs) > 0 { // restore IPs to eth0 if err = ConfigureLinkAddresses(link.Attrs().Index, interfaceIPs); err != nil { klog.ErrorS(err, "Restore IPs to host interface failed", "interface", interfaceName) - return + return nil } } if len(interfaceRoutes) > 0 { // restore routes to eth0 if err = ConfigureLinkRoutes(link, interfaceRoutes); err != nil { klog.ErrorS(err, "Restore routes to host interface failed", "interface", interfaceName) - return + return nil } } klog.V(2).InfoS("Finished restoring bridge config to host interface", "interface", interfaceName, "bridge", brName) + return nil } diff --git a/pkg/ovs/ovsconfig/interfaces.go b/pkg/ovs/ovsconfig/interfaces.go index 740631ec22f..653eb504a34 100644 --- a/pkg/ovs/ovsconfig/interfaces.go +++ b/pkg/ovs/ovsconfig/interfaces.go @@ -65,6 +65,7 @@ type OVSBridgeClient interface { CreateTunnelPort(name string, tunnelType TunnelType, ofPortRequest int32) (string, Error) CreateTunnelPortExt(name string, tunnelType TunnelType, ofPortRequest int32, csum bool, localIP string, remoteIP string, remoteName string, psk string, extraOptions, externalIDs map[string]interface{}) (string, Error) CreateUplinkPort(name string, ofPortRequest int32, externalIDs map[string]interface{}) (string, Error) + CreateTrunkPort(name string, ofPortRequest int32, vlanSpecs []string, externalIDs map[string]interface{}) (string, Error) DeletePort(portUUID string) Error DeletePorts(portUUIDList []string) Error GetOFPort(ifName string, waitUntilValid bool) (int32, Error) @@ -83,5 +84,6 @@ type OVSBridgeClient interface { SetInterfaceType(name, ifType string) Error SetPortExternalIDs(portName string, externalIDs map[string]interface{}) Error GetPortExternalIDs(portName string) (map[string]string, Error) + SetPortTrunks(portName string, vlanSpecs []string) Error SetInterfaceMAC(name string, mac net.HardwareAddr) Error } diff --git a/pkg/ovs/ovsconfig/ovs_client.go b/pkg/ovs/ovsconfig/ovs_client.go index 2f6f822b6c0..b3727217604 100644 --- a/pkg/ovs/ovsconfig/ovs_client.go +++ b/pkg/ovs/ovsconfig/ovs_client.go @@ -18,6 +18,7 @@ import ( "fmt" "net" "strconv" + "strings" "time" "github.com/TomCodeLV/OVSDB-golang-lib/pkg/dbtransaction" @@ -42,6 +43,9 @@ type OVSPortData struct { UUID string Name string VLANID uint16 + // Trunks contains the allowed VLAN IDs when the port is in trunk mode. + // It is empty when no trunk VLAN restriction is configured (all VLANs allowed). + Trunks []uint16 // Interface type. IFType string IFName string @@ -599,6 +603,117 @@ func (br *OVSBridge) CreateUplinkPort(name string, ofPortRequest int32, external return br.createPort(name, name, "", ofPortRequest, 0, "", externalIDs, nil) } +// parseVLANSpecs converts VLAN specifications such as "100" or "200-300" into +// a flat slice of uint16 VLAN IDs suitable for the OVSDB trunks set, which +// only supports discrete integer elements (no native range type). +func parseVLANSpecs(specs []string) ([]uint16, error) { + var ids []uint16 + for _, spec := range specs { + spec = strings.TrimSpace(spec) + if idx := strings.IndexByte(spec, '-'); idx >= 0 { + startStr, endStr := spec[:idx], spec[idx+1:] + start, err := strconv.ParseUint(startStr, 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid VLAN range start %q: %v", startStr, err) + } + end, err := strconv.ParseUint(endStr, 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid VLAN range end %q: %v", endStr, err) + } + if start > end { + return nil, fmt.Errorf("VLAN range start %d is greater than end %d", start, end) + } + for v := start; v <= end; v++ { + ids = append(ids, uint16(v)) + } + } else { + v, err := strconv.ParseUint(spec, 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid VLAN ID %q: %v", spec, err) + } + ids = append(ids, uint16(v)) + } + } + return ids, nil +} + +// CreateTrunkPort creates an OVS port in trunk mode for the physical interface +// identified by name. vlanSpecs lists the allowed VLANs as individual IDs or +// ranges (e.g. "100", "200-300"); an empty slice means all VLANs are allowed +// (standard OVS trunk default). +func (br *OVSBridge) CreateTrunkPort(name string, ofPortRequest int32, vlanSpecs []string, externalIDs map[string]interface{}) (string, Error) { + vlanIDs, err := parseVLANSpecs(vlanSpecs) + if err != nil { + return "", newInvalidArgumentsError(fmt.Sprintf("invalid VLAN specs for port %q: %v", name, err)) + } + + var externalIDMap []interface{} + if externalIDs != nil { + externalIDMap = helpers.MakeOVSDBMap(externalIDs) + } + + for _, id := range br.requiredPortExternalIDs { + if _, ok := externalIDs[id]; !ok { + return "", newInvalidArgumentsError(fmt.Sprintf("missing required externalID '%s' for port '%s'", id, name)) + } + } + + tx := br.ovsdb.Transaction(openvSwitchSchema) + + interf := Interface{ + Name: name, + OFPortRequest: ofPortRequest, + } + ifNamedUUID := tx.Insert(dbtransaction.Insert{ + Table: "Interface", + Row: interf, + }) + + port := Port{ + Name: name, + Interfaces: helpers.MakeOVSDBSet(map[string]interface{}{ + "named-uuid": []string{ifNamedUUID}, + }), + ExternalIDs: externalIDMap, + } + + var portRow interface{} + if len(vlanIDs) > 0 { + ids := make([]interface{}, len(vlanIDs)) + for i, v := range vlanIDs { + ids[i] = v + } + portRow = TrunkPort{ + Port: port, + Trunks: []interface{}{"set", ids}, + } + } else { + portRow = port + } + + portNamedUUID := tx.Insert(dbtransaction.Insert{ + Table: "Port", + Row: portRow, + }) + + mutateSet := helpers.MakeOVSDBSet(map[string]interface{}{ + "named-uuid": []string{portNamedUUID}, + }) + tx.Mutate(dbtransaction.Mutate{ + Table: "Bridge", + Mutations: [][]interface{}{{"ports", "insert", mutateSet}}, + Where: [][]interface{}{{"name", "==", br.name}}, + }) + + res, err, temporary := tx.Commit() + if err != nil { + klog.Error("Transaction failed: ", err) + return "", NewTransactionError(err, temporary) + } + + return res[1].UUID[1], nil +} + // CreatePort creates a port with the specified name on the bridge, and connects // the interface specified by ifDev to the port. // If externalIDs is not empty, the map key/value pairs will be set to the @@ -757,6 +872,28 @@ func buildPortDataCommon(port, intf map[string]interface{}, portData *OVSPortDat if tag, ok := port["tag"].(float64); ok { portData.VLANID = uint16(tag) } + // OVSDB represents a set as ["set", [v1, v2, ...]] when there are zero or + // multiple values, but as a bare scalar when there is exactly one value. + // Handle both forms so that a single-VLAN trunk port is not silently treated + // as having no trunks. + switch trunksRaw := port["trunks"].(type) { + case []interface{}: + elems := trunksRaw + if len(trunksRaw) == 2 { + if tag, ok := trunksRaw[0].(string); ok && tag == "set" { + if list, ok := trunksRaw[1].([]interface{}); ok { + elems = list + } + } + } + for _, v := range elems { + if id, ok := v.(float64); ok { + portData.Trunks = append(portData.Trunks, uint16(id)) + } + } + case float64: + portData.Trunks = append(portData.Trunks, uint16(trunksRaw)) + } portData.Options = buildMapFromOVSDBMap(intf["options"].([]interface{})) portData.IFType = intf["type"].(string) if ofPort, ok := intf["ofport"].(float64); ok { @@ -787,7 +924,7 @@ func (br *OVSBridge) GetPortData(portUUID, ifName string) (*OVSPortData, Error) tx := br.ovsdb.Transaction(openvSwitchSchema) tx.Select(dbtransaction.Select{ Table: "Port", - Columns: []string{"name", "external_ids", "interfaces", "tag"}, + Columns: []string{"name", "external_ids", "interfaces", "tag", "trunks"}, Where: [][]interface{}{{"_uuid", "==", []string{"uuid", portUUID}}}, }) tx.Select(dbtransaction.Select{ @@ -841,7 +978,7 @@ func (br *OVSBridge) GetPortList() ([]OVSPortData, Error) { }) tx.Select(dbtransaction.Select{ Table: "Port", - Columns: []string{"_uuid", "name", "external_ids", "interfaces", "tag"}, + Columns: []string{"_uuid", "name", "external_ids", "interfaces", "tag", "trunks"}, }) tx.Select(dbtransaction.Select{ Table: "Interface", @@ -1116,6 +1253,40 @@ func (br *OVSBridge) SetPortExternalIDs(portName string, externalIDs map[string] return nil } +// SetPortTrunks updates the trunk VLAN list on an existing OVS port. vlanSpecs +// lists the allowed VLANs as individual IDs or ranges (e.g. "100", "200-300"). +// Passing a nil or empty slice clears all trunk restrictions (all VLANs allowed). +func (br *OVSBridge) SetPortTrunks(portName string, vlanSpecs []string) Error { + vlanIDs, err := parseVLANSpecs(vlanSpecs) + if err != nil { + return newInvalidArgumentsError(fmt.Sprintf("invalid VLAN specs for port %q: %v", portName, err)) + } + tx := br.ovsdb.Transaction(openvSwitchSchema) + var trunksValue interface{} + if len(vlanIDs) > 0 { + ids := make([]interface{}, len(vlanIDs)) + for i, v := range vlanIDs { + ids[i] = v + } + trunksValue = []interface{}{"set", ids} + } else { + trunksValue = []interface{}{"set", []interface{}{}} + } + tx.Update(dbtransaction.Update{ + Table: "Port", + Where: [][]interface{}{{"name", "==", portName}}, + Row: map[string]interface{}{ + "trunks": trunksValue, + }, + }) + _, err, temporary := tx.Commit() + if err != nil { + klog.Error("Transaction failed: ", err) + return NewTransactionError(err, temporary) + } + return nil +} + func (br *OVSBridge) GetPortExternalIDs(portName string) (map[string]string, Error) { tx := br.ovsdb.Transaction(openvSwitchSchema) tx.Select(dbtransaction.Select{ diff --git a/pkg/ovs/ovsconfig/ovs_client_test.go b/pkg/ovs/ovsconfig/ovs_client_test.go index 35daad09b8c..b7b0057e9a8 100644 --- a/pkg/ovs/ovsconfig/ovs_client_test.go +++ b/pkg/ovs/ovsconfig/ovs_client_test.go @@ -19,6 +19,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestOVSClient(t *testing.T) { @@ -116,5 +117,105 @@ func TestBuildPortDataCommon(t *testing.T) { assert.Equal(t, tc.portData, portData) }) } +} +func TestBuildPortDataCommonTrunks(t *testing.T) { + basePort := func(trunks interface{}) map[string]interface{} { + return map[string]interface{}{ + "name": "eth1", + "external_ids": []interface{}{"map", []interface{}{}}, + "trunks": trunks, + } + } + baseIntf := map[string]interface{}{ + "name": "eth1", + "mac": []interface{}{}, + "type": "", + "ofport": float64(1), + "options": []interface{}{""}, + } + + tests := []struct { + name string + trunks interface{} + wantTrunks []uint16 + }{ + { + name: "no trunks field (nil)", + trunks: nil, + wantTrunks: nil, + }, + { + name: "empty set", + trunks: []interface{}{"set", []interface{}{}}, + wantTrunks: nil, + }, + { + name: "multi-value set", + trunks: []interface{}{"set", []interface{}{float64(100), float64(200)}}, + wantTrunks: []uint16{100, 200}, + }, + { + // OVSDB returns a bare scalar (float64) for a single-element set. + // This is the case that previously caused clearStaleTrunks to skip + // single-VLAN trunk ports. + name: "single VLAN — bare scalar (OVSDB single-element encoding)", + trunks: float64(100), + wantTrunks: []uint16{100}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + port := basePort(tc.trunks) + portData := &OVSPortData{} + buildPortDataCommon(port, baseIntf, portData) + assert.Equal(t, tc.wantTrunks, portData.Trunks) + }) + } +} + +func TestParseVLANSpecs(t *testing.T) { + tests := []struct { + name string + specs []string + wantIDs []uint16 + expectedErr string + }{ + { + name: "single VLAN", + specs: []string{"100"}, + wantIDs: []uint16{100}, + }, + { + name: "range", + specs: []string{"10-12"}, + wantIDs: []uint16{10, 11, 12}, + }, + { + name: "mixed", + specs: []string{"5", "10-11"}, + wantIDs: []uint16{5, 10, 11}, + }, + { + name: "invalid single", + specs: []string{"abc"}, + expectedErr: "invalid VLAN ID", + }, + { + name: "inverted range", + specs: []string{"200-100"}, + expectedErr: "VLAN range start", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseVLANSpecs(tc.specs) + if tc.expectedErr != "" { + assert.ErrorContains(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + assert.Equal(t, tc.wantIDs, got) + } + }) + } } diff --git a/pkg/ovs/ovsconfig/ovs_schema.go b/pkg/ovs/ovsconfig/ovs_schema.go index 158eb76905b..6e58a56e038 100644 --- a/pkg/ovs/ovsconfig/ovs_schema.go +++ b/pkg/ovs/ovsconfig/ovs_schema.go @@ -32,6 +32,13 @@ type AccessPort struct { Tag uint32 `json:"tag"` } +// TrunkPort is an OVS port in trunk mode, allowing only the specified VLAN IDs. +// When Trunks is empty, all VLANs are allowed (OVS default trunk behavior). +type TrunkPort struct { + Port + Trunks []interface{} `json:"trunks,omitempty"` +} + type Interface struct { Name string `json:"name"` Type string `json:"type,omitempty"` diff --git a/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go b/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go index 3e3aa733a13..abe8c5ad7a7 100644 --- a/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go +++ b/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go @@ -1,4 +1,4 @@ -// Copyright 2024 Antrea Authors +// Copyright 2026 Antrea Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -144,6 +144,21 @@ func (mr *MockOVSBridgeClientMockRecorder) CreatePort(name, ifDev, externalIDs a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePort", reflect.TypeOf((*MockOVSBridgeClient)(nil).CreatePort), name, ifDev, externalIDs) } +// CreateTrunkPort mocks base method. +func (m *MockOVSBridgeClient) CreateTrunkPort(name string, ofPortRequest int32, vlanSpecs []string, externalIDs map[string]any) (string, ovsconfig.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTrunkPort", name, ofPortRequest, vlanSpecs, externalIDs) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(ovsconfig.Error) + return ret0, ret1 +} + +// CreateTrunkPort indicates an expected call of CreateTrunkPort. +func (mr *MockOVSBridgeClientMockRecorder) CreateTrunkPort(name, ofPortRequest, vlanSpecs, externalIDs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTrunkPort", reflect.TypeOf((*MockOVSBridgeClient)(nil).CreateTrunkPort), name, ofPortRequest, vlanSpecs, externalIDs) +} + // CreateTunnelPort mocks base method. func (m *MockOVSBridgeClient) CreateTunnelPort(name string, tunnelType ovsconfig.TunnelType, ofPortRequest int32) (string, ovsconfig.Error) { m.ctrl.T.Helper() @@ -520,6 +535,20 @@ func (mr *MockOVSBridgeClientMockRecorder) SetPortExternalIDs(portName, external return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPortExternalIDs", reflect.TypeOf((*MockOVSBridgeClient)(nil).SetPortExternalIDs), portName, externalIDs) } +// SetPortTrunks mocks base method. +func (m *MockOVSBridgeClient) SetPortTrunks(portName string, vlanSpecs []string) ovsconfig.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetPortTrunks", portName, vlanSpecs) + ret0, _ := ret[0].(ovsconfig.Error) + return ret0 +} + +// SetPortTrunks indicates an expected call of SetPortTrunks. +func (mr *MockOVSBridgeClientMockRecorder) SetPortTrunks(portName, vlanSpecs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPortTrunks", reflect.TypeOf((*MockOVSBridgeClient)(nil).SetPortTrunks), portName, vlanSpecs) +} + // UpdateOVSOtherConfig mocks base method. func (m *MockOVSBridgeClient) UpdateOVSOtherConfig(configs map[string]any) ovsconfig.Error { m.ctrl.T.Helper() From 8366f68bab97a452c30b288fcb726f094fe63582 Mon Sep 17 00:00:00 2001 From: Lan Luo Date: Wed, 15 Apr 2026 08:17:59 +0000 Subject: [PATCH 09/13] Address comments Signed-off-by: Lan Luo --- cmd/antrea-agent/agent.go | 43 +- .../antreanodeconfig/antreanodeconfig.go | 124 ----- .../antreanodeconfig/antreanodeconfig_test.go | 323 ------------- pkg/agent/antreanodeconfig/controller.go | 302 +++++++----- pkg/agent/antreanodeconfig/controller_test.go | 301 ++++++++---- .../antreanodeconfig/effective_snapshot.go | 60 --- .../effective_snapshot_test.go | 56 --- .../antreanodeconfig/secondary_network.go | 86 ---- .../secondary_network_test.go | 161 ------- pkg/agent/antreanodeconfig/snapshot.go | 65 +++ pkg/agent/antreanodeconfig/snapshot_test.go | 45 ++ pkg/agent/secondarynetwork/anc_bridge.go | 115 +++++ pkg/agent/secondarynetwork/anc_bridge_test.go | 436 ++++++++++++++++++ pkg/agent/secondarynetwork/init.go | 137 ++++-- pkg/agent/secondarynetwork/init_linux.go | 49 +- pkg/agent/secondarynetwork/init_linux_test.go | 34 +- 16 files changed, 1236 insertions(+), 1101 deletions(-) delete mode 100644 pkg/agent/antreanodeconfig/antreanodeconfig.go delete mode 100644 pkg/agent/antreanodeconfig/antreanodeconfig_test.go delete mode 100644 pkg/agent/antreanodeconfig/effective_snapshot.go delete mode 100644 pkg/agent/antreanodeconfig/effective_snapshot_test.go delete mode 100644 pkg/agent/antreanodeconfig/secondary_network.go delete mode 100644 pkg/agent/antreanodeconfig/secondary_network_test.go create mode 100644 pkg/agent/antreanodeconfig/snapshot.go create mode 100644 pkg/agent/antreanodeconfig/snapshot_test.go create mode 100644 pkg/agent/secondarynetwork/anc_bridge.go create mode 100644 pkg/agent/secondarynetwork/anc_bridge_test.go diff --git a/cmd/antrea-agent/agent.go b/cmd/antrea-agent/agent.go index 2578660c52f..bc9fa26a997 100644 --- a/cmd/antrea-agent/agent.go +++ b/cmd/antrea-agent/agent.go @@ -627,35 +627,28 @@ func run(o *Options) error { var antreaNodeConfigController *antreanodeconfig.Controller var cniDeleteChecker agenttypes.CNIDeleteChecker cniDeleteChecker = nil + var ancSubscriber channel.Subscriber + + if features.DefaultFeatureGate.Enabled(features.AntreaNodeConfig) { + antreaNodeConfigInformer := crdInformerFactory.Crd().V1alpha1().AntreaNodeConfigs() + antreaNodeConfigUpdateChannel = channel.NewSubscribableChannel("AntreaNodeConfig", 100) + antreaNodeConfigController = antreanodeconfig.NewController( + antreaNodeConfigInformer, + nodeInformer, + nodeConfig.Name, + antreaNodeConfigUpdateChannel, + ) + ancSubscriber = antreaNodeConfigUpdateChannel + } + // Secondary network controller should be created before CNIServer.Run() to make sure no Pod CNI updates will be missed. if features.DefaultFeatureGate.Enabled(features.SecondaryNetwork) { - var effectiveBridgeFn func() *agenttypes.OVSBridgeConfig - var ancSubscriber channel.Subscriber - - if features.DefaultFeatureGate.Enabled(features.AntreaNodeConfig) { - antreaNodeConfigInformer := crdInformerFactory.Crd().V1alpha1().AntreaNodeConfigs() - antreaNodeConfigUpdateChannel = channel.NewSubscribableChannel("AntreaNodeConfig", 100) - antreaNodeConfigController = antreanodeconfig.NewController( - antreaNodeConfigInformer, - nodeInformer, - nodeConfig.Name, - o.config, - antreaNodeConfigUpdateChannel, - ) - effectiveBridgeFn = antreaNodeConfigController.EffectiveSecondaryOVSBridge - ancSubscriber = antreaNodeConfigUpdateChannel - } else { - effectiveBridgeFn = func() *agenttypes.OVSBridgeConfig { - return antreanodeconfig.EffectiveSecondaryOVSBridge(nil, nil, nil, false, &o.config.SecondaryNetwork) - } - } - secondaryNetworkController, err = secondarynetwork.NewController( o.config.ClientConnection, o.config.KubeAPIServerOverride, k8sClient, localPodInformer.Get(), podUpdateChannel, ifaceStore, nodeConfig, &o.config.SecondaryNetwork, ovsdbConnection, ipPoolInformer.Lister(), - effectiveBridgeFn, ancSubscriber) + ancSubscriber) if err != nil { return fmt.Errorf("failed to create secondary network controller: %w", err) } @@ -1001,8 +994,12 @@ func run(o *Options) error { } } // secondaryNetworkController Initialize must be run after FlowRestoreComplete for the case that Node - // IPs are moved to the secondary OVS bridge + // IPs are moved to the secondary OVS bridge. When AntreaNodeConfig drives the secondary bridge, + // wait for the first ANC snapshot before Initialize so the effective bridge is known. if features.DefaultFeatureGate.Enabled(features.SecondaryNetwork) { + if err := secondaryNetworkController.WaitForInitialANCSnapshotAndEnsureBridge(stopCh); err != nil { + return fmt.Errorf("failed to wait for AntreaNodeConfig snapshot for secondary network: %w", err) + } defer secondaryNetworkController.Restore() if err = secondaryNetworkController.Initialize(); err != nil { return fmt.Errorf("failed to initialize secondary network: %v", err) diff --git a/pkg/agent/antreanodeconfig/antreanodeconfig.go b/pkg/agent/antreanodeconfig/antreanodeconfig.go deleted file mode 100644 index 180c61041dc..00000000000 --- a/pkg/agent/antreanodeconfig/antreanodeconfig.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2026 Antrea Authors -// -// 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 antreanodeconfig provides a utility framework for evaluating which -// AntreaNodeConfig resources apply to a given Node and computing the effective -// merged secondary-network configuration. -// -// Controller watches AntreaNodeConfig and the local Node, -// computes an EffectiveSnapshot of all derived agent settings, and broadcasts -// updates on a util/channel SubscribableChannel so feature controllers do not -// each register their own ANC informer handlers. Extend EffectiveSnapshot and -// ComputeEffectiveSnapshot when new ANC spec fields gain agent consumers. -// -// SelectAndApplySecondaryNetworkConfig (and EffectiveSecondaryOVSBridge) can also be called directly -// with an informer-cached list of AntreaNodeConfig objects and the current Node -// to obtain merged configuration. See EffectiveSecondaryOVSBridge and -// Controller.EffectiveSecondaryOVSBridge for when the result is nil versus static fallback. -// -// # Selection and override semantics -// -// All AntreaNodeConfigs whose nodeSelector matches the Node's labels are -// gathered and sorted by creationTimestamp ascending (oldest first; name is -// used as a stable tiebreaker when timestamps are equal). The first (oldest) -// config that specifies a non-nil SecondaryNetwork takes effect entirely — -// there is no field-level merging within SecondaryNetwork and later configs -// are ignored once a winner is found. -package antreanodeconfig - -import ( - "sort" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/klog/v2" - - agenttypes "antrea.io/antrea/v2/pkg/agent/types" - crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" -) - -// SelectMatchingSecondaryNetworkConfigs returns the subset of configs whose nodeSelector -// matches node's labels, sorted by creationTimestamp ascending (oldest first). -// Configs with an invalid nodeSelector are skipped with a warning. -func SelectMatchingSecondaryNetworkConfigs(node *corev1.Node, configs []*crdv1alpha1.AntreaNodeConfig) []*crdv1alpha1.AntreaNodeConfig { - nodeLabels := labels.Set(node.Labels) - var matching []*crdv1alpha1.AntreaNodeConfig - for _, cfg := range configs { - sel, err := metav1.LabelSelectorAsSelector(&cfg.Spec.NodeSelector) - if err != nil { - klog.ErrorS(err, "Skipping AntreaNodeConfig with invalid nodeSelector", "config", cfg.Name) - continue - } - if sel.Matches(nodeLabels) { - matching = append(matching, cfg) - } - } - sort.Slice(matching, func(i, j int) bool { - ti := matching[i].CreationTimestamp - tj := matching[j].CreationTimestamp - if ti.Equal(&tj) { - return matching[i].Name < matching[j].Name - } - return ti.Before(&tj) - }) - return matching -} - -// ApplySecondaryNetworkConfigs computes the effective SecondaryNetworkConfig from an -// ordered (oldest-first) slice of AntreaNodeConfigs. It returns the -// SecondaryNetworkConfig from the first (oldest) config that specifies a -// non-nil SecondaryNetwork, and ignores all subsequent configs. It returns -// nil when none of the configs specifies a SecondaryNetwork, meaning the -// static agent config should remain in effect unchanged. -func ApplySecondaryNetworkConfigs(configs []*crdv1alpha1.AntreaNodeConfig) *agenttypes.SecondaryNetworkConfig { - for _, cfg := range configs { - if cfg.Spec.SecondaryNetwork == nil { - continue - } - converted := convertSecondaryNetwork(cfg.Spec.SecondaryNetwork) - return &converted - } - return nil -} - -// SelectAndApplySecondaryNetworkConfigs is a convenience wrapper that calls SelectMatchingSecondaryNetworkConfigsConfigs -// followed by ApplySecondaryNetworkConfigs. It returns the effective SecondaryNetworkConfig -// for node, or nil when no matching AntreaNodeConfig specifies a -// SecondaryNetwork (in which case the static agent config stays in effect). -func SelectAndApplySecondaryNetworkConfigs(node *corev1.Node, configs []*crdv1alpha1.AntreaNodeConfig) *agenttypes.SecondaryNetworkConfig { - return ApplySecondaryNetworkConfigs(SelectMatchingSecondaryNetworkConfigs(node, configs)) -} - -// convertSecondaryNetwork converts from the CRD type to SecondaryNetworkConfig. -// The CRD schema enforces at most one OVS bridge; OVSBridge is nil when the -// list is empty. -func convertSecondaryNetwork(in *crdv1alpha1.SecondaryNetworkConfig) agenttypes.SecondaryNetworkConfig { - if len(in.OVSBridges) == 0 { - return agenttypes.SecondaryNetworkConfig{} - } - b := in.OVSBridges[0] - bridge := &agenttypes.OVSBridgeConfig{ - BridgeName: b.BridgeName, - EnableMulticastSnooping: b.EnableMulticastSnooping, - } - for _, iface := range b.PhysicalInterfaces { - pi := agenttypes.PhysicalInterfaceConfig{Name: iface.Name} - if len(iface.AllowedVLANs) > 0 { - pi.AllowedVLANs = append(pi.AllowedVLANs, iface.AllowedVLANs...) - } - bridge.PhysicalInterfaces = append(bridge.PhysicalInterfaces, pi) - } - return agenttypes.SecondaryNetworkConfig{OVSBridge: bridge} -} diff --git a/pkg/agent/antreanodeconfig/antreanodeconfig_test.go b/pkg/agent/antreanodeconfig/antreanodeconfig_test.go deleted file mode 100644 index ab1cd77b78d..00000000000 --- a/pkg/agent/antreanodeconfig/antreanodeconfig_test.go +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright 2026 Antrea Authors -// -// 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 antreanodeconfig - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - agenttypes "antrea.io/antrea/v2/pkg/agent/types" - crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" -) - -func makeNode(labels map[string]string) *corev1.Node { - return &corev1.Node{ - ObjectMeta: metav1.ObjectMeta{ - Name: "node1", - Labels: labels, - }, - } -} - -func makeANC(name string, ts time.Time, nodeSelector map[string]string, secNet *crdv1alpha1.SecondaryNetworkConfig) *crdv1alpha1.AntreaNodeConfig { - return &crdv1alpha1.AntreaNodeConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - CreationTimestamp: metav1.NewTime(ts), - }, - Spec: crdv1alpha1.AntreaNodeConfigSpec{ - NodeSelector: metav1.LabelSelector{ - MatchLabels: nodeSelector, - }, - SecondaryNetwork: secNet, - }, - } -} - -func makeBridge(name string, mcast bool, ifaces ...crdv1alpha1.OVSPhysicalInterfaceConfig) crdv1alpha1.OVSBridgeConfig { - return crdv1alpha1.OVSBridgeConfig{ - BridgeName: name, - EnableMulticastSnooping: mcast, - PhysicalInterfaces: ifaces, - } -} - -func makeIface(name string, vlans ...string) crdv1alpha1.OVSPhysicalInterfaceConfig { - return crdv1alpha1.OVSPhysicalInterfaceConfig{Name: name, AllowedVLANs: vlans} -} - -var ( - t0 = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - t1 = t0.Add(time.Minute) - t2 = t0.Add(2 * time.Minute) -) - -func TestSelectMatchingConfigs(t *testing.T) { - node := makeNode(map[string]string{"role": "worker", "zone": "us-east"}) - - anc1 := makeANC("anc1", t0, map[string]string{"role": "worker"}, nil) - anc2 := makeANC("anc2", t1, map[string]string{"role": "control-plane"}, nil) - anc3 := makeANC("anc3", t2, map[string]string{"zone": "us-east"}, nil) - ancInvalidSel := &crdv1alpha1.AntreaNodeConfig{ - ObjectMeta: metav1.ObjectMeta{Name: "invalid"}, - Spec: crdv1alpha1.AntreaNodeConfigSpec{ - NodeSelector: metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "x", Operator: "BadOp", Values: []string{"v"}}, - }, - }, - }, - } - - tests := []struct { - name string - configs []*crdv1alpha1.AntreaNodeConfig - wantLen int - wantOrder []string - }{ - { - name: "no configs", - configs: nil, - wantLen: 0, - }, - { - name: "one matching", - configs: []*crdv1alpha1.AntreaNodeConfig{anc1}, - wantLen: 1, - }, - { - name: "one non-matching", - configs: []*crdv1alpha1.AntreaNodeConfig{anc2}, - wantLen: 0, - }, - { - name: "two matching sorted oldest-first", - configs: []*crdv1alpha1.AntreaNodeConfig{anc3, anc1}, // deliberately reversed - wantLen: 2, - wantOrder: []string{"anc1", "anc3"}, - }, - { - name: "invalid selector is skipped", - configs: []*crdv1alpha1.AntreaNodeConfig{ancInvalidSel, anc1}, - wantLen: 1, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := SelectMatchingSecondaryNetworkConfigs(node, tc.configs) - require.Len(t, got, tc.wantLen) - if tc.wantOrder != nil { - for i, name := range tc.wantOrder { - assert.Equal(t, name, got[i].Name) - } - } - }) - } -} - -func TestSelectMatchingConfigs_TimestampTiebreaker(t *testing.T) { - // Two configs with the same timestamp; name is the tiebreaker. - node := makeNode(map[string]string{"role": "worker"}) - ancA := makeANC("zzz", t0, map[string]string{"role": "worker"}, nil) - ancB := makeANC("aaa", t0, map[string]string{"role": "worker"}, nil) - - got := SelectMatchingSecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancA, ancB}) - require.Len(t, got, 2) - assert.Equal(t, "aaa", got[0].Name, "alphabetically earlier name should sort first") - assert.Equal(t, "zzz", got[1].Name) -} - -func TestApplyConfigs(t *testing.T) { - secNet1 := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{ - makeBridge("br0", false, makeIface("eth0")), - }, - } - secNet2 := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{ - makeBridge("br1", true, makeIface("eth1", "100", "200-300")), - }, - } - - anc1 := makeANC("anc1", t0, nil, secNet1) - anc2 := makeANC("anc2", t1, nil, secNet2) - ancNoSec := makeANC("ancNoSec", t2, nil, nil) - - tests := []struct { - name string - configs []*crdv1alpha1.AntreaNodeConfig - want *agenttypes.SecondaryNetworkConfig - }{ - { - name: "empty input returns nil", - configs: nil, - want: nil, - }, - { - name: "all configs have nil SecondaryNetwork returns nil", - configs: []*crdv1alpha1.AntreaNodeConfig{ancNoSec}, - want: nil, - }, - { - name: "single config with SecondaryNetwork", - configs: []*crdv1alpha1.AntreaNodeConfig{anc1}, - want: &agenttypes.SecondaryNetworkConfig{ - OVSBridge: &agenttypes.OVSBridgeConfig{ - BridgeName: "br0", EnableMulticastSnooping: false, - PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ - {Name: "eth0"}, - }, - }, - }, - }, - { - name: "older config wins over newer one", - // anc1 (older) sets br0/eth0; anc2 (newer) sets br1/eth1 — br0 wins. - configs: []*crdv1alpha1.AntreaNodeConfig{anc1, anc2}, - want: &agenttypes.SecondaryNetworkConfig{ - OVSBridge: &agenttypes.OVSBridgeConfig{ - BridgeName: "br0", EnableMulticastSnooping: false, - PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ - {Name: "eth0"}, - }, - }, - }, - }, - { - name: "nil SecondaryNetwork in between does not clear result", - // anc1 sets br0; ancNoSec has nil; the result should still be anc1's br0. - configs: []*crdv1alpha1.AntreaNodeConfig{anc1, ancNoSec}, - want: &agenttypes.SecondaryNetworkConfig{ - OVSBridge: &agenttypes.OVSBridgeConfig{ - BridgeName: "br0", - PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ - {Name: "eth0"}, - }, - }, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := ApplySecondaryNetworkConfigs(tc.configs) - assert.Equal(t, tc.want, got) - }) - } -} - -func TestSelectAndApply(t *testing.T) { - node := makeNode(map[string]string{"role": "worker"}) - - secNetA := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{makeBridge("brA", false, makeIface("eth0"))}, - } - secNetB := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{makeBridge("brB", true, makeIface("eth1", "100"))}, - } - - // anc-old matches, sets brA (older timestamp) — should win - ancOld := makeANC("anc-old", t0, map[string]string{"role": "worker"}, secNetA) - // anc-new matches, sets brB (newer timestamp) - ancNew := makeANC("anc-new", t1, map[string]string{"role": "worker"}, secNetB) - // anc-other does not match the node - ancOther := makeANC("anc-other", t2, map[string]string{"role": "control-plane"}, secNetA) - - t.Run("no configs returns nil", func(t *testing.T) { - assert.Nil(t, SelectAndApplySecondaryNetworkConfigs(node, nil)) - }) - - t.Run("non-matching config returns nil", func(t *testing.T) { - assert.Nil(t, SelectAndApplySecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancOther})) - }) - - t.Run("single matching config applied", func(t *testing.T) { - got := SelectAndApplySecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancOld}) - require.NotNil(t, got) - require.NotNil(t, got.OVSBridge) - assert.Equal(t, "brA", got.OVSBridge.BridgeName) - }) - - t.Run("older matching config takes effect over newer one", func(t *testing.T) { - got := SelectAndApplySecondaryNetworkConfigs(node, []*crdv1alpha1.AntreaNodeConfig{ancOld, ancNew, ancOther}) - require.NotNil(t, got) - require.NotNil(t, got.OVSBridge) - assert.Equal(t, "brA", got.OVSBridge.BridgeName) - assert.False(t, got.OVSBridge.EnableMulticastSnooping) - assert.Nil(t, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) - }) -} - -func TestConvertSecondaryNetwork(t *testing.T) { - t.Run("empty bridges yields nil OVSBridge", func(t *testing.T) { - got := convertSecondaryNetwork(&crdv1alpha1.SecondaryNetworkConfig{}) - assert.Nil(t, got.OVSBridge) - }) - - t.Run("interface without AllowedVLANs has nil AllowedVLANs in output", func(t *testing.T) { - in := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{ - makeBridge("br0", false, makeIface("eth0")), - }, - } - got := convertSecondaryNetwork(in) - require.NotNil(t, got.OVSBridge) - require.Len(t, got.OVSBridge.PhysicalInterfaces, 1) - assert.Equal(t, "eth0", got.OVSBridge.PhysicalInterfaces[0].Name) - assert.Nil(t, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) - }) - - t.Run("AllowedVLANs are preserved", func(t *testing.T) { - in := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{ - makeBridge("br0", false, makeIface("eth0", "100", "200-300")), - }, - } - got := convertSecondaryNetwork(in) - require.NotNil(t, got.OVSBridge) - assert.Equal(t, []string{"100", "200-300"}, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) - }) - - t.Run("multicast snooping flag is preserved", func(t *testing.T) { - in := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{ - makeBridge("br0", true), - }, - } - got := convertSecondaryNetwork(in) - require.NotNil(t, got.OVSBridge) - assert.True(t, got.OVSBridge.EnableMulticastSnooping) - }) - - t.Run("bridge with multiple interfaces", func(t *testing.T) { - in := &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{ - makeBridge("br0", false, makeIface("eth0"), makeIface("eth1", "10")), - }, - } - got := convertSecondaryNetwork(in) - require.NotNil(t, got.OVSBridge) - assert.Equal(t, "br0", got.OVSBridge.BridgeName) - assert.Len(t, got.OVSBridge.PhysicalInterfaces, 2) - assert.Nil(t, got.OVSBridge.PhysicalInterfaces[0].AllowedVLANs) - assert.Equal(t, []string{"10"}, got.OVSBridge.PhysicalInterfaces[1].AllowedVLANs) - }) -} diff --git a/pkg/agent/antreanodeconfig/controller.go b/pkg/agent/antreanodeconfig/controller.go index 66c92cd58a6..ed582384057 100644 --- a/pkg/agent/antreanodeconfig/controller.go +++ b/pkg/agent/antreanodeconfig/controller.go @@ -12,25 +12,33 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package antreanodeconfig watches AntreaNodeConfig resources and the local Node, +// and publishes immutable snapshots (Node plus the oldest matching AntreaNodeConfig) +// to channel subscribers when relevant state +// changes. Feature packages (for example secondary network) consume those +// snapshots and merge them with their own static configuration. package antreanodeconfig import ( + "fmt" "reflect" - "sync" + "sort" "time" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" coreinformers "k8s.io/client-go/informers/core/v1" corelisters "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" "k8s.io/klog/v2" - agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" crdinformers "antrea.io/antrea/v2/pkg/client/informers/externalversions/crd/v1alpha1" crdv1alpha1listers "antrea.io/antrea/v2/pkg/client/listers/crd/v1alpha1" - agentconfig "antrea.io/antrea/v2/pkg/config/agent" "antrea.io/antrea/v2/pkg/util/channel" ) @@ -39,58 +47,69 @@ const ( // SubscribableChannel delivery before reconciling AntreaNodeConfig-derived // state from the informer cache again. ancInformerResyncPeriod = 5 * time.Minute + + // snapshotQueueKey is the sole workqueue item: one worker reconciles the full + // snapshot from Node + AntreaNodeConfig listers whenever any relevant object changes. + snapshotQueueKey = "snapshot" + + defaultWorkers = 1 + + minRetryDelay = 5 * time.Millisecond + maxRetryDelay = 30 * time.Second ) -// Controller watches AntreaNodeConfig and the local Node, evaluates derived -// agent settings (see EffectiveSnapshot), and notifies subscribers when that -// aggregate snapshot changes. +// Controller watches AntreaNodeConfig and the local Node, builds snapshots of +// the effective AntreaNodeConfig for this Node (plus list errors), and notifies +// subscribers when that snapshot changes. type Controller struct { - nodeName string - staticSecondaryNetworkCfg *agentconfig.SecondaryNetworkConfig - ancLister crdv1alpha1listers.AntreaNodeConfigLister - nodeLister corelisters.NodeLister - notifier channel.Notifier + nodeName string + ancLister crdv1alpha1listers.AntreaNodeConfigLister + nodeLister corelisters.NodeLister + notifier channel.Notifier nodeListerSynced cache.InformerSynced ancListerSynced cache.InformerSynced - mu sync.RWMutex - node *corev1.Node - lastNotified *EffectiveSnapshot + queue workqueue.TypedRateLimitingInterface[string] + + // lastNotified is only read/written by the snapshot worker goroutine(s). + lastNotified *Snapshot } // NewController constructs a Controller and registers informer handlers. -// The local Node is loaded from nodeInformer after its cache syncs (see Run) -// and kept up to date via Add/Update/Delete callbacks. notifier.Notify -// receives *EffectiveSnapshot payloads (deep-copied). +// The local Node is read from nodeInformer after its cache syncs (see Run). +// notifier.Notify receives *Snapshot payloads (deep-copied). func NewController( ancInformer crdinformers.AntreaNodeConfigInformer, nodeInformer coreinformers.NodeInformer, nodeName string, - agentConfig *agentconfig.AgentConfig, notifier channel.Notifier, ) *Controller { c := &Controller{ - nodeName: nodeName, - staticSecondaryNetworkCfg: &agentConfig.SecondaryNetwork, - ancLister: ancInformer.Lister(), - nodeLister: nodeInformer.Lister(), - notifier: notifier, - nodeListerSynced: nodeInformer.Informer().HasSynced, - ancListerSynced: ancInformer.Informer().HasSynced, + nodeName: nodeName, + ancLister: ancInformer.Lister(), + nodeLister: nodeInformer.Lister(), + notifier: notifier, + nodeListerSynced: nodeInformer.Informer().HasSynced, + ancListerSynced: ancInformer.Informer().HasSynced, + queue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), + workqueue.TypedRateLimitingQueueConfig[string]{ + Name: "AntreaNodeConfig", + }, + ), } nodeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: c.onNodeAdd, UpdateFunc: c.onNodeUpdate, - DeleteFunc: c.onNodeDelete, }) ancInformer.Informer().AddEventHandlerWithResyncPeriod( cache.ResourceEventHandlerFuncs{ - AddFunc: func(_ interface{}) { c.recomputeAndNotifyAsync() }, - UpdateFunc: func(_, _ interface{}) { c.recomputeAndNotifyAsync() }, - DeleteFunc: func(_ interface{}) { c.recomputeAndNotifyAsync() }, + AddFunc: func(_ interface{}) { c.enqueueSnapshot() }, + UpdateFunc: func(_, _ interface{}) { c.enqueueSnapshot() }, + DeleteFunc: func(_ interface{}) { c.enqueueSnapshot() }, }, ancInformerResyncPeriod, ) @@ -98,62 +117,113 @@ func NewController( return c } -// EffectiveSecondaryOVSBridge returns the current effective bridge configuration -// from the informer cache and the latest known Node labels. It is safe to call -// concurrently with Run and informer callbacks. -// -// Before the Node and AntreaNodeConfig informer caches have synced, it returns -// nil so the secondary-network controller does not create a bridge from static -// ConfigMap data while AntreaNodeConfig objects are not yet visible (which would -// later be replaced by CR-driven reconcile). -func (c *Controller) EffectiveSecondaryOVSBridge() *agenttypes.OVSBridgeConfig { - if !c.nodeListerSynced() || !c.ancListerSynced() { +func (c *Controller) enqueueSnapshot() { + c.queue.Add(snapshotQueueKey) +} + +// InformersSynced reports whether both the Node and AntreaNodeConfig informer caches +// have completed an initial sync. +func (c *Controller) InformersSynced() bool { + return c.nodeListerSynced() && c.ancListerSynced() +} + +// getLocalNodeFromLister returns the Node for this agent's nodeName from the informer +// cache. If the Node is not present (NotFound), it returns (nil, nil) because an empty +// Node is valid snapshot input. Any other lister error is returned. +func (c *Controller) getLocalNodeFromLister() (*corev1.Node, error) { + node, err := c.nodeLister.Get(c.nodeName) + if err == nil { + return node, nil + } + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err +} + +// CurrentSnapshot returns a deep-copied snapshot of the local Node and the +// oldest AntreaNodeConfig that matches this Node's labels. It returns nil if +// informers are not synced yet. +func (c *Controller) CurrentSnapshot() *Snapshot { + if !c.InformersSynced() { return nil } - c.mu.RLock() - node := c.node - c.mu.RUnlock() - all, err := c.ancLister.List(labels.Everything()) - snap := ComputeEffectiveSnapshot(node, all, err, c.staticSecondaryNetworkCfg) - if snap == nil { + node, err := c.getLocalNodeFromLister() + if err != nil { + klog.ErrorS(err, "Failed to get local Node from lister for snapshot", "node", c.nodeName) return nil } - return snap.SecondaryOVSBridge + all, err := c.ancLister.List(labels.Everything()) + effective := antreaNodeConfigForSnapshot(node, all, err) + return NewSnapshot(node, effective, err) } -// Run waits for the AntreaNodeConfig and Node informer caches to sync, publishes -// the initial effective configuration, then blocks until stopCh is closed. +// Run waits for the AntreaNodeConfig and Node informer caches to sync, enqueues one +// snapshot reconciliation, then runs workers until stopCh is closed. The first +// successful syncSnapshot publishes a *Snapshot to notifier subscribers (including +// when no AntreaNodeConfig matches this Node: non-nil *Snapshot with nil +// AntreaNodeConfig). func (c *Controller) Run(stopCh <-chan struct{}) { klog.InfoS("Starting AntreaNodeConfig controller") defer klog.InfoS("Shutting down AntreaNodeConfig controller") + defer c.queue.ShutDown() + if !cache.WaitForNamedCacheSync("AntreaNodeConfigController", stopCh, c.nodeListerSynced, c.ancListerSynced) { return } - c.loadLocalNodeFromLister() - c.recomputeAndNotify() + c.enqueueSnapshot() + + for i := 0; i < defaultWorkers; i++ { + go wait.Until(func() { + for c.processNextWorkItem() { + } + }, time.Second, stopCh) + } <-stopCh } -// loadLocalNodeFromLister sets c.node from the shared Node informer cache after -// sync. Event handlers keep it current afterward. -func (c *Controller) loadLocalNodeFromLister() { - node, err := c.nodeLister.Get(c.nodeName) +func (c *Controller) processNextWorkItem() bool { + key, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(key) + + if err := c.syncSnapshot(key); err != nil { + c.queue.AddRateLimited(key) + klog.ErrorS(err, "Failed to sync AntreaNodeConfig snapshot", "key", key) + return true + } + c.queue.Forget(key) + return true +} + +// syncSnapshot builds a snapshot from informer listers and notifies subscribers +// when it differs from lastNotified. It is intended to run only from the workqueue worker. +func (c *Controller) syncSnapshot(key string) error { + _ = key + node, err := c.getLocalNodeFromLister() if err != nil { - if apierrors.IsNotFound(err) { - klog.InfoS("Local Node not present in informer cache after sync", "node", c.nodeName) - } else { - klog.ErrorS(err, "Failed to get local Node from informer lister", "node", c.nodeName) - } - c.mu.Lock() - c.node = nil - c.mu.Unlock() - return + return err } - c.mu.Lock() - c.node = node - c.mu.Unlock() + + all, listErr := c.ancLister.List(labels.Everything()) + effective := antreaNodeConfigForSnapshot(node, all, listErr) + next := NewSnapshot(node, effective, listErr) + + payload := next.DeepCopy() + if c.lastNotified != nil && reflect.DeepEqual(c.lastNotified, payload) { + return nil + } + + if !c.notifier.Notify(payload) { + return fmt.Errorf("notifier rejected AntreaNodeConfig snapshot update") + } + + c.lastNotified = payload + return nil } func (c *Controller) onNodeAdd(obj interface{}) { @@ -164,10 +234,7 @@ func (c *Controller) onNodeAdd(obj interface{}) { if node.Name != c.nodeName { return } - c.mu.Lock() - c.node = node - c.mu.Unlock() - c.recomputeAndNotifyAsync() + c.enqueueSnapshot() } func (c *Controller) onNodeUpdate(oldObj, newObj interface{}) { @@ -182,62 +249,65 @@ func (c *Controller) onNodeUpdate(oldObj, newObj interface{}) { if newNode.Name != c.nodeName { return } - c.mu.Lock() - c.node = newNode - c.mu.Unlock() if reflect.DeepEqual(oldNode.Labels, newNode.Labels) { return } - klog.V(2).InfoS("Local Node labels changed, recomputing AntreaNodeConfig-derived agent settings") - c.recomputeAndNotifyAsync() + klog.V(2).InfoS("Local Node labels changed, recomputing AntreaNodeConfig snapshot") + c.enqueueSnapshot() } -func (c *Controller) onNodeDelete(obj interface{}) { - node, ok := obj.(*corev1.Node) - if !ok { - tombstone, ok := obj.(cache.DeletedFinalStateUnknown) - if !ok { - return - } - n, ok := tombstone.Obj.(*corev1.Node) - if !ok { - return - } - node = n - } - if node.Name != c.nodeName { - return +// antreaNodeConfigForSnapshot returns the oldest AntreaNodeConfig that applies to +// node when the list succeeded; otherwise nil so subscribers do not act on a +// partial cluster view. +func antreaNodeConfigForSnapshot(node *corev1.Node, all []*crdv1alpha1.AntreaNodeConfig, listErr error) *crdv1alpha1.AntreaNodeConfig { + if listErr != nil { + return nil } - c.mu.Lock() - c.node = nil - c.mu.Unlock() - c.recomputeAndNotifyAsync() + return OldestMatchingAntreaNodeConfigForNode(node, all) } -func (c *Controller) recomputeAndNotifyAsync() { - go c.recomputeAndNotify() +// OldestMatchingAntreaNodeConfigForNode returns the AntreaNodeConfig whose +// nodeSelector matches the given Node's labels and has the oldest +// creationTimestamp (name breaks ties). It returns nil when node is nil, when no +// config matches, or when every matching config has an invalid nodeSelector. +func OldestMatchingAntreaNodeConfigForNode(node *corev1.Node, configs []*crdv1alpha1.AntreaNodeConfig) *crdv1alpha1.AntreaNodeConfig { + matched := SelectAntreaNodeConfigsForNode(node, configs) + if len(matched) == 0 { + return nil + } + return matched[0] } -func (c *Controller) recomputeAndNotify() { - c.mu.RLock() - node := c.node - c.mu.RUnlock() - all, err := c.ancLister.List(labels.Everything()) - next := ComputeEffectiveSnapshot(node, all, err, c.staticSecondaryNetworkCfg) - - // Compare and store using a deep copy so lastNotified is not aliased to - // memory that callers or the informer cache might reuse, and so future - // EffectiveSnapshot fields remain safe to extend. - payload := next.DeepCopy() - c.mu.Lock() - if c.lastNotified != nil && reflect.DeepEqual(c.lastNotified, payload) { - c.mu.Unlock() - return +// SelectAntreaNodeConfigsForNode returns AntreaNodeConfig objects whose nodeSelector +// matches the given Node's labels, sorted by creationTimestamp ascending (oldest +// first; name is used as a stable tiebreaker when timestamps are equal). +// Configs with an invalid nodeSelector are skipped with a log line. +func SelectAntreaNodeConfigsForNode(node *corev1.Node, configs []*crdv1alpha1.AntreaNodeConfig) []*crdv1alpha1.AntreaNodeConfig { + if node == nil { + return nil } - c.lastNotified = payload - c.mu.Unlock() - - if !c.notifier.Notify(payload) { - klog.Error("Failed to notify AntreaNodeConfig effective snapshot update; subscribers may be stale until next resync") + nodeLabels := labels.Set(node.Labels) + var matching []*crdv1alpha1.AntreaNodeConfig + for _, cfg := range configs { + if cfg == nil { + continue + } + sel, err := metav1.LabelSelectorAsSelector(&cfg.Spec.NodeSelector) + if err != nil { + klog.ErrorS(err, "Skipping AntreaNodeConfig with invalid nodeSelector", "config", cfg.Name) + continue + } + if sel.Matches(nodeLabels) { + matching = append(matching, cfg) + } } + sort.Slice(matching, func(i, j int) bool { + ti := matching[i].CreationTimestamp + tj := matching[j].CreationTimestamp + if ti.Equal(&tj) { + return matching[i].Name < matching[j].Name + } + return ti.Before(&tj) + }) + return matching } diff --git a/pkg/agent/antreanodeconfig/controller_test.go b/pkg/agent/antreanodeconfig/controller_test.go index ccb39025b19..2816e9a406d 100644 --- a/pkg/agent/antreanodeconfig/controller_test.go +++ b/pkg/agent/antreanodeconfig/controller_test.go @@ -23,19 +23,18 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" corev1informers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" - "k8s.io/client-go/tools/cache" crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" fakeversioned "antrea.io/antrea/v2/pkg/client/clientset/versioned/fake" crdinformers "antrea.io/antrea/v2/pkg/client/informers/externalversions" crdv1a1inf "antrea.io/antrea/v2/pkg/client/informers/externalversions/crd/v1alpha1" - agentconfig "antrea.io/antrea/v2/pkg/config/agent" ) const ( @@ -83,16 +82,6 @@ func testWorkerNode() *corev1.Node { } } -func testStaticSecondaryNet() *agentconfig.AgentConfig { - return &agentconfig.AgentConfig{ - SecondaryNetwork: agentconfig.SecondaryNetworkConfig{ - OVSBridges: []agentconfig.OVSBridgeConfig{ - {BridgeName: "br-static", PhysicalInterfaces: []string{"eth0"}}, - }, - }, - } -} - func testANC(name, bridge string) *crdv1alpha1.AntreaNodeConfig { return &crdv1alpha1.AntreaNodeConfig{ ObjectMeta: metav1.ObjectMeta{Name: name, CreationTimestamp: testANCBaseTime}, @@ -163,21 +152,38 @@ func newControllerTestEnv(t *testing.T, rec *notifyRecorder, node *corev1.Node, } stopCh, ancInf, nodeInf, kube := startTestInformers(t, node, ancObjs...) t.Cleanup(func() { close(stopCh) }) - c := NewController(ancInf, nodeInf, testLocalNodeName, testStaticSecondaryNet(), rec) + c := NewController(ancInf, nodeInf, testLocalNodeName, rec) return &controllerTestEnv{t: t, C: c, Rec: rec, Kube: kube} } +// drainQueue processes all work items currently queued by informer handlers (for example +// the initial sync after registering handlers on already-synced caches). +func (e *controllerTestEnv) drainQueue() { + e.t.Helper() + require.Eventually(e.t, func() bool { + if e.C.queue.Len() == 0 { + return true + } + if !e.C.processNextWorkItem() { + return e.C.queue.Len() == 0 + } + return false + }, 3*time.Second, 5*time.Millisecond, "workqueue should drain") +} + +// loadLocalNode drains queued snapshot work so tests start from a quiet baseline. func (e *controllerTestEnv) loadLocalNode() { e.t.Helper() - e.C.loadLocalNodeFromLister() + e.drainQueue() } func (e *controllerTestEnv) recompute() { e.t.Helper() - e.C.recomputeAndNotify() + e.C.enqueueSnapshot() + require.True(e.t, e.C.processNextWorkItem()) } -func TestLoadLocalNodeFromLister(t *testing.T) { +func TestLocalNodeFromLister(t *testing.T) { tests := []struct { name string node *corev1.Node @@ -189,13 +195,13 @@ func TestLoadLocalNodeFromLister(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { env := newControllerTestEnv(t, nil, tc.node) - env.loadLocalNode() - env.C.mu.RLock() - got := env.C.node - env.C.mu.RUnlock() + env.drainQueue() + got, err := env.C.nodeLister.Get(testLocalNodeName) if tc.wantNil { - assert.Nil(t, got) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err)) } else { + require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, testLocalNodeName, got.Name) } @@ -203,26 +209,28 @@ func TestLoadLocalNodeFromLister(t *testing.T) { } } -func TestEffectiveSecondaryOVSBridgeReturnsNilBeforeInformerSync(t *testing.T) { +func TestCurrentSnapshotNilBeforeInformerSync(t *testing.T) { rec := ¬ifyRecorder{} kube := fake.NewClientset(testWorkerNode()) nodeInf := informers.NewSharedInformerFactory(kube, 0).Core().V1().Nodes() crdClient := fakeversioned.NewSimpleClientset(testANC("a1", "br-anc")) crdInf := crdinformers.NewSharedInformerFactory(crdClient, 0).Crd().V1alpha1().AntreaNodeConfigs() - c := NewController(crdInf, nodeInf, testLocalNodeName, testStaticSecondaryNet(), rec) - // Informers are not started: caches are unsynced. Static secondary config - // must not be used while AntreaNodeConfig objects are not yet visible. - assert.Nil(t, c.EffectiveSecondaryOVSBridge()) + c := NewController(crdInf, nodeInf, testLocalNodeName, rec) + // Informers are not started: caches are unsynced. + assert.Nil(t, c.CurrentSnapshot()) } -func TestEffectiveSecondaryOVSBridgeUsesInformerCaches(t *testing.T) { +func TestCurrentSnapshotUsesInformerCaches(t *testing.T) { env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(testANC("a1", "br-anc"))...) - env.loadLocalNode() - - br := env.C.EffectiveSecondaryOVSBridge() - require.NotNil(t, br) - assert.Equal(t, "br-anc", br.BridgeName) + env.drainQueue() + + snap := env.C.CurrentSnapshot() + require.NotNil(t, snap) + require.NotNil(t, snap.AntreaNodeConfig) + require.NotNil(t, snap.AntreaNodeConfig.Spec.SecondaryNetwork) + require.Len(t, snap.AntreaNodeConfig.Spec.SecondaryNetwork.OVSBridges, 1) + assert.Equal(t, "br-anc", snap.AntreaNodeConfig.Spec.SecondaryNetwork.OVSBridges[0].BridgeName) } func TestRecomputeAndNotifyDedup(t *testing.T) { @@ -245,21 +253,32 @@ func TestRecomputeAndNotifyOnLabelChange(t *testing.T) { _, err := env.Kube.CoreV1().Nodes().Update(context.Background(), newNode, metav1.UpdateOptions{}) require.NoError(t, err) - require.Eventually(t, func() bool { return env.Rec.Len() >= 2 }, 2*time.Second, 10*time.Millisecond, + require.Eventually(t, func() bool { + n, e := env.C.nodeLister.Get(testLocalNodeName) + if e != nil || n.Labels["role"] != "other" { + return false + } + for env.C.queue.Len() > 0 { + if !env.C.processNextWorkItem() { + break + } + } + return env.Rec.Len() >= 2 + }, 2*time.Second, 10*time.Millisecond, "label change should trigger another notify") - last, ok := env.Rec.Last().(*EffectiveSnapshot) + last, ok := env.Rec.Last().(*Snapshot) require.True(t, ok) - require.NotNil(t, last.SecondaryOVSBridge) - assert.Equal(t, "br-static", last.SecondaryOVSBridge.BridgeName, "non-matching ANC should fall back to static") + require.NotNil(t, last.Node) + assert.Equal(t, "other", last.Node.Labels["role"], "snapshot should reflect updated Node labels") + assert.Nil(t, last.AntreaNodeConfig, "ANC matched worker role only; labels no longer match") } func TestNodeEventHandlersNoExtraNotify(t *testing.T) { otherNode := func() *corev1.Node { return &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "other-node", Labels: map[string]string{"a": "b"}}} } - // These handlers either no-op before touching the local Node or update c.node - // without calling recomputeAndNotifyAsync (same labels). All paths are - // synchronous — no synctest / sleep needed. + // Handlers either no-op or enqueue a snapshot reconcile. Drain after each act + // so queue-driven notifies are applied before asserting counts. tests := []struct { name string act func(t *testing.T, c *Controller) @@ -295,18 +314,6 @@ func TestNodeEventHandlersNoExtraNotify(t *testing.T) { c.onNodeUpdate(o, o2) }, }, - { - name: "OnNodeDelete wrong type ignored", - act: func(t *testing.T, c *Controller) { - c.onNodeDelete("not-a-node") - }, - }, - { - name: "OnNodeDelete different node ignored", - act: func(t *testing.T, c *Controller) { - c.onNodeDelete(&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "other-node"}}) - }, - }, { name: "OnNodeAdd wrong type ignored", act: func(t *testing.T, c *Controller) { @@ -321,6 +328,7 @@ func TestNodeEventHandlersNoExtraNotify(t *testing.T) { env.recompute() require.Equal(t, 1, env.Rec.Len()) tc.act(t, env.C) + env.drainQueue() assert.Equal(t, 1, env.Rec.Len()) }) } @@ -337,43 +345,25 @@ func TestRunReturnsWhenStopClosedWhileCachesNeverSynced(t *testing.T) { _ = nodeInf.Informer() _ = ancInf.Informer() // Intentionally do not Start factories: HasSynced stays false. - c := NewController(ancInf, nodeInf, testLocalNodeName, testStaticSecondaryNet(), rec) + c := NewController(ancInf, nodeInf, testLocalNodeName, rec) runStop := make(chan struct{}) close(runStop) c.Run(runStop) assert.Equal(t, 0, rec.Len(), "Run should exit when stopCh is closed before caches sync") } -func TestOnNodeDeleteTombstone(t *testing.T) { - env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(testANC("a1", "br-anc"))...) - env.loadLocalNode() - env.recompute() - require.Equal(t, 1, env.Rec.Len()) - - env.C.onNodeDelete(cache.DeletedFinalStateUnknown{ - Key: testLocalNodeName, - Obj: testWorkerNode(), - }) - - require.Eventually(t, func() bool { return env.Rec.Len() >= 2 }, 2*time.Second, 10*time.Millisecond) - env.C.mu.RLock() - n := env.C.node - env.C.mu.RUnlock() - assert.Nil(t, n) -} - -func TestRecomputeNotifyFailureStillStoresLastNotified(t *testing.T) { +func TestRecomputeNotifyFailureSkipsLastNotifiedUpdate(t *testing.T) { rec := ¬ifyRecorder{fail: true} env := newControllerTestEnv(t, rec, testWorkerNode()) - env.loadLocalNode() - env.recompute() - env.C.mu.RLock() - ln := env.C.lastNotified - env.C.mu.RUnlock() - require.NotNil(t, ln) - require.NotNil(t, ln.SecondaryOVSBridge) - assert.Equal(t, "br-static", ln.SecondaryOVSBridge.BridgeName) + assert.Nil(t, env.C.lastNotified, "lastNotified should reflect last successful notify only") + require.Error(t, env.C.syncSnapshot(snapshotQueueKey)) + + rec.fail = false + require.NoError(t, env.C.syncSnapshot(snapshotQueueKey)) + require.NotNil(t, env.C.lastNotified) + require.NotNil(t, env.C.lastNotified.Node) + assert.Empty(t, env.C.lastNotified.AntreaNodeConfigListError) } func TestControllerRunPublishesInitialSnapshot(t *testing.T) { @@ -387,11 +377,158 @@ func TestControllerRunPublishesInitialSnapshot(t *testing.T) { }() require.Eventually(t, func() bool { return env.Rec.Len() >= 1 }, 3*time.Second, 10*time.Millisecond) - snap, ok := env.Rec.Last().(*EffectiveSnapshot) + snap, ok := env.Rec.Last().(*Snapshot) require.True(t, ok) - require.NotNil(t, snap.SecondaryOVSBridge) - assert.Equal(t, "br-run", snap.SecondaryOVSBridge.BridgeName) + require.NotNil(t, snap.AntreaNodeConfig) + require.NotNil(t, snap.AntreaNodeConfig.Spec.SecondaryNetwork) + assert.Equal(t, "br-run", snap.AntreaNodeConfig.Spec.SecondaryNetwork.OVSBridges[0].BridgeName) close(runStop) wg.Wait() } + +func TestCurrentSnapshotOldestMatchWhenMultipleANC(t *testing.T) { + olderTS := metav1.NewTime(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + newerTS := metav1.NewTime(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + older := testANC("a-older", "br-old") + older.CreationTimestamp = olderTS + newer := testANC("a-newer", "br-new") + newer.CreationTimestamp = newerTS + + env := newControllerTestEnv(t, nil, testWorkerNode(), ancAsRuntime(newer, older)...) + env.drainQueue() + snap := env.C.CurrentSnapshot() + require.NotNil(t, snap) + require.NotNil(t, snap.AntreaNodeConfig) + assert.Equal(t, "a-older", snap.AntreaNodeConfig.Name) + assert.Equal(t, "br-old", snap.AntreaNodeConfig.Spec.SecondaryNetwork.OVSBridges[0].BridgeName) +} + +func matchTestNode(labels map[string]string) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: labels, + }, + } +} + +func matchTestANC(name string, ts time.Time, nodeSelector map[string]string) *crdv1alpha1.AntreaNodeConfig { + return &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + CreationTimestamp: metav1.NewTime(ts), + }, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{ + MatchLabels: nodeSelector, + }, + }, + } +} + +func TestSelectAntreaNodeConfigsForNode(t *testing.T) { + node := matchTestNode(map[string]string{"role": "worker", "zone": "us-east"}) + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Minute) + t2 := t0.Add(2 * time.Minute) + + anc1 := matchTestANC("anc1", t0, map[string]string{"role": "worker"}) + anc2 := matchTestANC("anc2", t1, map[string]string{"role": "control-plane"}) + anc3 := matchTestANC("anc3", t2, map[string]string{"zone": "us-east"}) + ancInvalidSel := &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid"}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "x", Operator: "BadOp", Values: []string{"v"}}, + }, + }, + }, + } + + tests := []struct { + name string + node *corev1.Node + configs []*crdv1alpha1.AntreaNodeConfig + wantLen int + wantOrder []string + }{ + { + name: "nil node", + node: nil, + configs: []*crdv1alpha1.AntreaNodeConfig{anc1}, + wantLen: 0, + }, + { + name: "no configs", + node: node, + configs: nil, + wantLen: 0, + }, + { + name: "one matching", + node: node, + configs: []*crdv1alpha1.AntreaNodeConfig{anc1}, + wantLen: 1, + }, + { + name: "one non-matching", + node: node, + configs: []*crdv1alpha1.AntreaNodeConfig{anc2}, + wantLen: 0, + }, + { + name: "two matching sorted oldest-first", + node: node, + configs: []*crdv1alpha1.AntreaNodeConfig{anc3, anc1}, + wantLen: 2, + wantOrder: []string{"anc1", "anc3"}, + }, + { + name: "invalid selector is skipped", + node: node, + configs: []*crdv1alpha1.AntreaNodeConfig{ancInvalidSel, anc1}, + wantLen: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := SelectAntreaNodeConfigsForNode(tc.node, tc.configs) + require.Len(t, got, tc.wantLen) + if tc.wantOrder != nil { + for i, name := range tc.wantOrder { + assert.Equal(t, name, got[i].Name) + } + } + }) + } +} + +func TestSelectAntreaNodeConfigsForNode_TimestampTiebreaker(t *testing.T) { + node := matchTestNode(map[string]string{"role": "worker"}) + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + ancA := matchTestANC("zzz", t0, map[string]string{"role": "worker"}) + ancB := matchTestANC("aaa", t0, map[string]string{"role": "worker"}) + + got := SelectAntreaNodeConfigsForNode(node, []*crdv1alpha1.AntreaNodeConfig{ancA, ancB}) + require.Len(t, got, 2) + assert.Equal(t, "aaa", got[0].Name, "alphabetically earlier name should sort first") + assert.Equal(t, "zzz", got[1].Name) +} + +func TestOldestMatchingAntreaNodeConfigForNode(t *testing.T) { + node := matchTestNode(map[string]string{"role": "worker"}) + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + ancOld := matchTestANC("old", t0, map[string]string{"role": "worker"}) + ancYoung := matchTestANC("young", t1, map[string]string{"role": "worker"}) + + assert.Nil(t, OldestMatchingAntreaNodeConfigForNode(nil, []*crdv1alpha1.AntreaNodeConfig{ancOld})) + assert.Nil(t, OldestMatchingAntreaNodeConfigForNode(node, nil)) + + got := OldestMatchingAntreaNodeConfigForNode(node, []*crdv1alpha1.AntreaNodeConfig{ancYoung, ancOld}) + require.NotNil(t, got) + assert.Equal(t, "old", got.Name) +} diff --git a/pkg/agent/antreanodeconfig/effective_snapshot.go b/pkg/agent/antreanodeconfig/effective_snapshot.go deleted file mode 100644 index 9f43ccb3678..00000000000 --- a/pkg/agent/antreanodeconfig/effective_snapshot.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2026 Antrea Authors -// -// 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 antreanodeconfig - -import ( - corev1 "k8s.io/api/core/v1" - - agenttypes "antrea.io/antrea/v2/pkg/agent/types" - crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" - agentconfig "antrea.io/antrea/v2/pkg/config/agent" -) - -// EffectiveSnapshot aggregates AntreaNodeConfig-derived settings for this Node -// that the agent exposes to SubscribableChannel subscribers. -// -// When new spec fields are added to AntreaNodeConfig and consumed by the agent, -// extend this struct and ComputeEffectiveSnapshot so the sync controller can -// detect changes to any derived sub-state, not only secondary networking. -type EffectiveSnapshot struct { - SecondaryOVSBridge *agenttypes.OVSBridgeConfig -} - -// DeepCopy returns a deep copy of the snapshot suitable for passing to -// channel subscribers. -func (s *EffectiveSnapshot) DeepCopy() *EffectiveSnapshot { - if s == nil { - return nil - } - out := &EffectiveSnapshot{} - if s.SecondaryOVSBridge != nil { - out.SecondaryOVSBridge = s.SecondaryOVSBridge.DeepCopy() - } - return out -} - -// ComputeEffectiveSnapshot builds the full derived state from the informer -// cache and static secondary-network YAML. Keep this the single place that -// maps ANC objects into agent-facing values as new ANC areas are added. -func ComputeEffectiveSnapshot( - node *corev1.Node, - ancConfigs []*crdv1alpha1.AntreaNodeConfig, - listErr error, - staticSecondaryNetworkCfg *agentconfig.SecondaryNetworkConfig, -) *EffectiveSnapshot { - return &EffectiveSnapshot{ - SecondaryOVSBridge: EffectiveSecondaryOVSBridge(node, ancConfigs, listErr, true, staticSecondaryNetworkCfg), - } -} diff --git a/pkg/agent/antreanodeconfig/effective_snapshot_test.go b/pkg/agent/antreanodeconfig/effective_snapshot_test.go deleted file mode 100644 index 549ee81ce42..00000000000 --- a/pkg/agent/antreanodeconfig/effective_snapshot_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2026 Antrea Authors -// -// 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 antreanodeconfig - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - agenttypes "antrea.io/antrea/v2/pkg/agent/types" -) - -func TestEffectiveSnapshotDeepCopy(t *testing.T) { - t.Run("nil receiver", func(t *testing.T) { - var s *EffectiveSnapshot - assert.Nil(t, s.DeepCopy()) - }) - - t.Run("empty bridge", func(t *testing.T) { - s := &EffectiveSnapshot{} - cp := s.DeepCopy() - require.NotNil(t, cp) - assert.Nil(t, cp.SecondaryOVSBridge) - }) - - t.Run("with bridge", func(t *testing.T) { - s := &EffectiveSnapshot{ - SecondaryOVSBridge: &agenttypes.OVSBridgeConfig{ - BridgeName: "br1", - PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ - {Name: "eth0", AllowedVLANs: []string{"100"}}, - }, - }, - } - cp := s.DeepCopy() - require.NotNil(t, cp) - require.NotNil(t, cp.SecondaryOVSBridge) - assert.NotSame(t, s.SecondaryOVSBridge, cp.SecondaryOVSBridge) - assert.Equal(t, s.SecondaryOVSBridge.BridgeName, cp.SecondaryOVSBridge.BridgeName) - cp.SecondaryOVSBridge.BridgeName = "mutated" - assert.Equal(t, "br1", s.SecondaryOVSBridge.BridgeName) - }) -} diff --git a/pkg/agent/antreanodeconfig/secondary_network.go b/pkg/agent/antreanodeconfig/secondary_network.go deleted file mode 100644 index 7963bf26b44..00000000000 --- a/pkg/agent/antreanodeconfig/secondary_network.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2026 Antrea Authors -// -// 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 antreanodeconfig - -import ( - corev1 "k8s.io/api/core/v1" - "k8s.io/klog/v2" - - agenttypes "antrea.io/antrea/v2/pkg/agent/types" - crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" - agentconfig "antrea.io/antrea/v2/pkg/config/agent" -) - -// EffectiveSecondaryOVSBridge returns the effective OVS bridge configuration for -// secondary networking on this Node. -// -// When useAntreaNodeConfig is false, AntreaNodeConfig objects are ignored and -// only staticCfg from the agent ConfigMap is used (rule 1). -// -// When useAntreaNodeConfig is true, staticCfg is only used after the Node and -// AntreaNodeConfig informer caches have synced (enforced in -// antreanodeconfig.Controller.EffectiveSecondaryOVSBridge) and the local Node -// object is known. That avoids treating a transient empty ANC list as “no CR” -// and applying static config before CRs are visible. -// -// When useAntreaNodeConfig is true and node is nil (Node not loaded yet), this -// function returns nil so static secondary-network config is not applied in -// place of AntreaNodeConfig. -// -// When useAntreaNodeConfig is true and node is non-nil, ancConfigs is the -// current list of AntreaNodeConfig objects from the informer cache. If listErr -// is non-nil, the static config is used after logging. Otherwise, when a -// matching AntreaNodeConfig specifies secondary network settings, those -// override staticCfg entirely (rule 2). When no matching config applies, or the -// winner has no bridge, the return value follows the same semantics as the -// previous resolveEffectiveBridgeConfig helper in the secondary network -// package. -func EffectiveSecondaryOVSBridge( - node *corev1.Node, - ancConfigs []*crdv1alpha1.AntreaNodeConfig, - listErr error, - useAntreaNodeConfig bool, - staticSecondaryNetworkCfg *agentconfig.SecondaryNetworkConfig, -) *agenttypes.OVSBridgeConfig { - if useAntreaNodeConfig && node == nil { - return nil - } - if useAntreaNodeConfig && node != nil { - if listErr != nil { - klog.ErrorS(listErr, "Failed to list AntreaNodeConfigs, falling back to static config") - } else { - effective := SelectAndApplySecondaryNetworkConfigs(node, ancConfigs) - if effective != nil { - if effective.OVSBridge != nil { - klog.V(2).InfoS("Using AntreaNodeConfig secondary network config", "bridge", effective.OVSBridge.BridgeName) - } - return effective.OVSBridge - } - } - } - - if staticSecondaryNetworkCfg == nil || len(staticSecondaryNetworkCfg.OVSBridges) == 0 { - return nil - } - b := staticSecondaryNetworkCfg.OVSBridges[0] - bridge := &agenttypes.OVSBridgeConfig{ - BridgeName: b.BridgeName, - EnableMulticastSnooping: b.EnableMulticastSnooping, - } - for _, iface := range b.PhysicalInterfaces { - bridge.PhysicalInterfaces = append(bridge.PhysicalInterfaces, agenttypes.PhysicalInterfaceConfig{Name: iface}) - } - return bridge -} diff --git a/pkg/agent/antreanodeconfig/secondary_network_test.go b/pkg/agent/antreanodeconfig/secondary_network_test.go deleted file mode 100644 index 81b995cc0be..00000000000 --- a/pkg/agent/antreanodeconfig/secondary_network_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2026 Antrea Authors -// -// 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 antreanodeconfig - -import ( - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - agenttypes "antrea.io/antrea/v2/pkg/agent/types" - crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" - agentconfig "antrea.io/antrea/v2/pkg/config/agent" -) - -func TestEffectiveSecondaryOVSBridge(t *testing.T) { - staticCfg := &agentconfig.SecondaryNetworkConfig{ - OVSBridges: []agentconfig.OVSBridgeConfig{ - {BridgeName: "br-static", PhysicalInterfaces: []string{"eth0"}}, - }, - } - emptyCfg := &agentconfig.SecondaryNetworkConfig{} - - workerNode := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node1", Labels: map[string]string{"role": "worker"}}} - - ancTime0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - ancMatchingWithBridge := &crdv1alpha1.AntreaNodeConfig{ - ObjectMeta: metav1.ObjectMeta{Name: "anc1", CreationTimestamp: metav1.NewTime(ancTime0)}, - Spec: crdv1alpha1.AntreaNodeConfigSpec{ - NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "worker"}}, - SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{ - { - BridgeName: "br-anc", - PhysicalInterfaces: []crdv1alpha1.OVSPhysicalInterfaceConfig{ - {Name: "eth1", AllowedVLANs: []string{"100"}}, - }, - }, - }, - }, - }, - } - ancMatchingNoBridge := &crdv1alpha1.AntreaNodeConfig{ - ObjectMeta: metav1.ObjectMeta{Name: "anc2", CreationTimestamp: metav1.NewTime(ancTime0)}, - Spec: crdv1alpha1.AntreaNodeConfigSpec{ - NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "worker"}}, - SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{}, - }, - } - ancNonMatching := &crdv1alpha1.AntreaNodeConfig{ - ObjectMeta: metav1.ObjectMeta{Name: "anc3", CreationTimestamp: metav1.NewTime(ancTime0)}, - Spec: crdv1alpha1.AntreaNodeConfigSpec{ - NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "control-plane"}}, - SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{ - OVSBridges: []crdv1alpha1.OVSBridgeConfig{{BridgeName: "br-other"}}, - }, - }, - } - - tests := []struct { - name string - node *corev1.Node - ancConfigs []*crdv1alpha1.AntreaNodeConfig - listErr error - useANC bool - staticCfg *agentconfig.SecondaryNetworkConfig - wantBridge *agenttypes.OVSBridgeConfig - }{ - { - name: "rule 1: AntreaNodeConfig disabled, use static config", - node: workerNode, - useANC: false, - staticCfg: staticCfg, - wantBridge: &agenttypes.OVSBridgeConfig{BridgeName: "br-static", PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: "eth0"}}}, - }, - { - name: "rule 1: no matching ANC, use static config", - node: workerNode, - ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancNonMatching}, - useANC: true, - staticCfg: staticCfg, - wantBridge: &agenttypes.OVSBridgeConfig{BridgeName: "br-static", PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: "eth0"}}}, - }, - { - name: "rule 1: empty static config and no matching ANC", - node: workerNode, - ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancNonMatching}, - useANC: true, - staticCfg: emptyCfg, - wantBridge: nil, - }, - { - name: "rule 2: matching ANC overrides static config", - node: workerNode, - ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, - useANC: true, - staticCfg: staticCfg, - wantBridge: &agenttypes.OVSBridgeConfig{ - BridgeName: "br-anc", - PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ - {Name: "eth1", AllowedVLANs: []string{"100"}}, - }, - }, - }, - { - name: "rule 2: matching ANC with no bridge yields nil", - node: workerNode, - ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingNoBridge}, - useANC: true, - staticCfg: staticCfg, - wantBridge: nil, - }, - { - name: "nil node returns nil when ANC enabled — do not prefer static over CR", - node: nil, - ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, - useANC: true, - staticCfg: staticCfg, - wantBridge: nil, - }, - { - name: "list error falls back to static config", - node: workerNode, - ancConfigs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, - listErr: errors.New("informer list failed"), - useANC: true, - staticCfg: staticCfg, - wantBridge: &agenttypes.OVSBridgeConfig{BridgeName: "br-static", PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: "eth0"}}}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := EffectiveSecondaryOVSBridge(tc.node, tc.ancConfigs, tc.listErr, tc.useANC, tc.staticCfg) - if tc.wantBridge == nil { - assert.Nil(t, got) - } else { - require.NotNil(t, got) - assert.Equal(t, tc.wantBridge.BridgeName, got.BridgeName) - assert.Equal(t, tc.wantBridge.EnableMulticastSnooping, got.EnableMulticastSnooping) - assert.Equal(t, tc.wantBridge.PhysicalInterfaces, got.PhysicalInterfaces) - } - }) - } -} diff --git a/pkg/agent/antreanodeconfig/snapshot.go b/pkg/agent/antreanodeconfig/snapshot.go new file mode 100644 index 00000000000..37f5b443590 --- /dev/null +++ b/pkg/agent/antreanodeconfig/snapshot.go @@ -0,0 +1,65 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + corev1 "k8s.io/api/core/v1" + + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" +) + +// Snapshot is an immutable view of the local Node and the AntreaNodeConfig that +// applies to this Node (nodeSelector match, oldest creationTimestamp wins), as +// computed by the AntreaNodeConfig controller. Subscribers use it together with +// feature-specific static configuration. +type Snapshot struct { + Node *corev1.Node + // AntreaNodeConfig is a deep copy of the effective AntreaNodeConfig for this + // Node at snapshot build time, or nil when none matches or the list failed. + AntreaNodeConfig *crdv1alpha1.AntreaNodeConfig + // AntreaNodeConfigListError is err.Error() from the lister List call when non-empty. + AntreaNodeConfigListError string +} + +// NewSnapshot returns a snapshot with deep-copied API objects suitable for +// passing to subscribers and for reflect.DeepEqual deduplication. +func NewSnapshot(node *corev1.Node, antreaNodeConfig *crdv1alpha1.AntreaNodeConfig, listErr error) *Snapshot { + s := &Snapshot{} + if listErr != nil { + s.AntreaNodeConfigListError = listErr.Error() + } + if node != nil { + s.Node = node.DeepCopy() + } + if antreaNodeConfig != nil { + s.AntreaNodeConfig = antreaNodeConfig.DeepCopy() + } + return s +} + +// DeepCopy returns a deep copy of the snapshot. +func (s *Snapshot) DeepCopy() *Snapshot { + if s == nil { + return nil + } + out := &Snapshot{AntreaNodeConfigListError: s.AntreaNodeConfigListError} + if s.Node != nil { + out.Node = s.Node.DeepCopy() + } + if s.AntreaNodeConfig != nil { + out.AntreaNodeConfig = s.AntreaNodeConfig.DeepCopy() + } + return out +} diff --git a/pkg/agent/antreanodeconfig/snapshot_test.go b/pkg/agent/antreanodeconfig/snapshot_test.go new file mode 100644 index 00000000000..6199eb9a8b4 --- /dev/null +++ b/pkg/agent/antreanodeconfig/snapshot_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 Antrea Authors +// +// 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 antreanodeconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" +) + +func TestSnapshotDeepCopy(t *testing.T) { + t.Run("nil receiver", func(t *testing.T) { + var s *Snapshot + assert.Nil(t, s.DeepCopy()) + }) + + t.Run("node and AntreaNodeConfig", func(t *testing.T) { + node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "n1", Labels: map[string]string{"k": "v"}}} + anc := &crdv1alpha1.AntreaNodeConfig{ObjectMeta: metav1.ObjectMeta{Name: "a1"}} + s := NewSnapshot(node, anc, nil) + cp := s.DeepCopy() + require.NotNil(t, cp) + assert.NotSame(t, s.Node, cp.Node) + assert.NotSame(t, s.AntreaNodeConfig, cp.AntreaNodeConfig) + cp.Node.Labels["k"] = "mutated" + assert.Equal(t, "v", s.Node.Labels["k"]) + }) +} diff --git a/pkg/agent/secondarynetwork/anc_bridge.go b/pkg/agent/secondarynetwork/anc_bridge.go new file mode 100644 index 00000000000..4c487b5f2fb --- /dev/null +++ b/pkg/agent/secondarynetwork/anc_bridge.go @@ -0,0 +1,115 @@ +// Copyright 2026 Antrea Authors +// +// 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 secondarynetwork + +import ( + "errors" + + "k8s.io/klog/v2" + + "antrea.io/antrea/v2/pkg/agent/antreanodeconfig" + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" +) + +// EffectiveSecondaryOVSBridgeFromStatic returns the secondary OVS bridge from agent static +// ConfigMap settings only. Used when AntreaNodeConfig does not drive the secondary bridge. +func EffectiveSecondaryOVSBridgeFromStatic(staticCfg *agentconfig.SecondaryNetworkConfig) *agenttypes.OVSBridgeConfig { + return ovsBridgeFromStatic(staticCfg) +} + +// EffectiveSecondaryOVSBridgeFromSnapshot resolves the desired secondary OVS bridge from an +// immutable *antreanodeconfig.Snapshot (for example the payload on the AntreaNodeConfig notify +// channel) merged with static agent ConfigMap settings. +// +// When snap is nil or snap.Node is nil, nil is returned (no bridge from this snapshot). +// +// When the snapshot records a non-empty AntreaNodeConfigListError, staticCfg is used +// after logging. Otherwise, when the oldest matching AntreaNodeConfig specifies secondary +// network settings, those override staticCfg; when it does not, staticCfg is used. +func EffectiveSecondaryOVSBridgeFromSnapshot(snap *antreanodeconfig.Snapshot, staticCfg *agentconfig.SecondaryNetworkConfig) *agenttypes.OVSBridgeConfig { + if snap == nil { + return nil + } + if snap.Node == nil { + return nil + } + if snap.AntreaNodeConfigListError != "" { + klog.ErrorS(errors.New(snap.AntreaNodeConfigListError), "Failed to list AntreaNodeConfigs, falling back to static config") + return ovsBridgeFromStatic(staticCfg) + } + effective := ApplySecondaryNetworkConfig(snap.AntreaNodeConfig) + if effective != nil { + if effective.OVSBridge != nil { + klog.V(2).InfoS("Using AntreaNodeConfig secondary network config", "bridge", effective.OVSBridge.BridgeName) + } + return effective.OVSBridge + } + return ovsBridgeFromStatic(staticCfg) +} + +func ovsBridgeFromStatic(staticCfg *agentconfig.SecondaryNetworkConfig) *agenttypes.OVSBridgeConfig { + if staticCfg == nil || len(staticCfg.OVSBridges) == 0 { + return nil + } + b := staticCfg.OVSBridges[0] + bridge := &agenttypes.OVSBridgeConfig{ + BridgeName: b.BridgeName, + EnableMulticastSnooping: b.EnableMulticastSnooping, + } + for _, iface := range b.PhysicalInterfaces { + bridge.PhysicalInterfaces = append(bridge.PhysicalInterfaces, agenttypes.PhysicalInterfaceConfig{Name: iface}) + } + return bridge +} + +// ApplySecondaryNetworkConfig derives the effective SecondaryNetworkConfig from the +// AntreaNodeConfig carried in the snapshot (the oldest matching object for the Node). +// It returns nil when cfg is nil or does not specify SecondaryNetwork, so static +// agent config stays in effect. +func ApplySecondaryNetworkConfig(cfg *crdv1alpha1.AntreaNodeConfig) *agenttypes.SecondaryNetworkConfig { + if cfg == nil || cfg.Spec.SecondaryNetwork == nil { + return nil + } + converted := convertCRDSecondaryNetwork(cfg.Spec.SecondaryNetwork, cfg.ObjectMeta.Name) + return &converted +} + +// convertCRDSecondaryNetwork converts from the CRD type to SecondaryNetworkConfig. +// The CRD schema enforces at most one OVS bridge; OVSBridge is nil when the +// list is empty or the sole bridge has an empty name (treated as unspecified). +func convertCRDSecondaryNetwork(in *crdv1alpha1.SecondaryNetworkConfig, antreaNodeConfigName string) agenttypes.SecondaryNetworkConfig { + if len(in.OVSBridges) == 0 { + return agenttypes.SecondaryNetworkConfig{} + } + b := in.OVSBridges[0] + if b.BridgeName == "" { + klog.ErrorS(errors.New("empty OVS bridge name"), "Ignoring AntreaNodeConfig secondary network config with empty bridge name", "antreaNodeConfig", antreaNodeConfigName) + return agenttypes.SecondaryNetworkConfig{} + } + bridge := &agenttypes.OVSBridgeConfig{ + BridgeName: b.BridgeName, + EnableMulticastSnooping: b.EnableMulticastSnooping, + } + for _, iface := range b.PhysicalInterfaces { + pi := agenttypes.PhysicalInterfaceConfig{Name: iface.Name} + if len(iface.AllowedVLANs) > 0 { + pi.AllowedVLANs = append(pi.AllowedVLANs, iface.AllowedVLANs...) + } + bridge.PhysicalInterfaces = append(bridge.PhysicalInterfaces, pi) + } + return agenttypes.SecondaryNetworkConfig{OVSBridge: bridge} +} diff --git a/pkg/agent/secondarynetwork/anc_bridge_test.go b/pkg/agent/secondarynetwork/anc_bridge_test.go new file mode 100644 index 00000000000..39a1b63df81 --- /dev/null +++ b/pkg/agent/secondarynetwork/anc_bridge_test.go @@ -0,0 +1,436 @@ +// Copyright 2026 Antrea Authors +// +// 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 secondarynetwork + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" + + "antrea.io/antrea/v2/pkg/agent/antreanodeconfig" + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" + fakeversioned "antrea.io/antrea/v2/pkg/client/clientset/versioned/fake" + crdinformers "antrea.io/antrea/v2/pkg/client/informers/externalversions" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" + "antrea.io/antrea/v2/pkg/util/channel" +) + +const ancBridgeTestLocalNodeName = "node1" + +// fakeANCController mirrors egress.fakeController: fake clients and informer factories +// around a real *antreanodeconfig.Controller. Call start from within t.Run after construction +// so informers and Run lifetimes match the subtest (see egress_controller_test.go). +type fakeANCController struct { + *antreanodeconfig.Controller + + crdInformerFactory crdinformers.SharedInformerFactory + kubeInformerFactory informers.SharedInformerFactory + + stopCh chan struct{} + fixtureNode *corev1.Node +} + +// newANCBridgeTestNotifier mirrors cniserver.newAsyncWaiter: a SubscribableChannel implements +// channel.Notifier and must have Run started so Notify does not block when the buffer fills. +func newANCBridgeTestNotifier(stopCh chan struct{}) *channel.SubscribableChannel { + n := channel.NewSubscribableChannel("ANCBridgeTest", 100) + n.Subscribe(func(interface{}) {}) + go n.Run(stopCh) + return n +} + +// newFakeANCController wires fake Node + AntreaNodeConfig API objects and informers like +// egress.newFakeController; it does not start informers or Run until start is called. +func newFakeANCController(t *testing.T, node *corev1.Node, ancObjs []*crdv1alpha1.AntreaNodeConfig) *fakeANCController { + t.Helper() + rt := make([]runtime.Object, len(ancObjs)) + for i := range ancObjs { + rt[i] = ancObjs[i] + } + stopCh := make(chan struct{}) + notifier := newANCBridgeTestNotifier(stopCh) + crdClient := fakeversioned.NewSimpleClientset(rt...) + var kubeObjs []runtime.Object + if node != nil { + kubeObjs = append(kubeObjs, node) + } + kubeClient := fake.NewClientset(kubeObjs...) + kubeFactory := informers.NewSharedInformerFactory(kubeClient, 0) + crdFactory := crdinformers.NewSharedInformerFactory(crdClient, 0) + nodeInformer := kubeFactory.Core().V1().Nodes() + ancInformer := crdFactory.Crd().V1alpha1().AntreaNodeConfigs() + _ = nodeInformer.Informer() + _ = ancInformer.Informer() + c := antreanodeconfig.NewController(ancInformer, nodeInformer, ancBridgeTestLocalNodeName, notifier) + return &fakeANCController{ + Controller: c, + crdInformerFactory: crdFactory, + kubeInformerFactory: kubeFactory, + stopCh: stopCh, + fixtureNode: node, + } +} + +// start starts informer factories, runs the AntreaNodeConfig controller, and waits until +// CurrentSnapshot reflects fixtureNode (non-nil Node object in API → non-nil snap.Node; +// nil fixture node → snap.Node nil after Run). +func (f *fakeANCController) start(t *testing.T) { + t.Helper() + f.kubeInformerFactory.Start(f.stopCh) + f.crdInformerFactory.Start(f.stopCh) + nodeInformer := f.kubeInformerFactory.Core().V1().Nodes() + ancInformer := f.crdInformerFactory.Crd().V1alpha1().AntreaNodeConfigs() + require.Eventually(t, func() bool { + return nodeInformer.Informer().HasSynced() && ancInformer.Informer().HasSynced() + }, 5*time.Second, 5*time.Millisecond, "informer caches should sync") + + runDone := make(chan struct{}) + go func() { + f.Run(f.stopCh) + close(runDone) + }() + t.Cleanup(func() { + close(f.stopCh) + <-runDone + }) + + require.Eventually(t, func() bool { + snap := f.CurrentSnapshot() + if snap == nil { + return false + } + if f.fixtureNode != nil { + return snap.Node != nil + } + return snap.Node == nil + }, 5*time.Second, 10*time.Millisecond, "controller should expose snapshot after Run") +} + +func TestEffectiveSecondaryOVSBridge(t *testing.T) { + staticCfg := &agentconfig.SecondaryNetworkConfig{ + OVSBridges: []agentconfig.OVSBridgeConfig{ + {BridgeName: "br-static", PhysicalInterfaces: []string{"eth0"}}, + }, + } + emptyCfg := &agentconfig.SecondaryNetworkConfig{} + + workerNode := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: ancBridgeTestLocalNodeName, Labels: map[string]string{"role": "worker"}}} + + ancTime0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + ancMatchingWithBridge := &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "anc1", CreationTimestamp: metav1.NewTime(ancTime0)}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "worker"}}, + SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + { + BridgeName: "br-anc", + PhysicalInterfaces: []crdv1alpha1.OVSPhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100"}}, + }, + }, + }, + }, + }, + } + ancMatchingNoBridge := &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "anc2", CreationTimestamp: metav1.NewTime(ancTime0)}, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{MatchLabels: map[string]string{"role": "worker"}}, + SecondaryNetwork: &crdv1alpha1.SecondaryNetworkConfig{}, + }, + } + wantStaticBridge := &agenttypes.OVSBridgeConfig{ + BridgeName: "br-static", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: "eth0"}}, + } + + tests := []struct { + name string + noANCController bool + node *corev1.Node + ancObjs []*crdv1alpha1.AntreaNodeConfig + staticCfg *agentconfig.SecondaryNetworkConfig + wantBridge *agenttypes.OVSBridgeConfig + }{ + { + name: "rule 1: AntreaNodeConfig disabled, use static config", + noANCController: true, + staticCfg: staticCfg, + wantBridge: wantStaticBridge, + }, + { + name: "rule 1: no ANC in snapshot, use static config", + node: workerNode, + staticCfg: staticCfg, + wantBridge: wantStaticBridge, + }, + { + name: "rule 1: empty static config and no ANC in snapshot", + node: workerNode, + staticCfg: emptyCfg, + wantBridge: nil, + }, + { + name: "rule 2: matching ANC overrides static config", + node: workerNode, + ancObjs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, + staticCfg: staticCfg, + wantBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br-anc", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth1", AllowedVLANs: []string{"100"}}, + }, + }, + }, + { + name: "rule 2: matching ANC with no bridge yields nil", + node: workerNode, + ancObjs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingNoBridge}, + staticCfg: staticCfg, + wantBridge: nil, + }, + { + name: "nil node returns nil when ANC enabled — do not prefer static over CR", + node: nil, + ancObjs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, + staticCfg: staticCfg, + wantBridge: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var got *agenttypes.OVSBridgeConfig + if tc.noANCController { + got = EffectiveSecondaryOVSBridgeFromStatic(tc.staticCfg) + } else { + fc := newFakeANCController(t, tc.node, tc.ancObjs) + fc.start(t) + got = EffectiveSecondaryOVSBridgeFromSnapshot(fc.CurrentSnapshot(), tc.staticCfg) + } + if tc.wantBridge == nil { + assert.Nil(t, got) + } else { + require.NotNil(t, got) + assert.Equal(t, tc.wantBridge.BridgeName, got.BridgeName) + assert.Equal(t, tc.wantBridge.EnableMulticastSnooping, got.EnableMulticastSnooping) + assert.Equal(t, tc.wantBridge.PhysicalInterfaces, got.PhysicalInterfaces) + } + }) + } +} + +func TestEffectiveOVSBridgeFromSnapshot_ListErrorFallsBackToStatic(t *testing.T) { + staticCfg := &agentconfig.SecondaryNetworkConfig{ + OVSBridges: []agentconfig.OVSBridgeConfig{ + {BridgeName: "br-static", PhysicalInterfaces: []string{"eth0"}}, + }, + } + node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: ancBridgeTestLocalNodeName}} + snap := antreanodeconfig.NewSnapshot(node, nil, errors.New("informer list failed")) + got := EffectiveSecondaryOVSBridgeFromSnapshot(snap, staticCfg) + require.NotNil(t, got) + assert.Equal(t, "br-static", got.BridgeName) +} + +func makeANC(name string, ts time.Time, nodeSelector map[string]string, secNet *crdv1alpha1.SecondaryNetworkConfig) *crdv1alpha1.AntreaNodeConfig { + return &crdv1alpha1.AntreaNodeConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + CreationTimestamp: metav1.NewTime(ts), + }, + Spec: crdv1alpha1.AntreaNodeConfigSpec{ + NodeSelector: metav1.LabelSelector{ + MatchLabels: nodeSelector, + }, + SecondaryNetwork: secNet, + }, + } +} + +func makeBridge(name string, mcast bool, ifaces ...crdv1alpha1.OVSPhysicalInterfaceConfig) crdv1alpha1.OVSBridgeConfig { + return crdv1alpha1.OVSBridgeConfig{ + BridgeName: name, + EnableMulticastSnooping: mcast, + PhysicalInterfaces: ifaces, + } +} + +func makeIface(name string, vlans ...string) crdv1alpha1.OVSPhysicalInterfaceConfig { + return crdv1alpha1.OVSPhysicalInterfaceConfig{Name: name, AllowedVLANs: vlans} +} + +var ( + t0 = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + t2 = t0.Add(2 * time.Minute) +) + +func TestApplySecondaryNetworkConfig(t *testing.T) { + secNet1 := &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0")), + }, + } + anc1 := makeANC("anc1", t0, nil, secNet1) + ancNoSec := makeANC("ancNoSec", t2, nil, nil) + + tests := []struct { + name string + cfg *crdv1alpha1.AntreaNodeConfig + want *agenttypes.SecondaryNetworkConfig + }{ + { + name: "nil cfg", + cfg: nil, + want: nil, + }, + { + name: "nil SecondaryNetwork", + cfg: ancNoSec, + want: nil, + }, + { + name: "with SecondaryNetwork", + cfg: anc1, + want: &agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", EnableMulticastSnooping: false, + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0"}, + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ApplySecondaryNetworkConfig(tc.cfg) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestConvertCRDSecondaryNetwork(t *testing.T) { + const testANCName = "test-antrea-node-config" + + tests := []struct { + name string + in *crdv1alpha1.SecondaryNetworkConfig + antreaNodeConfigName string + want agenttypes.SecondaryNetworkConfig + }{ + { + name: "empty bridges yields nil OVSBridge", + in: &crdv1alpha1.SecondaryNetworkConfig{}, + antreaNodeConfigName: testANCName, + want: agenttypes.SecondaryNetworkConfig{}, + }, + { + name: "empty bridge name yields nil OVSBridge", + in: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + {BridgeName: "", PhysicalInterfaces: []crdv1alpha1.OVSPhysicalInterfaceConfig{{Name: "eth0"}}}, + }, + }, + antreaNodeConfigName: "anc-empty-bridge-name", + want: agenttypes.SecondaryNetworkConfig{}, + }, + { + name: "interface without AllowedVLANs has nil AllowedVLANs in output", + in: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0")), + }, + }, + antreaNodeConfigName: testANCName, + want: agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0", AllowedVLANs: nil}, + }, + }, + }, + }, + { + name: "AllowedVLANs are preserved", + in: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0", "100", "200-300")), + }, + }, + antreaNodeConfigName: testANCName, + want: agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0", AllowedVLANs: []string{"100", "200-300"}}, + }, + }, + }, + }, + { + name: "multicast snooping flag is preserved", + in: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", true), + }, + }, + antreaNodeConfigName: testANCName, + want: agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", + EnableMulticastSnooping: true, + }, + }, + }, + { + name: "bridge with multiple interfaces", + in: &crdv1alpha1.SecondaryNetworkConfig{ + OVSBridges: []crdv1alpha1.OVSBridgeConfig{ + makeBridge("br0", false, makeIface("eth0"), makeIface("eth1", "10")), + }, + }, + antreaNodeConfigName: testANCName, + want: agenttypes.SecondaryNetworkConfig{ + OVSBridge: &agenttypes.OVSBridgeConfig{ + BridgeName: "br0", + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ + {Name: "eth0", AllowedVLANs: nil}, + {Name: "eth1", AllowedVLANs: []string{"10"}}, + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := convertCRDSecondaryNetwork(tc.in, tc.antreaNodeConfigName) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/agent/secondarynetwork/init.go b/pkg/agent/secondarynetwork/init.go index 120ed87660f..33d65759bae 100644 --- a/pkg/agent/secondarynetwork/init.go +++ b/pkg/agent/secondarynetwork/init.go @@ -15,8 +15,10 @@ package secondarynetwork import ( + "errors" "fmt" "sync" + "sync/atomic" "time" "github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb" @@ -27,6 +29,7 @@ import ( componentbaseconfig "k8s.io/component-base/config" "k8s.io/klog/v2" + "antrea.io/antrea/v2/pkg/agent/antreanodeconfig" "antrea.io/antrea/v2/pkg/agent/config" "antrea.io/antrea/v2/pkg/agent/interfacestore" "antrea.io/antrea/v2/pkg/agent/secondarynetwork/podwatch" @@ -67,9 +70,17 @@ type Controller struct { nodeName string ovsdbConn *ovsdb.OVSDB - // effectiveBridgeFn returns the desired OVS bridge configuration (from static - // agent config and, when enabled, AntreaNodeConfig via the controller). - effectiveBridgeFn func() *agenttypes.OVSBridgeConfig + // latestANCSnapshot is the last *antreanodeconfig.Snapshot received on the ANC + // notify channel. + latestANCSnapshot atomic.Pointer[antreanodeconfig.Snapshot] + // effectiveBridgeOverride is set only by unit tests to stub desired bridge resolution. + effectiveBridgeOverride func() *agenttypes.OVSBridgeConfig + + // ancFirstSnapshotCh is closed when the first *Snapshot is delivered after ANC + // informers have synced (including the no-ANC case: non-nil *Snapshot with nil + // AntreaNodeConfig). Only used when dynamicBridgeReconcile is true. + ancFirstSnapshotCh chan struct{} + signalFirstANC sync.Once // mu protects effectiveBridgeCfg for atomic point-in-time reads and writes. // It must never be held across blocking OVS calls. @@ -96,16 +107,35 @@ func NewController( secNetConfig *agentconfig.SecondaryNetworkConfig, ovsdbConn *ovsdb.OVSDB, ipPoolLister crdlisters.IPPoolLister, - effectiveBridgeFn func() *agenttypes.OVSBridgeConfig, ancUpdateSubscriber channel.Subscriber, ) (*Controller, error) { - if effectiveBridgeFn == nil { - return nil, fmt.Errorf("effectiveBridge must not be nil") + dynamic := ancUpdateSubscriber != nil + c := &Controller{ + ovsBridgeClient: nil, + secNetConfig: secNetConfig, + podController: nil, + nodeName: nodeConfig.Name, + ovsdbConn: ovsdbConn, + dynamicBridgeReconcile: dynamic, + queue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), + workqueue.TypedRateLimitingQueueConfig[string]{Name: "secondaryNetworkBridge"}, + ), + } + if dynamic { + c.ancFirstSnapshotCh = make(chan struct{}) } - effectiveBridgeCfg, ovsBridgeClient, err := resolveAndCreateOVSBridge(effectiveBridgeFn, ovsdbConn) - if err != nil { - return nil, err + var effectiveBridgeCfg *agenttypes.OVSBridgeConfig + var ovsBridgeClient ovsconfig.OVSBridgeClient + var err error + if dynamic { + effectiveBridgeCfg, ovsBridgeClient = nil, nil + } else { + effectiveBridgeCfg, ovsBridgeClient, err = resolveAndCreateOVSBridge(c.effectiveOVSBridge, ovsdbConn) + if err != nil { + return nil, err + } } netAttachDefClient, err := createNetworkAttachDefClient(clientConnectionConfig, kubeAPIServerOverride) @@ -120,26 +150,24 @@ func NewController( return nil, err } - dynamicBridgeReconcile := ancUpdateSubscriber != nil - c := &Controller{ - ovsBridgeClient: ovsBridgeClient, - secNetConfig: secNetConfig, - effectiveBridgeCfg: effectiveBridgeCfg, - podController: podWatchController, - nodeName: nodeConfig.Name, - effectiveBridgeFn: effectiveBridgeFn, - ovsdbConn: ovsdbConn, - dynamicBridgeReconcile: dynamicBridgeReconcile, - queue: workqueue.NewTypedRateLimitingQueueWithConfig( - workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), - workqueue.TypedRateLimitingQueueConfig[string]{Name: "secondaryNetworkBridge"}, - ), - } + c.ovsBridgeClient = ovsBridgeClient + c.secNetConfig = secNetConfig + c.effectiveBridgeCfg = effectiveBridgeCfg + c.podController = podWatchController - if dynamicBridgeReconcile { - // Notify payloads are *antreanodeconfig.EffectiveSnapshot; this controller - // reconciles from effectiveBridge() so it only needs the wakeup. - ancUpdateSubscriber.Subscribe(func(_ interface{}) { + if c.dynamicBridgeReconcile { + ancUpdateSubscriber.Subscribe(func(p interface{}) { + snap, ok := p.(*antreanodeconfig.Snapshot) + if !ok { + klog.ErrorS(errors.New("unexpected notify payload"), "AntreaNodeConfig notify payload", "type", fmt.Sprintf("%T", p)) + return + } + if snap == nil { + klog.ErrorS(errors.New("nil snapshot from notifier"), "AntreaNodeConfig notify payload") + return + } + c.latestANCSnapshot.Store(snap) + c.signalFirstANC.Do(func() { close(c.ancFirstSnapshotCh) }) c.enqueue() }) } @@ -147,16 +175,61 @@ func NewController( return c, nil } +// effectiveOVSBridge returns the desired OVS bridge for this node. When AntreaNodeConfig +// drives the bridge, only snapshots delivered on the notify channel are used. +// When ANC is disabled, only static agent config is consulted. +func (c *Controller) effectiveOVSBridge() *agenttypes.OVSBridgeConfig { + if c.effectiveBridgeOverride != nil { + return c.effectiveBridgeOverride() + } + if c.dynamicBridgeReconcile { + return EffectiveSecondaryOVSBridgeFromSnapshot(c.latestANCSnapshot.Load(), c.secNetConfig) + } + return EffectiveSecondaryOVSBridgeFromStatic(c.secNetConfig) +} + +// WaitForInitialANCSnapshotAndEnsureBridge blocks until the AntreaNodeConfig controller +// publishes the first *Snapshot after Node and ANC informers have synced (the same +// notify path as later updates). That snapshot may carry nil AntreaNodeConfig when no +// CR matches this Node. It then creates the OVS bridge if needed and updates the pod +// watcher. +// +// The agent calls this once before Initialize when AntreaNodeConfig drives the bridge; +// on error the agent exits and restarts, so this path does not retry or support +// concurrent callers. +func (c *Controller) WaitForInitialANCSnapshotAndEnsureBridge(stopCh <-chan struct{}) error { + if !c.dynamicBridgeReconcile { + return nil + } + select { + case <-c.ancFirstSnapshotCh: + case <-stopCh: + return fmt.Errorf("interrupted while waiting for initial AntreaNodeConfig snapshot") + } + effectiveBridgeCfg, ovsBridgeClient, err := resolveAndCreateOVSBridge(c.effectiveOVSBridge, c.ovsdbConn) + if err != nil { + return err + } + c.mu.Lock() + c.effectiveBridgeCfg = effectiveBridgeCfg + c.ovsBridgeClient = ovsBridgeClient + c.mu.Unlock() + if err := c.podController.UpdateOVSBridge(ovsBridgeClient); err != nil { + return err + } + klog.InfoS("Secondary network bridge bootstrapped from initial AntreaNodeConfig snapshot") + return nil +} + // enqueue adds the single reconciliation key to the work queue. func (c *Controller) enqueue() { c.queue.Add(reconcileKey) } // Run starts the secondary network controller. When AntreaNodeConfig is -// enabled, a bridge reconciliation worker processes items enqueued by the ANC -// SubscribableChannel (the AntreaNodeConfig controller only notifies after its -// informers have synced). When ANC is off, the bridge is static and no worker -// is started. +// enabled, the agent must have called WaitForInitialANCSnapshotAndEnsureBridge before +// Initialize; a bridge reconciliation worker then processes items enqueued by the ANC +// SubscribableChannel. When ANC is off, the bridge is static and no worker is started. func (c *Controller) Run(stopCh <-chan struct{}) { defer c.queue.ShutDown() diff --git a/pkg/agent/secondarynetwork/init_linux.go b/pkg/agent/secondarynetwork/init_linux.go index 86de83c4c96..08a92c4cadf 100644 --- a/pkg/agent/secondarynetwork/init_linux.go +++ b/pkg/agent/secondarynetwork/init_linux.go @@ -216,10 +216,13 @@ func (c *Controller) reconcileBridge() error { prev := c.effectiveBridgeCfg c.mu.RUnlock() - desired := c.effectiveBridgeFn() + desired := c.effectiveOVSBridge() + if prev == nil && desired == nil { + return nil + } // No change — nothing to do. - if reflect.DeepEqual(prev, desired) { + if prev != nil && desired != nil && reflect.DeepEqual(*prev, *desired) { return nil } @@ -228,20 +231,7 @@ func (c *Controller) reconcileBridge() error { // Case: no bridge desired — delete the existing one. if desired == nil { - if err := c.deleteBridge(prev); err != nil { - return err - } - // Clear state immediately after successful deletion so that a retry - // does not attempt to delete an already-removed bridge. - c.mu.Lock() - c.effectiveBridgeCfg = nil - c.ovsBridgeClient = nil - c.mu.Unlock() - // Notify PodController that the bridge is gone. - if err := c.podController.UpdateOVSBridge(nil); err != nil { - return err - } - return nil + return c.deleteAndDisconnectBridge(prev) } // Case: new bridge desired when none existed before. @@ -257,14 +247,9 @@ func (c *Controller) reconcileBridge() error { if prev.BridgeName != desired.BridgeName { klog.InfoS("Secondary OVS bridge name changed, deleting old bridge before creating new one", "old", prev.BridgeName, "new", desired.BridgeName) - if err := c.deleteBridge(prev); err != nil { + if err := c.deleteAndDisconnectBridge(prev); err != nil { return err } - // Old bridge is gone — clear state before proceeding. - c.mu.Lock() - c.effectiveBridgeCfg = nil - c.ovsBridgeClient = nil - c.mu.Unlock() return c.createAndConnectBridge(desired) } @@ -276,6 +261,26 @@ func (c *Controller) reconcileBridge() error { return c.updatePhysicalInterfaces(prev, desired) } +func (c *Controller) clearBridgeState() { + c.mu.Lock() + c.effectiveBridgeCfg = nil + c.ovsBridgeClient = nil + c.mu.Unlock() +} + +// deleteAndDisconnectBridge deletes the OVS bridge for cfg, notifies the pod controller that +// no secondary bridge is in use, and clears controller state. cfg may be nil (no-op delete). +func (c *Controller) deleteAndDisconnectBridge(cfg *agenttypes.OVSBridgeConfig) error { + if err := c.deleteBridge(cfg); err != nil { + return err + } + if err := c.podController.UpdateOVSBridge(nil); err != nil { + return err + } + c.clearBridgeState() + return nil +} + // deleteBridge tears down the single-interface host connection (if applicable) and deletes the // OVS bridge. func (c *Controller) deleteBridge(cfg *agenttypes.OVSBridgeConfig) error { diff --git a/pkg/agent/secondarynetwork/init_linux_test.go b/pkg/agent/secondarynetwork/init_linux_test.go index 62d75064ed6..ce58e8df492 100644 --- a/pkg/agent/secondarynetwork/init_linux_test.go +++ b/pkg/agent/secondarynetwork/init_linux_test.go @@ -470,8 +470,9 @@ func TestReconcileBridge(t *testing.T) { {Name: eth2, IFName: eth2, Trunks: nil}, }, nil).Times(1) }, - wantNewClient: true, - wantUpdateBridgeN: 1, + wantNewClient: true, + // UpdateOVSBridge(nil) after old bridge deleted, then UpdateOVSBridge(new) from createAndConnectBridge. + wantUpdateBridgeN: 2, }, { // When the old ANC CR stops matching (e.g. Node labels change) and a new ANC with a @@ -495,8 +496,9 @@ func TestReconcileBridge(t *testing.T) { {Name: eth2, IFName: eth2, Trunks: nil}, }, nil).Times(1) }, - wantNewClient: true, - wantUpdateBridgeN: 1, + wantNewClient: true, + // UpdateOVSBridge(nil) after old bridge deleted, then UpdateOVSBridge(new) from createAndConnectBridge. + wantUpdateBridgeN: 2, }, { name: "rule 3: same bridge name — add new interface", @@ -733,12 +735,12 @@ func TestReconcileBridge(t *testing.T) { fakePc := &fakePodController{} desiredCfg := tc.desiredCfg c := &Controller{ - ovsBridgeClient: oldMock, - secNetConfig: &agentconfig.SecondaryNetworkConfig{}, - effectiveBridgeCfg: tc.prevCfg, - effectiveBridgeFn: func() *agenttypes.OVSBridgeConfig { return desiredCfg }, - ovsdbConn: nil, - podController: fakePc, + ovsBridgeClient: oldMock, + secNetConfig: &agentconfig.SecondaryNetworkConfig{}, + effectiveBridgeCfg: tc.prevCfg, + effectiveBridgeOverride: func() *agenttypes.OVSBridgeConfig { return desiredCfg }, + ovsdbConn: nil, + podController: fakePc, } err := c.reconcileBridge() @@ -819,12 +821,12 @@ func TestReconcileBridgeStateCleared(t *testing.T) { } c := &Controller{ - ovsBridgeClient: oldMock, - secNetConfig: &agentconfig.SecondaryNetworkConfig{}, - effectiveBridgeCfg: prevCfg, - effectiveBridgeFn: func() *agenttypes.OVSBridgeConfig { return desired }, - ovsdbConn: nil, - podController: &fakePodController{}, + ovsBridgeClient: oldMock, + secNetConfig: &agentconfig.SecondaryNetworkConfig{}, + effectiveBridgeCfg: prevCfg, + effectiveBridgeOverride: func() *agenttypes.OVSBridgeConfig { return desired }, + ovsdbConn: nil, + podController: &fakePodController{}, } capturedController = c From 75d862c9f214044a15ee804a136bb0048276229f Mon Sep 17 00:00:00 2001 From: Lan Luo Date: Mon, 27 Apr 2026 10:01:18 +0000 Subject: [PATCH 10/13] Add e2e for AntreaNodeConfigs with SecondaryNetwork NodePool Support Signed-off-by: Lan Luo --- ci/kind/kind-setup.sh | 14 +- ci/kind/test-secondary-network-kind.sh | 207 +++++++++++++++++- .../infra/antrea-node-configs-linux.yml | 13 ++ .../infra/antrea-node-configs-nodepools.yml | 35 +++ .../infra/secondary-networks.yml | 85 +++++++ .../secondary_network_test.go | 165 +++++++++++++- 6 files changed, 498 insertions(+), 21 deletions(-) create mode 100644 test/e2e-secondary-network/infra/antrea-node-configs-linux.yml create mode 100644 test/e2e-secondary-network/infra/antrea-node-configs-nodepools.yml diff --git a/ci/kind/kind-setup.sh b/ci/kind/kind-setup.sh index d86d238cf41..c7e96750a95 100755 --- a/ci/kind/kind-setup.sh +++ b/ci/kind/kind-setup.sh @@ -310,6 +310,14 @@ function configure_extra_networks { fi echo "Configuring extra networks" + # Extra Docker networks must not become the container default gateway (Docker picks the + # highest --gw-priority; the Kind cluster network stays at implicit 0 on eth0). Without this, + # names like antrea--0 can win over network "kind" and default via eth1. + local -a extra_gw_priority=() + if [[ -n "${docker_version:-}" ]] && version_ge "$docker_version" "28.0.0"; then + extra_gw_priority=(--gw-priority -1000) + fi + # create new bridge networks i=0 networks=() @@ -324,12 +332,12 @@ function configure_extra_networks { nodes="$(kind get nodes --name $cluster_name)" for node in $nodes; do i=1 - for network in $networks; do + for network in "${networks[@]}"; do ifname="eth$i" - docker network connect --driver-opt=com.docker.network.endpoint.ifname=$ifname $network $node + docker network connect "${extra_gw_priority[@]}" --driver-opt=com.docker.network.endpoint.ifname=$ifname "$network" "$node" echo "connected worker $node to network $network" + i=$((i+1)) done - i=$((i+1)) done } diff --git a/ci/kind/test-secondary-network-kind.sh b/ci/kind/test-secondary-network-kind.sh index e1ba30f4a2b..3356a821901 100755 --- a/ci/kind/test-secondary-network-kind.sh +++ b/ci/kind/test-secondary-network-kind.sh @@ -35,13 +35,25 @@ THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" TESTBED_CMD=$THIS_DIR"/kind-setup.sh" ATTACHMENT_DEFINITION_YAML=$THIS_DIR"/../../test/e2e-secondary-network/infra/network-attachment-definition-crd.yml" SECONDARY_NETWORKS_YAML=$THIS_DIR"/../../test/e2e-secondary-network/infra/secondary-networks.yml" +ANTREA_NODE_CONFIGS_LINUX_YAML=$THIS_DIR"/../../test/e2e-secondary-network/infra/antrea-node-configs-linux.yml" +ANTREA_NODE_CONFIGS_NODEPOOL_YAML=$THIS_DIR"/../../test/e2e-secondary-network/infra/antrea-node-configs-nodepools.yml" ANTREA_CHART="$THIS_DIR/../../build/charts/antrea" +# JSON for helm --set-json (keep in a single-quoted variable to avoid nested quote/EOF parse issues). +SECONDARY_OVS_BRIDGES_JSON='[{"bridgeName":"br-secondary","physicalInterfaces":["eth1"]}]' + TIMEOUT="5m" # Antrea is deployed by this script. Do not deploy it again in the test. -TEST_OPTIONS="--logs-export-dir=$ANTREA_LOG_DIR --deploy-antrea=false" +ANTREA_LOG_DIR="${ANTREA_LOG_DIR:-${PWD}/log}" +mkdir -p "$ANTREA_LOG_DIR" +TEST_OPTIONS=(--logs-export-dir="$ANTREA_LOG_DIR" --deploy-antrea=false) EXTRA_NETWORK="20.20.20.0/24" +# One control-plane plus this many workers (total nodes = NUM_WORKERS + 1). +NUM_WORKERS=3 +# Label the first two and last two nodes (sorted by name) for pool scheduling tests. +NODEPOOL_LABEL_KEY="antrea.io/node-pool" + setup_only=false cleanup_only=false test_only=false @@ -93,7 +105,7 @@ if [[ $cleanup_only == "true" ]];then exit 0 fi -trap "quit" INT EXIT +# trap "quit" INT EXIT IMAGE_LIST=("antrea/toolbox:1.5-1" \ "antrea/antrea-agent-ubuntu:latest" \ @@ -107,28 +119,203 @@ function setup_cluster { eval "timeout 600 $TESTBED_CMD create kind --ip-family dual $args" } +function docker_bridge_for_network { + local net_name="$1" + local opt_name nid + opt_name="$(docker network inspect "$net_name" -f '{{index .Options "com.docker.network.bridge.name"}}' 2>/dev/null || true)" + if [[ -n "$opt_name" ]]; then + echo "$opt_name" + return 0 + fi + nid="$(docker network inspect "$net_name" -f '{{.Id}}')" + echo "br-${nid:0:12}" +} + +# Docker 28+: --gw-priority (highest wins default GW). Use negative on secondary nets so Kind eth0 stays default. +function docker_network_connect_lab_iface { + local ifname="$1" net="$2" node="$3" + local -a args=(--driver-opt=com.docker.network.endpoint.ifname="$ifname") + if docker network connect --help 2>&1 | grep -q '[[:space:]]--gw-priority[[:space:]]'; then + args+=(--gw-priority -1000) + fi + docker network connect "${args[@]}" "$net" "$node" +} + +# List interface names enslaved to a Linux bridge (same view as /sys/class/net/
/brif). +function kind_linux_bridge_port_names { + local br="$1" + ip link show master "$br" 2>/dev/null | sed -n 's/^[0-9]\+: \([^@[:space:]]\+\)@.*/\1/p' +} + +# After Kind nodes are connected: program bridge self and each slave port for lab VLAN IDs. +# First VLAN in the list is the port PVID: untagged Docker / host RX maps to that VLAN (pvid). +# Do not use "untagged" on the PVID: that adds "Egress Untagged" in bridge vlan show and makes the +# lab VLAN egress untagged on the veth; we want trunk-style egress (tagged) while still classifying +# untagged RX to the lab VID. Remaining VLANs are tagged trunks only. +function kind_bridge_apply_lab_vlans { + local br="$1" + shift + local -a vids=("$@") + if [[ "${#vids[@]}" -eq 0 ]]; then + return 0 + fi + local pvid="${vids[0]}" + local vid port + + bridge vlan add dev "$br" vid "$pvid" pvid self 2>/dev/null || true + for vid in "${vids[@]}"; do + [[ "$vid" == "$pvid" ]] && continue + bridge vlan add dev "$br" vid "$vid" self 2>/dev/null || true + done + while IFS= read -r port; do + [[ -n "$port" ]] || continue + bridge vlan add dev "$port" vid "$pvid" pvid 2>/dev/null || true + for vid in "${vids[@]}"; do + [[ "$vid" == "$pvid" ]] && continue + bridge vlan add dev "$port" vid "$vid" 2>/dev/null || true + done + done < <(kind_linux_bridge_port_names "$br") + + # With vlan_filtering enabled, the kernel / Docker typically leaves VID 1 as egress-untagged on + # bridge ports even after we set the lab PVID (100/300). Remove VID 1 when the lab PVID is not 1. + if [[ "$pvid" -ne 1 ]]; then + bridge vlan del dev "$br" vid 1 self 2>/dev/null || true + while IFS= read -r port; do + [[ -n "$port" ]] || continue + bridge vlan del dev "$port" vid 1 2>/dev/null || true + done < <(kind_linux_bridge_port_names "$br") + fi +} + +function prepare_cluster_nodes { + # This is for testing AntreaNodeConfigs with label selector support. + echo "Removing control-plane NoSchedule taint so Pods can schedule on control-plane Nodes" + for n in $(kubectl get nodes -l node-role.kubernetes.io/control-plane -o jsonpath='{.items[*].metadata.name}' 2>/dev/null); do + kubectl taint nodes "$n" node-role.kubernetes.io/control-plane:NoSchedule- 2>/dev/null || true + kubectl taint nodes "$n" node-role.kubernetes.io/master:NoSchedule- 2>/dev/null || true + done + + echo "Labeling two Nodes with ${NODEPOOL_LABEL_KEY}=pool1 and two with ${NODEPOOL_LABEL_KEY}=pool2" + mapfile -t _sn_nodes < <(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | LC_ALL=C sort) + if [[ "${#_sn_nodes[@]}" -lt 4 ]]; then + echoerr "Expected at least 4 Nodes for pool labeling, got ${#_sn_nodes[@]}" + exit 1 + fi + kubectl label node "${_sn_nodes[0]}" "${NODEPOOL_LABEL_KEY}=pool1" --overwrite + kubectl label node "${_sn_nodes[1]}" "${NODEPOOL_LABEL_KEY}=pool1" --overwrite + kubectl label node "${_sn_nodes[2]}" "${NODEPOOL_LABEL_KEY}=pool2" --overwrite + kubectl label node "${_sn_nodes[3]}" "${NODEPOOL_LABEL_KEY}=pool2" --overwrite +} + function run_test { - echo "deploying Antrea to the kind cluster" + echo "Deploying Antrea to the kind cluster" # Create a secondary OVS bridge with the Node's physical interface on the extra - # network. - helm install antrea $ANTREA_CHART --namespace kube-system \ + # network. Use upgrade --install so --test-only re-runs do not fail when the + # release already exists. + helm upgrade --install antrea "$ANTREA_CHART" --namespace kube-system \ --set featureGates.SecondaryNetwork=true,featureGates.AntreaIPAM=true \ - --set-json secondaryNetwork.ovsBridges='[{"bridgeName": "br-secondary", "physicalInterfaces": ["eth1"]}]' + --set-json secondaryNetwork.ovsBridges="$SECONDARY_OVS_BRIDGES_JSON" + + echo "Waiting for all Nodes to be Ready" + kubectl wait --for=condition=Ready nodes --all --timeout=10m # Wait for antrea-controller start to make sure the IPPool validation webhook is ready. kubectl rollout status --timeout=1m deployment.apps/antrea-controller -n kube-system # An extra small delay to reduce the possibility of failure in CI. sleep 5 - kubectl apply -f $ATTACHMENT_DEFINITION_YAML - kubectl apply -f $SECONDARY_NETWORKS_YAML + kubectl apply -f "$ATTACHMENT_DEFINITION_YAML" + kubectl apply -f "$SECONDARY_NETWORKS_YAML" + + go test -v -timeout="$TIMEOUT" antrea.io/antrea/v2/test/e2e-secondary-network -run=TestVLANNetwork -provider=kind "${TEST_OPTIONS[@]}" + run_test_with_antrenodeconfigs +} - go test -v -timeout=$TIMEOUT antrea.io/antrea/v2/test/e2e-secondary-network -run=TestVLANNetwork -provider=kind $TEST_OPTIONS +# Lab-style VLAN-aware bridges: create Docker bridge networks (Docker owns the Linux bridge), enable +# vlan_filtering on those bridges, connect every Kind node container with fixed interface names eth2/eth3, +# then program bridge VLAN on bridge self and each veth. Requires root for ip/bridge; +# Assumes eth2/eth3 are free inside nodes (single antrea--0 extra net → only eth1 in use). +function kind_extra_network_with_vlan_awareness_bridges { + # Union of VLAN IDs referenced in test/e2e-secondary-network/infra/antrea-node-configs-nodepools.yml + # (pool1 eth2: 100,101; pool1 eth3: 300; pool2 eth2: 100; pool2 eth3: 400). + KIND_EXTRA_BRIDGE_1_VLAN_IDS=(100 101) # eth2 + KIND_EXTRA_BRIDGE_2_VLAN_IDS=(300 400) # eth3 + # Kind cluster name passed to kind-setup.sh create (must match docker networks antrea--). + KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" + + if [[ "$(id -u)" -ne 0 ]]; then + echoerr "kind_extra_network_with_vlan_awareness_bridges requires root (sudo)." + return 1 + fi + + local net_eth2="kind-vlan-lab-eth2" + local net_eth3="kind-vlan-lab-eth3" + + if ! docker network inspect "$net_eth2" &>/dev/null; then + docker network create -d bridge \ + --subnet="20.20.21.0/24" \ + --gateway="20.20.21.1" \ + "$net_eth2" + fi + if ! docker network inspect "$net_eth3" &>/dev/null; then + docker network create -d bridge \ + --subnet="20.20.22.0/24" \ + --gateway="20.20.22.1" \ + "$net_eth3" + fi + + local br_eth2 br_eth3 + br_eth2="$(docker_bridge_for_network "$net_eth2")" + br_eth3="$(docker_bridge_for_network "$net_eth3")" + + ip link set "$br_eth2" up 2>/dev/null + ip link set "$br_eth3" up 2>/dev/null + ip link set "$br_eth2" type bridge vlan_filtering 1 + ip link set "$br_eth3" type bridge vlan_filtering 1 + + local node node_nets + for node in $(kind get nodes --name "$KIND_CLUSTER_NAME"); do + node_nets="$(docker inspect "$node" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null || true)" + if [[ " ${node_nets} " != *" ${net_eth2} "* ]]; then + docker_network_connect_lab_iface eth2 "$net_eth2" "$node" + echo "connected $node to Docker network $net_eth2 as eth2" + else + echo "$node already on $net_eth2" + fi + node_nets="$(docker inspect "$node" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null || true)" + if [[ " ${node_nets} " != *" ${net_eth3} "* ]]; then + docker_network_connect_lab_iface eth3 "$net_eth3" "$node" + echo "connected $node to Docker network $net_eth3 as eth3" + else + echo "$node already on $net_eth3" + fi + done + + kind_bridge_apply_lab_vlans "$br_eth2" "${KIND_EXTRA_BRIDGE_1_VLAN_IDS[@]}" + kind_bridge_apply_lab_vlans "$br_eth3" "${KIND_EXTRA_BRIDGE_2_VLAN_IDS[@]}" +} + +function run_test_with_antrenodeconfigs { + echo "Start testing AntreaNodeConfig with label selector support" + echo "Applying AntreaNodeConfig for Linux nodes with a new secondary OVS bridge name br1" + kubectl apply -f "$ANTREA_NODE_CONFIGS_LINUX_YAML" + sleep 5 + go test -v -timeout="$TIMEOUT" antrea.io/antrea/v2/test/e2e-secondary-network -run=TestVLANNetwork -provider=kind "${TEST_OPTIONS[@]}" + echo "Clean up the previous AntreaNodeConfig" + kubectl delete -f "$ANTREA_NODE_CONFIGS_LINUX_YAML" + sleep 5 + kind_extra_network_with_vlan_awareness_bridges + echo "Apply new AntreaNodeConfigs for NodePool 1 and NodePool 2 nodes" + kubectl apply -f "$ANTREA_NODE_CONFIGS_NODEPOOL_YAML" + sleep 5 + go test -v -timeout="$TIMEOUT" antrea.io/antrea/v2/test/e2e-secondary-network \ + -run='TestNodePoolsVLANNetwork' -provider=kind "${TEST_OPTIONS[@]}" } echo "======== Testing Antrea-native secondary network support ==========" if [[ "$test_only" == "false" ]];then cleanup_stale_kind - setup_cluster "--extra-networks \"$EXTRA_NETWORK\" --images \"$IMAGES\" --num-workers 1" + setup_cluster "--extra-networks \"$EXTRA_NETWORK\" --images \"$IMAGES\" --num-workers $NUM_WORKERS" fi +prepare_cluster_nodes run_test exit 0 diff --git a/test/e2e-secondary-network/infra/antrea-node-configs-linux.yml b/test/e2e-secondary-network/infra/antrea-node-configs-linux.yml new file mode 100644 index 00000000000..501dc6061de --- /dev/null +++ b/test/e2e-secondary-network/infra/antrea-node-configs-linux.yml @@ -0,0 +1,13 @@ +apiVersion: crd.antrea.io/v1alpha1 +kind: AntreaNodeConfig +metadata: + name: secondary-network-node-pool-all-linux +spec: + nodeSelector: + matchLabels: + kubernetes.io/os: "linux" + secondaryNetwork: + ovsBridges: + - bridgeName: br1 + physicalInterfaces: + - name: eth1 diff --git a/test/e2e-secondary-network/infra/antrea-node-configs-nodepools.yml b/test/e2e-secondary-network/infra/antrea-node-configs-nodepools.yml new file mode 100644 index 00000000000..fc9d67c02f7 --- /dev/null +++ b/test/e2e-secondary-network/infra/antrea-node-configs-nodepools.yml @@ -0,0 +1,35 @@ +apiVersion: crd.antrea.io/v1alpha1 +kind: AntreaNodeConfig +metadata: + name: secondary-network-node-pool-1 +spec: + nodeSelector: + matchLabels: + antrea.io/node-pool: "pool1" + secondaryNetwork: + ovsBridges: + - bridgeName: br1 + physicalInterfaces: + - name: eth2 + allowedVLANs: ["100", "101"] + - name: eth3 + allowedVLANs: ["300"] + enableMulticastSnooping: false +--- +apiVersion: crd.antrea.io/v1alpha1 +kind: AntreaNodeConfig +metadata: + name: secondary-network-node-pool-2 +spec: + nodeSelector: + matchLabels: + antrea.io/node-pool: "pool2" + secondaryNetwork: + ovsBridges: + - bridgeName: br1 + physicalInterfaces: + - name: eth2 + allowedVLANs: ["100"] + - name: eth3 + allowedVLANs: ["400"] + enableMulticastSnooping: false \ No newline at end of file diff --git a/test/e2e-secondary-network/infra/secondary-networks.yml b/test/e2e-secondary-network/infra/secondary-networks.yml index f776d82add7..69d372411c4 100644 --- a/test/e2e-secondary-network/infra/secondary-networks.yml +++ b/test/e2e-secondary-network/infra/secondary-networks.yml @@ -169,3 +169,88 @@ spec: "ippools": ["secnet-ipv4-3", "secnet-ipv6-3"] } }' + +--- +# VLAN 300 on eth3 for AntreaNodeConfig pool1 (see antrea-node-configs-nodepools.yml). +apiVersion: crd.antrea.io/v1beta1 +kind: IPPool +metadata: + name: secnet-ipv4-pool1-vlan300 +spec: + ipRanges: + - cidr: "148.14.28.0/24" + subnetInfo: + gateway: "148.14.28.1" + prefixLength: 24 + vlan: 300 +--- +apiVersion: "k8s.cni.cncf.io/v1" +kind: NetworkAttachmentDefinition +metadata: + name: vlan-net-pool1-eth3-300 +spec: + config: '{ + "cniVersion": "0.3.0", + "type": "antrea", + "networkType": "vlan", + "ipam": { + "type": "antrea", + "ippools": ["secnet-ipv4-pool1-vlan300"] + } + }' +--- +# VLAN 400 on eth3 for AntreaNodeConfig pool2 (see antrea-node-configs-nodepools.yml). +apiVersion: crd.antrea.io/v1beta1 +kind: IPPool +metadata: + name: secnet-ipv4-pool2-vlan400 +spec: + ipRanges: + - cidr: "148.14.27.0/24" + subnetInfo: + gateway: "148.14.27.1" + prefixLength: 24 +--- +apiVersion: "k8s.cni.cncf.io/v1" +kind: NetworkAttachmentDefinition +metadata: + name: vlan-net-pool2-eth3-400 +spec: + config: '{ + "cniVersion": "0.3.0", + "type": "antrea", + "networkType": "vlan", + "vlan": 400, + "ipam": { + "type": "antrea", + "ippools": ["secnet-ipv4-pool2-vlan400"] + } + }' +--- +# Shared VLAN 100 on eth2 for cross-pool connectivity (pool1 and pool2 both allow VLAN 100 on eth2). +apiVersion: crd.antrea.io/v1beta1 +kind: IPPool +metadata: + name: cross-pool-vlan100-eth2 +spec: + ipRanges: + - cidr: "172.27.100.0/24" + subnetInfo: + gateway: "172.27.100.1" + prefixLength: 24 +--- +apiVersion: "k8s.cni.cncf.io/v1" +kind: NetworkAttachmentDefinition +metadata: + name: vlan-net-cross-pool-eth2-vlan100 +spec: + config: '{ + "cniVersion": "0.3.0", + "type": "antrea", + "networkType": "vlan", + "vlan": 100, + "ipam": { + "type": "antrea", + "ippools": ["cross-pool-vlan100-eth2"] + } + }' \ No newline at end of file diff --git a/test/e2e-secondary-network/secondary_network_test.go b/test/e2e-secondary-network/secondary_network_test.go index 5374a605204..0d4e91a6e7d 100644 --- a/test/e2e-secondary-network/secondary_network_test.go +++ b/test/e2e-secondary-network/secondary_network_test.go @@ -19,6 +19,7 @@ import ( "encoding/json" "fmt" "net" + "sort" "strings" "testing" "time" @@ -34,6 +35,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" "antrea.io/antrea/v2/pkg/agent/cniserver" antreae2e "antrea.io/antrea/v2/test/e2e" @@ -62,6 +64,11 @@ const ( // Namespace of NetworkAttachmentDefinition CRs. attachDefNamespace = "default" ipPoolNamespace = "default" + // nodePoolLabelKey matches ci/kind/test-secondary-network-kind.sh NODEPOOL_LABEL_KEY. + nodePoolLabelKey = "antrea.io/node-pool" + // ovsPrimaryBridge is Antrea's default primary OVS bridge; all other OVS bridges on the + // Node are treated as secondary for list-ports. + ovsPrimaryBridge = "br-int" secondaryOVSBridge = "br-secondary" containerName = "toolbox" @@ -451,16 +458,73 @@ func (data *testData) checkIPReleased(ipPools map[string][]string, ifacesIPv4 ma }) } -func (data *testData) getOVSPortsOnSecondaryBridge(t *testing.T, nodeName string) ([]string, error) { - cmd := []string{"ovs-vsctl", "list-ports", secondaryOVSBridge} +// getSecondaryOVSBridgeName returns the OVS bridge used for native secondary networks by listing +// bridges and excluding ovsPrimaryBridge (br-int). When AntreaNodeConfig sets bridgeName to +// br1 (or any single non-integration bridge), that name is returned. +func (data *testData) getSecondaryOVSBridgeName(nodeName string) (string, error) { + stdout, stderr, err := data.e2eTestData.RunCommandFromAntreaPodOnNode(nodeName, []string{"ovs-vsctl", "list-br"}) + if err != nil { + return "", fmt.Errorf("failed to list OVS bridges on node %s: %w\nstderr: %s", nodeName, err, stderr) + } + var candidates []string + for _, name := range strings.Fields(stdout) { + if name == "" || name == ovsPrimaryBridge { + continue + } + candidates = append(candidates, name) + } + switch len(candidates) { + case 0: + return "", fmt.Errorf("no secondary OVS bridge on node %s (no bridge besides %s)", nodeName, ovsPrimaryBridge) + case 1: + return candidates[0], nil + default: + return "", fmt.Errorf("multiple secondary OVS bridges on node %s: %v; configure a single secondary bridge", + nodeName, candidates) + } +} - stdout, stderr, err := data.e2eTestData.RunCommandFromAntreaPodOnNode(nodeName, cmd) +func (data *testData) getOVSPortsOnSecondaryBridge(t *testing.T, nodeName string) ([]string, error) { + bridgeName, err := data.getSecondaryOVSBridgeName(nodeName) + if err != nil { + return nil, err + } + stdout, stderr, err := data.e2eTestData.RunCommandFromAntreaPodOnNode(nodeName, []string{"ovs-vsctl", "list-ports", bridgeName}) if err != nil { - return nil, fmt.Errorf("failed to run ovs-vsctl on node %s: %v\nstderr: %s", nodeName, err, stderr) + return nil, fmt.Errorf("failed to list ports on OVS bridge %s for node %s: %w\nstderr: %s", bridgeName, nodeName, err, stderr) } + return strings.Fields(stdout), nil +} - ovsPorts := strings.Fields(stdout) - return ovsPorts, nil +// pickPodForAgentRestartReconciliation chooses a pod and its secondary interface names for the +// post-restart delete / IPPool release checks. Prefers the pod with the most secondary attachments +// (e.g. eth1+eth2 in TestVLANNetwork); when several tie, the later pod in data.pods wins so behavior +// matches the historical choice of pods[1] for two-interface pods. Node pool tests attach only +// eth1 per pod; all tie on attachment count and the last pod is selected. +func (data *testData) pickPodForAgentRestartReconciliation() (*testPodInfo, []string, error) { + var best *testPodInfo + bestIdx := -1 + bestCount := -1 + for i, p := range data.pods { + n := len(p.interfaceNetworks) + if n == 0 { + continue + } + if n > bestCount || (n == bestCount && i > bestIdx) { + bestCount = n + bestIdx = i + best = p + } + } + if best == nil { + return nil, nil, fmt.Errorf("no pod with secondary network interfaces") + } + ifaceNames := make([]string, 0, len(best.interfaceNetworks)) + for k := range best.interfaceNetworks { + ifaceNames = append(ifaceNames, k) + } + sort.Strings(ifaceNames) + return best, ifaceNames, nil } // reconcilationAfterAgentRestart verifies OVS Ports cleanup and IP release. @@ -509,8 +573,11 @@ func (data *testData) reconcilationAfterAgentRestart(t *testing.T) error { "Pod: %s, IPv6 addresses mismatch after agent restart", podName) } - vlanPod := data.pods[1] - ifaces := []string{"eth1", "eth2"} + // Remove Pod and check IP released or not. Derive interfaces from the chosen pod so NodePool + // workloads (eth1 only; NAD mapped to eth2/eth3 on the Node in AntreaNodeConfig) keep backward + // compatibility with TestVLANNetwork (eth1 + eth2). + vlanPod, ifaces, err := data.pickPodForAgentRestartReconciliation() + require.NoError(t, err) ifacesIPv4, ifacesIPv6, _, err := data.listPodAddresses(vlanPod) require.NoError(t, err, "Failed to get IPs of Interfaces") @@ -530,13 +597,33 @@ func (data *testData) reconcilationAfterAgentRestart(t *testing.T) error { return data.checkIPReleased(ipPools, ifacesIPv4, ifacesIPv6, ifaces) } +// nodeNamesWithNodePool returns sorted Node names that carry the node pool label (see AntreaNodeConfig tests). +func nodeNamesWithNodePool(t *testing.T, kubeConfig *rest.Config, pool string) []string { + t.Helper() + cs, err := kubernetes.NewForConfig(kubeConfig) + require.NoError(t, err) + nl, err := cs.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{ + LabelSelector: nodePoolLabelKey + "=" + pool, + }) + require.NoError(t, err) + names := make([]string, 0, len(nl.Items)) + for i := range nl.Items { + names = append(names, nl.Items[i].Name) + } + sort.Strings(names) + return names +} + func testSecondaryNetwork(t *testing.T, networkType string, pods []*testPodInfo) { e2eTestData, err := antreae2e.SetupTest(t) if err != nil { t.Fatalf("Error when setting up test: %v", err) } defer antreae2e.TeardownTest(t, e2eTestData) + runSecondaryNetworkTestSteps(t, e2eTestData, networkType, pods) +} +func runSecondaryNetworkTestSteps(t *testing.T, e2eTestData *antreae2e.TestData, networkType string, pods []*testPodInfo) { testData := &testData{e2eTestData: e2eTestData, networkType: networkType, pods: pods} ns := e2eTestData.GetTestNamespace() @@ -603,6 +690,68 @@ func TestVLANNetwork(t *testing.T) { testSecondaryNetwork(t, networkTypeVLAN, pods) } +// TestVLANNetworkNodePools exercises VLAN secondary networks when Nodes are labeled +// antrea.io/node-pool=pool1|pool2 (see antrea-node-configs-nodepools.yml and ci/kind/test-secondary-network-kind.sh). +// Each pod uses eth1 as its first (and only) secondary interface; Antrea maps NADs to Node uplinks eth2/eth3 +// per AntreaNodeConfig, not by matching pod vs host interface names. +// It expects NADs/IPPools from secondary-networks.yml including vlan-net-pool1-eth3-300, vlan-net-pool2-eth3-400, +// and vlan-net-cross-pool-eth2-vlan100. Skips if fewer than two Nodes exist per pool. +func TestNodePoolsVLANNetwork(t *testing.T) { + if antreae2e.NodeCount() < 4 { + t.Skipf("Requires at least 4 nodes (two per pool), got %d", antreae2e.NodeCount()) + } + e2eTestData, err := antreae2e.SetupTest(t) + require.NoError(t, err) + defer antreae2e.TeardownTest(t, e2eTestData) + + pool1 := nodeNamesWithNodePool(t, e2eTestData.KubeConfig, "pool1") + pool2 := nodeNamesWithNodePool(t, e2eTestData.KubeConfig, "pool2") + if len(pool1) < 2 || len(pool2) < 2 { + t.Skipf("Need at least 2 nodes labeled %s=pool1 and 2 with pool2; got pool1=%v pool2=%v", + nodePoolLabelKey, pool1, pool2) + } + + pods := []*testPodInfo{ + { + podName: "pod1-vlan300", + nodeName: pool1[0], + interfaceNetworks: map[string]string{"eth1": "vlan-net-pool1-eth3-300"}, + macAddresses: map[string]string{"eth1": "aa:bb:cc:dd:e0:01"}, + }, + { + podName: "pod2-vlan300", + nodeName: pool1[1], + interfaceNetworks: map[string]string{"eth1": "vlan-net-pool1-eth3-300"}, + macAddresses: map[string]string{"eth1": "aa:bb:cc:dd:e0:02"}, + }, + { + podName: "pod1-vlan400", + nodeName: pool2[0], + interfaceNetworks: map[string]string{"eth1": "vlan-net-pool2-eth3-400"}, + macAddresses: map[string]string{"eth1": "aa:bb:cc:dd:e0:03"}, + }, + { + podName: "pod2-vlan400", + nodeName: pool2[1], + interfaceNetworks: map[string]string{"eth1": "vlan-net-pool2-eth3-400"}, + macAddresses: map[string]string{"eth1": "aa:bb:cc:dd:e0:04"}, + }, + { + podName: "pod1-vlan100", + nodeName: pool1[0], + interfaceNetworks: map[string]string{"eth1": "vlan-net-cross-pool-eth2-vlan100"}, + macAddresses: map[string]string{"eth1": "aa:bb:cc:dd:e0:05"}, + }, + { + podName: "pod2-vlan100", + nodeName: pool2[0], + interfaceNetworks: map[string]string{"eth1": "vlan-net-cross-pool-eth2-vlan100"}, + macAddresses: map[string]string{"eth1": "aa:bb:cc:dd:e0:06"}, + }, + } + runSecondaryNetworkTestSteps(t, e2eTestData, networkTypeVLAN, pods) +} + func (data *testData) assignIP(clientset *kubernetes.Clientset) error { e2eTestData := data.e2eTestData namespace := e2eTestData.GetTestNamespace() From 1751971153d5ccf1bb91b3c2701ca1940edf3231 Mon Sep 17 00:00:00 2001 From: Lan Luo Date: Tue, 26 May 2026 11:55:43 +0800 Subject: [PATCH 11/13] Address latest comments Signed-off-by: Lan Luo --- ci/kind/test-secondary-network-kind.sh | 144 ++++++------------ pkg/agent/antreanodeconfig/controller.go | 38 +++-- pkg/agent/antreanodeconfig/controller_test.go | 3 - pkg/agent/antreanodeconfig/snapshot.go | 19 +-- pkg/agent/antreanodeconfig/snapshot_test.go | 9 +- pkg/agent/interfacestore/interface_cache.go | 10 ++ .../testing/mock_interfacestore.go | 12 ++ pkg/agent/interfacestore/types.go | 1 + pkg/agent/secondarynetwork/anc_bridge.go | 5 +- pkg/agent/secondarynetwork/anc_bridge_test.go | 26 +--- pkg/agent/secondarynetwork/init_linux.go | 4 +- .../secondarynetwork/podwatch/controller.go | 24 ++- .../infra/secondary-networks.yml | 39 ++++- 13 files changed, 159 insertions(+), 175 deletions(-) diff --git a/ci/kind/test-secondary-network-kind.sh b/ci/kind/test-secondary-network-kind.sh index 3356a821901..64e705896fb 100755 --- a/ci/kind/test-secondary-network-kind.sh +++ b/ci/kind/test-secondary-network-kind.sh @@ -47,11 +47,10 @@ TIMEOUT="5m" ANTREA_LOG_DIR="${ANTREA_LOG_DIR:-${PWD}/log}" mkdir -p "$ANTREA_LOG_DIR" TEST_OPTIONS=(--logs-export-dir="$ANTREA_LOG_DIR" --deploy-antrea=false) -EXTRA_NETWORK="20.20.20.0/24" +# Subnets for extra Docker bridge networks: eth1 (static bridge), eth2 & eth3 (VLAN-tagged bridges). +EXTRA_NETWORKS="20.20.20.0/24 20.20.21.0/24 20.20.22.0/24" -# One control-plane plus this many workers (total nodes = NUM_WORKERS + 1). NUM_WORKERS=3 -# Label the first two and last two nodes (sorted by name) for pool scheduling tests. NODEPOOL_LABEL_KEY="antrea.io/node-pool" setup_only=false @@ -105,7 +104,7 @@ if [[ $cleanup_only == "true" ]];then exit 0 fi -# trap "quit" INT EXIT +trap "quit" INT EXIT IMAGE_LIST=("antrea/toolbox:1.5-1" \ "antrea/antrea-agent-ubuntu:latest" \ @@ -131,28 +130,17 @@ function docker_bridge_for_network { echo "br-${nid:0:12}" } -# Docker 28+: --gw-priority (highest wins default GW). Use negative on secondary nets so Kind eth0 stays default. -function docker_network_connect_lab_iface { - local ifname="$1" net="$2" node="$3" - local -a args=(--driver-opt=com.docker.network.endpoint.ifname="$ifname") - if docker network connect --help 2>&1 | grep -q '[[:space:]]--gw-priority[[:space:]]'; then - args+=(--gw-priority -1000) - fi - docker network connect "${args[@]}" "$net" "$node" -} - # List interface names enslaved to a Linux bridge (same view as /sys/class/net/
/brif). function kind_linux_bridge_port_names { local br="$1" ip link show master "$br" 2>/dev/null | sed -n 's/^[0-9]\+: \([^@[:space:]]\+\)@.*/\1/p' } -# After Kind nodes are connected: program bridge self and each slave port for lab VLAN IDs. -# First VLAN in the list is the port PVID: untagged Docker / host RX maps to that VLAN (pvid). -# Do not use "untagged" on the PVID: that adds "Egress Untagged" in bridge vlan show and makes the -# lab VLAN egress untagged on the veth; we want trunk-style egress (tagged) while still classifying -# untagged RX to the lab VID. Remaining VLANs are tagged trunks only. -function kind_bridge_apply_lab_vlans { +# Apply VLAN configuration to the bridge device and every slave port. +# The first VLAN ID is the PVID (untagged ingress); remaining VLANs are tagged trunks. +# VID 1 is removed unless it is the PVID, since the kernel leaves it as egress-untagged +# by default even after vlan_filtering is enabled. +function kind_bridge_apply_vlans { local br="$1" shift local -a vids=("$@") @@ -160,36 +148,29 @@ function kind_bridge_apply_lab_vlans { return 0 fi local pvid="${vids[0]}" - local vid port + local vid dev self_flag - bridge vlan add dev "$br" vid "$pvid" pvid self 2>/dev/null || true - for vid in "${vids[@]}"; do - [[ "$vid" == "$pvid" ]] && continue - bridge vlan add dev "$br" vid "$vid" self 2>/dev/null || true - done - while IFS= read -r port; do - [[ -n "$port" ]] || continue - bridge vlan add dev "$port" vid "$pvid" pvid 2>/dev/null || true + for dev in "$br" $(kind_linux_bridge_port_names "$br"); do + [[ -n "$dev" ]] || continue + if [[ "$dev" == "$br" ]]; then + self_flag="self" + else + self_flag="" + fi + sudo bridge vlan add dev "$dev" vid "$pvid" pvid $self_flag 2>/dev/null || true for vid in "${vids[@]}"; do [[ "$vid" == "$pvid" ]] && continue - bridge vlan add dev "$port" vid "$vid" 2>/dev/null || true + sudo bridge vlan add dev "$dev" vid "$vid" $self_flag 2>/dev/null || true done - done < <(kind_linux_bridge_port_names "$br") - - # With vlan_filtering enabled, the kernel / Docker typically leaves VID 1 as egress-untagged on - # bridge ports even after we set the lab PVID (100/300). Remove VID 1 when the lab PVID is not 1. - if [[ "$pvid" -ne 1 ]]; then - bridge vlan del dev "$br" vid 1 self 2>/dev/null || true - while IFS= read -r port; do - [[ -n "$port" ]] || continue - bridge vlan del dev "$port" vid 1 2>/dev/null || true - done < <(kind_linux_bridge_port_names "$br") - fi + if [[ "$pvid" -ne 1 ]]; then + sudo bridge vlan del dev "$dev" vid 1 $self_flag 2>/dev/null || true + fi + done } function prepare_cluster_nodes { - # This is for testing AntreaNodeConfigs with label selector support. - echo "Removing control-plane NoSchedule taint so Pods can schedule on control-plane Nodes" + # Allow Pods to schedule on control-plane Nodes for pool testing. + echo "Removing control-plane NoSchedule taint" for n in $(kubectl get nodes -l node-role.kubernetes.io/control-plane -o jsonpath='{.items[*].metadata.name}' 2>/dev/null); do kubectl taint nodes "$n" node-role.kubernetes.io/control-plane:NoSchedule- 2>/dev/null || true kubectl taint nodes "$n" node-role.kubernetes.io/master:NoSchedule- 2>/dev/null || true @@ -230,68 +211,29 @@ function run_test { run_test_with_antrenodeconfigs } -# Lab-style VLAN-aware bridges: create Docker bridge networks (Docker owns the Linux bridge), enable -# vlan_filtering on those bridges, connect every Kind node container with fixed interface names eth2/eth3, -# then program bridge VLAN on bridge self and each veth. Requires root for ip/bridge; -# Assumes eth2/eth3 are free inside nodes (single antrea--0 extra net → only eth1 in use). -function kind_extra_network_with_vlan_awareness_bridges { - # Union of VLAN IDs referenced in test/e2e-secondary-network/infra/antrea-node-configs-nodepools.yml - # (pool1 eth2: 100,101; pool1 eth3: 300; pool2 eth2: 100; pool2 eth3: 400). - KIND_EXTRA_BRIDGE_1_VLAN_IDS=(100 101) # eth2 - KIND_EXTRA_BRIDGE_2_VLAN_IDS=(300 400) # eth3 - # Kind cluster name passed to kind-setup.sh create (must match docker networks antrea--). - KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" - - if [[ "$(id -u)" -ne 0 ]]; then - echoerr "kind_extra_network_with_vlan_awareness_bridges requires root (sudo)." - return 1 - fi +# VLAN bridge configuration: configure_extra_networks (kind-setup.sh) has already created +# Docker bridge networks antrea--1 (eth2) and antrea--2 (eth3) and connected +# every Kind node. This function enables vlan_filtering on the underlying Linux bridges and +# programs per-port VLANs so that the ANC-per-interface allowedVLANs test exercises real +# VLAN filtering. Requires root (sudo ip link / bridge). +function configure_vlan_bridges { + local cluster_name="${KIND_CLUSTER_NAME:-kind}" + local net_eth2="antrea-${cluster_name}-1" + local net_eth3="antrea-${cluster_name}-2" - local net_eth2="kind-vlan-lab-eth2" - local net_eth3="kind-vlan-lab-eth3" - - if ! docker network inspect "$net_eth2" &>/dev/null; then - docker network create -d bridge \ - --subnet="20.20.21.0/24" \ - --gateway="20.20.21.1" \ - "$net_eth2" - fi - if ! docker network inspect "$net_eth3" &>/dev/null; then - docker network create -d bridge \ - --subnet="20.20.22.0/24" \ - --gateway="20.20.22.1" \ - "$net_eth3" - fi local br_eth2 br_eth3 br_eth2="$(docker_bridge_for_network "$net_eth2")" br_eth3="$(docker_bridge_for_network "$net_eth3")" - - ip link set "$br_eth2" up 2>/dev/null - ip link set "$br_eth3" up 2>/dev/null - ip link set "$br_eth2" type bridge vlan_filtering 1 - ip link set "$br_eth3" type bridge vlan_filtering 1 - - local node node_nets - for node in $(kind get nodes --name "$KIND_CLUSTER_NAME"); do - node_nets="$(docker inspect "$node" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null || true)" - if [[ " ${node_nets} " != *" ${net_eth2} "* ]]; then - docker_network_connect_lab_iface eth2 "$net_eth2" "$node" - echo "connected $node to Docker network $net_eth2 as eth2" - else - echo "$node already on $net_eth2" - fi - node_nets="$(docker inspect "$node" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null || true)" - if [[ " ${node_nets} " != *" ${net_eth3} "* ]]; then - docker_network_connect_lab_iface eth3 "$net_eth3" "$node" - echo "connected $node to Docker network $net_eth3 as eth3" - else - echo "$node already on $net_eth3" - fi - done - kind_bridge_apply_lab_vlans "$br_eth2" "${KIND_EXTRA_BRIDGE_1_VLAN_IDS[@]}" - kind_bridge_apply_lab_vlans "$br_eth3" "${KIND_EXTRA_BRIDGE_2_VLAN_IDS[@]}" + sudo ip link set "$br_eth2" type bridge vlan_filtering 1 + sudo ip link set "$br_eth3" type bridge vlan_filtering 1 + + # VLAN IDs from antrea-node-configs-nodepools.yml: + # pool1 eth2: 100,101; pool1 eth3: 300 + # pool2 eth2: 100; pool2 eth3: 400 + kind_bridge_apply_vlans "$br_eth2" 100 101 + kind_bridge_apply_vlans "$br_eth3" 300 400 } function run_test_with_antrenodeconfigs { @@ -303,7 +245,7 @@ function run_test_with_antrenodeconfigs { echo "Clean up the previous AntreaNodeConfig" kubectl delete -f "$ANTREA_NODE_CONFIGS_LINUX_YAML" sleep 5 - kind_extra_network_with_vlan_awareness_bridges + configure_vlan_bridges echo "Apply new AntreaNodeConfigs for NodePool 1 and NodePool 2 nodes" kubectl apply -f "$ANTREA_NODE_CONFIGS_NODEPOOL_YAML" sleep 5 @@ -314,7 +256,7 @@ function run_test_with_antrenodeconfigs { echo "======== Testing Antrea-native secondary network support ==========" if [[ "$test_only" == "false" ]];then cleanup_stale_kind - setup_cluster "--extra-networks \"$EXTRA_NETWORK\" --images \"$IMAGES\" --num-workers $NUM_WORKERS" + setup_cluster "--extra-networks \"$EXTRA_NETWORKS\" --images \"$IMAGES\" --num-workers $NUM_WORKERS" fi prepare_cluster_nodes run_test diff --git a/pkg/agent/antreanodeconfig/controller.go b/pkg/agent/antreanodeconfig/controller.go index ed582384057..ac00fbe196a 100644 --- a/pkg/agent/antreanodeconfig/controller.go +++ b/pkg/agent/antreanodeconfig/controller.go @@ -20,9 +20,10 @@ package antreanodeconfig import ( + "cmp" "fmt" "reflect" - "sort" + "slices" "time" corev1 "k8s.io/api/core/v1" @@ -141,9 +142,9 @@ func (c *Controller) getLocalNodeFromLister() (*corev1.Node, error) { return nil, err } -// CurrentSnapshot returns a deep-copied snapshot of the local Node and the -// oldest AntreaNodeConfig that matches this Node's labels. It returns nil if -// informers are not synced yet. +// CurrentSnapshot returns a deep-copied snapshot of the oldest AntreaNodeConfig +// that matches this Node's labels. It returns nil if informers are not synced or +// the local Node is not yet available. func (c *Controller) CurrentSnapshot() *Snapshot { if !c.InformersSynced() { return nil @@ -153,9 +154,12 @@ func (c *Controller) CurrentSnapshot() *Snapshot { klog.ErrorS(err, "Failed to get local Node from lister for snapshot", "node", c.nodeName) return nil } + if node == nil { + return nil + } all, err := c.ancLister.List(labels.Everything()) effective := antreaNodeConfigForSnapshot(node, all, err) - return NewSnapshot(node, effective, err) + return NewSnapshot(effective, err) } // Run waits for the AntreaNodeConfig and Node informer caches to sync, enqueues one @@ -201,18 +205,25 @@ func (c *Controller) processNextWorkItem() bool { } // syncSnapshot builds a snapshot from informer listers and notifies subscribers -// when it differs from lastNotified. It is intended to run only from the workqueue worker. +// when it differs from lastNotified. It returns an error when the local Node is not +// available so the workqueue retries; a published snapshot always reflects a ready +// controller. func (c *Controller) syncSnapshot(key string) error { _ = key node, err := c.getLocalNodeFromLister() if err != nil { return err } + if node == nil { + return fmt.Errorf("local Node %q not found in lister", c.nodeName) + } all, listErr := c.ancLister.List(labels.Everything()) effective := antreaNodeConfigForSnapshot(node, all, listErr) - next := NewSnapshot(node, effective, listErr) + next := NewSnapshot(effective, listErr) + // Deep-copy before notify so subscriber mutations cannot corrupt lastNotified, + // which is reused for deduplication on the next reconciliation. payload := next.DeepCopy() if c.lastNotified != nil && reflect.DeepEqual(c.lastNotified, payload) { return nil @@ -301,13 +312,14 @@ func SelectAntreaNodeConfigsForNode(node *corev1.Node, configs []*crdv1alpha1.An matching = append(matching, cfg) } } - sort.Slice(matching, func(i, j int) bool { - ti := matching[i].CreationTimestamp - tj := matching[j].CreationTimestamp - if ti.Equal(&tj) { - return matching[i].Name < matching[j].Name + slices.SortFunc(matching, func(a, b *crdv1alpha1.AntreaNodeConfig) int { + if a.CreationTimestamp.Before(&b.CreationTimestamp) { + return -1 + } + if b.CreationTimestamp.Before(&a.CreationTimestamp) { + return 1 } - return ti.Before(&tj) + return cmp.Compare(a.Name, b.Name) }) return matching } diff --git a/pkg/agent/antreanodeconfig/controller_test.go b/pkg/agent/antreanodeconfig/controller_test.go index 2816e9a406d..932634fa8b3 100644 --- a/pkg/agent/antreanodeconfig/controller_test.go +++ b/pkg/agent/antreanodeconfig/controller_test.go @@ -268,8 +268,6 @@ func TestRecomputeAndNotifyOnLabelChange(t *testing.T) { "label change should trigger another notify") last, ok := env.Rec.Last().(*Snapshot) require.True(t, ok) - require.NotNil(t, last.Node) - assert.Equal(t, "other", last.Node.Labels["role"], "snapshot should reflect updated Node labels") assert.Nil(t, last.AntreaNodeConfig, "ANC matched worker role only; labels no longer match") } @@ -362,7 +360,6 @@ func TestRecomputeNotifyFailureSkipsLastNotifiedUpdate(t *testing.T) { rec.fail = false require.NoError(t, env.C.syncSnapshot(snapshotQueueKey)) require.NotNil(t, env.C.lastNotified) - require.NotNil(t, env.C.lastNotified.Node) assert.Empty(t, env.C.lastNotified.AntreaNodeConfigListError) } diff --git a/pkg/agent/antreanodeconfig/snapshot.go b/pkg/agent/antreanodeconfig/snapshot.go index 37f5b443590..2229f4cd29e 100644 --- a/pkg/agent/antreanodeconfig/snapshot.go +++ b/pkg/agent/antreanodeconfig/snapshot.go @@ -15,17 +15,14 @@ package antreanodeconfig import ( - corev1 "k8s.io/api/core/v1" - crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" ) -// Snapshot is an immutable view of the local Node and the AntreaNodeConfig that -// applies to this Node (nodeSelector match, oldest creationTimestamp wins), as -// computed by the AntreaNodeConfig controller. Subscribers use it together with -// feature-specific static configuration. +// Snapshot is an immutable view of the AntreaNodeConfig that applies to this Node +// (nodeSelector match, oldest creationTimestamp wins), as computed by the +// AntreaNodeConfig controller. Subscribers use it together with feature-specific +// static configuration. type Snapshot struct { - Node *corev1.Node // AntreaNodeConfig is a deep copy of the effective AntreaNodeConfig for this // Node at snapshot build time, or nil when none matches or the list failed. AntreaNodeConfig *crdv1alpha1.AntreaNodeConfig @@ -35,14 +32,11 @@ type Snapshot struct { // NewSnapshot returns a snapshot with deep-copied API objects suitable for // passing to subscribers and for reflect.DeepEqual deduplication. -func NewSnapshot(node *corev1.Node, antreaNodeConfig *crdv1alpha1.AntreaNodeConfig, listErr error) *Snapshot { +func NewSnapshot(antreaNodeConfig *crdv1alpha1.AntreaNodeConfig, listErr error) *Snapshot { s := &Snapshot{} if listErr != nil { s.AntreaNodeConfigListError = listErr.Error() } - if node != nil { - s.Node = node.DeepCopy() - } if antreaNodeConfig != nil { s.AntreaNodeConfig = antreaNodeConfig.DeepCopy() } @@ -55,9 +49,6 @@ func (s *Snapshot) DeepCopy() *Snapshot { return nil } out := &Snapshot{AntreaNodeConfigListError: s.AntreaNodeConfigListError} - if s.Node != nil { - out.Node = s.Node.DeepCopy() - } if s.AntreaNodeConfig != nil { out.AntreaNodeConfig = s.AntreaNodeConfig.DeepCopy() } diff --git a/pkg/agent/antreanodeconfig/snapshot_test.go b/pkg/agent/antreanodeconfig/snapshot_test.go index 6199eb9a8b4..3e92a76916a 100644 --- a/pkg/agent/antreanodeconfig/snapshot_test.go +++ b/pkg/agent/antreanodeconfig/snapshot_test.go @@ -19,7 +19,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crdv1alpha1 "antrea.io/antrea/v2/pkg/apis/crd/v1alpha1" @@ -31,15 +30,11 @@ func TestSnapshotDeepCopy(t *testing.T) { assert.Nil(t, s.DeepCopy()) }) - t.Run("node and AntreaNodeConfig", func(t *testing.T) { - node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "n1", Labels: map[string]string{"k": "v"}}} + t.Run("AntreaNodeConfig", func(t *testing.T) { anc := &crdv1alpha1.AntreaNodeConfig{ObjectMeta: metav1.ObjectMeta{Name: "a1"}} - s := NewSnapshot(node, anc, nil) + s := NewSnapshot(anc, nil) cp := s.DeepCopy() require.NotNil(t, cp) - assert.NotSame(t, s.Node, cp.Node) assert.NotSame(t, s.AntreaNodeConfig, cp.AntreaNodeConfig) - cp.Node.Labels["k"] = "mutated" - assert.Equal(t, "v", s.Node.Labels["k"]) }) } diff --git a/pkg/agent/interfacestore/interface_cache.go b/pkg/agent/interfacestore/interface_cache.go index 5c4f9c87bd2..b5236f96b4b 100644 --- a/pkg/agent/interfacestore/interface_cache.go +++ b/pkg/agent/interfacestore/interface_cache.go @@ -79,6 +79,16 @@ func (c *interfaceCache) Initialize(interfaces []*InterfaceConfig) { } } +// Reset removes all entries from the store. It is used when the backing +// infrastructure (e.g. OVS bridge) is torn down and stale records must be +// discarded so that subsequent lookups do not return interfaces that no +// longer exist. +func (c *interfaceCache) Reset() { + for _, obj := range c.cache.List() { + c.DeleteInterface(obj.(*InterfaceConfig)) + } +} + // getInterfaceKey returns the key to access interfaceConfig from the cache. // It implements cache.KeyFunc. func getInterfaceKey(obj interface{}) (string, error) { diff --git a/pkg/agent/interfacestore/testing/mock_interfacestore.go b/pkg/agent/interfacestore/testing/mock_interfacestore.go index cde40f678b7..f45929335f2 100644 --- a/pkg/agent/interfacestore/testing/mock_interfacestore.go +++ b/pkg/agent/interfacestore/testing/mock_interfacestore.go @@ -279,6 +279,18 @@ func (mr *MockInterfaceStoreMockRecorder) ListInterfaces() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInterfaces", reflect.TypeOf((*MockInterfaceStore)(nil).ListInterfaces)) } +// Reset mocks base method. +func (m *MockInterfaceStore) Reset() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Reset") +} + +// Reset indicates an expected call of Reset. +func (mr *MockInterfaceStoreMockRecorder) Reset() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reset", reflect.TypeOf((*MockInterfaceStore)(nil).Reset)) +} + // UpdateInterface mocks base method. func (m *MockInterfaceStore) UpdateInterface(interfaceConfig *interfacestore.InterfaceConfig) { m.ctrl.T.Helper() diff --git a/pkg/agent/interfacestore/types.go b/pkg/agent/interfacestore/types.go index ac8390ccc4d..58559e15ecb 100644 --- a/pkg/agent/interfacestore/types.go +++ b/pkg/agent/interfacestore/types.go @@ -118,6 +118,7 @@ type InterfaceConfig struct { // Support add/delete/get operations type InterfaceStore interface { Initialize(interfaces []*InterfaceConfig) + Reset() AddInterface(interfaceConfig *InterfaceConfig) UpdateInterface(interfaceConfig *InterfaceConfig) ListInterfaces() []*InterfaceConfig diff --git a/pkg/agent/secondarynetwork/anc_bridge.go b/pkg/agent/secondarynetwork/anc_bridge.go index 4c487b5f2fb..05dda0274c6 100644 --- a/pkg/agent/secondarynetwork/anc_bridge.go +++ b/pkg/agent/secondarynetwork/anc_bridge.go @@ -35,7 +35,7 @@ func EffectiveSecondaryOVSBridgeFromStatic(staticCfg *agentconfig.SecondaryNetwo // immutable *antreanodeconfig.Snapshot (for example the payload on the AntreaNodeConfig notify // channel) merged with static agent ConfigMap settings. // -// When snap is nil or snap.Node is nil, nil is returned (no bridge from this snapshot). +// When snap is nil, nil is returned (no bridge from this snapshot). // // When the snapshot records a non-empty AntreaNodeConfigListError, staticCfg is used // after logging. Otherwise, when the oldest matching AntreaNodeConfig specifies secondary @@ -44,9 +44,6 @@ func EffectiveSecondaryOVSBridgeFromSnapshot(snap *antreanodeconfig.Snapshot, st if snap == nil { return nil } - if snap.Node == nil { - return nil - } if snap.AntreaNodeConfigListError != "" { klog.ErrorS(errors.New(snap.AntreaNodeConfigListError), "Failed to list AntreaNodeConfigs, falling back to static config") return ovsBridgeFromStatic(staticCfg) diff --git a/pkg/agent/secondarynetwork/anc_bridge_test.go b/pkg/agent/secondarynetwork/anc_bridge_test.go index 39a1b63df81..24397e70b50 100644 --- a/pkg/agent/secondarynetwork/anc_bridge_test.go +++ b/pkg/agent/secondarynetwork/anc_bridge_test.go @@ -47,8 +47,7 @@ type fakeANCController struct { crdInformerFactory crdinformers.SharedInformerFactory kubeInformerFactory informers.SharedInformerFactory - stopCh chan struct{} - fixtureNode *corev1.Node + stopCh chan struct{} } // newANCBridgeTestNotifier mirrors cniserver.newAsyncWaiter: a SubscribableChannel implements @@ -88,13 +87,11 @@ func newFakeANCController(t *testing.T, node *corev1.Node, ancObjs []*crdv1alpha crdInformerFactory: crdFactory, kubeInformerFactory: kubeFactory, stopCh: stopCh, - fixtureNode: node, } } // start starts informer factories, runs the AntreaNodeConfig controller, and waits until -// CurrentSnapshot reflects fixtureNode (non-nil Node object in API → non-nil snap.Node; -// nil fixture node → snap.Node nil after Run). +// the first snapshot is published. func (f *fakeANCController) start(t *testing.T) { t.Helper() f.kubeInformerFactory.Start(f.stopCh) @@ -116,14 +113,7 @@ func (f *fakeANCController) start(t *testing.T) { }) require.Eventually(t, func() bool { - snap := f.CurrentSnapshot() - if snap == nil { - return false - } - if f.fixtureNode != nil { - return snap.Node != nil - } - return snap.Node == nil + return f.CurrentSnapshot() != nil }, 5*time.Second, 10*time.Millisecond, "controller should expose snapshot after Run") } @@ -211,13 +201,6 @@ func TestEffectiveSecondaryOVSBridge(t *testing.T) { staticCfg: staticCfg, wantBridge: nil, }, - { - name: "nil node returns nil when ANC enabled — do not prefer static over CR", - node: nil, - ancObjs: []*crdv1alpha1.AntreaNodeConfig{ancMatchingWithBridge}, - staticCfg: staticCfg, - wantBridge: nil, - }, } for _, tc := range tests { @@ -248,8 +231,7 @@ func TestEffectiveOVSBridgeFromSnapshot_ListErrorFallsBackToStatic(t *testing.T) {BridgeName: "br-static", PhysicalInterfaces: []string{"eth0"}}, }, } - node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: ancBridgeTestLocalNodeName}} - snap := antreanodeconfig.NewSnapshot(node, nil, errors.New("informer list failed")) + snap := antreanodeconfig.NewSnapshot(nil, errors.New("informer list failed")) got := EffectiveSecondaryOVSBridgeFromSnapshot(snap, staticCfg) require.NotNil(t, got) assert.Equal(t, "br-static", got.BridgeName) diff --git a/pkg/agent/secondarynetwork/init_linux.go b/pkg/agent/secondarynetwork/init_linux.go index 08a92c4cadf..d26f6f85c4a 100644 --- a/pkg/agent/secondarynetwork/init_linux.go +++ b/pkg/agent/secondarynetwork/init_linux.go @@ -270,14 +270,16 @@ func (c *Controller) clearBridgeState() { // deleteAndDisconnectBridge deletes the OVS bridge for cfg, notifies the pod controller that // no secondary bridge is in use, and clears controller state. cfg may be nil (no-op delete). +// Controller state is cleared immediately after the bridge is deleted so that a retry (if +// UpdateOVSBridge fails below) does not call Delete() on an already-gone bridge. func (c *Controller) deleteAndDisconnectBridge(cfg *agenttypes.OVSBridgeConfig) error { if err := c.deleteBridge(cfg); err != nil { return err } + c.clearBridgeState() if err := c.podController.UpdateOVSBridge(nil); err != nil { return err } - c.clearBridgeState() return nil } diff --git a/pkg/agent/secondarynetwork/podwatch/controller.go b/pkg/agent/secondarynetwork/podwatch/controller.go index 91ac1412dd4..fa601366a0e 100644 --- a/pkg/agent/secondarynetwork/podwatch/controller.go +++ b/pkg/agent/secondarynetwork/podwatch/controller.go @@ -119,9 +119,13 @@ func NewPodController( ipPoolLister crdlisters.IPPoolLister, ) (*PodController, error) { ifaceStore := interfacestore.NewInterfaceStore() - interfaceConfigurator, err := cniserver.NewSecondaryInterfaceConfigurator(ovsBridgeClient, ifaceStore) - if err != nil { - return nil, fmt.Errorf("failed to create SecondaryInterfaceConfigurator: %v", err) + var interfaceConfigurator InterfaceConfigurator + if ovsBridgeClient != nil { + var err error + interfaceConfigurator, err = cniserver.NewSecondaryInterfaceConfigurator(ovsBridgeClient, ifaceStore) + if err != nil { + return nil, fmt.Errorf("failed to create SecondaryInterfaceConfigurator: %v", err) + } } podLister := corelisters.NewPodLister(podInformer.GetIndexer()) pc := PodController{ @@ -345,7 +349,12 @@ func (pc *PodController) removeInterfaces(interfaces []*interfacestore.Interface // interface type by checking interfaceConfig.OVSPortConfig is set or not. if interfaceConfig.OVSPortConfig != nil { if configurator == nil { - err = fmt.Errorf("OVS bridge not available, cannot delete VLAN interface") + // Bridge is gone — OVS ports no longer exist. Delete the + // stale record and proceed to IPAM release; this is not an + // error since the interface is already gone. + pc.interfaceStore.DeleteInterface(interfaceConfig) + klog.V(2).InfoS("Bridge not available, interface record removed", + "Pod", klog.KRef(podNamespace, podName), "interface", interfaceConfig.IFDev) } else { err = configurator.DeleteVLANSecondaryInterface(interfaceConfig) } @@ -834,9 +843,10 @@ func (pc *PodController) UpdateOVSBridge(newClient ovsconfig.OVSBridgeClient) er pc.mu.Unlock() if newClient == nil { - // Bridge is gone — drop any stale interface records. - pc.interfaceStore.Initialize(nil) - klog.InfoS("Secondary OVS bridge removed, interface store cleared") + // Bridge is gone — discard all stale interface records so that + // subsequent lookups do not return interfaces that no longer exist. + pc.interfaceStore.Reset() + klog.InfoS("Secondary OVS bridge removed, interface store reset") return nil } diff --git a/test/e2e-secondary-network/infra/secondary-networks.yml b/test/e2e-secondary-network/infra/secondary-networks.yml index 69d372411c4..95b6c061891 100644 --- a/test/e2e-secondary-network/infra/secondary-networks.yml +++ b/test/e2e-secondary-network/infra/secondary-networks.yml @@ -184,6 +184,17 @@ spec: prefixLength: 24 vlan: 300 --- +apiVersion: crd.antrea.io/v1beta1 +kind: IPPool +metadata: + name: secnet-ipv6-pool1-vlan300 +spec: + ipRanges: + - cidr: "10:2400:3::0/96" + subnetInfo: + gateway: "10:2400:3::1" + prefixLength: 64 +--- apiVersion: "k8s.cni.cncf.io/v1" kind: NetworkAttachmentDefinition metadata: @@ -195,7 +206,7 @@ spec: "networkType": "vlan", "ipam": { "type": "antrea", - "ippools": ["secnet-ipv4-pool1-vlan300"] + "ippools": ["secnet-ipv4-pool1-vlan300", "secnet-ipv6-pool1-vlan300"] } }' --- @@ -211,6 +222,17 @@ spec: gateway: "148.14.27.1" prefixLength: 24 --- +apiVersion: crd.antrea.io/v1beta1 +kind: IPPool +metadata: + name: secnet-ipv6-pool2-vlan400 +spec: + ipRanges: + - cidr: "10:2400:4::0/96" + subnetInfo: + gateway: "10:2400:4::1" + prefixLength: 64 +--- apiVersion: "k8s.cni.cncf.io/v1" kind: NetworkAttachmentDefinition metadata: @@ -223,7 +245,7 @@ spec: "vlan": 400, "ipam": { "type": "antrea", - "ippools": ["secnet-ipv4-pool2-vlan400"] + "ippools": ["secnet-ipv4-pool2-vlan400", "secnet-ipv6-pool2-vlan400"] } }' --- @@ -239,6 +261,17 @@ spec: gateway: "172.27.100.1" prefixLength: 24 --- +apiVersion: crd.antrea.io/v1beta1 +kind: IPPool +metadata: + name: cross-pool-vlan100-eth2-ipv6 +spec: + ipRanges: + - cidr: "10:2400:5::0/96" + subnetInfo: + gateway: "10:2400:5::1" + prefixLength: 64 +--- apiVersion: "k8s.cni.cncf.io/v1" kind: NetworkAttachmentDefinition metadata: @@ -251,6 +284,6 @@ spec: "vlan": 100, "ipam": { "type": "antrea", - "ippools": ["cross-pool-vlan100-eth2"] + "ippools": ["cross-pool-vlan100-eth2", "cross-pool-vlan100-eth2-ipv6"] } }' \ No newline at end of file From 197e185fa003be2684f7fb431283895eb779927e Mon Sep 17 00:00:00 2001 From: Lan Luo Date: Wed, 27 May 2026 14:47:43 +0800 Subject: [PATCH 12/13] Address comments Signed-off-by: Lan Luo --- cmd/antrea-agent/agent.go | 7 +- pkg/agent/secondarynetwork/anc_bridge.go | 6 +- pkg/agent/secondarynetwork/anc_bridge_test.go | 2 +- pkg/agent/secondarynetwork/init.go | 142 ++---------------- pkg/agent/secondarynetwork/init_linux.go | 125 ++++++++++++--- pkg/agent/secondarynetwork/init_linux_test.go | 36 ++--- pkg/agent/secondarynetwork/init_windows.go | 20 ++- .../secondarynetwork/podwatch/controller.go | 4 +- pkg/agent/types/antreanodeconfig.go | 31 ---- pkg/agent/util/net_linux.go | 9 +- pkg/ovs/ovsconfig/ovs_client.go | 90 ++--------- 11 files changed, 186 insertions(+), 286 deletions(-) diff --git a/cmd/antrea-agent/agent.go b/cmd/antrea-agent/agent.go index bc9fa26a997..add963080dc 100644 --- a/cmd/antrea-agent/agent.go +++ b/cmd/antrea-agent/agent.go @@ -995,13 +995,10 @@ func run(o *Options) error { } // secondaryNetworkController Initialize must be run after FlowRestoreComplete for the case that Node // IPs are moved to the secondary OVS bridge. When AntreaNodeConfig drives the secondary bridge, - // wait for the first ANC snapshot before Initialize so the effective bridge is known. + // Initialize waits for the first ANC snapshot before setting up the bridge. if features.DefaultFeatureGate.Enabled(features.SecondaryNetwork) { - if err := secondaryNetworkController.WaitForInitialANCSnapshotAndEnsureBridge(stopCh); err != nil { - return fmt.Errorf("failed to wait for AntreaNodeConfig snapshot for secondary network: %w", err) - } defer secondaryNetworkController.Restore() - if err = secondaryNetworkController.Initialize(); err != nil { + if err = secondaryNetworkController.Initialize(stopCh); err != nil { return fmt.Errorf("failed to initialize secondary network: %v", err) } go secondaryNetworkController.Run(stopCh) diff --git a/pkg/agent/secondarynetwork/anc_bridge.go b/pkg/agent/secondarynetwork/anc_bridge.go index 05dda0274c6..49653495426 100644 --- a/pkg/agent/secondarynetwork/anc_bridge.go +++ b/pkg/agent/secondarynetwork/anc_bridge.go @@ -25,9 +25,9 @@ import ( agentconfig "antrea.io/antrea/v2/pkg/config/agent" ) -// EffectiveSecondaryOVSBridgeFromStatic returns the secondary OVS bridge from agent static -// ConfigMap settings only. Used when AntreaNodeConfig does not drive the secondary bridge. -func EffectiveSecondaryOVSBridgeFromStatic(staticCfg *agentconfig.SecondaryNetworkConfig) *agenttypes.OVSBridgeConfig { +// EffectiveSecondaryOVSBridgeFromAgentConfig returns the secondary OVS bridge from the +// agent ConfigMap settings only. Used when AntreaNodeConfig does not drive the secondary bridge. +func EffectiveSecondaryOVSBridgeFromAgentConfig(staticCfg *agentconfig.SecondaryNetworkConfig) *agenttypes.OVSBridgeConfig { return ovsBridgeFromStatic(staticCfg) } diff --git a/pkg/agent/secondarynetwork/anc_bridge_test.go b/pkg/agent/secondarynetwork/anc_bridge_test.go index 24397e70b50..dd59363ffbe 100644 --- a/pkg/agent/secondarynetwork/anc_bridge_test.go +++ b/pkg/agent/secondarynetwork/anc_bridge_test.go @@ -207,7 +207,7 @@ func TestEffectiveSecondaryOVSBridge(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var got *agenttypes.OVSBridgeConfig if tc.noANCController { - got = EffectiveSecondaryOVSBridgeFromStatic(tc.staticCfg) + got = EffectiveSecondaryOVSBridgeFromAgentConfig(tc.staticCfg) } else { fc := newFakeANCController(t, tc.node, tc.ancObjs) fc.start(t) diff --git a/pkg/agent/secondarynetwork/init.go b/pkg/agent/secondarynetwork/init.go index 33d65759bae..94559b620f9 100644 --- a/pkg/agent/secondarynetwork/init.go +++ b/pkg/agent/secondarynetwork/init.go @@ -50,16 +50,12 @@ const ( maxRetryDelay = 300 * time.Second ) -var ( - newOVSBridgeFn = ovsconfig.NewOVSBridge -) - // podControllerInterface is the subset of podwatch.PodController used by Controller. // Defined as an interface to allow test injection. type podControllerInterface interface { Run(stopCh <-chan struct{}) AllowCNIDelete(podName, podNamespace string) bool - UpdateOVSBridge(newClient ovsconfig.OVSBridgeClient) error + UpdateOVSBridgeClient(newClient ovsconfig.OVSBridgeClient) error } // Controller manages secondary network resources for a Node. @@ -109,30 +105,27 @@ func NewController( ipPoolLister crdlisters.IPPoolLister, ancUpdateSubscriber channel.Subscriber, ) (*Controller, error) { - dynamic := ancUpdateSubscriber != nil c := &Controller{ - ovsBridgeClient: nil, - secNetConfig: secNetConfig, - podController: nil, - nodeName: nodeConfig.Name, - ovsdbConn: ovsdbConn, - dynamicBridgeReconcile: dynamic, - queue: workqueue.NewTypedRateLimitingQueueWithConfig( - workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), - workqueue.TypedRateLimitingQueueConfig[string]{Name: "secondaryNetworkBridge"}, - ), + secNetConfig: secNetConfig, + nodeName: nodeConfig.Name, + ovsdbConn: ovsdbConn, } - if dynamic { + + if ancUpdateSubscriber != nil { + c.dynamicBridgeReconcile = true c.ancFirstSnapshotCh = make(chan struct{}) + c.queue = workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), + workqueue.TypedRateLimitingQueueConfig[string]{Name: "secondaryNetworkBridge"}, + ) } var effectiveBridgeCfg *agenttypes.OVSBridgeConfig var ovsBridgeClient ovsconfig.OVSBridgeClient var err error - if dynamic { - effectiveBridgeCfg, ovsBridgeClient = nil, nil - } else { - effectiveBridgeCfg, ovsBridgeClient, err = resolveAndCreateOVSBridge(c.effectiveOVSBridge, ovsdbConn) + + if !c.dynamicBridgeReconcile { + effectiveBridgeCfg, ovsBridgeClient, err = resolveAndCreateOVSBridge(c.effectiveOVSBridge, c.ovsdbConn) if err != nil { return nil, err } @@ -151,7 +144,6 @@ func NewController( } c.ovsBridgeClient = ovsBridgeClient - c.secNetConfig = secNetConfig c.effectiveBridgeCfg = effectiveBridgeCfg c.podController = podWatchController @@ -185,40 +177,7 @@ func (c *Controller) effectiveOVSBridge() *agenttypes.OVSBridgeConfig { if c.dynamicBridgeReconcile { return EffectiveSecondaryOVSBridgeFromSnapshot(c.latestANCSnapshot.Load(), c.secNetConfig) } - return EffectiveSecondaryOVSBridgeFromStatic(c.secNetConfig) -} - -// WaitForInitialANCSnapshotAndEnsureBridge blocks until the AntreaNodeConfig controller -// publishes the first *Snapshot after Node and ANC informers have synced (the same -// notify path as later updates). That snapshot may carry nil AntreaNodeConfig when no -// CR matches this Node. It then creates the OVS bridge if needed and updates the pod -// watcher. -// -// The agent calls this once before Initialize when AntreaNodeConfig drives the bridge; -// on error the agent exits and restarts, so this path does not retry or support -// concurrent callers. -func (c *Controller) WaitForInitialANCSnapshotAndEnsureBridge(stopCh <-chan struct{}) error { - if !c.dynamicBridgeReconcile { - return nil - } - select { - case <-c.ancFirstSnapshotCh: - case <-stopCh: - return fmt.Errorf("interrupted while waiting for initial AntreaNodeConfig snapshot") - } - effectiveBridgeCfg, ovsBridgeClient, err := resolveAndCreateOVSBridge(c.effectiveOVSBridge, c.ovsdbConn) - if err != nil { - return err - } - c.mu.Lock() - c.effectiveBridgeCfg = effectiveBridgeCfg - c.ovsBridgeClient = ovsBridgeClient - c.mu.Unlock() - if err := c.podController.UpdateOVSBridge(ovsBridgeClient); err != nil { - return err - } - klog.InfoS("Secondary network bridge bootstrapped from initial AntreaNodeConfig snapshot") - return nil + return EffectiveSecondaryOVSBridgeFromAgentConfig(c.secNetConfig) } // enqueue adds the single reconciliation key to the work queue. @@ -226,44 +185,6 @@ func (c *Controller) enqueue() { c.queue.Add(reconcileKey) } -// Run starts the secondary network controller. When AntreaNodeConfig is -// enabled, the agent must have called WaitForInitialANCSnapshotAndEnsureBridge before -// Initialize; a bridge reconciliation worker then processes items enqueued by the ANC -// SubscribableChannel. When ANC is off, the bridge is static and no worker is started. -func (c *Controller) Run(stopCh <-chan struct{}) { - defer c.queue.ShutDown() - - klog.InfoS("Starting secondary network controller") - defer klog.InfoS("Shutting down secondary network controller") - - if c.dynamicBridgeReconcile { - go func() { - for c.processNextItem() { - } - }() - } - - go c.podController.Run(stopCh) - - <-stopCh -} - -func (c *Controller) processNextItem() bool { - key, quit := c.queue.Get() - if quit { - return false - } - defer c.queue.Done(key) - - if err := c.reconcileBridge(); err != nil { - c.queue.AddRateLimited(key) - klog.ErrorS(err, "Failed to reconcile secondary network bridge, requeuing") - } else { - c.queue.Forget(key) - } - return true -} - func (c *Controller) AllowCNIDelete(podName, podNamespace string) bool { return c.podController.AllowCNIDelete(podName, podNamespace) } @@ -281,36 +202,3 @@ func createNetworkAttachDefClient(cfg componentbaseconfig.ClientConnectionConfig } return netAttachDefClient, nil } - -// createOVSBridgeClient creates a new OVS bridge with the given name and -// multicast-snooping setting, returning the client for the newly created bridge. -func createOVSBridgeClient(bridgeName string, enableMulticastSnooping bool, ovsdbConn *ovsdb.OVSDB) (ovsconfig.OVSBridgeClient, error) { - var options []ovsconfig.OVSBridgeOption - if enableMulticastSnooping { - options = append(options, ovsconfig.WithMcastSnooping()) - } - client := newOVSBridgeFn(bridgeName, ovsconfig.OVSDatapathSystem, ovsdbConn, options...) - if err := client.Create(); err != nil { - return nil, fmt.Errorf("failed to create OVS bridge %s: %v", bridgeName, err) - } - klog.InfoS("OVS bridge created", "bridge", bridgeName) - return client, nil -} - -// resolveAndCreateOVSBridge evaluates effectiveBridge() and creates the OVS bridge. -// Returns the effective OVSBridgeConfig (nil when no bridge is configured), the -// corresponding OVSBridgeClient, and any error. -func resolveAndCreateOVSBridge( - effectiveBridge func() *agenttypes.OVSBridgeConfig, - ovsdbConn *ovsdb.OVSDB, -) (*agenttypes.OVSBridgeConfig, ovsconfig.OVSBridgeClient, error) { - effectiveBridgeCfg := effectiveBridge() - if effectiveBridgeCfg == nil { - return nil, nil, nil - } - ovsBridgeClient, err := createOVSBridgeClient(effectiveBridgeCfg.BridgeName, effectiveBridgeCfg.EnableMulticastSnooping, ovsdbConn) - if err != nil { - return nil, nil, err - } - return effectiveBridgeCfg, ovsBridgeClient, nil -} diff --git a/pkg/agent/secondarynetwork/init_linux.go b/pkg/agent/secondarynetwork/init_linux.go index d26f6f85c4a..6396f4b5cb0 100644 --- a/pkg/agent/secondarynetwork/init_linux.go +++ b/pkg/agent/secondarynetwork/init_linux.go @@ -22,6 +22,7 @@ import ( "net" "reflect" + "github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb" "k8s.io/klog/v2" "antrea.io/antrea/v2/pkg/agent/interfacestore" @@ -34,9 +35,12 @@ var ( // Funcs which will be overridden with mock funcs in tests. interfaceByNameFn = net.InterfaceByName restoreHostInterfaceConfigFn = util.RestoreHostInterfaceConfiguration // func(brName, ifName string) error + newOVSBridgeFn = ovsconfig.NewOVSBridge ) // Initialize sets up OVS bridges at agent start-up. +// When AntreaNodeConfig drives the bridge, it first waits for the initial ANC snapshot, +// creates the effective bridge, and then reconciles physical interfaces. // It reconciles the current OVS bridge state with the effective bridge config: // - rule 1: if the effective bridge has the same name as the previous bridge, // keep the bridge and update the physical interfaces (add/remove ports). @@ -44,11 +48,26 @@ var ( // delete the old bridge and recreate with the new config. // - rule 3: when allowedVLANs are set on a physical interface, configure the // OVS port in trunk mode with the specified VLAN IDs. -func (c *Controller) Initialize() error { - c.mu.RLock() - bridgeCfg := c.effectiveBridgeCfg - c.mu.RUnlock() +func (c *Controller) Initialize(stopCh <-chan struct{}) error { + if c.dynamicBridgeReconcile { + select { + case <-c.ancFirstSnapshotCh: + case <-stopCh: + return fmt.Errorf("interrupted while waiting for initial AntreaNodeConfig snapshot") + } + effectiveBridgeCfg, ovsBridgeClient, err := resolveAndCreateOVSBridge(c.effectiveOVSBridge, c.ovsdbConn) + if err != nil { + return err + } + c.effectiveBridgeCfg = effectiveBridgeCfg + c.ovsBridgeClient = ovsBridgeClient + if err := c.podController.UpdateOVSBridgeClient(ovsBridgeClient); err != nil { + return err + } + klog.InfoS("Secondary network bridge bootstrapped from initial AntreaNodeConfig snapshot") + } + bridgeCfg := c.effectiveBridgeCfg if bridgeCfg == nil { return nil } @@ -200,6 +219,76 @@ func (c *Controller) Restore() { } } +// Run starts the secondary network controller. When AntreaNodeConfig is +// enabled, Initialize handles the initial ANC snapshot wait and bridge creation; a bridge +// reconciliation worker then processes items enqueued by the ANC SubscribableChannel. +// When ANC is off, the bridge is static and no worker is started. +func (c *Controller) Run(stopCh <-chan struct{}) { + klog.InfoS("Starting secondary network controller") + defer klog.InfoS("Shutting down secondary network controller") + + if c.dynamicBridgeReconcile { + defer c.queue.ShutDown() + go func() { + for c.processNextItem() { + } + }() + } + + go c.podController.Run(stopCh) + + <-stopCh +} + +func (c *Controller) processNextItem() bool { + key, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(key) + + if err := c.reconcileBridge(); err != nil { + c.queue.AddRateLimited(key) + klog.ErrorS(err, "Failed to reconcile secondary network bridge, requeuing") + } else { + c.queue.Forget(key) + } + return true +} + +// createOVSBridgeClient creates a new OVS bridge with the given name and +// multicast-snooping setting, returning the client for the newly created bridge. +func createOVSBridgeClient(bridgeName string, enableMulticastSnooping bool, ovsdbConn *ovsdb.OVSDB) (ovsconfig.OVSBridgeClient, error) { + var options []ovsconfig.OVSBridgeOption + if enableMulticastSnooping { + options = append(options, ovsconfig.WithMcastSnooping()) + } + client := newOVSBridgeFn(bridgeName, ovsconfig.OVSDatapathSystem, ovsdbConn, options...) + if err := client.Create(); err != nil { + return nil, fmt.Errorf("failed to create OVS bridge %s: %v", bridgeName, err) + } + klog.InfoS("OVS bridge created", "bridge", bridgeName) + return client, nil +} + +// resolveAndCreateOVSBridge evaluates effectiveBridge() and creates the OVS bridge. +// Returns the effective OVSBridgeConfig (nil when no bridge is configured), the +// corresponding OVSBridgeClient, and any error. +func resolveAndCreateOVSBridge( + effectiveBridge func() *agenttypes.OVSBridgeConfig, + ovsdbConn *ovsdb.OVSDB, +) (*agenttypes.OVSBridgeConfig, ovsconfig.OVSBridgeClient, error) { + effectiveBridgeCfg := effectiveBridge() + if effectiveBridgeCfg == nil { + return nil, nil, nil + } + ovsBridgeClient, err := createOVSBridgeClient(effectiveBridgeCfg.BridgeName, effectiveBridgeCfg.EnableMulticastSnooping, ovsdbConn) + if err != nil { + return nil, nil, err + } + return effectiveBridgeCfg, ovsBridgeClient, nil +} + // reconcileBridge is called by the work queue worker when the AntreaNodeConfig sync // controller signals a change (or on retries). It re-computes the desired // bridge configuration and reconciles the OVS state accordingly: @@ -271,13 +360,13 @@ func (c *Controller) clearBridgeState() { // deleteAndDisconnectBridge deletes the OVS bridge for cfg, notifies the pod controller that // no secondary bridge is in use, and clears controller state. cfg may be nil (no-op delete). // Controller state is cleared immediately after the bridge is deleted so that a retry (if -// UpdateOVSBridge fails below) does not call Delete() on an already-gone bridge. +// UpdateOVSBridgeClient fails below) does not call Delete() on an already-gone bridge. func (c *Controller) deleteAndDisconnectBridge(cfg *agenttypes.OVSBridgeConfig) error { if err := c.deleteBridge(cfg); err != nil { return err } c.clearBridgeState() - if err := c.podController.UpdateOVSBridge(nil); err != nil { + if err := c.podController.UpdateOVSBridgeClient(nil); err != nil { return err } return nil @@ -358,13 +447,12 @@ func (c *Controller) createAndConnectBridge(desired *agenttypes.OVSBridgeConfig) // Notify PodController of the new bridge so it uses the correct OVS client // for future Pod interface operations and reloads its interface store. - return c.podController.UpdateOVSBridge(newClient) + return c.podController.UpdateOVSBridgeClient(newClient) } // updatePhysicalInterfaces reconciles OVS ports on an existing bridge to match the -// desired config. effectiveBridgeCfg is committed once per step under a single lock -// acquisition so that, if a later step fails, the next reconciliation retry sees an -// accurate picture of what is actually present on the bridge. +// desired config. effectiveBridgeCfg is updated under a lock so that concurrent +// podwatch events see a consistent state. func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridgeConfig) error { // Build a set of desired interface names. desiredIfaces := make(map[string]agenttypes.PhysicalInterfaceConfig, len(desired.PhysicalInterfaces)) @@ -448,11 +536,13 @@ func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridg // Build the post-deletion effective config: drop all interfaces not in desired // (whether just deleted or already absent) so the next retry does not re-attempt them. current := prev.DeepCopy() - for _, pi := range prev.PhysicalInterfaces { - if _, ok := desiredIfaces[pi.Name]; !ok { - current = current.WithoutInterface(pi.Name) + kept := current.PhysicalInterfaces[:0] + for _, pi := range current.PhysicalInterfaces { + if _, ok := desiredIfaces[pi.Name]; ok { + kept = append(kept, pi) } } + current.PhysicalInterfaces = kept c.mu.Lock() c.effectiveBridgeCfg = current c.mu.Unlock() @@ -463,11 +553,6 @@ func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridg if err := clearStaleTrunks(c.ovsBridgeClient, desired.PhysicalInterfaces); err != nil { return err } - // Reflect the cleared trunk state for any interface whose desired config now - // carries no AllowedVLANs, then commit once for the whole step. - c.mu.Lock() - c.effectiveBridgeCfg = current.WithClearedTrunks(desired.PhysicalInterfaces) - c.mu.Unlock() // Step 3: add new ports and update the trunk VLAN list on existing ports that // have AllowedVLANs. connectPhyInterfacesToOVSBridge creates the port when it @@ -511,7 +596,7 @@ func connectPhyInterfacesToOVSBridge(ovsBridgeClient ovsconfig.OVSBridgeClient, if len(pi.AllowedVLANs) > 0 { if notConnected != nil { - // Pass ofPortRequest=0 so OVS auto-assigns the OF port number. + // Pass ofPortRequest=0 so OVS auto-assign the OF port number. // Pinning a number derived from the loop index would collide across // reconciliation cycles when the interface list is a filtered subset. if _, err := ovsBridgeClient.CreateTrunkPort(pi.Name, 0, pi.AllowedVLANs, externalIDs); err != nil { @@ -528,7 +613,7 @@ func connectPhyInterfacesToOVSBridge(ovsBridgeClient ovsconfig.OVSBridgeClient, } if notConnected != nil { - // Pass ofPortRequest=0 so OVS auto-assigns the OF port number. + // Pass ofPortRequest=0 so OVS auto-assign the OF port number. if _, err := ovsBridgeClient.CreateUplinkPort(pi.Name, 0, externalIDs); err != nil { return fmt.Errorf("failed to create OVS uplink port %s: %v", pi.Name, err) } diff --git a/pkg/agent/secondarynetwork/init_linux_test.go b/pkg/agent/secondarynetwork/init_linux_test.go index ce58e8df492..a8d13a90580 100644 --- a/pkg/agent/secondarynetwork/init_linux_test.go +++ b/pkg/agent/secondarynetwork/init_linux_test.go @@ -334,7 +334,7 @@ func TestInitialize(t *testing.T) { ovsBridgeClient: mockOVSBridgeClient, effectiveBridgeCfg: tc.bridgeCfg, } - err := c.Initialize() + err := c.Initialize(nil) if tc.expectedErr != "" { assert.ErrorContains(t, err, tc.expectedErr) } else { @@ -353,7 +353,7 @@ const ( // TestReconcileBridge tests the reconcileBridge function with various transitions. // fakePodController implements podControllerInterface for unit tests. -// It records calls to UpdateOVSBridge so tests can assert on them. +// It records calls to UpdateOVSBridgeClient so tests can assert on them. type fakePodController struct { updateBridgeCalls []ovsconfig.OVSBridgeClient updateBridgeErr error @@ -363,7 +363,7 @@ func (f *fakePodController) Run(_ <-chan struct{}) {} func (f *fakePodController) AllowCNIDelete(_, _ string) bool { return true } -func (f *fakePodController) UpdateOVSBridge(c ovsconfig.OVSBridgeClient) error { +func (f *fakePodController) UpdateOVSBridgeClient(c ovsconfig.OVSBridgeClient) error { f.updateBridgeCalls = append(f.updateBridgeCalls, c) return f.updateBridgeErr } @@ -377,8 +377,8 @@ func TestReconcileBridge(t *testing.T) { desiredCfg *agenttypes.OVSBridgeConfig // returned by effectiveBridge() in production expectedCalls func(old, new *ovsconfigtest.MockOVSBridgeClient) wantNewClient bool // whether c.ovsBridgeClient should be the "new" mock after reconcile - wantUpdateBridgeN int // expected number of UpdateOVSBridge calls on the podController - wantUpdateBridgeNil bool // whether the last UpdateOVSBridge call should pass nil + wantUpdateBridgeN int // expected number of UpdateOVSBridgeClient calls on the podController + wantUpdateBridgeNil bool // whether the last UpdateOVSBridgeClient call should pass nil // wantRestoreCalls lists the (bridge, iface) pairs that restoreHostInterfaceConfigFn // must be called with, in order, when an interface is removed from the config. wantRestoreCalls []struct{ bridge, iface string } @@ -471,7 +471,7 @@ func TestReconcileBridge(t *testing.T) { }, nil).Times(1) }, wantNewClient: true, - // UpdateOVSBridge(nil) after old bridge deleted, then UpdateOVSBridge(new) from createAndConnectBridge. + // UpdateOVSBridgeClient(nil) after old bridge deleted, then UpdateOVSBridgeClient(new) from createAndConnectBridge. wantUpdateBridgeN: 2, }, { @@ -497,7 +497,7 @@ func TestReconcileBridge(t *testing.T) { }, nil).Times(1) }, wantNewClient: true, - // UpdateOVSBridge(nil) after old bridge deleted, then UpdateOVSBridge(new) from createAndConnectBridge. + // UpdateOVSBridgeClient(nil) after old bridge deleted, then UpdateOVSBridgeClient(new) from createAndConnectBridge. wantUpdateBridgeN: 2, }, { @@ -517,7 +517,7 @@ func TestReconcileBridge(t *testing.T) { {Name: eth1, IFName: eth1, Trunks: nil}, }, nil).Times(1) }, - // No UpdateOVSBridge: same bridge, client unchanged. + // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { name: "rule 3: same bridge name — remove old interface", @@ -535,7 +535,7 @@ func TestReconcileBridge(t *testing.T) { {Name: eth1, IFName: eth1, Trunks: nil}, }, nil).Times(1) }, - // No UpdateOVSBridge: same bridge, client unchanged. + // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { name: "rule 3: same bridge, add interface with VLANs (rule 5)", @@ -555,7 +555,7 @@ func TestReconcileBridge(t *testing.T) { {Name: eth1, IFName: eth1, Trunks: nil}, }, nil).Times(1) }, - // No UpdateOVSBridge: same bridge, client unchanged. + // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { // Regression test: existing port gains AllowedVLANs (e.g. ANC CR applied after @@ -574,7 +574,7 @@ func TestReconcileBridge(t *testing.T) { old.EXPECT().GetOFPort(eth1, false).Return(int32(uplinkOFPort), nil) old.EXPECT().SetPortTrunks(eth1, []string{"100", "300"}).Return(nil) }, - // No UpdateOVSBridge: same bridge, client unchanged. + // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { // Regression test: existing trunk ports have AllowedVLANs cleared (e.g. ANC CR @@ -603,7 +603,7 @@ func TestReconcileBridge(t *testing.T) { old.EXPECT().SetPortTrunks(eth1, nil).Return(nil) old.EXPECT().SetPortTrunks(eth2, nil).Return(nil) }, - // No UpdateOVSBridge: same bridge, client unchanged. + // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { // Regression: eth1 loses AllowedVLANs AND eth2 has a stale trunk 300 that @@ -634,7 +634,7 @@ func TestReconcileBridge(t *testing.T) { old.EXPECT().SetPortTrunks(eth1, nil).Return(nil) old.EXPECT().SetPortTrunks(eth2, nil).Return(nil) }, - // No UpdateOVSBridge: same bridge, client unchanged. + // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { // anc.yaml → anc1.yaml: eth1 had allowedVLANs:["100"] and eth2 had @@ -673,7 +673,7 @@ func TestReconcileBridge(t *testing.T) { old.EXPECT().SetPortTrunks(eth1, nil).Return(nil) old.EXPECT().SetPortTrunks(eth2, nil).Return(nil) }, - // No UpdateOVSBridge: same bridge, client unchanged. + // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { // Regression: eth1 was connected via PrepareHostInterfaceConnection (single-interface @@ -753,15 +753,15 @@ func TestReconcileBridge(t *testing.T) { assert.Equal(t, newMock, c.ovsBridgeClient) } c.mu.RUnlock() - // Verify UpdateOVSBridge was called the expected number of times. + // Verify UpdateOVSBridgeClient was called the expected number of times. assert.Len(t, fakePc.updateBridgeCalls, tc.wantUpdateBridgeN, - "unexpected number of UpdateOVSBridge calls") + "unexpected number of UpdateOVSBridgeClient calls") if tc.wantUpdateBridgeN > 0 { last := fakePc.updateBridgeCalls[len(fakePc.updateBridgeCalls)-1] if tc.wantUpdateBridgeNil { - assert.Nil(t, last, "expected UpdateOVSBridge(nil) for bridge deletion") + assert.Nil(t, last, "expected UpdateOVSBridgeClient(nil) for bridge deletion") } else { - assert.Equal(t, newMock, last, "expected UpdateOVSBridge(newMock) for bridge creation") + assert.Equal(t, newMock, last, "expected UpdateOVSBridgeClient(newMock) for bridge creation") } } // Verify restoreHostInterfaceConfigFn calls. diff --git a/pkg/agent/secondarynetwork/init_windows.go b/pkg/agent/secondarynetwork/init_windows.go index c03bde692eb..55dc7237742 100644 --- a/pkg/agent/secondarynetwork/init_windows.go +++ b/pkg/agent/secondarynetwork/init_windows.go @@ -17,7 +17,14 @@ package secondarynetwork -func (c *Controller) Initialize() error { +import ( + "github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb" + + agenttypes "antrea.io/antrea/v2/pkg/agent/types" + "antrea.io/antrea/v2/pkg/ovs/ovsconfig" +) + +func (c *Controller) Initialize(stopCh <-chan struct{}) error { return nil } @@ -29,3 +36,14 @@ func (c *Controller) reconcileBridge() error { // Not supported on Windows. return nil } + +func (c *Controller) Run(stopCh <-chan struct{}) { + return +} + +func resolveAndCreateOVSBridge( + effectiveBridge func() *agenttypes.OVSBridgeConfig, + ovsdbConn *ovsdb.OVSDB, +) (*agenttypes.OVSBridgeConfig, ovsconfig.OVSBridgeClient, error) { + return nil, nil, nil +} diff --git a/pkg/agent/secondarynetwork/podwatch/controller.go b/pkg/agent/secondarynetwork/podwatch/controller.go index fa601366a0e..94e517b0f68 100644 --- a/pkg/agent/secondarynetwork/podwatch/controller.go +++ b/pkg/agent/secondarynetwork/podwatch/controller.go @@ -820,14 +820,14 @@ func (pc *PodController) loadOVSInterfaceStore(client ovsconfig.OVSBridgeClient) return nil } -// UpdateOVSBridge replaces the OVS bridge client and interface configurator used by the +// UpdateOVSBridgeClient replaces the OVS bridge client and interface configurator used by the // PodController. It is called by the secondary network controller whenever the effective // bridge configuration changes (e.g. a new AntreaNodeConfig takes effect): // - newClient == nil: the bridge was deleted; clear the client and configurator and purge the // interface store so future Pod events are handled with no bridge. // - newClient != nil: a new (or replacement) bridge was created; install the new client and a // fresh configurator, then reload the interface store from the new bridge. -func (pc *PodController) UpdateOVSBridge(newClient ovsconfig.OVSBridgeClient) error { +func (pc *PodController) UpdateOVSBridgeClient(newClient ovsconfig.OVSBridgeClient) error { var newConfigurator InterfaceConfigurator if newClient != nil { var err error diff --git a/pkg/agent/types/antreanodeconfig.go b/pkg/agent/types/antreanodeconfig.go index 6aa0d154cda..2a0dc1ce772 100644 --- a/pkg/agent/types/antreanodeconfig.go +++ b/pkg/agent/types/antreanodeconfig.go @@ -69,34 +69,3 @@ func (b *OVSBridgeConfig) DeepCopy() *OVSBridgeConfig { } return cp } - -// WithoutInterface returns a new OVSBridgeConfig that is identical to b except -// that the interface with the given name is removed from PhysicalInterfaces. -func (b *OVSBridgeConfig) WithoutInterface(name string) *OVSBridgeConfig { - cp := b.DeepCopy() - filtered := cp.PhysicalInterfaces[:0] - for _, pi := range cp.PhysicalInterfaces { - if pi.Name != name { - filtered = append(filtered, pi) - } - } - cp.PhysicalInterfaces = filtered - return cp -} - -// WithClearedTrunks returns a new OVSBridgeConfig where AllowedVLANs is -// cleared for any interface whose entry in desired has no AllowedVLANs. This -// reflects the state after clearStaleTrunks has run successfully. -func (b *OVSBridgeConfig) WithClearedTrunks(desired []PhysicalInterfaceConfig) *OVSBridgeConfig { - desiredMap := make(map[string]PhysicalInterfaceConfig, len(desired)) - for _, pi := range desired { - desiredMap[pi.Name] = pi - } - cp := b.DeepCopy() - for i, pi := range cp.PhysicalInterfaces { - if d, ok := desiredMap[pi.Name]; ok && len(d.AllowedVLANs) == 0 { - cp.PhysicalInterfaces[i].AllowedVLANs = nil - } - } - return cp -} diff --git a/pkg/agent/util/net_linux.go b/pkg/agent/util/net_linux.go index 93f96212e9d..4ae1d05adc5 100644 --- a/pkg/agent/util/net_linux.go +++ b/pkg/agent/util/net_linux.go @@ -498,10 +498,10 @@ func PrepareHostInterfaceConnection( } // RestoreHostInterfaceConfiguration restores the configuration from the bridge back to the host -// interface, reverting the actions taken in PrepareHostInterfaceConnection. It returns an error +// interface, reverting the actions taken in PrepareHostInterfaceConnection. It returns an error // only when the critical uplink OVS port (bridgedName, e.g. "eth0~") cannot be deleted, because // that prevents the kernel-interface rename and leaves the system in a fully-intact state that -// the caller can retry. All other sub-step failures (IP/route restore, internal port delete, +// the caller can retry. All other sub-step failures (IP/route restore, internal port delete, // rename) are logged but do not cause a return error, since they represent best-effort cleanup // after the point of no return. func RestoreHostInterfaceConfiguration(brName string, interfaceName string) error { @@ -527,8 +527,9 @@ func RestoreHostInterfaceConfiguration(brName string, interfaceName string) erro klog.ErrorS(err, "Delete OVS port failed", "port", interfaceName) } } - // remove host interface (eth0~) from bridge — this is the critical step: if it fails the - // kernel interface is still renamed and both OVS ports still exist, so the caller can retry. + // remove host interface (eth0~) from bridge. This is the critical step - if it fails the + // kernel interface is still renamed and both OVS ports still exist. Return the error at a + // failure so the caller can retry. if err = deleteOVSPort(brName, bridgedName); err != nil { return fmt.Errorf("failed to delete OVS uplink port %s from bridge %s: %w", bridgedName, brName, err) } diff --git a/pkg/ovs/ovsconfig/ovs_client.go b/pkg/ovs/ovsconfig/ovs_client.go index b3727217604..20b54bb2631 100644 --- a/pkg/ovs/ovsconfig/ovs_client.go +++ b/pkg/ovs/ovsconfig/ovs_client.go @@ -436,7 +436,7 @@ func (br *OVSBridge) CreateInternalPort(name string, ofPortRequest int32, mac st if ofPortRequest < 0 || ofPortRequest > ofPortRequestMax { return "", newInvalidArgumentsError(fmt.Sprint("invalid ofPortRequest value: ", ofPortRequest)) } - return br.createPort(name, name, "internal", ofPortRequest, 0, mac, externalIDs, nil) + return br.createPort(name, name, "internal", ofPortRequest, 0, mac, externalIDs, nil, nil) } // CreateTunnelPort creates a tunnel port with the specified name and type on @@ -524,7 +524,7 @@ func (br *OVSBridge) createTunnelPort( options["csum"] = "true" } - return br.createPort(name, name, string(tunnelType), ofPortRequest, 0, "", externalIDs, options) + return br.createPort(name, name, string(tunnelType), ofPortRequest, 0, "", externalIDs, options, nil) } // GetInterfaceOptions returns the options of the provided interface. @@ -600,7 +600,7 @@ func ParseTunnelInterfaceOptions(portData *OVSPortData) (net.IP, net.IP, int32, // CreateUplinkPort creates uplink port. func (br *OVSBridge) CreateUplinkPort(name string, ofPortRequest int32, externalIDs map[string]interface{}) (string, Error) { - return br.createPort(name, name, "", ofPortRequest, 0, "", externalIDs, nil) + return br.createPort(name, name, "", ofPortRequest, 0, "", externalIDs, nil, nil) } // parseVLANSpecs converts VLAN specifications such as "100" or "200-300" into @@ -646,72 +646,7 @@ func (br *OVSBridge) CreateTrunkPort(name string, ofPortRequest int32, vlanSpecs if err != nil { return "", newInvalidArgumentsError(fmt.Sprintf("invalid VLAN specs for port %q: %v", name, err)) } - - var externalIDMap []interface{} - if externalIDs != nil { - externalIDMap = helpers.MakeOVSDBMap(externalIDs) - } - - for _, id := range br.requiredPortExternalIDs { - if _, ok := externalIDs[id]; !ok { - return "", newInvalidArgumentsError(fmt.Sprintf("missing required externalID '%s' for port '%s'", id, name)) - } - } - - tx := br.ovsdb.Transaction(openvSwitchSchema) - - interf := Interface{ - Name: name, - OFPortRequest: ofPortRequest, - } - ifNamedUUID := tx.Insert(dbtransaction.Insert{ - Table: "Interface", - Row: interf, - }) - - port := Port{ - Name: name, - Interfaces: helpers.MakeOVSDBSet(map[string]interface{}{ - "named-uuid": []string{ifNamedUUID}, - }), - ExternalIDs: externalIDMap, - } - - var portRow interface{} - if len(vlanIDs) > 0 { - ids := make([]interface{}, len(vlanIDs)) - for i, v := range vlanIDs { - ids[i] = v - } - portRow = TrunkPort{ - Port: port, - Trunks: []interface{}{"set", ids}, - } - } else { - portRow = port - } - - portNamedUUID := tx.Insert(dbtransaction.Insert{ - Table: "Port", - Row: portRow, - }) - - mutateSet := helpers.MakeOVSDBSet(map[string]interface{}{ - "named-uuid": []string{portNamedUUID}, - }) - tx.Mutate(dbtransaction.Mutate{ - Table: "Bridge", - Mutations: [][]interface{}{{"ports", "insert", mutateSet}}, - Where: [][]interface{}{{"name", "==", br.name}}, - }) - - res, err, temporary := tx.Commit() - if err != nil { - klog.Error("Transaction failed: ", err) - return "", NewTransactionError(err, temporary) - } - - return res[1].UUID[1], nil + return br.createPort(name, name, "", ofPortRequest, 0, "", externalIDs, nil, vlanIDs) } // CreatePort creates a port with the specified name on the bridge, and connects @@ -719,7 +654,7 @@ func (br *OVSBridge) CreateTrunkPort(name string, ofPortRequest int32, vlanSpecs // If externalIDs is not empty, the map key/value pairs will be set to the // port's external_ids. func (br *OVSBridge) CreatePort(name, ifDev string, externalIDs map[string]interface{}) (string, Error) { - return br.createPort(name, ifDev, "", 0, 0, "", externalIDs, nil) + return br.createPort(name, ifDev, "", 0, 0, "", externalIDs, nil, nil) } // CreateAccessPort creates a port with the specified name and VLAN ID on the bridge, and connects @@ -728,10 +663,10 @@ func (br *OVSBridge) CreatePort(name, ifDev string, externalIDs map[string]inter // port's external_ids. // vlanID=0 will perform same behavior as CreatePort. func (br *OVSBridge) CreateAccessPort(name, ifDev string, externalIDs map[string]interface{}, vlanID uint16) (string, Error) { - return br.createPort(name, ifDev, "", 0, vlanID, "", externalIDs, nil) + return br.createPort(name, ifDev, "", 0, vlanID, "", externalIDs, nil, nil) } -func (br *OVSBridge) createPort(name, ifName, ifType string, ofPortRequest int32, vlanID uint16, mac string, externalIDs, options map[string]interface{}) (string, Error) { +func (br *OVSBridge) createPort(name, ifName, ifType string, ofPortRequest int32, vlanID uint16, mac string, externalIDs, options map[string]interface{}, trunks []uint16) (string, Error) { var externalIDMap []interface{} var optionMap []interface{} @@ -770,9 +705,16 @@ func (br *OVSBridge) createPort(name, ifName, ifType string, ofPortRequest int32 ExternalIDs: externalIDMap, } var portInterface interface{} - portInterface = port - if vlanID > 0 { + if trunks != nil { + ids := make([]interface{}, len(trunks)) + for i, v := range trunks { + ids[i] = v + } + portInterface = TrunkPort{Port: port, Trunks: []interface{}{"set", ids}} + } else if vlanID > 0 { portInterface = AccessPort{Port: port, Tag: uint32(vlanID)} + } else { + portInterface = port } portNamedUUID := tx.Insert(dbtransaction.Insert{ Table: "Port", From 94815d4d159578975468d487bda79dbe34643a0d Mon Sep 17 00:00:00 2001 From: Lan Luo Date: Thu, 4 Jun 2026 18:12:59 +0800 Subject: [PATCH 13/13] Address latest comments Signed-off-by: Lan Luo --- ci/kind/test-secondary-network-kind.sh | 10 + cmd/antrea-agent/agent.go | 6 +- pkg/agent/secondarynetwork/init.go | 102 ------ pkg/agent/secondarynetwork/init_linux.go | 340 +++++++++++++----- pkg/agent/secondarynetwork/init_linux_test.go | 146 ++++++-- pkg/agent/secondarynetwork/init_windows.go | 33 +- 6 files changed, 410 insertions(+), 227 deletions(-) diff --git a/ci/kind/test-secondary-network-kind.sh b/ci/kind/test-secondary-network-kind.sh index 64e705896fb..f39e9414518 100755 --- a/ci/kind/test-secondary-network-kind.sh +++ b/ci/kind/test-secondary-network-kind.sh @@ -242,9 +242,19 @@ function run_test_with_antrenodeconfigs { kubectl apply -f "$ANTREA_NODE_CONFIGS_LINUX_YAML" sleep 5 go test -v -timeout="$TIMEOUT" antrea.io/antrea/v2/test/e2e-secondary-network -run=TestVLANNetwork -provider=kind "${TEST_OPTIONS[@]}" + + echo "Modifying multicast-snooping under the same bridge name" + kubectl patch antreanodeconfig secondary-network-node-pool-all-linux --type=merge -p '{"spec":{"secondaryNetwork":{"ovsBridges":[{"bridgeName":"br1","enableMulticastSnooping":true,"physicalInterfaces":[{"name":"eth1"}]}]}}}' + sleep 5 + go test -v -timeout="$TIMEOUT" antrea.io/antrea/v2/test/e2e-secondary-network -run=TestVLANNetwork -provider=kind "${TEST_OPTIONS[@]}" + echo "Clean up the previous AntreaNodeConfig" kubectl delete -f "$ANTREA_NODE_CONFIGS_LINUX_YAML" sleep 5 + + echo "Verifying fallback to static config after AntreaNodeConfig deletion" + go test -v -timeout="$TIMEOUT" antrea.io/antrea/v2/test/e2e-secondary-network -run=TestVLANNetwork -provider=kind "${TEST_OPTIONS[@]}" + configure_vlan_bridges echo "Apply new AntreaNodeConfigs for NodePool 1 and NodePool 2 nodes" kubectl apply -f "$ANTREA_NODE_CONFIGS_NODEPOOL_YAML" diff --git a/cmd/antrea-agent/agent.go b/cmd/antrea-agent/agent.go index add963080dc..feae400b300 100644 --- a/cmd/antrea-agent/agent.go +++ b/cmd/antrea-agent/agent.go @@ -652,7 +652,9 @@ func run(o *Options) error { if err != nil { return fmt.Errorf("failed to create secondary network controller: %w", err) } - cniDeleteChecker = secondaryNetworkController + if secondaryNetworkController != nil { + cniDeleteChecker = secondaryNetworkController + } } if o.nodeType == config.K8sNode { @@ -996,7 +998,7 @@ func run(o *Options) error { // secondaryNetworkController Initialize must be run after FlowRestoreComplete for the case that Node // IPs are moved to the secondary OVS bridge. When AntreaNodeConfig drives the secondary bridge, // Initialize waits for the first ANC snapshot before setting up the bridge. - if features.DefaultFeatureGate.Enabled(features.SecondaryNetwork) { + if features.DefaultFeatureGate.Enabled(features.SecondaryNetwork) && secondaryNetworkController != nil { defer secondaryNetworkController.Restore() if err = secondaryNetworkController.Initialize(stopCh); err != nil { return fmt.Errorf("failed to initialize secondary network: %v", err) diff --git a/pkg/agent/secondarynetwork/init.go b/pkg/agent/secondarynetwork/init.go index 94559b620f9..fad64a9631e 100644 --- a/pkg/agent/secondarynetwork/init.go +++ b/pkg/agent/secondarynetwork/init.go @@ -15,30 +15,17 @@ package secondarynetwork import ( - "errors" - "fmt" "sync" "sync/atomic" "time" "github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb" - netdefclient "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/client/clientset/versioned/typed/k8s.cni.cncf.io/v1" - clientset "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/cache" "k8s.io/client-go/util/workqueue" - componentbaseconfig "k8s.io/component-base/config" - "k8s.io/klog/v2" "antrea.io/antrea/v2/pkg/agent/antreanodeconfig" - "antrea.io/antrea/v2/pkg/agent/config" - "antrea.io/antrea/v2/pkg/agent/interfacestore" - "antrea.io/antrea/v2/pkg/agent/secondarynetwork/podwatch" agenttypes "antrea.io/antrea/v2/pkg/agent/types" - crdlisters "antrea.io/antrea/v2/pkg/client/listers/crd/v1beta1" agentconfig "antrea.io/antrea/v2/pkg/config/agent" "antrea.io/antrea/v2/pkg/ovs/ovsconfig" - "antrea.io/antrea/v2/pkg/util/channel" - "antrea.io/antrea/v2/pkg/util/k8s" ) const ( @@ -92,81 +79,6 @@ type Controller struct { queue workqueue.TypedRateLimitingInterface[string] } -func NewController( - clientConnectionConfig componentbaseconfig.ClientConnectionConfiguration, - kubeAPIServerOverride string, - k8sClient clientset.Interface, - podInformer cache.SharedIndexInformer, - podUpdateSubscriber channel.Subscriber, - primaryInterfaceStore interfacestore.InterfaceStore, - nodeConfig *config.NodeConfig, - secNetConfig *agentconfig.SecondaryNetworkConfig, - ovsdbConn *ovsdb.OVSDB, - ipPoolLister crdlisters.IPPoolLister, - ancUpdateSubscriber channel.Subscriber, -) (*Controller, error) { - c := &Controller{ - secNetConfig: secNetConfig, - nodeName: nodeConfig.Name, - ovsdbConn: ovsdbConn, - } - - if ancUpdateSubscriber != nil { - c.dynamicBridgeReconcile = true - c.ancFirstSnapshotCh = make(chan struct{}) - c.queue = workqueue.NewTypedRateLimitingQueueWithConfig( - workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), - workqueue.TypedRateLimitingQueueConfig[string]{Name: "secondaryNetworkBridge"}, - ) - } - - var effectiveBridgeCfg *agenttypes.OVSBridgeConfig - var ovsBridgeClient ovsconfig.OVSBridgeClient - var err error - - if !c.dynamicBridgeReconcile { - effectiveBridgeCfg, ovsBridgeClient, err = resolveAndCreateOVSBridge(c.effectiveOVSBridge, c.ovsdbConn) - if err != nil { - return nil, err - } - } - - netAttachDefClient, err := createNetworkAttachDefClient(clientConnectionConfig, kubeAPIServerOverride) - if err != nil { - return nil, fmt.Errorf("NetworkAttachmentDefinition client creation failed: %v", err) - } - - podWatchController, err := podwatch.NewPodController( - k8sClient, netAttachDefClient, podInformer, - podUpdateSubscriber, primaryInterfaceStore, nodeConfig, ovsBridgeClient, ipPoolLister) - if err != nil { - return nil, err - } - - c.ovsBridgeClient = ovsBridgeClient - c.effectiveBridgeCfg = effectiveBridgeCfg - c.podController = podWatchController - - if c.dynamicBridgeReconcile { - ancUpdateSubscriber.Subscribe(func(p interface{}) { - snap, ok := p.(*antreanodeconfig.Snapshot) - if !ok { - klog.ErrorS(errors.New("unexpected notify payload"), "AntreaNodeConfig notify payload", "type", fmt.Sprintf("%T", p)) - return - } - if snap == nil { - klog.ErrorS(errors.New("nil snapshot from notifier"), "AntreaNodeConfig notify payload") - return - } - c.latestANCSnapshot.Store(snap) - c.signalFirstANC.Do(func() { close(c.ancFirstSnapshotCh) }) - c.enqueue() - }) - } - - return c, nil -} - // effectiveOVSBridge returns the desired OVS bridge for this node. When AntreaNodeConfig // drives the bridge, only snapshots delivered on the notify channel are used. // When ANC is disabled, only static agent config is consulted. @@ -188,17 +100,3 @@ func (c *Controller) enqueue() { func (c *Controller) AllowCNIDelete(podName, podNamespace string) bool { return c.podController.AllowCNIDelete(podName, podNamespace) } - -// CreateNetworkAttachDefClient creates net-attach-def client handle from the given config. -func createNetworkAttachDefClient(cfg componentbaseconfig.ClientConnectionConfiguration, kubeAPIServerOverride string) (netdefclient.K8sCniCncfIoV1Interface, error) { - kubeConfig, err := k8s.CreateRestConfig(cfg, kubeAPIServerOverride) - if err != nil { - return nil, err - } - - netAttachDefClient, err := netdefclient.NewForConfig(kubeConfig) - if err != nil { - return nil, err - } - return netAttachDefClient, nil -} diff --git a/pkg/agent/secondarynetwork/init_linux.go b/pkg/agent/secondarynetwork/init_linux.go index 6396f4b5cb0..75c3389f057 100644 --- a/pkg/agent/secondarynetwork/init_linux.go +++ b/pkg/agent/secondarynetwork/init_linux.go @@ -18,35 +18,138 @@ package secondarynetwork import ( + "errors" "fmt" "net" - "reflect" "github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb" + netdefclient "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/client/clientset/versioned/typed/k8s.cni.cncf.io/v1" + clientset "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + componentbaseconfig "k8s.io/component-base/config" "k8s.io/klog/v2" + "antrea.io/antrea/v2/pkg/agent/antreanodeconfig" + "antrea.io/antrea/v2/pkg/agent/config" "antrea.io/antrea/v2/pkg/agent/interfacestore" + "antrea.io/antrea/v2/pkg/agent/secondarynetwork/podwatch" agenttypes "antrea.io/antrea/v2/pkg/agent/types" "antrea.io/antrea/v2/pkg/agent/util" + crdlisters "antrea.io/antrea/v2/pkg/client/listers/crd/v1beta1" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" "antrea.io/antrea/v2/pkg/ovs/ovsconfig" + "antrea.io/antrea/v2/pkg/util/channel" + "antrea.io/antrea/v2/pkg/util/k8s" ) var ( // Funcs which will be overridden with mock funcs in tests. - interfaceByNameFn = net.InterfaceByName - restoreHostInterfaceConfigFn = util.RestoreHostInterfaceConfiguration // func(brName, ifName string) error - newOVSBridgeFn = ovsconfig.NewOVSBridge + interfaceByNameFn = net.InterfaceByName + // func(bridge, ifName, ifOFPort, externalIDs, mtu) (bridgedName, alreadyExists, error) + prepareHostInterfaceConnectionFn = util.PrepareHostInterfaceConnection + restoreHostInterfaceConfigFn = util.RestoreHostInterfaceConfiguration // func(brName, ifName string) error + newOVSBridgeFn = ovsconfig.NewOVSBridge ) +func NewController( + clientConnectionConfig componentbaseconfig.ClientConnectionConfiguration, + kubeAPIServerOverride string, + k8sClient clientset.Interface, + podInformer cache.SharedIndexInformer, + podUpdateSubscriber channel.Subscriber, + primaryInterfaceStore interfacestore.InterfaceStore, + nodeConfig *config.NodeConfig, + secNetConfig *agentconfig.SecondaryNetworkConfig, + ovsdbConn *ovsdb.OVSDB, + ipPoolLister crdlisters.IPPoolLister, + ancUpdateSubscriber channel.Subscriber, +) (*Controller, error) { + c := &Controller{ + secNetConfig: secNetConfig, + nodeName: nodeConfig.Name, + ovsdbConn: ovsdbConn, + } + + if ancUpdateSubscriber != nil { + c.dynamicBridgeReconcile = true + c.ancFirstSnapshotCh = make(chan struct{}) + c.queue = workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.NewTypedItemExponentialFailureRateLimiter[string](minRetryDelay, maxRetryDelay), + workqueue.TypedRateLimitingQueueConfig[string]{Name: "secondaryNetworkBridge"}, + ) + } + + var effectiveBridgeCfg *agenttypes.OVSBridgeConfig + var ovsBridgeClient ovsconfig.OVSBridgeClient + var err error + + if !c.dynamicBridgeReconcile { + effectiveBridgeCfg, ovsBridgeClient, err = resolveAndCreateOVSBridge(c.effectiveOVSBridge, c.ovsdbConn) + if err != nil { + return nil, err + } + } + + netAttachDefClient, err := createNetworkAttachDefClient(clientConnectionConfig, kubeAPIServerOverride) + if err != nil { + return nil, fmt.Errorf("NetworkAttachmentDefinition client creation failed: %v", err) + } + + podWatchController, err := podwatch.NewPodController( + k8sClient, netAttachDefClient, podInformer, + podUpdateSubscriber, primaryInterfaceStore, nodeConfig, ovsBridgeClient, ipPoolLister) + if err != nil { + return nil, err + } + + c.ovsBridgeClient = ovsBridgeClient + c.effectiveBridgeCfg = effectiveBridgeCfg + c.podController = podWatchController + + if c.dynamicBridgeReconcile { + ancUpdateSubscriber.Subscribe(func(p interface{}) { + snap, ok := p.(*antreanodeconfig.Snapshot) + if !ok { + klog.ErrorS(errors.New("unexpected notify payload"), "AntreaNodeConfig notify payload", "type", fmt.Sprintf("%T", p)) + return + } + if snap == nil { + klog.ErrorS(errors.New("nil snapshot from notifier"), "AntreaNodeConfig notify payload") + return + } + c.latestANCSnapshot.Store(snap) + c.signalFirstANC.Do(func() { close(c.ancFirstSnapshotCh) }) + c.enqueue() + }) + } + + return c, nil +} + +// CreateNetworkAttachDefClient creates net-attach-def client handle from the given config. +func createNetworkAttachDefClient(cfg componentbaseconfig.ClientConnectionConfiguration, kubeAPIServerOverride string) (netdefclient.K8sCniCncfIoV1Interface, error) { + kubeConfig, err := k8s.CreateRestConfig(cfg, kubeAPIServerOverride) + if err != nil { + return nil, err + } + + netAttachDefClient, err := netdefclient.NewForConfig(kubeConfig) + if err != nil { + return nil, err + } + return netAttachDefClient, nil +} + // Initialize sets up OVS bridges at agent start-up. // When AntreaNodeConfig drives the bridge, it first waits for the initial ANC snapshot, // creates the effective bridge, and then reconciles physical interfaces. // It reconciles the current OVS bridge state with the effective bridge config: -// - rule 1: if the effective bridge has the same name as the previous bridge, +// - if the effective bridge has the same name as the previous bridge, // keep the bridge and update the physical interfaces (add/remove ports). -// - rule 2: if the effective bridge name differs from the previous bridge, +// - if the effective bridge name differs from the previous bridge, // delete the old bridge and recreate with the new config. -// - rule 3: when allowedVLANs are set on a physical interface, configure the +// - when allowedVLANs are set on a physical interface, configure the // OVS port in trunk mode with the specified VLAN IDs. func (c *Controller) Initialize(stopCh <-chan struct{}) error { if c.dynamicBridgeReconcile { @@ -75,7 +178,7 @@ func (c *Controller) Initialize(stopCh <-chan struct{}) error { // Only single-interface host-connection migration is supported. if len(bridgeCfg.PhysicalInterfaces) == 1 { iface := bridgeCfg.PhysicalInterfaces[0] - bridgedName, _, err := util.PrepareHostInterfaceConnection( + bridgedName, _, err := prepareHostInterfaceConnectionFn( c.ovsBridgeClient, iface.Name, 0, @@ -293,9 +396,9 @@ func resolveAndCreateOVSBridge( // controller signals a change (or on retries). It re-computes the desired // bridge configuration and reconciles the OVS state accordingly: // -// - rule 1: same bridge name as current → keep bridge, update physical interfaces. -// - rule 2: different bridge name → delete old bridge first, then create the new bridge. -// - rule 3: interfaces with allowedVLANs are configured as OVS trunk ports. +// - same bridge name as current → keep bridge, update physical interfaces. +// - different bridge name → delete old bridge first, then create the new bridge. +// - interfaces with allowedVLANs are configured as OVS trunk ports. // // State-update discipline: after any destructive operation (bridge deletion) the controller // state is immediately cleared under the mutex so that a subsequent retry does not attempt to @@ -310,11 +413,6 @@ func (c *Controller) reconcileBridge() error { if prev == nil && desired == nil { return nil } - // No change — nothing to do. - if prev != nil && desired != nil && reflect.DeepEqual(*prev, *desired) { - return nil - } - klog.InfoS("Reconciling secondary network bridge configuration", "previous", bridgeName(prev), "desired", bridgeName(desired)) @@ -328,7 +426,7 @@ func (c *Controller) reconcileBridge() error { return c.createAndConnectBridge(desired) } - // Case: bridge name changed (rule 2). + // Case: bridge name changed. // The old bridge MUST be deleted before the new one is created. State is // cleared under the mutex immediately after the deletion succeeds so that if // createAndConnectBridge subsequently fails the next retry starts from a clean @@ -342,12 +440,12 @@ func (c *Controller) reconcileBridge() error { return c.createAndConnectBridge(desired) } - // Case: same bridge name (rule 1) — update physical interfaces in-place. - // effectiveBridgeCfg is updated incrementally inside updatePhysicalInterfaces - // after each mutating step, so a retry always sees accurate state. + // Case: same bridge name — update physical interfaces in-place. + // effectiveBridgeCfg is updated only after all same-bridge mutations succeed, + // so a retry does not skip partially-applied host-connection changes. klog.InfoS("Secondary OVS bridge name unchanged, updating physical interfaces", "bridge", desired.BridgeName) - return c.updatePhysicalInterfaces(prev, desired) + return c.updatePhysicalInterfaces(desired) } func (c *Controller) clearBridgeState() { @@ -407,7 +505,7 @@ func (c *Controller) createAndConnectBridge(desired *agenttypes.OVSBridgeConfig) physInterfaces := desired.PhysicalInterfaces if len(physInterfaces) == 1 { - bridgedName, _, err := util.PrepareHostInterfaceConnection( + bridgedName, _, err := prepareHostInterfaceConnectionFn( newClient, physInterfaces[0].Name, 0, @@ -440,24 +538,25 @@ func (c *Controller) createAndConnectBridge(desired *agenttypes.OVSBridgeConfig) return err } + // Notify PodController of the new bridge so it uses the correct OVS client + // for future Pod interface operations and reloads its interface store. + if err := c.podController.UpdateOVSBridgeClient(newClient); err != nil { + return err + } + c.mu.Lock() c.ovsBridgeClient = newClient c.effectiveBridgeCfg = desired c.mu.Unlock() - - // Notify PodController of the new bridge so it uses the correct OVS client - // for future Pod interface operations and reloads its interface store. - return c.podController.UpdateOVSBridgeClient(newClient) + return nil } // updatePhysicalInterfaces reconciles OVS ports on an existing bridge to match the // desired config. effectiveBridgeCfg is updated under a lock so that concurrent // podwatch events see a consistent state. -func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridgeConfig) error { - // Build a set of desired interface names. - desiredIfaces := make(map[string]agenttypes.PhysicalInterfaceConfig, len(desired.PhysicalInterfaces)) - for _, pi := range desired.PhysicalInterfaces { - desiredIfaces[pi.Name] = pi +func (c *Controller) updatePhysicalInterfaces(desired *agenttypes.OVSBridgeConfig) error { + if _, err := createOVSBridgeClient(desired.BridgeName, desired.EnableMulticastSnooping, c.ovsdbConn); err != nil { + return err } // Build a map of currently present ports on the bridge: interface name → UUID, @@ -473,53 +572,36 @@ func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridg existingIFTypes[p.IFName] = p.IFType } - // Step 1: remove ports that were in the previous config but are no longer desired. - // - // When an interface was connected via PrepareHostInterfaceConnection (single-interface - // host-connection path), the kernel interface was renamed eth1 → eth1~, an internal - // OVS port "eth1" was created, and an uplink port "eth1~" was added. In that case - // prev records the original name "eth1", but the bridge holds TWO ports: "eth1" - // (internal) and "eth1~" (uplink). Simply deleting the "eth1" port via DeletePorts - // would leave "eth1~" orphaned on the bridge and the host kernel interface stranded - // under the renamed name. We must call RestoreHostInterfaceConfiguration instead, - // which removes both ports and renames eth1~ back to eth1 on the host. - // - // For plain uplink ports (no sibling) the normal DeletePorts path is used. Those - // are batched into a single OVSDB transaction for atomicity. + if err := c.restoreStaleHostConnections(desired, portList, existingPorts, existingIFTypes); err != nil { + return err + } + + bridgePhysInterfaces, prepareErr := c.prepareBridgePhysicalInterfaces(desired, existingPorts, existingIFTypes) + if prepareErr != nil { + return prepareErr + } + + desiredBridgeIfaces := make(map[string]struct{}, len(bridgePhysInterfaces)) + for _, pi := range bridgePhysInterfaces { + desiredBridgeIfaces[pi.Name] = struct{}{} + } + + // Step 1: remove Antrea-managed uplink ports observed in OVSDB but no longer desired. + // Host-connection pairs are restored above instead of being deleted as raw OVS ports. var toRemoveUUIDs []string var toRemoveNames []string - for _, pi := range prev.PhysicalInterfaces { - if _, ok := desiredIfaces[pi.Name]; ok { + for _, p := range portList { + if _, stillExists := existingPorts[p.IFName]; !stillExists { continue } - bridgedName := util.GenerateUplinkInterfaceName(pi.Name) - if existingIFTypes[pi.Name] == "internal" { - if _, siblingExists := existingPorts[bridgedName]; siblingExists { - // Host-connection pair: restore via the utility that removes both - // OVS ports and renames the kernel interface back. - klog.InfoS("Restoring host interface before removing from bridge", - "device", pi.Name, "bridge", desired.BridgeName) - if err := restoreHostInterfaceConfigFn(desired.BridgeName, pi.Name); err != nil { - return fmt.Errorf("failed to restore host interface %s on bridge %s: %w", - pi.Name, desired.BridgeName, err) - } - // Keep existingPorts in sync so Step 3 does not skip re-adding - // an interface that was just restored (remove-then-re-add scenario). - delete(existingPorts, pi.Name) - delete(existingPorts, bridgedName) - continue - } - } - if uuid, exists := existingPorts[pi.Name]; exists { - toRemoveUUIDs = append(toRemoveUUIDs, uuid) - toRemoveNames = append(toRemoveNames, pi.Name) + if p.ExternalIDs[interfacestore.AntreaInterfaceTypeKey] != interfacestore.AntreaUplink { + continue } - // Also remove the sibling uplink port if present (e.g. eth1~ left over from - // a partially-restored host-connection setup). - if uuid, exists := existingPorts[bridgedName]; exists { - toRemoveUUIDs = append(toRemoveUUIDs, uuid) - toRemoveNames = append(toRemoveNames, bridgedName) + if _, desired := desiredBridgeIfaces[p.IFName]; desired { + continue } + toRemoveUUIDs = append(toRemoveUUIDs, p.UUID) + toRemoveNames = append(toRemoveNames, p.IFName) } if len(toRemoveUUIDs) > 0 { if err := c.ovsBridgeClient.DeletePorts(toRemoveUUIDs); err != nil { @@ -533,24 +615,11 @@ func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridg delete(existingPorts, name) } } - // Build the post-deletion effective config: drop all interfaces not in desired - // (whether just deleted or already absent) so the next retry does not re-attempt them. - current := prev.DeepCopy() - kept := current.PhysicalInterfaces[:0] - for _, pi := range current.PhysicalInterfaces { - if _, ok := desiredIfaces[pi.Name]; ok { - kept = append(kept, pi) - } - } - current.PhysicalInterfaces = kept - c.mu.Lock() - c.effectiveBridgeCfg = current - c.mu.Unlock() // Step 2: clear trunk VLANs on existing ports whose desired config has no AllowedVLANs. // clearStaleTrunks reads the actual OVS port state and only calls SetPortTrunks // when the port genuinely has trunks set, so it is safe to call unconditionally. - if err := clearStaleTrunks(c.ovsBridgeClient, desired.PhysicalInterfaces); err != nil { + if err := clearStaleTrunks(c.ovsBridgeClient, bridgePhysInterfaces); err != nil { return err } @@ -559,7 +628,7 @@ func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridg // does not yet exist, and calls SetPortTrunks when it does and AllowedVLANs is // non-empty. var toConnect []agenttypes.PhysicalInterfaceConfig - for _, pi := range desired.PhysicalInterfaces { + for _, pi := range bridgePhysInterfaces { if _, alreadyExists := existingPorts[pi.Name]; !alreadyExists || len(pi.AllowedVLANs) > 0 { toConnect = append(toConnect, pi) } @@ -576,9 +645,104 @@ func (c *Controller) updatePhysicalInterfaces(prev, desired *agenttypes.OVSBridg return nil } +func (c *Controller) restoreStaleHostConnections( + desired *agenttypes.OVSBridgeConfig, + portList []ovsconfig.OVSPortData, + existingPorts map[string]string, + existingIFTypes map[string]string, +) error { + desiredIfaces := make(map[string]struct{}, len(desired.PhysicalInterfaces)) + for _, pi := range desired.PhysicalInterfaces { + desiredIfaces[pi.Name] = struct{}{} + } + keepSingleHostConnection := len(desired.PhysicalInterfaces) == 1 + + for _, p := range portList { + bridgedName := util.GenerateUplinkInterfaceName(p.IFName) + if existingIFTypes[p.IFName] != "internal" { + continue + } + if _, siblingExists := existingPorts[bridgedName]; !siblingExists { + continue + } + _, desiredIface := desiredIfaces[p.IFName] + if keepSingleHostConnection && desiredIface { + continue + } + klog.InfoS("Restoring host interface before reconciling secondary OVS bridge uplinks", + "device", p.IFName, "bridge", desired.BridgeName) + if err := restoreHostInterfaceConfigFn(desired.BridgeName, p.IFName); err != nil { + return fmt.Errorf("failed to restore host interface %s on bridge %s: %w", + p.IFName, desired.BridgeName, err) + } + delete(existingPorts, p.IFName) + delete(existingPorts, bridgedName) + delete(existingIFTypes, p.IFName) + delete(existingIFTypes, bridgedName) + } + return nil +} + +// prepareBridgePhysicalInterfaces returns the physical OVS port configs that should be used for +// trunk reconciliation and port connection on the bridge. +// +// AntreaNodeConfig records the original host interface name, but a single-uplink bridge does not +// use that original name as the physical uplink port. PrepareHostInterfaceConnection renames the +// host NIC from ethX to ethX~, creates ethX as an internal OVS port for host networking, and uses +// ethX~ as the real physical uplink. Therefore same-bridge updates must apply AllowedVLANs and +// stale-trunk clearing to ethX~, not to the internal ethX port. +// +// This helper normalizes desired.PhysicalInterfaces into the actual bridge-side physical ports. +// For single-uplink configs it prepares or reuses the host-connection setup and returns the +// generated ethX~ uplink. For multi-uplink configs, stale host-connection pairs have already +// been restored from observed OVSDB state, so the original interfaces are returned unchanged. +func (c *Controller) prepareBridgePhysicalInterfaces( + desired *agenttypes.OVSBridgeConfig, + existingPorts map[string]string, + existingIFTypes map[string]string, +) ([]agenttypes.PhysicalInterfaceConfig, error) { + if len(desired.PhysicalInterfaces) == 1 { + iface := desired.PhysicalInterfaces[0] + bridgedName := util.GenerateUplinkInterfaceName(iface.Name) + if _, exists := existingPorts[bridgedName]; exists { + return []agenttypes.PhysicalInterfaceConfig{ + {Name: bridgedName, AllowedVLANs: iface.AllowedVLANs}, + }, nil + } + if uuid, exists := existingPorts[iface.Name]; exists && existingIFTypes[iface.Name] != "internal" { + if err := c.ovsBridgeClient.DeletePorts([]string{uuid}); err != nil { + return nil, fmt.Errorf("failed to remove OVS port %s from bridge %s before host connection setup: %v", + iface.Name, desired.BridgeName, err) + } + delete(existingPorts, iface.Name) + delete(existingIFTypes, iface.Name) + klog.InfoS("Physical interface removed from secondary OVS bridge before host connection setup", + "device", iface.Name, "bridge", desired.BridgeName) + } + + bridgedName, _, err := prepareHostInterfaceConnectionFn( + c.ovsBridgeClient, + iface.Name, + 0, + map[string]interface{}{ + interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaHost, + }, + 0, + ) + if err != nil { + return nil, err + } + return []agenttypes.PhysicalInterfaceConfig{ + {Name: bridgedName, AllowedVLANs: iface.AllowedVLANs}, + }, nil + } + + return desired.PhysicalInterfaces, nil +} + // connectPhyInterfacesToOVSBridge adds each physical interface to the OVS bridge // as an uplink port. When AllowedVLANs is set the port is created or updated in -// trunk mode with those VLAN IDs (rule 3); otherwise a plain uplink port is created. +// trunk mode with those VLAN IDs; otherwise a plain uplink port is created. // If the port already exists and AllowedVLANs is non-empty, the trunk VLAN list is // always updated to match the desired config. func connectPhyInterfacesToOVSBridge(ovsBridgeClient ovsconfig.OVSBridgeClient, phyInterfaces []agenttypes.PhysicalInterfaceConfig) error { diff --git a/pkg/agent/secondarynetwork/init_linux_test.go b/pkg/agent/secondarynetwork/init_linux_test.go index a8d13a90580..0e9431063b5 100644 --- a/pkg/agent/secondarynetwork/init_linux_test.go +++ b/pkg/agent/secondarynetwork/init_linux_test.go @@ -382,6 +382,7 @@ func TestReconcileBridge(t *testing.T) { // wantRestoreCalls lists the (bridge, iface) pairs that restoreHostInterfaceConfigFn // must be called with, in order, when an interface is removed from the config. wantRestoreCalls []struct{ bridge, iface string } + wantPrepareCalls []string expectedErr string }{ { @@ -391,10 +392,19 @@ func TestReconcileBridge(t *testing.T) { expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) {}, }, { - name: "no change (same config)", - prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, - desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, - expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) {}, + name: "no change (same config)", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + eth1Tilde := eth1 + "~" + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: "uuid-eth1", IFName: eth1, IFType: "internal"}, + {UUID: "uuid-eth1-tilde", IFName: eth1Tilde, ExternalIDs: map[string]string{"antrea-type": "uplink"}}, + }, nil).Times(1) + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1Tilde, IFName: eth1Tilde, Trunks: nil}, + }, nil).Times(1) + }, }, { name: "bridge deleted (desired is nil)", @@ -454,7 +464,7 @@ func TestReconcileBridge(t *testing.T) { }, { // Use two interfaces to bypass the single-interface PrepareHostInterfaceConnection path. - name: "rule 4: different bridge name — delete old, create new", + name: "different bridge name — delete old, create new", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brNew, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { @@ -479,7 +489,7 @@ func TestReconcileBridge(t *testing.T) { // different bridge name becomes effective, the old OVS bridge must be deleted before // the new one is created. State must be cleared immediately after deletion so that a // retry does not attempt to delete an already-removed bridge. - name: "rule 4: old ANC stops matching, new ANC bridge created — old bridge deleted first", + name: "old ANC stops matching, new ANC bridge created — old bridge deleted first", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brNew, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { @@ -501,7 +511,7 @@ func TestReconcileBridge(t *testing.T) { wantUpdateBridgeN: 2, }, { - name: "rule 3: same bridge name — add new interface", + name: "same bridge name — add new interface", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { @@ -520,25 +530,50 @@ func TestReconcileBridge(t *testing.T) { // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { - name: "rule 3: same bridge name — remove old interface", + name: "same bridge name — remove old interface", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, + expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {UUID: portUUID, IFName: eth1, IFType: ""}, + {UUID: "uuid-eth2", IFName: eth2, ExternalIDs: map[string]string{"antrea-type": "uplink"}}, + }, nil).Times(1) + // eth1 transitions from a plain uplink to the single-uplink host-connection + // setup, so the plain eth1 OVS port is removed before PrepareHostInterfaceConnection. + old.EXPECT().DeletePorts([]string{portUUID}).Return(nil) + // eth2 is no longer desired and is removed based on the observed OVSDB state. + old.EXPECT().DeletePorts([]string{"uuid-eth2"}).Return(nil) + // clearStaleTrunks: eth1~ will be the physical uplink port and has no trunks. + old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, IFType: "internal"}, + }, nil).Times(1) + old.EXPECT().GetOFPort(eth1+"~", false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + old.EXPECT().CreateUplinkPort(eth1+"~", int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + }, + wantPrepareCalls: []string{eth1}, + // No UpdateOVSBridgeClient: same bridge, client unchanged. + }, + { + name: "same bridge name — multicast snooping toggled", + prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}}, + desiredCfg: &agenttypes.OVSBridgeConfig{ + BridgeName: brOld, + EnableMulticastSnooping: true, + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}, + }, expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ {UUID: portUUID, IFName: eth1}, {UUID: "uuid-eth2", IFName: eth2}, }, nil).Times(1) - // eth2 is removed in a single batch call. - old.EXPECT().DeletePorts([]string{"uuid-eth2"}).Return(nil) - // clearStaleTrunks: eth1 remains with no AllowedVLANs; no trunks → no-op. old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ {Name: eth1, IFName: eth1, Trunks: nil}, + {Name: eth2, IFName: eth2, Trunks: nil}, }, nil).Times(1) }, - // No UpdateOVSBridgeClient: same bridge, client unchanged. }, { - name: "rule 3: same bridge, add interface with VLANs (rule 5)", + name: "same bridge, add interface with VLANs", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ {Name: eth1}, @@ -561,18 +596,20 @@ func TestReconcileBridge(t *testing.T) { // Regression test: existing port gains AllowedVLANs (e.g. ANC CR applied after // agent started with static config that had no VLANs). The port is already // present on the bridge so only SetPortTrunks must be called to update it. - name: "rule 3: same bridge, existing interface gains AllowedVLANs", + name: "same bridge, existing interface gains AllowedVLANs", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}}}, desiredCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ {Name: eth1, AllowedVLANs: []string{"100", "300"}}, }}, expectedCalls: func(old, new *ovsconfigtest.MockOVSBridgeClient) { + eth1Tilde := eth1 + "~" old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ - {UUID: portUUID, IFName: eth1}, + {UUID: "uuid-eth1", IFName: eth1, IFType: "internal"}, + {UUID: "uuid-eth1-tilde", IFName: eth1Tilde, IFType: ""}, }, nil) - // eth1 already exists and now has AllowedVLANs — trunk list must be updated. - old.EXPECT().GetOFPort(eth1, false).Return(int32(uplinkOFPort), nil) - old.EXPECT().SetPortTrunks(eth1, []string{"100", "300"}).Return(nil) + // eth1 is a single-uplink host connection, so trunk list must be updated on eth1~. + old.EXPECT().GetOFPort(eth1Tilde, false).Return(int32(uplinkOFPort), nil) + old.EXPECT().SetPortTrunks(eth1Tilde, []string{"100", "300"}).Return(nil) }, // No UpdateOVSBridgeClient: same bridge, client unchanged. }, @@ -580,7 +617,7 @@ func TestReconcileBridge(t *testing.T) { // Regression test: existing trunk ports have AllowedVLANs cleared (e.g. ANC CR // updated to remove allowedVLANs). clearStaleTrunks reads the actual OVS port // state and calls SetPortTrunks(nil) for ports that still have trunks set. - name: "rule 3: same bridge, existing interface loses AllowedVLANs", + name: "same bridge, existing interface loses AllowedVLANs", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ {Name: eth1, AllowedVLANs: []string{"100", "300"}}, {Name: eth2, AllowedVLANs: []string{"200"}}, @@ -609,7 +646,7 @@ func TestReconcileBridge(t *testing.T) { // Regression: eth1 loses AllowedVLANs AND eth2 has a stale trunk 300 that // was never reflected in prev (set externally or from a run the controller // didn't track). clearStaleTrunks reads actual OVS state and clears both. - name: "rule 3: stale trunk on eth2 not in prev config — cleared via OVS state", + name: "stale trunk on eth2 not in prev config — cleared via OVS state", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ {Name: eth1, AllowedVLANs: []string{"100"}}, {Name: eth2}, @@ -682,7 +719,7 @@ func TestReconcileBridge(t *testing.T) { // must call restoreHostInterfaceConfigFn(brOld, eth1) to remove both ports and restore // the kernel interface name — NOT merely DeletePorts("eth1"), which would leave "eth1~" // stranded on the bridge and the host kernel interface stuck under the renamed name. - name: "rule 3: host-connection port removed — RestoreHostInterfaceConfiguration called", + name: "host-connection port removed — RestoreHostInterfaceConfiguration called", prevCfg: &agenttypes.OVSBridgeConfig{BridgeName: brOld, PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{ {Name: eth1}, }}, @@ -698,15 +735,16 @@ func TestReconcileBridge(t *testing.T) { {UUID: "uuid-eth1", IFName: eth1, IFType: "internal"}, {UUID: "uuid-eth1-tilde", IFName: eth1Tilde, IFType: ""}, }, nil).Times(1) - // eth2 is new — add it as a plain uplink. - old.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) - old.EXPECT().CreateUplinkPort(eth2, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) - // clearStaleTrunks: eth2 has no AllowedVLANs; second GetPortList shows no trunks. + // eth2 is the new single uplink, so the bridge port is eth2~ after + // PrepareHostInterfaceConnection. old.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ - {Name: eth2, IFName: eth2, Trunks: nil}, + {Name: eth2, IFName: eth2, IFType: "internal"}, }, nil).Times(1) + old.EXPECT().GetOFPort(eth2+"~", false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + old.EXPECT().CreateUplinkPort(eth2+"~", int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) }, wantRestoreCalls: []struct{ bridge, iface string }{{brOld, eth1}}, + wantPrepareCalls: []string{eth2}, }, } @@ -719,6 +757,9 @@ func TestReconcileBridge(t *testing.T) { mockInterfaceByName(t) mockNewOVSBridge(t, newMock) + if tc.prevCfg != nil && tc.desiredCfg != nil && tc.prevCfg.BridgeName == tc.desiredCfg.BridgeName { + newMock.EXPECT().Create().Return(nil) + } // Capture restoreHostInterfaceConfigFn calls for verification. var gotRestoreCalls []struct{ bridge, iface string } origRestore := restoreHostInterfaceConfigFn @@ -728,6 +769,14 @@ func TestReconcileBridge(t *testing.T) { } t.Cleanup(func() { restoreHostInterfaceConfigFn = origRestore }) + var gotPrepareCalls []string + origPrepare := prepareHostInterfaceConnectionFn + prepareHostInterfaceConnectionFn = func(_ ovsconfig.OVSBridgeClient, ifaceName string, _ int32, _ map[string]interface{}, _ int) (string, bool, error) { + gotPrepareCalls = append(gotPrepareCalls, ifaceName) + return ifaceName + "~", false, nil + } + t.Cleanup(func() { prepareHostInterfaceConnectionFn = origPrepare }) + if tc.expectedCalls != nil { tc.expectedCalls(oldMock, newMock) } @@ -771,12 +820,14 @@ func TestReconcileBridge(t *testing.T) { } else { assert.Empty(t, gotRestoreCalls, "unexpected restoreHostInterfaceConfigFn calls") } + assert.Equal(t, tc.wantPrepareCalls, gotPrepareCalls, + "unexpected PrepareHostInterfaceConnection calls") } }) } } -// TestReconcileBridgeStateCleared verifies that when the bridge name changes (rule 4) — +// TestReconcileBridgeStateCleared verifies that when the bridge name changes — // for example when an old AntreaNodeConfig CR stops matching the Node and a new ANC with a // different bridge name takes effect — the controller's state (effectiveBridgeCfg and // ovsBridgeClient) is cleared immediately after the old bridge is deleted. This ensures that @@ -840,6 +891,47 @@ func TestReconcileBridgeStateCleared(t *testing.T) { c.mu.RUnlock() } +func TestCreateAndConnectBridgeDoesNotRecordStateOnPodControllerUpdateFailure(t *testing.T) { + ctrl := mock.NewController(t) + newMock := ovsconfigtest.NewMockOVSBridgeClient(ctrl) + + mockInterfaceByName(t) + mockNewOVSBridge(t, newMock) + + desired := &agenttypes.OVSBridgeConfig{ + BridgeName: brNew, + PhysicalInterfaces: []agenttypes.PhysicalInterfaceConfig{{Name: eth1}, {Name: eth2}}, + } + updateErr := errors.New("interface store reload failed") + + newMock.EXPECT().Create().Return(nil) + newMock.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{}, nil).Times(1) + newMock.EXPECT().GetOFPort(eth1, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + newMock.EXPECT().CreateUplinkPort(eth1, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + newMock.EXPECT().GetOFPort(eth2, false).Return(int32(0), ovsconfig.InvalidArgumentsError("not found")) + newMock.EXPECT().CreateUplinkPort(eth2, int32(0), map[string]interface{}{"antrea-type": "uplink"}).Return("", nil) + newMock.EXPECT().GetPortList().Return([]ovsconfig.OVSPortData{ + {Name: eth1, IFName: eth1, Trunks: nil}, + {Name: eth2, IFName: eth2, Trunks: nil}, + }, nil).Times(1) + + c := &Controller{ + secNetConfig: &agentconfig.SecondaryNetworkConfig{}, + ovsdbConn: nil, + podController: &fakePodController{updateBridgeErr: updateErr}, + ovsBridgeClient: nil, + effectiveBridgeCfg: nil, + } + + err := c.createAndConnectBridge(desired) + require.ErrorIs(t, err, updateErr) + + c.mu.RLock() + assert.Nil(t, c.effectiveBridgeCfg, "effectiveBridgeCfg should not advance when PodController update fails") + assert.Nil(t, c.ovsBridgeClient, "ovsBridgeClient should not advance when PodController update fails") + c.mu.RUnlock() +} + func mockInterfaceByName(t *testing.T) { prevFunc := interfaceByNameFn interfaceByNameFn = func(name string) (*net.Interface, error) { diff --git a/pkg/agent/secondarynetwork/init_windows.go b/pkg/agent/secondarynetwork/init_windows.go index 55dc7237742..0b6bf6e06ef 100644 --- a/pkg/agent/secondarynetwork/init_windows.go +++ b/pkg/agent/secondarynetwork/init_windows.go @@ -19,11 +19,35 @@ package secondarynetwork import ( "github.com/TomCodeLV/OVSDB-golang-lib/pkg/ovsdb" + clientset "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + componentbaseconfig "k8s.io/component-base/config" + "antrea.io/antrea/v2/pkg/agent/interfacestore" agenttypes "antrea.io/antrea/v2/pkg/agent/types" + crdlisters "antrea.io/antrea/v2/pkg/client/listers/crd/v1beta1" + "antrea.io/antrea/v2/pkg/agent/config" + agentconfig "antrea.io/antrea/v2/pkg/config/agent" "antrea.io/antrea/v2/pkg/ovs/ovsconfig" + "antrea.io/antrea/v2/pkg/util/channel" ) +func NewController( + clientConnectionConfig componentbaseconfig.ClientConnectionConfiguration, + kubeAPIServerOverride string, + k8sClient clientset.Interface, + podInformer cache.SharedIndexInformer, + podUpdateSubscriber channel.Subscriber, + primaryInterfaceStore interfacestore.InterfaceStore, + nodeConfig *config.NodeConfig, + secNetConfig *agentconfig.SecondaryNetworkConfig, + ovsdbConn *ovsdb.OVSDB, + ipPoolLister crdlisters.IPPoolLister, + ancUpdateSubscriber channel.Subscriber, +) (*Controller, error) { + return nil, nil +} + func (c *Controller) Initialize(stopCh <-chan struct{}) error { return nil } @@ -32,14 +56,7 @@ func (c *Controller) Restore() { // Not supported on Windows. } -func (c *Controller) reconcileBridge() error { - // Not supported on Windows. - return nil -} - -func (c *Controller) Run(stopCh <-chan struct{}) { - return -} +func (c *Controller) Run(stopCh <-chan struct{}) {} func resolveAndCreateOVSBridge( effectiveBridge func() *agenttypes.OVSBridgeConfig,