From 2d8109822ff939be20862748e0de94490ae96659 Mon Sep 17 00:00:00 2001 From: Evgeny Safronov Date: Mon, 4 May 2026 15:53:16 +0300 Subject: [PATCH] feat: route-operator --- .github/workflows/build-push-images.yml | 3 + Cargo.lock | 28 +- Cargo.toml | 3 +- Makefile | 6 +- agents/bird-adapter/internal/rib/routes.go | 2 +- agents/bird-adapter/service.go | 2 +- agents/meson.build | 1 + .../internal/operator/actuator_fanout.go | 75 --- .../internal/operator/operator.go | 5 +- .../internal/operator/options.go | 18 - .../internal/operator/reconciler.go | 6 +- .../cli/neighbour/Cargo.toml | 26 + .../cli/neighbour/build.rs | 15 + .../cli/neighbour/src/main.rs | 142 +++-- .../cli/route}/Cargo.toml | 22 +- .../yanet-route-operator/cli/route/build.rs | 14 + .../cli/route/src/main.rs | 386 ++++++++++++++ .../cmd/yanet-route-operator/main.go | 88 ++++ .../etc/yanet/yanet-route-operator.yaml | 69 +++ .../internal/discovery/neigh/entry.go | 0 .../internal/discovery/neigh/neigh.go | 0 .../internal/discovery/neigh/state.go | 0 .../internal/discovery/neigh/table.go | 0 .../internal/discovery/neigh/table_test.go | 0 .../internal/operator/actuator_gateway.go | 147 ++++++ .../internal/operator/cfg.go | 195 +++++++ .../internal/operator/fib_build.go | 117 +++++ .../internal/operator/metrics.go | 31 ++ .../internal/operator/metrics_observer.go | 49 ++ .../internal/operator/operator.go | 377 +++++++++++++ .../internal/operator/options.go | 212 ++++++++ .../internal/operator/reconciler.go | 138 +++++ .../internal/operator/server.go | 85 +++ .../internal/operator/service_metrics.go | 40 ++ .../internal/operator/service_neighbour.go | 211 ++++++++ .../internal/operator/service_operator.go | 40 ++ .../internal/operator/service_route.go | 367 +++++++++++++ .../yanet-route-operator}/internal/rib/rib.go | 0 .../internal/rib/rib_stats.go | 0 .../internal/rib/routes.go | 0 .../internal/rib/routes_test.go | 0 agents/yanet-route-operator/meson.build | 29 + .../operatorpb}/convert.go | 19 +- .../operatorpb/meson.build | 29 + .../operatorpb/neighbour.proto | 110 ++++ .../operatorpb/operator.proto | 43 ++ .../operatorpb/route.proto | 149 ++++++ .../routepb => common/commonpb}/macaddr.go | 5 +- .../routepb => common/commonpb}/macaddr.proto | 7 +- .../commonpb}/macaddr_test.go | 2 +- common/commonpb/meson.build | 2 + common/go/operator/actuator.go | 96 ++++ common/rust/commonpb/build.rs | 7 +- .../etc/yanet/controlplane-director.yaml | 6 +- controlplane/internal/gateway/gateway.go | 22 + controlplane/internal/gateway/runner.go | 13 +- controlplane/meson.build | 1 + controlplane/yncp/director.go | 6 +- debian/control | 10 + debian/yanet2-cli.install | 3 +- debian/yanet2-route-operator.install | 2 + debian/yanet2-route-operator.service | 19 + deploy/yanet-route-operator.Dockerfile | 15 + modules/route/cli/neighbour/build.rs | 13 - modules/route/cli/neighbour/src/lib.rs | 96 ---- modules/route/cli/route/Cargo.toml | 9 +- modules/route/cli/route/build.rs | 3 +- modules/route/cli/route/src/lib.rs | 120 +---- modules/route/cli/route/src/main.rs | 323 ++++-------- modules/route/controlplane/backend.go | 226 ++++---- modules/route/controlplane/cfg.go | 41 +- modules/route/controlplane/mod.go | 143 ++--- .../route/controlplane/neighbour_service.go | 202 ------- .../route/controlplane/routepb/meson.build | 5 - .../controlplane/routepb/neighbour.proto | 175 ------- .../route/controlplane/routepb/route.proto | 208 +------- modules/route/controlplane/service.go | 494 ++++-------------- tests/functional/converted/setup_test.go | 23 + tests/functional/framework/framework.go | 9 +- tests/functional/framework/qemu.go | 13 +- tests/functional/main/framework_test.go | 23 + tests/functional/main/route_mpls_test.go | 56 +- tests/functional/main/route_test.go | 403 +++----------- web/src/api/neighbours.ts | 2 +- web/src/api/routes.ts | 16 +- web/src/pages/route/FIBTable.tsx | 4 +- 86 files changed, 3871 insertions(+), 2251 deletions(-) delete mode 100644 agents/yanet-pipeline-operator/internal/operator/actuator_fanout.go create mode 100644 agents/yanet-route-operator/cli/neighbour/Cargo.toml create mode 100644 agents/yanet-route-operator/cli/neighbour/build.rs rename {modules/route => agents/yanet-route-operator}/cli/neighbour/src/main.rs (73%) rename {modules/route/cli/neighbour => agents/yanet-route-operator/cli/route}/Cargo.toml (75%) mode change 100755 => 100644 create mode 100644 agents/yanet-route-operator/cli/route/build.rs create mode 100644 agents/yanet-route-operator/cli/route/src/main.rs create mode 100644 agents/yanet-route-operator/cmd/yanet-route-operator/main.go create mode 100644 agents/yanet-route-operator/etc/yanet/yanet-route-operator.yaml rename {modules/route => agents/yanet-route-operator}/internal/discovery/neigh/entry.go (100%) rename {modules/route => agents/yanet-route-operator}/internal/discovery/neigh/neigh.go (100%) rename {modules/route => agents/yanet-route-operator}/internal/discovery/neigh/state.go (100%) rename {modules/route => agents/yanet-route-operator}/internal/discovery/neigh/table.go (100%) rename {modules/route => agents/yanet-route-operator}/internal/discovery/neigh/table_test.go (100%) create mode 100644 agents/yanet-route-operator/internal/operator/actuator_gateway.go create mode 100644 agents/yanet-route-operator/internal/operator/cfg.go create mode 100644 agents/yanet-route-operator/internal/operator/fib_build.go create mode 100644 agents/yanet-route-operator/internal/operator/metrics.go create mode 100644 agents/yanet-route-operator/internal/operator/metrics_observer.go create mode 100644 agents/yanet-route-operator/internal/operator/operator.go create mode 100644 agents/yanet-route-operator/internal/operator/options.go create mode 100644 agents/yanet-route-operator/internal/operator/reconciler.go create mode 100644 agents/yanet-route-operator/internal/operator/server.go create mode 100644 agents/yanet-route-operator/internal/operator/service_metrics.go create mode 100644 agents/yanet-route-operator/internal/operator/service_neighbour.go create mode 100644 agents/yanet-route-operator/internal/operator/service_operator.go create mode 100644 agents/yanet-route-operator/internal/operator/service_route.go rename {modules/route => agents/yanet-route-operator}/internal/rib/rib.go (100%) rename {modules/route => agents/yanet-route-operator}/internal/rib/rib_stats.go (100%) rename {modules/route => agents/yanet-route-operator}/internal/rib/routes.go (100%) rename {modules/route => agents/yanet-route-operator}/internal/rib/routes_test.go (100%) create mode 100644 agents/yanet-route-operator/meson.build rename {modules/route/controlplane/routepb => agents/yanet-route-operator/operatorpb}/convert.go (83%) create mode 100644 agents/yanet-route-operator/operatorpb/meson.build create mode 100644 agents/yanet-route-operator/operatorpb/neighbour.proto create mode 100644 agents/yanet-route-operator/operatorpb/operator.proto create mode 100644 agents/yanet-route-operator/operatorpb/route.proto rename {modules/route/controlplane/routepb => common/commonpb}/macaddr.go (97%) rename {modules/route/controlplane/routepb => common/commonpb}/macaddr.proto (78%) rename {modules/route/controlplane/routepb => common/commonpb}/macaddr_test.go (99%) create mode 100644 common/go/operator/actuator.go create mode 100644 debian/yanet2-route-operator.install create mode 100644 debian/yanet2-route-operator.service create mode 100644 deploy/yanet-route-operator.Dockerfile delete mode 100644 modules/route/cli/neighbour/build.rs delete mode 100644 modules/route/cli/neighbour/src/lib.rs delete mode 100644 modules/route/controlplane/neighbour_service.go delete mode 100644 modules/route/controlplane/routepb/neighbour.proto diff --git a/.github/workflows/build-push-images.yml b/.github/workflows/build-push-images.yml index aab79b83d..c22ac9b65 100644 --- a/.github/workflows/build-push-images.yml +++ b/.github/workflows/build-push-images.yml @@ -95,6 +95,9 @@ jobs: - component: pipeline-operator image: ghcr.io/yanet-platform/yanet2/pipeline-operator dockerfile: deploy/yanet-pipeline-operator.Dockerfile + - component: route-operator + image: ghcr.io/yanet-platform/yanet2/route-operator + dockerfile: deploy/yanet-route-operator.Dockerfile steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index e31035503..b5dc8ca42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3346,7 +3346,7 @@ dependencies = [ ] [[package]] -name = "yanet-cli-neighbour" +name = "yanet-cli-operator-neighbour" version = "0.1.0" dependencies = [ "clap", @@ -3354,12 +3354,12 @@ dependencies = [ "log", "netip", "prost", - "prost-types", "tabled", "tokio", "tonic", "tonic-build", "yanet-cli", + "yanet-commonpb", ] [[package]] @@ -3379,6 +3379,23 @@ dependencies = [ "yanet-commonpb", ] +[[package]] +name = "yanet-cli-operator-route" +version = "0.1.0" +dependencies = [ + "clap", + "clap_complete", + "colored", + "log", + "netip", + "prost", + "tabled", + "tokio", + "tonic", + "tonic-build", + "yanet-cli", +] + [[package]] name = "yanet-cli-pdump" version = "0.1.0" @@ -3425,18 +3442,17 @@ version = "0.1.0" dependencies = [ "clap", "clap_complete", - "colored", "log", "netip", "prost", - "prost-types", - "ptree", + "serde", + "serde_yaml", "tabled", "tokio", "tonic", "tonic-build", - "tower", "yanet-cli", + "yanet-commonpb", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index adddc495b..382b6aa8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,13 @@ members = [ "modules/nat64/cli", "modules/pdump/cli", "modules/balancer/cli", - "modules/route/cli/neighbour", "modules/route/cli/route", "modules/route-mpls/cli", "devices/plain/cli", "devices/vlan/cli", "agents/yanet-pipeline-operator/cli", + "agents/yanet-route-operator/cli/route", + "agents/yanet-route-operator/cli/neighbour", ] [profile.release] diff --git a/Makefile b/Makefile index b5abab4d2..e732e576f 100644 --- a/Makefile +++ b/Makefile @@ -29,13 +29,14 @@ CLI_MODULES := \ device-vlan \ dscp \ fwstate \ - neighbour \ route \ route-mpls \ forward \ nat64 \ pdump \ - operator-pipeline + operator-neighbour \ + operator-pipeline \ + operator-route CLI_MODULE_BINARIES := $(addprefix yanet-cli-,$(CLI_MODULES)) @@ -199,6 +200,7 @@ install: dataplane cli-install install -m 644 dataplane.yaml $(DESTDIR)/etc/yanet2/dataplane.yaml install -m 644 agents/bird-adapter/etc/yanet/bird-adapter.yaml $(DESTDIR)/etc/yanet2/bird-adapter.yaml install -m 644 agents/yanet-pipeline-operator/etc/yanet/yanet-pipeline-operator.yaml $(DESTDIR)/etc/yanet2/yanet-pipeline-operator.yaml + install -m 644 agents/yanet-route-operator/etc/yanet/yanet-route-operator.yaml $(DESTDIR)/etc/yanet2/yanet-route-operator.yaml install -d $(DESTDIR)/etc/yanet2/forward.d install -m 644 agents/yanet-forward-operator/etc/yanet/forward.d/vlan-phy.yaml $(DESTDIR)/etc/yanet2/forward.d/vlan-phy.yaml install -m 644 agents/yanet-forward-operator/etc/yanet/forward.d/phy-vlan.yaml $(DESTDIR)/etc/yanet2/forward.d/phy-vlan.yaml diff --git a/agents/bird-adapter/internal/rib/routes.go b/agents/bird-adapter/internal/rib/routes.go index 9752e803c..f6cf2dc95 100644 --- a/agents/bird-adapter/internal/rib/routes.go +++ b/agents/bird-adapter/internal/rib/routes.go @@ -4,9 +4,9 @@ import ( "fmt" "net/netip" + routepb "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" "github.com/yanet-platform/yanet2/common/filterpb" "github.com/yanet-platform/yanet2/modules/route-mpls/controlplane/routemplspb" - "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb" ) type RouteSourceID uint8 diff --git a/agents/bird-adapter/service.go b/agents/bird-adapter/service.go index f3eeb4e69..282a3a017 100644 --- a/agents/bird-adapter/service.go +++ b/agents/bird-adapter/service.go @@ -21,8 +21,8 @@ import ( "github.com/yanet-platform/yanet2/agents/bird-adapter/internal/bird" "github.com/yanet-platform/yanet2/agents/bird-adapter/internal/mpls" "github.com/yanet-platform/yanet2/agents/bird-adapter/internal/rib" + routepb "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" "github.com/yanet-platform/yanet2/modules/route-mpls/controlplane/routemplspb" - "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb" ) // levelFilterCore wraps a zapcore.Core and filters log entries by level. diff --git a/agents/meson.build b/agents/meson.build index cd4b0a057..35aad419f 100644 --- a/agents/meson.build +++ b/agents/meson.build @@ -1,4 +1,5 @@ subdir('bird-adapter') subdir('yanet-pipeline-operator') +subdir('yanet-route-operator') subdir('yanet-forward-operator') diff --git a/agents/yanet-pipeline-operator/internal/operator/actuator_fanout.go b/agents/yanet-pipeline-operator/internal/operator/actuator_fanout.go deleted file mode 100644 index ead5050fc..000000000 --- a/agents/yanet-pipeline-operator/internal/operator/actuator_fanout.go +++ /dev/null @@ -1,75 +0,0 @@ -package operator - -import ( - "context" - "errors" - - "go.uber.org/zap" - "golang.org/x/sync/errgroup" -) - -// FanOutActuator applies a StageConfig to several Actuators concurrently. -// -// Apply waits for every underlying Actuator to complete and returns the -// first non-nil error observed. If any underlying Actuator fails the stage -// is considered "not applied" by the reconciler. -type FanOutActuator struct { - actuators []Actuator - - log *zap.Logger -} - -func NewFanOutActuator( - actuators []Actuator, - options ...FanOutActuatorOption, -) *FanOutActuator { - opts := newFanOutActuatorOptions() - for _, o := range options { - o(opts) - } - - return &FanOutActuator{ - actuators: actuators, - log: opts.Log, - } -} - -// Apply runs Apply on every underlying Actuator concurrently. -// -// We deliberately use errgroup.Group without a derived context: every -// underlying Apply runs to completion regardless of whether a sibling -// failed, so each gateway lands in a deterministic state instead of a -// half-applied one. -func (m *FanOutActuator) Apply(ctx context.Context, stage *StageConfig) error { - wg := errgroup.Group{} - for _, a := range m.actuators { - wg.Go(func() error { - return a.Apply(ctx, stage) - }) - } - - if err := wg.Wait(); err != nil { - return err - } - - m.log.Info("applied stage to gateways", - zap.String("stage", stage.Name), - ) - - return nil -} - -// Close closes every underlying Actuator serially and joins their errors. -// -// Each Actuator is closed regardless of whether a sibling failed, so one -// bad close does not mask others. -func (m *FanOutActuator) Close() error { - var errs []error - for _, a := range m.actuators { - if err := a.Close(); err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/agents/yanet-pipeline-operator/internal/operator/operator.go b/agents/yanet-pipeline-operator/internal/operator/operator.go index a9afb646c..bcaffd055 100644 --- a/agents/yanet-pipeline-operator/internal/operator/operator.go +++ b/agents/yanet-pipeline-operator/internal/operator/operator.go @@ -10,6 +10,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/yanet-platform/yanet2/agents/yanet-pipeline-operator/operatorpb" + "github.com/yanet-platform/yanet2/common/go/operator" "github.com/yanet-platform/yanet2/controlplane/gateway" ) @@ -65,9 +66,9 @@ func NewOperator(cfg *Config, options ...Option) (*Operator, error) { actuators = append(actuators, actuator) } - actuator := NewFanOutActuator( + actuator := operator.NewFanOutActuator[*StageConfig]( actuators, - WithFanOutActuatorLog(log), + operator.WithFanOutLog(log), ) reconciler := NewReconciler( diff --git a/agents/yanet-pipeline-operator/internal/operator/options.go b/agents/yanet-pipeline-operator/internal/operator/options.go index 7a7ed1e7c..6f1f43f71 100644 --- a/agents/yanet-pipeline-operator/internal/operator/options.go +++ b/agents/yanet-pipeline-operator/internal/operator/options.go @@ -130,21 +130,3 @@ func WithGatewayActuatorMetrics(metrics GatewayActuatorMetricsObserver) GatewayA o.Metrics = metrics } } - -type fanOutActuatorOptions struct { - Log *zap.Logger -} - -func newFanOutActuatorOptions() *fanOutActuatorOptions { - return &fanOutActuatorOptions{ - Log: zap.NewNop(), - } -} - -type FanOutActuatorOption func(*fanOutActuatorOptions) - -func WithFanOutActuatorLog(log *zap.Logger) FanOutActuatorOption { - return func(o *fanOutActuatorOptions) { - o.Log = log - } -} diff --git a/agents/yanet-pipeline-operator/internal/operator/reconciler.go b/agents/yanet-pipeline-operator/internal/operator/reconciler.go index f77b8bab2..8a1c72971 100644 --- a/agents/yanet-pipeline-operator/internal/operator/reconciler.go +++ b/agents/yanet-pipeline-operator/internal/operator/reconciler.go @@ -8,14 +8,12 @@ import ( "go.uber.org/zap" + "github.com/yanet-platform/yanet2/common/go/operator" "github.com/yanet-platform/yanet2/common/go/xbackoff" ) // Actuator applies a desired stage configuration. -type Actuator interface { - Apply(ctx context.Context, stage *StageConfig) error - Close() error -} +type Actuator = operator.Actuator[*StageConfig] // Reconciler drives the system through an ordered queue of target // StageConfigs. diff --git a/agents/yanet-route-operator/cli/neighbour/Cargo.toml b/agents/yanet-route-operator/cli/neighbour/Cargo.toml new file mode 100644 index 000000000..406711365 --- /dev/null +++ b/agents/yanet-route-operator/cli/neighbour/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "yanet-cli-operator-neighbour" +version = "0.1.0" +edition = "2024" +publish = false + +rust-version = "1.85" + +[[bin]] +name = "yanet-cli-operator-neighbour" +path = "src/main.rs" + +[dependencies] +commonpb = { path = "../../../../common/rust/commonpb", version = "0.1", package = "yanet-commonpb" } +ync = { path = "../../../../cli/core", version = "0.1", package = "yanet-cli" } +clap = { version = "4.5", features = ["derive"] } +clap_complete = { version = "4.5", features = ["unstable-dynamic"] } +prost = "0.13" +log = "0.4" +netip = "0.2" +tabled = { version = "0.18", features = ["ansi"] } +tokio = { version = "1", features = ["rt", "net", "time", "macros", "sync"] } +tonic = { version = "0.13", features = ["gzip"] } + +[build-dependencies] +tonic-build = "0.13" diff --git a/agents/yanet-route-operator/cli/neighbour/build.rs b/agents/yanet-route-operator/cli/neighbour/build.rs new file mode 100644 index 000000000..4b38c0e16 --- /dev/null +++ b/agents/yanet-route-operator/cli/neighbour/build.rs @@ -0,0 +1,15 @@ +use core::error::Error; + +pub fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=../../operatorpb/neighbour.proto"); + + tonic_build::configure() + .emit_rerun_if_changed(false) + .build_server(false) + .extern_path(".commonpb", "::commonpb::pb") + .compile_protos( + &["../../../../agents/yanet-route-operator/operatorpb/neighbour.proto"], + &["../../../../"], + ) + .map_err(Into::into) +} diff --git a/modules/route/cli/neighbour/src/main.rs b/agents/yanet-route-operator/cli/neighbour/src/main.rs similarity index 73% rename from modules/route/cli/neighbour/src/main.rs rename to agents/yanet-route-operator/cli/neighbour/src/main.rs index 721919b6d..afd76d843 100644 --- a/modules/route/cli/neighbour/src/main.rs +++ b/agents/yanet-route-operator/cli/neighbour/src/main.rs @@ -1,30 +1,41 @@ -//! CLI for YANET "neighbour" module. - -use core::{error::Error, time::Duration}; -use std::time::UNIX_EPOCH; +//! CLI for YANET route operator (neighbour-side commands). +//! +//! Connects to a gRPC endpoint exposing the operator's NeighbourService +//! (the operator process directly, or the gateway once registration +//! has propagated) and drives the operator-owned neighbour tables. + +use core::{ + error::Error, + fmt::{self, Display, Formatter}, + net::IpAddr, + time::Duration, +}; +use std::time::{SystemTime, UNIX_EPOCH}; use clap::{ArgAction, CommandFactory, Parser}; use clap_complete::CompleteEnv; -use code::{ - neighbour_client::NeighbourClient, CreateNeighbourTableRequest, ListNeighbourTablesRequest, ListNeighboursRequest, - MacAddress, NeighbourEntry as ProtoNeighbourEntry, RemoveNeighbourTableRequest, RemoveNeighboursRequest, - UpdateNeighbourTableRequest, UpdateNeighboursRequest, -}; +use commonpb::pb::MacAddress; use netip::MacAddr; +use tabled::Tabled; use tonic::codec::CompressionEncoding; -use yanet_cli_neighbour::{Age, NeighbourEntry, State, TableEntry}; use ync::{ client::{ConnectionArgs, LayeredChannel}, display::print_table, logging, }; -#[allow(non_snake_case)] -pub mod code { - tonic::include_proto!("routepb"); +use crate::operatorpb::{ + CreateNeighbourTableRequest, ListNeighbourTablesRequest, ListNeighboursRequest, + NeighbourEntry as ProtoNeighbourEntry, RemoveNeighbourTableRequest, RemoveNeighboursRequest, + UpdateNeighbourTableRequest, UpdateNeighboursRequest, neighbour_service_client::NeighbourServiceClient, +}; + +#[allow(clippy::all, non_snake_case)] +pub mod operatorpb { + tonic::include_proto!("operatorpb"); } -/// Neighbour module. +/// Neighbour operator CLI (neighbour table management). #[derive(Debug, Clone, Parser)] #[command(version, about)] #[command(flatten_help = true)] @@ -40,7 +51,7 @@ pub struct Cmd { #[derive(Debug, Clone, Parser)] pub enum ModeCmd { - /// Show current neighbors. + /// Show current neighbours. Show(ShowCmd), /// Add one or more static neighbour entries. Add(AddCmd), @@ -89,15 +100,11 @@ pub struct AddCmd { /// Network interface name. #[arg(long)] pub device: Option, - /// Neighbour table name. - /// - /// Defaults to "static". + /// Neighbour table name. Defaults to "static". #[arg(long)] pub table: Option, - /// Priority for this entry. - /// - /// Lower value means higher priority. - /// Defaults to the table's default priority. + /// Priority for this entry (lower wins). Defaults to the table's + /// default priority. #[arg(long)] pub priority: Option, } @@ -106,9 +113,7 @@ pub struct AddCmd { pub struct RemoveCmd { /// Next-hop IP address(es) to remove. pub next_hops: Vec, - /// Neighbour table name. - /// - /// Defaults to "static". + /// Neighbour table name. Defaults to "static". #[arg(long)] pub table: Option, } @@ -166,26 +171,19 @@ async fn run(cmd: Cmd) -> Result<(), Box> { } } -/// Service for interacting with the neighbour module of the YANET router. -/// -/// Provides methods to retrieve and display neighbor information through -/// gRPC communication with the control plane. pub struct NeighbourService { - client: NeighbourClient, + client: NeighbourServiceClient, } impl NeighbourService { - /// Creates a new NeighbourService connected to the specified endpoint. pub async fn new(connection: &ConnectionArgs) -> Result> { let channel = ync::client::connect(connection).await?; - let client = NeighbourClient::new(channel) + let client = NeighbourServiceClient::new(channel) .send_compressed(CompressionEncoding::Gzip) .accept_compressed(CompressionEncoding::Gzip); Ok(Self { client }) } - /// Retrieves and displays the current neighbor table in a formatted - /// table. pub async fn show_neighbours(&mut self, table: Option) -> Result<(), Box> { let request = ListNeighboursRequest { table: table.unwrap_or_default() }; let response = self.client.list(request).await?.into_inner(); @@ -222,11 +220,9 @@ impl NeighbourService { entries.sort_by(|a, b| (a.state, &a.next_hop).cmp(&(b.state, &b.next_hop))); print_table(entries); - Ok(()) } - /// Adds or updates a neighbour entry. pub async fn update_neighbour(&mut self, args: AddCmd) -> Result<(), Box> { let link_addr = parse_mac(&args.link_addr)?; let hardware_addr = parse_mac(&args.hardware_addr)?; @@ -248,7 +244,6 @@ impl NeighbourService { Ok(()) } - /// Removes one or more neighbour entries. pub async fn remove_neighbours(&mut self, args: RemoveCmd) -> Result<(), Box> { let request = RemoveNeighboursRequest { table: args.table.unwrap_or_default(), @@ -260,7 +255,6 @@ impl NeighbourService { Ok(()) } - /// Lists all neighbour tables. pub async fn list_tables(&mut self) -> Result<(), Box> { let response = self .client @@ -283,34 +277,28 @@ impl NeighbourService { Ok(()) } - /// Creates a new neighbour table. pub async fn create_table(&mut self, args: CreateTableCmd) -> Result<(), Box> { let request = CreateNeighbourTableRequest { name: args.name, default_priority: args.default_priority, }; - self.client.create_table(request).await?; println!("OK"); Ok(()) } - /// Updates an existing neighbour table. pub async fn update_table(&mut self, args: UpdateTableCmd) -> Result<(), Box> { let request = UpdateNeighbourTableRequest { name: args.name, default_priority: args.default_priority, }; - self.client.update_table(request).await?; println!("OK"); Ok(()) } - /// Removes a neighbour table. pub async fn remove_table(&mut self, args: RemoveTableCmd) -> Result<(), Box> { let request = RemoveNeighbourTableRequest { name: args.name }; - self.client.remove_table(request).await?; println!("OK"); Ok(()) @@ -321,3 +309,69 @@ fn parse_mac(s: &str) -> Result> { let mac: MacAddr = s.parse()?; Ok(MacAddress { addr: mac.as_u64() }) } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct State(pub i32); + +impl Display for State { + fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { + let v = match self { + Self(0x00) => "NONE", + Self(0x01) => "INCOMPLETE", + Self(0x02) => "REACHABLE", + Self(0x04) => "STALE", + Self(0x08) => "DELAY", + Self(0x10) => "PROBE", + Self(0x20) => "FAILED", + Self(0x40) => "NOARP", + Self(0x80) => "PERMANENT", + Self(..) => "UNKNOWN", + }; + write!(f, "{v}") + } +} + +#[derive(Debug)] +pub struct Age(pub SystemTime); + +impl Display for Age { + fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { + let now = SystemTime::now(); + let duration = match self { + Self(timestamp) => now.duration_since(*timestamp).unwrap_or_default(), + }; + write!(f, "{duration:.2?}") + } +} + +#[derive(Debug, Tabled)] +pub struct NeighbourEntry { + #[tabled(rename = "NEXTHOP")] + pub next_hop: IpAddr, + #[tabled(rename = "NEIGHBOUR MAC")] + pub link_addr: MacAddr, + #[tabled(rename = "INTERFACE MAC")] + pub hardware_addr: MacAddr, + #[tabled(rename = "DEVICE")] + pub device: String, + #[tabled(rename = "STATE")] + pub state: State, + #[tabled(rename = "AGE")] + pub age: Age, + #[tabled(rename = "SOURCE")] + pub source: String, + #[tabled(rename = "PRIORITY")] + pub priority: u32, +} + +#[derive(Debug, Tabled)] +pub struct TableEntry { + #[tabled(rename = "NAME")] + pub name: String, + #[tabled(rename = "DEFAULT PRIORITY")] + pub default_priority: u32, + #[tabled(rename = "ENTRIES")] + pub entry_count: i64, + #[tabled(rename = "BUILT-IN")] + pub built_in: bool, +} diff --git a/modules/route/cli/neighbour/Cargo.toml b/agents/yanet-route-operator/cli/route/Cargo.toml old mode 100755 new mode 100644 similarity index 75% rename from modules/route/cli/neighbour/Cargo.toml rename to agents/yanet-route-operator/cli/route/Cargo.toml index 57648dc9a..97d9bf0a0 --- a/modules/route/cli/neighbour/Cargo.toml +++ b/agents/yanet-route-operator/cli/route/Cargo.toml @@ -1,22 +1,26 @@ [package] -name = "yanet-cli-neighbour" +name = "yanet-cli-operator-route" version = "0.1.0" -edition = "2021" +edition = "2024" publish = false -rust-version = "1.84" +rust-version = "1.85" + +[[bin]] +name = "yanet-cli-operator-route" +path = "src/main.rs" [dependencies] -ync = { path = "../../../../cli/core", version = "0.1", package = "yanet-cli"} -log = "0.4" +ync = { path = "../../../../cli/core", version = "0.1", package = "yanet-cli" } clap = { version = "4.5", features = ["derive"] } clap_complete = { version = "4.5", features = ["unstable-dynamic"] } -tokio = { version = "1", features = ["rt", "net", "time", "macros", "sync"] } prost = "0.13" -prost-types = "0.13" -tonic = { version = "0.13", features = ["gzip"] } -tabled = { version = "0.18", features = ["ansi"] } +log = "0.4" netip = "0.2" +colored = "3" +tabled = { version = "0.18", features = ["ansi"] } +tokio = { version = "1", features = ["rt", "net", "time", "macros", "sync"] } +tonic = { version = "0.13", features = ["gzip"] } [build-dependencies] tonic-build = "0.13" diff --git a/agents/yanet-route-operator/cli/route/build.rs b/agents/yanet-route-operator/cli/route/build.rs new file mode 100644 index 000000000..0321902b1 --- /dev/null +++ b/agents/yanet-route-operator/cli/route/build.rs @@ -0,0 +1,14 @@ +use core::error::Error; + +pub fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=../../operatorpb/route.proto"); + + tonic_build::configure() + .emit_rerun_if_changed(false) + .build_server(false) + .compile_protos( + &["../../../../agents/yanet-route-operator/operatorpb/route.proto"], + &["../../../../"], + ) + .map_err(Into::into) +} diff --git a/agents/yanet-route-operator/cli/route/src/main.rs b/agents/yanet-route-operator/cli/route/src/main.rs new file mode 100644 index 000000000..7868d03eb --- /dev/null +++ b/agents/yanet-route-operator/cli/route/src/main.rs @@ -0,0 +1,386 @@ +//! CLI for YANET route operator (route-side commands). +//! +//! Connects to a gRPC endpoint exposing the operator's RouteService +//! (the operator process directly, or the gateway once registration +//! has propagated) and drives the operator-owned RIB. + +use core::{ + error::Error, + fmt::{self, Display, Formatter}, + net::IpAddr, +}; + +use clap::{ArgAction, CommandFactory, Parser}; +use clap_complete::CompleteEnv; +use colored::Colorize; +use netip::{Contiguous, IpNetwork}; +use tabled::{ + Table, Tabled, + settings::{ + Color, Style, + object::{Columns, Rows}, + style::{BorderColor, HorizontalLine}, + }, +}; +use tonic::codec::CompressionEncoding; +use ync::{ + client::{ConnectionArgs, LayeredChannel}, + logging, +}; + +use crate::operatorpb::{ + DeleteRouteRequest, FlushRoutesRequest, InsertRouteRequest, ListConfigsRequest, LookupRouteRequest, RouteSourceId, + ShowRoutesRequest, route_service_client::RouteServiceClient, +}; + +#[allow(clippy::all, non_snake_case)] +pub mod operatorpb { + tonic::include_proto!("operatorpb"); +} + +/// Route operator CLI (RIB management). +#[derive(Debug, Clone, Parser)] +#[command(version, about)] +#[command(flatten_help = true)] +pub struct Cmd { + #[clap(subcommand)] + pub mode: ModeCmd, + #[command(flatten)] + pub connection: ConnectionArgs, + /// Be verbose in terms of logging. + #[clap(short, action = ArgAction::Count, global = true)] + pub verbose: u8, +} + +#[derive(Debug, Clone, Parser)] +pub enum ModeCmd { + /// List all RIB configurations known to the operator. + List, + /// Show routes currently stored in RIB. + Show(RouteShowCmd), + /// Perform RIB route lookup. + Lookup(RouteLookupCmd), + /// Insert a unicast static route. + Insert(RouteInsertCmd), + /// Remove a unicast static route. + Remove(RouteRemoveCmd), + /// Flush RIB to FIB for a configuration. + Flush(RouteFlushCmd), +} + +#[derive(Debug, Clone, Parser)] +pub struct RouteShowCmd { + /// Show only IPv4 routes. + #[arg(long)] + pub ipv4: bool, + /// Show only IPv6 routes. + #[arg(long)] + pub ipv6: bool, + /// Route config name. + #[arg(long = "cfg", short)] + pub config_name: String, +} + +#[derive(Debug, Clone, Parser)] +pub struct RouteLookupCmd { + /// IP address to look up. + pub addr: IpAddr, + /// Route config name. + #[arg(long = "cfg", short)] + pub config_name: String, +} + +#[derive(Debug, Clone, Parser)] +pub struct RouteInsertCmd { + /// Destination prefix in CIDR notation. + pub prefix: Contiguous, + /// Route config name. + #[arg(long = "cfg", short)] + pub config_name: String, + /// Next-hop IP address. + #[arg(long = "via")] + pub nexthop_addr: IpAddr, + /// Route source type (static or bird). Defaults to static. + #[arg(long = "source", default_value = "static")] + pub source: RouteSource, +} + +#[derive(Debug, Clone, Parser)] +pub struct RouteRemoveCmd { + /// Destination prefix in CIDR notation. + pub prefix: Contiguous, + /// Route config name. + #[arg(long = "cfg", short)] + pub config_name: String, + /// Next-hop IP address. + #[arg(long = "via")] + pub nexthop_addr: IpAddr, + /// Route source type (static or bird). Defaults to static. + #[arg(long = "source", default_value = "static")] + pub source: RouteSource, +} + +#[derive(Debug, Clone, Parser)] +pub struct RouteFlushCmd { + /// Route config name. + #[arg(long = "cfg", short)] + pub config_name: String, +} + +#[derive(Debug, Clone, clap::ValueEnum)] +pub enum RouteSource { + Static, + Bird, +} + +impl RouteSource { + fn to_proto(&self) -> RouteSourceId { + match self { + Self::Static => RouteSourceId::Static, + Self::Bird => RouteSourceId::Bird, + } + } +} + +#[tokio::main(flavor = "current_thread")] +pub async fn main() { + CompleteEnv::with_factory(Cmd::command).complete(); + + let cmd = Cmd::parse(); + logging::init(cmd.verbose as usize).expect("no error expected"); + + if let Err(err) = run(cmd).await { + log::error!("ERROR: {err}"); + std::process::exit(1); + } +} + +async fn run(cmd: Cmd) -> Result<(), Box> { + let mut service = RouteService::new(&cmd.connection).await?; + + match cmd.mode { + ModeCmd::List => service.list_configs().await, + ModeCmd::Show(cmd) => service.show_routes(cmd).await, + ModeCmd::Lookup(cmd) => service.lookup_route(cmd).await, + ModeCmd::Insert(cmd) => service.insert_route(cmd).await, + ModeCmd::Remove(cmd) => service.remove_route(cmd).await, + ModeCmd::Flush(cmd) => service.flush_routes(cmd).await, + } +} + +pub struct RouteService { + client: RouteServiceClient, +} + +impl RouteService { + pub async fn new(connection: &ConnectionArgs) -> Result> { + let channel = ync::client::connect(connection).await?; + let client = RouteServiceClient::new(channel) + .send_compressed(CompressionEncoding::Gzip) + .accept_compressed(CompressionEncoding::Gzip); + Ok(Self { client }) + } + + pub async fn list_configs(&mut self) -> Result<(), Box> { + let request = ListConfigsRequest {}; + let response = self.client.list_configs(request).await?.into_inner(); + + for config in response.configs { + println!("{config}"); + } + Ok(()) + } + + pub async fn show_routes(&mut self, cmd: RouteShowCmd) -> Result<(), Box> { + let request = ShowRoutesRequest { + name: cmd.config_name.clone(), + ipv4_only: cmd.ipv4, + ipv6_only: cmd.ipv6, + }; + + let response = self.client.show_routes(request).await?.into_inner(); + + let mut entries = response.routes.into_iter().map(RouteEntry::from).collect::>(); + entries.sort_by_key(|a| a.prefix.0); + + print_table(entries); + Ok(()) + } + + pub async fn lookup_route(&mut self, cmd: RouteLookupCmd) -> Result<(), Box> { + let request = LookupRouteRequest { + name: cmd.config_name.clone(), + ip_addr: cmd.addr.to_string(), + }; + + let response = self.client.lookup_route(request).await?.into_inner(); + if response.routes.is_empty() { + log::info!("No routes found for {}", cmd.addr); + return Ok(()); + } + + print_table(response.routes.into_iter().map(RouteEntry::from)); + Ok(()) + } + + pub async fn insert_route(&mut self, cmd: RouteInsertCmd) -> Result<(), Box> { + let request = InsertRouteRequest { + name: cmd.config_name.clone(), + prefix: cmd.prefix.to_string(), + nexthop_addr: cmd.nexthop_addr.to_string(), + do_flush: true, + source_id: cmd.source.to_proto().into(), + }; + + self.client.insert_route(request).await?; + log::info!( + "Route inserted successfully: {} via {} (source: {:?})", + cmd.prefix, + cmd.nexthop_addr, + cmd.source + ); + Ok(()) + } + + pub async fn remove_route(&mut self, cmd: RouteRemoveCmd) -> Result<(), Box> { + let request = DeleteRouteRequest { + name: cmd.config_name.clone(), + prefix: cmd.prefix.to_string(), + nexthop_addr: cmd.nexthop_addr.to_string(), + do_flush: true, + source_id: cmd.source.to_proto().into(), + }; + + self.client.delete_route(request).await?; + log::info!( + "Route removed successfully: {} via {} (source: {:?})", + cmd.prefix, + cmd.nexthop_addr, + cmd.source + ); + Ok(()) + } + + pub async fn flush_routes(&mut self, cmd: RouteFlushCmd) -> Result<(), Box> { + let request = FlushRoutesRequest { name: cmd.config_name.clone() }; + self.client.flush_routes(request).await?; + log::info!("Routes flushed successfully"); + Ok(()) + } +} + +#[derive(Debug)] +pub struct LargeCommunity { + pub global_administrator: u32, + pub local_data_part1: u32, + pub local_data_part2: u32, +} + +impl From for LargeCommunity { + fn from(community: operatorpb::LargeCommunity) -> Self { + Self { + global_administrator: community.global_administrator, + local_data_part1: community.local_data_part1, + local_data_part2: community.local_data_part2, + } + } +} + +impl Display for LargeCommunity { + fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { + write!( + f, + "{}:{}:{}", + self.global_administrator, self.local_data_part1, self.local_data_part2 + ) + } +} + +#[derive(Debug)] +pub struct Communities(pub Vec); + +impl Display for Communities { + fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { + let communities: Vec = self.0.iter().map(|c| c.to_string()).collect(); + write!(f, "{}", communities.join(" ")) + } +} + +#[derive(Debug)] +pub struct Prefix(pub Contiguous, pub bool); + +impl Display for Prefix { + fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { + let Prefix(prefix, is_best) = self; + let prefix = prefix.to_string(); + let prefix = if *is_best { + prefix.into() + } else { + prefix.truecolor(127, 127, 127) + }; + write!(f, "{prefix}") + } +} + +#[derive(Debug, Tabled)] +pub struct RouteEntry { + #[tabled(rename = "Prefix")] + pub prefix: Prefix, + #[tabled(rename = "Next Hop")] + pub next_hop: String, + #[tabled(rename = "Peer")] + pub peer: String, + #[tabled(rename = "Source")] + pub source: String, + #[tabled(rename = "Peer AS")] + pub peer_as: u32, + #[tabled(rename = "Origin")] + pub origin_as: u32, + #[tabled(rename = "Pref")] + pub pref: u32, + #[tabled(rename = "MED")] + pub med: u32, + #[tabled(rename = "Communities")] + pub communities: Communities, +} + +impl From for RouteEntry { + fn from(route: operatorpb::Route) -> Self { + let communities = route.large_communities.into_iter().map(|c| c.into()).collect(); + let prefix = Contiguous::::parse(&route.prefix).expect("must be valid prefix"); + let source = RouteSourceId::try_from(route.source) + .unwrap_or_default() + .as_str_name() + .strip_prefix("ROUTE_SOURCE_ID_") + .unwrap_or_default() + .to_lowercase(); + + Self { + prefix: Prefix(prefix, route.is_best), + next_hop: route.next_hop, + peer: route.peer, + source, + peer_as: route.peer_as, + origin_as: route.origin_as, + pref: route.pref, + med: route.med, + communities: Communities(communities), + } + } +} + +fn print_table(entries: I) +where + I: IntoIterator, + T: Tabled, +{ + let mut table = Table::new(entries); + table.with( + Style::modern() + .horizontals([(1, HorizontalLine::inherit(Style::modern()))]) + .remove_horizontal(), + ); + table.modify(Columns::new(..), BorderColor::filled(Color::rgb_fg(0x4e, 0x4e, 0x4e))); + table.modify(Rows::first(), Color::BOLD); + println!("{table}"); +} diff --git a/agents/yanet-route-operator/cmd/yanet-route-operator/main.go b/agents/yanet-route-operator/cmd/yanet-route-operator/main.go new file mode 100644 index 000000000..39ece9261 --- /dev/null +++ b/agents/yanet-route-operator/cmd/yanet-route-operator/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + _ "google.golang.org/grpc/encoding/gzip" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/operator" + "github.com/yanet-platform/yanet2/common/go/logging" + "github.com/yanet-platform/yanet2/common/go/xcmd" +) + +var rootArgs struct { + ConfigPath string +} + +var rootCmd = &cobra.Command{ + Use: "yanet-route-operator", + Short: "YANET route operator — owns RIB, neighbour tables and FIB build", + RunE: func(cmd *cobra.Command, args []string) error { + err := run() + if errors.Is(err, xcmd.Interrupted{}) { + return nil + } + + return err + }, +} + +func init() { + rootCmd.Flags().StringVarP( + &rootArgs.ConfigPath, + "config", "c", "", + "Path to the configuration file (required)", + ) + rootCmd.MarkFlagRequired("config") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + if errors.Is(err, xcmd.Interrupted{}) { + return + } + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := operator.LoadConfig(rootArgs.ConfigPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + log, _, err := logging.Init(&cfg.Logging) + if err != nil { + return fmt.Errorf("failed to initialize logging: %w", err) + } + defer log.Sync() + + log.Debugw("parsed config", zap.Any("config", cfg)) + + op, err := operator.NewOperator(cfg, operator.WithLog(log.Desugar())) + if err != nil { + return fmt.Errorf("failed to construct operator: %w", err) + } + defer func() { + if err := op.Close(); err != nil { + log.Warnw("failed to close operator", zap.Error(err)) + } + }() + + wg, ctx := errgroup.WithContext(context.Background()) + wg.Go(func() error { + return op.Run(ctx) + }) + wg.Go(func() error { + return xcmd.WaitInterrupted(ctx) + }) + + return wg.Wait() +} diff --git a/agents/yanet-route-operator/etc/yanet/yanet-route-operator.yaml b/agents/yanet-route-operator/etc/yanet/yanet-route-operator.yaml new file mode 100644 index 000000000..839b5085d --- /dev/null +++ b/agents/yanet-route-operator/etc/yanet/yanet-route-operator.yaml @@ -0,0 +1,69 @@ +# YANET Route Operator Configuration +--- +# Logging configuration. +logging: + level: debug + +# gRPC server exposed by the operator. +server: + endpoint: "[::1]:50002" + +# One entry per dataplane instance (typically one per NUMA node). +# +# The operator pushes the same computed FIB to every gateway listed +# below via the route-module UpdateFIB RPC. +gateways: + - name: numa0 + endpoint: "[::1]:8080" + +# Heartbeat interval for self-registration with each gateway's module +# registry. +register: + interval: 30s + +# Reconcile loop: snapshot RIB + neighbour-table -> build FIB -> +# fan-out per-gateway. +# +# Backoffs apply on per-gateway push errors. +reconcile: + interval: 30s + initial_backoff: 500ms + max_backoff: 30s + +# The single network function published by the operator on every +# gateway. +# +# Re-applied every reconcile pass (idempotent). One operator manages exactly +# one function with one chain and one route module. +function: + name: "fn:route" + chain: "default" + weight: 1 + module: "route0" + +# Static seed data applied on startup, before serving requests. +static: + # Static routes seeded into the operator RIB (module above). + routes: + - prefix: 2a02:6b8:c00::/40 + nexthop_addr: fe80::1 + - prefix: 2a02:6b8:fc00::/40 + nexthop_addr: fe80::1 + # Static neighbour entries seeded into operator-managed tables. + neighbours: [] + +# Mapping from logical link names (as carried in routes) to OS +# interface names used by the netlink monitor. +link_map: {} + +# RIB session TTL — stale FeedRIB sessions are garbage-collected +# after this duration once superseded by a newer session id. +rib_ttl: 5m + +# Kernel neighbour discovery via netlink (ND/ARP). Disable when +# the operator is not running on a host with the same network +# namespace as the dataplane. +netlink_monitor: + disabled: false + table_name: kernel + default_priority: 100 diff --git a/modules/route/internal/discovery/neigh/entry.go b/agents/yanet-route-operator/internal/discovery/neigh/entry.go similarity index 100% rename from modules/route/internal/discovery/neigh/entry.go rename to agents/yanet-route-operator/internal/discovery/neigh/entry.go diff --git a/modules/route/internal/discovery/neigh/neigh.go b/agents/yanet-route-operator/internal/discovery/neigh/neigh.go similarity index 100% rename from modules/route/internal/discovery/neigh/neigh.go rename to agents/yanet-route-operator/internal/discovery/neigh/neigh.go diff --git a/modules/route/internal/discovery/neigh/state.go b/agents/yanet-route-operator/internal/discovery/neigh/state.go similarity index 100% rename from modules/route/internal/discovery/neigh/state.go rename to agents/yanet-route-operator/internal/discovery/neigh/state.go diff --git a/modules/route/internal/discovery/neigh/table.go b/agents/yanet-route-operator/internal/discovery/neigh/table.go similarity index 100% rename from modules/route/internal/discovery/neigh/table.go rename to agents/yanet-route-operator/internal/discovery/neigh/table.go diff --git a/modules/route/internal/discovery/neigh/table_test.go b/agents/yanet-route-operator/internal/discovery/neigh/table_test.go similarity index 100% rename from modules/route/internal/discovery/neigh/table_test.go rename to agents/yanet-route-operator/internal/discovery/neigh/table_test.go diff --git a/agents/yanet-route-operator/internal/operator/actuator_gateway.go b/agents/yanet-route-operator/internal/operator/actuator_gateway.go new file mode 100644 index 000000000..0c576af26 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/actuator_gateway.go @@ -0,0 +1,147 @@ +package operator + +import ( + "context" + "errors" + "fmt" + + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/yanet-platform/yanet2/common/commonpb" + "github.com/yanet-platform/yanet2/controlplane/ynpb" + "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb" +) + +// GatewayActuator applies route-operator state to a single Gateway via +// the route module's UpdateFIB unary RPC. +type GatewayActuator struct { + name string + conn *grpc.ClientConn + routes routepb.RouteServiceClient + functions ynpb.FunctionServiceClient + fn FunctionConfig + log *zap.Logger +} + +// NewGatewayActuator dials the Gateway endpoint and returns a +// ready-to-use actuator. +func NewGatewayActuator( + cfg GatewayConfig, + options ...GatewayActuatorOption, +) (*GatewayActuator, error) { + opts := newGatewayActuatorOptions() + for _, o := range options { + o(opts) + } + + if opts.Function.Name.Unwrap() == "" { + return nil, fmt.Errorf("gateway actuator: function is required") + } + + endpoint := cfg.Endpoint.Unwrap() + conn, err := grpc.NewClient( + endpoint, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("failed to dial gateway %q at %q: %w", cfg.Name, endpoint, err) + } + + return &GatewayActuator{ + name: cfg.Name, + conn: conn, + routes: routepb.NewRouteServiceClient(conn), + functions: ynpb.NewFunctionServiceClient(conn), + fn: opts.Function, + log: opts.Log.With(zap.String("gateway", cfg.Name)), + }, nil +} + +// Close releases the underlying gRPC connection. +func (m *GatewayActuator) Close() error { + return m.conn.Close() +} + +// Apply pushes every FIB in fibs to the gateway via UpdateFIB. +// +// Errors from individual FIBs are joined; the reconcile loop applies +// backoff, so each Apply pass tries every FIB regardless of partial +// failures. +func (m *GatewayActuator) Apply(ctx context.Context, fibs []FIB) error { + var err error + for _, fib := range fibs { + if fib.Name == "" { + err = errors.Join(err, fmt.Errorf("FIB is missing module config name")) + continue + } + if e := m.pushFIB(ctx, fib); e != nil { + err = errors.Join(err, fmt.Errorf("failed to push FIB to gateway %q: %w", m.name, e)) + } + } + + return errors.Join(err, m.applyFunction(ctx)) +} + +// applyFunction publishes the operator's single network-function +// definition to the gateway via FunctionService.Update. Idempotent; +// called every reconcile pass. The function has exactly one chain with +// exactly one module of type "route". +func (m *GatewayActuator) applyFunction(ctx context.Context) error { + modules := []*commonpb.ModuleId{{ + Type: "route", + Name: m.fn.Module.Unwrap(), + }} + chains := []*ynpb.FunctionChain{{ + Chain: &ynpb.Chain{Name: m.fn.Chain.Unwrap(), Modules: modules}, + Weight: m.fn.Weight, + }} + req := &ynpb.UpdateFunctionRequest{ + Function: &ynpb.Function{ + Id: &commonpb.FunctionId{Name: m.fn.Name.Unwrap()}, + Chains: chains, + }, + } + if _, err := m.functions.Update(ctx, req); err != nil { + return fmt.Errorf("failed to update function %q on gateway %q: %w", m.fn.Name.Unwrap(), m.name, err) + } + m.log.Debug("updated function", zap.String("function", m.fn.Name.Unwrap())) + return nil +} + +// pushFIB applies fib to the gateway via the UpdateFIB unary RPC. +func (m *GatewayActuator) pushFIB(ctx context.Context, fib FIB) error { + entries := make([]*routepb.FIBEntry, len(fib.Entries)) + for idx, entry := range fib.Entries { + entries[idx] = fibEntryToProto(entry) + } + + req := &routepb.UpdateFIBRequest{ + ModuleName: fib.Name, + Entries: entries, + } + + if _, err := m.routes.UpdateFIB(ctx, req); err != nil { + return fmt.Errorf("failed to call UpdateFIB: %w", err) + } + + m.log.Debug("pushed FIB to gateway", zap.String("name", fib.Name)) + return nil +} + +// fibEntryToProto converts an internal FIBEntry to the wire format. +func fibEntryToProto(entry FIBEntry) *routepb.FIBEntry { + nexthops := make([]*routepb.FIBNexthop, len(entry.Nexthops)) + for idx, nh := range entry.Nexthops { + nexthops[idx] = &routepb.FIBNexthop{ + SrcMac: commonpb.NewMACAddressEUI48(nh.SourceMAC), + DstMac: commonpb.NewMACAddressEUI48(nh.DestinationMAC), + Device: nh.Device, + } + } + return &routepb.FIBEntry{ + Prefix: entry.Prefix.String(), + Nexthops: nexthops, + } +} diff --git a/agents/yanet-route-operator/internal/operator/cfg.go b/agents/yanet-route-operator/internal/operator/cfg.go new file mode 100644 index 000000000..fa46e9591 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/cfg.go @@ -0,0 +1,195 @@ +package operator + +import ( + "errors" + "fmt" + "os" + "time" + + "go.uber.org/zap/zapcore" + + "github.com/yanet-platform/yanet2/common/go/logging" + "github.com/yanet-platform/yanet2/common/go/xcfg" +) + +const ( + DefaultReconcileInterval = 30 * time.Second + DefaultReconcileInitialBackoff = 500 * time.Millisecond + DefaultReconcileMaxBackoff = 30 * time.Second + DefaultRegisterInterval = 30 * time.Second + DefaultRIBTTL = 5 * time.Minute +) + +// Config is the top-level YAML configuration for yanet-route-operator. +type Config struct { + Logging logging.Config `yaml:"logging"` + Server *GRPCServerConfig `yaml:"server"` + Gateways []GatewayConfig `yaml:"gateways"` + Register RegisterConfig `yaml:"register"` + Reconcile ReconcileConfig `yaml:"reconcile"` + Static StaticConfig `yaml:"static"` + // Function is the single gateway-side network function published by + // this operator. Re-applied every reconcile pass; idempotent updates. + Function FunctionConfig `yaml:"function"` + LinkMap map[string]string `yaml:"link_map"` + RIBTTL time.Duration `yaml:"rib_ttl"` + NetlinkMonitor NetlinkMonitorConfig `yaml:"netlink_monitor"` +} + +// GatewayConfig holds the name and gRPC endpoint of a single Gateway. +type GatewayConfig struct { + // Name is a human-readable label used in logs and status reports. + Name string `yaml:"name"` + // Endpoint is the gRPC address of the Gateway. + Endpoint xcfg.NonEmptyString `yaml:"endpoint"` +} + +// RegisterConfig holds the gateway registration heartbeat parameter. +type RegisterConfig struct { + // Interval sets heartbeat period between registration refreshes. + Interval xcfg.NonZero[time.Duration] `yaml:"interval"` +} + +// ReconcileConfig holds timing parameters for the reconcile loop. +type ReconcileConfig struct { + // Interval is the steady-state period between successful reconcile + // passes. + Interval xcfg.NonZero[time.Duration] `yaml:"interval"` + // InitialBackoff is the first sleep after a failed pass; grows + // exponentially. + InitialBackoff xcfg.NonZero[time.Duration] `yaml:"initial_backoff"` + // MaxBackoff caps the exponential backoff sleep. + MaxBackoff xcfg.NonZero[time.Duration] `yaml:"max_backoff"` +} + +func (m *ReconcileConfig) Validate() error { + if m.MaxBackoff.Unwrap() < m.InitialBackoff.Unwrap() { + return fmt.Errorf( + "max_backoff (%s) must be >= initial_backoff (%s)", + m.MaxBackoff.Unwrap(), + m.InitialBackoff.Unwrap(), + ) + } + + return nil +} + +// FunctionConfig describes the single gateway-side function definition +// this operator publishes. The architectural invariant is "one operator +// manages exactly one network function with one chain and one module". +type FunctionConfig struct { + // Name is the function identifier (e.g. "fn:route"). + Name xcfg.NonEmptyString `yaml:"name"` + // Chain is the chain name (e.g. "default"). + Chain xcfg.NonEmptyString `yaml:"chain"` + // Weight is the chain weight inside the function. + Weight uint64 `yaml:"weight"` + // Module is the route module config name this function targets + // (e.g. "route0"). + Module xcfg.NonEmptyString `yaml:"module"` +} + +// StaticConfig holds static RIB and neighbour seed data applied at +// startup before the operator begins serving requests. +type StaticConfig struct { + Routes []StaticRouteConfig `yaml:"routes"` + Neighbours []StaticNeighbourConfig `yaml:"neighbours"` +} + +// StaticRouteConfig describes a single static-route entry to seed. The +// route is implicitly seeded into the operator's single managed module +// (Config.Function.Module). +type StaticRouteConfig struct { + // Prefix is the destination prefix in CIDR notation. + Prefix string `yaml:"prefix"` + // NexthopAddr is the next-hop IP address. + NexthopAddr string `yaml:"nexthop_addr"` +} + +// StaticNeighbourConfig describes a single static neighbour entry to +// seed. +type StaticNeighbourConfig struct { + // Table is the destination neighbour table name. + Table string `yaml:"table"` + // NextHop is the next-hop IP address. + NextHop string `yaml:"next_hop"` + // LinkAddr is the destination MAC address. + LinkAddr string `yaml:"link_addr"` + // HardwareAddr is the local interface MAC address. + HardwareAddr string `yaml:"hardware_addr"` + // Device is the egress interface name. + Device string `yaml:"device"` + // Priority overrides the table default priority when non-zero. + Priority uint32 `yaml:"priority"` +} + +// NetlinkMonitorConfig configures the kernel neighbour discovery via +// netlink. +type NetlinkMonitorConfig struct { + // Disabled disables the netlink neighbour monitor entirely. + Disabled bool `yaml:"disabled"` + // TableName is the name of the kernel neighbour table. + TableName string `yaml:"table_name"` + // DefaultPriority is the default priority for kernel-learned + // neighbour entries. + DefaultPriority uint32 `yaml:"default_priority"` +} + +func (m *Config) Validate() error { + if len(m.Gateways) == 0 { + return errors.New("at least one gateway must be configured") + } + + return nil +} + +// DefaultConfig returns a Config populated with sensible defaults. +func DefaultConfig() *Config { + return &Config{ + Logging: logging.Config{ + Level: zapcore.InfoLevel, + }, + Server: &GRPCServerConfig{ + Endpoint: xcfg.MustNonEmptyString("localhost:50002"), + }, + Reconcile: ReconcileConfig{ + Interval: xcfg.MustNonZero(DefaultReconcileInterval), + InitialBackoff: xcfg.MustNonZero(DefaultReconcileInitialBackoff), + MaxBackoff: xcfg.MustNonZero(DefaultReconcileMaxBackoff), + }, + Register: RegisterConfig{ + Interval: xcfg.MustNonZero(DefaultRegisterInterval), + }, + Function: FunctionConfig{ + Name: xcfg.MustNonEmptyString("fn:route"), + Chain: xcfg.MustNonEmptyString("default"), + Weight: 1, + Module: xcfg.MustNonEmptyString("route0"), + }, + RIBTTL: DefaultRIBTTL, + LinkMap: map[string]string{}, + NetlinkMonitor: NetlinkMonitorConfig{ + TableName: "kernel", + DefaultPriority: 100, + }, + } +} + +// LoadConfig reads a YAML file from path and returns the parsed Config. +// +// Default values are applied before unmarshalling so any absent field +// retains its default. Validation is driven by xcfg.Decode, which calls +// Validate() on every field whose type implements it. +func LoadConfig(path string) (*Config, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + cfg := DefaultConfig() + if err := xcfg.Decode(buf, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + return cfg, nil +} diff --git a/agents/yanet-route-operator/internal/operator/fib_build.go b/agents/yanet-route-operator/internal/operator/fib_build.go new file mode 100644 index 000000000..a5bc2ef04 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/fib_build.go @@ -0,0 +1,117 @@ +package operator + +import ( + "net/netip" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/discovery/neigh" + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/rib" + "github.com/yanet-platform/yanet2/common/go/bitset" + "github.com/yanet-platform/yanet2/common/go/maptrie" +) + +// FIBNexthop describes a hardware-level nexthop (resolved neighbour). +type FIBNexthop struct { + // SourceMAC is the local interface MAC. + SourceMAC [6]byte + // DestinationMAC is the next-hop MAC. + DestinationMAC [6]byte + // Device is the egress interface name. + Device string +} + +// FIBEntry describes a single FIB prefix and its ECMP nexthops. +type FIBEntry struct { + // Prefix is the destination network. + Prefix netip.Prefix + // Nexthops are the resolved hardware routes for the prefix. The slice + // is deduplicated. + Nexthops []FIBNexthop +} + +// FIB is the complete forwarding table for one module config. +type FIB struct { + // Name is the module config name this FIB belongs to. + Name string + // Entries is the list of FIB entries. + Entries []FIBEntry +} + +// FIBBuildStats summarises a BuildFIB pass for observability. +type FIBBuildStats struct { + TotalPrefixes int + TotalRoutes int + SkippedPrefixes int + NeighbourNotFound int + HardwareRoutes int + PrefixesAdded int +} + +// BuildFIB resolves a RIB dump against the supplied neighbour view and +// produces a deduplicated FIB. The function is pure: no shared memory +// is touched and no errors are returned because every individual route +// resolution failure is best-effort (recorded in stats). +func BuildFIB( + ribDump maptrie.MapTrie[netip.Prefix, netip.Addr, rib.RoutesList], + neighbours neigh.NexthopCacheView, +) (FIB, FIBBuildStats) { + var stats FIBBuildStats + + // Track hardware-route uniqueness so the resulting FIB nexthops are + // deduplicated per prefix using TinyBitset semantics, mirroring the + // behaviour of the legacy backend. + hardwareIndex := map[neigh.HardwareRoute]uint32{} + hardwareSlice := []neigh.HardwareRoute{} + entries := make([]FIBEntry, 0) + + for prefixLen := range ribDump { + for prefix, routesList := range ribDump[prefixLen] { + stats.TotalPrefixes++ + if len(routesList.Routes) == 0 { + stats.SkippedPrefixes++ + continue + } + + stats.TotalRoutes += len(routesList.Routes) + + key := bitset.TinyBitset{} + for _, route := range routesList.Routes { + entry, ok := neighbours.Lookup(route.NextHop.Unmap()) + if !ok { + stats.NeighbourNotFound++ + continue + } + idx, ok := hardwareIndex[entry.HardwareRoute] + if !ok { + idx = uint32(len(hardwareSlice)) + hardwareIndex[entry.HardwareRoute] = idx + hardwareSlice = append(hardwareSlice, entry.HardwareRoute) + stats.HardwareRoutes++ + } + key.Insert(idx) + } + + if key.Count() == 0 { + continue + } + + indices := key.AsSlice() + nexthops := make([]FIBNexthop, 0, len(indices)) + for _, idx := range indices { + hr := hardwareSlice[idx] + nexthops = append(nexthops, FIBNexthop{ + SourceMAC: hr.SourceMAC, + DestinationMAC: hr.DestinationMAC, + Device: hr.Device, + }) + } + + entries = append(entries, FIBEntry{ + Prefix: prefix, + Nexthops: nexthops, + }) + stats.PrefixesAdded++ + } + } + + return FIB{Entries: entries}, stats +} diff --git a/agents/yanet-route-operator/internal/operator/metrics.go b/agents/yanet-route-operator/internal/operator/metrics.go new file mode 100644 index 000000000..d6b2ce7d6 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/metrics.go @@ -0,0 +1,31 @@ +package operator + +import ( + "time" + + "github.com/yanet-platform/yanet2/common/commonpb" +) + +// Metrics is the single observability sink for the route operator. +// +// The Phase 1 scaffold only exposes the empty shape; counters and +// gauges will land in a follow-up phase. It already satisfies both +// MetricsCollector and ReconcilerMetricsObserver so wiring is stable +// across future phases. +type Metrics struct{} + +// NewMetrics constructs an empty Metrics sink. +func NewMetrics() *Metrics { + return &Metrics{} +} + +// Collect renders the current state of every metric as a slice of +// commonpb.Metric values. Empty for now. +func (m *Metrics) Collect() []*commonpb.Metric { + return nil +} + +func (m *Metrics) OnReconcileCompleted(error) {} +func (m *Metrics) OnBackoffScheduled(time.Duration) {} +func (m *Metrics) OnBackoffReset() {} +func (m *Metrics) OnStateChanged(ReconcilerState) {} diff --git a/agents/yanet-route-operator/internal/operator/metrics_observer.go b/agents/yanet-route-operator/internal/operator/metrics_observer.go new file mode 100644 index 000000000..03960d6b2 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/metrics_observer.go @@ -0,0 +1,49 @@ +package operator + +import ( + "time" + + "github.com/yanet-platform/yanet2/common/commonpb" +) + +// MetricsCollector renders the current state of the operator metrics +// as a flat slice of commonpb.Metric values. +type MetricsCollector interface { + Collect() []*commonpb.Metric +} + +// noopMetricsCollector is the default MetricsCollector wired into +// service options when the caller does not pass a real one. +type noopMetricsCollector struct{} + +func (noopMetricsCollector) Collect() []*commonpb.Metric { + return nil +} + +// ReconcilerState is the current state of the reconcile loop. +type ReconcilerState int + +const ( + ReconcilerStateIdle ReconcilerState = iota + ReconcilerStateApplying + ReconcilerStateSleeping +) + +// ReconcilerMetricsObserver receives semantic events from the reconcile +// loop and translates them into metrics. +type ReconcilerMetricsObserver interface { + OnReconcileCompleted(err error) + OnBackoffScheduled(delay time.Duration) + OnBackoffReset() + OnStateChanged(state ReconcilerState) +} + +// noopReconcilerMetricsObserver is the default +// ReconcilerMetricsObserver wired into reconciler options when no +// metrics sink is attached. +type noopReconcilerMetricsObserver struct{} + +func (noopReconcilerMetricsObserver) OnReconcileCompleted(error) {} +func (noopReconcilerMetricsObserver) OnBackoffScheduled(time.Duration) {} +func (noopReconcilerMetricsObserver) OnBackoffReset() {} +func (noopReconcilerMetricsObserver) OnStateChanged(ReconcilerState) {} diff --git a/agents/yanet-route-operator/internal/operator/operator.go b/agents/yanet-route-operator/internal/operator/operator.go new file mode 100644 index 000000000..22561140c --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/operator.go @@ -0,0 +1,377 @@ +package operator + +import ( + "context" + "fmt" + "net" + "net/netip" + "time" + + "github.com/cenkalti/backoff/v5" + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/discovery/neigh" + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/rib" + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" + "github.com/yanet-platform/yanet2/common/go/operator" + "github.com/yanet-platform/yanet2/controlplane/gateway" +) + +const ( + // staticTablePriority is the default priority assigned to entries in + // the built-in "static" neighbour table. + staticTablePriority = 10 +) + +var ( + serviceNames = []string{ + operatorpb.RouteService_ServiceDesc.ServiceName, + operatorpb.NeighbourService_ServiceDesc.ServiceName, + operatorpb.RouteOperatorService_ServiceDesc.ServiceName, + operatorpb.MetricsService_ServiceDesc.ServiceName, + } +) + +// Operator wires together the gRPC server, gateway-registration loop +// and reconciler for the route operator. +type Operator struct { + cfg *Config + server *GRPCServer + reconciler *Reconciler + actuator Actuator + routeSvc *RouteService + neighbourSvc *NeighbourService + neighTable *neigh.NeighTable + neighMonitor *neigh.NeighMonitor + log *zap.Logger +} + +// NewOperator constructs an Operator from the supplied configuration. +func NewOperator(cfg *Config, options ...Option) (*Operator, error) { + opts := newOptions() + for _, o := range options { + o(opts) + } + + log := opts.Log + metrics := NewMetrics() + + neighTable := neigh.NewNeighTable() + if _, err := neighTable.CreateSource("static", staticTablePriority, true); err != nil { + return nil, fmt.Errorf("failed to create static neighbour source: %w", err) + } + + neighMonitor, err := newNeighbourMonitor(cfg, neighTable, log) + if err != nil { + return nil, fmt.Errorf("failed to create neighbour monitor: %w", err) + } + + // Reconciler needs services for snapshot + services need a wake + // callback. Construct a placeholder closure first; once both sides + // exist we resolve the closure to the real reconciler. + var reconciler *Reconciler + wake := func() { + if reconciler != nil { + reconciler.Wake() + } + } + + routeSvc := NewRouteService( + neighTable, + WithRouteServiceLog(log), + WithRouteServiceRIBTTL(ribTTL(cfg)), + WithRouteServiceOnChanged(wake), + ) + neighbourSvc := NewNeighbourService( + neighTable, + WithNeighbourServiceOnChanged(wake), + ) + metricsSvc := NewMetricsService( + WithMetricsServiceCollector(metrics), + ) + operatorSvc := NewRouteOperatorService() + + server := NewGRPCServer( + cfg.Server, + routeSvc, + neighbourSvc, + metricsSvc, + operatorSvc, + WithGRPCLog(log), + ) + + actuators := make([]Actuator, 0, len(cfg.Gateways)) + for _, gw := range cfg.Gateways { + actuator, err := NewGatewayActuator( + gw, + WithGatewayActuatorLog(log), + WithGatewayActuatorFunction(cfg.Function), + ) + if err != nil { + for _, a := range actuators { + _ = a.Close() + } + return nil, fmt.Errorf("failed to construct gateway actuator %q: %w", gw.Name, err) + } + + actuators = append(actuators, actuator) + } + + actuator := operator.NewFanOutActuator( + actuators, + operator.WithFanOutLog(log), + ) + + snapshot := func() []FIB { + ribs := routeSvc.Snapshot() + view := neighTable.View() + + fibs := make([]FIB, 0, len(ribs)) + for name, ribRef := range ribs { + fib, _ := BuildFIB(ribRef.DumpRoutes(), view) + fib.Name = name + fibs = append(fibs, fib) + } + return fibs + } + + reconciler = NewReconciler( + actuator, + snapshot, + WithReconcileInterval(cfg.Reconcile.Interval.Unwrap()), + WithReconcileBackoff( + cfg.Reconcile.InitialBackoff.Unwrap(), + cfg.Reconcile.MaxBackoff.Unwrap(), + ), + WithReconcilerMetrics(metrics), + WithReconcilerLog(log), + ) + + return &Operator{ + cfg: cfg, + server: server, + reconciler: reconciler, + actuator: actuator, + routeSvc: routeSvc, + neighbourSvc: neighbourSvc, + neighTable: neighTable, + neighMonitor: neighMonitor, + log: log, + }, nil +} + +// ribTTL returns the configured RIB TTL or the default if unset. +func ribTTL(cfg *Config) time.Duration { + if cfg.RIBTTL > 0 { + return cfg.RIBTTL + } + return DefaultRIBTTL +} + +// newNeighbourMonitor constructs the netlink-backed neighbour monitor +// when enabled in the config; otherwise returns nil. +func newNeighbourMonitor( + cfg *Config, + neighTable *neigh.NeighTable, + log *zap.Logger, +) (*neigh.NeighMonitor, error) { + if cfg.NetlinkMonitor.Disabled { + return nil, nil + } + + source, err := neighTable.CreateSource( + cfg.NetlinkMonitor.TableName, + cfg.NetlinkMonitor.DefaultPriority, + true, + ) + if err != nil { + return nil, fmt.Errorf("failed to create kernel neighbour source: %w", err) + } + + monitor := neigh.NewNeighMonitor( + neighTable, + source, + neigh.WithLog(log), + neigh.WithLinkMap(cfg.LinkMap), + ) + + return monitor, nil +} + +// Close releases resources owned by the operator. +func (m *Operator) Close() error { + if err := m.routeSvc.Close(); err != nil { + m.log.Warn("failed to close route service", zap.Error(err)) + } + return m.actuator.Close() +} + +// Run starts the gRPC server, gateway-registration loops and reconcile +// loop. It blocks until the supplied context is cancelled or any +// goroutine returns an error. +func (m *Operator) Run(ctx context.Context) error { + if err := m.applyStaticSeed(); err != nil { + return fmt.Errorf("failed to apply static seed: %w", err) + } + + wg, ctx := errgroup.WithContext(ctx) + listener, err := net.Listen("tcp", m.cfg.Server.Endpoint.Unwrap()) + if err != nil { + return fmt.Errorf("failed to listen gRPC operator endpoint %q: %w", m.cfg.Server.Endpoint.Unwrap(), err) + } + + wg.Go(func() error { + return m.server.Run(ctx, listener) + }) + wg.Go(func() error { + return m.runGatewayRegistration(ctx, listener.Addr()) + }) + wg.Go(func() error { + return m.reconciler.Run(ctx) + }) + wg.Go(func() error { + return m.runNeighbourMonitor(ctx) + }) + + return wg.Wait() +} + +// runNeighbourMonitor runs the netlink monitor when enabled. +func (m *Operator) runNeighbourMonitor(ctx context.Context) error { + if m.neighMonitor == nil { + <-ctx.Done() + return ctx.Err() + } + return m.neighMonitor.Run(ctx) +} + +// applyStaticSeed seeds the operator state from the YAML static config. +func (m *Operator) applyStaticSeed() error { + module := m.cfg.Function.Module.Unwrap() + for _, route := range m.cfg.Static.Routes { + prefix, err := netip.ParsePrefix(route.Prefix) + if err != nil { + return fmt.Errorf("failed to parse static route prefix %q: %w", route.Prefix, err) + } + nexthop, err := netip.ParseAddr(route.NexthopAddr) + if err != nil { + return fmt.Errorf("failed to parse static route nexthop %q: %w", route.NexthopAddr, err) + } + holder := m.routeSvc.getOrCreateRib(module) + if err := holder.AddUnicastRoute(prefix, nexthop, rib.RouteSourceStatic); err != nil { + return fmt.Errorf("failed to seed static route %s via %s: %w", prefix, nexthop, err) + } + } + + if len(m.cfg.Static.Neighbours) > 0 { + grouped := map[string][]neigh.NeighbourEntry{} + for _, n := range m.cfg.Static.Neighbours { + table := n.Table + if table == "" { + table = defaultStaticTable + } + addr, err := netip.ParseAddr(n.NextHop) + if err != nil { + return fmt.Errorf("failed to parse static neighbour next_hop %q: %w", n.NextHop, err) + } + linkMAC, err := parseMAC(n.LinkAddr) + if err != nil { + return fmt.Errorf("failed to parse static neighbour link_addr %q: %w", n.LinkAddr, err) + } + hwMAC, err := parseMAC(n.HardwareAddr) + if err != nil { + return fmt.Errorf("failed to parse static neighbour hardware_addr %q: %w", n.HardwareAddr, err) + } + grouped[table] = append(grouped[table], neigh.NeighbourEntry{ + NextHop: addr, + HardwareRoute: neigh.HardwareRoute{ + SourceMAC: hwMAC, + DestinationMAC: linkMAC, + Device: n.Device, + }, + UpdatedAt: time.Now(), + State: neigh.NeighbourStatePermanent, + Priority: n.Priority, + }) + } + for table, entries := range grouped { + if err := m.neighTable.Add(table, entries); err != nil { + return fmt.Errorf("failed to seed neighbours into table %q: %w", table, err) + } + } + } + + if len(m.cfg.Static.Routes) > 0 || len(m.cfg.Static.Neighbours) > 0 { + m.reconciler.Wake() + } + + return nil +} + +// parseMAC parses an EUI-48 MAC address into a 6-byte array. +func parseMAC(s string) ([6]byte, error) { + hw, err := net.ParseMAC(s) + if err != nil { + return [6]byte{}, err + } + if len(hw) != 6 { + return [6]byte{}, fmt.Errorf("expected 6-byte MAC, got %d bytes", len(hw)) + } + return [6]byte(hw), nil +} + +// runGatewayRegistration heart-beats this operator's service set to +// every configured gateway. +func (m *Operator) runGatewayRegistration( + ctx context.Context, + endpoint net.Addr, +) error { + if len(m.cfg.Gateways) == 0 { + m.log.Warn("no gateways configured for operator registration", + zap.Strings("services", serviceNames), + ) + return nil + } + + interval := m.cfg.Register.Interval.Unwrap() + shortBackOff := func() backoff.BackOff { + return backoff.NewExponentialBackOff() + } + + wg, ctx := errgroup.WithContext(ctx) + for _, cfg := range m.cfg.Gateways { + log := m.log.With( + zap.String("gateway", cfg.Name), + zap.String("gateway_endpoint", cfg.Endpoint.Unwrap()), + ) + registrar, err := gateway.NewGatewayRegistrar( + cfg.Endpoint.Unwrap(), + nil, + gateway.WithLog(log), + gateway.WithBackOff(shortBackOff), + gateway.WithMaxElapsedTime(interval/2), + ) + if err != nil { + return fmt.Errorf("failed to create gateway registrar for %q: %w", cfg.Name, err) + } + + wg.Go(func() error { + defer func() { + if err := registrar.Close(); err != nil { + log.Warn("failed to close gateway registrar", zap.Error(err)) + } + }() + loop := gateway.NewRegistrationLoop( + registrar, + serviceNames, + endpoint.String(), + gateway.WithLoopInterval(interval), + gateway.WithLoopLog(log), + ) + return loop.Run(ctx) + }) + } + + return wg.Wait() +} diff --git a/agents/yanet-route-operator/internal/operator/options.go b/agents/yanet-route-operator/internal/operator/options.go new file mode 100644 index 000000000..f3263070c --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/options.go @@ -0,0 +1,212 @@ +package operator + +import ( + "time" + + "go.uber.org/zap" +) + +type options struct { + Log *zap.Logger +} + +func newOptions() *options { + return &options{ + Log: zap.NewNop(), + } +} + +type Option func(*options) + +// WithLog sets the logger for the Operator and all sub-components. +func WithLog(log *zap.Logger) Option { + return func(o *options) { + o.Log = log + } +} + +type reconcilerOptions struct { + Interval time.Duration + InitialBackoff time.Duration + MaxBackoff time.Duration + Metrics ReconcilerMetricsObserver + Log *zap.Logger +} + +func newReconcilerOptions() *reconcilerOptions { + return &reconcilerOptions{ + Interval: DefaultReconcileInterval, + InitialBackoff: DefaultReconcileInitialBackoff, + MaxBackoff: DefaultReconcileMaxBackoff, + Metrics: noopReconcilerMetricsObserver{}, + Log: zap.NewNop(), + } +} + +type ReconcilerOption func(*reconcilerOptions) + +// WithReconcilerLog sets the logger used by the reconcile loop. +func WithReconcilerLog(log *zap.Logger) ReconcilerOption { + return func(o *reconcilerOptions) { + o.Log = log + } +} + +// WithReconcileInterval sets the steady-state period between successful +// reconcile passes. +func WithReconcileInterval(d time.Duration) ReconcilerOption { + return func(o *reconcilerOptions) { + o.Interval = d + } +} + +// WithReconcileBackoff sets the initial and maximum sleep durations for +// the exponential backoff applied after failed reconcile passes. +func WithReconcileBackoff(initial, max time.Duration) ReconcilerOption { + return func(o *reconcilerOptions) { + o.InitialBackoff = initial + o.MaxBackoff = max + } +} + +// WithReconcilerMetrics attaches the metrics observer for the reconcile +// loop. +func WithReconcilerMetrics(metrics ReconcilerMetricsObserver) ReconcilerOption { + return func(o *reconcilerOptions) { + o.Metrics = metrics + } +} + +type routeServiceOptions struct { + RIBTTL time.Duration + OnChanged func() + Log *zap.Logger +} + +func newRouteServiceOptions() *routeServiceOptions { + return &routeServiceOptions{ + RIBTTL: DefaultRIBTTL, + OnChanged: func() {}, + Log: zap.NewNop(), + } +} + +type RouteServiceOption func(*routeServiceOptions) + +// WithRouteServiceLog sets the logger for the RouteService. +func WithRouteServiceLog(log *zap.Logger) RouteServiceOption { + return func(o *routeServiceOptions) { + o.Log = log + } +} + +// WithRouteServiceRIBTTL sets the TTL applied to FeedRIB cleanup tasks. +func WithRouteServiceRIBTTL(ttl time.Duration) RouteServiceOption { + return func(o *routeServiceOptions) { + o.RIBTTL = ttl + } +} + +// WithRouteServiceOnChanged registers a callback fired whenever the +// RIB state mutates so the reconcile loop can wake up. +func WithRouteServiceOnChanged(fn func()) RouteServiceOption { + return func(o *routeServiceOptions) { + o.OnChanged = fn + } +} + +type neighbourServiceOptions struct { + OnChanged func() +} + +func newNeighbourServiceOptions() *neighbourServiceOptions { + return &neighbourServiceOptions{ + OnChanged: func() {}, + } +} + +type NeighbourServiceOption func(*neighbourServiceOptions) + +// WithNeighbourServiceOnChanged registers a callback fired whenever +// neighbour state mutates so the reconcile loop can wake up. +func WithNeighbourServiceOnChanged(fn func()) NeighbourServiceOption { + return func(o *neighbourServiceOptions) { + o.OnChanged = fn + } +} + +type metricsServiceOptions struct { + Metrics MetricsCollector +} + +func newMetricsServiceOptions() *metricsServiceOptions { + return &metricsServiceOptions{ + Metrics: noopMetricsCollector{}, + } +} + +type MetricsServiceOption func(*metricsServiceOptions) + +// WithMetricsServiceCollector attaches the metrics collector that +// GetMetrics serves from. When unset, GetMetrics returns an empty +// response. +func WithMetricsServiceCollector(c MetricsCollector) MetricsServiceOption { + return func(o *metricsServiceOptions) { + o.Metrics = c + } +} + +type operatorServiceOptions struct{} + +func newOperatorServiceOptions() *operatorServiceOptions { + return &operatorServiceOptions{} +} + +type OperatorServiceOption func(*operatorServiceOptions) + +type grpcServerOptions struct { + Log *zap.Logger +} + +func newGRPCServerOptions() *grpcServerOptions { + return &grpcServerOptions{ + Log: zap.NewNop(), + } +} + +type GRPCServerOption func(*grpcServerOptions) + +// WithGRPCLog sets the logger used by the gRPC server wrapper. +func WithGRPCLog(log *zap.Logger) GRPCServerOption { + return func(o *grpcServerOptions) { + o.Log = log + } +} + +type gatewayActuatorOptions struct { + Function FunctionConfig + Log *zap.Logger +} + +func newGatewayActuatorOptions() *gatewayActuatorOptions { + return &gatewayActuatorOptions{ + Log: zap.NewNop(), + } +} + +type GatewayActuatorOption func(*gatewayActuatorOptions) + +// WithGatewayActuatorLog sets the logger for a single gateway actuator. +func WithGatewayActuatorLog(log *zap.Logger) GatewayActuatorOption { + return func(o *gatewayActuatorOptions) { + o.Log = log + } +} + +// WithGatewayActuatorFunction sets the network function the actuator +// publishes to its gateway on every Apply pass. +func WithGatewayActuatorFunction(fn FunctionConfig) GatewayActuatorOption { + return func(o *gatewayActuatorOptions) { + o.Function = fn + } +} diff --git a/agents/yanet-route-operator/internal/operator/reconciler.go b/agents/yanet-route-operator/internal/operator/reconciler.go new file mode 100644 index 000000000..c02195d9d --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/reconciler.go @@ -0,0 +1,138 @@ +package operator + +import ( + "context" + "errors" + "time" + + "go.uber.org/zap" + + "github.com/yanet-platform/yanet2/common/go/operator" + "github.com/yanet-platform/yanet2/common/go/xbackoff" +) + +// Actuator applies a desired set of FIBs to one or more downstream +// targets (gateways). +type Actuator = operator.Actuator[[]FIB] + +// SnapshotFunc returns the current desired FIB set. It is called once +// per reconcile pass under no held locks; implementations are +// responsible for their own concurrency control. +type SnapshotFunc func() []FIB + +// Reconciler is the route-operator reconcile loop. +type Reconciler struct { + actuator Actuator + snapshot SnapshotFunc + backoff *xbackoff.Backoff + interval time.Duration + + wakeCh chan struct{} + + metrics ReconcilerMetricsObserver + log *zap.Logger +} + +// NewReconciler constructs a Reconciler bound to the supplied actuator +// and snapshot source. +func NewReconciler( + actuator Actuator, + snapshot SnapshotFunc, + options ...ReconcilerOption, +) *Reconciler { + opts := newReconcilerOptions() + for _, o := range options { + o(opts) + } + + wakeCh := make(chan struct{}, 1) + + backoff := xbackoff.New(opts.InitialBackoff, + xbackoff.WithMax(opts.MaxBackoff), + xbackoff.WithSleeper(reconcilerSleeper{wake: wakeCh}), + xbackoff.WithOnRetry(func(_ int, d time.Duration, err error) { + opts.Log.Warn("reconcile pass failed", + zap.Duration("backoff", d), + zap.Error(err), + ) + opts.Metrics.OnReconcileCompleted(err) + opts.Metrics.OnBackoffScheduled(d) + opts.Metrics.OnStateChanged(ReconcilerStateSleeping) + }), + xbackoff.WithOnReset(func() { + opts.Metrics.OnBackoffReset() + }), + ) + + return &Reconciler{ + actuator: actuator, + snapshot: snapshot, + backoff: backoff, + interval: opts.Interval, + wakeCh: wakeCh, + metrics: opts.Metrics, + log: opts.Log, + } +} + +// Wake nudges the reconcile loop out of its sleep so the next pass runs +// immediately. Safe to call from any goroutine. +func (m *Reconciler) Wake() { + select { + case m.wakeCh <- struct{}{}: + default: + } +} + +// Run executes the reconcile loop until the supplied context is +// cancelled. +func (m *Reconciler) Run(ctx context.Context) error { + m.log.Info("running reconciler loop") + defer m.log.Info("stopped reconciler loop") + + sleeper := reconcilerSleeper{wake: m.wakeCh} + for { + m.metrics.OnStateChanged(ReconcilerStateApplying) + fibs := m.snapshot() + err := m.backoff.RunContext(ctx, func() error { + return m.actuator.Apply(ctx, fibs) + }) + switch { + case err == nil: + m.metrics.OnReconcileCompleted(nil) + m.metrics.OnStateChanged(ReconcilerStateSleeping) + if err := sleeper.Sleep(ctx, m.interval); err != nil { + if errors.Is(err, xbackoff.ErrInterrupted) { + continue + } + return err + } + case errors.Is(err, xbackoff.ErrInterrupted): + continue + default: + return err + } + } +} + +// reconcilerSleeper interrupts the backoff sleep on a wake signal. +type reconcilerSleeper struct { + wake <-chan struct{} +} + +func (m reconcilerSleeper) Sleep(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + t := time.NewTimer(d) + defer t.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-m.wake: + return xbackoff.ErrInterrupted + case <-t.C: + return nil + } +} diff --git a/agents/yanet-route-operator/internal/operator/server.go b/agents/yanet-route-operator/internal/operator/server.go new file mode 100644 index 000000000..960ee33be --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/server.go @@ -0,0 +1,85 @@ +package operator + +import ( + "context" + "fmt" + "net" + + "go.uber.org/zap" + "google.golang.org/grpc" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" + "github.com/yanet-platform/yanet2/common/go/xcfg" +) + +// GRPCServerConfig describes how to expose the operator's gRPC server. +type GRPCServerConfig struct { + Endpoint xcfg.NonEmptyString `yaml:"endpoint"` +} + +// GRPCServer wraps a grpc.Server with the operator's service set. +type GRPCServer struct { + cfg *GRPCServerConfig + server *grpc.Server + log *zap.Logger +} + +// NewGRPCServer registers all operator services on a fresh grpc.Server +// and returns the ready-to-run wrapper. +func NewGRPCServer( + cfg *GRPCServerConfig, + routeSvc *RouteService, + neighbourSvc *NeighbourService, + metricsSvc *MetricsService, + operatorSvc *RouteOperatorService, + options ...GRPCServerOption, +) *GRPCServer { + opts := newGRPCServerOptions() + for _, o := range options { + o(opts) + } + + server := grpc.NewServer() + operatorpb.RegisterRouteServiceServer(server, routeSvc) + operatorpb.RegisterNeighbourServiceServer(server, neighbourSvc) + operatorpb.RegisterMetricsServiceServer(server, metricsSvc) + operatorpb.RegisterRouteOperatorServiceServer(server, operatorSvc) + + return &GRPCServer{ + cfg: cfg, + server: server, + log: opts.Log, + } +} + +// Run serves until the supplied context is cancelled. On cancellation +// it performs a graceful stop and drains Serve's return value. +func (m *GRPCServer) Run(ctx context.Context, listener net.Listener) error { + serveErr := make(chan error, 1) + go func() { + m.log.Info("exposing gRPC server", + zap.Stringer("addr", listener.Addr()), + ) + serveErr <- m.server.Serve(listener) + }() + + select { + case <-ctx.Done(): + m.log.Info("stopping gRPC server", zap.Stringer("addr", listener.Addr())) + defer m.log.Info("stopped gRPC server", zap.Stringer("addr", listener.Addr())) + + m.server.GracefulStop() + // Drain Serve's return value; after GracefulStop it returns nil + // on clean shutdown. + if err := <-serveErr; err != nil { + return fmt.Errorf("failed to serve gRPC: %w", err) + } + return nil + case err := <-serveErr: + // Serve returned before ctx was cancelled — treat as fatal. + if err != nil { + return fmt.Errorf("failed to serve gRPC: %w", err) + } + return nil + } +} diff --git a/agents/yanet-route-operator/internal/operator/service_metrics.go b/agents/yanet-route-operator/internal/operator/service_metrics.go new file mode 100644 index 000000000..ad7bc9d08 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/service_metrics.go @@ -0,0 +1,40 @@ +package operator + +import ( + "context" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" +) + +// MetricsService exposes operator runtime metrics over gRPC. +type MetricsService struct { + operatorpb.UnimplementedMetricsServiceServer + + metrics MetricsCollector +} + +// NewMetricsService constructs a MetricsService bound to the supplied +// collector. +func NewMetricsService(options ...MetricsServiceOption) *MetricsService { + opts := newMetricsServiceOptions() + for _, o := range options { + o(opts) + } + + return &MetricsService{ + metrics: opts.Metrics, + } +} + +// GetMetrics returns the current snapshot of all operator metrics. +// +// When no metrics sink is wired in, the response is empty rather than +// an error. +func (m *MetricsService) GetMetrics( + ctx context.Context, + req *operatorpb.GetMetricsRequest, +) (*operatorpb.GetMetricsResponse, error) { + return &operatorpb.GetMetricsResponse{ + Metrics: m.metrics.Collect(), + }, nil +} diff --git a/agents/yanet-route-operator/internal/operator/service_neighbour.go b/agents/yanet-route-operator/internal/operator/service_neighbour.go new file mode 100644 index 000000000..44b0c98aa --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/service_neighbour.go @@ -0,0 +1,211 @@ +package operator + +import ( + "context" + "net/netip" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/discovery/neigh" + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" + "github.com/yanet-platform/yanet2/common/commonpb" +) + +const defaultStaticTable = "static" + +// NeighbourService implements the operator-owned NeighbourService +// surface. Mutations wake the reconcile loop. +type NeighbourService struct { + operatorpb.UnimplementedNeighbourServiceServer + + neighTable *neigh.NeighTable + onChanged func() +} + +// NewNeighbourService constructs a NeighbourService bound to the +// supplied neighbour table. +func NewNeighbourService( + neighTable *neigh.NeighTable, + options ...NeighbourServiceOption, +) *NeighbourService { + opts := newNeighbourServiceOptions() + for _, o := range options { + o(opts) + } + + return &NeighbourService{ + neighTable: neighTable, + onChanged: opts.OnChanged, + } +} + +func (m *NeighbourService) List( + ctx context.Context, + req *operatorpb.ListNeighboursRequest, +) (*operatorpb.ListNeighboursResponse, error) { + table := req.GetTable() + + var view neigh.NexthopCacheView + if table == "" { + view = m.neighTable.View() + } else { + v, ok := m.neighTable.SourceView(table) + if !ok { + return nil, status.Errorf(codes.NotFound, "table %q not found", table) + } + view = v + } + + entries, size := view.Entries() + + neighbours := make([]*operatorpb.NeighbourEntry, 0, size) + for entry := range entries { + source := entry.Source + if source == "" { + source = table + } + + neighbours = append( + neighbours, + &operatorpb.NeighbourEntry{ + NextHop: entry.NextHop.String(), + LinkAddr: commonpb.NewMACAddressEUI48(entry.HardwareRoute.DestinationMAC), + HardwareAddr: commonpb.NewMACAddressEUI48(entry.HardwareRoute.SourceMAC), + State: operatorpb.NeighbourState(entry.State), + UpdatedAt: entry.UpdatedAt.Unix(), + Source: source, + Priority: entry.Priority, + Device: entry.HardwareRoute.Device, + }, + ) + } + + return &operatorpb.ListNeighboursResponse{ + Neighbours: neighbours, + }, nil +} + +func (m *NeighbourService) CreateTable( + ctx context.Context, + req *operatorpb.CreateNeighbourTableRequest, +) (*operatorpb.CreateNeighbourTableResponse, error) { + if _, err := m.neighTable.CreateSource(req.GetName(), req.GetDefaultPriority(), false); err != nil { + return nil, status.Errorf(codes.Internal, "failed to create neighbour table: %v", err) + } + m.onChanged() + return &operatorpb.CreateNeighbourTableResponse{}, nil +} + +func (m *NeighbourService) UpdateTable( + ctx context.Context, + req *operatorpb.UpdateNeighbourTableRequest, +) (*operatorpb.UpdateNeighbourTableResponse, error) { + if err := m.neighTable.UpdateSource(req.GetName(), req.GetDefaultPriority()); err != nil { + return nil, status.Errorf(codes.Internal, "failed to update neighbour table: %v", err) + } + m.onChanged() + return &operatorpb.UpdateNeighbourTableResponse{}, nil +} + +func (m *NeighbourService) RemoveTable( + ctx context.Context, + req *operatorpb.RemoveNeighbourTableRequest, +) (*operatorpb.RemoveNeighbourTableResponse, error) { + if err := m.neighTable.DeleteSource(req.GetName()); err != nil { + return nil, status.Errorf(codes.Internal, "failed to remove neighbour table: %v", err) + } + m.onChanged() + return &operatorpb.RemoveNeighbourTableResponse{}, nil +} + +func (m *NeighbourService) ListTables( + ctx context.Context, + req *operatorpb.ListNeighbourTablesRequest, +) (*operatorpb.ListNeighbourTablesResponse, error) { + sources := m.neighTable.ListSources() + + tables := make([]*operatorpb.NeighbourTableInfo, 0, len(sources)) + for _, src := range sources { + tables = append(tables, &operatorpb.NeighbourTableInfo{ + Name: src.Name, + DefaultPriority: src.DefaultPriority, + EntryCount: int64(src.EntryCount), + BuiltIn: src.BuiltIn, + }) + } + + return &operatorpb.ListNeighbourTablesResponse{ + Tables: tables, + }, nil +} + +func (m *NeighbourService) UpdateNeighbours( + ctx context.Context, + req *operatorpb.UpdateNeighboursRequest, +) (*operatorpb.UpdateNeighboursResponse, error) { + table := req.GetTable() + if table == "" { + table = defaultStaticTable + } + + entries := make([]neigh.NeighbourEntry, 0, len(req.GetEntries())) + for _, e := range req.GetEntries() { + addr, err := netip.ParseAddr(e.GetNextHop()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid nexthop %q: %v", e.GetNextHop(), err) + } + if e.GetHardwareAddr() == nil { + return nil, status.Errorf(codes.InvalidArgument, "neighbour entry %q is missing hardware_addr", e.GetNextHop()) + } + if e.GetLinkAddr() == nil { + return nil, status.Errorf(codes.InvalidArgument, "neighbour entry %q is missing link_addr", e.GetNextHop()) + } + + entries = append(entries, neigh.NeighbourEntry{ + NextHop: addr, + HardwareRoute: neigh.HardwareRoute{ + SourceMAC: e.GetHardwareAddr().EUI48(), + DestinationMAC: e.GetLinkAddr().EUI48(), + Device: e.GetDevice(), + }, + UpdatedAt: time.Now(), + State: neigh.NeighbourStatePermanent, + Priority: e.GetPriority(), + }) + } + + if err := m.neighTable.Add(table, entries); err != nil { + return nil, status.Errorf(codes.Internal, "failed to add neighbours: %v", err) + } + + m.onChanged() + return &operatorpb.UpdateNeighboursResponse{}, nil +} + +func (m *NeighbourService) RemoveNeighbours( + ctx context.Context, + req *operatorpb.RemoveNeighboursRequest, +) (*operatorpb.RemoveNeighboursResponse, error) { + table := req.GetTable() + if table == "" { + table = defaultStaticTable + } + + addrs := make([]netip.Addr, 0, len(req.GetNextHops())) + for _, hop := range req.GetNextHops() { + addr, err := netip.ParseAddr(hop) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid next_hop %q: %v", hop, err) + } + addrs = append(addrs, addr) + } + + if err := m.neighTable.Remove(table, addrs); err != nil { + return nil, status.Errorf(codes.Internal, "failed to remove neighbours: %v", err) + } + + m.onChanged() + return &operatorpb.RemoveNeighboursResponse{}, nil +} diff --git a/agents/yanet-route-operator/internal/operator/service_operator.go b/agents/yanet-route-operator/internal/operator/service_operator.go new file mode 100644 index 000000000..3f55613f5 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/service_operator.go @@ -0,0 +1,40 @@ +package operator + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" +) + +// RouteOperatorService is the intent surface for the route operator. +// Phase 1: Switch / Status return codes.Unimplemented. +type RouteOperatorService struct { + operatorpb.UnimplementedRouteOperatorServiceServer +} + +// NewRouteOperatorService constructs a RouteOperatorService. +func NewRouteOperatorService(options ...OperatorServiceOption) *RouteOperatorService { + opts := newOperatorServiceOptions() + for _, o := range options { + o(opts) + } + + return &RouteOperatorService{} +} + +func (m *RouteOperatorService) Switch( + ctx context.Context, + req *operatorpb.SwitchRequest, +) (*operatorpb.SwitchResponse, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *RouteOperatorService) Status( + ctx context.Context, + req *operatorpb.StatusRequest, +) (*operatorpb.StatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} diff --git a/agents/yanet-route-operator/internal/operator/service_route.go b/agents/yanet-route-operator/internal/operator/service_route.go new file mode 100644 index 000000000..938f1a6d1 --- /dev/null +++ b/agents/yanet-route-operator/internal/operator/service_route.go @@ -0,0 +1,367 @@ +package operator + +import ( + "context" + "io" + "maps" + "net/netip" + "sync" + "sync/atomic" + "time" + + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/discovery/neigh" + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/rib" + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" +) + +// RouteService implements the operator-owned RouteService surface. +// +// Mutation RPCs update the RIB held in this process and wake the +// reconcile loop via onChanged. The reconcile loop snapshots state and +// pushes the rebuilt FIB to the gateways through the actuator. +type RouteService struct { + operatorpb.UnimplementedRouteServiceServer + + ribsLock sync.RWMutex + ribs map[string]*rib.RIB + neighTable *neigh.NeighTable + + ribTTL time.Duration + quitCh chan bool + onChanged func() + + log *zap.Logger +} + +// NewRouteService constructs a RouteService bound to the supplied +// neighbour table. +func NewRouteService( + neighTable *neigh.NeighTable, + options ...RouteServiceOption, +) *RouteService { + opts := newRouteServiceOptions() + for _, o := range options { + o(opts) + } + + return &RouteService{ + ribs: map[string]*rib.RIB{}, + neighTable: neighTable, + ribTTL: opts.RIBTTL, + quitCh: make(chan bool), + onChanged: opts.OnChanged, + log: opts.Log, + } +} + +// Close releases resources owned by the service. It is safe to call +// concurrently with in-flight RPCs. +func (m *RouteService) Close() error { + close(m.quitCh) + return nil +} + +// Configs returns a snapshot of all known RIB config names. +func (m *RouteService) Configs() []string { + m.ribsLock.RLock() + defer m.ribsLock.RUnlock() + + out := make([]string, 0, len(m.ribs)) + for name := range m.ribs { + out = append(out, name) + } + return out +} + +// ListConfigs returns the names of all RIB configs known to the +// operator. +func (m *RouteService) ListConfigs( + ctx context.Context, + req *operatorpb.ListConfigsRequest, +) (*operatorpb.ListConfigsResponse, error) { + return &operatorpb.ListConfigsResponse{ + Configs: m.Configs(), + }, nil +} + +// Snapshot returns a snapshot of all RIBs keyed by config name. +func (m *RouteService) Snapshot() map[string]*rib.RIB { + m.ribsLock.RLock() + defer m.ribsLock.RUnlock() + + out := make(map[string]*rib.RIB, len(m.ribs)) + maps.Copy(out, m.ribs) + return out +} + +func (m *RouteService) ShowRoutes( + ctx context.Context, + req *operatorpb.ShowRoutesRequest, +) (*operatorpb.ShowRoutesResponse, error) { + name := req.GetName() + if name == "" { + return nil, status.Error(codes.InvalidArgument, "module config name is required") + } + + holder, ok := m.getRib(name) + if !ok { + return &operatorpb.ShowRoutesResponse{}, nil + } + ribDump := holder.DumpRoutes() + + response := &operatorpb.ShowRoutesResponse{} + + for prefixLen := range ribDump { + for prefix, routesList := range ribDump[prefixLen] { + if len(routesList.Routes) == 0 { + continue + } + + if req.GetIpv4Only() && !prefix.Addr().Is4() { + continue + } + if req.GetIpv6Only() && !prefix.Addr().Is6() { + continue + } + + for idx, r := range routesList.Routes { + isBest := idx == 0 + response.Routes = append(response.Routes, operatorpb.FromRIBRoute(&r, isBest)) + } + } + } + + return response, nil +} + +func (m *RouteService) LookupRoute( + ctx context.Context, + req *operatorpb.LookupRouteRequest, +) (*operatorpb.LookupRouteResponse, error) { + name := req.GetName() + if name == "" { + return nil, status.Error(codes.InvalidArgument, "module config name is required") + } + + addr, err := netip.ParseAddr(req.GetIpAddr()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse IP address: %v", err) + } + + holder, ok := m.getRib(name) + if !ok { + return &operatorpb.LookupRouteResponse{}, nil + } + + prefix, routes, ok := holder.LongestMatch(addr) + if !ok { + return &operatorpb.LookupRouteResponse{}, nil + } + + response := &operatorpb.LookupRouteResponse{ + Prefix: prefix.String(), + Routes: make([]*operatorpb.Route, 0, len(routes.Routes)), + } + + for idx, r := range routes.Routes { + isBest := idx == 0 + response.Routes = append(response.Routes, operatorpb.FromRIBRoute(&r, isBest)) + } + + return response, nil +} + +func (m *RouteService) InsertRoute( + ctx context.Context, + req *operatorpb.InsertRouteRequest, +) (*operatorpb.InsertRouteResponse, error) { + name := req.GetName() + if name == "" { + return nil, status.Error(codes.InvalidArgument, "module config name is required") + } + + prefix, err := netip.ParsePrefix(req.GetPrefix()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse prefix %q: %v", req.GetPrefix(), err) + } + + nexthopAddr, err := netip.ParseAddr(req.GetNexthopAddr()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse nexthop address %q: %v", req.GetNexthopAddr(), err) + } + + sourceID := req.RouteSourceID() + holder := m.getOrCreateRib(name) + + if err := holder.AddUnicastRoute(prefix, nexthopAddr, sourceID); err != nil { + return nil, status.Errorf(codes.Internal, "failed to add unicast route: %v", err) + } + + // Wake the reconcile loop only when the caller explicitly asks for a + // flush; otherwise the RIB mutation is buffered until a later flush. + if req.GetDoFlush() { + m.onChanged() + } + + return &operatorpb.InsertRouteResponse{}, nil +} + +func (m *RouteService) DeleteRoute( + ctx context.Context, + req *operatorpb.DeleteRouteRequest, +) (*operatorpb.DeleteRouteResponse, error) { + name := req.GetName() + if name == "" { + return nil, status.Error(codes.InvalidArgument, "module config name is required") + } + + prefix, err := netip.ParsePrefix(req.GetPrefix()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse prefix: %v", err) + } + + nexthopAddr, err := netip.ParseAddr(req.GetNexthopAddr()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse nexthop address: %v", err) + } + + sourceID := req.RouteSourceID() + holder, ok := m.getRib(name) + if !ok { + return &operatorpb.DeleteRouteResponse{}, nil + } + + if err := holder.RemoveUnicastRoute(prefix, nexthopAddr, sourceID); err != nil { + return nil, status.Errorf(codes.Internal, "failed to remove unicast route: %v", err) + } + + // Wake the reconcile loop only when the caller explicitly asks for a + // flush; otherwise the RIB mutation is buffered until a later flush. + if req.GetDoFlush() { + m.onChanged() + } + + return &operatorpb.DeleteRouteResponse{}, nil +} + +func (m *RouteService) FlushRoutes( + ctx context.Context, + req *operatorpb.FlushRoutesRequest, +) (*operatorpb.FlushRoutesResponse, error) { + name := req.GetName() + if name == "" { + return nil, status.Error(codes.InvalidArgument, "module config name is required") + } + if _, ok := m.getRib(name); !ok { + return &operatorpb.FlushRoutesResponse{}, nil + } + + m.onChanged() + + return &operatorpb.FlushRoutesResponse{}, nil +} + +// FeedRIB receives a stream of route updates and applies them to the +// matching RIB. Session semantics mirror the legacy route-module +// implementation: a new stream supersedes any prior session for the +// same RIB and stale routes are cleaned up after RIBTTL. +func (m *RouteService) FeedRIB(stream operatorpb.RouteService_FeedRIBServer) error { + var ( + update *operatorpb.Update + name string + err error + ribRef *rib.RIB + sessionID uint64 + terminated *atomic.Bool + ) + for { + update, err = stream.Recv() + if err == io.EOF { + err = stream.SendAndClose(&operatorpb.UpdateSummary{}) + break + } + if err != nil { + break + } + + if ribRef == nil { + name = update.GetName() + if name == "" { + err = status.Error(codes.InvalidArgument, "module config name is required") + break + } + ribRef = m.getOrCreateRib(name) + sessionID, terminated = ribRef.NewSession() + m.log.Info("started FeedRIB session", + zap.Uint64("session_id", sessionID), + zap.String("name", name), + ) + } + + if terminated.Load() { + m.log.Warn("FeedRIB session terminated by a newer session", + zap.Uint64("session_id", sessionID), + zap.String("name", name), + ) + err = stream.SendAndClose(&operatorpb.UpdateSummary{}) + break + } + if update.GetRoute() == nil { + m.log.Info("flushed routes due to FeedRIB flush event", + zap.Uint64("session_id", sessionID), + zap.String("name", name), + ) + m.onChanged() + continue + } + + route, convertErr := operatorpb.ToRIBRoute(update.GetRoute(), update.GetIsDelete()) + if convertErr != nil { + m.log.Error("failed to convert proto route to RIB route", + zap.Uint64("session_id", sessionID), + zap.Error(convertErr), + ) + continue + } + route.SessionID = sessionID + ribRef.Update(*route) + } + + if ribRef != nil { + m.log.Info("FeedRIB session ended; scheduling cleanup", + zap.Uint64("session_id", sessionID), + zap.String("name", name), + zap.Duration("ttl", m.ribTTL), + ) + go ribRef.CleanupTask(sessionID, m.quitCh, m.ribTTL) + m.onChanged() + } + + return err +} + +func (m *RouteService) getRib(name string) (*rib.RIB, bool) { + m.ribsLock.RLock() + defer m.ribsLock.RUnlock() + ribRef, ok := m.ribs[name] + return ribRef, ok +} + +func (m *RouteService) getOrCreateRib(name string) *rib.RIB { + m.ribsLock.Lock() + defer m.ribsLock.Unlock() + + ribRef, ok := m.ribs[name] + if !ok { + m.log.Info("created new RIB", + zap.String("name", name), + ) + ribRef = rib.NewRIB(m.log) + m.ribs[name] = ribRef + } + return ribRef +} diff --git a/modules/route/internal/rib/rib.go b/agents/yanet-route-operator/internal/rib/rib.go similarity index 100% rename from modules/route/internal/rib/rib.go rename to agents/yanet-route-operator/internal/rib/rib.go diff --git a/modules/route/internal/rib/rib_stats.go b/agents/yanet-route-operator/internal/rib/rib_stats.go similarity index 100% rename from modules/route/internal/rib/rib_stats.go rename to agents/yanet-route-operator/internal/rib/rib_stats.go diff --git a/modules/route/internal/rib/routes.go b/agents/yanet-route-operator/internal/rib/routes.go similarity index 100% rename from modules/route/internal/rib/routes.go rename to agents/yanet-route-operator/internal/rib/routes.go diff --git a/modules/route/internal/rib/routes_test.go b/agents/yanet-route-operator/internal/rib/routes_test.go similarity index 100% rename from modules/route/internal/rib/routes_test.go rename to agents/yanet-route-operator/internal/rib/routes_test.go diff --git a/agents/yanet-route-operator/meson.build b/agents/yanet-route-operator/meson.build new file mode 100644 index 000000000..caff7bdee --- /dev/null +++ b/agents/yanet-route-operator/meson.build @@ -0,0 +1,29 @@ +subdir('operatorpb') + +# Skip route-operator when fuzzing — no CGO but still gets sanitizer flags +# that interfere with the fuzzing build. +if not get_option('fuzzing').enabled() + custom_target( + 'yanet-route-operator', + output: 'yanet-route-operator', + command: [ + go, + 'build', + '-ldflags=' + ld_flags, + '-buildvcs=false', + '-o', '@OUTPUT@', + join_paths(meson.current_source_dir(), 'cmd', 'yanet-route-operator'), + ], + env: yanet_go_env, + build_by_default: true, + build_always_stale: true, + depends: [ + route_operator_protoc_gen, + ynpb_gen, + common_protoc_gen, + route_protoc_gen, + ], + install: true, + install_dir: get_option('bindir'), + ) +endif diff --git a/modules/route/controlplane/routepb/convert.go b/agents/yanet-route-operator/operatorpb/convert.go similarity index 83% rename from modules/route/controlplane/routepb/convert.go rename to agents/yanet-route-operator/operatorpb/convert.go index 2392c60bf..4af862c65 100644 --- a/modules/route/controlplane/routepb/convert.go +++ b/agents/yanet-route-operator/operatorpb/convert.go @@ -1,15 +1,16 @@ -package routepb +package operatorpb import ( "fmt" "net/netip" "time" - "github.com/yanet-platform/yanet2/modules/route/internal/rib" + "github.com/yanet-platform/yanet2/agents/yanet-route-operator/internal/rib" ) +// FromRIBRoute converts an internal rib.Route to the wire Route message. func FromRIBRoute(route *rib.Route, isBest bool) *Route { - communities := make([]*LargeCommunity, len(route.LargeCommunities)) + communities := make([]*LargeCommunity, 0, len(route.LargeCommunities)) for _, c := range route.LargeCommunities { communities = append(communities, convertLargeCommunity(c)) } @@ -41,6 +42,8 @@ func convertLargeCommunity(community rib.LargeCommunity) *LargeCommunity { } } +// ToRIBRoute converts a wire Route message to the internal rib.Route +// representation. func ToRIBRoute(route *Route, toRemove bool) (*rib.Route, error) { if route == nil { return nil, fmt.Errorf("update.Route cannot be nil") @@ -59,7 +62,6 @@ func ToRIBRoute(route *Route, toRemove bool) (*rib.Route, error) { return nil, err } largeCommunities := make([]rib.LargeCommunity, 0, len(route.LargeCommunities)) - for _, community := range route.LargeCommunities { largeCommunities = append(largeCommunities, rib.LargeCommunity{ GlobalAdministrator: community.GetGlobalAdministrator(), @@ -91,11 +93,10 @@ func ToRIBRoute(route *Route, toRemove bool) (*rib.Route, error) { SourceID: sourceID, ToRemove: toRemove, }, nil - } -// RouteSourceID returns the internal rib.RouteSourceID from InsertRouteRequest -// Defaults to RouteSourceStatic if unknown or unspecified +// RouteSourceID returns the internal rib.RouteSourceID for an +// InsertRouteRequest. Defaults to RouteSourceStatic. func (m *InsertRouteRequest) RouteSourceID() rib.RouteSourceID { switch m.GetSourceId() { case RouteSourceID_ROUTE_SOURCE_ID_BIRD: @@ -105,8 +106,8 @@ func (m *InsertRouteRequest) RouteSourceID() rib.RouteSourceID { } } -// RouteSourceID returns the internal rib.RouteSourceID from DeleteRouteRequest -// Defaults to RouteSourceStatic if unknown or unspecified +// RouteSourceID returns the internal rib.RouteSourceID for a +// DeleteRouteRequest. Defaults to RouteSourceStatic. func (m *DeleteRouteRequest) RouteSourceID() rib.RouteSourceID { switch m.GetSourceId() { case RouteSourceID_ROUTE_SOURCE_ID_BIRD: diff --git a/agents/yanet-route-operator/operatorpb/meson.build b/agents/yanet-route-operator/operatorpb/meson.build new file mode 100644 index 000000000..a3d5c0979 --- /dev/null +++ b/agents/yanet-route-operator/operatorpb/meson.build @@ -0,0 +1,29 @@ +proto_dir = meson.current_source_dir() +root_dir = meson.project_source_root() +proto_files = [ + join_paths(proto_dir, 'operator.proto'), + join_paths(proto_dir, 'route.proto'), + join_paths(proto_dir, 'neighbour.proto'), +] + +protoc_gen = custom_target( + 'route-operator-protoc', + output: [ + 'operator.pb.go', + 'operator_grpc.pb.go', + 'route.pb.go', + 'route_grpc.pb.go', + 'neighbour.pb.go', + 'neighbour_grpc.pb.go', + ], + input: proto_files, + command: [ + protoc, + '-I', root_dir, + '--go_out=paths=source_relative:' + root_dir, + '--go-grpc_out=paths=source_relative:' + root_dir, + '@INPUT@', + ], + build_by_default: true, +) +route_operator_protoc_gen = protoc_gen diff --git a/agents/yanet-route-operator/operatorpb/neighbour.proto b/agents/yanet-route-operator/operatorpb/neighbour.proto new file mode 100644 index 000000000..0c6a16de3 --- /dev/null +++ b/agents/yanet-route-operator/operatorpb/neighbour.proto @@ -0,0 +1,110 @@ +syntax = "proto3"; + +package operatorpb; + +option go_package = "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb;operatorpb"; + +import "common/commonpb/macaddr.proto"; + +// NeighbourService is the operator-owned neighbour table surface. +service NeighbourService { + // List returns neighbors either merged view or from a specific table. + rpc List(ListNeighboursRequest) returns (ListNeighboursResponse); + + // CreateTable creates a new user-defined neighbour table. + rpc CreateTable(CreateNeighbourTableRequest) returns (CreateNeighbourTableResponse); + // UpdateTable changes the default priority of an existing neighbour + // table. + rpc UpdateTable(UpdateNeighbourTableRequest) returns (UpdateNeighbourTableResponse); + // RemoveTable removes a user-defined neighbour table. + rpc RemoveTable(RemoveNeighbourTableRequest) returns (RemoveNeighbourTableResponse); + // ListTables returns metadata about all registered neighbour tables. + rpc ListTables(ListNeighbourTablesRequest) returns (ListNeighbourTablesResponse); + + // UpdateNeighbours inserts or updates one or more neighbour entries. + rpc UpdateNeighbours(UpdateNeighboursRequest) returns (UpdateNeighboursResponse); + // RemoveNeighbours deletes one or more neighbour entries. + rpc RemoveNeighbours(RemoveNeighboursRequest) returns (RemoveNeighboursResponse); +} + +// ListNeighboursRequest is the request to list neighbors. +message ListNeighboursRequest { + // Table is the name of the table to list entries from. + // + // If empty, returns the merged view. + string table = 1; +} + +// ListNeighboursResponse contains the list of neighbors. +message ListNeighboursResponse { repeated NeighbourEntry neighbours = 1; } + +// NeighbourState represents the state of a neighbor entry. +enum NeighbourState { + NUD_NONE = 0x00; + NUD_INCOMPLETE = 0x01; + NUD_REACHABLE = 0x02; + NUD_STALE = 0x04; + NUD_DELAY = 0x08; + NUD_PROBE = 0x10; + NUD_FAILED = 0x20; + NUD_NOARP = 0x40; + NUD_PERMANENT = 0x80; + NUD_UNKNOWN = 0xff; +} + +// NeighbourEntry represents a single neighbor entry. +message NeighbourEntry { + string next_hop = 1; + commonpb.MACAddress link_addr = 2; + commonpb.MACAddress hardware_addr = 3; + NeighbourState state = 4; + int64 updated_at = 5; + string source = 6; + uint32 priority = 7; + string device = 8; +} + +// CreateNeighbourTableRequest is the request to create a new neighbour +// table. +message CreateNeighbourTableRequest { + string name = 1; + uint32 default_priority = 2; +} + +message CreateNeighbourTableResponse {} + +message UpdateNeighbourTableRequest { + string name = 1; + uint32 default_priority = 2; +} + +message UpdateNeighbourTableResponse {} + +message RemoveNeighbourTableRequest { string name = 1; } + +message RemoveNeighbourTableResponse {} + +message ListNeighbourTablesRequest {} + +message ListNeighbourTablesResponse { repeated NeighbourTableInfo tables = 1; } + +message NeighbourTableInfo { + string name = 1; + uint32 default_priority = 2; + int64 entry_count = 3; + bool built_in = 4; +} + +message UpdateNeighboursRequest { + string table = 1; + repeated NeighbourEntry entries = 2; +} + +message UpdateNeighboursResponse {} + +message RemoveNeighboursRequest { + string table = 1; + repeated string next_hops = 2; +} + +message RemoveNeighboursResponse {} diff --git a/agents/yanet-route-operator/operatorpb/operator.proto b/agents/yanet-route-operator/operatorpb/operator.proto new file mode 100644 index 000000000..494508aee --- /dev/null +++ b/agents/yanet-route-operator/operatorpb/operator.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +// TODO: package yanet.operator.route.v1; +package operatorpb; + +option go_package = "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb;operatorpb"; + +import "common/commonpb/metric.proto"; + +// RouteOperatorService is the intent surface for RIB / FIB-build lifecycle +// management owned by the route operator. +// +// The Switch / Status RPCs are placeholders for the upcoming +// declarative-target workflow; their request and response messages are +// intentionally empty in this scaffold and will be fleshed out in a +// follow-up phase. +service RouteOperatorService { + // Switch atomically replaces the desired target the operator drives + // toward. + rpc Switch(SwitchRequest) returns (SwitchResponse); + + // Status returns a snapshot of the operator's current target and + // reconciliation state. + rpc Status(StatusRequest) returns (StatusResponse); +} + +// MetricsService exposes operator runtime metrics. +service MetricsService { + // GetMetrics returns a snapshot of operator runtime metrics. + rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse); +} + +message SwitchRequest {} + +message SwitchResponse {} + +message StatusRequest {} + +message StatusResponse {} + +message GetMetricsRequest {} + +message GetMetricsResponse { repeated commonpb.Metric metrics = 1; } diff --git a/agents/yanet-route-operator/operatorpb/route.proto b/agents/yanet-route-operator/operatorpb/route.proto new file mode 100644 index 000000000..2b0926151 --- /dev/null +++ b/agents/yanet-route-operator/operatorpb/route.proto @@ -0,0 +1,149 @@ +syntax = "proto3"; + +package operatorpb; + +option go_package = "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb;operatorpb"; + +// RouteService is the operator-owned routing surface. +service RouteService { + // ShowRoutes returns all routes in the routing table. + rpc ShowRoutes(ShowRoutesRequest) returns (ShowRoutesResponse); + + // LookupRoute looks up possible routes for the given IP address. + rpc LookupRoute(LookupRouteRequest) returns (LookupRouteResponse); + + // InsertRoute inserts a route into the routing table. + rpc InsertRoute(InsertRouteRequest) returns (InsertRouteResponse); + + // DeleteRoute deletes a route from the routing table. + rpc DeleteRoute(DeleteRouteRequest) returns (DeleteRouteResponse); + + // FlushRoutes triggers a reconcile pass that rebuilds the FIB from + // the current RIB and pushes it to the dataplane via the route module. + rpc FlushRoutes(FlushRoutesRequest) returns (FlushRoutesResponse); + + // FeedRIB receives a stream of route updates (typically from BIRD) and + // applies them to the operator's RIB. Session semantics match the + // legacy route-module FeedRIB. + rpc FeedRIB(stream Update) returns (UpdateSummary); + + // ListConfigs returns the names of all RIB configs known to the + // operator. + rpc ListConfigs(ListConfigsRequest) returns (ListConfigsResponse); +} + +// ShowRoutesRequest contains filters for route listing. +message ShowRoutesRequest { + string name = 1; + // Filter to show only IPv4 routes. + bool ipv4_only = 2; + // Filter to show only IPv6 routes. + bool ipv6_only = 3; +} + +// ShowRoutesResponse contains the list of routes. +message ShowRoutesResponse { + // List of routes in the routing table. + repeated Route routes = 1; +} + +// LookupRouteRequest contains the IP address to lookup. +message LookupRouteRequest { + string name = 1; + // IP address to lookup. + string ip_addr = 2; +} + +// LookupRouteResponse contains the matching routes for the IP address. +message LookupRouteResponse { + // Prefix is the prefix that matched with the IP address. + string prefix = 1; + // Matching routes for the IP address, sorted with best path first. + repeated Route routes = 2; +} + +// InsertRouteRequest is the request to insert a route. +message InsertRouteRequest { + string name = 1; + // The destination prefix of the route. + string prefix = 2; + // The IP address of the nexthop router. + string nexthop_addr = 3; + + // Indicates whether the RIB should be flushed to the FIB after this + // request. + bool do_flush = 4; + + // Route source identifier (e.g., Static, BIRD). + RouteSourceID source_id = 5; +} + +// InsertRouteResponse is the response of "InsertRoute" request. +message InsertRouteResponse {} + +// DeleteRouteRequest is the request to delete a route. +message DeleteRouteRequest { + string name = 1; + string prefix = 2; + string nexthop_addr = 3; + + bool do_flush = 4; + RouteSourceID source_id = 5; +} + +// DeleteRouteResponse is the response of "DeleteRoute" request. +message DeleteRouteResponse {} + +// FlushRoutesRequest specifies which module config should be reconciled. +message FlushRoutesRequest { string name = 1; } + +message FlushRoutesResponse {} + +// Update represents a message in the stream for inserting one route +// into the operator's RIB. +message Update { + // The module config name where the RIB should be updated. + string name = 1; + // Indicates whether this is a route deletion event. + bool is_delete = 2; + // The route to add to the RIB. + Route route = 3; +} + +message UpdateSummary {} + +// ListConfigsRequest is the request for ListConfigs. +message ListConfigsRequest {} + +// ListConfigsResponse contains the list of known RIB config names. +message ListConfigsResponse { repeated string configs = 1; } + +// Route represents a routing table entry. +message Route { + string prefix = 1; + string next_hop = 2; + string peer = 3; + uint64 route_distinguisher = 4; + uint32 peer_as = 5; + uint32 origin_as = 6; + uint32 med = 7; + uint32 pref = 8; + uint32 as_path_len = 9; + RouteSourceID source = 10; + repeated LargeCommunity large_communities = 11; + bool is_best = 12; +} + +// LargeCommunity represents a BGP Large Community value. +message LargeCommunity { + uint32 global_administrator = 1; + uint32 local_data_part1 = 2; + uint32 local_data_part2 = 3; +} + +// RouteSourceID identifies the origin protocol of a route. +enum RouteSourceID { + ROUTE_SOURCE_ID_UNKNOWN = 0; + ROUTE_SOURCE_ID_STATIC = 1; + ROUTE_SOURCE_ID_BIRD = 2; +} diff --git a/modules/route/controlplane/routepb/macaddr.go b/common/commonpb/macaddr.go similarity index 97% rename from modules/route/controlplane/routepb/macaddr.go rename to common/commonpb/macaddr.go index 18f9f26f6..f81a24201 100644 --- a/modules/route/controlplane/routepb/macaddr.go +++ b/common/commonpb/macaddr.go @@ -1,4 +1,4 @@ -package routepb +package commonpb import ( "encoding/binary" @@ -32,7 +32,8 @@ func (m *MACAddress) MarshalJSON() ([]byte, error) { return fmt.Appendf(nil, `{"addr":"%s"}`, net.HardwareAddr(eui48[:])), nil } -// UnmarshalJSON accepts addr as a MAC address string in various EUI-48 formats. +// UnmarshalJSON accepts addr as a MAC address string in various EUI-48 +// formats. func (m *MACAddress) UnmarshalJSON(data []byte) error { var raw struct { Addr string `json:"addr"` diff --git a/modules/route/controlplane/routepb/macaddr.proto b/common/commonpb/macaddr.proto similarity index 78% rename from modules/route/controlplane/routepb/macaddr.proto rename to common/commonpb/macaddr.proto index 499102d44..0c738b013 100644 --- a/modules/route/controlplane/routepb/macaddr.proto +++ b/common/commonpb/macaddr.proto @@ -1,8 +1,8 @@ syntax = "proto3"; -package routepb; +package commonpb; -option go_package = "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb;routepb"; +option go_package = "github.com/yanet-platform/yanet2/common/commonpb;commonpb"; // MACAddress represents a hardware address in EUI-48 format. message MACAddress { @@ -10,7 +10,8 @@ message MACAddress { // Addr keeps MAC address bytes as a 64-bit unsigned integer in network // byte order. // - // Stored as the lower 48 bits of a uint64. The upper 16 bits MUST be zero. + // Stored as the lower 48 bits of a uint64. The upper 16 bits MUST be + // zero. // // For example, for the given MAC address "3a:ac:26:9b:5b:f9": // - As array: [0x00, 0x00, 0x3a, 0xac, 0x26, 0x9b, 0x5b, 0xf9]. diff --git a/modules/route/controlplane/routepb/macaddr_test.go b/common/commonpb/macaddr_test.go similarity index 99% rename from modules/route/controlplane/routepb/macaddr_test.go rename to common/commonpb/macaddr_test.go index 22644d895..f3a35bb1e 100644 --- a/modules/route/controlplane/routepb/macaddr_test.go +++ b/common/commonpb/macaddr_test.go @@ -1,4 +1,4 @@ -package routepb +package commonpb import ( "encoding/json" diff --git a/common/commonpb/meson.build b/common/commonpb/meson.build index b81fe98a5..0f6e8f344 100644 --- a/common/commonpb/meson.build +++ b/common/commonpb/meson.build @@ -3,6 +3,7 @@ common_proto_dir = meson.current_source_dir() common_proto_files = [ join_paths(common_proto_dir, 'target.proto'), join_paths(common_proto_dir, 'metric.proto'), + join_paths(common_proto_dir, 'macaddr.proto'), ] # Generate protobuf files @@ -11,6 +12,7 @@ common_protoc_gen = custom_target( output: [ 'target.pb.go', 'metric.pb.go', + 'macaddr.pb.go', ], input: common_proto_files, command: [ diff --git a/common/go/operator/actuator.go b/common/go/operator/actuator.go new file mode 100644 index 000000000..e2a8872bf --- /dev/null +++ b/common/go/operator/actuator.go @@ -0,0 +1,96 @@ +package operator + +import ( + "context" + "errors" + + "go.uber.org/zap" + "golang.org/x/sync/errgroup" +) + +type fanOutOptions struct { + Log *zap.Logger +} + +func newFanOutOptions() *fanOutOptions { + return &fanOutOptions{ + Log: zap.NewNop(), + } +} + +// FanOutOption configures NewFanOutActuator. +type FanOutOption func(*fanOutOptions) + +// WithFanOutLog sets the logger for the fan-out actuator. +func WithFanOutLog(log *zap.Logger) FanOutOption { + return func(o *fanOutOptions) { + o.Log = log + } +} + +// Actuator applies a desired state of type T to a single downstream +// target, typically a single gateway. +type Actuator[T any] interface { + Apply(ctx context.Context, state T) error + Close() error +} + +// FanOutActuator applies state to several Actuators concurrently. +type FanOutActuator[T any] struct { + actuators []Actuator[T] + log *zap.Logger +} + +// NewFanOutActuator constructs a fan-out actuator from a slice of +// underlying actuators. +func NewFanOutActuator[T any]( + actuators []Actuator[T], + options ...FanOutOption, +) *FanOutActuator[T] { + opts := newFanOutOptions() + for _, o := range options { + o(opts) + } + + return &FanOutActuator[T]{ + actuators: actuators, + log: opts.Log, + } +} + +// Apply runs Apply on every underlying Actuator concurrently and joins +// all observed errors. +func (m *FanOutActuator[T]) Apply(ctx context.Context, state T) error { + errs := make([]error, len(m.actuators)) + + var wg errgroup.Group + for idx, a := range m.actuators { + wg.Go(func() error { + errs[idx] = a.Apply(ctx, state) + return nil + }) + } + _ = wg.Wait() + + if err := errors.Join(errs...); err != nil { + return err + } + + m.log.Debug("fan-out actuator apply complete", + zap.Int("actuator_count", len(m.actuators)), + ) + return nil +} + +// Close closes every underlying Actuator serially and joins their +// errors. +func (m *FanOutActuator[T]) Close() error { + var out error + for _, a := range m.actuators { + if err := a.Close(); err != nil { + out = errors.Join(out, err) + } + } + + return out +} diff --git a/common/rust/commonpb/build.rs b/common/rust/commonpb/build.rs index d05d7c9e4..e289c9100 100644 --- a/common/rust/commonpb/build.rs +++ b/common/rust/commonpb/build.rs @@ -3,13 +3,18 @@ use core::error::Error; pub fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=common/commonpb/target.proto"); println!("cargo:rerun-if-changed=common/commonpb/metric.proto"); + println!("cargo:rerun-if-changed=common/commonpb/macaddr.proto"); tonic_build::configure() .build_server(false) .message_attribute(".", "#[derive(serde::Serialize)]") .enum_attribute(".", "#[derive(serde::Serialize)]") .compile_protos( - &["common/commonpb/target.proto", "common/commonpb/metric.proto"], + &[ + "common/commonpb/target.proto", + "common/commonpb/metric.proto", + "common/commonpb/macaddr.proto", + ], &["../../.."], )?; Ok(()) diff --git a/controlplane/etc/yanet/controlplane-director.yaml b/controlplane/etc/yanet/controlplane-director.yaml index d89e2b1fa..5f5f0c7ef 100644 --- a/controlplane/etc/yanet/controlplane-director.yaml +++ b/controlplane/etc/yanet/controlplane-director.yaml @@ -27,10 +27,10 @@ gateway: permissions_path: /etc/yanet/auth/permissions.yaml modules: route: - # MemoryPathPrefix is the path to the shared-memory file that is used to - # communicate with dataplane. + # MemoryPath is the path to the shared-memory file that is used + # to communicate with the dataplane. memory_path: *memory_path - # Memory requirements for a single transaction. + # Memory requirements for a single FIB-push transaction. memory_requirements: 16MB endpoint: "[::1]:0" gateway_endpoint: *gateway_endpoint diff --git a/controlplane/internal/gateway/gateway.go b/controlplane/internal/gateway/gateway.go index d1ee2529c..02d6194a8 100644 --- a/controlplane/internal/gateway/gateway.go +++ b/controlplane/internal/gateway/gateway.go @@ -239,6 +239,28 @@ func (m *Gateway) Run(ctx context.Context) error { }) } + // Emit a single deterministic readiness marker once every built-in + // module has finished its initial service registration. Functional + // tests grep for this exact line to know the gateway is ready to + // accept module RPCs. + if len(m.builtInModules) > 0 { + wg.Go(func() error { + for _, builtInModule := range m.builtInModules { + select { + case <-ctx.Done(): + return nil + case <-builtInModule.Ready(): + } + } + m.log.Infow("all built-in modules ready", + zap.Int("count", len(m.builtInModules)), + ) + return nil + }) + } else { + m.log.Infow("all built-in modules ready", zap.Int("count", 0)) + } + <-ctx.Done() m.log.Infow("stopping gRPC gateway", zap.Stringer("addr", listener.Addr())) diff --git a/controlplane/internal/gateway/runner.go b/controlplane/internal/gateway/runner.go index 71c33a82b..defb3d2bc 100644 --- a/controlplane/internal/gateway/runner.go +++ b/controlplane/internal/gateway/runner.go @@ -33,6 +33,7 @@ type BuiltInModuleRunner struct { gatewayEndpoint string gatewayTLS *gateway.TLSConfig server *grpc.Server + ready chan struct{} log *zap.SugaredLogger } @@ -52,10 +53,19 @@ func NewBuiltInModuleRunner( grpc.ChainUnaryInterceptor(xgrpc.AccessLogInterceptor(log.Desugar())), grpc.MaxRecvMsgSize(1024*1024*256), grpc.MaxSendMsgSize(1024*1024*256), ), - log: log, + ready: make(chan struct{}), + log: log, } } +// Ready returns a channel that is closed when the runner has finished +// the initial service registration phase against the gateway. The +// channel is closed exactly once; consumers can use it to detect that +// the module is reachable through the gateway. +func (m *BuiltInModuleRunner) Ready() <-chan struct{} { + return m.ready +} + func (m *BuiltInModuleRunner) Close() error { return m.module.Close() } @@ -84,6 +94,7 @@ func (m *BuiltInModuleRunner) Run(ctx context.Context) error { if err = m.register(ctx, listener.Addr()); err != nil { return fmt.Errorf("failed to register services: %w", err) } + close(m.ready) <-ctx.Done() diff --git a/controlplane/meson.build b/controlplane/meson.build index 3ae539d52..46b85b792 100644 --- a/controlplane/meson.build +++ b/controlplane/meson.build @@ -18,6 +18,7 @@ custom_target( depends: [ # go proto gen deps ynpb_gen, + common_protoc_gen, decap_protoc_gen, dscp_protoc_gen, forward_protoc_gen, diff --git a/controlplane/yncp/director.go b/controlplane/yncp/director.go index d0f4cbb4b..47a10197f 100644 --- a/controlplane/yncp/director.go +++ b/controlplane/yncp/director.go @@ -6,6 +6,10 @@ import ( "go.uber.org/zap" + // Blank import registers operator proto descriptors in the global + // protobuf registry so the gateway HTTP/gRPC proxy can resolve + // operatorpb services. + _ "github.com/yanet-platform/yanet2/agents/yanet-route-operator/operatorpb" "github.com/yanet-platform/yanet2/controlplane/ffi" "github.com/yanet-platform/yanet2/controlplane/internal/gateway" acl "github.com/yanet-platform/yanet2/modules/acl/controlplane" @@ -84,7 +88,7 @@ func NewDirector(cfg *Config, options ...DirectorOption) (*Director, error) { } log.Debugw("attached to shared memory", zap.String("path", cfg.MemoryPath)) - routeModule, err := route.NewRouteModule(cfg.Modules.Route, log.Desugar()) + routeModule, err := route.NewRouteModule(cfg.Modules.Route, route.WithLog(log.Desugar())) if err != nil { return nil, fmt.Errorf("failed to initialize route built-in module: %w", err) } diff --git a/debian/control b/debian/control index 99eda3af8..50a55ce04 100644 --- a/debian/control +++ b/debian/control @@ -88,6 +88,16 @@ Description: YANET2 pipeline operator This package contains the pipeline operator that owns pipelines and device-to-pipeline bindings. +Package: yanet2-route-operator +Architecture: any +Depends: ${shlibs:Depends} + , ${misc:Depends} +Description: YANET2 route operator + Autonomous YANET2 route lifecycle management operator. + . + This package contains the route operator that owns FIB programming + and neighbour discovery. + Package: yanet2-forward-operator Architecture: all Depends: ${misc:Depends} diff --git a/debian/yanet2-cli.install b/debian/yanet2-cli.install index d77407683..adc08a6f9 100644 --- a/debian/yanet2-cli.install +++ b/debian/yanet2-cli.install @@ -11,10 +11,11 @@ usr/bin/yanet-cli-forward usr/bin/yanet-cli-nat64 usr/bin/yanet-cli-route usr/bin/yanet-cli-route-mpls -usr/bin/yanet-cli-neighbour usr/bin/yanet-cli-pdump usr/bin/yanet-cli-counters usr/bin/yanet-cli-device-plain usr/bin/yanet-cli-device-vlan usr/bin/yanet-cli-balancer usr/bin/yanet-cli-operator-pipeline +usr/bin/yanet-cli-operator-neighbour +usr/bin/yanet-cli-operator-route diff --git a/debian/yanet2-route-operator.install b/debian/yanet2-route-operator.install new file mode 100644 index 000000000..6644804d2 --- /dev/null +++ b/debian/yanet2-route-operator.install @@ -0,0 +1,2 @@ +usr/bin/yanet-route-operator +etc/yanet2/yanet-route-operator.yaml diff --git a/debian/yanet2-route-operator.service b/debian/yanet2-route-operator.service new file mode 100644 index 000000000..e530cd2e6 --- /dev/null +++ b/debian/yanet2-route-operator.service @@ -0,0 +1,19 @@ +[Unit] +Description=yanet2 route operator +After=yanet2-controlplane.service +Wants=yanet2-controlplane.service +StartLimitBurst=3600000 +StartLimitIntervalSec=0 + +[Service] +User=root +Group=yanet + +ExecStart=/usr/bin/yanet-route-operator -c /etc/yanet2/yanet-route-operator.yaml +TimeoutSec=1200 +Restart=always +RestartSec=1 +LimitCORE=infinity + +[Install] +WantedBy=multi-user.target diff --git a/deploy/yanet-route-operator.Dockerfile b/deploy/yanet-route-operator.Dockerfile new file mode 100644 index 000000000..58be3f619 --- /dev/null +++ b/deploy/yanet-route-operator.Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/99sandbox + +COPY deploy/packages/yanet2-route-operator_*.deb /tmp/ + +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends /tmp/*.deb \ + && rm -rf /tmp/*.deb /var/lib/apt/lists/* + +ENTRYPOINT ["yanet-route-operator", "-c", "/etc/yanet2/yanet-route-operator.yaml"] diff --git a/modules/route/cli/neighbour/build.rs b/modules/route/cli/neighbour/build.rs deleted file mode 100644 index f863ae5ef..000000000 --- a/modules/route/cli/neighbour/build.rs +++ /dev/null @@ -1,13 +0,0 @@ -fn main() -> Result<(), Box> { - println!("cargo:rerun-if-changed=../../controlplane/routepb/neighbour.proto"); - - tonic_build::configure() - .emit_rerun_if_changed(false) - .build_server(false) - .compile_protos( - &["modules/route/controlplane/routepb/neighbour.proto"], - &["../../../../"], - )?; - - Ok(()) -} diff --git a/modules/route/cli/neighbour/src/lib.rs b/modules/route/cli/neighbour/src/lib.rs deleted file mode 100644 index c6377a09e..000000000 --- a/modules/route/cli/neighbour/src/lib.rs +++ /dev/null @@ -1,96 +0,0 @@ -use core::{ - fmt::{self, Display, Formatter}, - net::IpAddr, -}; -use std::time::SystemTime; - -use netip::MacAddr; -use tabled::Tabled; - -// Newtype wrapper around NeighbourState for better display -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct State(pub i32); - -impl Display for State { - fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { - let v = match self { - Self(0x00) => "NONE", - Self(0x01) => "INCOMPLETE", - Self(0x02) => "REACHABLE", - Self(0x04) => "STALE", - Self(0x08) => "DELAY", - Self(0x10) => "PROBE", - Self(0x20) => "FAILED", - Self(0x40) => "NOARP", - Self(0x80) => "PERMANENT", - Self(..) => "UNKNOWN", - }; - - write!(f, "{v}") - } -} - -/// Represents the time since a neighbor entry was last updated. -/// -/// This is a newtype wrapper around SystemTime that provides custom -/// display formatting to show the age as a human-readable duration. -#[derive(Debug)] -pub struct Age(pub SystemTime); - -impl Display for Age { - fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { - let now = SystemTime::now(); - let duration = match self { - Self(timestamp) => now.duration_since(*timestamp).unwrap_or_default(), - }; - - write!(f, "{duration:.2?}") - } -} - -/// Represents a neighbor entry in the routing table. -#[derive(Debug, Tabled)] -pub struct NeighbourEntry { - /// IP address of the next-hop router or directly connected host. - #[tabled(rename = "NEXTHOP")] - pub next_hop: IpAddr, - /// MAC address of the next-hop device. - #[tabled(rename = "NEIGHBOUR MAC")] - pub link_addr: MacAddr, - /// MAC address of the local interface that connects to this neighbor. - #[tabled(rename = "INTERFACE MAC")] - pub hardware_addr: MacAddr, - /// Network interface name. - #[tabled(rename = "DEVICE")] - pub device: String, - /// Current state of the neighbor relationship (e.g., REACHABLE, - /// STALE, PROBE). - #[tabled(rename = "STATE")] - pub state: State, - /// Time elapsed since this neighbor entry was last updated. - #[tabled(rename = "AGE")] - pub age: Age, - /// Name of the source table this entry belongs to. - #[tabled(rename = "SOURCE")] - pub source: String, - /// Priority of this entry (lower wins). - #[tabled(rename = "PRIORITY")] - pub priority: u32, -} - -/// Represents metadata about a neighbour table. -#[derive(Debug, Tabled)] -pub struct TableEntry { - /// Name of the table. - #[tabled(rename = "NAME")] - pub name: String, - /// Default priority for entries in this table. - #[tabled(rename = "DEFAULT PRIORITY")] - pub default_priority: u32, - /// Number of entries in this table. - #[tabled(rename = "ENTRIES")] - pub entry_count: i64, - /// Whether this table is built-in. - #[tabled(rename = "BUILT-IN")] - pub built_in: bool, -} diff --git a/modules/route/cli/route/Cargo.toml b/modules/route/cli/route/Cargo.toml index eb4d364c5..ae1c19b2c 100644 --- a/modules/route/cli/route/Cargo.toml +++ b/modules/route/cli/route/Cargo.toml @@ -7,19 +7,18 @@ publish = false rust-version = "1.84" [dependencies] +commonpb = { path = "../../../../common/rust/commonpb", version = "0.1", package = "yanet-commonpb" } ync = { path = "../../../../cli/core", version = "0.1", package = "yanet-cli" } -netip = "0.2" log = "0.4" clap = { version = "4.5", features = ["derive"] } clap_complete = { version = "4.5", features = ["unstable-dynamic"] } tokio = { version = "1", features = ["rt", "net", "time", "macros", "sync"] } prost = "0.13" -prost-types = "0.13" tonic = { version = "0.13", features = ["gzip"] } -tower = "0.5" tabled = { version = "0.18", features = ["ansi"] } -ptree = "0.5" -colored = "3" +serde = { version = "1", features = ["derive"] } +netip = "0.2" +serde_yaml = "0.9.34" [build-dependencies] tonic-build = "0.13" diff --git a/modules/route/cli/route/build.rs b/modules/route/cli/route/build.rs index 8f290122c..45f45e87f 100644 --- a/modules/route/cli/route/build.rs +++ b/modules/route/cli/route/build.rs @@ -6,7 +6,8 @@ pub fn main() -> Result<(), Box> { tonic_build::configure() .emit_rerun_if_changed(false) .build_server(false) - .compile_protos(&["routepb/route.proto"], &["../../controlplane"])?; + .extern_path(".commonpb", "::commonpb::pb") + .compile_protos(&["modules/route/controlplane/routepb/route.proto"], &["../../../../"])?; Ok(()) } diff --git a/modules/route/cli/route/src/lib.rs b/modules/route/cli/route/src/lib.rs index 9e8543149..57b4d071a 100644 --- a/modules/route/cli/route/src/lib.rs +++ b/modules/route/cli/route/src/lib.rs @@ -1,7 +1,4 @@ -use core::fmt::{self, Display, Formatter}; - -use colored::Colorize; -use netip::{Contiguous, IpNetwork}; +use commonpb::pb::MacAddress; use tabled::Tabled; #[allow(clippy::all, non_snake_case)] @@ -9,111 +6,12 @@ pub mod routepb { tonic::include_proto!("routepb"); } -/// BGP Large Community value. -#[derive(Debug)] -pub struct LargeCommunity { - pub global_administrator: u32, - pub local_data_part1: u32, - pub local_data_part2: u32, -} - -impl From for LargeCommunity { - fn from(community: routepb::LargeCommunity) -> Self { - Self { - global_administrator: community.global_administrator, - local_data_part1: community.local_data_part1, - local_data_part2: community.local_data_part2, - } - } -} - -impl Display for LargeCommunity { - fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { - write!( - f, - "{}:{}:{}", - self.global_administrator, self.local_data_part1, self.local_data_part2 - ) - } -} - -/// List of BGP Large Communities. -#[derive(Debug)] -pub struct Communities(pub Vec); - -impl Display for Communities { - fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { - let communities: Vec = self.0.iter().map(|c| c.to_string()).collect(); - write!(f, "{}", communities.join(" ")) - } -} - -/// Route entry in the routing table. -#[derive(Debug, Tabled)] -pub struct RouteEntry { - #[tabled(rename = "Prefix")] - pub prefix: Prefix, - #[tabled(rename = "Next Hop")] - pub next_hop: String, - #[tabled(rename = "Peer")] - pub peer: String, - #[tabled(rename = "Source")] - pub source: String, - #[tabled(rename = "Peer AS")] - pub peer_as: u32, - #[tabled(rename = "Origin")] - pub origin_as: u32, - #[tabled(rename = "Pref")] - pub pref: u32, - #[tabled(rename = "MED")] - pub med: u32, - #[tabled(rename = "Communities")] - pub communities: Communities, -} - -impl From for RouteEntry { - fn from(route: routepb::Route) -> Self { - let communities = route.large_communities.into_iter().map(|c| c.into()).collect(); - - // TODO: migrate to strongly-typed protobuf messages for IPNetwork. - let prefix = Contiguous::::parse(&route.prefix).expect("must be valid prefix"); - - let source = routepb::RouteSourceId::try_from(route.source) - .unwrap_or_default() - .as_str_name() - .strip_prefix("ROUTE_SOURCE_ID_") - .unwrap_or_default() - .to_lowercase(); - - Self { - prefix: Prefix(prefix, route.is_best), - next_hop: route.next_hop, - peer: route.peer, - source, - peer_as: route.peer_as, - origin_as: route.origin_as, - pref: route.pref, - med: route.med, - communities: Communities(communities), - } - } -} - -#[derive(Debug)] -pub struct Prefix(pub Contiguous, pub bool); - -impl Display for Prefix { - fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { - let Prefix(ref prefix, is_best) = self; - let prefix = prefix.to_string(); - let prefix = if *is_best { - prefix.into() - } else { - prefix.truecolor(127, 127, 127) - }; - - write!(f, "{prefix}") - } +fn format_mac(mac: Option) -> String { + let bytes = mac.map(|m| m.addr.to_be_bytes()).unwrap_or_default(); + format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ) } /// FIB entry for display in the CLI table. @@ -139,8 +37,8 @@ impl FibDisplayEntry { .into_iter() .map(|nh| FibDisplayEntry { prefix: prefix.clone(), - dst_mac: nh.dst_mac, - src_mac: nh.src_mac, + dst_mac: format_mac(nh.dst_mac), + src_mac: format_mac(nh.src_mac), device: nh.device, }) .collect() diff --git a/modules/route/cli/route/src/main.rs b/modules/route/cli/route/src/main.rs index 993be6761..0afe6414e 100644 --- a/modules/route/cli/route/src/main.rs +++ b/modules/route/cli/route/src/main.rs @@ -1,11 +1,16 @@ //! CLI for YANET "route" module. -use core::{error::Error, net::IpAddr}; +use core::error::Error; +use std::{ + fs::File, + path::{Path, PathBuf}, +}; use clap::{ArgAction, CommandFactory, Parser}; use clap_complete::CompleteEnv; -use netip::{Contiguous, IpNetwork}; -use ptree::TreeBuilder; +use commonpb::pb::MacAddress; +use netip::MacAddr; +use serde::{Deserialize, Serialize}; use tabled::{ settings::{ object::{Columns, Rows}, @@ -16,18 +21,76 @@ use tabled::{ }; use tonic::codec::CompressionEncoding; use yanet_cli_route::{ - routepb::{ - route_service_client::RouteServiceClient, DeleteConfigRequest, DeleteRouteRequest, FlushRoutesRequest, - InsertRouteRequest, ListConfigsRequest, LookupRouteRequest, RouteSourceId, ShowFibRequest, ShowRoutesRequest, - }, - FibDisplayEntry, RouteEntry, + routepb::{route_service_client::RouteServiceClient, ShowFibRequest, UpdateFibRequest}, + FibDisplayEntry, }; use ync::{ client::{ConnectionArgs, LayeredChannel}, logging, }; -/// Route module. +#[derive(Debug, Serialize, Deserialize)] +struct FibNexthop { + dst_mac: String, + src_mac: String, + device: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FibEntry { + prefix: String, + #[serde(default)] + nexthops: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FibConfig { + #[serde(default)] + entries: Vec, +} + +impl FibConfig { + fn load

(path: P) -> Result> + where + P: AsRef, + { + let file = File::open(path)?; + let config = serde_yaml::from_reader(file)?; + Ok(config) + } +} + +fn parse_mac(s: &str) -> Result> { + let mac: MacAddr = s.parse()?; + Ok(MacAddress { addr: mac.as_u64() }) +} + +impl TryFrom for yanet_cli_route::routepb::FibNexthop { + type Error = Box; + + fn try_from(nh: FibNexthop) -> Result { + Ok(Self { + dst_mac: Some(parse_mac(&nh.dst_mac)?), + src_mac: Some(parse_mac(&nh.src_mac)?), + device: nh.device, + }) + } +} + +impl TryFrom for yanet_cli_route::routepb::FibEntry { + type Error = Box; + + fn try_from(entry: FibEntry) -> Result { + let nexthops = entry + .nexthops + .into_iter() + .map(yanet_cli_route::routepb::FibNexthop::try_from) + .collect::, _>>()?; + Ok(Self { prefix: entry.prefix, nexthops }) + } +} + +/// Route module CLI. #[derive(Debug, Clone, Parser)] #[command(version, about)] #[command(flatten_help = true)] @@ -43,96 +106,10 @@ pub struct Cmd { #[derive(Debug, Clone, Parser)] pub enum ModeCmd { - /// List all route configurations. - List, - /// Show routes currently stored in RIB (route information base). - Show(RouteShowCmd), - /// Perform RIB route lookup. - Lookup(RouteLookupCmd), - /// Inserts a unicast static route. - Insert(RouteInsertCmd), - /// Removes a unicast static route. - Remove(RouteRemoveCmd), - /// Deletes a route configuration (RIB). - Delete(RouteDeleteConfigCmd), - /// Flush RIB to FIB for a configuration. - Flush(RouteFlushCmd), /// FIB (Forwarding Information Base) operations. Fib(FibCmd), } -#[derive(Debug, Clone, Parser)] -pub struct RouteShowCmd { - /// Show only IPv4 routes. - #[arg(long)] - pub ipv4: bool, - /// Show only IPv6 routes. - #[arg(long)] - pub ipv6: bool, - /// Route config name. - #[arg(long = "cfg", short)] - pub config_name: String, -} - -#[derive(Debug, Clone, Parser)] -pub struct RouteLookupCmd { - /// The IP address to lookup in the routing table. - pub addr: IpAddr, - /// Route config name. - #[arg(long = "cfg", short)] - pub config_name: String, -} - -#[derive(Debug, Clone, Parser)] -pub struct RouteInsertCmd { - /// The destination prefix of the route. - /// - /// The prefix must be an IPv4 or IPv6 address followed by "/" and the - /// length of the prefix. - pub prefix: Contiguous, - /// Route config name. - #[arg(long = "cfg", short)] - pub config_name: String, - /// The IP address of the nexthop router. - #[arg(long = "via")] - pub nexthop_addr: IpAddr, - /// Route source type (static or bird). Defaults to static. - #[arg(long = "source", default_value = "static")] - pub source: RouteSource, -} - -#[derive(Debug, Clone, Parser)] -pub struct RouteRemoveCmd { - /// The destination prefix of the route to remove. - /// - /// The prefix must be an IPv4 or IPv6 address followed by "/" and the - /// length of the prefix. - pub prefix: Contiguous, - /// Route config name. - #[arg(long = "cfg", short)] - pub config_name: String, - /// The IP address of the nexthop router. - #[arg(long = "via")] - pub nexthop_addr: IpAddr, - /// Route source type (static or bird). Defaults to static. - #[arg(long = "source", default_value = "static")] - pub source: RouteSource, -} - -#[derive(Debug, Clone, Parser)] -pub struct RouteDeleteConfigCmd { - /// Route config name to delete. - #[arg(long = "cfg", short)] - pub config_name: String, -} - -#[derive(Debug, Clone, Parser)] -pub struct RouteFlushCmd { - /// Route config name. - #[arg(long = "cfg", short)] - pub config_name: String, -} - #[derive(Debug, Clone, Parser)] pub struct FibCmd { #[clap(subcommand)] @@ -143,6 +120,18 @@ pub struct FibCmd { pub enum FibAction { /// Dump FIB entries. Show(FibShowCmd), + /// Replace the FIB atomically with entries from a YAML file. + Update(FibUpdateCmd), +} + +#[derive(Debug, Clone, Parser)] +pub struct FibUpdateCmd { + /// Route module config name. + #[arg(long = "cfg", short)] + pub config_name: String, + /// Path to the FIB YAML file. + #[arg(required = true, long = "rules", value_name = "PATH")] + pub rules: PathBuf, } #[derive(Debug, Clone, Parser)] @@ -158,21 +147,6 @@ pub struct FibShowCmd { pub config_name: String, } -#[derive(Debug, Clone, clap::ValueEnum)] -pub enum RouteSource { - Static, - Bird, -} - -impl RouteSource { - fn to_proto(&self) -> RouteSourceId { - match self { - RouteSource::Static => RouteSourceId::Static, - RouteSource::Bird => RouteSourceId::Bird, - } - } -} - #[tokio::main(flavor = "current_thread")] pub async fn main() { CompleteEnv::with_factory(Cmd::command).complete(); @@ -190,15 +164,9 @@ async fn run(cmd: Cmd) -> Result<(), Box> { let mut service = RouteService::new(&cmd.connection).await?; match cmd.mode { - ModeCmd::List => service.list_configs().await, - ModeCmd::Show(cmd) => service.show_routes(cmd).await, - ModeCmd::Lookup(cmd) => service.lookup_route(cmd).await, - ModeCmd::Insert(cmd) => service.insert_route(cmd).await, - ModeCmd::Remove(cmd) => service.remove_route(cmd).await, - ModeCmd::Delete(cmd) => service.delete_config(cmd).await, - ModeCmd::Flush(cmd) => service.flush_routes(cmd).await, ModeCmd::Fib(cmd) => match cmd.action { FibAction::Show(cmd) => service.show_fib(cmd).await, + FibAction::Update(cmd) => service.update_fib(cmd).await, }, } } @@ -216,117 +184,20 @@ impl RouteService { Ok(Self { client }) } - pub async fn list_configs(&mut self) -> Result<(), Box> { - let request = ListConfigsRequest {}; - log::trace!("list configs request: {request:?}"); - let response = self.client.list_configs(request).await?.into_inner(); - log::debug!("list configs response: {response:?}"); - - let mut tree = TreeBuilder::new("List Route Configs".to_string()); - for config in response.configs { - tree.add_empty_child(config); - } - let tree = tree.build(); - ptree::print_tree(&tree)?; - Ok(()) - } - - pub async fn show_routes(&mut self, cmd: RouteShowCmd) -> Result<(), Box> { - let request = ShowRoutesRequest { - name: cmd.config_name.clone(), - ipv4_only: cmd.ipv4, - ipv6_only: cmd.ipv6, - }; - - let response = self.client.show_routes(request).await?.into_inner(); - - let mut entries = response.routes.into_iter().map(RouteEntry::from).collect::>(); - - entries.sort_by_key(|a| a.prefix.0); - - print_table(entries); - - Ok(()) - } - - pub async fn lookup_route(&mut self, cmd: RouteLookupCmd) -> Result<(), Box> { - let request = LookupRouteRequest { - name: cmd.config_name.clone(), - ip_addr: cmd.addr.to_string(), - }; - - let response = self.client.lookup_route(request).await?.into_inner(); - - if response.routes.is_empty() { - log::info!("No routes found for {}", cmd.addr); - return Ok(()); - } - - // NOTE: no sorting here, since routes are already sorted by their best. - print_table(response.routes.into_iter().map(RouteEntry::from)); - - Ok(()) - } - - pub async fn insert_route(&mut self, cmd: RouteInsertCmd) -> Result<(), Box> { - let request = InsertRouteRequest { - name: cmd.config_name.clone(), - prefix: cmd.prefix.to_string(), - nexthop_addr: cmd.nexthop_addr.to_string(), - do_flush: true, - source_id: cmd.source.to_proto().into(), - }; - - self.client.insert_route(request).await?; - - log::info!( - "Route inserted successfully: {} via {} (source: {:?})", - cmd.prefix, - cmd.nexthop_addr, - cmd.source - ); - - Ok(()) - } - - pub async fn remove_route(&mut self, cmd: RouteRemoveCmd) -> Result<(), Box> { - let request = DeleteRouteRequest { - name: cmd.config_name.clone(), - prefix: cmd.prefix.to_string(), - nexthop_addr: cmd.nexthop_addr.to_string(), - do_flush: true, - source_id: cmd.source.to_proto().into(), + pub async fn update_fib(&mut self, cmd: FibUpdateCmd) -> Result<(), Box> { + let config = FibConfig::load(&cmd.rules)?; + let entries = config + .entries + .into_iter() + .map(yanet_cli_route::routepb::FibEntry::try_from) + .collect::, _>>()?; + let request = UpdateFibRequest { + module_name: cmd.config_name, + entries, }; + self.client.update_fib(request).await?; - self.client.delete_route(request).await?; - - log::info!( - "Route removed successfully: {} via {} (source: {:?})", - cmd.prefix, - cmd.nexthop_addr, - cmd.source - ); - - Ok(()) - } - - pub async fn delete_config(&mut self, cmd: RouteDeleteConfigCmd) -> Result<(), Box> { - let request = DeleteConfigRequest { name: cmd.config_name.clone() }; - - self.client.delete_config(request).await?; - - log::info!("Config deleted successfully: {}", cmd.config_name); - - Ok(()) - } - - pub async fn flush_routes(&mut self, cmd: RouteFlushCmd) -> Result<(), Box> { - let request = FlushRoutesRequest { name: cmd.config_name.clone() }; - - self.client.flush_routes(request).await?; - - log::info!("Routes flushed successfully"); - + println!("OK"); Ok(()) } diff --git a/modules/route/controlplane/backend.go b/modules/route/controlplane/backend.go index 3d40ce10f..58ceb31dd 100644 --- a/modules/route/controlplane/backend.go +++ b/modules/route/controlplane/backend.go @@ -3,33 +3,30 @@ package route import ( "fmt" "net/netip" - "time" - - "go.uber.org/zap" "github.com/yanet-platform/yanet2/common/go/bitset" - "github.com/yanet-platform/yanet2/common/go/maptrie" "github.com/yanet-platform/yanet2/controlplane/ffi" "github.com/yanet-platform/yanet2/modules/route/bindings/go/croute" - "github.com/yanet-platform/yanet2/modules/route/internal/discovery/neigh" - "github.com/yanet-platform/yanet2/modules/route/internal/rib" + "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb" ) -// ModuleHandle is a handle to a module configuration. +// ModuleHandle is a handle to a route module configuration in shared +// memory. type ModuleHandle interface { DumpFIB() ([]croute.FIBEntry, error) Free() } -// Backend abstracts shared memory operations. +// Compile-time assertion that *croute.ModuleConfig satisfies the +// ModuleHandle interface; catches drift in the bindings layer. +var _ ModuleHandle = (*croute.ModuleConfig)(nil) + +// Backend abstracts shared memory write-path operations for the route +// module. type Backend interface { - // UpdateModule resolves RIB routes against the neighbour table and - // publishes the result to the dataplane. - UpdateModule( - name string, - ribDump maptrie.MapTrie[netip.Prefix, netip.Addr, rib.RoutesList], - neighbours neigh.NexthopCacheView, - ) (ModuleHandle, error) + // UpdateModule builds a fresh ModuleConfig from the supplied FIB + // entries and publishes it to the dataplane atomically. + UpdateModule(name string, entries []*routepb.FIBEntry) (ModuleHandle, error) // DeleteModule removes a module config from the dataplane. DeleteModule(name string) error } @@ -37,154 +34,113 @@ type Backend interface { // backend is the real Backend implementation backed by shared memory. type backend struct { agent *ffi.Agent - log *zap.Logger } // NewBackend creates a Backend that operates on real shared memory. -func NewBackend(agent *ffi.Agent, log *zap.Logger) Backend { +func NewBackend(agent *ffi.Agent) Backend { return &backend{ agent: agent, - log: log, } } -func (m *backend) UpdateModule( - name string, - ribDump maptrie.MapTrie[netip.Prefix, netip.Addr, rib.RoutesList], - neighbours neigh.NexthopCacheView, -) (ModuleHandle, error) { - config, err := croute.NewModuleConfig(m.agent, name) +func (m *backend) UpdateModule(name string, entries []*routepb.FIBEntry) (ModuleHandle, error) { + module, err := croute.NewModuleConfig(m.agent, name) if err != nil { - m.log.Error("failed to create module config", - zap.Error(err), - zap.String("name", name), - ) - return nil, fmt.Errorf("failed to create %q module config: %w", name, err) - } - - // Statistics for summary logging. - var stats struct { - totalPrefixes int - totalRoutes int - skippedPrefixes int - neighbourNotFound int - hardwareRoutesAdded int - prefixesAdded int + return nil, fmt.Errorf("failed to create module config: %w", err) } - hardwareRoutes := map[neigh.HardwareRoute]uint32{} - routesListsSet := map[bitset.TinyBitset]int{} - - routeInsertionStart := time.Now() - - for prefixLen := range ribDump { - for prefix, routesList := range ribDump[prefixLen] { - stats.totalPrefixes++ - routesListSetKey := bitset.TinyBitset{} + // Defensively dedup hardware routes per-prefix using TinyBitset: + // the operator already feeds deduplicated entries, but the wire + // format encodes a list-of-nexthops per prefix and we keep the + // route module robust to mistakes upstream. + hardwareIndex := map[hardwareKey]uint32{} + routeListIndex := map[bitset.TinyBitset]uint32{} + + for _, entry := range entries { + prefix, err := netip.ParsePrefix(entry.GetPrefix()) + if err != nil { + module.Free() + return nil, fmt.Errorf("failed to parse prefix %q: %w", entry.GetPrefix(), err) + } - if len(routesList.Routes) == 0 { - stats.skippedPrefixes++ - continue + key := bitset.TinyBitset{} + for _, nh := range entry.GetNexthops() { + hk, err := newHardwareKey(nh) + if err != nil { + module.Free() + return nil, fmt.Errorf("failed to parse nexthop %v: %w", nh, err) } - stats.totalRoutes += len(routesList.Routes) - - for _, route := range routesList.Routes { - // Lookup hwaddress for the route. - entry, ok := neighbours.Lookup(route.NextHop.Unmap()) - if !ok { - m.log.Warn("neighbour not found for nexthop", - zap.Stringer("nexthop", route.NextHop), - zap.Stringer("prefix", prefix), - zap.String("name", name), - ) - stats.neighbourNotFound++ - continue - } - - if idx, ok := hardwareRoutes[entry.HardwareRoute]; ok { - routesListSetKey.Insert(idx) - continue - } - - idx, err := config.AddRoute( - entry.HardwareRoute.SourceMAC[:], - entry.HardwareRoute.DestinationMAC[:], - entry.HardwareRoute.Device, - ) + idx, ok := hardwareIndex[hk] + if !ok { + added, err := module.AddRoute(hk.SrcMAC[:], hk.DstMAC[:], hk.Device) if err != nil { - m.log.Error("failed to add hardware route", - zap.Error(err), - zap.Stringer("hardware_route", entry.HardwareRoute), - zap.Stringer("prefix", prefix), - zap.String("name", name), - ) - config.Free() - return nil, fmt.Errorf("failed to add hardware route %v for prefix %s: %w", entry.HardwareRoute, prefix, err) + module.Free() + return nil, fmt.Errorf("failed to add hardware route: %w", err) } - stats.hardwareRoutesAdded++ - hardwareRoutes[entry.HardwareRoute] = uint32(idx) - routesListSetKey.Insert(uint32(idx)) + idx = uint32(added) + hardwareIndex[hk] = idx } + key.Insert(idx) + } - if routesListSetKey.Count() == 0 { - continue - } + if key.Count() == 0 { + continue + } - idx, ok := routesListsSet[routesListSetKey] - if !ok { - routeListIdx, err := config.AddRouteList(routesListSetKey.AsSlice()) - if err != nil { - m.log.Error("failed to add route list", - zap.Error(err), - zap.Uint32s("route_indices", routesListSetKey.AsSlice()), - zap.Stringer("prefix", prefix), - zap.String("name", name), - ) - config.Free() - return nil, fmt.Errorf("failed to add routes list: %w", err) - } - idx = routeListIdx - routesListsSet[routesListSetKey] = idx + listIdx, ok := routeListIndex[key] + if !ok { + added, err := module.AddRouteList(key.AsSlice()) + if err != nil { + module.Free() + return nil, fmt.Errorf("failed to add route list: %w", err) } + listIdx = uint32(added) + routeListIndex[key] = listIdx + } - if err := config.AddPrefix(prefix, uint32(idx)); err != nil { - m.log.Error("failed to add prefix", - zap.Error(err), - zap.Stringer("prefix", prefix), - zap.Int("route_list_index", idx), - zap.String("name", name), - ) - config.Free() - return nil, fmt.Errorf("failed to add prefix %q: %w", prefix, err) - } - stats.prefixesAdded++ + if err := module.AddPrefix(prefix, listIdx); err != nil { + module.Free() + return nil, fmt.Errorf("failed to add prefix %q: %w", prefix, err) } } - m.log.Info("finished processing routes", - zap.String("module", name), - zap.Int("total_prefixes", stats.totalPrefixes), - zap.Int("total_routes", stats.totalRoutes), - zap.Int("skipped_prefixes", stats.skippedPrefixes), - zap.Int("neighbour_not_found", stats.neighbourNotFound), - zap.Int("hardware_routes_added", stats.hardwareRoutesAdded), - zap.Int("prefixes_added", stats.prefixesAdded), - zap.Duration("processing_duration", time.Since(routeInsertionStart)), - ) - - if err := m.agent.UpdateModules([]ffi.ModuleConfig{config.AsFFIModule()}); err != nil { - m.log.Error("failed to update modules via FFI", - zap.Error(err), - zap.String("name", name), - ) - config.Free() - return nil, fmt.Errorf("failed to update module: %w", err) + if err := m.agent.UpdateModules([]ffi.ModuleConfig{module.AsFFIModule()}); err != nil { + module.Free() + return nil, fmt.Errorf("failed to update modules: %w", err) } - return config, nil + return module, nil } func (m *backend) DeleteModule(name string) error { return m.agent.DeleteModuleConfig(name) } + +// hardwareKey is a comparable form of a hardware route used to +// deduplicate AddRoute calls. +type hardwareKey struct { + SrcMAC [6]byte + DstMAC [6]byte + Device string +} + +func newHardwareKey(nh *routepb.FIBNexthop) (hardwareKey, error) { + src := nh.GetSrcMac() + if src == nil { + return hardwareKey{}, fmt.Errorf("src_mac is required") + } + dst := nh.GetDstMac() + if dst == nil { + return hardwareKey{}, fmt.Errorf("dst_mac is required") + } + device := nh.GetDevice() + if device == "" { + return hardwareKey{}, fmt.Errorf("device is required") + } + return hardwareKey{ + SrcMAC: src.EUI48(), + DstMAC: dst.EUI48(), + Device: device, + }, nil +} diff --git a/modules/route/controlplane/cfg.go b/modules/route/controlplane/cfg.go index 12f1f5a59..1b09429f0 100644 --- a/modules/route/controlplane/cfg.go +++ b/modules/route/controlplane/cfg.go @@ -1,54 +1,33 @@ package route import ( - "time" - "github.com/c2h5oh/datasize" "github.com/yanet-platform/yanet2/common/go/xcfg" ) +// Config is the route module shim configuration. +// +// All RIB / neighbour / link-map fields moved to the route operator +// (see agents/yanet-route-operator). The shim only owns shared-memory +// access and a gRPC endpoint for the operator to push FIBs into. type Config struct { // InstanceID specifies which dataplane instance this module serves. InstanceID uint32 `yaml:"instance_id"` // MemoryPath is the path to the shared-memory file that is used to // communicate with dataplane. MemoryPath xcfg.NonEmptyString `yaml:"memory_path"` - // MemoryRequirements is the amount of memory that is required for a single - // transaction. + // MemoryRequirements is the amount of memory that is required for a + // single transaction. MemoryRequirements xcfg.NonZero[datasize.ByteSize] `yaml:"memory_requirements"` - Endpoint xcfg.NonEmptyString `yaml:"endpoint"` - GatewayEndpoint xcfg.NonEmptyString `yaml:"gateway_endpoint"` - RibTTL time.Duration `yaml:"rib_ttl"` - LinkMap map[string]string `yaml:"link_map"` - // NetlinkMonitor configures the kernel neighbour discovery via netlink. - NetlinkMonitor NetlinkMonitorConfig `yaml:"netlink_monitor"` -} - -// NetlinkMonitorConfig configures the kernel neighbour discovery via netlink. -type NetlinkMonitorConfig struct { - // Disabled disables the netlink neighbour monitor entirely. - // - // When disabled, no kernel neighbour table is created and no netlink - // subscription is started. - Disabled bool `yaml:"disabled"` - // TableName is the name of the kernel neighbour table. - TableName string `yaml:"table_name"` - // DefaultPriority is the default priority for kernel-learned - // neighbour entries. - DefaultPriority uint32 `yaml:"default_priority"` + // Endpoint is the gRPC endpoint of the route module shim. + Endpoint xcfg.NonEmptyString `yaml:"endpoint"` } +// DefaultConfig returns a Config populated with sensible defaults. func DefaultConfig() *Config { return &Config{ MemoryPath: xcfg.MustNonEmptyString("/dev/hugepages/yanet"), MemoryRequirements: xcfg.MustNonZero(16 * datasize.MB), Endpoint: xcfg.MustNonEmptyString("[::1]:0"), - GatewayEndpoint: xcfg.MustNonEmptyString("[::1]:8080"), - RibTTL: time.Minute, - LinkMap: make(map[string]string), - NetlinkMonitor: NetlinkMonitorConfig{ - TableName: "kernel", - DefaultPriority: 100, - }, } } diff --git a/modules/route/controlplane/mod.go b/modules/route/controlplane/mod.go index 4ea22c45f..f59001c13 100644 --- a/modules/route/controlplane/mod.go +++ b/modules/route/controlplane/mod.go @@ -1,52 +1,62 @@ package route import ( - "context" "fmt" "go.uber.org/zap" - "golang.org/x/sync/errgroup" "google.golang.org/grpc" cpffi "github.com/yanet-platform/yanet2/controlplane/ffi" "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb" - "github.com/yanet-platform/yanet2/modules/route/internal/discovery/neigh" ) const ( agentName = "route" - - // defaultStaticPriority is the default priority for statically - // configured neighbours. - defaultStaticPriority = 10 ) -// RouteModule is a controlplane part of a module that is responsible for -// routing configuration. -type RouteModule struct { - cfg *Config - shm *cpffi.SharedMemory - agent *cpffi.Agent - neighbourMonitor *neigh.NeighMonitor - routeService *RouteService - neighbourService *NeighbourService - log *zap.Logger +// Option configures the RouteModule constructor. +type Option func(*moduleOptions) + +type moduleOptions struct { + Log *zap.Logger } -// NewRouteModule creates a new RouteModule. -func NewRouteModule(cfg *Config, log *zap.Logger) (*RouteModule, error) { - log = log.With(zap.String("module", "routepb.RouteService")) +func newModuleOptions() *moduleOptions { + return &moduleOptions{ + Log: zap.NewNop(), + } +} - neighbourTable := neigh.NewNeighTable() - if _, err := neighbourTable.CreateSource("static", defaultStaticPriority, true); err != nil { - return nil, fmt.Errorf("failed to create static neighbour source: %w", err) +// WithLog sets the logger for the route module. +func WithLog(log *zap.Logger) Option { + return func(o *moduleOptions) { + o.Log = log } +} - neighbourMonitor, err := newNeighbourMonitor(cfg, neighbourTable, log) - if err != nil { - return nil, fmt.Errorf("failed to create neighbour monitor: %w", err) +// RouteModule is the slim route-module shim that owns shared memory and +// exposes the routepb.RouteService gRPC surface. +// +// The module no longer owns a RIB or a neighbour table; the +// yanet-route-operator agent rebuilds the FIB and pushes it via +// UpdateFIB. +type RouteModule struct { + cfg *Config + shm *cpffi.SharedMemory + agent *cpffi.Agent + service *RouteService + log *zap.Logger +} + +// NewRouteModule creates a new RouteModule. +func NewRouteModule(cfg *Config, options ...Option) (*RouteModule, error) { + opts := newModuleOptions() + for _, o := range options { + o(opts) } + log := opts.Log.With(zap.String("module", "routepb.RouteService")) + shm, err := cpffi.AttachSharedMemory(cfg.MemoryPath.Unwrap()) if err != nil { return nil, fmt.Errorf("failed to attach to shared memory %q: %w", cfg.MemoryPath, err) @@ -62,68 +72,37 @@ func NewRouteModule(cfg *Config, log *zap.Logger) (*RouteModule, error) { return nil, fmt.Errorf("failed to attach agent to shared memory: %w", err) } - routeService := NewRouteService(NewBackend(agent, log), neighbourTable, cfg.RibTTL, log) - neighbourService := NewNeighbourService(neighbourTable) + service := NewRouteService(NewBackend(agent), WithRouteServiceLog(log)) return &RouteModule{ - cfg: cfg, - shm: shm, - agent: agent, - neighbourMonitor: neighbourMonitor, - routeService: routeService, - neighbourService: neighbourService, - log: log, + cfg: cfg, + shm: shm, + agent: agent, + service: service, + log: log, }, nil } -// newNeighbourMonitor creates a new neighbour monitor if netlink discovery is -// enabled. -func newNeighbourMonitor( - cfg *Config, - neighTable *neigh.NeighTable, - log *zap.Logger, -) (*neigh.NeighMonitor, error) { - if cfg.NetlinkMonitor.Disabled { - return nil, nil - } - - source, err := neighTable.CreateSource( - cfg.NetlinkMonitor.TableName, - cfg.NetlinkMonitor.DefaultPriority, - true, - ) - if err != nil { - return nil, fmt.Errorf("failed to create kernel neighbour source: %w", err) - } - - neighbourMonitor := neigh.NewNeighMonitor( - neighTable, - source, - neigh.WithLog(log), - neigh.WithLinkMap(cfg.LinkMap), - ) - - return neighbourMonitor, nil -} - +// Name returns the module name. func (m *RouteModule) Name() string { return agentName } +// Endpoint returns the gRPC endpoint for the route module shim. func (m *RouteModule) Endpoint() string { return m.cfg.Endpoint.Unwrap() } +// ServicesNames returns the gRPC service names exposed by the module. func (m *RouteModule) ServicesNames() []string { return []string{ "routepb.RouteService", - "routepb.Neighbour", } } +// RegisterService registers the route module's gRPC service. func (m *RouteModule) RegisterService(server *grpc.Server) { - routepb.RegisterRouteServiceServer(server, m.routeService) - routepb.RegisterNeighbourServer(server, m.neighbourService) + routepb.RegisterRouteServiceServer(server, m.service) } // Close closes the module. @@ -131,36 +110,8 @@ func (m *RouteModule) Close() error { if err := m.agent.Close(); err != nil { m.log.Warn("failed to close shared memory agent", zap.Error(err)) } - if err := m.shm.Detach(); err != nil { m.log.Warn("failed to detach from shared memory mapping", zap.Error(err)) } - return nil } - -// Run runs the module until the specified context is canceled. -// Implements the BackgroundBuiltInModule interface from -// controlplane/internal/gateway/runner.go -func (m *RouteModule) Run(ctx context.Context) error { - wg, ctx := errgroup.WithContext(ctx) - - wg.Go(func() error { - return m.runNeighbourMonitor(ctx) - }) - wg.Go(func() error { - <-ctx.Done() - close(m.routeService.quitCh) - return ctx.Err() - }) - - return wg.Wait() -} - -func (m *RouteModule) runNeighbourMonitor(ctx context.Context) error { - if m.neighbourMonitor == nil { - return nil - } - - return m.neighbourMonitor.Run(ctx) -} diff --git a/modules/route/controlplane/neighbour_service.go b/modules/route/controlplane/neighbour_service.go deleted file mode 100644 index 155f7b199..000000000 --- a/modules/route/controlplane/neighbour_service.go +++ /dev/null @@ -1,202 +0,0 @@ -package route - -import ( - "context" - "fmt" - "net/netip" - "time" - - "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb" - "github.com/yanet-platform/yanet2/modules/route/internal/discovery/neigh" -) - -const defaultStaticTable = "static" - -// NeighbourService implements the Neighbour service for retrieving and -// managing neighbor information. -type NeighbourService struct { - routepb.UnimplementedNeighbourServer - - neighTable *neigh.NeighTable -} - -// NewNeighbourService creates a new NeighbourService. -func NewNeighbourService(neighTable *neigh.NeighTable) *NeighbourService { - return &NeighbourService{ - neighTable: neighTable, - } -} - -// List returns neighbors. -// -// If table is empty, returns the merged view. -// If table is set, returns entries from the specified table only. -func (m *NeighbourService) List( - ctx context.Context, - req *routepb.ListNeighboursRequest, -) (*routepb.ListNeighboursResponse, error) { - table := req.GetTable() - - var view neigh.NexthopCacheView - if table == "" { - view = m.neighTable.View() - } else { - v, ok := m.neighTable.SourceView(table) - if !ok { - return nil, fmt.Errorf("table %q not found", table) - } - view = v - } - - entries, size := view.Entries() - - neighbours := make([]*routepb.NeighbourEntry, 0, size) - for entry := range entries { - source := entry.Source - if source == "" { - source = table - } - - neighbours = append( - neighbours, - &routepb.NeighbourEntry{ - NextHop: entry.NextHop.String(), - LinkAddr: routepb.NewMACAddressEUI48(entry.HardwareRoute.DestinationMAC), - HardwareAddr: routepb.NewMACAddressEUI48(entry.HardwareRoute.SourceMAC), - State: routepb.NeighbourState(entry.State), - UpdatedAt: entry.UpdatedAt.Unix(), - Source: source, - Priority: entry.Priority, - Device: entry.HardwareRoute.Device, - }, - ) - } - - return &routepb.ListNeighboursResponse{ - Neighbours: neighbours, - }, nil -} - -// CreateTable creates a new neighbour table. -func (m *NeighbourService) CreateTable( - ctx context.Context, - request *routepb.CreateNeighbourTableRequest, -) (*routepb.CreateNeighbourTableResponse, error) { - if _, err := m.neighTable.CreateSource(request.GetName(), request.GetDefaultPriority(), false); err != nil { - return nil, err - } - - return &routepb.CreateNeighbourTableResponse{}, nil -} - -// UpdateTable updates the default priority of an existing neighbour -// table. -func (m *NeighbourService) UpdateTable( - ctx context.Context, - request *routepb.UpdateNeighbourTableRequest, -) (*routepb.UpdateNeighbourTableResponse, error) { - if err := m.neighTable.UpdateSource(request.GetName(), request.GetDefaultPriority()); err != nil { - return nil, err - } - - return &routepb.UpdateNeighbourTableResponse{}, nil -} - -// RemoveTable removes a user-defined neighbour table. -func (m *NeighbourService) RemoveTable( - ctx context.Context, - request *routepb.RemoveNeighbourTableRequest, -) (*routepb.RemoveNeighbourTableResponse, error) { - if err := m.neighTable.DeleteSource(request.GetName()); err != nil { - return nil, err - } - - return &routepb.RemoveNeighbourTableResponse{}, nil -} - -// ListTables returns metadata about all registered neighbour tables. -func (m *NeighbourService) ListTables( - ctx context.Context, - request *routepb.ListNeighbourTablesRequest, -) (*routepb.ListNeighbourTablesResponse, error) { - sources := m.neighTable.ListSources() - - tables := make([]*routepb.NeighbourTableInfo, 0, len(sources)) - for _, src := range sources { - tables = append(tables, &routepb.NeighbourTableInfo{ - Name: src.Name, - DefaultPriority: src.DefaultPriority, - EntryCount: int64(src.EntryCount), - BuiltIn: src.BuiltIn, - }) - } - - return &routepb.ListNeighbourTablesResponse{ - Tables: tables, - }, nil -} - -// UpdateNeighbours inserts or updates one or more neighbour entries in -// the specified table. -func (m *NeighbourService) UpdateNeighbours( - ctx context.Context, - request *routepb.UpdateNeighboursRequest, -) (*routepb.UpdateNeighboursResponse, error) { - table := request.GetTable() - if table == "" { - table = defaultStaticTable - } - - entries := make([]neigh.NeighbourEntry, 0, len(request.GetEntries())) - for _, e := range request.GetEntries() { - addr, err := netip.ParseAddr(e.GetNextHop()) - if err != nil { - return nil, fmt.Errorf("invalid nexthop %q: %w", e.GetNextHop(), err) - } - - entries = append(entries, neigh.NeighbourEntry{ - NextHop: addr, - HardwareRoute: neigh.HardwareRoute{ - SourceMAC: e.GetHardwareAddr().EUI48(), - DestinationMAC: e.GetLinkAddr().EUI48(), - Device: e.GetDevice(), - }, - UpdatedAt: time.Now(), - State: neigh.NeighbourStatePermanent, - Priority: e.GetPriority(), - }) - } - - if err := m.neighTable.Add(table, entries); err != nil { - return nil, err - } - - return &routepb.UpdateNeighboursResponse{}, nil -} - -// RemoveNeighbours deletes one or more neighbour entries from the -// specified table. -func (m *NeighbourService) RemoveNeighbours( - ctx context.Context, - request *routepb.RemoveNeighboursRequest, -) (*routepb.RemoveNeighboursResponse, error) { - table := request.GetTable() - if table == "" { - table = defaultStaticTable - } - - addrs := make([]netip.Addr, 0, len(request.GetNextHops())) - for _, hop := range request.GetNextHops() { - addr, err := netip.ParseAddr(hop) - if err != nil { - return nil, fmt.Errorf("invalid next_hop %q: %w", hop, err) - } - addrs = append(addrs, addr) - } - - if err := m.neighTable.Remove(table, addrs); err != nil { - return nil, err - } - - return &routepb.RemoveNeighboursResponse{}, nil -} diff --git a/modules/route/controlplane/routepb/meson.build b/modules/route/controlplane/routepb/meson.build index 303c7136e..5fadce27e 100644 --- a/modules/route/controlplane/routepb/meson.build +++ b/modules/route/controlplane/routepb/meson.build @@ -2,8 +2,6 @@ root_dir = meson.project_source_root() proto_dir = meson.current_source_dir() proto_files = [ join_paths(proto_dir, 'route.proto'), - join_paths(proto_dir, 'neighbour.proto'), - join_paths(proto_dir, 'macaddr.proto'), ] protoc_gen = custom_target( @@ -11,9 +9,6 @@ protoc_gen = custom_target( output: [ 'route.pb.go', 'route_grpc.pb.go', - 'neighbour.pb.go', - 'neighbour_grpc.pb.go', - 'macaddr.pb.go', ], input: proto_files, command: [ diff --git a/modules/route/controlplane/routepb/neighbour.proto b/modules/route/controlplane/routepb/neighbour.proto deleted file mode 100644 index 15f69271b..000000000 --- a/modules/route/controlplane/routepb/neighbour.proto +++ /dev/null @@ -1,175 +0,0 @@ -syntax = "proto3"; - -package routepb; - -option go_package = "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb;routepb"; - -import "modules/route/controlplane/routepb/macaddr.proto"; - -// Neighbour is a service for viewing and managing neighbors. -service Neighbour { - // List returns neighbors either merged view or from a specific table. - // - // If table name is empty, returns the merged view across all tables. - // If table name is specified, returns entries from that specific table - // only. - rpc List(ListNeighboursRequest) returns (ListNeighboursResponse); - - // CreateTable creates a new user-defined neighbour table with the - // given default priority. - rpc CreateTable(CreateNeighbourTableRequest) returns (CreateNeighbourTableResponse); - // UpdateTable changes the default priority of an existing neighbour - // table. - rpc UpdateTable(UpdateNeighbourTableRequest) returns (UpdateNeighbourTableResponse); - // RemoveTable removes a user-defined neighbour table and all its - // entries. - // - // Built-in tables (e.g. "kernel", "static") cannot be removed. - rpc RemoveTable(RemoveNeighbourTableRequest) returns (RemoveNeighbourTableResponse); - // ListTables returns metadata about all registered neighbour tables. - rpc ListTables(ListNeighbourTablesRequest) returns (ListNeighbourTablesResponse); - - // UpdateNeighbours inserts or updates one or more neighbour entries - // in the specified table. - // - // If the table name is empty, defaults to "static". Server-managed - // fields (state, updated_at, source) in NeighbourEntry are ignored - // on input. - rpc UpdateNeighbours(UpdateNeighboursRequest) returns (UpdateNeighboursResponse); - // RemoveNeighbours deletes one or more neighbour entries from the - // specified table by their next-hop IP addresses. - // - // If the table name is empty, defaults to "static". - rpc RemoveNeighbours(RemoveNeighboursRequest) returns (RemoveNeighboursResponse); -} - -// ListNeighboursRequest is the request to list neighbors. -message ListNeighboursRequest { - // Table is the name of the table to list entries from. - // - // If empty, returns the merged view. - // If specified, returns entries from that specific table only. - string table = 1; -} - -// ListNeighboursResponse contains the list of neighbors. -message ListNeighboursResponse { repeated NeighbourEntry neighbours = 1; } - -// NeighbourState represents the state of a neighbor entry. -enum NeighbourState { - NUD_NONE = 0x00; - NUD_INCOMPLETE = 0x01; - NUD_REACHABLE = 0x02; - NUD_STALE = 0x04; - NUD_DELAY = 0x08; - NUD_PROBE = 0x10; - NUD_FAILED = 0x20; - NUD_NOARP = 0x40; - NUD_PERMANENT = 0x80; - NUD_UNKNOWN = 0xff; -} - -// NeighbourEntry represents a single neighbor entry. -message NeighbourEntry { - // NextHop is the IP address of the next hop. - string next_hop = 1; - // LinkAddr is the MAC address of the next hop. - MACAddress link_addr = 2; - // HardwareAddr is the MAC address of the local interface. - MACAddress hardware_addr = 3; - // State is the state of the neighbor entry. - NeighbourState state = 4; - // UpdatedAt is the timestamp when this entry was last updated. - // This is a UNIX timestamp in seconds. - int64 updated_at = 5; - // Source is the name of the table this entry belongs to. - string source = 6; - // Priority determines which entry wins when the same IP exists in - // multiple tables. Lower value means higher priority. - uint32 priority = 7; - // Device is the network interface name. - string device = 8; -} - -// CreateNeighbourTableRequest is the request to create a new neighbour table. -message CreateNeighbourTableRequest { - // Name is the name of the table to create. - string name = 1; - // DefaultPriority is the default priority for entries in this table. - uint32 default_priority = 2; -} - -// CreateNeighbourTableResponse is the response to creating a neighbour table. -message CreateNeighbourTableResponse {} - -// UpdateNeighbourTableRequest is the request to update a neighbour table. -message UpdateNeighbourTableRequest { - // Name is the name of the table to update. - string name = 1; - // DefaultPriority is the default priority for entries in this table. - uint32 default_priority = 2; -} - -// UpdateNeighbourTableResponse is the response to updating a neighbour table. -message UpdateNeighbourTableResponse {} - -// RemoveNeighbourTableRequest is the request to remove a neighbour table. -message RemoveNeighbourTableRequest { - // Name is the name of the table to remove. - string name = 1; -} - -// RemoveNeighbourTableResponse is the response to removing a neighbour -// table. -message RemoveNeighbourTableResponse {} - -// ListNeighbourTablesRequest is the request to list all neighbour tables. -message ListNeighbourTablesRequest {} - -// ListNeighbourTablesResponse contains the list of all neighbour tables. -message ListNeighbourTablesResponse { repeated NeighbourTableInfo tables = 1; } - -// NeighbourTableInfo contains metadata about a neighbour table. -message NeighbourTableInfo { - // Name is the unique name of the table. - string name = 1; - // DefaultPriority is the default priority assigned to new entries. - uint32 default_priority = 2; - // EntryCount is the number of entries currently in the table. - int64 entry_count = 3; - // BuiltIn indicates whether the table is built-in and cannot be - // removed. - bool built_in = 4; -} - -// UpdateNeighboursRequest is the request to insert or update one or more -// neighbour entries. -message UpdateNeighboursRequest { - // Table is the target table name. - // - // Defaults to "static" if empty. - string table = 1; - // Entries is the list of neighbour entries to insert or update. - // - // Server-managed fields (state, updated_at, source) are ignored - // on input. - repeated NeighbourEntry entries = 2; -} - -// UpdateNeighboursResponse is the response to updating neighbour -// entries. -message UpdateNeighboursResponse {} - -// RemoveNeighboursRequest is the request to remove one or more -// neighbour entries. -message RemoveNeighboursRequest { - // Table is the target table name. - // - // Defaults to "static" if empty. - string table = 1; - // NextHops is the list of next-hop IP addresses to remove. - repeated string next_hops = 2; -} - -// RemoveNeighboursResponse is the response to removing neighbour entries. -message RemoveNeighboursResponse {} diff --git a/modules/route/controlplane/routepb/route.proto b/modules/route/controlplane/routepb/route.proto index 469c9664e..22877c2a0 100644 --- a/modules/route/controlplane/routepb/route.proto +++ b/modules/route/controlplane/routepb/route.proto @@ -4,201 +4,37 @@ package routepb; option go_package = "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb;routepb"; -// RouteService is a service for routing table management. +import "common/commonpb/macaddr.proto"; + service RouteService { - // ListConfigs returns all route module configurations. + // ListConfigs returns all route module configurations known to the + // module. rpc ListConfigs(ListConfigsRequest) returns (ListConfigsResponse); - // ShowRoutes returns all routes in the routing table. - rpc ShowRoutes(ShowRoutesRequest) returns (ShowRoutesResponse); - - // LookupRoute looks up possible routes for the given IP address. - rpc LookupRoute(LookupRouteRequest) returns (LookupRouteResponse); - - // InsertRoute inserts a route into the routing table. - rpc InsertRoute(InsertRouteRequest) returns (InsertRouteResponse); - - // DeleteRoute deletes a route from the routing table. - rpc DeleteRoute(DeleteRouteRequest) returns (DeleteRouteResponse); - - // DeleteConfig deletes a route configuration (RIB). + // DeleteConfig deletes a route configuration. rpc DeleteConfig(DeleteConfigRequest) returns (DeleteConfigResponse); - // FlushRoutes triggers the operation of flushing routes from - // the Routing Information Base (RIB) to the Forwarding Information Base - // (FIB). - rpc FlushRoutes(FlushRoutesRequest) returns (FlushRoutesResponse); - - // ShowFIB returns the current Forwarding Information Base entries. - // - // Unlike ShowRoutes which returns RIB data, ShowFIB returns the - // compiled hardware-level forwarding table that the dataplane uses for - // packet forwarding. + // ShowFIB returns the current Forwarding Information Base entries + // by reading them straight from shared memory. rpc ShowFIB(ShowFIBRequest) returns (ShowFIBResponse); - // FeedRIB receives a stream of route updates (typically from BIRD) and - // applies them to the RIB. It implements session management to handle - // stale routes: - // 1. On first update, a new session is started in the RIB, - // invalidating any prior session. - // 2. Routes received are tagged with the current session ID. - // 3. If this stream is superseded by another FeedRIB call for the same - // RIB, it will terminate. - // 4. When the stream ends, a cleanup task removes routes from this - // session after a TTL. - rpc FeedRIB(stream Update) returns (UpdateSummary); + // UpdateFIB pushes a freshly-built FIB to the route module and + // applies it atomically. + rpc UpdateFIB(UpdateFIBRequest) returns (UpdateFIBResponse); } +// ListConfigsRequest is the request to list configurations. message ListConfigsRequest {} // ListConfigsResponse contains existing configurations. message ListConfigsResponse { repeated string configs = 1; } -// ShowRoutesRequest contains filters for route listing. -message ShowRoutesRequest { - string name = 1; - // Filter to show only IPv4 routes. - bool ipv4_only = 2; - // Filter to show only IPv6 routes. - bool ipv6_only = 3; -} - -// ShowRoutesResponse contains the list of routes. -message ShowRoutesResponse { - // List of routes in the routing table. - repeated Route routes = 1; -} - -// LookupRouteRequest contains the IP address to lookup. -message LookupRouteRequest { - string name = 1; - // IP address to lookup. - string ip_addr = 2; -} - -// LookupRouteResponse contains the matching routes for the IP address. -message LookupRouteResponse { - // Prefix is the prefix that matched with the IP address. - string prefix = 1; - // Matching routes for the IP address, sorted with best path first. - repeated Route routes = 2; -} - -// InsertRouteRequest is the request to insert a route. -message InsertRouteRequest { - string name = 1; - // The destination prefix of the route. - // - // The prefix must be an IPv4 or IPv6 address followed by "/" and the - // length of the prefix. - string prefix = 2; - // The IP address of the nexthop router. - // - // The address must be either an IPv4 or IPv6 address. - // - // Example: "fe80::1", "192.168.1.1" - string nexthop_addr = 3; - - // Indicates whether the RIB should be flushed to the FIB after this - // request. - bool do_flush = 4; - - // Route source identifier (e.g., Static, BIRD). - RouteSourceID source_id = 5; -} - -// InsertRouteResponse is the response of "InsertRoute" request. -message InsertRouteResponse {} - -// DeleteRouteRequest is the request to delete a route. -message DeleteRouteRequest { - string name = 1; - // The destination prefix of the route to delete. - // - // The prefix must be an IPv4 or IPv6 address followed by "/" and the - // length of the prefix. - string prefix = 2; - // The IP address of the nexthop router. - // - // The address must be either an IPv4 or IPv6 address. - // - // Example: "fe80::1", "192.168.1.1" - string nexthop_addr = 3; - - // Indicates whether the RIB should be flushed to the FIB after this - // request. - bool do_flush = 4; - - // Route source identifier (e.g., Static, BIRD). - RouteSourceID source_id = 5; -} - -// DeleteRouteResponse is the response of "DeleteRoute" request. -message DeleteRouteResponse {} - // DeleteConfigRequest is the request to delete a route configuration. message DeleteConfigRequest { string name = 1; } // DeleteConfigResponse is the response of "DeleteConfig" request. message DeleteConfigResponse {} -// FlushRoutesRequest specifies which module should perform the RIB to FIB -// flush. -message FlushRoutesRequest { string name = 1; } - -message FlushRoutesResponse {} - -// Update represents a message in the stream for inserting one route -// into the module's RIB. -message Update { - // The module config name where the RIB should be updated. - string name = 1; - // Indicates whether this is a route deletion event. - bool is_delete = 2; - // The route to add to the RIB. - Route route = 3; -} - -message UpdateSummary {} - -// Route represents a routing table entry. -message Route { - // Network prefix in CIDR notation. - string prefix = 1; - // Next hop IP address. - string next_hop = 2; - // Peer IP address. - string peer = 3; - // https://datatracker.ietf.org/doc/html/rfc4364#section-4.2 - uint64 route_distinguisher = 4; - // Peer's autonomous system number. - uint32 peer_as = 5; - // Origin autonomous system number. - uint32 origin_as = 6; - // Multi-Exit Discriminator value. - uint32 med = 7; - // Local preference value. - uint32 pref = 8; - // ASPathLen measures the number of AS hops to reach our system. - uint32 as_path_len = 9; - // Route source (e.g., Static, BIRD). - RouteSourceID source = 10; - // BGP large communities attached to the route. - repeated LargeCommunity large_communities = 11; - // Indicates if this is the best path. - bool is_best = 12; -} - -// LargeCommunity represents a BGP Large Community value. -message LargeCommunity { - // Global administrator identifier. - uint32 global_administrator = 1; - // First part of local data. - uint32 local_data_part1 = 2; - // Second part of local data. - uint32 local_data_part2 = 3; -} - // ShowFIBRequest contains filters for FIB listing. message ShowFIBRequest { // Route module config name. @@ -224,18 +60,22 @@ message FIBEntry { } // FIBNexthop represents a hardware-level nexthop in the FIB. +// +// Destination MAC (binary EUI-48 carried in commonpb.MACAddress). message FIBNexthop { - // Destination MAC address (e.g. "aa:bb:cc:dd:ee:ff"). - string dst_mac = 1; - // Source MAC address (e.g. "11:22:33:44:55:66"). - string src_mac = 2; + commonpb.MACAddress dst_mac = 1; + commonpb.MACAddress src_mac = 2; // Egress device name. string device = 3; } -// NeighbourState represents the state of a neighbor entry. -enum RouteSourceID { - ROUTE_SOURCE_ID_UNKNOWN = 0; - ROUTE_SOURCE_ID_STATIC = 1; - ROUTE_SOURCE_ID_BIRD = 2; +// UpdateFIBRequest carries the full FIB to be applied atomically. +message UpdateFIBRequest { + // ModuleName is the route module config name. + string module_name = 1; + // Entries are the full FIB to be applied atomically. + repeated FIBEntry entries = 3; } + +// UpdateFIBResponse is the empty ack for UpdateFIB. +message UpdateFIBResponse {} diff --git a/modules/route/controlplane/service.go b/modules/route/controlplane/service.go index 2d5921982..c59eb3156 100644 --- a/modules/route/controlplane/service.go +++ b/modules/route/controlplane/service.go @@ -2,497 +2,199 @@ package route import ( "context" - "fmt" - "io" "net/netip" "sync" - "sync/atomic" - "time" "go.uber.org/zap" - "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/yanet-platform/yanet2/common/commonpb" "github.com/yanet-platform/yanet2/common/go/xnetip" "github.com/yanet-platform/yanet2/modules/route/bindings/go/croute" "github.com/yanet-platform/yanet2/modules/route/controlplane/routepb" - "github.com/yanet-platform/yanet2/modules/route/internal/discovery/neigh" - "github.com/yanet-platform/yanet2/modules/route/internal/rib" ) -type RouteService struct { - routepb.UnimplementedRouteServiceServer - - // shmLock serializes shared-memory mutations and protects the ffiModules - // map. - shmLock sync.RWMutex - backend Backend - // ribsLock protects the ribs map only. - ribsLock sync.RWMutex - ribs map[string]*rib.RIB - ffiModules map[string]ModuleHandle - neighTable *neigh.NeighTable +// RouteServiceOption configures the RouteService constructor. +type RouteServiceOption func(*routeServiceOptions) - ribTTL time.Duration - quitCh chan bool - - log *zap.Logger +type routeServiceOptions struct { + Log *zap.Logger } -func NewRouteService( - backend Backend, - neighTable *neigh.NeighTable, - ribTTL time.Duration, - log *zap.Logger, -) *RouteService { - return &RouteService{ - backend: backend, - ribs: map[string]*rib.RIB{}, - ffiModules: map[string]ModuleHandle{}, - neighTable: neighTable, - ribTTL: ribTTL, - quitCh: make(chan bool), - log: log, +func newRouteServiceOptions() *routeServiceOptions { + return &routeServiceOptions{ + Log: zap.NewNop(), } } -func (m *RouteService) ListConfigs( - ctx context.Context, - request *routepb.ListConfigsRequest, -) (*routepb.ListConfigsResponse, error) { - response := &routepb.ListConfigsResponse{ - Configs: []string{}, - } - - m.ribsLock.RLock() - for key := range m.ribs { - response.Configs = append(response.Configs, key) +// WithRouteServiceLog sets the logger for the RouteService. +func WithRouteServiceLog(log *zap.Logger) RouteServiceOption { + return func(o *routeServiceOptions) { + o.Log = log } - m.ribsLock.RUnlock() - - return response, nil } -func (m *RouteService) ShowRoutes( - ctx context.Context, - request *routepb.ShowRoutesRequest, -) (*routepb.ShowRoutesResponse, error) { - - name := request.GetName() - if name == "" { - return nil, status.Error(codes.InvalidArgument, "module config name is required") - } - - holder, ok := m.getRib(name) - if !ok { - return &routepb.ShowRoutesResponse{}, nil - } - ribDump := holder.DumpRoutes() - - response := &routepb.ShowRoutesResponse{} - - for prefixLen := range ribDump { - for prefix, routesList := range ribDump[prefixLen] { - if len(routesList.Routes) == 0 { - continue - } +// RouteService is the gRPC service implementation backing the slim +// route-module shim. +type RouteService struct { + routepb.UnimplementedRouteServiceServer - // Apply IPv4/IPv6 filters if specified. - if request.Ipv4Only && !prefix.Addr().Is4() { - continue - } - if request.Ipv6Only && !prefix.Addr().Is6() { - continue - } + backend Backend - for idx, r := range routesList.Routes { - isBest := idx == 0 - response.Routes = append(response.Routes, routepb.FromRIBRoute(&r, isBest)) - } - } - } + // shmLock serializes shared-memory mutations and protects the + // configs map. + shmLock sync.RWMutex + configs map[string]ModuleHandle - return response, nil + log *zap.Logger } -func (m *RouteService) LookupRoute( - ctx context.Context, - request *routepb.LookupRouteRequest, -) (*routepb.LookupRouteResponse, error) { - - name := request.GetName() - if name == "" { - return nil, status.Error(codes.InvalidArgument, "module config name is required") - } - - addr, err := netip.ParseAddr(request.GetIpAddr()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "failed to parse IP address: %v", err) +// NewRouteService builds a RouteService bound to the supplied backend. +func NewRouteService(backend Backend, options ...RouteServiceOption) *RouteService { + opts := newRouteServiceOptions() + for _, o := range options { + o(opts) } - holder, ok := m.getRib(name) - if !ok { - return &routepb.LookupRouteResponse{}, nil + return &RouteService{ + backend: backend, + configs: map[string]ModuleHandle{}, + log: opts.Log, } +} - prefix, routes, ok := holder.LongestMatch(addr) - if !ok { - return &routepb.LookupRouteResponse{}, nil - } +// ListConfigs returns the names of all route module configurations +// currently known to the service. +func (m *RouteService) ListConfigs( + ctx context.Context, + req *routepb.ListConfigsRequest, +) (*routepb.ListConfigsResponse, error) { + m.shmLock.RLock() + defer m.shmLock.RUnlock() - response := &routepb.LookupRouteResponse{ - // TODO: Replace with IPNetwork protobuf message. - Prefix: prefix.String(), - Routes: make([]*routepb.Route, 0, len(routes.Routes)), + response := &routepb.ListConfigsResponse{ + Configs: make([]string, 0, len(m.configs)), } - - for idx, r := range routes.Routes { - isBest := idx == 0 - response.Routes = append(response.Routes, routepb.FromRIBRoute(&r, isBest)) + for name := range m.configs { + response.Configs = append(response.Configs, name) } - return response, nil } +// ShowFIB returns the FIB entries currently applied in shared memory +// for the requested configuration. func (m *RouteService) ShowFIB( ctx context.Context, - request *routepb.ShowFIBRequest, + req *routepb.ShowFIBRequest, ) (*routepb.ShowFIBResponse, error) { - name := request.GetName() + name := req.GetName() if name == "" { return nil, status.Error(codes.InvalidArgument, "module config name is required") } + // Hold RLock for the entire DumpFIB call so a concurrent Free under + // shmLock.Lock cannot release the underlying shared memory. m.shmLock.RLock() - ffiModule, ok := m.ffiModules[name] + defer m.shmLock.RUnlock() + + module, ok := m.configs[name] if !ok { - m.shmLock.RUnlock() return &routepb.ShowFIBResponse{}, nil } - // Hold RLock for the entire DumpFIB call so that a concurrent Free under - // shmMu.Lock cannot release the underlying shared memory. - entries, err := ffiModule.DumpFIB() - m.shmLock.RUnlock() + entries, err := module.DumpFIB() if err != nil { return nil, status.Errorf(codes.Internal, "failed to dump FIB: %v", err) } - response := &routepb.ShowFIBResponse{} - + response := &routepb.ShowFIBResponse{ + Entries: make([]*routepb.FIBEntry, 0, len(entries)), + } for _, e := range entries { - if request.GetIpv4Only() && e.AddressFamily != croute.AddressFamilyIPv4 { + if req.GetIpv4Only() && e.AddressFamily != croute.AddressFamilyIPv4 { continue } - if request.GetIpv6Only() && e.AddressFamily != croute.AddressFamilyIPv6 { + if req.GetIpv6Only() && e.AddressFamily != croute.AddressFamilyIPv6 { continue } - prefix := formatPrefixRange(e.PrefixFrom, e.PrefixTo) - nexthops := make([]*routepb.FIBNexthop, len(e.Nexthops)) - for i, nh := range e.Nexthops { - nexthops[i] = &routepb.FIBNexthop{ - DstMac: nh.DstMAC.String(), - SrcMac: nh.SrcMAC.String(), + for idx, nh := range e.Nexthops { + nexthops[idx] = &routepb.FIBNexthop{ + DstMac: commonpb.NewMACAddressEUI48([6]byte(nh.DstMAC)), + SrcMac: commonpb.NewMACAddressEUI48([6]byte(nh.SrcMAC)), Device: nh.Device, } } response.Entries = append(response.Entries, &routepb.FIBEntry{ - Prefix: prefix, + Prefix: formatPrefixRange(e.PrefixFrom, e.PrefixTo), Nexthops: nexthops, }) } - return response, nil } -// formatPrefixRange converts an address range to a human-readable string. -// -// If the range corresponds to a single CIDR prefix, it returns CIDR notation; -// otherwise "from-to" range notation. -func formatPrefixRange(from, to netip.Addr) string { - if prefix, ok := xnetip.RangeToCIDR(from, to); ok { - return prefix.String() - } - - return from.String() + "-" + to.String() -} - -func (m *RouteService) FlushRoutes( - ctx context.Context, - request *routepb.FlushRoutesRequest, -) (*routepb.FlushRoutesResponse, error) { - name := request.GetName() - if name == "" { - return nil, status.Error(codes.InvalidArgument, "module config name is required") - } - ribRef, ok := m.getRib(name) - if !ok { - return &routepb.FlushRoutesResponse{}, nil - } - - if err := m.syncRouteUpdates(ribRef, name); err != nil { - return nil, fmt.Errorf("failed to sync route updates: %w", err) - } - - return &routepb.FlushRoutesResponse{}, nil -} - -func (m *RouteService) InsertRoute( - ctx context.Context, - request *routepb.InsertRouteRequest, -) (*routepb.InsertRouteResponse, error) { - name := request.GetName() - if name == "" { - return nil, status.Error(codes.InvalidArgument, "module config name is required") - } - - prefix, err := netip.ParsePrefix(request.GetPrefix()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "failed to parse prefix %q: %v", request.GetPrefix(), err) - } - - nexthopAddr, err := netip.ParseAddr(request.GetNexthopAddr()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "failed to parse nexthop address %q: %v", request.GetNexthopAddr(), err) - } - - sourceID := request.RouteSourceID() - - holder := m.getOrCreateRib(name) - - if err := holder.AddUnicastRoute(prefix, nexthopAddr, sourceID); err != nil { - return nil, fmt.Errorf("failed to add unicast route: %w", err) - } - - if request.GetDoFlush() { - if err := m.syncRouteUpdates(holder, name); err != nil { - return nil, fmt.Errorf("failed to sync route updates: %w", err) - } - } - - return &routepb.InsertRouteResponse{}, nil -} - -func (m *RouteService) DeleteRoute( - ctx context.Context, - request *routepb.DeleteRouteRequest, -) (*routepb.DeleteRouteResponse, error) { - name := request.GetName() - if name == "" { - return nil, status.Error(codes.InvalidArgument, "module config name is required") - } - - prefix, err := netip.ParsePrefix(request.GetPrefix()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "failed to parse prefix: %v", err) - } - - nexthopAddr, err := netip.ParseAddr(request.GetNexthopAddr()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "failed to parse nexthop address: %v", err) - } - - sourceID := request.RouteSourceID() - - holder, ok := m.getRib(name) - if !ok { - return &routepb.DeleteRouteResponse{}, nil - } - - if err := holder.RemoveUnicastRoute(prefix, nexthopAddr, sourceID); err != nil { - return nil, fmt.Errorf("failed to remove unicast route: %w", err) - } - - if request.GetDoFlush() { - if err := m.syncRouteUpdates(holder, name); err != nil { - return nil, fmt.Errorf("failed to sync route deletions: %w", err) - } - } - - return &routepb.DeleteRouteResponse{}, nil -} - +// DeleteConfig deletes a route module configuration. func (m *RouteService) DeleteConfig( ctx context.Context, - request *routepb.DeleteConfigRequest, + req *routepb.DeleteConfigRequest, ) (*routepb.DeleteConfigResponse, error) { - name := request.GetName() + name := req.GetName() if name == "" { return nil, status.Error(codes.InvalidArgument, "module config name is required") } - // Lock order: shmLock -> ribsLock. m.shmLock.Lock() defer m.shmLock.Unlock() - // Delete the module config from the data plane if it exists. - ffiModule, hasFFIModule := m.ffiModules[name] - if hasFFIModule { - if err := m.backend.DeleteModule(name); err != nil { - return nil, status.Errorf(codes.Internal, "failed to delete module config %q: %v", name, err) - } - ffiModule.Free() - delete(m.ffiModules, name) - } - - // Remove the RIB from the map. - m.ribsLock.Lock() - if _, ok := m.ribs[name]; !ok { - m.ribsLock.Unlock() + module, ok := m.configs[name] + if !ok { return &routepb.DeleteConfigResponse{}, nil } - delete(m.ribs, name) - m.ribsLock.Unlock() - - return &routepb.DeleteConfigResponse{}, nil -} -// FeedRIB receives a stream of route updates (typically from BIRD) and applies them to the -// appropriate RIB instance. It implements session management to handle stale routes: -// 1. On first update, a new session is started in the RIB. This invalidates any prior session -// for the same RIB, signaling its stream (if active) to terminate. -// 2. Routes received are tagged with the current session ID. -// 3. If this stream is superseded by another FeedRIB call for the same RIB, -// its `terminated` flag will be set, causing this stream to close. -// 4. When the stream ends (EOF or error), a CleanupTask is launched for the RIB -// to remove routes from this session (and older BIRD sessions) after a TTL. -func (m *RouteService) FeedRIB(stream grpc.ClientStreamingServer[routepb.Update, routepb.UpdateSummary]) error { - var ( - update *routepb.Update - name string - err error - ribRef *rib.RIB // Reference to the target RIB for this stream. - sessionId uint64 // ID for the current route import session. - terminated *atomic.Bool // Flag to signal termination of this specific stream. - ) - for { - update, err = stream.Recv() - if err == io.EOF { // Stream closed by client. - err = stream.SendAndClose(&routepb.UpdateSummary{}) - break - } - if err != nil { // Other stream error. - break - } - - // On the first update, identify the target RIB and start a new session. - if ribRef == nil { - name = update.GetName() - if name == "" { - err = status.Error(codes.InvalidArgument, "module config name is required") - break // Invalid target, cannot proceed. - } - ribRef = m.getOrCreateRib(name) - // NewSession() increments RIB's session counter and returns the new ID. - // It also sets the termination flag for the *previous* session's stream. - sessionId, terminated = ribRef.NewSession() - m.log.Info("new FeedRIB session started", - zap.Uint64("session_id", sessionId), - zap.String("name", name), - ) - } - - // Check if this session has been superseded by a newer one. - if terminated.Load() { - m.log.Warn("FeedRIB session terminated by a newer session", - zap.Uint64("session_id", sessionId), - zap.String("name", name), - ) - err = stream.SendAndClose(&routepb.UpdateSummary{}) // Gracefully close our side. - break - } - if update.GetRoute() == nil { // flush event - m.log.Info("sync routes due to flush event in FeedRIB stream", - zap.Uint64("session_id", sessionId), - zap.String("name", name), - ) - err = m.syncRouteUpdates(ribRef, name) - if err != nil { - break - } - } else { - route, convertErr := routepb.ToRIBRoute(update.GetRoute(), update.GetIsDelete()) - if convertErr != nil { - m.log.Error("failed to convert proto route to RIB route", - zap.Uint64("session_id", sessionId), - zap.Error(convertErr), - ) - continue // Skip this invalid route update. - } - route.SessionID = sessionId // Tag route with current session ID. - ribRef.Update(*route) - } - - } - - // If a RIB was established for this stream, schedule cleanup for its session. - // This runs regardless of whether the stream ended cleanly or with an error. - if ribRef != nil { - m.log.Info("FeedRIB session ended, scheduling cleanup", - zap.Uint64("session_id", sessionId), - zap.String("name", name), - zap.Duration("ttl", m.ribTTL), - ) - // CleanupTask will remove routes from this sessionID (and older BIRD ones) after ribTTL. - go ribRef.CleanupTask(sessionId, m.quitCh, m.ribTTL) + if err := m.backend.DeleteModule(name); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete module config %q: %v", name, err) } + module.Free() + delete(m.configs, name) - // err will be nil on clean EOF, or the stream error otherwise. - return err -} - -func (m *RouteService) getRib(name string) (*rib.RIB, bool) { - m.ribsLock.RLock() - defer m.ribsLock.RUnlock() - rib, ok := m.ribs[name] - return rib, ok + return &routepb.DeleteConfigResponse{}, nil } -func (m *RouteService) getOrCreateRib(name string) *rib.RIB { - m.ribsLock.Lock() - defer m.ribsLock.Unlock() - - ribRef, ok := m.ribs[name] - if !ok { - m.log.Info("creating new RIB", - zap.String("name", name), - ) - ribRef = rib.NewRIB(m.log) - m.ribs[name] = ribRef +// UpdateFIB applies a freshly-built FIB to the dataplane atomically. +func (m *RouteService) UpdateFIB( + ctx context.Context, + req *routepb.UpdateFIBRequest, +) (*routepb.UpdateFIBResponse, error) { + name := req.GetModuleName() + if name == "" { + return nil, status.Error(codes.InvalidArgument, "module_name is required") } - return ribRef -} - -func (m *RouteService) syncRouteUpdates(ribRef *rib.RIB, name string) error { - ribDump := ribRef.DumpRoutes() - neighbours := m.neighTable.View() - // Huge mutex, but our shared memory must be protected from concurrent - // access. m.shmLock.Lock() defer m.shmLock.Unlock() - newModule, err := m.backend.UpdateModule(name, ribDump, neighbours) + module, err := m.backend.UpdateModule(name, req.GetEntries()) if err != nil { - m.log.Error("syncRouteUpdates: failed to update module config", - zap.Error(err), - zap.String("name", name), - ) - return err + return nil, status.Errorf(codes.Internal, "failed to apply FIB for %q: %v", name, err) } - // Swap the FFI module and free the old one. - // - // The caller already holds shmLock, so both the ffiModules map and - // the Free call are protected. - if oldModule, exists := m.ffiModules[name]; exists { - oldModule.Free() + if old, ok := m.configs[name]; ok { + old.Free() } - m.ffiModules[name] = newModule + m.configs[name] = module - return nil + return &routepb.UpdateFIBResponse{}, nil +} + +// formatPrefixRange converts an address range to a human-readable +// string. If the range corresponds to a single CIDR prefix, it returns +// CIDR notation; otherwise "from-to" range notation. +func formatPrefixRange(from, to netip.Addr) string { + if prefix, ok := xnetip.RangeToCIDR(from, to); ok { + return prefix.String() + } + return from.String() + "-" + to.String() } diff --git a/tests/functional/converted/setup_test.go b/tests/functional/converted/setup_test.go index 0b01dec71..189ff4214 100644 --- a/tests/functional/converted/setup_test.go +++ b/tests/functional/converted/setup_test.go @@ -125,6 +125,8 @@ logging: level: debug gateway: + server: + endpoint: "0.0.0.0:8080" auth: disabled: true @@ -198,6 +200,27 @@ rules: return 1 } + // Bootstrap the default IPv4/IPv6 FIB for route0. The YAML is + // consumed by the "yanet-cli-route fib update" entry appended to + // CommonConfigCommands. + route0Config := ` +entries: + - prefix: "0.0.0.0/0" + nexthops: + - dst_mac: "` + framework.SrcMAC + `" + src_mac: "` + framework.DstMAC + `" + device: "01:00.0" + - prefix: "::/0" + nexthops: + - dst_mac: "` + framework.SrcMAC + `" + src_mac: "` + framework.DstMAC + `" + device: "01:00.0" +` + if err := gfw.CreateConfigFile("route0.yaml", route0Config); err != nil { + sugar.Errorf("Failed to create route0 config: %v", err) + return 1 + } + sugar.Info("Executing common configuration commands...") if _, err := gfw.ExecuteCommands(framework.CommonConfigCommands...); err != nil { sugar.Errorf("Failed to execute common configuration commands: %v", err) diff --git a/tests/functional/framework/framework.go b/tests/functional/framework/framework.go index 3097a004f..f12b48d36 100644 --- a/tests/functional/framework/framework.go +++ b/tests/functional/framework/framework.go @@ -56,9 +56,10 @@ var ( // Configure L2 and L3 forwarding CLIForward + " update --cfg=forward0 --rules /mnt/yanet2/forward.yaml", - // Configure routing - CLIRoute + " insert --cfg route0 --via " + VMIPv6Gateway + " ::/0", - CLIRoute + " insert --cfg route0 --via " + VMIPv4Gateway + " 0.0.0.0/0", + // Bootstrap the default IPv4/IPv6 FIB for the "route0" config. + // The YAML payload is created on the host before this command + // runs; see framework_test.go. + CLIRoute + " fib update --cfg=route0 --rules /mnt/config/route0.yaml", CLIFunction + " update --name=virt --chains chain0:10=forward:forward0", CLIFunction + " update --name=test --chains chain2:1=forward:forward0,route:route0", @@ -983,7 +984,7 @@ func (f *F) StartYANET(dataplaneConfig string, controlplaneConfig string) error f.log.Debug("Verifying YANET services are running...") err = f.WaitOutputPresent("cat /mnt/logs/yanet-controlplane.log", func(output string) bool { - return strings.Contains(output, "updated nexthop cache") + return strings.Contains(output, "all built-in modules ready") }, 10*time.Second) if err != nil { return fmt.Errorf("failed to start controlplane: %w", err) diff --git a/tests/functional/framework/qemu.go b/tests/functional/framework/qemu.go index a3e84a66e..4ff82efa3 100644 --- a/tests/functional/framework/qemu.go +++ b/tests/functional/framework/qemu.go @@ -212,7 +212,9 @@ func (q *QEMUManager) Start() error { "-drive", fmt.Sprintf("file=%s,if=virtio,format=qcow2", q.ImagePath), ) - // Network interface configuration + // Network interface configuration. SSH forwarding is added in + // keep-alive mode for manual debugging. + netdev := "user,id=net0" if ShouldKeepVMAlive() { // Get a random free port for SSH forwarding to support multiple VMs var err error @@ -221,10 +223,9 @@ func (q *QEMUManager) Start() error { return fmt.Errorf("failed to get free port for SSH forwarding: %w", err) } q.log.Infof("Keep VM alive mode enabled: SSH port forwarding 127.0.0.1:%d -> VM:22", q.sshPort) - args = append(args, "-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp:127.0.0.1:%d-:22", q.sshPort)) - } else { - args = append(args, "-netdev", "user,id=net0") + netdev += fmt.Sprintf(",hostfwd=tcp:127.0.0.1:%d-:22", q.sshPort) } + args = append(args, "-netdev", netdev) args = append(args, "-device", "virtio-net-pci,netdev=net0,mac=AA:BB:CC:DD:CA:B0", @@ -466,7 +467,7 @@ func (q *QEMUManager) captureStderr(stderr io.ReadCloser, logWriter *os.File) { // connectToMonitor connects to QEMU monitor interface via unix socket func (q *QEMUManager) connectToMonitor() error { // Try multiple times to connect to monitor - for i := 0; i < 10; i++ { + for i := range 10 { // Create connection for monitoring conn, err := net.Dial("unix", q.MonitorPath) if err != nil { @@ -488,7 +489,7 @@ func (q *QEMUManager) connectToMonitor() error { // connectToSerial connects to QEMU serial console via unix socket func (q *QEMUManager) connectToSerial() error { // Try multiple times to connect to serial console - for i := 0; i < 10; i++ { + for i := range 10 { // Create connection for commands conn, err := net.Dial("unix", q.SerialPath) if err != nil { diff --git a/tests/functional/main/framework_test.go b/tests/functional/main/framework_test.go index 69ec0ae40..4090e75e1 100644 --- a/tests/functional/main/framework_test.go +++ b/tests/functional/main/framework_test.go @@ -141,6 +141,8 @@ logging: level: debug gateway: + server: + endpoint: "0.0.0.0:8080" auth: disabled: true @@ -213,6 +215,27 @@ rules: return 1 } + // Bootstrap the default IPv4/IPv6 FIB for route0. The YAML is + // consumed by the "yanet-cli-route fib update" entry appended to + // CommonConfigCommands below. + route0Config := ` +entries: + - prefix: "0.0.0.0/0" + nexthops: + - dst_mac: "` + framework.SrcMAC + `" + src_mac: "` + framework.DstMAC + `" + device: "01:00.0" + - prefix: "::/0" + nexthops: + - dst_mac: "` + framework.SrcMAC + `" + src_mac: "` + framework.DstMAC + `" + device: "01:00.0" +` + if err := gfw.CreateConfigFile("route0.yaml", route0Config); err != nil { + sugar.Errorf("Failed to create route0 config: %v", err) + return 1 + } + if _, err := gfw.ExecuteCommands(framework.CommonConfigCommands...); err != nil { sugar.Errorf("Failed to execute common configuration commands: %v", err) return 1 diff --git a/tests/functional/main/route_mpls_test.go b/tests/functional/main/route_mpls_test.go index 2cc003181..340975c12 100644 --- a/tests/functional/main/route_mpls_test.go +++ b/tests/functional/main/route_mpls_test.go @@ -2,16 +2,21 @@ package functional import ( "net" - "strings" "testing" "time" "github.com/gopacket/gopacket" "github.com/gopacket/gopacket/layers" "github.com/stretchr/testify/require" + "github.com/yanet-platform/yanet2/tests/functional/framework" ) +// routeMPLSCfgName is the route module config used by the route-mpls +// tests. The route-mpls module shares the cfg name with the route +// module that backs its egress lookups. +const routeMPLSCfgName = "route-mpls" + // createRouteMPLSTestPacket creates a TCP packet for route testing func createRouteMPLSTestPacket(srcIP, dstIP net.IP, payload []byte) []byte { eth := layers.Ethernet{ @@ -102,40 +107,11 @@ func TestRouteMPLS(t *testing.T) { fw := globalFramework.ForTest(t) require.NotNil(t, fw, "Global framework should be initialized") - fw.Run("Insert_Static_Route4", func(fw *framework.F, t *testing.T) { - // Add neighbour for the nexthop - _, err := fw.ExecuteCommand("ip nei add 192.0.2.1 lladdr " + framework.SrcMAC + " dev kni0") - require.NoError(t, err, "Failed to add neighbour") - - // Wait for neighbour to appear in yanet - err = fw.WaitOutputPresent("/mnt/target/release/yanet-cli-neighbour show", func(output string) bool { - return strings.Contains(output, "192.0.2.1") - }, 10*time.Second) - require.NoError(t, err, "Neighbour 192.0.2.1 did not appear in yanet") - - // Insert route with the nexthop (do_flush is automatic in insert command) - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-mpls 10.0.0.0/8 --via 192.0.2.1") - require.NoError(t, err, "Failed to insert route") - t.Logf("Insert route output: %s", output) - t.Logf("Successfully inserted route 10.0.0.0/8 via 192.0.2.1") - }) - - fw.Run("Insert_Static_Route6", func(fw *framework.F, t *testing.T) { - // Add neighbour for the nexthop - _, err := fw.ExecuteCommand("ip -6 nei add aabb::1 lladdr " + framework.SrcMAC + " dev kni0") - require.NoError(t, err, "Failed to add neighbour") - - // Wait for neighbour to appear in yanet - err = fw.WaitOutputPresent("/mnt/target/release/yanet-cli-neighbour show", func(output string) bool { - return strings.Contains(output, "aabb::1") - }, 10*time.Second) - require.NoError(t, err, "Neighbour aabb::1 did not appear in yanet") - - // Insert route with the nexthop (do_flush is automatic in insert command) - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-mpls ccee::0/16 --via aabb::1") - require.NoError(t, err, "Failed to insert route") - t.Logf("Insert route output: %s", output) - t.Logf("Successfully inserted route ccee::0/16 via aabb::1") + fw.Run("Insert_Static_Routes", func(fw *framework.F, t *testing.T) { + // Push the IPv4 and IPv6 prefixes the MPLS tunnel decisions + // depend on as a single atomic FIB update. + applyFIB(t, fw, routeMPLSCfgName, "setup", "10.0.0.0/8", "ccee::0/16") + t.Logf("Successfully inserted routes 10.0.0.0/8 and ccee::0/16") }) fw.Run("Insert_Static_RouteMPLS-4-4", func(fw *framework.F, t *testing.T) { @@ -263,14 +239,4 @@ func TestRouteMPLS(t *testing.T) { require.Equal(t, outputPacket.DstPort, uint16(6635), "Invalid destination port") }) - fw.Run("Delete_Static_Route", func(fw *framework.F, t *testing.T) { - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-mpls 10.0.0.0/24 --via 192.0.2.1") - require.NoError(t, err, "Failed to delete route") - t.Logf("Delete route output: %s", output) - t.Logf("Successfully deleted route 10.0.0.0/8 via 192.0.2.1") - - // Clean up neighbour - _, err = fw.ExecuteCommand("ip nei del 192.0.2.1 dev kni0") - require.NoError(t, err, "Failed to delete neighbour") - }) } diff --git a/tests/functional/main/route_test.go b/tests/functional/main/route_test.go index fb069af4b..f2eaffb26 100644 --- a/tests/functional/main/route_test.go +++ b/tests/functional/main/route_test.go @@ -2,18 +2,26 @@ package functional import ( "net" - "strings" "testing" "time" "github.com/gopacket/gopacket" "github.com/gopacket/gopacket/layers" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + "github.com/yanet-platform/yanet2/tests/functional/framework" ) -// createRouteTestPacket creates a TCP packet for route testing +// routeCfgName is the route module config used by the route tests. +const routeCfgName = "route-tfn0" + +// routeEgressDevice is the dataplane egress device used by the route +// tests. It matches the "01:00.0" device declared in the test +// dataplane configuration in framework_test.go. +const routeEgressDevice = "01:00.0" + +// createRouteTestPacket creates a TCP packet for route testing. func createRouteTestPacket(srcIP, dstIP net.IP, payload []byte) []byte { eth := layers.Ethernet{ SrcMAC: framework.MustParseMAC(framework.SrcMAC), @@ -57,33 +65,68 @@ func createRouteTestPacket(srcIP, dstIP net.IP, payload []byte) []byte { return buf.Bytes() } -// TestRoute tests route module functionality including static route insertion and deletion +// applyFIB writes a FIB YAML file under /mnt/config and pushes it via +// "yanet-cli-route fib update" against the named route module config. +// +// The MAC pair is intentionally swapped relative to the framework's +// canonical SrcMAC/DstMAC so that egressing packets carry the host's +// expected MACs, matching the assertions in the route packet tests. +// +// An empty prefixes set produces an empty entries list, which the CLI +// treats as a full FIB clear. +func applyFIB(t *testing.T, fw *framework.F, cfgName, suffix string, prefixes ...string) { + t.Helper() + + type fibNexthopYAML struct { + DstMAC string `yaml:"dst_mac"` + SrcMAC string `yaml:"src_mac"` + Device string `yaml:"device"` + } + type fibEntryYAML struct { + Prefix string `yaml:"prefix"` + Nexthops []fibNexthopYAML `yaml:"nexthops"` + } + type fibConfigYAML struct { + Entries []fibEntryYAML `yaml:"entries"` + } + + cfg := fibConfigYAML{Entries: make([]fibEntryYAML, 0, len(prefixes))} + for _, prefix := range prefixes { + cfg.Entries = append(cfg.Entries, fibEntryYAML{ + Prefix: prefix, + Nexthops: []fibNexthopYAML{{ + DstMAC: framework.SrcMAC, + SrcMAC: framework.DstMAC, + Device: routeEgressDevice, + }}, + }) + } + + body, err := yaml.Marshal(&cfg) + require.NoError(t, err, "failed to marshal FIB config") + + name := cfgName + "-" + suffix + ".yaml" + require.NoError(t, fw.CreateConfigFile(name, string(body)), + "failed to create FIB config file") + + cmd := framework.CLIRoute + " fib update --cfg=" + cfgName + + " --rules /mnt/config/" + name + _, err = fw.ExecuteCommand(cmd) + require.NoError(t, err, "failed to update FIB") +} + +// TestRoute tests route module functionality including static route insertion and deletion. func TestRoute(t *testing.T) { fw := globalFramework.ForTest(t) require.NotNil(t, fw, "Global framework should be initialized") - fw.Run("Insert_Static_Route", func(fw *framework.F, t *testing.T) { - // Add neighbour for the nexthop - _, err := fw.ExecuteCommand("ip nei add 192.0.2.1 lladdr " + framework.SrcMAC + " dev kni0") - require.NoError(t, err, "Failed to add neighbour") - - // Wait for neighbour to appear in yanet - err = fw.WaitOutputPresent("/mnt/target/release/yanet-cli-neighbour show", func(output string) bool { - return strings.Contains(output, "192.0.2.1") - }, 10*time.Second) - require.NoError(t, err, "Neighbour 192.0.2.1 did not appear in yanet") - - // Insert route with the nexthop (do_flush is automatic in insert command) - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-tfn0 10.0.0.0/24 --via 192.0.2.1") - require.NoError(t, err, "Failed to insert route") - t.Logf("Insert route output: %s", output) - t.Logf("Successfully inserted route 10.0.0.0/24 via 192.0.2.1") + fw.Run("Setup_Route_Config", func(fw *framework.F, t *testing.T) { + applyFIB(t, fw, routeCfgName, "setup", "10.0.0.0/24") }) fw.Run("Configure_Route_Module", func(fw *framework.F, t *testing.T) { - // Configure route module commands := []string{ - framework.CLIFunction + " update --name=test --chains ch0:4=route:route-tfn0", + framework.CLIFunction + " update --name=test --chains ch0:4=route:" + routeCfgName, framework.CLIPipeline + " update --name=test --functions test", } @@ -91,48 +134,13 @@ func TestRoute(t *testing.T) { require.NoError(t, err, "Failed to configure route module") }) - fw.Run("List_Configs", func(fw *framework.F, t *testing.T) { - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route list") - require.NoError(t, err, "Failed to list configs") - t.Logf("Available configs: %s", output) - }) - - fw.Run("Show_Routes_After_Insert", func(fw *framework.F, t *testing.T) { - // First, show all routes without filter - outputAll, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0") - require.NoError(t, err, "Failed to show all routes") - t.Logf("Show all routes output:\n%s", outputAll) - - // Then, show only IPv4 routes with filter - outputIPv4, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show IPv4 routes") - - // Verify our route is present in filtered output - require.Contains(t, outputIPv4, "10.0.0.0/24", "Inserted route prefix should be present") - require.Contains(t, outputIPv4, "192.0.2.1", "Inserted route nexthop should be present") - require.Contains(t, outputIPv4, "static", "Route should be marked as 'static'") - t.Logf("Show IPv4 routes (filtered) output:\n%s", outputIPv4) - }) - - fw.Run("Lookup_Route", func(fw *framework.F, t *testing.T) { - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route lookup --cfg route-tfn0 10.0.0.10") - require.NoError(t, err, "Failed to lookup route") - - // Verify lookup result contains our prefix - require.Contains(t, output, "10.0.0.0/24", "Lookup should match our test prefix") - t.Logf("Lookup output:\n%s", output) - }) - fw.Run("Test_Packet_Routing_With_Route", func(fw *framework.F, t *testing.T) { - - // Create packet destined to our routed network packet := createRouteTestPacket( - net.ParseIP("192.0.2.100"), // src IP - net.ParseIP("10.0.0.10"), // dst IP in our routed network + net.ParseIP("192.0.2.100"), + net.ParseIP("10.0.0.10"), []byte("route test"), ) - // Send packet and check if it's routed inputPacket, outputPacket, err := fw.SendPacketAndParse(0, 0, packet, 100*time.Millisecond) require.NoError(t, err, "Failed to send packet") @@ -143,45 +151,19 @@ func TestRoute(t *testing.T) { }) fw.Run("Delete_Static_Route", func(fw *framework.F, t *testing.T) { - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-tfn0 10.0.0.0/24 --via 192.0.2.1") - require.NoError(t, err, "Failed to delete route") - t.Logf("Delete route output: %s", output) - t.Logf("Successfully deleted route 10.0.0.0/24 via 192.0.2.1") - - // Clean up neighbour - _, err = fw.ExecuteCommand("ip nei del 192.0.2.1 dev kni0") - require.NoError(t, err, "Failed to delete neighbour") - }) - - fw.Run("Show_Routes_After_Delete", func(fw *framework.F, t *testing.T) { - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show routes") - - // Verify our static route is deleted - // The output should either not contain the prefix, or if it does, it shouldn't be marked as static - if strings.Contains(output, "10.0.0.0/24") { - // If prefix is still there, it should not be from static source - lines := strings.Split(output, "\n") - for _, line := range lines { - if strings.Contains(line, "10.0.0.0/24") && strings.Contains(line, "192.0.2.1") { - assert.NotContains(t, line, "static", "Deleted static route should not be present") - } - } - } - t.Logf("Verified route deletion. Show routes output:\n%s", output) + // fib update is a full atomic replacement; an empty entry set + // effectively removes all routes from the module. + applyFIB(t, fw, routeCfgName, "clear") + t.Logf("Successfully cleared route FIB") }) fw.Run("Test_Packet_Without_Route", func(fw *framework.F, t *testing.T) { - - // Create packet destined to a network without any route - // Without default route, this packet should be dropped packet := createRouteTestPacket( - net.ParseIP("192.0.2.100"), // src IP - net.ParseIP("172.16.0.10"), // dst IP - no route for this network + net.ParseIP("192.0.2.100"), + net.ParseIP("172.16.0.10"), []byte("no route test"), ) - // Try to receive packet on interface 0 inputPacket, outputPacket0, err := fw.SendPacketAndParse(0, 0, packet, 100*time.Millisecond) require.Error(t, err, "Should get error when packet is dropped") var netErr0 net.Error @@ -190,7 +172,6 @@ func TestRoute(t *testing.T) { require.NotNil(t, inputPacket, "Input packet should be parsed") require.Nil(t, outputPacket0, "Packet should be dropped (no route) on interface 0") - // Also check interface 1 to be sure outputPacket1, err := fw.SendPacketAndCapture(0, 1, packet, 100*time.Millisecond) require.Error(t, err, "Should get error when packet is dropped") var netErr1 net.Error @@ -202,252 +183,24 @@ func TestRoute(t *testing.T) { }) fw.Run("Test_Packet_With_Default_Route", func(fw *framework.F, t *testing.T) { - // Neighbour for gateway already added in CommonConfigCommands (framework.VMIPv4Gateway) - - // Insert default route using the gateway from framework into route-tfn0 config - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-tfn0 0.0.0.0/0 --via " + framework.VMIPv4Gateway) - require.NoError(t, err, "Failed to insert default route") - t.Logf("Inserted default route: %s", output) + applyFIB(t, fw, routeCfgName, "default", "0.0.0.0/0") - // Create packet destined to a network without specific route - // This should now match the default route packet := createRouteTestPacket( - net.ParseIP("192.0.2.100"), // src IP - net.ParseIP("172.16.0.10"), // dst IP - will use default route + net.ParseIP("192.0.2.100"), + net.ParseIP("172.16.0.10"), []byte("default route test"), ) - // Send packet and expect it to be routed via default route on interface 0 inputPacket, outputPacket, err := fw.SendPacketAndParse(0, 0, packet, 100*time.Millisecond) require.NoError(t, err, "Failed to send packet") require.NotNil(t, inputPacket, "Input packet should be parsed") - // With default route configured, packet should be routed require.NotNil(t, outputPacket, "Packet should be routed via default route") require.Equal(t, "172.16.0.10", outputPacket.DstIP.String(), "Destination IP should be preserved") t.Logf("Packet correctly routed via default route: src=%s dst=%s", outputPacket.SrcIP, outputPacket.DstIP) - // Clean up: delete default route - output, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-tfn0 0.0.0.0/0 --via " + framework.VMIPv4Gateway) - require.NoError(t, err, "Failed to delete default route") - t.Logf("Deleted default route: %s", output) - }) - - fw.Run("Insert_Multiple_Routes", func(fw *framework.F, t *testing.T) { - routes := []struct { - prefix string - nexthop string - }{ - {"10.1.0.0/24", "192.0.2.1"}, - {"10.2.0.0/24", "192.0.2.2"}, - {"10.3.0.0/24", "192.0.2.3"}, - } - - // Add neighbours for all nexthops - for _, route := range routes { - _, err := fw.ExecuteCommand("ip nei add " + route.nexthop + " lladdr " + framework.SrcMAC + " dev kni0") - require.NoError(t, err, "Failed to add neighbour for %s", route.nexthop) - } - - // Wait for all neighbours to appear - for _, route := range routes { - err := fw.WaitOutputPresent("/mnt/target/release/yanet-cli-neighbour show", func(output string) bool { - return strings.Contains(output, route.nexthop) - }, 10*time.Second) - require.NoError(t, err, "Neighbour %s did not appear in yanet", route.nexthop) - } - - // Insert routes - for _, route := range routes { - _, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-tfn0 " + route.prefix + " --via " + route.nexthop) - require.NoError(t, err, "Failed to insert route %s", route.prefix) - } - - // Flush routes - _, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route flush --cfg route-tfn0") - require.NoError(t, err, "Failed to flush routes") - - // Verify all routes are present - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show routes") - - for _, route := range routes { - require.Contains(t, output, route.prefix, "Route %s should be present", route.prefix) - require.Contains(t, output, route.nexthop, "Nexthop %s should be present", route.nexthop) - } - - t.Logf("Successfully inserted and verified %d routes", len(routes)) + applyFIB(t, fw, routeCfgName, "default-clear") + t.Logf("Cleared default route") }) - fw.Run("Delete_Multiple_Routes", func(fw *framework.F, t *testing.T) { - routes := []struct { - prefix string - nexthop string - }{ - {"10.1.0.0/24", "192.0.2.1"}, - {"10.2.0.0/24", "192.0.2.2"}, - {"10.3.0.0/24", "192.0.2.3"}, - } - - // Delete routes - for _, route := range routes { - _, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-tfn0 " + route.prefix + " --via " + route.nexthop) - require.NoError(t, err, "Failed to delete route %s", route.prefix) - } - - // Clean up neighbours - for _, route := range routes { - _, err := fw.ExecuteCommand("ip nei del " + route.nexthop + " dev kni0") - require.NoError(t, err, "Failed to delete neighbour for %s", route.nexthop) - } - - // Flush routes - _, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route flush --cfg route-tfn0") - require.NoError(t, err, "Failed to flush routes") - - // Verify all static routes are deleted - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show routes") - - for _, route := range routes { - // Check that if the prefix exists, it's not from static source - if strings.Contains(output, route.prefix) { - lines := strings.Split(output, "\n") - for _, line := range lines { - if strings.Contains(line, route.prefix) && strings.Contains(line, route.nexthop) { - assert.NotContains(t, line, "static", "Route %s via %s should not be static", route.prefix, route.nexthop) - } - } - } - } - - t.Logf("Successfully deleted and verified %d routes", len(routes)) - }) - - fw.Run("Test_Route_Source_Static", func(fw *framework.F, t *testing.T) { - // Add neighbour for the nexthop - _, err := fw.ExecuteCommand("ip nei add 192.0.2.10 lladdr " + framework.SrcMAC + " dev kni0") - require.NoError(t, err, "Failed to add neighbour") - - // Wait for neighbour to appear in yanet - err = fw.WaitOutputPresent("/mnt/target/release/yanet-cli-neighbour show", func(output string) bool { - return strings.Contains(output, "192.0.2.10") - }, 10*time.Second) - require.NoError(t, err, "Neighbour 192.0.2.10 did not appear in yanet") - - // Insert route with explicit --source static - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-tfn0 10.10.0.0/24 --via 192.0.2.10 --source static") - require.NoError(t, err, "Failed to insert route with --source static") - t.Logf("Insert route with --source static output: %s", output) - - // Verify route is present and marked as static - output, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show routes") - require.Contains(t, output, "10.10.0.0/24", "Route prefix should be present") - require.Contains(t, output, "192.0.2.10", "Route nexthop should be present") - require.Contains(t, output, "static", "Route should be marked as 'static'") - t.Logf("Route with --source static verified:\n%s", output) - - // Delete route with explicit --source static - output, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-tfn0 10.10.0.0/24 --via 192.0.2.10 --source static") - require.NoError(t, err, "Failed to delete route with --source static") - t.Logf("Delete route with --source static output: %s", output) - - // Clean up neighbour - _, err = fw.ExecuteCommand("ip nei del 192.0.2.10 dev kni0") - require.NoError(t, err, "Failed to delete neighbour") - }) - - fw.Run("Test_Route_Source_Bird", func(fw *framework.F, t *testing.T) { - // Add neighbour for the nexthop - _, err := fw.ExecuteCommand("ip nei add 192.0.2.11 lladdr " + framework.SrcMAC + " dev kni0") - require.NoError(t, err, "Failed to add neighbour") - - // Wait for neighbour to appear in yanet - err = fw.WaitOutputPresent("/mnt/target/release/yanet-cli-neighbour show", func(output string) bool { - return strings.Contains(output, "192.0.2.11") - }, 10*time.Second) - require.NoError(t, err, "Neighbour 192.0.2.11 did not appear in yanet") - - // Insert route with --source bird - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-tfn0 10.11.0.0/24 --via 192.0.2.11 --source bird") - require.NoError(t, err, "Failed to insert route with --source bird") - t.Logf("Insert route with --source bird output: %s", output) - - // Verify route is present and marked as bird - output, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show routes") - require.Contains(t, output, "10.11.0.0/24", "Route prefix should be present") - require.Contains(t, output, "192.0.2.11", "Route nexthop should be present") - require.Contains(t, output, "bird", "Route should be marked as 'bird'") - t.Logf("Route with --source bird verified:\n%s", output) - - // Delete route with explicit --source bird - output, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-tfn0 10.11.0.0/24 --via 192.0.2.11 --source bird") - require.NoError(t, err, "Failed to delete route with --source bird") - t.Logf("Delete route with --source bird output: %s", output) - - // Clean up neighbour - _, err = fw.ExecuteCommand("ip nei del 192.0.2.11 dev kni0") - require.NoError(t, err, "Failed to delete neighbour") - }) - - fw.Run("Test_Route_Source_Isolation", func(fw *framework.F, t *testing.T) { - // This test verifies that routes with different sources are isolated - // i.e., deleting a static route doesn't affect a bird route with the same prefix/nexthop - - // Add neighbour for the nexthop - _, err := fw.ExecuteCommand("ip nei add 192.0.2.12 lladdr " + framework.SrcMAC + " dev kni0") - require.NoError(t, err, "Failed to add neighbour") - - // Wait for neighbour to appear in yanet - err = fw.WaitOutputPresent("/mnt/target/release/yanet-cli-neighbour show", func(output string) bool { - return strings.Contains(output, "192.0.2.12") - }, 10*time.Second) - require.NoError(t, err, "Neighbour 192.0.2.12 did not appear in yanet") - - // Insert route with --source static - _, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-tfn0 10.12.0.0/24 --via 192.0.2.12 --source static") - require.NoError(t, err, "Failed to insert static route") - - // Insert same route with --source bird - _, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route insert --cfg route-tfn0 10.12.0.0/24 --via 192.0.2.12 --source bird") - require.NoError(t, err, "Failed to insert bird route") - - // Verify both routes are present - output, err := fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show routes") - - // Count occurrences of the prefix - should appear twice (once for static, once for bird) - prefixCount := strings.Count(output, "10.12.0.0/24") - require.Equal(t, 2, prefixCount, "Should have 2 routes with same prefix (static and bird)") - t.Logf("Both static and bird routes present:\n%s", output) - - // Delete only the static route - _, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-tfn0 10.12.0.0/24 --via 192.0.2.12 --source static") - require.NoError(t, err, "Failed to delete static route") - - // Verify bird route is still present - output, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route show --cfg route-tfn0 --ipv4") - require.NoError(t, err, "Failed to show routes") - require.Contains(t, output, "10.12.0.0/24", "Bird route should still be present") - require.Contains(t, output, "bird", "Route should be marked as 'bird'") - - // Verify static route is gone - lines := strings.Split(output, "\n") - for _, line := range lines { - if strings.Contains(line, "10.12.0.0/24") { - require.NotContains(t, line, "static", "Static route should be deleted") - } - } - t.Logf("Static route deleted, bird route preserved:\n%s", output) - - // Clean up: delete bird route - _, err = fw.ExecuteCommand("/mnt/target/release/yanet-cli-route remove --cfg route-tfn0 10.12.0.0/24 --via 192.0.2.12 --source bird") - require.NoError(t, err, "Failed to delete bird route") - - // Clean up neighbour - _, err = fw.ExecuteCommand("ip nei del 192.0.2.12 dev kni0") - require.NoError(t, err, "Failed to delete neighbour") - }) } diff --git a/web/src/api/neighbours.ts b/web/src/api/neighbours.ts index 4e4c56f9a..338abcaa6 100644 --- a/web/src/api/neighbours.ts +++ b/web/src/api/neighbours.ts @@ -31,7 +31,7 @@ export interface ListNeighbourTablesResponse { tables?: NeighbourTableInfo[]; } -const neighbourService = createService('routepb.Neighbour'); +const neighbourService = createService('operatorpb.NeighbourService'); export const neighbours = { list: (table?: string, options?: CallOptions): Promise => { diff --git a/web/src/api/routes.ts b/web/src/api/routes.ts index 013dbd3f6..d520c9e27 100644 --- a/web/src/api/routes.ts +++ b/web/src/api/routes.ts @@ -1,4 +1,5 @@ import { createService, type CallOptions } from './client'; +import type { MACAddress } from './neighbours'; // Route types @@ -90,28 +91,29 @@ export interface FIBEntry { } export interface FIBNexthop { - dst_mac?: string; - src_mac?: string; + dst_mac?: MACAddress; + src_mac?: MACAddress; device?: string; } const routeService = createService('routepb.RouteService'); +const operatorRouteService = createService('operatorpb.RouteService'); export const route = { listConfigs: (options?: CallOptions): Promise => { - return routeService.call('ListConfigs', options); + return operatorRouteService.call('ListConfigs', options); }, showRoutes: (request: ShowRoutesRequest, options?: CallOptions): Promise => { - return routeService.callWithBody('ShowRoutes', request, options); + return operatorRouteService.callWithBody('ShowRoutes', request, options); }, insertRoute: (request: InsertRouteRequest, options?: CallOptions): Promise => { - return routeService.callWithBody('InsertRoute', request, options); + return operatorRouteService.callWithBody('InsertRoute', request, options); }, deleteRoute: (request: DeleteRouteRequest, options?: CallOptions): Promise => { - return routeService.callWithBody('DeleteRoute', request, options); + return operatorRouteService.callWithBody('DeleteRoute', request, options); }, flushRoutes: (request: FlushRoutesRequest, options?: CallOptions): Promise => { - return routeService.callWithBody('FlushRoutes', request, options); + return operatorRouteService.callWithBody('FlushRoutes', request, options); }, showFIB: (request: ShowFIBRequest, options?: CallOptions): Promise => { return routeService.callWithBody('ShowFIB', request, options); diff --git a/web/src/pages/route/FIBTable.tsx b/web/src/pages/route/FIBTable.tsx index ed8c80e88..046dc7821 100644 --- a/web/src/pages/route/FIBTable.tsx +++ b/web/src/pages/route/FIBTable.tsx @@ -30,8 +30,8 @@ const flattenEntries = (entries: FIBEntry[]): FIBRow[] => { rows.push({ id: `${prefix}-${idx}`, prefix, - dst_mac: nh.dst_mac || '', - src_mac: nh.src_mac || '', + dst_mac: nh.dst_mac?.addr || '', + src_mac: nh.src_mac?.addr || '', device: nh.device || '', }); });