From 817c4b198717c355840e2a35fdceb1d8200ccc8a Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:16:46 -0500 Subject: [PATCH 1/3] e2e: add flex-algo topology test and devnet infrastructure for RFC-18 --- e2e/docker/controller/entrypoint.sh | 6 +- e2e/flex_algo_test.go | 231 ++++++++++++++++++ e2e/internal/devnet/cmd/add-device.go | 16 +- e2e/internal/devnet/cmd/devnet.go | 5 +- e2e/internal/devnet/cmd/root.go | 10 +- e2e/internal/devnet/controller.go | 17 ++ .../devnet/smartcontract_geolocation.go | 2 +- 7 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 e2e/flex_algo_test.go diff --git a/e2e/docker/controller/entrypoint.sh b/e2e/docker/controller/entrypoint.sh index 305a342b37..8bf16dd4d9 100755 --- a/e2e/docker/controller/entrypoint.sh +++ b/e2e/docker/controller/entrypoint.sh @@ -26,4 +26,8 @@ if [ -n "${ALLOY_PROMETHEUS_URL:-}" ]; then fi # Start the controller. -doublezero-controller start -listen-addr 0.0.0.0 -listen-port 7000 -program-id ${DZ_SERVICEABILITY_PROGRAM_ID} -solana-rpc-endpoint ${DZ_LEDGER_URL} -device-local-asn 65342 -no-hardware +CONTROLLER_ARGS="-listen-addr 0.0.0.0 -listen-port 7000 -program-id ${DZ_SERVICEABILITY_PROGRAM_ID} -solana-rpc-endpoint ${DZ_LEDGER_URL} -device-local-asn 65342 -no-hardware" +if [ -n "${DZ_FEATURES_CONFIG_PATH:-}" ]; then + CONTROLLER_ARGS="${CONTROLLER_ARGS} -features-config ${DZ_FEATURES_CONFIG_PATH}" +fi +doublezero-controller start ${CONTROLLER_ARGS} diff --git a/e2e/flex_algo_test.go b/e2e/flex_algo_test.go new file mode 100644 index 0000000000..9abcf71ea8 --- /dev/null +++ b/e2e/flex_algo_test.go @@ -0,0 +1,231 @@ +//go:build e2e + +package e2e_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/malbeclabs/doublezero/e2e/internal/devnet" + "github.com/malbeclabs/doublezero/e2e/internal/random" + serviceability "github.com/malbeclabs/doublezero/sdk/serviceability/go" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/require" +) + +// TestE2E_FlexAlgo_BasicWiring verifies the steady-state flex-algo wiring: +// - When features.flex_algo.enabled is true, the controller generates +// "router traffic-engineering", IS-IS flex-algo, and TE admin-group blocks +// - A WAN link tagged with UNICAST-DEFAULT topology gets the admin-group attribute +// - Vpnv4 loopbacks with backfilled node segments get the flex-algo node-segment line +// +// This test uses normal operational CLI commands (not the one-time migrate tool): +// - doublezero link topology create — to create the topology +// - doublezero link topology backfill — to add flex-algo node segments to Loopback255 +// - doublezero link update --link-topology — to tag the WAN link +func TestE2E_FlexAlgo_BasicWiring(t *testing.T) { + t.Parallel() + + deployID := "dz-e2e-" + t.Name() + "-" + random.ShortID() + log := newTestLoggerForTest(t) + + // Write features.yaml enabling flex_algo to a file in the test's temp dir. + // FeaturesConfigPath is a host-side path volume-mounted into the controller container. + deployDir := t.TempDir() + featuresConfigPath := filepath.Join(deployDir, "features.yaml") + err := os.WriteFile(featuresConfigPath, []byte("features:\n flex_algo:\n enabled: true\n"), 0o644) + require.NoError(t, err) + + dn, err := devnet.New(devnet.DevnetSpec{ + DeployID: deployID, + DeployDir: deployDir, + + CYOANetwork: devnet.CYOANetworkSpec{ + CIDRPrefix: subnetCIDRPrefix, + }, + Controller: devnet.ControllerSpec{ + FeaturesConfigPath: featuresConfigPath, + }, + }, log, dockerClient, subnetAllocator) + require.NoError(t, err) + + ctx := t.Context() + + log.Debug("==> Starting devnet") + err = dn.Start(ctx, nil) + require.NoError(t, err) + log.Debug("--> Devnet started") + + // Create a Docker network simulating the WAN link between the two devices. + linkNetwork := devnet.NewMiscNetwork(dn, log, "la2-dz01:ewr1-dz01") + _, err = linkNetwork.CreateIfNotExists(ctx) + require.NoError(t, err) + + // Add both devices in parallel. Each device has: + // - Ethernet2: physical WAN interface (on the shared link network) + // - Loopback255: vpnv4 loopback (required for IS-IS SR flex-algo node segments) + var wg sync.WaitGroup + var device1, device2 *devnet.Device + + wg.Add(1) + go func() { + defer wg.Done() + var addErr error + device1, addErr = dn.AddDevice(ctx, devnet.DeviceSpec{ + Code: "la2-dz01", + Location: "lax", + Exchange: "xlax", + CYOANetworkIPHostID: 8, + CYOANetworkAllocatablePrefix: 29, + AdditionalNetworks: []string{linkNetwork.Name}, + Interfaces: map[string]string{"Ethernet2": "physical"}, + LoopbackInterfaces: map[string]string{"Loopback255": "vpnv4"}, + }) + require.NoError(t, addErr) + log.Debug("--> Device1 added", "id", device1.ID, "code", device1.Spec.Code) + }() + + wg.Add(1) + go func() { + defer wg.Done() + var addErr error + device2, addErr = dn.AddDevice(ctx, devnet.DeviceSpec{ + Code: "ewr1-dz01", + Location: "ewr", + Exchange: "xewr", + CYOANetworkIPHostID: 16, + CYOANetworkAllocatablePrefix: 29, + AdditionalNetworks: []string{linkNetwork.Name}, + Interfaces: map[string]string{"Ethernet2": "physical"}, + LoopbackInterfaces: map[string]string{"Loopback255": "vpnv4"}, + }) + require.NoError(t, addErr) + log.Debug("--> Device2 added", "id", device2.ID, "code", device2.Spec.Code) + }() + + wg.Wait() + + // Create the UNICAST-DEFAULT topology. The smart contract auto-assigns + // admin_group_bit and flex_algo_number. + log.Debug("==> Creating UNICAST-DEFAULT topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "unicast-default", + "--constraint", "include-any", + }) + require.NoError(t, err) + log.Debug("--> UNICAST-DEFAULT topology created") + + // Create a WAN link between the two devices and wait for activation. + log.Debug("==> Creating WAN link onchain") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", + `doublezero link create wan \ + --code "la2-dz01:ewr1-dz01" \ + --contributor co01 \ + --side-a la2-dz01 \ + --side-a-interface Ethernet2 \ + --side-z ewr1-dz01 \ + --side-z-interface Ethernet2 \ + --bandwidth "10 Gbps" \ + --mtu 2048 \ + --delay-ms 20 \ + --jitter-ms 2 \ + --desired-status activated \ + -w`, + }) + require.NoError(t, err) + log.Debug("--> WAN link created") + + // Wait for the link to be activated onchain and capture its pubkey. + log.Debug("==> Waiting for link activation") + serviceabilityClient, err := dn.Ledger.GetServiceabilityClient() + require.NoError(t, err) + var linkPubkey string + require.Eventually(t, func() bool { + data, err := serviceabilityClient.GetProgramData(ctx) + if err != nil { + log.Debug("Failed to get program data", "error", err) + return false + } + for _, link := range data.Links { + if link.Code == "la2-dz01:ewr1-dz01" { + if link.Status == serviceability.LinkStatusActivated { + linkPubkey = base58.Encode(link.PubKey[:]) + return true + } + log.Debug("Link not yet activated", "status", link.Status) + return false + } + } + log.Debug("Link not found yet") + return false + }, 60*time.Second, 2*time.Second, "link was not activated within timeout") + log.Debug("--> Link activated", "pubkey", linkPubkey) + + // Tag the WAN link with the UNICAST-DEFAULT topology. + // In steady-state operation this is done when the link is provisioned. + log.Debug("==> Tagging WAN link with UNICAST-DEFAULT topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "update", + "--pubkey", linkPubkey, + "--link-topology", "unicast-default", + }) + require.NoError(t, err) + log.Debug("--> WAN link tagged") + + // Note: flex-algo node segments are automatically backfilled by the activator when the + // topology is created — no manual backfill command is required. + + // Poll the controller-rendered config for each device and assert that all + // expected flex-algo sections are present. + for _, device := range []*devnet.Device{device1, device2} { + device := device + t.Run(fmt.Sprintf("controller_config_%s", device.Spec.Code), func(t *testing.T) { + log.Debug("==> Checking flex-algo controller config", "device", device.Spec.Code) + + // Expected strings in the EOS config when flex_algo is enabled, + // the link is tagged, and node segments are backfilled. + want := []string{ + // router traffic-engineering block + "router traffic-engineering", + // UNICAST-DEFAULT admin-group alias + "administrative-group alias UNICAST-DEFAULT group", + // IS-IS flex-algo advertisement + "flex-algo UNICAST-DEFAULT level-2 advertised", + // Ethernet2 WAN interface TE tagging + "traffic-engineering administrative-group UNICAST-DEFAULT", + // Loopback255 flex-algo node segment + "flex-algo UNICAST-DEFAULT", + } + + require.Eventually(t, func() bool { + cfg, fetchErr := dn.Controller.GetAgentConfig(ctx, device.ID) + if fetchErr != nil { + log.Debug("Failed to get agent config", "device", device.Spec.Code, "error", fetchErr) + return false + } + config := cfg.Config + for _, s := range want { + if !strings.Contains(config, s) { + log.Debug("Config missing expected section", + "device", device.Spec.Code, + "missing", s, + ) + return false + } + } + return true + }, 60*time.Second, 2*time.Second, + "device %s controller config did not contain flex-algo sections within timeout", + device.Spec.Code, + ) + + log.Debug("--> flex-algo controller config verified", "device", device.Spec.Code) + }) + } +} diff --git a/e2e/internal/devnet/cmd/add-device.go b/e2e/internal/devnet/cmd/add-device.go index e60e043cea..f29a984561 100644 --- a/e2e/internal/devnet/cmd/add-device.go +++ b/e2e/internal/devnet/cmd/add-device.go @@ -8,6 +8,18 @@ import ( "github.com/spf13/cobra" ) +// physicalInterfacesForNetworks returns a map of Ethernet interface names to "physical" +// for each additional network. Ethernet2 maps to the first additional network, +// Ethernet3 to the second, and so on — matching how cEOS assigns eth interfaces +// to Docker networks (eth0=Management0, eth1=Ethernet1/CYOA, eth2+=additional). +func physicalInterfacesForNetworks(count int) map[string]string { + interfaces := make(map[string]string, count) + for i := range count { + interfaces[fmt.Sprintf("Ethernet%d", i+2)] = "physical" + } + return interfaces +} + type AddDeviceCmd struct{} func NewAddDeviceCmd() *AddDeviceCmd { @@ -52,9 +64,7 @@ func (c *AddDeviceCmd) Command() *cobra.Command { ManagementNS: "ns-management", Verbose: true, }, - Interfaces: map[string]string{ - "Ethernet2": "physical", - }, + Interfaces: physicalInterfacesForNetworks(len(additionalNetworks)), LoopbackInterfaces: map[string]string{ "Loopback255": "vpnv4", "Loopback256": "ipv4", diff --git a/e2e/internal/devnet/cmd/devnet.go b/e2e/internal/devnet/cmd/devnet.go index 169229203f..5defbd3e41 100644 --- a/e2e/internal/devnet/cmd/devnet.go +++ b/e2e/internal/devnet/cmd/devnet.go @@ -37,7 +37,7 @@ type LocalDevnet struct { workspaceDir string } -func NewLocalDevnet(log *slog.Logger, deployID string) (*LocalDevnet, error) { +func NewLocalDevnet(log *slog.Logger, deployID string, featuresConfigPath string) (*LocalDevnet, error) { // Set the default logger for testcontainers. logging.SetTestcontainersLogger(log) @@ -90,6 +90,9 @@ func NewLocalDevnet(log *slog.Logger, deployID string) (*LocalDevnet, error) { Verbose: true, Interval: 10 * time.Second, }, + Controller: devnet.ControllerSpec{ + FeaturesConfigPath: featuresConfigPath, + }, }, log, dockerClient, subnetAllocator) if err != nil { return nil, fmt.Errorf("failed to create devnet: %w", err) diff --git a/e2e/internal/devnet/cmd/root.go b/e2e/internal/devnet/cmd/root.go index 9b7cd87ba1..dfaf730446 100644 --- a/e2e/internal/devnet/cmd/root.go +++ b/e2e/internal/devnet/cmd/root.go @@ -42,6 +42,9 @@ func Run() ExitCode { var deployID string rootCmd.PersistentFlags().StringVar(&deployID, "deploy-id", envWithDefault("DZ_DEPLOY_ID", defaultDeployID), "deploy identifier (env: DZ_DEPLOY_ID, default: "+defaultDeployID+")") + var featuresConfigPath string + rootCmd.PersistentFlags().StringVar(&featuresConfigPath, "features-config", "", "path to features.yaml to pass to the controller (optional)") + rootCmd.AddCommand( NewBuildCmd().Command(), NewStartCmd().Command(), @@ -77,7 +80,12 @@ func withDevnet(f func(ctx context.Context, dn *LocalDevnet, cmd *cobra.Command, return fmt.Errorf("failed to get deploy-id flag: %w", err) } - dn, err := NewLocalDevnet(log, deployID) + featuresConfigPath, err := cmd.Root().PersistentFlags().GetString("features-config") + if err != nil { + return fmt.Errorf("failed to get features-config flag: %w", err) + } + + dn, err := NewLocalDevnet(log, deployID, featuresConfigPath) if err != nil { return fmt.Errorf("failed to create devnet: %w", err) } diff --git a/e2e/internal/devnet/controller.go b/e2e/internal/devnet/controller.go index 006bdd9074..96989cddb3 100644 --- a/e2e/internal/devnet/controller.go +++ b/e2e/internal/devnet/controller.go @@ -26,6 +26,10 @@ const ( type ControllerSpec struct { ContainerImage string + // FeaturesConfigPath is the host path to a features.yaml file to mount into the + // controller container and pass via -features-config. If empty, the controller + // starts without a features config (flex_algo.enabled defaults to false). + FeaturesConfigPath string } func (s *ControllerSpec) Validate(cyoaNetworkSpec CYOANetworkSpec) error { @@ -117,6 +121,18 @@ func (c *Controller) Start(ctx context.Context) error { env["ALLOY_PROMETHEUS_URL"] = c.dn.Prometheus.InternalRemoteWriteURL() } + const containerFeaturesConfigPath = "/features.yaml" + var files []testcontainers.ContainerFile + if c.dn.Spec.Controller.FeaturesConfigPath != "" { + env["DZ_FEATURES_CONFIG_PATH"] = containerFeaturesConfigPath + files = append(files, testcontainers.ContainerFile{ + HostFilePath: c.dn.Spec.Controller.FeaturesConfigPath, + ContainerFilePath: containerFeaturesConfigPath, + FileMode: 0o644, + }) + c.log.Debug("==> Controller features config", "hostPath", c.dn.Spec.Controller.FeaturesConfigPath) + } + req := testcontainers.ContainerRequest{ Image: c.dn.Spec.Controller.ContainerImage, Name: c.dockerContainerName(), @@ -126,6 +142,7 @@ func (c *Controller) Start(ctx context.Context) error { ExposedPorts: []string{fmt.Sprintf("%d/tcp", internalControllerPort)}, WaitingFor: wait.ForExposedPort(), Env: env, + Files: files, Networks: []string{c.dn.DefaultNetwork.Name}, NetworkAliases: map[string][]string{ c.dn.DefaultNetwork.Name: {"controller"}, diff --git a/e2e/internal/devnet/smartcontract_geolocation.go b/e2e/internal/devnet/smartcontract_geolocation.go index a93d9cd425..097e4fed21 100644 --- a/e2e/internal/devnet/smartcontract_geolocation.go +++ b/e2e/internal/devnet/smartcontract_geolocation.go @@ -81,7 +81,7 @@ func (dn *Devnet) InitGeolocationProgramConfigIfNotInitialized(ctx context.Conte }, docker.NoPrintOnError()) if err != nil { outputStr := strings.ToLower(string(output)) - if strings.Contains(outputStr, "already") || strings.Contains(outputStr, "already in use") { + if strings.Contains(outputStr, "already") || strings.Contains(outputStr, "uninitialized account") { dn.log.Debug("--> Geolocation program config is already initialized") return false, nil } From 8b936d3cd4b9c6fbcdb84019b93080e84b09dc6a Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 17:18:14 -0500 Subject: [PATCH 2/3] e2e: add RFC-18 topology lifecycle, filter, unicast_drained, tenant, and migrate tests --- e2e/link_onchain_allocation_test.go | 10 + e2e/tenant_topology_test.go | 139 +++++++++++ e2e/topology_filter_test.go | 368 ++++++++++++++++++++++++++++ e2e/topology_lifecycle_test.go | 222 +++++++++++++++++ e2e/topology_migrate_test.go | 191 +++++++++++++++ 5 files changed, 930 insertions(+) create mode 100644 e2e/tenant_topology_test.go create mode 100644 e2e/topology_filter_test.go create mode 100644 e2e/topology_lifecycle_test.go create mode 100644 e2e/topology_migrate_test.go diff --git a/e2e/link_onchain_allocation_test.go b/e2e/link_onchain_allocation_test.go index f0008d13b2..8881c0e1e6 100644 --- a/e2e/link_onchain_allocation_test.go +++ b/e2e/link_onchain_allocation_test.go @@ -55,6 +55,16 @@ func TestE2E_Link_OnchainAllocation(t *testing.T) { err = dn.Start(ctx, nil) require.NoError(t, err) + // Create UNICAST-DEFAULT topology before any link activation — required by the + // onchain program to auto-tag links at activation time. + log.Debug("==> Creating UNICAST-DEFAULT topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "unicast-default", + "--constraint", "include-any", + }) + require.NoError(t, err) + // Create two devices for the link endpoints // Note: Must use globally routable IPs - smart contract rejects private, documentation, and other reserved IPs log.Debug("==> Creating devices for link test") diff --git a/e2e/tenant_topology_test.go b/e2e/tenant_topology_test.go new file mode 100644 index 0000000000..afe30acdcc --- /dev/null +++ b/e2e/tenant_topology_test.go @@ -0,0 +1,139 @@ +//go:build e2e + +package e2e_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/malbeclabs/doublezero/e2e/internal/devnet" + "github.com/malbeclabs/doublezero/e2e/internal/random" + "github.com/stretchr/testify/require" +) + +// TestE2E_FlexAlgo_TenantIncludeTopologies verifies that a foundation operator can +// assign and clear topology filters on a tenant account via +// `doublezero tenant update --include-topologies`. +func TestE2E_FlexAlgo_TenantIncludeTopologies(t *testing.T) { + t.Parallel() + + deployID := "dz-e2e-" + t.Name() + "-" + random.ShortID() + log := newTestLoggerForTest(t) + + currentDir, err := os.Getwd() + require.NoError(t, err) + serviceabilityProgramKeypairPath := filepath.Join(currentDir, "data", "serviceability-program-keypair.json") + + // This test only needs the ledger and manager — no activator, no cEOS. + dn, err := devnet.New(devnet.DevnetSpec{ + DeployID: deployID, + DeployDir: t.TempDir(), + CYOANetwork: devnet.CYOANetworkSpec{ + CIDRPrefix: subnetCIDRPrefix, + }, + Manager: devnet.ManagerSpec{ + ServiceabilityProgramKeypairPath: serviceabilityProgramKeypairPath, + }, + Activator: devnet.ActivatorSpec{Disabled: devnet.BoolPtr(true)}, + }, log, dockerClient, subnetAllocator) + require.NoError(t, err) + + ctx := t.Context() + + err = dn.Start(ctx, nil) + require.NoError(t, err) + + serviceabilityClient, err := dn.Ledger.GetServiceabilityClient() + require.NoError(t, err) + + // Create a topology to use for include_topologies assignment. + // AdminGroupBits is created during global-config set (devnet init), so + // topology create can proceed without any extra setup. + log.Debug("==> Creating tenant-topo topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "tenant-topo", + "--constraint", "include-any", + }) + require.NoError(t, err) + + // Create a tenant. + log.Debug("==> Creating tenant") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "tenant", "create", + "--code", "test-tenant", + }) + require.NoError(t, err) + + // Wait for the tenant to appear onchain. + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, tenant := range data.Tenants { + if tenant.Code == "test-tenant" { + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "tenant did not appear onchain") + + // Confirm include_topologies starts empty. + data, err := serviceabilityClient.GetProgramData(ctx) + require.NoError(t, err) + for _, tenant := range data.Tenants { + if tenant.Code == "test-tenant" { + require.Empty(t, tenant.IncludeTopologies, "include_topologies should start empty") + } + } + + // Assign the topology to the tenant. + log.Debug("==> Setting include_topologies = tenant-topo") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "tenant", "update", + "--pubkey", "test-tenant", + "--include-topologies", "tenant-topo", + }) + require.NoError(t, err) + + // Verify include_topologies is now set (one entry). + require.Eventually(t, func() bool { + d, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, tenant := range d.Tenants { + if tenant.Code == "test-tenant" { + return len(tenant.IncludeTopologies) == 1 + } + } + return false + }, 30*time.Second, 2*time.Second, "include_topologies was not set") + log.Debug("--> include_topologies = [tenant-topo] confirmed") + + // Clear include_topologies by setting it to "default". + log.Debug("==> Clearing include_topologies") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "tenant", "update", + "--pubkey", "test-tenant", + "--include-topologies", "default", + }) + require.NoError(t, err) + + require.Eventually(t, func() bool { + d, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, tenant := range d.Tenants { + if tenant.Code == "test-tenant" { + return len(tenant.IncludeTopologies) == 0 + } + } + return false + }, 30*time.Second, 2*time.Second, "include_topologies did not clear") + log.Debug("--> include_topologies cleared") +} diff --git a/e2e/topology_filter_test.go b/e2e/topology_filter_test.go new file mode 100644 index 0000000000..4d6187d31a --- /dev/null +++ b/e2e/topology_filter_test.go @@ -0,0 +1,368 @@ +//go:build e2e + +package e2e_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/malbeclabs/doublezero/e2e/internal/devnet" + "github.com/malbeclabs/doublezero/e2e/internal/random" + serviceability "github.com/malbeclabs/doublezero/sdk/serviceability/go" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/require" +) + +// linkListEntry is a minimal subset of the JSON shape emitted by +// `doublezero link list --json` / `--json-compact`. +type linkListEntry struct { + Account string `json:"account"` + Code string `json:"code"` + LinkTopologies string `json:"link_topologies"` + UnicastDrained bool `json:"unicast_drained"` +} + +// TestE2E_FlexAlgo_TopologyFilter verifies that `doublezero link list --topology` +// correctly filters links by assigned topology. +func TestE2E_FlexAlgo_TopologyFilter(t *testing.T) { + t.Parallel() + + deployID := "dz-e2e-" + t.Name() + "-" + random.ShortID() + log := newTestLoggerForTest(t) + + currentDir, err := os.Getwd() + require.NoError(t, err) + serviceabilityProgramKeypairPath := filepath.Join(currentDir, "data", "serviceability-program-keypair.json") + + dn, err := devnet.New(devnet.DevnetSpec{ + DeployID: deployID, + DeployDir: t.TempDir(), + CYOANetwork: devnet.CYOANetworkSpec{ + CIDRPrefix: subnetCIDRPrefix, + }, + Manager: devnet.ManagerSpec{ + ServiceabilityProgramKeypairPath: serviceabilityProgramKeypairPath, + }, + }, log, dockerClient, subnetAllocator) + require.NoError(t, err) + + ctx := t.Context() + + err = dn.Start(ctx, nil) + require.NoError(t, err) + + serviceabilityClient, err := dn.Ledger.GetServiceabilityClient() + require.NoError(t, err) + + // Create UNICAST-DEFAULT topology before link activation. + log.Debug("==> Creating UNICAST-DEFAULT topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "unicast-default", + "--constraint", "include-any", + }) + require.NoError(t, err) + + // Create two devices with WAN interfaces. + log.Debug("==> Creating devices and interfaces") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + set -euo pipefail + doublezero device create --code tf-dz01 --contributor co01 --location lax --exchange xlax --public-ip "45.33.101.1" --dz-prefixes "45.33.101.8/29" --desired-status activated 2>&1 + doublezero device create --code tf-dz02 --contributor co01 --location ewr --exchange xewr --public-ip "45.33.101.2" --dz-prefixes "45.33.101.16/29" --desired-status activated 2>&1 + doublezero device create --code tf-dz03 --contributor co01 --location fra --exchange xfra --public-ip "45.33.101.3" --dz-prefixes "45.33.101.24/29" --desired-status activated 2>&1 + doublezero device interface create tf-dz01 "Ethernet2" --bandwidth 10Gbps 2>&1 + doublezero device interface create tf-dz02 "Ethernet2" --bandwidth 10Gbps 2>&1 + doublezero device interface create tf-dz03 "Ethernet2" --bandwidth 10Gbps 2>&1 + `}) + require.NoError(t, err) + + // Wait for all three Ethernet2 interfaces to reach Unlinked. + log.Debug("==> Waiting for interfaces to be unlinked") + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + unlinked := 0 + for _, d := range data.Devices { + for _, iface := range d.Interfaces { + if (d.Code == "tf-dz01" || d.Code == "tf-dz02" || d.Code == "tf-dz03") && + iface.Name == "Ethernet2" && + iface.Status == serviceability.InterfaceStatusUnlinked { + unlinked++ + } + } + } + return unlinked == 3 + }, 60*time.Second, 2*time.Second, "interfaces were not unlinked within timeout") + + // Create two WAN links (link1: dz01↔dz02, link2: dz02↔dz03). + log.Debug("==> Creating WAN links") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + set -euo pipefail + doublezero link create wan \ + --code "tf-dz01:tf-dz02" \ + --contributor co01 \ + --side-a tf-dz01 --side-a-interface Ethernet2 \ + --side-z tf-dz02 --side-z-interface Ethernet2 \ + --bandwidth "10 Gbps" --delay-ms 10 --jitter-ms 1 \ + --desired-status activated -w 2>&1 + doublezero link create wan \ + --code "tf-dz02:tf-dz03" \ + --contributor co01 \ + --side-a tf-dz02 --side-a-interface Ethernet2 \ + --side-z tf-dz03 --side-z-interface Ethernet2 \ + --bandwidth "10 Gbps" --delay-ms 20 --jitter-ms 2 \ + --desired-status activated -w 2>&1 + `}) + require.NoError(t, err) + + // Wait for both links to be activated and capture their pubkeys. + log.Debug("==> Waiting for both links to be activated") + var link1Pubkey, link2Pubkey string + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range data.Links { + if link.Status != serviceability.LinkStatusActivated { + continue + } + switch link.Code { + case "tf-dz01:tf-dz02": + link1Pubkey = base58.Encode(link.PubKey[:]) + case "tf-dz02:tf-dz03": + link2Pubkey = base58.Encode(link.PubKey[:]) + } + } + return link1Pubkey != "" && link2Pubkey != "" + }, 90*time.Second, 2*time.Second, "links were not activated within timeout") + log.Debug("--> Links activated", "link1", link1Pubkey, "link2", link2Pubkey) + + // Create the filter topology. + log.Debug("==> Creating filter-alpha topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "filter-alpha", + "--constraint", "include-any", + }) + require.NoError(t, err) + + // Before tagging: --topology filter-alpha should return 0 links. + out, err := dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "list", + "--topology", "filter-alpha", + "--json-compact", + }) + require.NoError(t, err) + var entries []linkListEntry + require.NoError(t, json.Unmarshal(out, &entries)) + require.Empty(t, entries, "no links should be tagged with filter-alpha yet") + + // Tag link1 with filter-alpha. + log.Debug("==> Tagging link1 with filter-alpha") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "update", + "--pubkey", link1Pubkey, + "--link-topology", "filter-alpha", + }) + require.NoError(t, err) + + // After tagging: --topology filter-alpha returns exactly link1. + require.Eventually(t, func() bool { + out, execErr := dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "list", + "--topology", "filter-alpha", + "--json-compact", + }) + if execErr != nil { + return false + } + var e []linkListEntry + if jsonErr := json.Unmarshal(out, &e); jsonErr != nil { + return false + } + return len(e) == 1 && e[0].Account == link1Pubkey + }, 30*time.Second, 2*time.Second, "filter-alpha filter should return exactly link1") + + // --topology unicast-default returns both links (both auto-tagged at activation). + out, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "list", + "--topology", "unicast-default", + "--json-compact", + }) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(out, &entries)) + require.Equal(t, 2, len(entries), "--topology unicast-default should return both links") + + // --topology default returns 0 links (both links carry at least unicast-default). + out, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "list", + "--topology", "default", + "--json-compact", + }) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(out, &entries)) + require.Empty(t, entries, "--topology default should return no links (all are tagged)") +} + +// TestE2E_FlexAlgo_UnicastDrained verifies that the unicast_drained flag on a link +// can be set and cleared by the contributor, and that the change is reflected in +// the onchain state. +func TestE2E_FlexAlgo_UnicastDrained(t *testing.T) { + t.Parallel() + + deployID := "dz-e2e-" + t.Name() + "-" + random.ShortID() + log := newTestLoggerForTest(t) + + currentDir, err := os.Getwd() + require.NoError(t, err) + serviceabilityProgramKeypairPath := filepath.Join(currentDir, "data", "serviceability-program-keypair.json") + + dn, err := devnet.New(devnet.DevnetSpec{ + DeployID: deployID, + DeployDir: t.TempDir(), + CYOANetwork: devnet.CYOANetworkSpec{ + CIDRPrefix: subnetCIDRPrefix, + }, + Manager: devnet.ManagerSpec{ + ServiceabilityProgramKeypairPath: serviceabilityProgramKeypairPath, + }, + }, log, dockerClient, subnetAllocator) + require.NoError(t, err) + + ctx := t.Context() + + err = dn.Start(ctx, nil) + require.NoError(t, err) + + serviceabilityClient, err := dn.Ledger.GetServiceabilityClient() + require.NoError(t, err) + + // Create UNICAST-DEFAULT before link activation. + log.Debug("==> Creating UNICAST-DEFAULT topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "unicast-default", + "--constraint", "include-any", + }) + require.NoError(t, err) + + // Create devices and interfaces. + log.Debug("==> Creating devices and interfaces") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + set -euo pipefail + doublezero device create --code ud-dz01 --contributor co01 --location lax --exchange xlax --public-ip "45.33.102.1" --dz-prefixes "45.33.102.8/29" --desired-status activated 2>&1 + doublezero device create --code ud-dz02 --contributor co01 --location ewr --exchange xewr --public-ip "45.33.102.2" --dz-prefixes "45.33.102.16/29" --desired-status activated 2>&1 + doublezero device interface create ud-dz01 "Ethernet2" --bandwidth 10Gbps 2>&1 + doublezero device interface create ud-dz02 "Ethernet2" --bandwidth 10Gbps 2>&1 + `}) + require.NoError(t, err) + + log.Debug("==> Waiting for interfaces to be unlinked") + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + unlinked := 0 + for _, d := range data.Devices { + for _, iface := range d.Interfaces { + if (d.Code == "ud-dz01" || d.Code == "ud-dz02") && + iface.Name == "Ethernet2" && + iface.Status == serviceability.InterfaceStatusUnlinked { + unlinked++ + } + } + } + return unlinked == 2 + }, 60*time.Second, 2*time.Second, "interfaces were not unlinked within timeout") + + // Create WAN link. + log.Debug("==> Creating WAN link") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + doublezero link create wan \ + --code "ud-dz01:ud-dz02" \ + --contributor co01 \ + --side-a ud-dz01 --side-a-interface Ethernet2 \ + --side-z ud-dz02 --side-z-interface Ethernet2 \ + --bandwidth "10 Gbps" --delay-ms 10 --jitter-ms 1 \ + --desired-status activated -w + `}) + require.NoError(t, err) + + var linkPubkey string + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range data.Links { + if link.Code == "ud-dz01:ud-dz02" && link.Status == serviceability.LinkStatusActivated { + linkPubkey = base58.Encode(link.PubKey[:]) + return true + } + } + return false + }, 60*time.Second, 2*time.Second, "link was not activated within timeout") + log.Debug("--> Link activated", "pubkey", linkPubkey) + + // Verify unicast_drained starts false. + data, err := serviceabilityClient.GetProgramData(ctx) + require.NoError(t, err) + for _, link := range data.Links { + if link.Code == "ud-dz01:ud-dz02" { + require.Equal(t, uint8(0), link.LinkFlags&0x01, "unicast_drained should start as false") + } + } + + // Set unicast_drained = true. + log.Debug("==> Setting unicast_drained = true") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "update", + "--pubkey", linkPubkey, + "--unicast-drained", "true", + }) + require.NoError(t, err) + + require.Eventually(t, func() bool { + d, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range d.Links { + if link.Code == "ud-dz01:ud-dz02" { + return link.LinkFlags&0x01 != 0 + } + } + return false + }, 30*time.Second, 2*time.Second, "unicast_drained did not become true") + log.Debug("--> unicast_drained = true confirmed") + + // Clear unicast_drained. + log.Debug("==> Clearing unicast_drained") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "update", + "--pubkey", linkPubkey, + "--unicast-drained", "false", + }) + require.NoError(t, err) + + require.Eventually(t, func() bool { + d, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range d.Links { + if link.Code == "ud-dz01:ud-dz02" { + return link.LinkFlags&0x01 == 0 + } + } + return false + }, 30*time.Second, 2*time.Second, "unicast_drained did not clear") + log.Debug("--> unicast_drained cleared") +} diff --git a/e2e/topology_lifecycle_test.go b/e2e/topology_lifecycle_test.go new file mode 100644 index 0000000000..91ee5274b8 --- /dev/null +++ b/e2e/topology_lifecycle_test.go @@ -0,0 +1,222 @@ +//go:build e2e + +package e2e_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/malbeclabs/doublezero/e2e/internal/devnet" + "github.com/malbeclabs/doublezero/e2e/internal/docker" + "github.com/malbeclabs/doublezero/e2e/internal/random" + serviceability "github.com/malbeclabs/doublezero/sdk/serviceability/go" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/require" +) + +// topologyListEntry is the JSON shape emitted by `doublezero link topology list --json`. +type topologyListEntry struct { + Name string `json:"name"` + Links int `json:"links"` +} + +// TestE2E_FlexAlgo_TopologyLifecycle verifies the topology delete/clear guard: +// - Deleting a topology while links reference it must be rejected +// - Clearing a topology from all links (auto-discovery) succeeds +// - Deleting the topology after clearing it succeeds +func TestE2E_FlexAlgo_TopologyLifecycle(t *testing.T) { + t.Parallel() + + deployID := "dz-e2e-" + t.Name() + "-" + random.ShortID() + log := newTestLoggerForTest(t) + + currentDir, err := os.Getwd() + require.NoError(t, err) + serviceabilityProgramKeypairPath := filepath.Join(currentDir, "data", "serviceability-program-keypair.json") + + dn, err := devnet.New(devnet.DevnetSpec{ + DeployID: deployID, + DeployDir: t.TempDir(), + CYOANetwork: devnet.CYOANetworkSpec{ + CIDRPrefix: subnetCIDRPrefix, + }, + Manager: devnet.ManagerSpec{ + ServiceabilityProgramKeypairPath: serviceabilityProgramKeypairPath, + }, + }, log, dockerClient, subnetAllocator) + require.NoError(t, err) + + ctx := t.Context() + + err = dn.Start(ctx, nil) + require.NoError(t, err) + + serviceabilityClient, err := dn.Ledger.GetServiceabilityClient() + require.NoError(t, err) + + // Create UNICAST-DEFAULT topology before any link activation — required by the + // onchain program to auto-tag links at activation time. + log.Debug("==> Creating UNICAST-DEFAULT topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "unicast-default", + "--constraint", "include-any", + }) + require.NoError(t, err) + + // Create two devices and their WAN interfaces. + log.Debug("==> Creating devices and interfaces") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + set -euo pipefail + doublezero device create --code lc-dz01 --contributor co01 --location lax --exchange xlax --public-ip "45.33.100.1" --dz-prefixes "45.33.100.8/29" --desired-status activated 2>&1 + doublezero device create --code lc-dz02 --contributor co01 --location ewr --exchange xewr --public-ip "45.33.100.2" --dz-prefixes "45.33.100.16/29" --desired-status activated 2>&1 + doublezero device interface create lc-dz01 "Ethernet2" --bandwidth 10Gbps 2>&1 + doublezero device interface create lc-dz02 "Ethernet2" --bandwidth 10Gbps 2>&1 + `}) + require.NoError(t, err) + + // Wait for interfaces to reach Unlinked state (activator processes them). + log.Debug("==> Waiting for interfaces to be unlinked") + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + unlinked := 0 + for _, d := range data.Devices { + for _, iface := range d.Interfaces { + if (d.Code == "lc-dz01" || d.Code == "lc-dz02") && + iface.Name == "Ethernet2" && + iface.Status == serviceability.InterfaceStatusUnlinked { + unlinked++ + } + } + } + return unlinked == 2 + }, 60*time.Second, 2*time.Second, "interfaces were not unlinked within timeout") + + // Create a WAN link and wait for activation. + log.Debug("==> Creating WAN link") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + doublezero link create wan \ + --code "lc-dz01:lc-dz02" \ + --contributor co01 \ + --side-a lc-dz01 \ + --side-a-interface Ethernet2 \ + --side-z lc-dz02 \ + --side-z-interface Ethernet2 \ + --bandwidth "10 Gbps" \ + --delay-ms 10 \ + --jitter-ms 1 \ + --desired-status activated \ + -w + `}) + require.NoError(t, err) + + var linkPubkey string + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range data.Links { + if link.Code == "lc-dz01:lc-dz02" && link.Status == serviceability.LinkStatusActivated { + linkPubkey = base58.Encode(link.PubKey[:]) + return true + } + } + return false + }, 60*time.Second, 2*time.Second, "link was not activated within timeout") + log.Debug("--> Link activated", "pubkey", linkPubkey) + + // Create a second topology to test the lifecycle against. + log.Debug("==> Creating test topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "lifecycle-test", + "--constraint", "include-any", + }) + require.NoError(t, err) + + // Tag the link with the test topology. + log.Debug("==> Tagging link with lifecycle-test topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "update", + "--pubkey", linkPubkey, + "--link-topology", "lifecycle-test", + }) + require.NoError(t, err) + + // Verify the link now references the topology. + require.Eventually(t, func() bool { + out, execErr := dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "list", "--json", + }) + if execErr != nil { + return false + } + var entries []topologyListEntry + if jsonErr := json.Unmarshal(out, &entries); jsonErr != nil { + return false + } + for _, e := range entries { + if e.Name == "lifecycle-test" && e.Links == 1 { + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "lifecycle-test topology did not show 1 link") + + // Attempt to delete the topology while the link still references it — must fail. + log.Debug("==> Attempting topology delete with active reference (expect failure)") + out, err := dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "delete", + "--name", "lifecycle-test", + }, docker.NoPrintOnError()) + require.Error(t, err, "delete should fail while link references topology") + require.Contains(t, string(out), "still reference it") + log.Debug("--> Delete correctly rejected") + + // Clear the topology from all links (auto-discovers referenced links). + log.Debug("==> Clearing topology from all links") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "clear", + "--name", "lifecycle-test", + }) + require.NoError(t, err) + + // Now delete the topology — must succeed. + log.Debug("==> Deleting topology after clear") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "delete", + "--name", "lifecycle-test", + }) + require.NoError(t, err) + log.Debug("--> Topology deleted") + + // Verify topology is no longer present in the list. + out, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "list", "--json", + }) + require.NoError(t, err) + var finalEntries []topologyListEntry + require.NoError(t, json.Unmarshal(out, &finalEntries)) + for _, e := range finalEntries { + require.NotEqual(t, "lifecycle-test", e.Name, "deleted topology still appears in list") + } + + // Verify the link no longer carries the lifecycle-test topology (only UNICAST-DEFAULT). + data, err := serviceabilityClient.GetProgramData(ctx) + require.NoError(t, err) + for _, link := range data.Links { + if link.Code == "lc-dz01:lc-dz02" { + require.Equal(t, 1, len(link.LinkTopologies), + "link should have exactly one topology (unicast-default) after clearing lifecycle-test") + return + } + } + t.Fatal("link lc-dz01:lc-dz02 not found in program data") +} diff --git a/e2e/topology_migrate_test.go b/e2e/topology_migrate_test.go new file mode 100644 index 0000000000..73a641c450 --- /dev/null +++ b/e2e/topology_migrate_test.go @@ -0,0 +1,191 @@ +//go:build e2e + +package e2e_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/malbeclabs/doublezero/e2e/internal/devnet" + "github.com/malbeclabs/doublezero/e2e/internal/random" + serviceability "github.com/malbeclabs/doublezero/sdk/serviceability/go" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/require" +) + +// TestE2E_FlexAlgo_Migrate verifies the `doublezero-admin migrate flex-algo` command: +// - Dry-run correctly identifies a link whose topologies have been cleared +// - Live run re-tags the link with UNICAST-DEFAULT +// +// Setup: activate a link (auto-tagged with UNICAST-DEFAULT), clear all topologies +// from it to simulate a pre-RFC-18 link, then run the migrate command. +func TestE2E_FlexAlgo_Migrate(t *testing.T) { + t.Parallel() + + deployID := "dz-e2e-" + t.Name() + "-" + random.ShortID() + log := newTestLoggerForTest(t) + + currentDir, err := os.Getwd() + require.NoError(t, err) + serviceabilityProgramKeypairPath := filepath.Join(currentDir, "data", "serviceability-program-keypair.json") + + dn, err := devnet.New(devnet.DevnetSpec{ + DeployID: deployID, + DeployDir: t.TempDir(), + CYOANetwork: devnet.CYOANetworkSpec{ + CIDRPrefix: subnetCIDRPrefix, + }, + Manager: devnet.ManagerSpec{ + ServiceabilityProgramKeypairPath: serviceabilityProgramKeypairPath, + }, + }, log, dockerClient, subnetAllocator) + require.NoError(t, err) + + ctx := t.Context() + + err = dn.Start(ctx, nil) + require.NoError(t, err) + + serviceabilityClient, err := dn.Ledger.GetServiceabilityClient() + require.NoError(t, err) + + // Create UNICAST-DEFAULT topology before link activation. + log.Debug("==> Creating UNICAST-DEFAULT topology") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "topology", "create", + "--name", "unicast-default", + "--constraint", "include-any", + }) + require.NoError(t, err) + + // Create two devices with WAN interfaces. + log.Debug("==> Creating devices and interfaces") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + set -euo pipefail + doublezero device create --code mg-dz01 --contributor co01 --location lax --exchange xlax --public-ip "45.33.103.1" --dz-prefixes "45.33.103.8/29" --desired-status activated 2>&1 + doublezero device create --code mg-dz02 --contributor co01 --location ewr --exchange xewr --public-ip "45.33.103.2" --dz-prefixes "45.33.103.16/29" --desired-status activated 2>&1 + doublezero device interface create mg-dz01 "Ethernet2" --bandwidth 10Gbps 2>&1 + doublezero device interface create mg-dz02 "Ethernet2" --bandwidth 10Gbps 2>&1 + `}) + require.NoError(t, err) + + log.Debug("==> Waiting for interfaces to be unlinked") + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + unlinked := 0 + for _, d := range data.Devices { + for _, iface := range d.Interfaces { + if (d.Code == "mg-dz01" || d.Code == "mg-dz02") && + iface.Name == "Ethernet2" && + iface.Status == serviceability.InterfaceStatusUnlinked { + unlinked++ + } + } + } + return unlinked == 2 + }, 60*time.Second, 2*time.Second, "interfaces were not unlinked within timeout") + + // Create WAN link and wait for activation. + log.Debug("==> Creating WAN link") + _, err = dn.Manager.Exec(ctx, []string{"bash", "-c", ` + doublezero link create wan \ + --code "mg-dz01:mg-dz02" \ + --contributor co01 \ + --side-a mg-dz01 --side-a-interface Ethernet2 \ + --side-z mg-dz02 --side-z-interface Ethernet2 \ + --bandwidth "10 Gbps" --delay-ms 10 --jitter-ms 1 \ + --desired-status activated -w + `}) + require.NoError(t, err) + + var linkPubkey string + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range data.Links { + if link.Code == "mg-dz01:mg-dz02" && link.Status == serviceability.LinkStatusActivated { + linkPubkey = base58.Encode(link.PubKey[:]) + return true + } + } + return false + }, 60*time.Second, 2*time.Second, "link was not activated within timeout") + log.Debug("--> Link activated and auto-tagged with UNICAST-DEFAULT", "pubkey", linkPubkey) + + // Clear all topologies from the link to simulate a pre-RFC-18 state. + log.Debug("==> Clearing all topologies from link (simulating pre-RFC-18)") + _, err = dn.Manager.Exec(ctx, []string{ + "doublezero", "link", "update", + "--pubkey", linkPubkey, + "--link-topology", "default", + }) + require.NoError(t, err) + + // Verify the link now has no topologies. + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range data.Links { + if link.Code == "mg-dz01:mg-dz02" { + return len(link.LinkTopologies) == 0 + } + } + return false + }, 30*time.Second, 2*time.Second, "link topologies were not cleared") + log.Debug("--> Link topologies cleared (simulates pre-RFC-18 state)") + + // Run migrate in dry-run mode and verify it identifies the untagged link. + log.Debug("==> Running migrate flex-algo --dry-run") + out, err := dn.Manager.Exec(ctx, []string{ + "doublezero-admin", "migrate", "flex-algo", "--dry-run", + }) + require.NoError(t, err) + output := string(out) + require.True(t, + strings.Contains(output, "1 link(s) would be tagged"), + "dry-run should report 1 link to be tagged, got: %s", output, + ) + require.True(t, + strings.Contains(output, "DRY RUN"), + "dry-run output should include DRY RUN marker, got: %s", output, + ) + log.Debug("--> Dry-run correctly identified 1 untagged link") + + // Run migrate live. + log.Debug("==> Running migrate flex-algo (live)") + out, err = dn.Manager.Exec(ctx, []string{ + "doublezero-admin", "migrate", "flex-algo", + }) + require.NoError(t, err) + output = string(out) + require.True(t, + strings.Contains(output, "1 link(s) tagged"), + "live migrate should report 1 link tagged, got: %s", output, + ) + log.Debug("--> Live migrate completed") + + // Verify the link has UNICAST-DEFAULT again. + require.Eventually(t, func() bool { + data, fetchErr := serviceabilityClient.GetProgramData(ctx) + if fetchErr != nil { + return false + } + for _, link := range data.Links { + if link.Code == "mg-dz01:mg-dz02" { + return len(link.LinkTopologies) == 1 + } + } + return false + }, 30*time.Second, 2*time.Second, "link was not re-tagged with UNICAST-DEFAULT after migrate") + log.Debug("--> Link re-tagged with UNICAST-DEFAULT confirmed") +} From 5e945fef28a25b41c5c70bb216c0a3ba7c144165 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 23:06:23 -0500 Subject: [PATCH 3/3] e2e: add RFC-18 flex-algo CHANGELOG entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa6f67197..9e1e14ecee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ All notable changes to this project will be documented in this file. - When flex-algo is enabled: generate IS-IS TE admin-group assignments on link interfaces, flex-algo topology definitions, and `system-colored-tunnel-rib` as the BGP next-hop resolution source - Stamp BGP color extcommunity on user tunnel route-maps for tenants with `include_topologies` set - Add `node-segment ipv4 index` lines to Vpnv4 loopback config for each flex-algo, sourced from segment routing IDs backfilled by the activator +- E2E Tests + - Add flex-algo e2e tests: topology lifecycle (safe-delete enforcement), topology filter (`--topology` CLI validation), unicast-drained flag transitions, tenant `include_topologies` changes, migration dry-run and live run, and controller config generation against live cEOS - Client - Add `doublezero_connection_info` Prometheus metric exposing connection metadata (user_type, network, current_device, metro, tunnel_name, tunnel_src, tunnel_dst) ([#3201](https://github.com/malbeclabs/doublezero/pull/3201)) - Add `doublezero_connection_rtt_nanoseconds` and `doublezero_connection_loss_percentage` Prometheus metrics reporting RTT and packet loss to the current connected device